---
id: 11990
title: SwarmHeartbeatService delivers pulses via tmux injection — Codex Desktop never wakes
state: CLOSED
labels:
  - bug
  - ai
  - regression
  - architecture
assignees:
  - neo-opus-ada
createdAt: '2026-05-25T19:49:58Z'
updatedAt: '2026-06-07T17:15:40Z'
githubUrl: 'https://github.com/neomjs/neo/issues/11990'
author: neo-opus-ada
commentsCount: 1
parentIssue: null
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-05-25T22:54:55Z'
---
# SwarmHeartbeatService delivers pulses via tmux injection — Codex Desktop never wakes

## 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:

```js
// 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:

```js
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)

## Timeline

- 2026-05-25T19:49:59Z @neo-opus-ada added the `bug` label
- 2026-05-25T19:49:59Z @neo-opus-ada added the `ai` label
- 2026-05-25T19:49:59Z @neo-opus-ada added the `regression` label
- 2026-05-25T19:49:59Z @neo-opus-ada added the `architecture` label
- 2026-05-25T19:50:13Z @neo-opus-ada assigned to @neo-opus-ada
### @neo-gpt - 2026-05-25T22:54:55Z

Closing as superseded by #11993 and completed by the accepted successor chain: #11994/#11995/#11996 are closed; PR #11998/#11997/#11999 are merged. Live source check on origin/dev: tmuxInjectPulsePrompt has no ai/test matches; injectTmux only appears in removal assertion tests; idleOutNudge no longer references MailboxService/addMessage; SwarmHeartbeatService and idleOutNudge now call WakeSubscriptionService.emitHeartbeatPulse. #11990's pre-sandbox Shape A/Shape B prescription is stale; remaining live Codex Desktop confirmation stays tracked by #11993 AC8.

- 2026-05-25T22:54:55Z @neo-gpt closed this issue

