Skip to content

fix(native-chat): after a crash, show an unconfirmed message as sent with one chat notice instead of guessing from the transcript - #23604

Closed
brennanb2025 wants to merge 56 commits into
mainfrom
brennanb2025/sta-7416-delivery-by-message-id
Closed

brennanb2025 wants to merge 56 commits into
mainfrom
brennanb2025/sta-7416-delivery-by-message-id

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 44 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1804 $\color{#cf222e}{\Huge{\mathbf{−}}}$​1810 $\color{#cf222e}{\Huge{\mathbf{−}}}$​6
Prod 75 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​737 $\color{#cf222e}{\Huge{\mathbf{−}}}$​1216 $\color{#cf222e}{\Huge{\mathbf{−}}}$​479

What this fixes (scope)

  • STA-7416: after Orca or Claude crashed with a message in flight, Orca could decide a message Claude had really received was "not delivered". It hid the message, and Retry sent Claude a second copy.
  • STA-7453: the same wrong "not delivered" for a real prompt whose text starts like harness output, for example <system-reminder>.
  • STA-7434: every time a Claude chat attached its agent, Orca read and parsed the chat's whole transcript file, even when nothing needed checking.
  • Reopen deadlock (found in QA while fixing the above): after a crash, a message Orca could not confirm showed "Message delivery is unconfirmed." with Retry, and every message typed after it waited behind it and never went out, across relaunches. The only way out was Retry, which gave up on a message Claude usually already had.
  • Who hits these: people using structured (native) agent chat, only after Orca or the agent process died while a message was in flight. The wrong "not delivered" and the transcript parsing were Claude chats only. The reopen deadlock hit Claude chats (seen live) and, from the code, Codex chats too (not run live).
  • Not affected: agents running in terminal panes, and any chat where nothing was in flight at the crash.
  • Where they came from: the wrong "not delivered" and the per-attach transcript parse were introduced by feat(native-chat): decide a restart-stranded send against provider history #20139, which added the after-the-fact transcript check (verified from its diff and commit message). The reopen deadlock appeared when fix(native-chat): the conversation outlives its agent #22835 stopped opening a chat from starting its agent: the transcript check only ran when an agent started, so a reopened chat could no longer clear its doubt, and the waiting queue held back the one send that would have (verified by reading both changes; not run on a build from before fix(native-chat): the conversation outlives its agent #22835).

ELI5

If Orca or the agent crashes right after you send a message, nobody can know for sure whether the agent got it. Orca used to guess by searching the agent's saved conversation for your message, and the guess was sometimes wrong. Now Orca stops guessing: your message stays in the chat as sent, and one line in the chat says what happened and that you can send a message to continue.

What Changed

Before, after a crash with a message in flight:

  • The message showed "Message delivery is unconfirmed." with a Retry button, and everything you typed next queued behind it and never went out.
  • Or Orca decided the message was "not delivered" and hid it, even though Claude had it, and Retry sent it twice.
  • A reply cut off because the agent process died sometimes had nothing explaining why.

After:

  • The message stays in the conversation as an ordinary sent message, on desktop, the paired web client and the phone, including desktop and phone builds released before this change when they talk to a host with it. There is no per-message Retry or "unconfirmed" note, and the next message you type goes out at once.
  • A crash or a dead agent gets exactly one plain notice, written by the host so every client sees it, with no button:
    • Orca crashed or restarted: "Claude's session didn't survive the restart. Send a message to continue."
    • The agent process died while Orca kept running: the existing "Claude stopped while this response was in progress. You can continue in this conversation."
    • Codex chats name Codex.
  • Closing the tab, quitting Orca, a turn ending, or Stop while a message is unconfirmed adds no notice. The message stays drawn as sent and the next message continues the chat.
  • A message the agent was handed but never answered no longer keeps the chat "working" forever. It stops counting as unfinished work, and is drawn as sent with no notice, as soon as one of these happens: the agent says its turn is over (Claude reports that it is idle; Codex completes the turn, including a turn that fails after an error Codex will not retry), the agent process exits or the chat is closed, the attempt to hand it over fails with an unknown outcome, or you press Stop. Stop always works and always clears it. If the agent later confirms it received the message, that confirmation still replaces the doubt.
  • The background idle sweep still never stops an agent while a message is genuinely waiting on it (handed over, with none of the above having happened yet). Before this PR the sweep stopped the agent after 30 idle minutes regardless; the previous revision of this PR made it wait, and a message nothing would ever answer could then hold the agent until the tab was closed. Every such message now has a point where it is settled, so the sweep waits only while something can still answer it.
  • A failure notice stays visible when it is how the turn ended: it is the turn's last failure and nothing the agent answered comes after it. A failure the agent answered past, or followed by a later failure, folds with the rest of the work under "Worked for Ns". The crash and "stopped" notices come after the cut-off reply and before your next message, so they stay visible.
  • The sidebar, orca worktree ps and the phone now list a chat whose only message a crash cut off, as idle, instead of showing nothing for it.

How it works:

  • When a chat is first opened after a restart, or its agent dies, or Orca closes it, the host marks each message the old agent never acknowledged as settled-in-doubt (the existing unknown state with its recovered marker; nothing is ever re-sent). A crash or a dead agent also gets its one notice row. One function decides which row, so one event never produces two, including when proof that the old agent died arrives after the chat was already opened; reopening the chat again adds nothing. Which sentence it shows depends on what happened, never on timing: the record of how the agent's death was learned says it. An exit that a running Orca watched gets "stopped while this response was in progress"; a death found by checking the process after Orca itself went away (a crash or restart) gets "didn't survive the restart", whether that check ran before or after the chat was reopened.
  • Clients decide how to draw a message from one shared function that classifies it as still open, sent, or refused. A message still open (the agent is alive and has not answered yet) is held without blocking anything behind it; a refused one keeps its text and a Retry. A test fails if any client code reads the raw delivery fields instead of going through that function.
  • The idle sweep asks the same "does this chat still owe work" question that makes a chat show as working, so it keeps an agent exactly while a message is unanswered or a turn runs.
  • A message stops being unanswered work at each point where nothing is left to answer it, and is recorded as settled-in-doubt (the same unknown + recovered state a crash uses):
    • The agent says its turn is over. Each agent's adapter reports this at the agent's own signal, and the settlement is queued behind the chat rows already waiting to be written, so it lands after the turn's last row; every message still unanswered at that point is settled. Claude: the CLI's idle state, which it reports only once it has nothing left to run. A Claude result is not enough, because the CLI runs a message sent during a turn as its next request cycle, after that turn's result and with no idle in between; settling at the result made the chat read idle, and could announce the turn finished, until the message's echo a few seconds later. A Claude CLI that never reports its state gets its result treated as the end instead; a CLI that does report it says running before anything else in its first cycle, so which rule applies is settled before the first result. Codex: turn/completed on the chat's main thread; a subagent's thread ending its own turn does not count. An error the agent will not retry is covered too: Codex always follows one with a failed turn/completed (every case in the captured Codex transcripts does), and Claude's error result is followed by its idle. A failure to record the settlement is logged and never holds up the chat's other rows; the next Stop, agent exit or reopen settles the message.
    • Handing the message over throws, or its outcome is unknown: it is written as settled straight away instead of as a still-open doubt. If the agent did take it, its turn and its echo say so.
    • Stop: after the interrupt is sent (so the agent's own "withdrawn" answer lands first), every unanswered message is settled. A failure to record this is reported and never fails the Stop.
    • Removed: the Codex path that settled an open doubt when Codex reported its thread idle. Nothing writes an open doubt any more, so it could no longer fire.
  • The fold keeps a failure row out only when it is the last failure of its turn and no answer from the session's own agent follows it; rows before any prompt belong to no turn and were already outside.
  • Released clients: clients from this version advertise a new capability, agent-session.crash-doubted-send.v1. For a client that does not, the host publishes every message the shared classifier draws as sent (a settled-in-doubt message, or an older host's "not delivered" verdict still in the journal) as accepted, the one state a released client already draws as sent. This happens only at the RPC boundary (history, the live stream and a replayed send's reply), the same per-client downgrade the turn record already uses. The journal and every host decision keep the real facts.
  • Deleted: the restart-time delivery check that read Claude's transcript, the history window it read from, the text matcher, the check at agent start, and the flags that only existed to make that guess safe.
  • Also fixed on the way: when a Claude process died mid-reply, the host compared the turn's end time with a slightly later clock of its own, so on a busy machine the "Claude stopped…" notice was sometimes skipped (this happens on main too). The host now uses the exit time the agent adapter recorded, as the Codex path already did.

Why

  • No guessing: Orca no longer decides after the fact whether the agent received a message. The transcript search caused both wrong verdicts: it started at the last completed turn, so it missed older messages (STA-7416), and it skipped prompts that look like harness output (STA-7453). Reading an agent's own record after a crash cannot prove a message is missing, because Claude merges messages queued back to back into one record under the last message's id.
  • No barrier, no deadlock: a message in doubt no longer blocks the messages after it, so nothing depends on a check that only runs after the next send.
  • Fewer moving parts: about 2,600 lines deleted, including the transcript window, the matcher and its safety flags. The crash boundary does what it already did (mark unacknowledged sends, settle leftovers) plus one notice row.
  • Retry means sending again: a message that truly never arrived costs one new message, and the notice says so. Before, a wrong verdict could make the agent act on the same message twice.
  • One classification rule: the review repeatedly found the "is this message settled" rule copied into places that disagreed with each other; it now lives in one function with a test that keeps it there.
  • Three deliberate decisions, each matching how comparable agent chat tools behave:
    • No notice for an expected stop. Comparable tools write nothing when a session is stopped or closed with a message still unanswered; the message stays an ordinary sent message. An earlier revision of this PR added a "didn't respond to your last message" row for it; it was dropped because no failure seen in Orca called for it.
    • Keep, don't release, and give every wait an end. Comparable runtimes refuse to idle-release a session while a turn start is pending, a turn is active or an operation is in flight, and clear the pending start when a turn starts or completes, when the provider reports an error it will not retry, when the start throws, and when the session is stopped. This PR does the same, with three differences: a turn starting does not settle a message (Orca waits for the agent's echo of that exact message, and a running turn already keeps the chat working); for Claude, "completes" is the CLI reporting itself idle, not each result; and a non-retrying error is read through the failed turn end the agent always sends right after it, because Orca's Codex path deliberately takes a turn's end only from turn/completed.
    • Only the row that ends a turn stays out of the fold. Comparable timelines keep the terminal row of a turn (its last answer or its error) and any row not tied to a turn visible, and fold earlier errors with the work. The previous revision kept every failure row visible wherever it landed.
  • Released clients, by per-client down-projection: comparable tools rewrite a value an older client cannot read into an old shape it already handles, per subscriber, keeping identities and sequence numbers. Here that old shape is accepted: a released desktop drops its local copy and its Retry on it, and a released phone clears its saved send record, which is exactly "drawn as sent". A released client reads nothing else from these fields, and the empty unconfirmedClientMessageIds list is read by no client. Dropping the submission instead would leave a released desktop's copy unanswered, so it would re-send it under the same id and get the doubt back.
  • Alternatives considered:
    • Look the message up by its own id in the transcript when the chat reopens: still an after-the-fact guess, and Claude drops the id of a message merged into a later one.
    • Keep the per-message "unconfirmed" state and only move the check to reopen: the barrier and the per-message Retry would stay.
    • Explain an expected close with a row: more copy for a case comparable tools treat as a normal stop.

Linked Issue

Linear: STA-7416, STA-7453, STA-7434 (no GitHub issue). Supersedes #23606 (STA-7434's separate fix), and the stale #20723 and #20792.

Visual Proof

Live QA on a separate Mac with the real Claude CLI. "Crash" means Orca was killed right after Claude saved the message and before its reply finished; then Orca was relaunched and the chat reopened.

Before (main):

  • Crash, reopen, no new message: "Message delivery is unconfirmed." with Retry, on every reopen. before: unconfirmed with Retry
  • Crash, then a new message: the new message is drawn but never sent or answered (the deadlock). before: new message stuck
  • A message Claude never received, then "please redo": the redo is stuck too. before: redo stuck
  • A <system-reminder>-shaped message, crash, then a new message: the message is hidden, there is no Retry, and the new message is stuck with nothing to click. before: hidden and stuck

After (this branch). Every after shot was taken before this revision's three changes (dropping the "didn't respond" notice, the narrower fold rule, and the downgrade for released clients). None of them shows the dropped notice, and each notice they show is the last row of its turn, so the narrower fold rule draws them the same way.

  • Crash, reopen twice, no new message: drawn as sent, one notice, no Retry; the sidebar lists the chat. (predates this revision) after: one notice
  • Crash after part of the reply streamed: the notice is visible without expanding anything. (predates this revision) after: partial reply plus notice
  • Crash, then a new message: it goes out at once and is answered. (predates this revision) after: next message answered
  • A message Claude never received: drawn as sent with the notice; "please redo" is answered, and Claude holds no copy of the lost message. (predates this revision) after: redo answered
  • Only the Claude process killed mid-reply (Orca kept running): the "Claude stopped…" notice is visible under the cut-off reply, and the next message is answered. (predates this revision) after: stop notice
  • A <system-reminder>-shaped message, crash: the notice is visible and the next message is answered; the message itself is still hidden by the separate display filter (see Notes). (predates this revision) after: harness-shaped prompt

This revision (decision changes), live on the same machine:

  • Close the chat tab with an unconfirmed message: the tab closes with no extra notice. close with doubt
  • Quit Orca cleanly with an unconfirmed message, relaunch: no notice row, the message drawn as sent, the existing "Resume interrupted chats?" offer appears, and the next message is answered. quit: resume offer quit: next message answered
  • Orca and the agent both stopped together, relaunch: exactly one "Claude stopped…" notice and no "didn't respond" notice. one stop notice
  • A turn that ends because the Claude process died: its notice is visible without expanding anything. turn ending in failure

Final head (after the latest merge of main), same machine:

  • Crash, reopen twice: exactly one "didn't survive the restart" notice, message drawn as sent, sidebar lists the chat. final: crash reopen
  • Crash after part of the reply streamed: exactly one notice. This shot predates this revision and reads "Claude stopped while this response was in progress", the timing-dependent wording this revision removes; a crash now always reads "didn't survive the restart". final: partial reply
  • Crash, then a new message: one notice, the message goes out and is answered. final: crash then send
  • Only the Claude process killed mid-reply: the stop notice is visible without expanding. final: stop notice
  • Quitting Orca with an unconfirmed message (both a clean quit and a stop of the whole process group): no notice row; the message is drawn as sent, the existing resume offer appears, and the next message is answered (screenshots could not be uploaded; recorded in the QA log).

Latest head (doubted messages always settle; wording by what happened), same machine:

  • Stop on a message left in doubt while a reply streams: the chat goes idle within about a second, the message is drawn as sent, Claude holds no copy of it, and the next message is answered. stop on doubt
  • Crash after part of the reply streamed: exactly one notice, now always "didn't survive the restart" for an Orca restart. restart wording
  • Only the Claude process killed: exactly one "Claude stopped…" notice, visible without expanding. stopped wording
  • A second message sent while Claude is still answering (sampled every 100 ms): Stop stays shown and the sidebar stays working through the hand-off, and nothing reports "finished" until the second answer ends; each message has one copy in Claude's transcript. message during a reply

Not run live: Codex (no usable Codex login on the QA machine); the phone (the emulator's text input cannot enter a pairing code, and the only other route is not allowed); the idle sweep keeping an agent (its 30-minute window has no test setting), and an earlier failure followed by an answer (the real CLI does not produce one on demand) — both are covered by unit tests.

Testing

  • I manually tested these changes locally (on a separate QA Mac; see Visual Proof)
  • Automated tests added/updated, or explained why not below

Live QA on a separate Mac against the real Claude CLI, main versus this branch, with Orca killed right after Claude recorded the message (see Visual Proof).

Automated:

  • Host, against the real host, store and journal: a message the old process handed over is left settled-in-doubt, never re-sent, with exactly one restart row; reopening twice more adds nothing; an agent start that is the first to open the chat after a crash writes the same single row; an unexpected agent exit writes only the "stopped" row; an expected close, a turn end and a Stop write no row; the Claude exit race is reproduced with a scripted CLI and fails without the fix.
  • Idle sweep and the ways out: an agent whose handed-over message is waiting to start is not stopped however long it is quiet. One test per way out shows the message settled as sent with no notice and the chat no longer working: a Claude CLI reporting idle, a Claude CLI that reports no state ending its turn (scripted CLI, with and without an error result), a Codex turn completing without echoing it and a Codex turn failing after an error it will not retry (fake Codex with real turn bookkeeping), the hand-over throwing or losing its outcome, and Stop; the sweep then stops the agent. A recorded real-CLI session in which Claude runs a message as its next request cycle is replayed through the host: the message stays unfinished work, with no turn announced as finished, from its send until its echo. A turn row ending without the agent saying its turn is over, and a Codex subagent thread ending its turn, both leave the message owed. A Stop whose record write fails still succeeds.
  • Crash wording: a crash whose proof lands before the chat opens and one whose proof lands after both leave exactly one "didn't survive the restart" row, under the same id, and a reopen adds nothing; an exit Orca watched keeps "stopped while this response was in progress", with or without a crash in between.
  • Fold: a turn that ends in a failure keeps that row visible; a failure followed by an answer folds; a crash or "stopped" row after a partial answer stays visible; a row tied to no turn is never folded; a subagent's later words do not count as the turn's answer.
  • Mixed versions: at the RPC boundary an older client gets a settled-in-doubt message as accepted on history, snapshot, batch, reset and a replayed send, a current or in-process client gets the real facts, the host's own record is untouched, and a live unknown, pending, refused or accepted message passes through unchanged. The cross-version suite pairs the current host with a client derived from the newest release's list minus the capability.
  • Clients: a message settled in doubt, or an older host's "not delivered", is drawn as sent with no barrier, Retry or error; a message still open is held without blocking; a refused one keeps its text and Retry; a lost reply between this device and the host still shows "Message delivery is unconfirmed." and is re-asked under the same id.
  • A table test covers every delivery state old and new hosts send, and a boundary test fails on any new raw read of those fields in client code.
  • Each key test was checked by reverting its fix and seeing it fail.
  • oxlint, the anti-slop lint and the changed-code quality gate pass; typecheck is re-run on this head before merge.

Review

  • Mixed versions (clients and hosts update independently):
    • New host, released client: the client advertises no agent-session.crash-doubted-send.v1, so the host publishes a settled-in-doubt message to it as accepted, and it draws the message as sent with no note, Retry or barrier. It shows the crash notice as a plain line, which a released desktop client's older fold may collapse under "Worked for" above it. That is accepted: released client code cannot change, the notice is still there when the turn is expanded, and the phone does not fold.
    • A new host still sends unconfirmedClientMessageIds, always empty; no released client reads it, and older parsers still accept the reply.
    • New client, older host: kept as it was. The client draws that host's crash-stranded messages, including its "not delivered" verdicts, as ordinary sent messages. Older hosts write no notice row, so nothing explains it; sending again is how the chat continues. Comparable tools either ship client and server as one build or refuse a mismatched client outright, so there is no finer mechanism to follow here.
    • Existing chats: a "not delivered" message an older build wrote is now shown in the conversation instead of hidden.
  • Known edges, narrow and not seen live:
    • The journal does not forbid a later answer for a settled-in-doubt message. A later "accepted" agrees with how it is drawn. A later refusal (for example a Codex turn that fails without echoing a message the turn's end already settled) turns the message into a refused one with Retry, on new and released clients alike.
    • A message the agent accepted but never echoed, with no turn running, still holds the agent and shows working: nothing has ended that could settle it. On desktop, Stop is offered while the chat reads working and clears it; on the phone, Stop is offered only while a turn runs, so there closing the tab or quitting ends it. Claude echoes every message it takes, so this needs a wedged CLI.
    • An agent that exits right after a hand-over threw now adds no "stopped while this response was in progress" notice: the throw already settled the message, so no response was in progress. Main showed the notice there.
    • A message sent in the instant after the agent reported its turn over, before Orca had recorded that, is settled in doubt with the turn; the agent's echo then marks it accepted, and in between the chat can read idle for a moment.
  • fix(codex): steer a mid-turn send into the running turn by name #21062 ("settle in-flight sends when their turn ends") is related and still open. This PR covers the problem it fixes: a turn that ends with a message still unacknowledged no longer leaves the chat working or blocks /clear and /compact, for Claude as well as Codex. It does not include all of fix(codex): steer a mid-turn send into the running turn by name #21062: fix(codex): steer a mid-turn send into the running turn by name #21062 settles only the messages Codex tied to that exact turn and writes them in the same database transaction as the turn's end, with a retry for a Codex settlement that fails to save; this PR settles every message still unanswered when the agent says its turn is over (Claude's idle, Codex's main-thread turn/completed), right after that turn's rows are written, and leaves a failed save to the next Stop, exit or reopen.
  • SSH, paired web and mobile: the notice is written by the host that runs the agent, into the journal every client reads, and the downgrade happens on that host per connected client.
  • Codex: it shares the crash boundary, so a crash-stranded Codex message behaves the same way. Not verified live (no usable Codex login on the QA machine).
  • Folder workspaces: nothing here depends on git.

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

  • STA-7453 is only half fixed: no text filter takes part in a delivery decision any more, but a message that starts with <system-reminder> is still hidden in the conversation by the separate display filter that decides by text shape (native-chat-noise.ts). That is a pre-existing issue left for a follow-up.
  • Broader than the tickets, called out for review: the idle sweep now keeps an agent while a message is unanswered (main stopped it), and a message now settles as sent when the agent says its turn is over, when a hand-over fails or on Stop, instead of waiting for Codex to report its thread idle; failure notices that end a turn stay outside the collapsed turn (main folded them, including the "stopped" notice); released clients get the downgrade described above; and the Claude exit-time race fix corrects a pre-existing gap on main.
  • Transitional: once no supported release lacks agent-session.crash-doubted-send.v1, delete the downgrade module and the capability check.
  • Follow-up: claude-transcript-branch-proof.ts and claude-transcript-branch-graph.ts now have no production user and are left for a deletion-only PR.
  • Follow-up: main's turn grouping assigns every row after a turn to that turn, so after a crash the crash notice is grouped under the previous turn and that turn's files-changed summary draws below the notice. The notice itself stays visible; making rows written outside any turn stay out of that grouping is a separate change.
  • Copy: the "earlier message hasn't been confirmed" command refusal no longer says "or retry it". All six catalogs are updated.

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)

… frame id

A send left unconfirmed by a crash was judged by looking for its text in the
transcript after the last completed turn. A message before that point (the
point moves on with every later turn) or one starting with a harness-looking
tag was missed, declared not delivered, hidden, and Retry sent it again.

The hand-over now records the uuid Orca stamps on the Claude frame, which
Claude keeps as the transcript record's uuid. A restart looks that id up in
the whole transcript. Sends recorded by older builds carry no id and are
never declared undelivered; they stay unconfirmed.

Linear: STA-7416, STA-7453
@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: fee100e9-abb7-4ebd-a5f7-5c878cf616f1

📥 Commits

Reviewing files that changed from the base of the PR and between 50b9bf2 and ddd9c15.

📒 Files selected for processing (51)
  • mobile/src/session/mobile-structured-send-delivery.test.ts
  • mobile/src/session/mobile-structured-send-delivery.ts
  • mobile/src/session/mobile-structured-send-operation-journal.test.ts
  • mobile/src/session/mobile-structured-send-operation-journal.ts
  • src/main/claude/claude-history-window-source-budget.test.ts
  • src/main/claude/claude-structured-history-window.test.ts
  • src/main/claude/claude-structured-history-window.ts
  • src/main/claude/claude-structured-session-adapter.ts
  • src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts
  • src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts
  • src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts
  • src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts
  • src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts
  • src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx
  • src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx
  • src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts
  • src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts
  • src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts
  • src/renderer/src/i18n/en-runtime-required.json
  • src/renderer/src/i18n/locales/en.json
  • src/renderer/src/i18n/locales/es.json
  • src/renderer/src/i18n/locales/fr.json
  • src/renderer/src/i18n/locales/ja.json
  • src/renderer/src/i18n/locales/ko.json
  • src/renderer/src/i18n/locales/zh.json
  • src/renderer/src/lib/structured-agent-session-launch-prompt.ts
  • src/shared/agent-session-failure-words.ts
  • src/shared/agent-session-journal-types.ts
  • src/shared/agent-session-wire.ts
  • src/shared/agent-session-write-notice-copy.ts
  • src/shared/structured-agent-session-dispatch-rejection.ts
  • src/shared/structured-agent-session-message-projection.ts
  • src/shared/structured-agent-session-outbox.ts
  • src/shared/structured-agent-session-send-disposition.test.ts
  • src/shared/structured-agent-session-send-disposition.ts
💤 Files with no reviewable changes (9)
  • src/main/claude/claude-structured-history-window.test.ts
  • src/main/claude/claude-history-window-source-budget.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
  • src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts
  • src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts
  • src/main/claude/claude-structured-session-adapter.ts
  • src/main/claude/claude-structured-history-window.ts
  • src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/shared/agent-session-journal-types.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 5 remain after this review.


📝 Walkthrough

Walkthrough

The change removes provider-history-based reconciliation of journal submissions after restart. Unacknowledged sends remain unknown, are not resent, and can produce a host-restarted status row at a crash boundary. A shared classification rule updates how mobile and renderer surfaces display unknown and legacy not-delivered submissions. Related tests and delivery notices are updated.

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Priority: ➖ Normal

Estimated code review effort:

Merge Risk: 🔵 Low · up to ddd9c

A journal write failure during recovery can leave a sent-looking message without its session-restart notice. This narrow failure does not appear to block merging, but it warrants owner awareness.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to ddd9c

The change reduces the risk of sending a message twice after a crash. However, a failure while recording the restart notice can leave a message uncertain without restoring the explanation on a later reopen. That failure path warrants design review.

Retained concerns

  • Medium · reliability · inferred: If marking a handed-over send unknown succeeds but the separate status-row append fails, a later open may never record the host-restarted notice: the send is already marked recovered and a send-only conversation has no remaining in-progress work from which to regenerate the notice.
Security review details

Security Blast Radius

  • inferred — The directly evidenced effect is on delivery decisions and display for journaled structured-session sends across host, renderer, and mobile. The inspected paths do not show an expanded credential or infrastructure boundary; they do not establish safety for uninspected callers.

Trust Boundaries and Controls

  • observed — Recovered unknown is a journal state rather than provider-delivery proof. Renderer reconciliation uses the journal’s client message ID to remove matching retry state; mobile retains distinct handling for results with no submission and for rejected submissions not classified as sent.

Resilience and Maintainability Implications

  • inferred — Atomic lifecycle batches and successful repeat-open behavior do not make unknown-state persistence and status publication atomic together. Failure before the status batch commits can therefore strand the explanation without re-enabling automatic dispatch.

Hardening Proposals

  • proposed — Make the crash-boundary notice recoverable from durable state, or commit it with the unknown-send transition, so a failed status append can be retried without relying on a one-time transition count.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 46.84% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 79 functions across 53 files. (7 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly describes the main behavior change: after a crash, an unconfirmed native-chat message remains sent and the host shows one notice instead of guessing from the transcript.
Description check ✅ Passed The description is comprehensive and covers the required explanation, user-visible changes, rationale, linked issues, visual proof, testing, compatibility, review notes, and checklist. It documents th…
Full details: Docstring Coverage

Explanation

Docstring coverage is 46.84% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 79 functions across 53 files. (7 skipped: 7 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch brennanb2025/sta-7416-delivery-by-message-id

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 critical issues — minor suggestions inline.

Reviewed changes

  • Delivery decided by the frame id. At hand-over Orca mints a Claude frame uuid (mintClaudeDispatchIdentity), persists it on the dispatch{pending} row as handedOverItemId, and sends that exact uuid on the frame; dispatchClaudeTurn now accepts a caller-supplied sentUuid.
  • Whole-transcript lookup on restart. resolveClaudeProviderHistoryWindow additionally builds a ProviderRecordedHistory (every user record's journal key plus keys indexed by payload fingerprint) in one streaming pass.
  • Reconciler gains an identity path. reconcileSubmissions resolves id-bearing submissions by handedOverItemId: present → accepted; absent from a whole read with no same-text record → rejected (not_delivered); otherwise unknown. No-id rows keep the anchored-window match, but a miss is now unknown (no_dispatch_identity) instead of rejected.
  • Journal plumbing and tests. Optional handedOverItemId on the pending dispatch row/reducer; providerIdentity on ResolveDispatchInput and the adapter contract; regression tests for STA-7416/STA-7453 plus dispatch-identity durability.

ℹ️ The by-id absence proof rests on an unchecked append-only assumption

resolveByIdentity settles a send rejected: not_delivered from a whole-file read without consulting history.boundaryConsistent, which the legacy path still does. The absence proof (provesAbsenceOf) only checks that every line parsed and that the key names this session. This is not a demonstrated bug — but it means the safety of the new path depends on a property nothing in the code verifies.

Technical details
# Whole-file absence proof vs. transcript mutation

## Where
- `src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts:188` — `provesAbsenceOf`
  gates the rejection; `boundaryConsistent` is not consulted on this path.
- `src/main/claude/claude-structured-history-window.ts:244-254` — `finish()` returns
  `provesAbsenceOf` true whenever `whole` (no line failed `JSON.parse`).

## Why it could matter
If a Claude client ever rewrites or truncates the session JSONL in place, a uuid that was written
would be absent, `provesAbsenceOf` would return true, and a delivered send would settle
`rejected: not_delivered` — hiding the message and inviting a duplicate via Retry, the exact cost
this PR removes elsewhere.

## Evidence (not reachable on the evidence available)
- Compaction appends a boundary/summary and keeps the original messages; `/branch`/`--fork-session`
  writes a new file under a new session id, which fails the session-id equality in
  `provesAbsenceOf` and stays `unknown`. Missing/unreadable file yields `null` history → no
  reconcile. So the path is not demonstrably unsound for the CLI/SDK Orca drives.
- Counterexample shape to be aware of: Claude Desktop's resume path has been reported to truncate
  the same `~/.claude/projects/<proj>/<id>.jsonl` files in place.

## Open question for the human
Is append-only a contract you can rely on here (worth a comment or a targeted test), or should the
by-id rejection also require the branch proof (`boundaryConsistent`)?

ℹ️ Nitpicks

  • mintClaudeDispatchIdentity reconstructs the trailing prompt from body.blocks while dispatchClaudeTurn decides "is this a command" from the mapped content produced by claudeDispatchMessageContent. Sharing that derivation would keep the mint and dispatch decisions from silently desynchronizing.

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

Comment thread src/main/claude/claude-structured-dispatch-content.ts Outdated
… need it

Opening a structured chat read the whole Claude transcript up to three times
(branch proof, anchored replay, and the new whole-file id scan) on every attach,
even when the journal had nothing to decide. The adapter now samples liveness at
attach and hands back a history source; reconciliation reads the recorded
history only for a stranded send handed over under an id, and the anchored
window only for one without. A normal chat open reads no transcript.
…turn

Claude records a frame that arrives while a turn runs in one of two ways
besides its own row: folded into the turn, it gets only a queued_command
attachment naming it by source_uuid; queued with others behind the turn, it is
merged into one row under the last frame's uuid. The id scan read only
type:user rows by uuid, so a delivered mid-turn send was judged not delivered,
hidden, and offered for a Retry that sends it twice.

The scan now takes a queued_command attachment's source_uuid as the frame's
id, as Claude's own readers do, and indexes every text block of every
recorded frame. A send whose id is absent stays unconfirmed while any copy of
its text is left over once each send found by id takes its own, which covers
the merged row. The scan moves out of the anchored-window module, which
returns to its previous shape.
With no durable anchor yet, the anchored read reports an inconsistent window
that carries no liveness. The attach's sampled liveness now decides the id
path, so a send whose record Claude has not written yet stays unconfirmed
instead of being called not delivered mid-turn.
The mint comment claimed Claude always records a frame under its own row
uuid; a frame folded into a running turn keeps it as the attachment's
source_uuid instead.

@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

The merged-queue recording shape the new collector relies on to avoid false not delivered verdicts is unverified and has a real counterexample — see the inline note.

Reviewed changes (since the prior pullfrog review at 4f20e4b4b0)

  • Provider history is now read lazily. openClaudeProviderHistory returns a ProviderHistorySource; readHistoryFor reads the anchored window only for id-less sends and the recorded set only for id-bearing ones, and a failed read leaves every send unknown.
  • Folded and merged Claude frames are indexed by id. The recorded collector also reads queued_command attachments (source_uuid → the frame's id, prompt → text) and indexes each text block of a merged row separately; unclaimedCopies subtracts a copy for every send whose id the history holds.
  • The by-id path gained a liveness guard. resolveByIdentity returns turn_in_flight before proving absence, and the attach samples liveness before a new child is acquired.
  • Tests and docs exercise the recorded history through a real transcript file and the restart-interruption harness.

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

Comment thread src/main/claude/claude-structured-provider-history.ts Outdated
…s owner is accepted

Restart reconciliation dropped every id the journal had committed from every
copy of text in the transcript. When Claude records two queued sends in one row
under the later send's id and that later send was already accepted, this also
dropped the only copy of the earlier send's text, so the earlier send was called
not delivered and offered for Retry, which sent it twice.

A committed id now takes only one copy: the text it was committed with. A row
that holds another send's text still vetoes that send's rejection, and a crashed
repeat of an older delivered message is still decided not delivered.

@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 in the latest commits. Two earlier review threads are still open, so this isn't an approval.

Reviewed changes

  • A merged send no longer collapses to rejected when its row's owner was accepted. unseenHistory now subtracts a committed id from only the one fingerprint it was committed with (withoutOwnCopies), instead of stripping that id from every fingerprint list, so an earlier send's text surviving inside a later accepted send's Claude row stays claimable and the send remains unknown rather than rejected: not_delivered. unclaimedCopies is refactored onto the same helper; the change is strictly more conservative.
  • Regression coverage. New reconciler cases pin a send merged into an accepted later row (same and different text) and a repeat of a text the journal already holds; a new dispatch-identity case pins the adapter stamping the recorded handedOverItemId uuid onto the frame it sends.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

…frame id

A send found under its own frame id is delivered; one that is not found stays
unconfirmed. Claude records frames queued together under the last one's id, so
a delivered send can leave no record of its own, and calling it not delivered
hid it and offered a Retry that sent it twice. Restart reconciliation no longer
decides `rejected` at all, which removes the same-content check, the per-block
text index and the whole-file flag that only served that verdict.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status: code ready, on hold for one product decision (head 50b9bf2).

What changed during review (new commits, no force-push):

  • 07368bb: the Claude transcript is read only when a crash left a message undecided; an ordinary chat open reads nothing, and "is a turn running" now comes only from the attach, so a message can no longer be judged while a turn is still running.
  • f1ba667 + 4e261d0: messages sent while Claude was busy are found by their id even when Claude records them as a queued-command note, and a message Claude merged into another one is no longer called not delivered.
  • 2aeb8b0: an end-to-end test that the id Orca records is exactly the id on the frame sent to Claude.
  • 50b9bf2: after a restart Orca now only ever moves a message to "sent" (its id was found) and otherwise leaves it "unconfirmed"; it never says "not delivered" after a restart. The absence-based rule had needed a new guard for each way Claude records a message (merged queued messages lose their own id entirely), and a wrong "not delivered" is exactly what caused the duplicate send.

Validation: node typecheck passes at 50b9bf2; the changed suites pass with ablations showing each new test fails without its fix; lint and the changed-lines quality gate pass. Live crash-and-restart QA against the real Claude CLI on a separate Mac is in the PR body's Visual Proof.

On hold because of what live QA showed: after a crash, reopening the chat still shows a delivered message as "Message delivery is unconfirmed." with Retry (and after an earlier completed turn the message is hidden) until the user sends another message. The delivery check only runs when the chat next starts Claude, not on reopen. Main behaves identically, so this PR does not cause it, but the user does not yet see "sent" at reopen.

Decisions needed: (1) run the id lookup when the chat is reopened too, which is safe now that only a found id can change a message's state; (2) confirm the trade that a message Claude truly never received shows "unconfirmed" rather than "not delivered" after a crash.

@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 critical issues — the new commit is a sound simplification; one description mismatch to reconcile.

Reviewed changes

Since the prior pullfrog review at 2aeb8b0, one commit landed (50b9bf2):

  • Restart reconciliation no longer decides rejected. resolveByIdentity returns accepted when the frame id is found and unknown: not_found otherwise; the id-less resolveOne path now also ends at unknown: not_found rather than rejected: not_delivered. SubmissionReconciliation drops its rejected variant, and reconcileJournalSubmissionsAgainstHistory only ever writes accepted, so no restart path can hide a delivered message or offer a Retry.
  • The absence-proving machinery is gone. ProviderRecordedHistory keeps only itemIds; itemIdsByFingerprint, withoutOwnCopies/unclaimedCopies, provesAbsenceOf, the whole-file flag, and the ambiguous_match veto they fed are removed, along with the second fingerprint pass in createRecordedCollector.
  • Caller and tests updated. readHistoryFor still reads only what a strand needs (window for id-less sends, recorded ids for id-bearing ones); the reconciler, attach, crash-boundary, provider-history, acquisition-options and restart-ownership suites were rewritten to expect unknown from a miss and acceptance from a found id.

ℹ️ The PR description still describes the by-id "not delivered" verdict the final commit removed

50b9bf2 makes restart reconciliation incapable of ever producing rejected: not_delivered: an id absent from the transcript now leaves the message unconfirmed, never not delivered. The PR body still describes the opposite — the ELI5 and "What Changed" say Orca "says 'not delivered' only when it can prove the message is missing", the mechanism spells out the "id missing ⇒ not delivered if every line parsed, nothing running, no same-text copy" rule, and the "Why" section explicitly rejects "stop ever saying 'not delivered' (#20723)" as throwing away a correct answer the product relies on. That rejected alternative is now the implemented behavior, and notDelivered has no production writer left (it survives only as read-only legacy vocabulary in structured-agent-session-dispatch-rejection.ts). Only the author can say which is intended: rewrite the description (and the #20723 supersede note) to match the conservative design, or reconsider the commit.

Technical details
# Stale PR description vs. the implemented behavior

## Affected sites
- PR body only — the code is internally consistent; the mismatch is with the PR's stated contract.
- `src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts:7-11` — comment now states the new rule ("no send is ever called not delivered").
- `src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts:4-8` — "It never decides `rejected`".
- `src/shared/structured-agent-session-dispatch-rejection.ts:40-42,136` — `DISPATCH_REJECTED_NOT_DELIVERED`/`notDelivered` are now read-only (no producer).

## Required outcome
- Align the PR description's ELI5, "What Changed", mechanism, "Why / Alternatives considered", and "Testing" sections with the commit (or revert the commit if the by-id rejection was intended to stay).
- Note in the supersede line that the final behavior matches the #20723 approach for the restart path.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

The restart decision moves to the session level; nothing reads the
provider transcript to decide delivery any more.
…ding the transcript

The crash boundary no longer decides delivery from the provider's saved
transcript. A send an earlier process handed over and never heard back
about stays in doubt, and the chat's open writes one status row saying
the session did not survive the restart. The row comes from the stale
settlement that already writes the exit row, so one boundary never
shows two; an acquisition that is first to open the chat now settles
there too, with the death evidence it read before its reserve.

Deletes the anchored history window, the submission reconciler, the
attach-time reconcile and its pre-acquire sampling, and the adapter
hook that fed them. The attach result keeps an always-empty
unconfirmedClientMessageIds for older parsers.
…field's meaning

The restart fact gets a row sentence: the agent's session didn't survive
the restart, send a message to continue. unconfirmedClientMessageIds is
documented as always empty, and an unknown submission as drawn as sent.
…or Retry

A submission the host settled unknown, or an older host's not-delivered
verdict, is an ordinary sent message on desktop, paired web and mobile:
the outbox drops it, nothing queues behind it, and there is no
per-message Retry. The refused-redelivery path and its untranslated
error go. Mobile spends the operation id on a host unknown, so typing
the message again sends a new one. The unconfirmed state and its copy
stay only for a send whose reply this client lost.

The command refusal for an unsettled message no longer offers a retry
that does not exist, in every catalog.

@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

The final test commits break the CI typecheck: pnpm tc:web fails on NativeChatStructuredSession.transport-probe.test.tsx:179. Everything else in the reversal reads as sound.

Reviewed changes

Since the prior pullfrog review at 50b9bf2, six commits landed (plus a merge of main), reversing the by-id mechanism:

  • Dropped the transcript id lookup. resolveClaudeProviderHistoryWindow, openClaudeProviderHistory, journal-submission-reconciler.ts, journal-restart-reconciliation.ts, and the handedOverItemId/mintDispatchIdentity plumbing are deleted; nothing reads the Claude transcript on restart any more.
  • Doubt is drawn as sent. New structuredAgentSessionSubmissionDrawsAsSent (accepted | unknown | legacy rejected with kind notDelivered) now drives mobile delivery, the renderer outbox/dispatch/launch-prompt, message projection, shared outbox reconciliation, and send disposition; the send-disposition refusedRedelivery parking path is gone.
  • One boundary row per crash. The conversation open settles stale state (now also for acquisitions) and writes a single hostRestarted notice row when it left a send in doubt or an in-progress item, instead of providerExited when an observed exit interrupted it; unconfirmedClientMessageIds is always [].
  • Copy and mobile bookkeeping. The restart row now reads "…'s session didn't survive the restart. Send a message to continue."; mobile spends the operation id for unknown, and clearMobileStructuredSettledSendOperations clears everything not pending.

ℹ️ The transcript branch-proof module now has no production caller

Deleting claude-structured-history-window.ts removes the only production consumer of claude-transcript-branch-proof.ts (and the claude-transcript-branch-graph.ts it builds on). Both modules now survive only for their own tests, so the PR can delete them and their test files too, or say why they stay.

Technical details
# Orphaned branch-proof modules

## Affected sites
- `src/main/claude/claude-transcript-branch-proof.ts` — exports `proveClaudeTranscriptBranchFromJsonl`, `replayClaudeTranscriptBranchAncestry`, `replayClaudeTranscriptBranchAncestryFromJsonl`; imported only by `claude-transcript-{rewind-proof,marker-branch-proof,branch-streaming}.test.ts`.
- `src/main/claude/claude-transcript-branch-graph.ts` — imported only by `claude-transcript-branch-proof.ts` and its own test.

## Required outcome
- Either delete the two modules and their three test files as part of removing the history window, or leave a note that they are intentionally retained.

## Suggested approach
- Confirm no non-`src` consumer first; a repo-wide search for `transcript-branch-proof` / `transcript-branch-graph` returns only these modules and their tests.

ℹ️ Nitpicks

  • The PR description still documents the removed id-lookup mechanism (ELI5, "What Changed", the mechanism bullets, and "Why" all describe looking the message up by a minted uuid and reading Claude's transcript on restart). The final commits delete that path entirely; the description needs the same rewrite the code got.

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

…er rejection keeps its text

A live unknown can still turn rejected (an agent that never proves its start
rejects it). Dropping the outbox entry on it left that rejection with nothing
to draw: the message vanished with no Retry. Only a recovered unknown is
settled as sent now; a live one is held like a pending, which is no barrier.
@greptile-apps

greptile-apps Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 1/5

[High risk] Restructures message delivery and crash recovery across chat sessions.

The PR does not appear safe to merge while the outstanding Stop, Codex idle, and delayed turn-settlement defects remain.

Findings

  1. P1 Restart confirmation was removed ▶
  2. P1 Stop settles unrelated sends ▶
  3. P1 Idle sends remain pending ▶
  4. P1 New sends settled by old turns ▶
  5. P2 Restart notice can disappear ▶

Summary

The PR replaces transcript-based delivery guesses after a structured-chat crash with settled-in-doubt sends and a host-written notice.

  • It changes send display and retry behavior across desktop, web, mobile, and older clients.
  • It changes agent idle cleanup, turn-end settlement, and failure-row folding.
  • The only change since the previous review adds an assertion to the Stop test.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Send recorded] --> B{Agent acknowledges?}
  B -->|Yes| C[Accepted]
  B -->|No; crash or exit| D[Settled in doubt]
  D --> E[Draw as sent; do not resend]
  D --> F[Host writes one failure notice]
Loading

Reviews (20) · Last reviewed commit: "test(native-chat): pin that a Stop namin..."

page: readAgentSessionHydrationPage(attached.journal, fence),
unconfirmedClientMessageIds: attached.unconfirmedClientMessageIds,
// No host decides delivery after a restart any more; kept because the wire type requires it.
unconfirmedClientMessageIds: [],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Restart confirmation was removed When Claude saves a message and Orca crashes before recording its reply, attach now opens the journal without checking Claude's transcript. The message remains unknown instead of being confirmed as delivered, so the restart cases this PR aims to fix remain unresolved.

Comment thread src/shared/structured-agent-session-dispatch-rejection.ts Outdated
@greptile-apps

greptile-apps Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Comments Outside Diff

These findings could not be posted inline.

  • P1 Unanswered sends lose their notice src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts:160 ▶

    When an expected close leaves a handed-over message unacknowledged, settlement marks it as recovered but no longer writes the “didn't respond” notice. Clients then show the message as sent without explaining that delivery is uncertain. The provider-idle release path also retires such messages without a notice.

  • P1 Live uncertainty releases replay protection mobile/src/session/mobile-structured-send-delivery.ts:84 ▶

    A host can return a live unknown while delivery is still unresolved. Mobile now reports it as accepted and clears its saved operation ID; journal cleanup also clears IDs for live unknown submissions. If the user sends the same text again, mobile can use a new ID rather than replaying the first operation, risking a second delivery.

  • P2 Completed subagent appears active src/renderer/src/components/native-chat/native-chat-transcript-slots.ts:123 ▶

    When one subagent finishes and another starts in the same active turn, this scan marks both subagents’ last rows as trailing. The earlier subagent’s tool run then keeps showing as live, making finished work look unfinished.

@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

The merged tree does not compile: #23682 (merged in from main) deleted src/main/codex/codex-structured-journal-provider-verdicts.ts but left src/main/codex/codex-background-task-frames.ts importing it. Every failing check on this head — typecheck, static analysis, verify, and all six persistence shards — traces to that one unresolved import. It is inherited from main, not introduced by this PR's own changes; the settlement reconciliation itself reads as sound and its tests pass.

Reviewed changes

Since the prior pullfrog review at cd2da8e, one commit landed — the merge of origin/main (7445feaca) into the branch:

  • The crash-boundary settle is reconciled with main's per-owner death rule. A new structured-agent-session-stale-settlement-row.ts (created by the merge's conflict resolution) extracts staleSettlementRow and crashBoundaryExplainedProvenTurn. The settle writes the provider-exit row only when a proof names the turn's own owner (diedInProgress) or a proof revised a turn no boundary row already explains (provenUnexplained), and keeps the branch's one-row-per-boundary crash notice otherwise. Main's per-fence verdictFor and provenUnverifiableTurnRevisions are adopted wholesale; the branch's pre-#23456 stale-turn-verdict.ts had no unique logic to lose.
  • The conversation open keeps the branch's acquisition semantics. The object-shaped acquisition: { deathEvidence } option, the single settle call, and the settleGoneGeneration helper survive; main's resettleOpenStructuredAgentSessionConversation is retained and all callers pass the new shape.
  • Tests updated for the union. The stale-turn-verdict suite gains a "crash boundary writes at most one row" block including the proof-arrives-later suppression case; the wedged-profile-migration remote case now expects the crash-boundary row.

⚠️ The merged tree imports a module #23682 deleted

codex-background-task-frames.ts:10 imports readCodexProviderVerdict from ./codex-structured-journal-provider-verdicts, a module absent from both merge parents' tips and from the merged tree. The same esbuild resolution failure stops TC_NODE, static analysis and the persistence build on the head, so nothing downstream can run. This is main's breakage (7445feaca fails the same way), not this PR's — but this head cannot merge green until the importer is reconciled with the deletion.

Technical details
# Dangling import of a deleted Codex module

## Affected sites
- `src/main/codex/codex-background-task-frames.ts:10` — `import { readCodexProviderVerdict } from './codex-structured-journal-provider-verdicts'`; used at line 54.
- `src/main/codex/codex-structured-journal-provider-verdicts.ts` — deleted by main's `#23682` (`94b5a7d256`), which made only `turn/completed` end a Codex turn.

## Required outcome
- The head must compile: the importer stops referencing the deleted module. Per `#23682` the `error`-based `turn-failed` arm of `readCodexChildTurnEnding` (lines 43-46) has no remaining source; `thread/closed` → `unverifiable` is the surviving child-turn ending.

## Notes
- Reproduced independently: a `7445feaca` worktree fails `tsc -p config/tsconfig.node.json` with the same `TS2307`; remote `main` has no such module while its importer still references it.
- The PR's own new files are clean — this is the only `tc:node` error, `tc:web` passes, and oxlint is clean on the merge-touched sources. The 250+ focused tests I ran around the settlement/restart/fold paths all pass, including main's `#23456` `crash-turn-end` suite against the merged code.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

… client

The suite's client advertised only the structured capability, so the host
down-projected the crash-doubted send to accepted for it while the test
expected the raw unknown. Advertise the current remote capability list and
pin the recorded unknown + recovered facts on both the send reply and the
journal history.

@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. The delta is a test-client correction; one earlier review thread (a shared-doc wording nit) is still open, so this isn't an approval.

Reviewed changes

Since the prior pullfrog review at 7cb874af, one commit landed (c2004eb):

  • The crash-restart integration test now reads the journal as a current client. CLIENT builds its capability list with remoteRuntimeClientCapabilities([...]) instead of a bare [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], so it advertises agent-session.crash-doubted-send.v1 (and the turn-item capability) the way a current remote client does. The host therefore returns the real unknown, recovered: true submission rather than the accepted down-projection a client that predates the capability is served.
  • The crash case pins the settled facts, not just the send reply. The in-flight submission must carry recovered: true, and a follow-up agentSession.history read must return that same recovered submission, covering both the send reply and history on the current-client path. The now-redundant per-call subscribe(client) override and the AGENT_SESSION_TURN_ITEM_CAPABILITY import are removed, since remoteRuntimeClientCapabilities already includes it.

Verified locally: the suite passes (18 tests) after stubbing the inherited dangling codex-structured-journal-provider-verdicts import for module resolution; tc:node still fails on that inherited import, as raised in the prior review.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (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. The delta is a merge of origin/main that lands the fix for the build blocker raised in the previous review; one earlier review thread (a shared-doc wording nit) is still open, so this isn't an approval.

Reviewed changes

Since the prior pullfrog review at c2004eb, one commit landed — the merge of origin/main (643f93f) into the branch:

  • The branch build blocker is resolved by main's #23801. The merge's only production change is src/main/codex/codex-background-task-frames.ts dropping its import of the #23682-deleted codex-structured-journal-provider-verdicts and its error-based turn-failed child-turn ending. turn-ended now fires only for thread/closed, with turnId: null and state: 'unverifiable'. This clears the tc:node / static analysis / verify / persistence failures reported last run.
  • No PR-authored code changed. The merge touches 9 files in total, all main-authored: the Codex fix plus CI workflow, config/scripts, and two renderer-settings files from other main commits. No conflict resolution touched a branch file, and no PR file differs from its pre-merge content.
  • No dangling references remain. A repo-wide search finds no remaining importer of codex-structured-journal-provider-verdicts and no CodexChildTurnEnding type use.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (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. One earlier review thread (a shared-doc wording nit) is still open, so this isn't an approval.

Reviewed changes

Since the prior pullfrog review at f71d13b, one merge landed (e69eee9c, second parent 5560e534):

  • Merged origin/main. Main's #23808 (25d9c57e) restores the Codex child-turn ending on a willRetry: false error — readCodexErrorWillRetry is reintroduced inline in codex-structured-thread-facts.ts and readCodexChildTurnEnding again returns a turn-ended frame with state: 'failed', so a child turn no longer latches on working for the life of the session. Main's #23802 (2d358df8) prunes the already-removed waitForSetupBeforeAgentHelp from the six locale catalogs, and the README downloads badge advances.
  • No PR-authored file changed. The merge's first-parent diff is main's four files only. The branch's own removal of the same help key and main's prune merge to the same result: the key is absent from all six catalogs and their JSON still parses. The dangling codex-structured-journal-provider-verdicts import raised in the prior review is grep-clean at head, so the earlier build blocker stays resolved.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

… start that threw, and Stop

A handed-over send the provider never answered counted as owed work with no exit, so the
idle sweep held the agent and the chat read working until the tab closed, and Stop did not
clear it. It now stops counting as owed work, settled in doubt (recovered unknown, drawn as
sent, no row), at each exit:

- the provider ends the turn it was running (Claude result, Codex turn/completed, including
  a failed turn after an error Codex will not retry): the journal settles every open send at
  that fence inside the terminal row's serialized write, never for a revision of an earlier
  turn or a host settlement;
- the dispatch threw or lost its write outcome: written settled at once;
- Stop: after the interrupt, and a failure to record it never fails the Stop.

A late acceptance or refusal still replaces the doubt. The Codex idle report path that only
released live unknowns is removed: nothing writes a live unknown any more.
… when its proof landed

One Orca crash could read "stopped while this response was in progress" or "didn't survive
the restart" depending on whether the probe proving the old agent dead ran before or after
the chat opened. The death evidence's kind is the boundary: only a running Orca watching the
agent records exit-observed, and a probe proves death only for an owner whose Orca process
was already gone. So probe evidence now writes the restart row, keyed by the dead owner's
fence so both orders name the same row; an observed exit keeps the stopped row.
…nst the real journal shapes

A Claude provider handle carries its leaf, and a turn has no 'failed' state: a failed turn
completes with a failure outcome. Cover the real terminal states, unverifiable included.
Comment on lines +187 to +193
try {
return child
? await plan.run(ctx)
: { ok: true, value: { turnId: params.turnId, cancelled: withdrawn.length > 0 } }
} finally {
await settleSendsLeftByStop(context, ctx)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Stop settles unrelated sends When Stop names an already-finished or different turn, the adapter can return cancelled: false while another turn still has an unanswered send. This finally block still marks every handed-over send on the fence as settled in doubt. The chat then stops showing that send as pending, and the idle sweep can stop the agent before it answers.

Comment on lines 193 to +198
if (!childAdmission.accepted) {
return childAdmission
}
const admission =
event.method === 'turn/started'
? turnBoundaries.start(event)
: turnBoundaries.complete(event)
if (admission.accepted) {
reportPrimaryThreadStoppedRunning()
}
return admission
return event.method === 'turn/started'
? turnBoundaries.start(event)
: turnBoundaries.complete(event)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Idle sends remain pending If Codex accepts a send without returning a turn ID, then reports its primary thread idle without a terminal turn row, removing the idle-status settlement leaves that send pending. The new settlement path requires a terminal row, so the chat remains working and the idle sweep keeps an agent that has stopped doing the work.

@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 critical issues — two substantive fixes, one documentation nit inline.

Reviewed changes

Since the prior pullfrog review at e69eee9c, two commits landed:

  • A doubted send now has exits instead of pinning the agent forever. 0e4263f adds journal-turn-end-dispatch-settlement.ts, wired into AgentSessionJournal.appendItem and appendLifecycleBatch: a terminal turn row captures the open sends at its fence inside the row's serialized write and settles them unknown, recovered: true once it lands, so a handed-over send the provider never answered stops reading as owed work; handOverSubmission writes recovered: true for a start that threw or lost its write outcome; Stop settles the sends it left in doubt after the interrupt; and the Codex thread/status/changed release path is removed because no producer writes a live unknown any more.
  • The crash row is named by the boundary crossed, not by when the proof landed. c709f012 makes staleSettlementRow write the providerExited ("stopped while this response was in progress") row only for exit-observed evidence, and the crash-boundary:<sid>:<ownerFence> restart row for a probe's pid-absent/identity-mismatch, so one Orca crash reads as one restart row whether the probe lands before or after the chat reopens.

I traced both mechanisms through their callers and the serialized journal write path, and independently had each commit reviewed; neither turns up a real-path over-settle, missed settle, or duplicate-row case.

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

Comment on lines +24 to +26
* The evidence kind is the boundary: only a running Orca watching the agent records
* `exit-observed`, while a probe proves death only for an owner whose transport holder, the Orca
* process that started it, was already gone. */

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.

The second clause overstates the invariant, and that wording is the only stated reason the exit-observed split is safe. A probe can also run in the same live Orca: resolveStructuredSessionRecovery (reached from structured-agent-session-agent-start.ts:106 and structured-agent-session-attach-orchestration.ts:118) resolves a lease a failed unproven acquisition latched recovering, and agent-session-acquisition-failure-settlement.ts:134-146 (and :87-96) keeps that owner's ownerProcess while the Orca that started it is still alive, so its probe can return pid-absent. I could not make that path reach a visible row — the evidence fence it names carries no running turn, so diedInProgress/provenUnexplained stay false — but the guard that actually keeps the mapping correct is that structure, not the absolute claim as written. Consider naming the real guard (the evidence fence has no unsettled work) or noting the same-process probe case.

@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. This commit is test-only; one earlier review thread (a shared-doc wording nit) is still open, so this isn't an approval.

Reviewed changes

Since the prior pullfrog review at c709f012, one commit landed (58f406f):

  • The new settlement fixtures are typed against the real journal shapes. In journal-turn-end-dispatch-settlement.test.ts the turn helper now takes an optional AgentJournalTurnItem['outcome'], the it.each matrix becomes the real completed/success, completed/failure, interrupted/cancellation and unverifiable states (replacing the previously mis-typed failed), and both this file and structured-agent-session-crash-row-boundary.test.ts add leafUuid: null to the Claude providerHandle. Test-only; no production change.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

…not at every turn row that ends

A send written during a Claude turn that the CLI runs as its NEXT request cycle was settled at
the first turn's result: the result arrives without it, the next cycle's init right after, and
its echo seconds later. For that gap the chat and every session list read idle and the finished
announcement could fire, then flipped back to working at the echo.

The journal inferred the turn-over from any terminal turn row, but a Claude result ends one
request cycle, not the CLI's work. Each provider now declares its own turn-over through the
event sink, queued behind the rows ahead of it: Claude at its session-state idle (at its result
only for a CLI that never reports session state), Codex at the primary thread's
turn/completed as before. A child's exit, a superseded cycle and a host settlement no longer
settle sends on the side; the dead-generation settlement and Stop own those.
…ent sink

A journal that throws before returning its promise escaped the handler and stalled the queue
behind it. The settle is bookkeeping, so any failure is logged and the next exit settles the send.
Comment on lines +318 to +323
run: async (bound) => {
try {
await bound.journal.markPendingSubmissionsUnknown(
bound.fence,
DISPATCH_DOUBT_TURN_SETTLED
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 New sends settled by old turns When a provider ends one turn, this queues settlement of unanswered sends but does not identify which sends belong to that turn. If an earlier journal write delays the queue, the delivery loop can hand over a new message before settlement runs. Settlement then marks that live message as recovered and no longer unanswered, so the idle sweep can stop the agent before it answers.

@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. Two earlier review threads (a stale-settlement-row wording nit and a shared-type doc nit) are still open and were not touched by these commits, so this isn't an approval.

Reviewed changes

Since the prior pullfrog review at 58f406f, three commits landed:

  • Doubts are settled at the provider's own turn-over, not at every terminal turn row. settleSendsAtTurnOver joins the event sink, which queues a markPendingSubmissionsUnknown for the bound fence. Claude calls it at its session_state_changed idle, and at a result only for a CLI that never reports session state; Codex calls it at the primary thread's turn/completed. journal-turn-end-dispatch-settlement.ts and its plumbing (the beforeBuild hooks on the item/batch appenders and the which filter on markPendingSubmissionsUnknown) are deleted, so a send a Claude result does not take and the CLI runs as its next cycle is no longer settled in the gap.
  • A failed turn-over settle can no longer stall the sink. The queued settle awaits the journal write inside a try/catch that only logs, so a journal that throws before returning its promise cannot escape the handler and wedge the queue behind it.
  • Regression coverage. claude-structured-turn-end-settlement.test.ts gains a real 2.1.280 capture that keeps a steer owed from its send to its echo with no completion in the gap, plus an idle-settle case; the surface-lifetime suite pins that a turn row ending without the provider's word keeps the send owed.

Verified locally: claude-structured-turn-end-settlement, structured-agent-session-surface-lifetime, structured-agent-session-idle-sweep and codex-structured-journal-translation-settlement pass (62 tests), and pnpm tc:node plus oxlint on the changed production files are clean.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

…send owed

Only the primary thread's turn/completed is Codex's turn-over for a send; a child thread
ending its own turn must not settle it. Fails if the settle fires for any thread.

@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. The delta is a single test-only commit; two earlier review threads (both documentation nits on files this commit does not touch) remain open, so this isn't an approval.

Reviewed changes

Since the prior pullfrog review at 76c2d5b, one commit landed (814cf11, test-only, 1 file, +25):

  • The Codex child-thread turn-over is pinned as not settling a user's send. structured-agent-session-codex-turn-end-settlement.test.ts gains a case that steers a follow-up send into the running primary turn, then emits turn/started/turn/completed for a separate child-thread. The follow-up stays pending with owesWork true through the child's turn end, and only becomes unknown once the primary turn ends (turns.end('completed')). Verified the case passes and is falsifiable: dropping the event.threadId === primaryThreadId() guard in CodexJournalTurnBoundaries.complete makes the mid-child assertion fail (unknown vs pending), because the doubt settle rides sink.settleSendsAtTurnOver, which only fires when that guard produced a turnLifecycle. No production change.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Conflicts resolved keeping both sides:
- codex-structured-thread-facts.ts: main's version (main removed the retry reader's last
  caller and now uses codexThreadStoppedRunning for the Stop turn-open wait).
- host-mutations: main's working-gated Stop keeps the settle-in-doubt of sends left by Stop,
  now inline in the finally.
- codex turn-end settlement test, transcript slots test: both sides' cases.
- outbox hook: main's extracted unconfirmed probe, with this branch's comment carried into it.
- NativeChatStructuredSession.transport-probe.test.tsx: main's deletion accepted; its
  "doubted send drawn as sent, never re-sent, next goes out" case ported to the outbox hook test.
…ettlement classifier

The Stop-withdrawal restore and the turn-grouping in-flight set landed on main reading a
submission's dispatch state directly, which the settlement boundary test refuses. Withdrawn is a
refused send whose rejection classifies as withdrawn; the in-flight set is the open sends not yet
matched to a provider item.

@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. Two earlier documentation-nit threads are still open on files this delta does not touch, so this isn't an approval.

Reviewed changes

Since the prior pullfrog review at 814cf11, three commits landed:

  • Merged origin/main into the branch. The merge adopts main's Stop-withdrawal (#23026), turn grouping (#23671) and terminal-chat changes while keeping the branch's crash-boundary settle (settleSendsAtTurnOver is still called from the Claude translator and the Codex turn boundaries); the conflict files resolve by keeping both sides, and the branch's settle-in-doubt of sends left by Stop moves inline into main's working-gated finally.
  • Main's two new send-state checks now read through the settlement classifier. dispatchWasWithdrawn is folded into structuredAgentSessionSubmissionWasWithdrawn in structured-agent-session-submission-settlement.ts — equivalent, since a withdrawn rejection is cancelled, never notDelivered, so it classifies refused — and the turn-timing in-flight set tests settlement === 'open' rather than a raw pending, so a live unknown awaiting its echo can anchor a turn's opener fallback.
  • Main's new turn-grouping fold fixture was given the field the fold now reads. A test-only commit adds reportsFailure: false to the #23671 fixture, clearing a tc:node break the merge created when main's fixture (which predates the branch's newly-required NativeChatTurnFoldRow.reportsFailure) was combined with the branch's type.

Verified locally at the live head (2586cf9b): pnpm tc:node and tc:web clean, 187 focused tests pass (settlement classifier + its boundary ratchet, turn timing, latest-request, send disposition, withdrawn-message restore, outbox and outbox admission), and live-head CI has no failing check.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

… interrupted

Main's Stop now interrupts a turnless Stop only while the session reads working; the settle of a
handed-over send in doubt runs after it either way. Only turn-naming Stops were pinned.

@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. The commit is test-only; two earlier review threads remain open, so this isn't an approval.

Reviewed changes

Since the prior pullfrog review at 2586cf9b, one commit landed (c9ecc80):

  • Pinned that a turnless Stop settles the send it interrupted. The existing "interrupts a handed-over message before its turn opens" case now also asserts the journal submission ends unknown, reason stopped_before_acknowledgement, recovered: true — the facts markPendingSubmissionsUnknown writes and the settlement classifier reads as drawn-as-sent, so the chat reads idle. Before this commit only the cancel result, the provider call and the status row were pinned; the settle the reopen deadlock depended on had no end-to-end assertion. Ran the suite: 10 tests pass.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@brennanb2025

brennanb2025 commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Review status: ready for review at c9ecc80. CI green (all five unit shards, typecheck, static analysis, cross-version wire, packaging). Not merging.

Since the last status, the open decisions were carried out, each matching how comparable agent chat tools behave:

  • No notice when a chat is closed or Orca quits with an unanswered message; the message stays drawn as sent.
  • A message still in doubt always has a way to settle: when the agent says its turn is over (for Claude, its own "idle" signal, so a message typed mid-reply is not settled early), on an error the agent will not retry, when handing it over throws, and on Stop. Stop always works and never fails because of this bookkeeping. The idle sweep no longer stops an agent that still owes a reply.
  • The crash notice now depends on what happened: an Orca restart shows "didn't survive the restart", an agent that exited while Orca kept running shows "stopped while this response was in progress". Always one notice per event.
  • A failure notice stays outside the collapsed "Worked for" summary only when the turn ended in it, or when it belongs to no turn.
  • Released desktop and phone apps talking to a new host see a crash-left message as sent (no Retry, no waiting queue), through a per-connection downgrade; nothing stored changes. Their older fold may still collapse the crash notice; that cannot change in shipped code.

Review: the architecture was re-checked after the "is this message settled" rule kept being copied into places that disagreed; every client reader now goes through one shared classifier, and a test fails on any new copy. Four review passes this round, the last two clean. Typechecks (node, web, cli, mobile) pass.

Live QA on a separate Mac against the real Claude CLI (screenshots in the description): crash then reopen shows one notice and survives reopening; crash then send works; Stop on a message in doubt returns the chat to idle and the next message is answered; killing only the agent shows one visible "stopped" notice; quitting with a message in doubt shows no notice; a message typed while Claude is answering keeps the chat and sidebar working until it is answered, with nothing reporting "finished" early.

Not run live: Codex (no usable login on the QA machine) and the phone (the emulator cannot enter a pairing code). Known edges are listed in the description, including a message the agent accepted but never echoed with no turn running (needs a wedged CLI).

Relation to #21062: this PR covers the problem #21062 fixes (a message whose turn ended without an acknowledgement stays "working" forever), for Claude and Codex, but not all of its mechanism; #21062 is left open.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Closing this stale crash-recovery implementation while preserving the remaining problem: a message the agent may have received must not be guessed unsent and delivered twice.

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