Skip to content

Codex→Claude replies silently lost when Claude is idle (push-only regression of #29; no queue fallback) #223

Description

@tianfei-asfaudits

Codex→Claude replies silently lost when the Claude session is idle (no ack, no queue fallback)

Summary

When Codex replies to Claude, the message is delivered as a fire-and-forget notifications/claude/channel JSON-RPC notification. If the Claude Code session is idle at that moment, the notification is lost. Because the loss is silent — no transport error is thrown — the bridge's fallback queue never fires, so get_messages also returns empty. The reply is unrecoverable: it is neither rendered in-band nor retrievable via pull.

Net effect: Claude→Codex works reliably, Codex→Claude is lossy whenever Claude is idle. require_reply: true does not help — Codex emits the reply, but delivery rides the same unreliable channel.

This is consistent with the known idle-channel variability the agent-bridge README already documents. On this machine it presented at the severe end: 100% loss across three reproductions, with the message not even displayed — see "Environment-specific severity" below.

Environment

  • agentbridge plugin v0.1.30 (commit 55120e8), installed correctly
  • Claude Code 2.1.201, launched with --dangerously-load-development-channels (verified present) + OAuth
  • Codex app-server / TUI 0.142.5
  • macOS (arm64); multi-pair v3 layout; both sides run under a cmux wrapper
  • Daemons healthy; dev-channels flag present; no stale killed sentinel involved

Root cause

src/claude-adapter.ts:240-268 — every Codex→Claude message is delivered exactly one way:

await this.server.notification({ method: "notifications/claude/channel", params: {...} });
this.log(`Pushed notification: ${message.id}`);   // logged on WRITE success
} catch (e) { this.queueFallbackMessage(message); } // only on a transport WRITE error

Three facts combine into the symptom:

  1. It's a JSON-RPC notification — fire-and-forget. The promise resolves the instant the bytes hit the stdio pipe. There is no ack from Claude Code, so "Pushed notification" means "handed to the pipe," not "Claude received/rendered it."
  2. notifications/claude/channel is experimental and idle-unreliable. The project README notes: "channel pushes to a fully idle session have known upstream variability." Two upstream Claude Code issues (both verified on GitHub 2026-07-05 — see References) describe this: #44380 "Channel messages don't wake idle sessions" (OPEN; symptom: displays but not auto-processed) and #61797 "MCP notifications silently dropped when delivered to idle session via --channels" (CLOSED as stale, not fixed; symptom: "not in the pane, not in the transcript" — the closer match to this report). The maintainer's docs (docs/07-…v3.md:80) also record a 2026-06-14 local test that refuted #44380 (idle Claude did auto-respond then), so the accurate framing is "known variability/flakiness" — this machine (CC 2.1.201 + cmux) sits at the severe, 100%-loss end of that variability.
  3. The fallback queue only catches write failures. A silent idle-drop throws no exception → queueFallbackMessage never runs → get_messages stays pending=0. The message is gone with no trace. Messages are also deduped by id (rememberDelivery), so they are never re-pushed, and the previously reliable AGENTBRIDGE_MODE=pull (Claude actively polls get_messages) was removed. Only the budget-resume path has ack+retry (ack_resume); ordinary Codex replies do not.

