LearnNewsExamplesServices
Frontmatter
id15704
titleWake daemon promotes replayed old messages as live wakes
stateClosed
labels
bugairegressionmodel-experience
assigneesneo-gpt
createdAtJul 22, 2026, 1:41 PM
updatedAtJul 22, 2026, 10:46 PM
githubUrlhttps://github.com/neomjs/neo/issues/15704
authorneo-gpt
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtJul 22, 2026, 9:14 PM

Wake daemon promotes replayed old messages as live wakes

Closed Backlog/active-chunk-8 bugairegressionmodel-experience
neo-gpt
neo-gpt commented on Jul 22, 2026, 1:41 PM

Context

On 2026-07-22 the wake daemon delivered MESSAGE:da405982-6c2d-4bd1-900d-958fc9cd5783 to @neo-gpt as “1 new messages.” The MESSAGE was authored by @neo-fable-clio at 2026-07-18T02:53:13.791Z, remained unread, and arrived as a live high-priority wake at 2026-07-22T11:25:59.661Z—more than four days later. The sender was no longer online and the referenced ticket #15401 was already closed.

Live daemon evidence directly before delivery:

  • 11:21:35Z: heavy GraphLog delta, 2,363 rows
  • 11:23:19Z: heavy GraphLog delta, 1,199 rows
  • 11:25:59Z: wake submitted with exact stale message id
  • 11:26:01Z: nonce-correlated turn-start proof names exact id

This is a distinct successor class to #12479 and #12849: the row was unread and had never been woken or claimed before this replay.

The Problem

Current wake eligibility answers “unread?” and “already woken?” but never “still timely enough to interrupt?”

The replayed MESSAGE had canonical sentAt, readAt:null, and a freshly encountered GraphLog SENT_TO row. That passes the existing defenses:

  • read-state reconciliation keeps it because it is genuinely unread;
  • numeric watermark sees a new log position;
  • stable message-id history has no prior claim;
  • heavy-delta defer waits for state to settle, then still delivers it.

The mailbox should retain old unread material for explicit drain and recovery. A wake is different: it asserts live interruption relevance. Replaying a days-old mailbox fact as “new” converts historical state into false urgency.

The Architectural Reality

  • MailboxService.addMessage() server-stamps canonical sentAt with new Date().toISOString().
  • heartbeatPulseEvaluator.mjs#buildSentToMeInner() owns the canonical shared SENT_TO_ME payload but currently omits sentAt.
  • ai/daemons/wake/daemon.mjs maps that payload into coalesced message events, then filters on read-state, GraphLog watermark, and stable message-id claims.
  • The retry path rechecks read-state only.
  • Existing ai/daemons/wake/coalescePolicy.mjs is the pure policy seam for bounded wake latency. Discussion #14145 provides adjacent, non-governing precedent that wake artifacts older than one hour are stale noise.
  • Structure-map sweep confirms no new directory or file is needed: the owning siblings already exist in ai/services/memory-core/ and ai/daemons/wake/.

The Fix

  1. Carry canonical sentAt through the shared SENT_TO_ME payload and daemon message event.
  2. Add a pure wake-message age predicate in the existing coalescing policy seam. A canonical message wake is eligible only while 0 <= now - sentAt <= 1 hour; missing or invalid timestamps fail closed for wake delivery.
  3. Apply the predicate immediately before initial digest delivery and again on every retry. Expired events remain consumed for watermark and stable-id purposes so replay cannot churn, but they never affect count, preview, or priority.
  4. Preserve mailbox state: suppression must not mark, archive, or delete the message.
  5. Emit bounded observability when stale events are suppressed (count and oldest age, no message body).

No new operator knob: one hour is a mechanism safety ceiling, not a per-route policy election. The existing mailbox remains the recovery surface for anything older.

Contract Ledger

Target Surface Source of Authority Proposed Behavior Fallback / Edge Case Docs Evidence
Shared SENT_TO_ME inner payload heartbeatPulseEvaluator.mjs#buildSentToMeInner plus canonical MailboxService timestamp Additive sentAt field carried unchanged Missing or invalid timestamp is not wake-eligible; mailbox row remains JSDoc shared matcher unit witness
Initial daemon digest eligibility flushSubscription() Drop message events older than one hour before count, preview, and priority Fresh messages, tasks, permissions, and heartbeats in same digest still deliver JSDoc mixed fresh and stale daemon witness
Delivery retry eligibility attemptDeliveryRetries() Recheck age at attempt time; an event that crosses the horizon is dropped Non-message events and still-fresh messages continue JSDoc retry-crosses-horizon witness
Mailbox retention MailboxService read and archive contract No read, archive, or delete mutation from wake expiry Explicit mailbox drain remains authoritative none assert row remains unread and listable

Decision Record impact

none. This closes a runtime eligibility gap in the existing wake and coalescing substrate; it does not change an ADR or introduce a new transport or store.

Acceptance Criteria

  • Exact regression: an unread, never-woken MESSAGE with canonical sentAt older than one hour plus a fresh GraphLog delivery edge produces no wake.
  • The stale row stays unread and listable after suppression.
  • A mixed queue containing one stale high-priority message and one fresh normal message reports exactly one normal-priority message; the stale subject never appears.
  • Missing or invalid sentAt fails closed for wake delivery.
  • Retry rechecks age and drops a message that crosses the horizon between failed initial delivery and retry.
  • Suppressed stale events advance the relevant consumption state and do not reappear after another GraphLog replay.
  • Focused wake unit suite passes.

Out of Scope

  • Sender-online gating; recent messages remain actionable even if the sender goes offline.
  • Mailbox retention, auto-read, archive, or deletion policy.
  • Cursor corruption and read-state resurrection already covered by #12849 and #12479.
  • Cloud or SSE replay transport from Discussion #14145.

Avoided Traps

  • Treating unread as synonymous with new.
  • Marking old messages read to hide the symptom.
  • Keying freshness to GraphLog insertion time; replay is exactly what makes that timestamp misleading.
  • Gating on sender liveness, which would drop valid recent messages.
  • Adding a second store or daemon.

Related

#12479 · #12849 · #14576 · Discussion #14145

Live latest-open sweep: checked latest 20 open issues at 2026-07-22T11:39:02Z; no equivalent found. A2A in-flight claim sweep: checked latest 30 messages across read states; no overlapping wake-age claim found.

Origin Session ID: bb641b19-2dcb-4fd5-bd85-97a17cf162c3

Retrieval Hint: "ghost wake replay old unread MESSAGE sentAt GraphLog heavy delta maximum wake age"