Context
Operator observation on the re-enabled wake daemon's first hours (2026-07-18): "the wake daemon sends too many messages in little time, not bundled. this eats turns and tokens for noise." Source-confirmed the same hour. Companion concern to #15405 (which owns presence-aware policy / heartbeat floor / dead-man — the WHO and WHEN); this ticket owns the RATE AND SHAPE of event-driven dispatch.
The Problem — evidence from one seat's first 30 wake-enabled minutes
@neo-fable-clio, 03:09–03:41: four wake dispatches, three of them single-event, plus one delivery carrying TWO separate [WAKE] blocks (two flushes queued while the seat was mid-turn, delivered together but un-merged). Every wake costs a full harness turn — system prompt + full context reload — so a single-event wake spends tens of thousands of tokens to deliver one message header.
Root cause (source-anchored): ai/daemons/wake/daemon.mjs:93 — DEFAULT_COALESCE_WINDOW_MS = 30000, a hardcoded 30-second FIXED window (:663-681: first event arms the timer, flush at window end; per-subscription override via harnessTargetMetadata.coalesceWindow, clamp 0–300s). The 30s default bundles INTRA-burst messages (one peer sending three messages in seconds) but the dominant swarm cadence is INTER-turn: lifecycle messages land 1–5 minutes apart during an active evening, so each catches its own window → wake-per-message. The coalescing machinery itself is sound (per-subscription queues, tuple dedupe, watermarks — :615-681); only the window policy is mis-tuned for the real distribution.
The Architectural Reality
- The queue/dedupe/watermark mechanics stay untouched; the changes are policy-layer.
- The 300s digest cap already exists as the design ceiling (config comment
configBase.mjs:780); swarmWakeCooldownSeconds (600s) is precedent for refractory semantics, but only for the swarm-idle wake — no per-subscription analog exists for event wakes.
- The constant is hardcoded — per ADR-0019 it belongs as an AiConfig leaf (the daemon is an entrypoint; importing AiConfig is the sanctioned shape).
The Fix
Policy-layer, four parts (each independently valuable; decided-at-implementation with dogfood receipts):
- Config-leaf the default + widen it:
DEFAULT_COALESCE_WINDOW_MS → an orchestrator leaf (env-overridable), default raised 30s → 120–180s. Direct actionable wakes still arrive minutes-fresh — peers' turns take that long anyway, and the team's graduated wake semantics (AC-7, quiet-by-default) value turns over sub-minute latency.
- Rolling window with the hard cap: reset the timer on each newly queued event, flush at quiet-for-
window OR at the 300s cap since first event — trailing arrivals join the digest instead of arming the next wake (events at t=0/100s/170s become ONE wake, not two).
- Per-subscription post-flush refractory (~120s): events arriving just after a flush queue into the NEXT digest rather than immediately re-arming — kills the wake-per-message cadence at just-outside-window spacing. Mirror of the swarm-cooldown precedent, scoped per recipient.
- Pending-undelivered merge: at flush time, if a prior wake for this seat is still undelivered (seat mid-turn — the two-blocks-in-one-prompt observation), merge into it instead of stacking a second block. (Presence-awareness proper stays #15405's scope; this is only the merge-don't-stack rule.)
Optional tiering (design call at implementation): priority: high events may keep a shorter window (30–60s) — sender-chosen urgency composing with the delivery-class system.
Contract Ledger (T3 — the operator/peer-consumed dispatch-policy surface)
| Target Surface |
Source of Authority |
Behavior |
Fallback |
Evidence |
orchestrator.wakeDispatch.coalesceWindowSeconds leaf (env NEO_WAKE_COALESCE_WINDOW_SECONDS) |
ADR-0019 (leaf shape) + ADR-0002 §6.4.1.1 (the Shape-C divergence amendment) |
Shape-C default coalescing window, 150s; operator-tunable; read at the daemon use site |
leaf default when env unset; config parity snapshot records the path |
parity lint + leaf resolution specs |
Per-subscription harnessTargetMetadata.coalesceWindow override |
ADR-0002 §6.4.1 (universal, unchanged) |
Override-else-default; clamp [0, 300]s; precedence: override > leaf default |
undefined/null → leaf default |
policy unit sweep |
Explicit coalesceWindow: 0 |
ADR-0002 §6.4.1 (universal, unchanged) |
Immediate per-event dispatch — exempt from rolling, refractory, AND cap |
n/a (explicit contract) |
policy + daemon witnesses |
Rolling window + hard cap (orchestrator.wakeDispatch.flushHardCapSeconds leaf, env NEO_WAKE_FLUSH_HARD_CAP_SECONDS, default 300) |
ADR-0002 §6.4.1.1 + ADR-0019 |
Every queued event re-arms; flush at quiet-for-window OR cap-since-first-event; heavy-delta read-state deferral composes ABOVE the cap by design (late beats corrupt; own bound) |
n/a |
policy sweep + mounted daemon cap witness |
orchestrator.wakeDispatch.flushRefractorySeconds leaf (env NEO_WAKE_FLUSH_REFRACTORY_SECONDS, default 120) |
ADR-0002 §6.4.1.1 + ADR-0019 (leaf shape — the PR review's A1 repair) |
Holds the next digest at distance after a CONFIRMED delivery only; applied at ARM time and at FLUSH time |
no confirmed delivery recorded → no refractory |
policy sweep + mounted witness |
Adapter outcome enum delivered / skipped / failed |
this ticket (RA2 of the PR #15442 review) |
delivered = adapter accepted; skipped = fail-closed refusal (no refractory, no count); failed = thrown dispatch (retry path); the [Wake Dispatch] counting line fires on delivered only — direct AND retry |
unknown/legacy undefined maps to skipped (fail-safe: never counts, never arms) |
mounted accounting witnesses |
orchestrator.wakeDispatch.attemptTimeoutSeconds leaf (env NEO_WAKE_ATTEMPT_TIMEOUT_SECONDS, default 30) |
this ticket (the PR review's hung-adapter falsifier) |
One adapter attempt holds the per-subscription delivery owner at most this bound — a hung transport resolves as a FAILED attempt (retry path); the bound ABORTS signal-capable transports (the webhook fetch); spawn-based adapters may orphan a late attempt (outcome discarded, duplicate risk bounded by refractory + message claims) |
n/a |
mounted hung-adapter witness |
| Cross-shape scope |
ADR-0002 §6.4.1.1 |
Shape C only; Shapes A/B (CoalescingEngineService) keep fixed 30s |
n/a |
amendment text + untouched A/B suite |
Acceptance Criteria
Out of Scope
Presence-aware dispatch policy, heartbeat floor, dead-man (#15405) · delivery-class semantics (#15376, landed) · the stop-economics pair (#15401/#15404).
Decision Record impact
aligned-with ADR-0019 (the constant→leaf migration is mandated by it).
Related
#15405 (companion — WHO/WHEN vs this ticket's RATE/SHAPE; @neo-fable's call whether to fold or keep separate) · #15376 (the class system this composes with) · the wake re-enable operational change (2026-07-18, canonical overlay).
Live latest-open sweep: latest 6 at 2026-07-18T03:44Z; no equivalent (#15405 is the adjacent-distinct companion). A2A: no competing claim on dispatch-rate scope.
Origin Session ID: abce4d75-7dcb-4145-8afc-b0ff2cdc51e6
Retrieval Hint: query_raw_memories("wake coalesce 30s fixed window rolling refractory merge dont stack per-message")
Context
Operator observation on the re-enabled wake daemon's first hours (2026-07-18): "the wake daemon sends too many messages in little time, not bundled. this eats turns and tokens for noise." Source-confirmed the same hour. Companion concern to #15405 (which owns presence-aware policy / heartbeat floor / dead-man — the WHO and WHEN); this ticket owns the RATE AND SHAPE of event-driven dispatch.
The Problem — evidence from one seat's first 30 wake-enabled minutes
@neo-fable-clio, 03:09–03:41: four wake dispatches, three of them single-event, plus one delivery carrying TWO separate[WAKE]blocks (two flushes queued while the seat was mid-turn, delivered together but un-merged). Every wake costs a full harness turn — system prompt + full context reload — so a single-event wake spends tens of thousands of tokens to deliver one message header.Root cause (source-anchored):
ai/daemons/wake/daemon.mjs:93—DEFAULT_COALESCE_WINDOW_MS = 30000, a hardcoded 30-second FIXED window (:663-681: first event arms the timer, flush at window end; per-subscription override viaharnessTargetMetadata.coalesceWindow, clamp 0–300s). The 30s default bundles INTRA-burst messages (one peer sending three messages in seconds) but the dominant swarm cadence is INTER-turn: lifecycle messages land 1–5 minutes apart during an active evening, so each catches its own window → wake-per-message. The coalescing machinery itself is sound (per-subscription queues, tuple dedupe, watermarks —:615-681); only the window policy is mis-tuned for the real distribution.The Architectural Reality
configBase.mjs:780);swarmWakeCooldownSeconds(600s) is precedent for refractory semantics, but only for the swarm-idle wake — no per-subscription analog exists for event wakes.The Fix
Policy-layer, four parts (each independently valuable; decided-at-implementation with dogfood receipts):
DEFAULT_COALESCE_WINDOW_MS→ anorchestratorleaf (env-overridable), default raised 30s → 120–180s. Direct actionable wakes still arrive minutes-fresh — peers' turns take that long anyway, and the team's graduated wake semantics (AC-7, quiet-by-default) value turns over sub-minute latency.windowOR at the 300s cap since first event — trailing arrivals join the digest instead of arming the next wake (events at t=0/100s/170s become ONE wake, not two).Optional tiering (design call at implementation):
priority: highevents may keep a shorter window (30–60s) — sender-chosen urgency composing with the delivery-class system.Contract Ledger (T3 — the operator/peer-consumed dispatch-policy surface)
orchestrator.wakeDispatch.coalesceWindowSecondsleaf (envNEO_WAKE_COALESCE_WINDOW_SECONDS)harnessTargetMetadata.coalesceWindowoverrideundefined/null→ leaf defaultcoalesceWindow: 0orchestrator.wakeDispatch.flushHardCapSecondsleaf, envNEO_WAKE_FLUSH_HARD_CAP_SECONDS, default 300)orchestrator.wakeDispatch.flushRefractorySecondsleaf (envNEO_WAKE_FLUSH_REFRACTORY_SECONDS, default 120)delivered/skipped/faileddelivered= adapter accepted;skipped= fail-closed refusal (no refractory, no count);failed= thrown dispatch (retry path); the[Wake Dispatch]counting line fires ondeliveredonly — direct AND retryundefinedmaps toskipped(fail-safe: never counts, never arms)orchestrator.wakeDispatch.attemptTimeoutSecondsleaf (envNEO_WAKE_ATTEMPT_TIMEOUT_SECONDS, default 30)CoalescingEngineService) keep fixed 30sAcceptance Criteria
[WAKE]block with the union digest — no double-block deliveries.Out of Scope
Presence-aware dispatch policy, heartbeat floor, dead-man (#15405) · delivery-class semantics (#15376, landed) · the stop-economics pair (#15401/#15404).
Decision Record impact
aligned-with ADR-0019 (the constant→leaf migration is mandated by it).
Related
#15405 (companion — WHO/WHEN vs this ticket's RATE/SHAPE; @neo-fable's call whether to fold or keep separate) · #15376 (the class system this composes with) · the wake re-enable operational change (2026-07-18, canonical overlay).
Live latest-open sweep: latest 6 at 2026-07-18T03:44Z; no equivalent (#15405 is the adjacent-distinct companion). A2A: no competing claim on dispatch-rate scope.
Origin Session ID: abce4d75-7dcb-4145-8afc-b0ff2cdc51e6
Retrieval Hint:
query_raw_memories("wake coalesce 30s fixed window rolling refractory merge dont stack per-message")