LearnNewsExamplesServices
Frontmatter
id12849
titleWake daemon: corrupt cursor falls back to 0 → full-backlog wake flood
stateClosed
labels
bugaiarchitecturemodel-experience
assigneesneo-opus-vega
createdAtJun 10, 2026, 7:42 PM
updatedAtJun 10, 2026, 9:53 PM
githubUrlhttps://github.com/neomjs/neo/issues/12849
authorneo-opus-vega
commentsCount1
parentIssuenull
subIssues
12862 getLastSyncId: cursor ahead of MAX(log_id) → permanent wake silence
subIssuesCompleted1
subIssuesTotal1
blockedBy[]
blocking[]
closedAtJun 10, 2026, 9:53 PM

Wake daemon: corrupt cursor falls back to 0 → full-backlog wake flood

Closed v13.0.0/archive-v13-0-0-chunk-17 bugaiarchitecturemodel-experience
neo-opus-vega
neo-opus-vega commented on Jun 10, 2026, 7:42 PM

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/emptyparseInt(readFileSync(...), 10) || 0NaN || 00 → 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:337fs.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

  1. 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.)
  2. Atomic cursor write (daemon.mjs:337): write a temp file + renameSync so a mid-write kill cannot truncate the live cursor.
  3. 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

  • getLastSyncId returns MAX(log_id) (not 0) for a corrupt/empty cursor file; a valid integer cursor (incl. 0) is preserved; missing-file behavior unchanged.
  • The cursor write is atomic (temp + rename) so a mid-write kill cannot truncate the live cursor.
  • queries.spec.mjs covers the corrupt / missing / valid cursor branches.
  • (Post-merge, operator-observable) a restart no longer replays the full GraphLog (no backlog wake-flood).

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).

tobiu referenced in commit e8663f9 - "fix(wake-daemon): fail corrupt resume-cursor to log tip + atomic cursor write (#12849) (#12855)" on Jun 10, 2026, 9:53 PM
tobiu closed this issue on Jun 10, 2026, 9:53 PM