LearnNewsExamplesServices
Frontmatter
id11990
titleSwarmHeartbeatService delivers pulses via tmux injection — Codex Desktop never wakes
stateClosed
labels
bugairegressionarchitecture
assigneesneo-opus-4-7
createdAtMay 25, 2026, 9:49 PM
updatedAtMay 26, 2026, 12:54 AM
githubUrlhttps://github.com/neomjs/neo/issues/11990
authorneo-opus-4-7
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtMay 26, 2026, 12:54 AM

SwarmHeartbeatService delivers pulses via tmux injection — Codex Desktop never wakes

Closedbugairegressionarchitecture
neo-opus-4-7
neo-opus-4-7 commented on May 25, 2026, 9:49 PM

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:

  1. Looks up the recipient's active SENT_TO_ME bridge-daemon subscriptions
  2. Emits a wake event with the same notification envelope shape as add_message would
  3. The bridge daemon picks up the wake event → osascript → recipient app activates

SwarmHeartbeatService.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

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

  • AC1 — SwarmHeartbeatService.tmuxInjectPulsePrompt() method deleted; zero call sites remain (grep -rn "tmuxInjectPulsePrompt" ai test/ returns no matches)
  • AC2 — SwarmHeartbeatService.pulse() Step 7 emits a SENT_TO_ME wake event per pulse target via the bridge-daemon route (Shape A preferred; Shape B acceptable if Shape A plumbing turns out complex)
  • AC3 — Existing tmux env-var (TMUX_SESSION) handling removed from SwarmHeartbeatService; no shell-shape leftovers
  • AC4 — Unit coverage proves: (a) pulse emits a wake event per target with active bridge subscription, (b) targets without active subscription are logged + skipped without error, (c) the wake-event envelope matches the WakeSubscriptionService canonical shape
  • AC5 — Wake-substrate runbook updated: tmux-injection references removed, unified-delivery-via-SENT_TO_ME documented
  • AC6 — Cross-family review per pull-request §6.1
  • AC7 — Post-merge: operator confirms Codex Desktop receives heartbeat-driven activation (kill lms to force a heartbeat cycle, observe Codex wakes per the next pulse if Codex was idle)

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)