Skip to content

fix(codex): correlate dispatch identity on the echoed client message id - #19331

Closed
brennanb2025 wants to merge 5 commits into
mainfrom
brennanb2025/fix-codex-rapid-send
Closed

brennanb2025 wants to merge 5 commits into
mainfrom
brennanb2025/fix-codex-rapid-send

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 9 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​586 $\color{#cf222e}{\Huge{\mathbf{−}}}$​78 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​508
Prod 10 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​385 $\color{#cf222e}{\Huge{\mathbf{−}}}$​25 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​360

The defect

A turn/start issued while a Codex turn is running is coalesced into that turn: the app-server
returns the same turn id, emits no second turn/started, and both user messages become items of
one turn at ordinals 0 and 2.

dispatchCodexTurn ignored this and stamped ordinal: 0 on every accepted send, under a comment
asserting "Codex records the user message first in a turn" — false for a coalesced send. So the
second submission adopted the first message's identity, applyDispatch re-pointed that alias to it,
and the second message's true identity was owned by nobody. On any thread restore — the 15s eviction
re-attach or an app restart — its history echo failed both alias and fingerprint reconciliation and
was appended as a fresh bubble: the prompt renders twice, and the copy has no reply.

resolveDispatch's own docstring predicted it: a mismatched identity "would silently give the user
a second copy of their own message."

The fix

Both legs of the correlation already flowed — Orca sends clientUserMessageId on every
turn/start, and the echo comes back carrying it. The identity was computed one line above the
point where it was discarded. This stops discarding it.

That is also how the sibling provider already works: Claude's adapter never fabricates an identity,
it correlates the provider's echo on a client-minted id and settles late through
settleStructuredAgentSessionLateDispatch, which was already provider-generic. Codex was the odd
one out. No change to AgentSessionDispatchOutcome, no change to shared journal code, no wire or
schema change — only the Codex adapter moves.

Classifying the send

const coalesced =
  turnId !== null && !started.startedNotificationObserved && session.activeTurnIds.has(turnId)

An observed turn/started overrides the in-flight check, because on builds that ack before naming
the turn, a fresh turn's own notification lands first and would otherwise read as coalesced.

Two windows, from measurement

Echo latency measured on codex-cli 0.153.4: +1.73s for a fresh turn, +6.15s for a coalesced
send, which waits for the running turn to yield. An in-flight dispatch renders as dispatching,
while unknown renders as "Message delivery is unconfirmed."

  • Coalesced — full request window. Honest dispatching meanwhile; degrading after ~1s would
    tell the user a delivered message was unconfirmed.
  • Fresh — short grace, then the positional ordinal-0 fallback that is provably correct there.
    The common path gains no latency.
  • Once a session has seen any clientId echo, the positional fallback is disabled permanently, so
    a misclassified send degrades to unknown plus late settlement rather than to corruption.

Tests

The repro drives the real dispatchCodexTurn against a host that answers exactly as the probed
app-server does — same turn id for both starts, echoes at ordinals 0 and 2 — into the real journal,
then replays restore. It was written failing (three bubbles for two prompts) and its assertions are
unchanged; the fix turns it green through the mechanism. Plus 9 unit tests for the waiter registry
and an end-to-end coalesced-ordinal case through a real translator.

Not included

Journals already corrupted are not repaired. The reconciler that could do it
(reconcileSubmissions) has no production caller today; wiring it into restore is a separate
change.

Merge Sim added 2 commits September 7, 2026 11:45
Codex coalesces a turn/start issued while a turn is running into that
turn: same turn id back, no second turn/started. dispatchCodexTurn
stamped every accepted send ordinal 0, so a rapid second send adopted
the first message's identity and duplicated as a second bubble on any
restore.

Both correlation legs already flowed — Orca sends clientUserMessageId
and the userMessage echo carries it back as clientId — but the echo was
discarded. A per-session echo-waiter registry (mirroring the Claude
adapter's dispatch waiters) now settles each dispatch on its own echo,
with the identity computed by the translator's CodexTurnOrdinals so the
settled key matches history replay exactly.

Dispatch windows, per live measurement on codex-cli 0.153.4:
- coalesced send (response names a turn already in flight): wait the
  full request timeout — the echo defers until the running turn yields
  (+6.15s measured), and the outbox honestly shows 'dispatching'.
- fresh turn: short ack grace (echo +1.73s measured); on expiry with a
  sole waiter and no clientId echo ever seen, keep today's positional
  ordinal-0 fallback for old builds, which is provably correct there.
- anything else: unknown, and a late echo upgrades the submission via
  onDispatchSettledLate -> settleLateDispatch.
@coderabbitai

coderabbitai Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

Next included review available in 4 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Team

Run ID: 0f8dbec1-6da8-4649-a125-b2af29817de3

📥 Commits

Reviewing files that changed from the base of the PR and between 5ea7e6c and 448b423.

📒 Files selected for processing (4)
  • src/main/codex/codex-structured-session-acquire.ts
  • src/main/codex/codex-structured-session-adapter.ts
  • src/main/codex/codex-structured-session-cancel.test.ts
  • src/main/codex/codex-structured-session-state.ts

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Team

Run ID: 20ad898a-a0fe-493b-8e67-b9dff03ba69d

📥 Commits

Reviewing files that changed from the base of the PR and between 3a72820 and 5ea7e6c.

📒 Files selected for processing (5)
  • src/main/codex/codex-structured-dispatch-echo.test.ts
  • src/main/codex/codex-structured-dispatch-echo.ts
  • src/main/codex/codex-structured-session-adapter.test.ts
  • src/main/codex/codex-structured-turn-start.ts
  • src/main/codex/fake-codex-app-server.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Adds Codex echo waiters that correlate turn/start dispatches with echoed userMessage items. The implementation handles client IDs, timeouts, retired waiters, late settlements, thread and provider validation, and session closure. It tracks active turns to distinguish coalesced sends and applies conditional positional fallback. Session adapters and runtimes wire echo settlement and late-dispatch callbacks. Tests cover registry behavior, dispatch races, coalesced turns, replay integrity, and runtime notification handling.

Merge Risk: ⚪ Minimal · up to 5ea7e

The correlation, lifecycle settlement, and coalesced-turn handling changes have no unresolved material merge risk.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the defect, fix, scope, and tests, but it omits most required template sections. It has no linked issue, visual-proof statement, testing checklist, AI disclosure, review secti… Complete the repository template. Add an issue reference under Linked Issue, state N/A with a reason under Visual Proof, complete the Testing checkboxes and list tested platforms, provide the required AI Disclosure, Review, Agent skill upst…
Docstring Coverage ⚠️ Warning Docstring coverage is 30.30% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 33 functions across 20 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: correlating Codex dispatch identity using the echoed client message ID.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description explains the defect, fix, scope, and tests, but it omits most required template sections. It has no linked issue, visual-proof statement, testing checklist, AI disclosure, review section, boundary checklist, notes, or final checklist.

Resolution

Complete the repository template. Add an issue reference under Linked Issue, state N/A with a reason under Visual Proof, complete the Testing checkboxes and list tested platforms, provide the required AI Disclosure, Review, Agent skill upstream boundary, Notes, and Checklist sections. Add the ELI5, What Changed, and Why headings or map the existing content to those sections.

✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

This PR replaces dispatchCodexTurn's blanket ordinal: 0 stamp with provider-echo correlation, so a send coalesced into a running Codex turn adopts its true ordinal instead of the first message's identity — the source of the duplicate-prompt bubble on thread restore.

  • Echo waiter registry (codex-structured-dispatch-echo.ts) — a dispatched send registers a waiter keyed on clientMessageId; the live userMessage echo settles it with the translator-projected identity, with retired-waiter and null-clientId (old-build) reconciliation.
  • Coalesced-send classification (codex-structured-turn-start.ts) — startCodexTurn now reports startedNotificationObserved, and a response naming a turn already in activeTurnIds (with no own turn/started) is treated as coalesced, earning the full echo window rather than the ordinal-0 fallback.
  • Active-turn tracking (codex-structured-provider-events.ts) — turn/started/turn/completed maintain session.activeTurnIds, recorded before cancellation or admission consumes a frame.
  • Late settlement wiring — expired/dispatched echoes settle through the existing provider-generic settleStructuredAgentSessionLateDispatch, mirrored from the Claude adapter.
  • Regression coverage — a real-journal + restore repro (journal-rapid-send-turn-coalescing.test.ts) that fails before the fix, plus unit tests for the waiter registry and updated adapter/replay/runtime fixtures.

The two-window design, sawClientIdEcho latch, positional-fallback gating, and late-settlement interplay all check out against the shared journal reducer (which ignores repeat accepted/rejected dispatches) and the existing Claude correlation it mirrors.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review completed on c960a46386 with focused fixes pushed in 5ea7e6c4c2 and c960a46386.

  • Prevented duplicate or late events without a client message ID from acquiring another submission's identity.
  • Required fresh-turn notification evidence before the legacy ordinal-zero fallback, including notifications arriving during the acknowledgement grace period.
  • Kept completed turn IDs from being resurrected by late responses and disposed notification waiters/timers after dispatch.
  • Added regression coverage and corrected the cancellation fixture to emit real fresh-turn notifications.

The same-model review/fix/re-review loop and initial/final readiness audits found no remaining proven production issues. Validation: 125 tests passed across 14 suites, node typecheck passed with an 8 GB heap, and changed-file lint/format checks passed. Five regression checks failed against the original production code and passed with the fixes.

Compatibility limit: providers without echoed client IDs leave ambiguous sends unconfirmed; subsequent ID-less sends may stay unconfirmed for that acquired session. Existing corrupted journals are not repaired by this PR.

Rendered Electron validation and screenshot evidence remain with the coordinator's Grok QA agent; this comment does not claim that QA passed or that the PR is ready to merge. CI for the latest push must also complete.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

One existing test now fails: the fresh-turn-evidence fence breaks codex-structured-session-cancel.test.ts. See the inline comment.

Reviewed changes

This delta fences the positional ordinal-0 fallback behind positive fresh-turn evidence rather than the previous !coalesced negation.

  • Lazy freshness getter + dispose() — startCodexTurn now returns a startedNotificationObserved getter (re-evaluated after the echo grace, so a late turn/started still counts) and a dispose() that clears the notification timer and unregisters the turn-id waiter.
  • Active-turn membership moves to notifications only — dispatchCodexTurn no longer adds turnId to activeTurnIds; trackCodexActiveTurn owns it on turn/started/turn/completed, so a completed turn is not resurrected by a late turn/start response.
  • retired.length === 0 joins the fallback guard — a send only gets the ordinal-0 fallback when no waiter has ever retired, so a resumed/ambiguous send degrades to unknown instead of corruption.
  • ID-less echo reconciliation removed — resolveCodexUserMessageEcho no longer settles a sole candidate or retired waiter on a clientId === null frame, since such frames can belong to an earlier send.

The registry and dispatchCodexTurn tests still pass locally; the one regression is in the cancel-suite fixture, which was not updated to emit turn/started.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

This delta fixes the failing cancel-suite fixture flagged in the prior review, no production code changes.

  • Emit turn/started in the resend fixture — codex-structured-session-cancel.test.ts's turn/start route now fires the fresh-turn notification with the incremented turn id, so the positional fallback fences correctly and the immediate-resend dispatch asserts accepted with turn-2 (suite is green again).
  • Asserted the initial dispatch — the first client-1 dispatch is now pinned to accepted { turnId: 'turn-1' } instead of being left unasserted.

The cancel, close, adapter, dispatch-echo, and rapid-send tests all pass locally.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Electron QA — rendered validation on c960a46386 (macOS)

Ran by the coordinator rather than the Grok QA agent: that agent could not start, its terminal
reporting "Weekly limit left: 0%" (account out of weekly quota). Substituting the runner rather
than skipping the step.

Setup

Fresh dev instance from a child worktree at c960a46386, structured native chat enabled
(experimentalNativeChat, experimentalStructuredNativeChat, openAgentTabsInChatByDefault),
renderer reloaded after the flags. Host answered agentSession.createSupport → {supported: true}.

1. Two prompts sent in quick succession

Second prompt submitted ~1.2s after the first, while the first turn was still answering — the
coalesced case this PR is about. Both replied within 5s. No "Message delivery is unconfirmed"
banner appeared
, which was the specific regression risk in giving coalesced sends a longer echo
window.

2. Full app restart, then re-open the chat — the money shot

Dev instance killed by PID and relaunched, session restored from the journal.

Transcript bubbles counted structurally (scoped to the message-bubble container, not raw page text,
so sidebar previews don't inflate the count):

before restart after restart
Reply with exactly: RAPID_ONE 1 1
Reply with exactly: RAPID_TWO 1 1
total bubbles 2 2

Pre-fix this is exactly where the second prompt appeared a second time, with no reply attached.

after restart

Two user bubbles right, two matching replies left, after restore. ("Provider exited: recorded pid
absent on host" is expected — the restart killed the provider child and the transcript came back
from the journal.)

Not claimed

No before/after screenshot pair: the "before" would need a dev build of the pre-fix commit. The
red-then-green repro test (journal-rapid-send-turn-coalescing.test.ts, three bubbles for two
prompts without the fix) is the evidence for that direction, not this screenshot.

Independent gates on this head

pnpm tc clean · 1063 test files / 10,646 tests passed, 0 failed (codex, native-chat, claude,
runtime) · changed-file quality gate 0 findings · CI 17 pass / 0 fail.

Main landed the same turn-tracking independently for rewind-busy detection, so
this drops our duplicate tracker and reads its set. The auto-merge had also
produced two activeTurnIds declarations and two initialisers.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

This delta merges the Codex rewind/fork feature from main and reconciles activeTurnIds ownership with the dispatch-echo work.

  • Consolidated activeTurnIds ownership — this PR's trackCodexActiveTurn helper was removed and main's observeCodexRewindActivity now owns the turn/started add / turn/completed delete, so the coalesced-send classifier and rewind's busy-fence share one source of truth at the same translateNotification call site.
  • Added a dispatchPending fence — dispatch() sets/clears a dispatchPending flag around captureBaseline + dispatchCodexTurn; rewind treats an in-flight dispatch as busy.
  • Adopted the rewind feature wiring — CodexSession gained the rewind-required fields (threadId, historyPath, historyMode, dispatchPending, prompts).

Verified the two active-turn trackers are semantically identical (same thread/turn-id guards, same call site), and that tc:node typecheck plus the codex dispatch-echo/adapter/cancel/close/options/rewind suites, the rapid-send E2E, and the runtime integration/replay/exit suites are all green.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Superseded by #20138, which derives the Codex dispatch identity from the echoed user message.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant