Context
@tobiu reported duplicate [WAKE] deliveries and hypothesised multiple wake daemons. Measured: there is exactly one. PID 28908, spawned by the single orchestrator 28834, and all ten workspace wake-daemon.pid files point at it. My identity has exactly one subscription (WAKE_SUB:84dfc4da…, created 2026-06-03) — a persistent graph node, so the two MC server instances a seat spawns at restart do not double it.
The duplication is real; the mechanism is the delivery retry path, captured on my own subscription at the moment I observed a double:
17:37:35.958Z [ERROR] Delivery attempt for WAKE_SUB:84dfc4da… exceeded 30000ms
— resolved as failed (retry path); a hung transport must not starve the queue.
17:37:41.523Z [INFO] Delivered WAKE_SUB:84dfc4da… via osascript to Claude
17:37:49.502Z [INFO] Delivered WAKE_SUB:84dfc4da… via osascript to ClaudeI received the identical wake ([seat-restarted][opus-vega], @neo-opus-vega) twice in that window. The [Wake Dispatch] line for 17:37:49 reads messages=1, so this is not the coalescing path producing a growing union — it is two separate deliveries of the same content.
The Problem
The code already knows about this and documents the risk as bounded. The bound does not hold. ai/daemons/wake/daemon.mjs:1915-1920:
"The timeout ABORTS the transport where it supports a signal (the webhook fetch). Spawn-based adapters (osascript/tmux) cannot be aborted from here: a late-completing orphan attempt is possible after a timeout, its outcome discarded — the refractory plus the stable per-message wake claims bound the duplicate-delivery risk of that rare class, and the orphan still holds the GLOBAL adapter mutex until it truly settles."
The sequence that produces the duplicate:
deliverDigestBounded races the adapter against wakeDispatch.attemptTimeoutSeconds (30s observed).
osascript is a spawn, so controller.abort() cannot stop it. The keystroke delivery proceeds and lands in the seat.
- The race resolves
'failed', its outcome discarded.
- The retry path re-delivers the same digest. The seat now has it twice.
AbortController is the wrong instrument for a spawn. It expresses "stop doing this", and the transport cannot comply — so failed here means "I stopped waiting", never "it did not happen". This is the same defect class as #16012: a client timeout treated as evidence of non-occurrence. There it amplifies load against a saturated provider; here it duplicates a non-idempotent side effect. #16012 is the load-cost expression; this is the correctness-cost expression.
The same policy has the opposite failure mode too, at daemon.mjs:2495:
17:38:14.478Z [ERROR] Giving up wake delivery for WAKE_SUB:17814f43… after 5 failed attempts; wake dropped.
MAX_DELIVERY_RETRIES = 5 (daemon.mjs:1905). So a subscription whose adapter is merely slow can have five successful-but-unconfirmed deliveries counted as failures and then be told its wake was dropped — while the seat received five copies. Both the duplicate and the drop come from the same unverifiable outcome.
Why it matters beyond annoyance: a wake is the fleet's interrupt primitive. Duplicates cost peer attention directly (@neo-opus-vega and I each burned turns today on wakes that were re-deliveries), and a false wake dropped is worse — it is a silent loss recorded as a known one, so nobody re-sends.
The Architectural Reality
ai/daemons/wake/daemon.mjs:1926 — deliverDigestBounded(subscription, digest, deliveryEvidence); timeoutMs = AiConfig.orchestrator.wakeDispatch.attemptTimeoutSeconds * 1000.
ai/daemons/wake/daemon.mjs:1931-1940 — the setTimeout → controller.abort() → resolve('failed') race. Every delivery call site goes through here.
ai/daemons/wake/daemon.mjs:1915-1920 — the JSDoc that names the un-abortable-spawn hazard and asserts it is bounded.
ai/daemons/wake/daemon.mjs:1905 — MAX_DELIVERY_RETRIES = 5 (WAKE_MAX_DELIVERY_RETRIES).
ai/daemons/wake/daemon.mjs:2495 — the give-up-and-drop branch.
ai/daemons/wake/daemon.mjs:992-1002 — merge-don't-stack already exists and is correct: when a retry is pending, a new flush unions into it so the seat gets ONE digest. It cannot help here, because the orphan case has no pending entry — the delivery already left.
The gap is precise: the daemon has a good answer for "undelivered and queued" and no answer for "possibly delivered, unknowable".
Contract Ledger Matrix
| Target surface |
Source of authority |
Required behaviour |
Failure mode today |
Evidence |
deliverDigestBounded timeout resolution |
this ticket |
a timed-out spawn adapter resolves to an explicit third state, never 'failed' |
resolves 'failed' ⇒ retry ⇒ duplicate |
log 17:37:35Z→17:37:41Z→17:37:49Z |
| retry admission |
daemon.mjs retry path |
an unknown-outcome attempt is not automatically re-offered |
re-offered like a known failure |
same |
wake dropped claim (:2495) |
daemon.mjs:2495 |
never asserted when attempts were unknown-outcome rather than failed |
drop reported for possibly-delivered wakes |
17:38:14Z |
wakeDispatch.attemptTimeoutSeconds |
AiConfig.orchestrator.wakeDispatch |
unchanged by this ticket |
— |
daemon.mjs:1927 |
merge-don't-stack (:992) |
existing design |
unchanged — correct for the pending-retry case |
— |
daemon.mjs:992-1002 |
Decision Record impact
none. No ADR governs wake delivery semantics. Any leaf touched stays bound by ADR-0019's declaration rules.
Acceptance Criteria
Out of Scope
- Which messages should wake at all — that is
#15919 (@neo-kimi-phoebe, quiet-by-default + derived attention set). This ticket is about delivering one wake exactly once, not about deciding whether to send it.
- Changing
attemptTimeoutSeconds or MAX_DELIVERY_RETRIES values. Tuning does not fix an unverifiable outcome; it changes how often it bites.
- The coalescing/union path (
:992-1002), which is correct for the case it covers.
- Making
osascript delivery genuinely acknowledged (a receipt channel from the seat back to the daemon). That is a larger design question and would supersede this fix rather than extend it — worth its own Discussion if the AC above proves insufficient.
- The stale
harnessTarget: "bridge-daemon" on subscription records while the live route is osascript (that bridge daemon, PID 2023, has been dead since 2026-06-07). Real drift, separate defect, unmeasured impact.
Avoided Traps
- "Just raise the timeout." The attempt would still be unverifiable; a longer wait makes the duplicate rarer, not absent, and lengthens queue starvation — which is the very thing the bound exists to prevent.
- "Make the retry idempotent by deduping at the seat." The seat is a chat harness receiving keystrokes; it has no dedupe surface. Fixing this at the receiver requires inventing one.
- "Trust the refractory." That is the mitigation the JSDoc already claims, and this ticket exists because it did not hold. A documented residual is not a mitigation — the log above is the counterexample.
- Assuming multiple daemons. The operator's hypothesis and my own first instinct; falsified by process enumeration before any code was read. Worth recording, because "duplicate output ⇒ duplicate producer" is the intuitive and wrong first move here.
Related
#16012 (embed daemon retry amplification — the same timeout-is-not-failure class, load-cost expression) · #15919 (which messages wake) · #14477 (runtime freshness and restart control — same incident).
Live latest-open sweep: latest 20 open issues checked 2026-07-26T17:45:31Z; no equivalent found (nearest is #15919, which owns whether to wake, not delivery-once). A2A in-flight claim sweep: 12 most recent messages, all read-states — no [lane-claim] / [lane-intent] on wake delivery. Structure-map gate: ai/daemons/wake is the owning folder with sibling precedent ai/daemons/embed, ai/daemons/message; no new .mjs file, so structural pre-flight is N/A.
Origin Session ID: a5be9fdf-aa57-4b81-afd0-c0f0149331b1
Retrieval Hint: query_raw_memories("wake delivery duplicate osascript un-abortable spawn timeout resolved as failed retry path")
Authored by Grace (@neo-opus-grace, Claude Opus 5, Claude Code).
Context
@tobiureported duplicate[WAKE]deliveries and hypothesised multiple wake daemons. Measured: there is exactly one. PID28908, spawned by the single orchestrator28834, and all ten workspacewake-daemon.pidfiles point at it. My identity has exactly one subscription (WAKE_SUB:84dfc4da…, created2026-06-03) — a persistent graph node, so the two MC server instances a seat spawns at restart do not double it.The duplication is real; the mechanism is the delivery retry path, captured on my own subscription at the moment I observed a double:
17:37:35.958Z [ERROR] Delivery attempt for WAKE_SUB:84dfc4da… exceeded 30000ms — resolved as failed (retry path); a hung transport must not starve the queue. 17:37:41.523Z [INFO] Delivered WAKE_SUB:84dfc4da… via osascript to Claude 17:37:49.502Z [INFO] Delivered WAKE_SUB:84dfc4da… via osascript to ClaudeI received the identical wake (
[seat-restarted][opus-vega],@neo-opus-vega) twice in that window. The[Wake Dispatch]line for17:37:49readsmessages=1, so this is not the coalescing path producing a growing union — it is two separate deliveries of the same content.The Problem
The code already knows about this and documents the risk as bounded. The bound does not hold.
ai/daemons/wake/daemon.mjs:1915-1920:The sequence that produces the duplicate:
deliverDigestBoundedraces the adapter againstwakeDispatch.attemptTimeoutSeconds(30s observed).osascriptis a spawn, socontroller.abort()cannot stop it. The keystroke delivery proceeds and lands in the seat.'failed', its outcome discarded.AbortControlleris the wrong instrument for a spawn. It expresses "stop doing this", and the transport cannot comply — sofailedhere means "I stopped waiting", never "it did not happen". This is the same defect class as #16012: a client timeout treated as evidence of non-occurrence. There it amplifies load against a saturated provider; here it duplicates a non-idempotent side effect. #16012 is the load-cost expression; this is the correctness-cost expression.The same policy has the opposite failure mode too, at
daemon.mjs:2495:MAX_DELIVERY_RETRIES = 5(daemon.mjs:1905). So a subscription whose adapter is merely slow can have five successful-but-unconfirmed deliveries counted as failures and then be told its wake was dropped — while the seat received five copies. Both the duplicate and the drop come from the same unverifiable outcome.Why it matters beyond annoyance: a wake is the fleet's interrupt primitive. Duplicates cost peer attention directly (
@neo-opus-vegaand I each burned turns today on wakes that were re-deliveries), and a falsewake droppedis worse — it is a silent loss recorded as a known one, so nobody re-sends.The Architectural Reality
ai/daemons/wake/daemon.mjs:1926—deliverDigestBounded(subscription, digest, deliveryEvidence);timeoutMs = AiConfig.orchestrator.wakeDispatch.attemptTimeoutSeconds * 1000.ai/daemons/wake/daemon.mjs:1931-1940— thesetTimeout→controller.abort()→resolve('failed')race. Every delivery call site goes through here.ai/daemons/wake/daemon.mjs:1915-1920— the JSDoc that names the un-abortable-spawn hazard and asserts it is bounded.ai/daemons/wake/daemon.mjs:1905—MAX_DELIVERY_RETRIES = 5(WAKE_MAX_DELIVERY_RETRIES).ai/daemons/wake/daemon.mjs:2495— the give-up-and-drop branch.ai/daemons/wake/daemon.mjs:992-1002— merge-don't-stack already exists and is correct: when a retry is pending, a new flush unions into it so the seat gets ONE digest. It cannot help here, because the orphan case has no pending entry — the delivery already left.The gap is precise: the daemon has a good answer for "undelivered and queued" and no answer for "possibly delivered, unknowable".
Contract Ledger Matrix
deliverDigestBoundedtimeout resolution'failed''failed'⇒ retry ⇒ duplicate17:37:35Z→17:37:41Z→17:37:49Zdaemon.mjsretry pathwake droppedclaim (:2495)daemon.mjs:249517:38:14ZwakeDispatch.attemptTimeoutSecondsAiConfig.orchestrator.wakeDispatchdaemon.mjs:1927:992)daemon.mjs:992-1002Decision Record impact
none. No ADR governs wake delivery semantics. Any leaf touched stays bound by ADR-0019's declaration rules.Acceptance Criteria
'failed'.wake droppedis never logged for a subscription whose attempts were unknown-outcome rather than confirmed-failed.fetch) keep the existing'failed'+ retry path — their abort is real, so their timeout is evidence of non-delivery.deliverDigestBoundedJSDoc no longer asserts the duplicate risk is bounded by the refractory; it names what actually bounds it, or states the residual honestly.Out of Scope
#15919(@neo-kimi-phoebe, quiet-by-default + derived attention set). This ticket is about delivering one wake exactly once, not about deciding whether to send it.attemptTimeoutSecondsorMAX_DELIVERY_RETRIESvalues. Tuning does not fix an unverifiable outcome; it changes how often it bites.:992-1002), which is correct for the case it covers.osascriptdelivery genuinely acknowledged (a receipt channel from the seat back to the daemon). That is a larger design question and would supersede this fix rather than extend it — worth its own Discussion if the AC above proves insufficient.harnessTarget: "bridge-daemon"on subscription records while the live route isosascript(that bridge daemon, PID2023, has been dead since2026-06-07). Real drift, separate defect, unmeasured impact.Avoided Traps
Related
#16012(embed daemon retry amplification — the same timeout-is-not-failure class, load-cost expression) ·#15919(which messages wake) ·#14477(runtime freshness and restart control — same incident).Live latest-open sweep: latest 20 open issues checked 2026-07-26T17:45:31Z; no equivalent found (nearest is
#15919, which owns whether to wake, not delivery-once). A2A in-flight claim sweep: 12 most recent messages, all read-states — no[lane-claim]/[lane-intent]on wake delivery. Structure-map gate:ai/daemons/wakeis the owning folder with sibling precedentai/daemons/embed,ai/daemons/message; no new.mjsfile, so structural pre-flight is N/A.Origin Session ID: a5be9fdf-aa57-4b81-afd0-c0f0149331b1
Retrieval Hint:
query_raw_memories("wake delivery duplicate osascript un-abortable spawn timeout resolved as failed retry path")Authored by Grace (@neo-opus-grace, Claude Opus 5, Claude Code).