Skip to content

fix(native-chat): a message whose delivery is in doubt no longer holds every later send - #25028

Closed
brennanb2025 wants to merge 25 commits into
brennanb2025/chat-start-failure-on-messagefrom
brennanb2025/in-doubt-message-no-barrier
Closed

brennanb2025 wants to merge 25 commits into
brennanb2025/chat-start-failure-on-messagefrom
brennanb2025/in-doubt-message-no-barrier

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 12 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​774 $\color{#cf222e}{\Huge{\mathbf{−}}}$​175 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​599
Prod 3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​26 $\color{#cf222e}{\Huge{\mathbf{−}}}$​23 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​3

Stacked on #24340 (base branch brennanb2025/chat-start-failure-on-message). Retarget to main once #24340 lands, and before this is marked ready. This branch has #24340's current head merged in, so its diff against its base shows only this PR's changes: three production files in src/shared/ (structured-agent-session-outbox-reconcile.ts, structured-agent-session-send-disposition.ts, and a comment in structured-agent-session-outbox-admission.ts), plus tests.

ELI5

You press Stop on a Codex chat while a follow-up message is still on its way. Codex doesn't take the Stop, so Orca ends the Codex process to stop it. Nobody can now say whether Codex read that follow-up. Today the chat then refuses to send anything else: every message you type after it waits forever, even after you quit and reopen Orca, and Retry doesn't free it.

After this change, that message stays in the chat as a normal message, and whatever you send next goes out.

What Changed

The problem: a message the host recorded as in doubt freezes every later send.

  • When a Stop's interrupt is refused or never answered, Orca ends the Codex process (fix(native-chat): a Codex Stop that Codex refuses or never answers ends the Codex process #24334). A follow-up steered into the turn that Codex hadn't echoed yet is recorded by the host as "in doubt" (unknown): Codex may have read it, or not.
  • The desktop keeps its own copy of each message it sends (its outbox) until the host settles it. On main, a copy whose message the host recorded in doubt is kept and marked "unconfirmed" (structured-agent-session-outbox-reconcile.ts, the unknown branch), and admission treats an "unconfirmed" copy as a barrier (structured-agent-session-outbox-admission.ts): nothing behind it is sent.
  • Nothing ever confirms it once the Codex process is gone. The message reads "Message delivery is unconfirmed." with a Retry; Retry resends the same id, the host replays its record (still in doubt), and the copy stays a barrier.
  • Main's own test pins this: NativeChatStructuredSessionDelivery.probe.test.tsx, "parks a host-confirmed unknown instead of probing it", whose message "must stay wedged behind the parked head".
  • The outbox is saved, so the freeze comes back after quitting and reopening Orca.

What you see now.

  • A message the host has recorded as in doubt is drawn from the host's record as a plain message: no warning line, no Retry. Messages after it go out as you send them. This also covers a message recorded in doubt mid-turn.
  • A chat that is already stuck this way frees itself the next time it opens.
  • Unchanged: a message the host may never have received (a send that timed out, a connection that dropped before the host answered) still holds the messages behind it, reads "Message delivery is unconfirmed." with Retry, and is re-sent automatically under the same id until the host answers.

The mechanism. The desktop lets go of its copy once the host records the message in doubt, the same way it already lets go once the host records it delivered or rejected:

  • when the host's history (its journal) shows the message as in doubt (reconcileStructuredAgentSessionOutbox);
  • when the host's answer to a send, or to the automatic re-send, says it is in doubt (disposeStructuredAgentSessionSendResult). That answer is also what frees a message whose record is older than the loaded page of history.

Two exceptions keep the copy: a Retry a person already asked of that very record waits for its answer, and a replay saying the host lost the message's record (durable_send_submission_missing) leaves the copy as the only thing showing the message, as before.

What this PR no longer does. Earlier versions also drew messages rejected after hand-over from the host's record on every desktop, remembered the id a Retry replaced, and threaded the whole outbox into the transcript for that. #24710, now on main, draws every message the host rejected where it was rejected, in the host's words, and gives such a message no Retry, so none of that is needed and it is removed. Which rejected message has a Retry is now only #24340's rule: a failed start no agent took, on a host that advertises retry in place.

Why

  • Why not change the host's verdict instead, writing the follow-up off as "withdrawn"? That would be false: the follow-up was handed to a running Codex turn, and Codex may have read it. It would also make older desktops and phones read it as "not sent", with a Retry that could send it twice. The host's verdict is honest; only the desktop's reaction to it was wrong.
  • Why let go of the copy rather than keep it and skip it in the queue? A kept copy is a hold with no way to end. The desktop judged it against only the history it had loaded, so once the record scrolled out of that window a reopen froze the queue again; it kept a hidden chat's history stream open (a live connection for a remote host); and it flashed the warning on every reopen. The host's record already draws the message, so nothing is lost.

Differences from the common pattern

  • Temporary: a message the host may never have received is marked with the red "Message delivery is unconfirmed." line and a Retry, while Orca re-sends it under the same id and holds the messages behind it. This PR doesn't change that surface. Follow-up: a quiet pending marker that never shows before the chat's history has loaded (STA-9327).

Linked Issue

No issue. Found while working on #24864 (a Stop binds only the turn it actually stopped): a Codex Stop whose interrupt fails while a steered follow-up is unanswered leaves the chat unable to send.

Visual Proof

Live QA on 2026-10-05 ran the same steps on main (e2da3a1), on this PR's current head (16ca69f) and on #24340 alone (e477d91, the base this PR stacks on). It used a hidden dev build on a second Mac with an isolated profile, and Grok drove it. In each run a Codex turn runs, a follow-up is steered into it and not echoed, Stop is pressed and the interrupt is refused, then two more messages are sent and the app is relaunched. Real Codex won't refuse an interrupt on demand, so the run used a stand-in Codex that refuses it, as real Codex can. The stand-in keeps turn and thread ids unique, as real Codex does. An earlier stand-in reused turn ids, which drew a spurious "Worked for 0s" and an empty "Subagent" row. That came from the stand-in, not from Orca.

Before (main): stuck.

main after Stop: the follow-up says delivery is unconfirmed, with Retry
main, after Stop: the follow-up reads "Message delivery is unconfirmed." with Retry.

main: later messages stuck on Sending
main: "after stop 1" and "after stop 2" stay on "Sending…" and never reach Codex, and the composer still offers Stop.

main after relaunch: still stuck
main, after a relaunch on the same profile: still stuck, with the notice, Retry and both "Sending…" messages.

#24340 alone: later messages stuck on Sending
#24340 without this PR freezes the same way, so the fix is this PR's.

After (this PR): fixed.

this PR after Stop: the follow-up is a plain message
This PR, after Stop: the follow-up is a plain message, with no notice and no Retry.

this PR: later messages go out and the chat reads Working
This PR: both later messages go to a new Codex process, and the chat and sidebar read Working. Nothing is duplicated.

this PR after relaunch: a resume prompt over a clean chat
This PR, after a relaunch: the chat is clean, with no notice and no Retry. The "Resume interrupted chats?" dialog on top is Orca's normal offer, because the stand-in's turn was still open when the app quit.

Testing

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

  • The outbox: a message recorded in doubt leaves the outbox when its record arrives (on its way, already unconfirmed, or retried before the record was seen) and when the host's answer or replay says so; the lost-record replay stays; a Retry of that same record stays for its answer; a message the host may not have still holds the queue until the host answers it.

  • Reopen without the record in view: a reopen whose loaded history no longer reaches the record does not hold the next send; a desktop that never loaded the record is freed by the automatic re-send's replay; a saved queue stuck behind a recorded message frees on open and the held message is never sent again.

  • Host-level repro against the real host, journal and Codex adapter with a fake Codex: the interrupt fails (-32603 or unanswered), Orca ends the process, the follow-up is unknown, and the next message goes to a fresh Codex; -32600 "no active turn" keeps the child; a first close that doesn't prove the exit keeps the follow-up pending until the next send proves the stop.

  • Which rejected message has a Retry, stated by test titles (structured-agent-session-delivery-notices.rejected-retry.test.ts): "a message the host recorded and then rejected is drawn in place with the host's words and no Retry, even on a host that retries in place"; "a failed start no agent took gets Retry in place when the host advertises retry-message", and none when it doesn't.

  • Ablation: with the unknown-drop removed from the reconcile and the send answer, 17 tests fail across the four freeze test files, including the probe test "never probes a host-confirmed unknown, and sends what follows it".

  • Existing tests changed only where the unknown-drop changes the outcome: main's probe test (the parked unknown now sends what follows), the reopened mid-send Delivery test, the in-doubt delivery-notice cases (now no notice), the outbox reconcile, retry-hold and admission tests, and the outbox hook's unknown-head tests.

  • 468 desktop/host test files (5,234 tests) and 34 phone test files pass; tsc for node, web, cli and the phone app and the phone's test ratchet pass; full-file oxlint, anti-slop, the changed-lines quality gate and the max-lines ratchet pass.

Review

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Mixed versions: no wire change, and nothing the host publishes changes. A new desktop on an older host works the same way (older hosts record the same unknown rows and answers). An older desktop keeps today's behaviour: the queue is held until Retry, which is safe but still stuck. The phone doesn't keep an outbox, so it is unaffected.
  • SSH and remote hosts: a copy leaves only when the host's record or answer shows the host recorded the message, never because the host stopped answering. A lost connection still leaves the message holding the queue, as before. Letting go of the copy also stops a hidden chat from keeping a remote history stream open for it.
  • Folder workspaces: not affected.
  • Known limits:
    • If the host lost a message's record (it replays durable_send_submission_missing), the copy stays and still holds the queue, as on main.
    • A Claude message recorded in doubt and later withdrawn by a Stop, after this desktop let its copy go, stays hidden like any withdrawn message; its text is not put back in the composer.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

…-message-no-barrier

# Conflicts:
#	src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx
…one never delivered shows from its journal row
…ections stay visible in their own words, and the phone keeps hiding them
…ered, ended the child or not, or proved its exit late, never holds the next message
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Live QA: stuck on main, fixed on this PR

On main, Stop after Codex refuses the interrupt marks the in-flight follow-up "Message delivery is unconfirmed" with Retry, and later messages never reach Codex. On this PR the follow-up stays a plain message, and both later messages are delivered to a new Codex process. Same stub, same steps. Main 786a040b, this PR 221a168c.

Main: the turn is running
Main. "first: work for a while" is running.

Main: follow-up steered into the running turn
Main. The follow-up is steered in. The stub never echoes it.

Main: follow-up unconfirmed after Stop
Main, after Stop. The follow-up says "Message delivery is unconfirmed" and shows Retry.

Main: later messages never go out
Main. "after stop 1" and "after stop 2" sit in the chat. The stub log has no new turn.

Main: still stuck after relaunch
Main, same profile reopened. The notice, Retry, and both later messages are still there. No warning toast flashed.

PR: the turn is running
This PR. The same first message is running.

PR: follow-up steered into the running turn
This PR. The follow-up is steered in, with no echo from the stub.

PR: follow-up stays plain after Stop
This PR, after Stop. The follow-up is a plain message. No notice and no Retry. Stop still ends the process.

PR: later messages are delivered
This PR. Both later messages reached a new Codex process as turn/start. A collapsed Subagent row sits under the first of them; opening it was empty, and each user message appears once.

PR: still clear after relaunch
This PR, same profile reopened. No unconfirmed notice, no Retry, and no warning flash.

…on-message' into brennanb2025/in-doubt-message-no-barrier

# Conflicts:
#	src/shared/agent-session-refusal-notice.ts
#	src/shared/agent-session-refusal-reason-words.ts
…on-message' into brennanb2025/in-doubt-message-no-barrier

# Conflicts:
#	mobile/src/session/use-mobile-structured-agent-session.ts
#	src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx
#	src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts
#	src/renderer/src/components/native-chat/structured-agent-session-failed-start-elsewhere.test.ts
#	src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts
#	src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx
#	src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts
#	src/renderer/src/components/native-chat/use-structured-agent-session-messages.ts
#	src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx
#	src/renderer/src/components/native-chat/use-structured-agent-session.ts
#	src/shared/structured-agent-session-message-projection.ts
#	src/shared/structured-agent-session-outbox.ts
…on-message' into brennanb2025/in-doubt-message-no-barrier
…ded in doubt

#24710 now draws every message the host rejected where it was rejected, in the host's words, so
the journal-drawn not-delivered row, its outbox exclusion and the Retry rotation record go. Tests
state which rejected message has a Retry: only a failed start no agent took, on a host that
retries in place.
…on-message' into brennanb2025/in-doubt-message-no-barrier
…on-message' into brennanb2025/in-doubt-message-no-barrier
…on-message' into brennanb2025/in-doubt-message-no-barrier
…on-message' into brennanb2025/in-doubt-message-no-barrier
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Closing in favour of #25959.

The problem this PR fixed: on the desktop, a message whose delivery was in doubt (the host answered "unknown", or the host recorded it as unknown) sat in the saved outbox and held every later message behind it. The chat looked frozen until the person acted on that message, and the freeze survived reopening the chat.

Why #25959 replaces it: #25959 removes the desktop's saved outbox altogether. Messages go one at a time, and the host owns what it accepted. With no outbox there is nothing for an in-doubt message to hold up, and nothing to freeze across a reopen.

  • Its tests cover both freeze paths: "never probes a host-recorded unknown, and sends what follows it" and "sends the next at once when the host can neither confirm nor deny the one ahead". It also has "never holds a later send behind one nobody answered" and "holds the chat no longer than the deadline of a send nobody answers".
  • With the fix removed, those tests fail: 3 to 5 for an in-doubt send that is never given back, and 2 for an answer that proves nothing being resent forever.
  • Phone: it never had this outbox. Each message goes straight to the host, and an unknown outcome is held in memory for 20 s for that one message only, so it doesn't hold later messages back.

Follow-up, separate from the freeze: if the host keeps a message as unknown indefinitely, the phone keeps that message's id, so typing the exact same text again in that chat keeps answering "Delivery unconfirmed". This is being tracked with the send-layer work.

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