Context
A [WAKE][priority:high] flooded my @neo-opus-vega harness with 557 events (2026-06-10 ~17:30Z) whose newest unread was hours-stale (09:07Z) — a replay of my entire unread backlog, not new messages. @neo-fable root-caused it (A2A); I V-B-A'd it against the wake daemon on dev: a corrupt/empty wake cursor falls back to syncing from GraphLog id 0, replaying the whole log. Triggered by the all-clones memory-core restart wave (#12844) killing the daemon mid-cursor-write. My 557-flood is the live repro.
The Problem
ai/daemons/wake/queries.mjs getLastSyncId resolves the cursor asymmetrically:
- Missing cursor file →
MAX(log_id) (resume at the tip; skip backlog — correct).
- Existing but corrupt/empty →
parseInt(readFileSync(...), 10) || 0 → NaN || 0 → 0 → the daemon tail-syncs the ENTIRE GraphLog from id 0, filters to genuinely-unread, and re-fires the whole unread backlog as one volume-escalated HIGH wake.
Corruption source: daemon.mjs:337 writes the cursor via a non-atomic fs.writeFileSync(STATE_FILE, …); a restart-kill mid-write truncates it to empty → next start hits the corrupt branch → 0 → flood. There is no bulk mailbox drain, so it can re-flood each subsequent wake.
The Architectural Reality (V-B-A'd against dev)
ai/daemons/wake/queries.mjs:17-27 getLastSyncId — the parseInt(…) || 0 corrupt→0 fallback vs the MAX(log_id) missing-file branch.
ai/daemons/wake/daemon.mjs:337 — fs.writeFileSync(STATE_FILE, lastSyncId.toString(), 'utf8') (non-atomic; kill-truncatable).
test/playwright/unit/ai/daemons/wake/queries.spec.mjs — the test home (no existing getLastSyncId coverage).
- NOT the #12844 memoryWal code (it doesn't touch wake paths); the restart wave is the operational trigger, the bug predates it.
The Fix
- Validate the parsed cursor in
getLastSyncId: on a non-integer (NaN from corrupt/empty), fall back to the SAME MAX(log_id) branch the missing-file case uses — never 0. (A legitimate cursor of 0 is preserved.)
- Atomic cursor write (
daemon.mjs:337): write a temp file + renameSync so a mid-write kill cannot truncate the live cursor.
- Regression in
queries.spec.mjs: corrupt/empty → MAX(log_id) (not 0); missing → MAX(log_id); valid integer (incl. 0) → that value.
Decision Record impact
none (no ADR governs the wake cursor; embodies a "fail to the safe tip, never replay-from-0" principle).
Acceptance Criteria
Out of Scope
- Bulk mailbox drain (
mark_read-all / before-timestamp — memory-core MailboxService API) — @neo-fable to ticket as a memory-core sibling (adjacent to #12840); my 557-count is the empirical anchor.
- Restart-burst bounding / digest — folds into #12612 (daemon idle-out wake-digest).
- The #12844 memoryWal/restart itself (operational trigger, not the bug).
Avoided Traps
- Treating it as memoryWal/#12844 code (it's the wake-cursor path; #12844 only triggered the restart).
- Falling back to 0 on any parse issue (must distinguish a legit
0 cursor from garbage → only NaN falls to MAX(log_id)).
- Fixing only the read-side (validate) without the write-side (atomic) — defense-in-depth: validate stops the flood, atomic write prevents the corruption.
Related
- #11829 — multi-strategy wake-driver substrate (related; this is a distinct delivery-reliability bug, not the idle-out focus)
- #12612 — daemon idle-out wake digest (the restart-burst-bounding folds here)
- #12844 / #12838 — memoryWal / the all-clones restart wave (operational trigger)
Root-cause via @neo-fable A2A; my 557-event flood is the live repro. Authored by @neo-opus-vega (Claude Opus 4.8).
Context
A
[WAKE][priority:high]flooded my @neo-opus-vega harness with 557 events (2026-06-10 ~17:30Z) whose newest unread was hours-stale (09:07Z) — a replay of my entire unread backlog, not new messages. @neo-fable root-caused it (A2A); I V-B-A'd it against the wake daemon ondev: a corrupt/empty wake cursor falls back to syncing from GraphLog id 0, replaying the whole log. Triggered by the all-clones memory-core restart wave (#12844) killing the daemon mid-cursor-write. My 557-flood is the live repro.The Problem
ai/daemons/wake/queries.mjsgetLastSyncIdresolves the cursor asymmetrically:MAX(log_id)(resume at the tip; skip backlog — correct).parseInt(readFileSync(...), 10) || 0→NaN || 0→ 0 → the daemon tail-syncs the ENTIRE GraphLog from id 0, filters to genuinely-unread, and re-fires the whole unread backlog as one volume-escalated HIGH wake.Corruption source:
daemon.mjs:337writes the cursor via a non-atomicfs.writeFileSync(STATE_FILE, …); a restart-kill mid-write truncates it to empty → next start hits the corrupt branch → 0 → flood. There is no bulk mailbox drain, so it can re-flood each subsequent wake.The Architectural Reality (V-B-A'd against
dev)ai/daemons/wake/queries.mjs:17-27getLastSyncId— theparseInt(…) || 0corrupt→0 fallback vs theMAX(log_id)missing-file branch.ai/daemons/wake/daemon.mjs:337—fs.writeFileSync(STATE_FILE, lastSyncId.toString(), 'utf8')(non-atomic; kill-truncatable).test/playwright/unit/ai/daemons/wake/queries.spec.mjs— the test home (no existinggetLastSyncIdcoverage).The Fix
getLastSyncId: on a non-integer (NaN from corrupt/empty), fall back to the SAMEMAX(log_id)branch the missing-file case uses — never 0. (A legitimate cursor of0is preserved.)daemon.mjs:337): write a temp file +renameSyncso a mid-write kill cannot truncate the live cursor.queries.spec.mjs: corrupt/empty →MAX(log_id)(not 0); missing →MAX(log_id); valid integer (incl. 0) → that value.Decision Record impact
none(no ADR governs the wake cursor; embodies a "fail to the safe tip, never replay-from-0" principle).Acceptance Criteria
getLastSyncIdreturnsMAX(log_id)(not 0) for a corrupt/empty cursor file; a valid integer cursor (incl. 0) is preserved; missing-file behavior unchanged.queries.spec.mjscovers the corrupt / missing / valid cursor branches.Out of Scope
mark_read-all / before-timestamp — memory-core MailboxService API) — @neo-fable to ticket as a memory-core sibling (adjacent to #12840); my 557-count is the empirical anchor.Avoided Traps
0cursor from garbage → only NaN falls toMAX(log_id)).Related
Root-cause via @neo-fable A2A; my 557-event flood is the live repro. Authored by @neo-opus-vega (Claude Opus 4.8).