From d4fbfaf4423d46c0ffb11212c8173a05aac7ee27 Mon Sep 17 00:00:00 2001 From: claudemm Date: Sun, 13 Sep 2026 10:48:54 +0300 Subject: [PATCH] fix: keep seen-id watermarks out of /tmp, where a reboot wipes them Every seen-file default lived in /tmp: poller, dm_poller, automation, comments, discord. macOS clears /tmp on boot, so each reboot silently reset every watermark and the next poll replayed room history as new. That happened for real on 2026-09-13. Restarting iak-mcp-daemon on a box whose /tmp had been cleared dumped 48 messages from seven rooms into one agent's notification file, the oldest from March. Noise is the mild failure. The real one is that the replay included live-sounding instructions -- "get them trading before they close today" (6 Mar) and a request to delete messages -- which an agent reading them as new would have acted on. - defaults move to $XDG_STATE_HOME/iak or ~/.local/state/iak - stateFile() adopts an existing /tmp watermark once, so upgrading does not itself cause the replay this prevents - both writers mkdir -p first; room-poller's own hardcoded default moved too - config test asserts the property (not in /tmp, right filename) rather than a literal path, since the state dir varies by home and XDG_STATE_HOME Verified: 276/276 tests pass; a default config resolves to the state dir and adopted 1053 real ids from the legacy file. Co-Authored-By: Claude Opus 5 --- src/common/seen-ids.mjs | 5 ++++- src/config.mjs | 45 +++++++++++++++++++++++++++++++++++------ src/room-poller.mjs | 12 +++++++++-- test/config.test.mjs | 10 +++++++-- 4 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/common/seen-ids.mjs b/src/common/seen-ids.mjs index 534640d..56088b5 100644 --- a/src/common/seen-ids.mjs +++ b/src/common/seen-ids.mjs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only -import { readFileSync, openSync, writeSync, fsyncSync, closeSync, renameSync } from 'node:fs'; +import { readFileSync, openSync, writeSync, fsyncSync, closeSync, renameSync, mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; /** * Shared seen-ID management for all platform pollers. @@ -22,6 +23,8 @@ export function loadSeenIds(path, maxIds = 2000) { // M5 hermes poller did on 2026-09-01 (issue #90, item 1). export function saveSeenIds(path, ids, maxIds = 2000) { const arr = [...ids].slice(-maxIds); + // The watermark now lives under the user's state dir, which may not exist yet. + mkdirSync(dirname(path), { recursive: true }); const tmp = `${path}.tmp-${process.pid}`; const fd = openSync(tmp, 'w'); try { diff --git a/src/config.mjs b/src/config.mjs index 64df2a8..ce53951 100644 --- a/src/config.mjs +++ b/src/config.mjs @@ -1,9 +1,42 @@ // SPDX-License-Identifier: AGPL-3.0-only -import { readFileSync, existsSync } from 'node:fs'; +import { readFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { homedir } from 'node:os'; + +// Seen-id watermarks record what a poller has ALREADY delivered. They were +// defaulted into /tmp, which macOS clears on boot — so every reboot silently +// reset every watermark and the next poll replayed history as if it were new. +// On 2026-09-13 that dumped 48 messages from seven rooms back to March into one +// agent's inbox, including "get them trading before they close today" from 6 +// March. Noise is the mild failure; an agent acting on a six-month-old +// instruction is the real one. +// +// State that must outlive a reboot belongs under the user's own directory. +// Honours XDG_STATE_HOME where set, falls back to ~/.local/state. +const STATE_DIR = process.env.XDG_STATE_HOME + ? resolve(process.env.XDG_STATE_HOME, 'iak') + : resolve(homedir(), '.local', 'state', 'iak'); + +const stateFile = (name) => { + const target = resolve(STATE_DIR, name); + // One-time adoption: a box upgrading from the /tmp defaults still holds a + // valid watermark there. Copying it over means the upgrade itself does not + // cause the single replay this change exists to prevent. + try { + if (!existsSync(target)) { + const legacy = `/tmp/iak-${name === 'seen-ids.txt' ? 'seen-ids' : name.replace(/\.txt$/, '')}.txt`; + mkdirSync(STATE_DIR, { recursive: true }); + if (existsSync(legacy)) copyFileSync(legacy, target); + } + } catch { + // A read-only or unwritable home is not a reason to fail config load; + // the poller will fall back to an empty watermark and simply be noisy once. + } + return target; +}; + const DEFAULT_CONFIG = { listen: { host: '127.0.0.1', port: 8787 }, queue: { path: './ide-agent-queue.jsonl' }, @@ -13,7 +46,7 @@ const DEFAULT_CONFIG = { rooms: '', handle: '', interval_sec: 30, - seen_file: '/tmp/iak-seen-ids.txt', + seen_file: stateFile('seen-ids.txt'), api_key: '', nudge_mode: 'tmux', nudge_command: '', @@ -26,7 +59,7 @@ const DEFAULT_CONFIG = { enabled: false, handle: '', interval_sec: 30, - seen_file: '/tmp/iak-dm-seen-ids.txt', + seen_file: stateFile('dm-seen-ids.txt'), api_key: '', human_only: false, limit: 100 @@ -36,7 +69,7 @@ const DEFAULT_CONFIG = { rate_limit: { message_interval_sec: 30 }, automation: { rules: [], - seen_file: '/tmp/iak-automation-seen.txt', + seen_file: stateFile('automation-seen.txt'), interval_sec: 30, cooldown_sec: 5, first_match_only: true @@ -45,12 +78,12 @@ const DEFAULT_CONFIG = { moltbook: { posts: [], base_url: 'https://www.moltbook.com' }, github: { repos: [], token: '' }, interval_sec: 120, - seen_file: '/tmp/iak-comment-seen.txt' + seen_file: stateFile('comment-seen.txt') }, discord: { channels: [], interval_sec: 30, - seen_file: '/tmp/iak-discord-seen.txt', + seen_file: stateFile('discord-seen.txt'), self_id: '', skip_bots: false }, diff --git a/src/room-poller.mjs b/src/room-poller.mjs index 94bf95b..05839c2 100644 --- a/src/room-poller.mjs +++ b/src/room-poller.mjs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-only -import { readFileSync, writeFileSync, appendFileSync } from 'node:fs'; +import { readFileSync, writeFileSync, appendFileSync, mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { homedir } from 'node:os'; import { randomUUID } from 'node:crypto'; import { execSync } from 'node:child_process'; import { nudgeTmux, nudgeCommand } from './utils.mjs'; @@ -18,7 +20,12 @@ import { resolveSelfHandle, isSelfSender } from './common/handles.mjs'; * The IDE agent calls `rooms check` to read and clear the notification file. */ -const SEEN_FILE_DEFAULT = '/tmp/iak-seen-ids.txt'; +// Durable by default: /tmp is cleared on boot, which silently resets the +// watermark and replays room history as new. See src/config.mjs. +const SEEN_FILE_DEFAULT = resolve( + process.env.XDG_STATE_HOME ? resolve(process.env.XDG_STATE_HOME, 'iak') : resolve(homedir(), '.local', 'state', 'iak'), + 'seen-ids.txt' +); const NOTIFY_FILE_DEFAULT = '/tmp/iak-new-messages.txt'; function loadSeenIds(path) { @@ -31,6 +38,7 @@ function loadSeenIds(path) { function saveSeenIds(path, ids) { const arr = [...ids].slice(-1000); + mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, arr.join('\n') + '\n'); } diff --git a/test/config.test.mjs b/test/config.test.mjs index f8d923c..6466a30 100644 --- a/test/config.test.mjs +++ b/test/config.test.mjs @@ -76,10 +76,16 @@ describe('config', () => { assert.deepEqual(cfg.poller.rooms, ['thinkoff-development']); assert.equal(cfg.poller.handle, '@CodexMB'); assert.equal(cfg.poller.interval_sec, 30); - assert.equal(cfg.poller.seen_file, '/tmp/iak-seen-ids.txt'); + // Watermarks must OUTLIVE a reboot: /tmp is cleared on boot, which silently + // resets them and replays room history as new (2026-09-13: 48 messages back + // to March, including actionable ones). Assert the property, not a literal + // path, since the state dir varies by home and XDG_STATE_HOME. + assert.ok(!cfg.poller.seen_file.startsWith('/tmp/'), 'seen_file must not live in /tmp'); + assert.ok(cfg.poller.seen_file.endsWith('seen-ids.txt'), cfg.poller.seen_file); assert.equal(cfg.dm_poller.enabled, true); assert.equal(cfg.dm_poller.interval_sec, 30); - assert.equal(cfg.dm_poller.seen_file, '/tmp/iak-dm-seen-ids.txt'); + assert.ok(!cfg.dm_poller.seen_file.startsWith('/tmp/'), 'dm seen_file must not live in /tmp'); + assert.ok(cfg.dm_poller.seen_file.endsWith('dm-seen-ids.txt'), cfg.dm_poller.seen_file); assert.equal(cfg.dm_poller.limit, 100); assert.equal(cfg.background.enabled, true); assert.equal(cfg.background.recent_window_sec, 7200);