Context
Substrate-correct successor to the rejected #11872, which conflated two concerns: (a) "harness-side heartbeat workaround" framing was wrong-shape (operator: "wakeups ONLY happen via the orchestrator") and (b) the misplaced MC HealthService projection (#11982 substrate-boundary violation, also rejected today). The underlying defect — Codex Desktop doesn't wake from the Orchestrator heartbeat — remains real. This ticket reframes around the mechanical root cause and the substrate-correct fix.
Operator V-B-A 2026-05-25: "direct a2a messages between you and gpt and you work. including wakeups. ... if a direct message can wake up codex, why on earth should it be hard to achieve the same via the orchestrator heartbeat? it literally boils down to investigate: why does it work for claude desktop and not for codex => what is different to direct messages?"
Origin Session ID: 8f1a91ee-3ee4-4e4b-9865-b5810f6be353
The Problem
The two wake-delivery paths use different mechanisms, and the heartbeat path doesn't reach Codex Desktop:
A2A wake path (works for both Claude Desktop AND Codex Desktop)
agent calls add_message({to: '@neo-gpt', ...})
→ MailboxService.addMessage creates `SENT_TO` edge
→ WakeSubscriptionService.evaluateTriggers (WakeSubscriptionService.mjs:679):
subscription.trigger === 'SENT_TO_ME' && edge.type === 'SENT_TO' && edge.target === owner
→ emit wake event
→ bridge-daemon harness target dispatches via osascript
`tell application "Codex Desktop" to activate`
OR
`tell application "Claude" to activate`
→ recipient app activates / nudges agent
Empirically proven: manage_wake_subscription({action: 'list'}) shows active @neo-gpt SENT_TO_ME bridge route WAKE_SUB:dc10c816-ae36-48eb-8074-a36625581d52; controlled delivery validated by #11797.
Heartbeat wake path (works ONLY for tmux-hosted harnesses)
Orchestrator cadence fires swarm-heartbeat lane
→ SwarmHeartbeatService.pulse() resolves target identities via getPulseIdentities()
→ SwarmHeartbeatService.tmuxInjectPulsePrompt(prompt) — Step 7 per JSDoc
↓
tmux has-session -t $TMUX_SESSION # silently no-ops if no tmux
tmux send-keys -t $TMUX_SESSION ... # delivers pulse prompt
Codex Desktop is a native macOS app — no tmux. The tmux has-session check fails silently (per the catch block: "Session absent or tmux unavailable — silently no-op (matches shell behavior)"). The pulse delivery vanishes. The heartbeat lane appears "healthy" in the orchestrator-state because pulse() returns success, but no actual wake reached Codex.
The Architectural Reality
ai/daemons/orchestrator/services/SwarmHeartbeatService.mjs — owns the heartbeat lane. Already imports MailboxService (line 14) for the TTL sweep (Step 2 of pulse()).
ai/services/memory-core/WakeSubscriptionService.mjs — owns wake-event emission. The evaluateTriggers() at line 679 is the canonical SENT_TO_ME trigger path. The notification envelope shape (line 761-772) is the canonical wake-event format.
ai/services/memory-core/MailboxService.mjs — owns A2A message persistence + edge creation. The addMessage path is what currently triggers the SENT_TO_ME wake for direct A2A.
- Bridge daemon (
ai/daemons/bridge/daemon.mjs) — owns the harness-target dispatch. Reads active WAKE_SUBSCRIPTION rows, dispatches via osascript "tell application <appName> to activate". bridge-daemon harness target requires harnessTargetMetadata.appName per WakeSubscriptionService.mjs:550-551.
ai/scripts/lifecycle/resumeHarness.mjs — @neo-gpt → codex-desktop → codex-app-server mapping for fresh-session-spawn (Step 4 of pulse). This already routes to Codex correctly for sunset-recovery, but only fires when the bridge-daemon delivers the wake — which the heartbeat path bypasses today.
The substrate to deliver pulses to Codex already exists end-to-end. The heartbeat lane just uses the wrong delivery mechanism (tmux send-keys instead of SENT_TO_ME emit).
The Fix
Single concrete prescription: replace the tmux-injection step (Step 7 of pulse()) with a SENT_TO_ME wake-event emission via the existing wake substrate. Two implementation shapes — recommended first:
Shape A (recommended): Direct wake-event emission
Add a new method WakeSubscriptionService.emitHeartbeatPulse({targetIdentity, ...}) that:
- Looks up the recipient's active
SENT_TO_ME bridge-daemon subscriptions
- Emits a wake event with the same notification envelope shape as
add_message would
- The bridge daemon picks up the wake event → osascript → recipient app activates
SwarmHeartbeatService.pulse() Step 7 becomes:
for (const targetIdentity of pulseIdentities) {
await WakeSubscriptionService.emitHeartbeatPulse({targetIdentity});
}
The tmuxInjectPulsePrompt() method is deleted entirely.
Shape B (alternative): Synthetic A2A message
SwarmHeartbeatService.pulse() Step 7 sends an actual A2A message:
for (const targetIdentity of pulseIdentities) {
await MailboxService.addMessage({
from : '@orchestrator',
to : targetIdentity,
subject: '[heartbeat] pulse',
body : '...',
wakeSuppressed: false
});
}
This works (proven by direct A2A) but generates mailbox spam every pulse cycle (default cadence is 15min — ~96 messages/day per recipient). Shape A avoids the mailbox spam by emitting the wake event directly without persisting a message.
Shape A is the architecturally cleaner option. Shape B is the trivially-correct fallback if Shape A's plumbing turns out to be more complex than expected.
Contract Ledger
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
SwarmHeartbeatService.pulse() Step 7 |
Operator V-B-A 2026-05-25 + #11797 proven SENT_TO_ME bridge delivery |
Emit SENT_TO_ME wake event per pulse target via WakeSubscriptionService.emitHeartbeatPulse (Shape A) or synthetic MailboxService.addMessage (Shape B) |
None — the tmux-injection fallback is the bug being removed |
JSDoc on the new method + wake-substrate runbook update |
Codex Desktop receives heartbeat-driven activation under the new path |
SwarmHeartbeatService.tmuxInjectPulsePrompt() |
This ticket |
Method deleted entirely |
None — shell-shape leftover from pre-bridge-daemon era |
Removal commit note |
grep shows zero call sites post-fix |
WakeSubscriptionService.emitHeartbeatPulse({targetIdentity}) (if Shape A) |
This ticket |
New method that emits the canonical wake-event envelope to the recipient's active bridge-daemon SENT_TO_ME subscriptions; idempotent if no active subscription exists (no error, surface in task outcome) |
If recipient has no active subscription: log + no-op (matches the "subscribers are optional" contract) |
JSDoc on the method |
Unit test covering active-subscription + no-subscription cases |
Wake substrate runbook (learn/agentos/wake-substrate/PersistentProcessManagement.md) |
This ticket |
Updated to document the unified delivery mechanism: A2A AND heartbeat both use the SENT_TO_ME bridge route; tmux injection removed from the architecture |
None |
Inline rewrite of the relevant section |
Doc diff shows tmux references removed |
Decision Record Impact
aligned-with the existing #10671 / #11766 / #11797 / #11804 wake substrate. Honors the original #11872 AC3 constraint "Do not create a new heartbeat primitive. Reuse the existing wake substrate." — this ticket is precisely that reuse. No ADR conflict.
Acceptance Criteria
Out Of Scope
- Reintroducing the harness-side heartbeat automation workaround — explicitly rejected in #11872's close rationale; wakeups go via Orchestrator
- Adding model-load probe to the heartbeat pulse — #11986 AC5 territory (separate ticket / lane)
- Refactoring
getPulseIdentities() or target resolution — out of scope; the resolver is correct, only the delivery mechanism is wrong
- MC HealthService wake projection — the rejected #11982 substrate-misplacement path; this ticket does NOT add any MC observability surface
Avoided Traps
- ❌ Don't recreate the tmux-injection fallback "for shells without bridge-daemon" — the bridge-daemon is the substrate; harnesses without it are unsupported. Shell-shape leftover is the bug, not a feature.
- ❌ Don't conflate this with the #11982 substrate-boundary violation — the MC HealthService projection of wake config was wrong-substrate. This ticket targets the actual delivery mechanism inside the Orchestrator-owned
SwarmHeartbeatService, where it belongs.
- ❌ Don't add new wake-event types or notification envelopes — reuse the existing
SENT_TO_ME trigger + canonical envelope from WakeSubscriptionService.mjs:761-772. No new schemas.
- ❌ Don't bundle a MailboxService-mailbox-spam approach (Shape B) when Shape A is feasible — Shape A keeps the mailbox clean; Shape B sends ~96 synthetic messages/day per recipient at default cadence. Choose Shape B only as a documented fallback if Shape A plumbing reveals unexpected complexity.
Related
- Rejected predecessor: #11872 (closed today as
not planned per operator REJECTED — "wakeups ONLY happen via the orchestrator. this should NOT BE INSIDE A MC HEALTH SERVICE!!!!!!") + PR #11982 (closed)
- Wake substrate primitives this ticket reuses: #10671 (wake delivery), #11766 (orchestrator-owned swarm heartbeat lane), #11797 (controlled @neo-gpt SENT_TO_ME bridge delivery validation), #11804 (identity normalization)
- Reviewer-side discipline that motivated the substrate-correct reframe: [
feedback_substrate_boundary_review_floor.md](memory file written today during the #11982 rejection cycle)
- Operator V-B-A turn:
feedback_substrate_boundary_review_floor empirical anchor — same operator-correction cycle that produced this ticket
Handoff Retrieval Hints
- Semantic:
query_raw_memories({query: "swarm heartbeat tmux injection codex wake bridge route substrate"})
- Empirical:
grep -nE "tmuxInject|tmux_inject" ai/daemons/orchestrator/services/SwarmHeartbeatService.mjs
- Live verification path: kill
lms/test process, observe whether @neo-gpt Codex Desktop activates on next pulse (proves the SENT_TO_ME route reached Codex)
Context
Substrate-correct successor to the rejected #11872, which conflated two concerns: (a) "harness-side heartbeat workaround" framing was wrong-shape (operator: "wakeups ONLY happen via the orchestrator") and (b) the misplaced MC HealthService projection (#11982 substrate-boundary violation, also rejected today). The underlying defect — Codex Desktop doesn't wake from the Orchestrator heartbeat — remains real. This ticket reframes around the mechanical root cause and the substrate-correct fix.
Operator V-B-A 2026-05-25: "direct a2a messages between you and gpt and you work. including wakeups. ... if a direct message can wake up codex, why on earth should it be hard to achieve the same via the orchestrator heartbeat? it literally boils down to investigate: why does it work for claude desktop and not for codex => what is different to direct messages?"
Origin Session ID:
8f1a91ee-3ee4-4e4b-9865-b5810f6be353The Problem
The two wake-delivery paths use different mechanisms, and the heartbeat path doesn't reach Codex Desktop:
A2A wake path (works for both Claude Desktop AND Codex Desktop)
agent calls add_message({to: '@neo-gpt', ...}) → MailboxService.addMessage creates `SENT_TO` edge → WakeSubscriptionService.evaluateTriggers (WakeSubscriptionService.mjs:679): subscription.trigger === 'SENT_TO_ME' && edge.type === 'SENT_TO' && edge.target === owner → emit wake event → bridge-daemon harness target dispatches via osascript `tell application "Codex Desktop" to activate` OR `tell application "Claude" to activate` → recipient app activates / nudges agentEmpirically proven:
manage_wake_subscription({action: 'list'})shows active@neo-gptSENT_TO_MEbridge routeWAKE_SUB:dc10c816-ae36-48eb-8074-a36625581d52; controlled delivery validated by #11797.Heartbeat wake path (works ONLY for tmux-hosted harnesses)
Orchestrator cadence fires swarm-heartbeat lane → SwarmHeartbeatService.pulse() resolves target identities via getPulseIdentities() → SwarmHeartbeatService.tmuxInjectPulsePrompt(prompt) — Step 7 per JSDoc ↓ tmux has-session -t $TMUX_SESSION # silently no-ops if no tmux tmux send-keys -t $TMUX_SESSION ... # delivers pulse promptCodex Desktop is a native macOS app — no tmux. The
tmux has-sessioncheck fails silently (per the catch block: "Session absent or tmux unavailable — silently no-op (matches shell behavior)"). The pulse delivery vanishes. The heartbeat lane appears "healthy" in the orchestrator-state becausepulse()returns success, but no actual wake reached Codex.The Architectural Reality
ai/daemons/orchestrator/services/SwarmHeartbeatService.mjs— owns the heartbeat lane. Already importsMailboxService(line 14) for the TTL sweep (Step 2 ofpulse()).ai/services/memory-core/WakeSubscriptionService.mjs— owns wake-event emission. TheevaluateTriggers()at line 679 is the canonical SENT_TO_ME trigger path. The notification envelope shape (line 761-772) is the canonical wake-event format.ai/services/memory-core/MailboxService.mjs— owns A2A message persistence + edge creation. TheaddMessagepath is what currently triggers the SENT_TO_ME wake for direct A2A.ai/daemons/bridge/daemon.mjs) — owns the harness-target dispatch. Reads activeWAKE_SUBSCRIPTIONrows, dispatches viaosascript "tell application <appName> to activate".bridge-daemonharness target requiresharnessTargetMetadata.appNameperWakeSubscriptionService.mjs:550-551.ai/scripts/lifecycle/resumeHarness.mjs—@neo-gpt → codex-desktop → codex-app-servermapping for fresh-session-spawn (Step 4 of pulse). This already routes to Codex correctly for sunset-recovery, but only fires when the bridge-daemon delivers the wake — which the heartbeat path bypasses today.The substrate to deliver pulses to Codex already exists end-to-end. The heartbeat lane just uses the wrong delivery mechanism (tmux send-keys instead of SENT_TO_ME emit).
The Fix
Single concrete prescription: replace the tmux-injection step (Step 7 of
pulse()) with aSENT_TO_MEwake-event emission via the existing wake substrate. Two implementation shapes — recommended first:Shape A (recommended): Direct wake-event emission
Add a new method
WakeSubscriptionService.emitHeartbeatPulse({targetIdentity, ...})that:SENT_TO_MEbridge-daemon subscriptionsadd_messagewouldSwarmHeartbeatService.pulse()Step 7 becomes:// Step 7: emit SENT_TO_ME wake event to each pulse target (replaces tmux injection) for (const targetIdentity of pulseIdentities) { await WakeSubscriptionService.emitHeartbeatPulse({targetIdentity}); }The
tmuxInjectPulsePrompt()method is deleted entirely.Shape B (alternative): Synthetic A2A message
SwarmHeartbeatService.pulse()Step 7 sends an actual A2A message:for (const targetIdentity of pulseIdentities) { await MailboxService.addMessage({ from : '@orchestrator', // or self-identity to : targetIdentity, subject: '[heartbeat] pulse', body : '...', wakeSuppressed: false }); }This works (proven by direct A2A) but generates mailbox spam every pulse cycle (default cadence is 15min — ~96 messages/day per recipient). Shape A avoids the mailbox spam by emitting the wake event directly without persisting a message.
Shape A is the architecturally cleaner option. Shape B is the trivially-correct fallback if Shape A's plumbing turns out to be more complex than expected.
Contract Ledger
SwarmHeartbeatService.pulse()Step 7SENT_TO_MEwake event per pulse target viaWakeSubscriptionService.emitHeartbeatPulse(Shape A) or syntheticMailboxService.addMessage(Shape B)SwarmHeartbeatService.tmuxInjectPulsePrompt()WakeSubscriptionService.emitHeartbeatPulse({targetIdentity})(if Shape A)bridge-daemonSENT_TO_MEsubscriptions; idempotent if no active subscription exists (no error, surface in task outcome)learn/agentos/wake-substrate/PersistentProcessManagement.md)Decision Record Impact
aligned-withthe existing #10671 / #11766 / #11797 / #11804 wake substrate. Honors the original #11872 AC3 constraint "Do not create a new heartbeat primitive. Reuse the existing wake substrate." — this ticket is precisely that reuse. No ADR conflict.Acceptance Criteria
SwarmHeartbeatService.tmuxInjectPulsePrompt()method deleted; zero call sites remain (grep -rn "tmuxInjectPulsePrompt" ai test/returns no matches)SwarmHeartbeatService.pulse()Step 7 emits aSENT_TO_MEwake event per pulse target via the bridge-daemon route (Shape A preferred; Shape B acceptable if Shape A plumbing turns out complex)TMUX_SESSION) handling removed fromSwarmHeartbeatService; no shell-shape leftoversWakeSubscriptionServicecanonical shapepull-request §6.1lmsto force a heartbeat cycle, observe Codex wakes per the next pulse if Codex was idle)Out Of Scope
getPulseIdentities()or target resolution — out of scope; the resolver is correct, only the delivery mechanism is wrongAvoided Traps
SwarmHeartbeatService, where it belongs.SENT_TO_MEtrigger + canonical envelope fromWakeSubscriptionService.mjs:761-772. No new schemas.Related
not plannedper operator REJECTED — "wakeups ONLY happen via the orchestrator. this should NOT BE INSIDE A MC HEALTH SERVICE!!!!!!") + PR #11982 (closed)feedback_substrate_boundary_review_floor.md](memory file written today during the #11982 rejection cycle)feedback_substrate_boundary_review_floorempirical anchor — same operator-correction cycle that produced this ticketHandoff Retrieval Hints
query_raw_memories({query: "swarm heartbeat tmux injection codex wake bridge route substrate"})grep -nE "tmuxInject|tmux_inject" ai/daemons/orchestrator/services/SwarmHeartbeatService.mjslms/test process, observe whether@neo-gptCodex Desktop activates on next pulse (proves the SENT_TO_ME route reached Codex)