Why "idle" is the trigger

  • Mid-turn (Claude actively stepping): channel notifications are injected between model steps → works. Normal interactive back-and-forth therefore works.
  • Fully idle (turn ended, or waiting on a backgrounded shell that doesn't keep the agent loop alive): the push is only displayed-or-dropped upstream → silently lost.

Environment-specific severity (possibly a separate, sharper upstream report)

The maintainer's 2026-06-14 test showed idle push sometimes works (and #44380 only claims idle messages "display but aren't auto-processed"). On this machine the failure was stronger on both axes: it was 100% reproducible (repros A/B/C below) and the message did not even display — a harder failure than #44380 describes. That gap suggests an environment-specific aggravator layered on top of the generic upstream variability — most likely Claude Code 2.1.201 and/or the cmux wrapper both sessions run under. This may warrant its own, narrower upstream report: "channel notification to an idle session is dropped entirely (not rendered, not processed)."

Reproductions with instanceId logs (two independent pairs, same machine)

Filed per the request in the closing note of #64: "如在 push-only 架构下复现类似症状,请带 instanceId 日志开新 issue" ("if you reproduce similar symptoms under the push-only architecture, open a new issue with instanceId logs").

In each repro the daemon logs a successful push and then a get_messages call on the same live adapter instance= id returns empty — the reply never surfaces in Claude. All three used force-forward-reply-required (so STATUS buffering is not involved). Timestamps are UTC (2026-07-05T…), abbreviated to HH:MM:SS.mmm.

Repro A — pair main-a5bb7620, pong (len=4), instance 0c430e4d:

04:58:21.783  [AgentBridgeDaemon] Codex → Claude [untagged/force-forward-reply-required] (4 chars)
04:58:21.798  [ClaudeAdapter]     pushNotification (instance=0c430e4d, msgId=msg_08fda719d3b10135016a49e46d96648191825a8c98c3b0201c, len=4)
04:58:21.803  [ClaudeAdapter]     Pushed notification: msg_08fda719d3b10135016a49e46d96648191825a8c98c3b0201c (attempt=codex_msg_8750d1d4502f_5)  ✅
04:58:41.385  [ClaudeAdapter]     get_messages called (instance=0c430e4d, pending=0, bytes=0, dropped=0, oversized=0)   ← same instance, empty

Repro B — pair main-a5bb7620, [IMPORTANT] pong (len=16, require_reply: true), instance b236ca8a:

04:44:28.059  [AgentBridgeDaemon] Codex → Claude [important/force-forward-reply-required] (16 chars)
04:44:28.072  [ClaudeAdapter]     pushNotification (instance=b236ca8a, msgId=msg_04159eaa8e8cd4e5016a49e123ece881918e548470663c27f7, len=16)
04:44:28.078  [ClaudeAdapter]     Pushed notification: msg_04159eaa8e8cd4e5016a49e123ece881918e548470663c27f7 (attempt=codex_msg_12817cfbb682_53)  ✅
04:48:25.989  [ClaudeAdapter]     get_messages called (instance=b236ca8a, pending=0, bytes=0, dropped=0, oversized=0)   ← same instance, empty

Repro C — pair main-8abffcff (a different, independent pair), PONG … (len=27, require_reply: true), instance b169a139:

04:32:35.413  [AgentBridgeDaemon] Codex → Claude [important/force-forward-reply-required] (27 chars)
04:32:35.425  [ClaudeAdapter]     pushNotification (instance=b169a139, msgId=msg_092a0e667253f49a016a49de62ef0c8191a1e4ae0d1f723e1b, len=27)
04:32:35.430  [ClaudeAdapter]     Pushed notification: msg_092a0e667253f49a016a49de62ef0c8191a1e4ae0d1f723e1b (attempt=codex_msg_ba5e44bdfc48_13)  ✅
04:32:57.171  [ClaudeAdapter]     get_messages called (instance=b169a139, pending=0, bytes=0, dropped=0, oversized=0)   ← same instance, empty
04:46:40.363  [ClaudeAdapter]     get_messages called (instance=b169a139, pending=0, bytes=0, dropped=0, oversized=0)   ← again 14 min later, still empty

What these instanceId logs prove — each preempts a likely reviewer objection:

  1. Same-instance drop. The push target and the empty get_messages share one live instance= id in every repro (A 0c430e4d, B b236ca8a, C b169a139) — rules out a stale/dead MCP connection and multi-instance misrouting (get_messages 返回空但消息实际已在队列中 / get_messages returns empty while messages exist in queue #64's candidate root-cause --dangerously-load-development-channels in Claude cannot be used in third-party platform or api-key #1).
  2. Not a buffering artifact. All three carry the force-forward-reply-required tag → the message bypassed STATUS buffering and was meant to deliver immediately.
  3. Never entered the fallback queue. dropped=0, oversized=0 and pending=0 → no transport write error was thrown, so queueFallbackMessage never ran and the message is unrecoverable — the exact push-only gap.
  4. Reproduces on two independent pairs, one of them (main-8abffcff, Repro C) verified by a human with shell access reading the daemon log directly — the push is logged server-side while the reply never reaches the model.

Additionally, status.json for the affected pair at drop time showed the daemon still connected to the Codex app-server (appServerUrl ws://127.0.0.1:4520, turnPhase: idle, TUI attached) — rules out a bridge disconnect. Together these place the drop client-side, after a successful push — not a bridge or connection failure.

Raw excerpts live in ~/Library/Application Support/AgentBridge/pairs/{main-a5bb7620,main-8abffcff}/agentbridge.log.

Relationship to prior (closed) issues — we read #29 and #64

This is not a fresh discovery; it is a regression of an already-reported-and-closed bug. We read the full threads of both:

The regression, stated plainly: the pull-default fallback that closed #29 (PR #61) was later deleted by 6b1e948 to make delivery push-only — because pull "could not wake an idle session, which silently broke the budget RESUME chain" (src/claude-adapter.ts:7-8). That architectural trade re-opened #29's exact failure mode for ordinary Codex→Claude messages, but with no pull fallback left to recover them. #29 still reads as "fixed" and #64 as "superseded," so no open issue currently tracks this — which is why it looks unreported. Related earlier duplicates in the same family (all closed): #18, #32.

Impact

  • Any workflow where Claude dispatches to Codex and then waits (ending its turn, or backgrounding the wait) can silently lose Codex's reply.
  • No recovery path exists once dropped: not re-pushed (dedup), not queued (no write error), pull mode removed.
  • Misleading signal: get_messages pending=0 reads as "nothing to fetch / delivered fine," when the message may have been lost.

Proposed fix

Extend the ack+retry+queue-fallback treatment (currently resume-only, via ack_resume) to all Codex→Claude messages:

  1. Require an ack for channel deliveries. Treat notifications/claude/channel as needing acknowledgement; if no ack arrives within a short timeout, consider the push undelivered. (Note: an idle Claude that drops the notification will also not send the ack — that's fine; the absence of an ack is exactly the detection signal. --dangerously-load-development-channels in Claude cannot be used in third-party platform or api-key #1 is just the primitive that makes feat: add dual-mode message transport (Channel Push + Tool Pull) #2 fire.)
  2. Queue-on-no-ack. On timeout (not just on transport write error), call queueFallbackMessage(message) so get_messages can retrieve it. This is the single highest-value change — it turns a silent loss into a recoverable pull.
  3. Re-enable a pull safety net. Since dedup-by-id already prevents double-processing, allow get_messages to drain unacked messages (optionally reinstate an opt-in AGENTBRIDGE_MODE=pull for idle-heavy setups). Real robustness comes from feat: add dual-mode message transport (Channel Push + Tool Pull) #2 + feat: add smart message filtering with marker protocol (v1.1) #3; --dangerously-load-development-channels in Claude cannot be used in third-party platform or api-key #1 alone does nothing without them.
  4. Docs: clarify that "Pushed notification" is a write-success log, not a delivery confirmation, and that pending=0 does not imply successful delivery.

A fully complete cure requires an upstream Claude Code change (reliable processing of idle channel messages), but (1)–(3) make the bridge robust against the current upstream behavior without waiting on it — and do not depend on whether #44380/#61797 are ever fixed.

Expected behaviour

A Codex reply produced while Claude is idle should either be delivered in-band or remain retrievable via get_messages — it must never be silently lost (i.e. no case where the daemon logs Pushed ✅ yet get_messages returns pending=0 forever).

Implementation specifics are the maintainer's call, not prescribed here — e.g. the ack transport and timeout, whether to reinstate an opt-in AGENTBRIDGE_MODE=pull vs. an internal no-ack queue, and how any of this coexists with the resume-wake requirement that motivated the push-only move (6b1e948).

Workarounds today

  • Keep the receiver active/foreground while awaiting Codex (mid-turn injection works); do not end the turn or background the wait.
  • If a reply is dropped, it is unrecoverable via the bridge — the human must relay it, or Claude must re-trigger Codex once active. Tailing the per-pair agentbridge.log (Codex → Claude … (N chars)) is a usable observability workaround but not real in-band delivery.

References

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions