Resolves #10675
Authored by Claude Opus 4.7 (Claude Code). Session cce1fea5-32ff-410c-b820-2e9a27b3cd51.
Adds ai/scripts/idleOutNudge.mjs — per-identity dispatcher consumed by swarm-heartbeat.sh when checkSunsetted.mjs (#10689 detector contract) emits recommended_action: 'idle_out_nudge' for a specific identity. Reuses the A2A messaging path (MailboxService.addMessage → bridge-daemon keystroke delivery); zero new transport, no fresh-session spawn.
What changed
3 files, 366 insertions:
| File |
Surface |
ai/scripts/idleOutNudge.mjs |
NEW — per-identity dispatcher |
ai/scripts/swarm-heartbeat.sh |
+26 — routes recommended_action: 'idle_out_nudge' to the new dispatcher |
test/playwright/unit/ai/scripts/idleOutNudge.spec.mjs |
NEW — 7 tests |
Distinct from trioWakeCooldown.mjs: trio-wake fires on the swarm-wide allIdle signal (ALL configured agents idle simultaneously); this dispatcher fires when ONE identity is stale while the swarm is otherwise active. Both reuse the inflightLock.mjs idle_out_nudge mode (#10683) but operate independently.
Invariants per #10675
- Bounded: in-flight
idle_out_nudge lock prevents re-fire within BOOT_TIMEOUT_MS (15 min default per inflightLock.mjs). One nudge per identity per window — no spam during long deep-thinking turns or rate-limit throttle.
- Non-spawning: uses the A2A messaging path. Recipient sees a normal mailbox message that
bridge-daemon keystroke-injects into their active chat tab. No Cmd+N spawn; no new harness session.
- Idempotent: lock-acquire is the gate. Detector contract (#10689) consults the lock and downgrades
recommended_action to 'no_action' when held; this dispatcher's defensive checkInflightLock is layer-2 against the narrow detector-dispatcher race window.
- No-destructive-type-into-active-draft:
bridge-daemon's existing focus-steal protection (#10422) covers the keystroke delivery; if the recipient is mid-typing, the bridge-daemon defers without clobbering input.
Safety sequencing
idleOutNudge.mjs follows the resumeHarness defense-in-depth pattern:
- Wake safety gate (#10648) check with
WAKE_GATE_OVERRIDE operator bypass
- Memory Core service initialization (
LifecycleService + GraphService)
- Defensive in-flight lock check (handles detector-dispatcher race window)
- Acquire in-flight
idle_out_nudge lock
- A2A send via
MailboxService.addMessage
- On send error: clear lock so the next interval can retry without waiting for
BOOT_TIMEOUT_MS expiration
swarm-heartbeat.sh shell-side gate check (defense-in-depth) skips the Node-process spawn cost when the gate is closed.
Lock-release path
Lock is NOT cleared on dispatcher success. It stays held until checkSunsetted.mjs (next heartbeat cycle) observes a fresh AGENT_MEMORY post-lock-acquire and clears it ("memory-resolved" release per #10683). If the recipient never saves a memory (rejection / sustained rate-limit), the lock ages out at BOOT_TIMEOUT_MS and gets cleared as "abandoned" — MAX_ABANDONED_ACTIONS triggers the wake safety gate auto-trip.
Test Evidence
npx playwright test test/playwright/unit/ai/scripts/idleOutNudge.spec.mjs → 7 passed (1.5s)
Coverage:
- Usage error on missing identity argument
- Wake safety gate tripped → no nudge dispatched, no lock acquired
- In-flight lock already held → nudge skipped, lock unchanged (idempotent invariant)
- Static body-convention check (identity + memory-resolved framing + bounded/non-spawning/in-place language)
- Static defense-in-depth sequence ordering (gate → lock check → lock acquire → A2A send)
- Static send-failure lock-clear path
swarm-heartbeat.sh integration: routes recommended_action: 'idle_out_nudge' AFTER sunset-spawn path, BEFORE all-agent-idle path
Downstream regression check: swarm-heartbeat.spec.mjs + checkSunsetted.spec.mjs + inflightLock.spec.mjs → 23 passed. No regression in any consumer.
Post-Merge Validation
Related
- Parent epic: #10671 (substrate-restart recovery, two-mode)
- Builds on: #10689 (detector contract — emits
recommended_action: 'idle_out_nudge'), #10683 (in-flight lock primitive — provides idle_out_nudge mode), #10641 (staleness ≠ sunset discipline)
- Sibling dispatchers: #10676 (sunset-mode restart substrate, pending) for
recommended_action: 'sunset_restart'; existing trioWakeCooldown.mjs for the swarm-wide all-idle signal
- Adjacent: #10422 (
bridge-daemon focus-steal protection — covers keystroke delivery to active drafts)
- Forensic context: PR #10688 (
learn/agentos/incidents/2026-05-04-runaway-spawn-pattern.md)
Resolves #10675
Authored by Claude Opus 4.7 (Claude Code). Session cce1fea5-32ff-410c-b820-2e9a27b3cd51.
Adds
ai/scripts/idleOutNudge.mjs— per-identity dispatcher consumed byswarm-heartbeat.shwhencheckSunsetted.mjs(#10689 detector contract) emitsrecommended_action: 'idle_out_nudge'for a specific identity. Reuses the A2A messaging path (MailboxService.addMessage→bridge-daemonkeystroke delivery); zero new transport, no fresh-session spawn.What changed
3 files, 366 insertions:
ai/scripts/idleOutNudge.mjsai/scripts/swarm-heartbeat.shrecommended_action: 'idle_out_nudge'to the new dispatchertest/playwright/unit/ai/scripts/idleOutNudge.spec.mjsDistinct from
trioWakeCooldown.mjs: trio-wake fires on the swarm-wideallIdlesignal (ALL configured agents idle simultaneously); this dispatcher fires when ONE identity is stale while the swarm is otherwise active. Both reuse theinflightLock.mjsidle_out_nudgemode (#10683) but operate independently.Invariants per #10675
idle_out_nudgelock prevents re-fire withinBOOT_TIMEOUT_MS(15 min default perinflightLock.mjs). One nudge per identity per window — no spam during long deep-thinking turns or rate-limit throttle.bridge-daemonkeystroke-injects into their active chat tab. NoCmd+Nspawn; no new harness session.recommended_actionto'no_action'when held; this dispatcher's defensivecheckInflightLockis layer-2 against the narrow detector-dispatcher race window.bridge-daemon's existing focus-steal protection (#10422) covers the keystroke delivery; if the recipient is mid-typing, the bridge-daemon defers without clobbering input.Safety sequencing
idleOutNudge.mjsfollows the resumeHarness defense-in-depth pattern:WAKE_GATE_OVERRIDEoperator bypassLifecycleService+GraphService)idle_out_nudgelockMailboxService.addMessageBOOT_TIMEOUT_MSexpirationswarm-heartbeat.shshell-side gate check (defense-in-depth) skips the Node-process spawn cost when the gate is closed.Lock-release path
Lock is NOT cleared on dispatcher success. It stays held until
checkSunsetted.mjs(next heartbeat cycle) observes a freshAGENT_MEMORYpost-lock-acquire and clears it ("memory-resolved" release per #10683). If the recipient never saves a memory (rejection / sustained rate-limit), the lock ages out atBOOT_TIMEOUT_MSand gets cleared as "abandoned" —MAX_ABANDONED_ACTIONStriggers the wake safety gate auto-trip.Test Evidence
npx playwright test test/playwright/unit/ai/scripts/idleOutNudge.spec.mjs→ 7 passed (1.5s)Coverage:
swarm-heartbeat.shintegration: routesrecommended_action: 'idle_out_nudge'AFTER sunset-spawn path, BEFORE all-agent-idle pathDownstream regression check:
swarm-heartbeat.spec.mjs+checkSunsetted.spec.mjs+inflightLock.spec.mjs→ 23 passed. No regression in any consumer.Post-Merge Validation
swarm-heartbeat.shcorrectly firesidleOutNudge.mjsin production cron when an identity has active subscription + stale memoryadd_memory(memory-resolved path)idle_out_nudgeactions fires, surface to operator via the existing gate-trip notification pathRelated
recommended_action: 'idle_out_nudge'), #10683 (in-flight lock primitive — providesidle_out_nudgemode), #10641 (staleness ≠ sunset discipline)recommended_action: 'sunset_restart'; existingtrioWakeCooldown.mjsfor the swarm-wide all-idle signalbridge-daemonfocus-steal protection — covers keystroke delivery to active drafts)learn/agentos/incidents/2026-05-04-runaway-spawn-pattern.md)