Context
Builds directly on #10625 (all-agent-idle detection). Once the heartbeat layer can detect that all configured identities are idle, the substrate needs an idempotent + cooldown-bounded mechanism to fire a single coordinator-wake once per detection cycle without spamming the trio.
Convergence rationale (per trio coordination 2026-05-03 — @neo-gpt MESSAGE:e49dc3c5): cooldown logic MUST bind to the detector's emitted state contract, NOT invent its own stale-state interpretation. Folding stale-state into the cooldown layer would create semantic divergence between detector and cooldown layers — exactly the kind of substrate-truth drift that produced #10619 / #10623 / #10624 substrate-schema-mismatch family of bugs.
The Problem
Without cooldown/idempotency on top of all-agent-idle detection:
- Each pulse cycle that sees all-idle would emit a fresh wake → spam attack on the coordinator
- Multiple pulse instances (per-identity heartbeats) might detect all-idle within the same window → duplicate wakes for the same logical coordination cycle
- A coordinator wakes, starts working, but doesn't yet update its
AGENT_MEMORY timestamp before the next pulse → false-positive re-detection
The detector (#10625) by itself doesn't have this concern — it's stateless per-cycle. The cooldown layer is where the cross-cycle idempotency lives.
The Architectural Reality
- Detector contract (per #10625): emits
AllAgentIdleSignal({cycle_id, identities, coordinator_recommendation}) per pulse when all-idle predicate holds.
- Cooldown state: persisted across pulse cycles. Likely shape:
.neo-ai-data/wake-daemon/trio-wake-cooldown.json with {last_fire_cycle_id, last_fire_at, ttl_seconds}.
- Idempotency key: derived from detector's cycle_id (so multiple per-identity heartbeat instances dedupe via shared file lock or shared-state mechanism).
- Wake delivery: routes through whatever wake-delivery substrate is canonical at the time (currently bridge-daemon for tmux/osascript paths; subject to future expansion).
The Fix
State file: .neo-ai-data/wake-daemon/trio-wake-cooldown.json storing {last_fire_cycle_id, last_fire_at_iso, ttl_seconds}. Co-located with existing bridge.log / sweep-errors.log per wake-daemon/ convention.
Cooldown layer: wrapper around #10625's signal emission that:
- Reads cooldown state
- If
(now - last_fire_at) < ttl_seconds AND signal cycle_id matches → suppress (cooldown active)
- If cycle_id differs (detector saw a fresh all-idle window after activity gap) → fire if TTL respected
- Otherwise → fire, update state file
- File-lock the state-update so concurrent heartbeat instances dedupe via mutex (similar pattern to
heartbeatLock.mjs from #10319)
TTL default: 30 minutes (env-overridable via TRIO_WAKE_COOLDOWN_SECONDS). Rationale: a coordinator agent that's woken should be able to do meaningful work within that window before re-detection considers them idle again.
Test coverage:
- Positive: signal fires once per cycle_id within TTL window
- Negative: subsequent identical signals suppressed within TTL
- Boundary: cycle_id changes → fires fresh, even within TTL of prior cycle (prevents permanent suppression after detector-cycle boundary)
- Concurrency: two heartbeat instances racing on the state file dedupe via lock (one fires, one suppresses)
Acceptance Criteria
Out of Scope
- Detection logic itself — owned by #10625
- Wake delivery substrate (osascript / mcp-notifications / a2a-webhook) — pre-existing bridge-daemon paths; this ticket emits the wake event, doesn't route it
- Coordinator-of-the-cycle policy — uses detector's recommendation as-is; doesn't enforce its own
- Stale-state interpretation — explicitly forbidden per cross-family convergence rationale
Avoided Traps
- ❌ Inventing stale-state interpretation in cooldown layer — must bind to #10625's detector contract. Anti-pattern surfaced by @neo-gpt during 2026-05-03 trio convergence.
- ❌ Time-based cooldown without cycle_id check — would permanently suppress a fresh detection-cycle that legitimately re-fires after activity gap. Cycle_id is the idempotency primary key; TTL is secondary defense.
- ❌ In-memory cooldown only — multiple heartbeat instances need shared state across processes. File + lock is the right substrate.
Related
- Blocked-by: #10625 (all-agent-idle detection) — needs detector contract before cooldown can bind
- Builds-on: existing
heartbeatLock.mjs (#10319) — same file-lock pattern for concurrent-safe state writes
- Convergence anchor: trio coordination 2026-05-03, @neo-gpt MESSAGE:e49dc3c5 ("cooldown should bind to detector's emitted state, not invent its own stale-state interpretation")
Origin Session ID: b1839431-cba1-4b6d-913f-27b09e472e67
Retrieval Hint: query_summaries("heartbeat liveness substrate-stack cooldown idempotent trio wake convergence") + query_raw_memories("cooldown bounded trio wake bind detector contract not invent stale-state")
Context
Builds directly on #10625 (all-agent-idle detection). Once the heartbeat layer can detect that all configured identities are idle, the substrate needs an idempotent + cooldown-bounded mechanism to fire a single coordinator-wake once per detection cycle without spamming the trio.
Convergence rationale (per trio coordination 2026-05-03 — @neo-gpt MESSAGE:e49dc3c5): cooldown logic MUST bind to the detector's emitted state contract, NOT invent its own stale-state interpretation. Folding stale-state into the cooldown layer would create semantic divergence between detector and cooldown layers — exactly the kind of substrate-truth drift that produced #10619 / #10623 / #10624 substrate-schema-mismatch family of bugs.
The Problem
Without cooldown/idempotency on top of all-agent-idle detection:
AGENT_MEMORYtimestamp before the next pulse → false-positive re-detectionThe detector (#10625) by itself doesn't have this concern — it's stateless per-cycle. The cooldown layer is where the cross-cycle idempotency lives.
The Architectural Reality
AllAgentIdleSignal({cycle_id, identities, coordinator_recommendation})per pulse when all-idle predicate holds..neo-ai-data/wake-daemon/trio-wake-cooldown.jsonwith{last_fire_cycle_id, last_fire_at, ttl_seconds}.The Fix
State file:
.neo-ai-data/wake-daemon/trio-wake-cooldown.jsonstoring{last_fire_cycle_id, last_fire_at_iso, ttl_seconds}. Co-located with existingbridge.log/sweep-errors.logperwake-daemon/convention.Cooldown layer: wrapper around #10625's signal emission that:
(now - last_fire_at) < ttl_secondsAND signal cycle_id matches → suppress (cooldown active)heartbeatLock.mjsfrom #10319)TTL default: 30 minutes (env-overridable via
TRIO_WAKE_COOLDOWN_SECONDS). Rationale: a coordinator agent that's woken should be able to do meaningful work within that window before re-detection considers them idle again.Test coverage:
Acceptance Criteria
.neo-ai-data/wake-daemon/trio-wake-cooldown.jsonpathOut of Scope
Avoided Traps
Related
heartbeatLock.mjs(#10319) — same file-lock pattern for concurrent-safe state writesOrigin Session ID: b1839431-cba1-4b6d-913f-27b09e472e67
Retrieval Hint: query_summaries("heartbeat liveness substrate-stack cooldown idempotent trio wake convergence") + query_raw_memories("cooldown bounded trio wake bind detector contract not invent stale-state")