Skip to content

feat(native-chat): decide a restart-stranded send against provider history - #20139

Merged
brennanb2025 merged 3 commits into
mainfrom
brennanb2025/wire-submission-reconciler
Sep 13, 2026
Merged

brennanb2025 merged 3 commits into
mainfrom
brennanb2025/wire-submission-reconciler

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

ELI5

If the agent process dies or Orca restarts while your message is in flight, Orca currently gives up and marks it "delivery unconfirmed" — forever. Retry refuses (correctly, it might duplicate), so your only option is to select your own text, copy it, and type it again. This makes Orca go and look at the agent's transcript and decide: it either landed or it didn't.

The critical change, in one sentence

Orca now asks the agent's transcript what happened, instead of assuming the worst and stopping.

journal-submission-reconciler.ts documents a two-step recovery in its own header:

"Every surviving pending becomes unknown and is then matched against provider history."

Only the first half was ever wired:

attach → markPendingSubmissionsUnknown()   <- step 1: mark everything unknown
         (nothing)                          <- step 2: never ran

reconcileSubmissions is imported by one test file and has no production caller. So every message in flight at a crash became permanently unconfirmed, with no attempt to find out whether it had actually landed.

After:

attach → markPendingSubmissionsUnknown()
       → reconcileSubmissions(history window)
           found in transcript      → accepted   (banner clears itself)
           provably absent          → rejected   (Retry now works)
           can't tell               → unknown    (unchanged, honest)

The history window is the Claude project transcript — the file a resume actually replays — which is what makes "absent from it" mean something rather than nothing. It is sampled before a new agent child starts, because once one is running an absent message can no longer prove non-delivery.

That is the change: a step that was written, tested, and never called now runs.

What you see today vs after

The agent's process dies mid-send, or Orca restarts while a message is in flight.

Today After
Banner "Message delivery is unconfirmed." Gone, if the message actually landed
Retry Refuses. The entry disappears, leaving a transient error line Works, if the message provably did not land
Your options Copy your own text out of the bubble and retype it Usually nothing — it resolves itself

The key change is that Orca stops guessing. It reads the transcript the agent will resume from and gets one of three answers: found (settle silently, no banner), provably absent (mark it as not sent, so Retry is safe and works), or genuinely can't tell (say so honestly and leave it alone).

Why

There's a module in the repo, journal-submission-reconciler.ts, written and fully tested for exactly this. Its own header describes a two-step process: "Every surviving pending becomes unknown and is then matched against provider history."

Only step one ever shipped. Step two has never had a production caller — the function is imported by one test file and nothing else. So Orca marks everything unknown and never does the matching, which is why a stranded message has no recourse.

This is also the gap that makes us unusual. Every comparable implementation can resolve this ambiguity by reading something authoritative. We had the machinery and never called it.

What changed in the code

  • A new reader builds a history window from the Claude project transcript — the file a resume actually replays, which is what makes "absent from it" mean something.
  • Boundary consistency reuses the existing proveClaudeTranscriptBranchFromJsonl rather than inventing a check: a forked branch, a compacted log and a truncated tail each already throw there, and each becomes "this window proves nothing."
  • The reconciler runs at attach, right after the sweep that marks things unknown — completing the two-step.
  • The history window is sampled before a new agent child is started, because once one is running the liveness signal has to go conservative and an absent message can no longer prove non-delivery.

Limits, stated plainly

  • Codex gets more from this than Claude. The exact-match path needs the agent to echo our message id back. Codex does; Claude doesn't, so Claude falls back to matching on a payload fingerprint.
  • Two identical prompts stay unknown by design. If you send "continue" twice, fingerprint matching can't tell them apart, and the reconciler deliberately refuses rather than guess — guessing would either duplicate your message or drop one.
  • This is not the structural fix. Every comparable implementation reconciles against a record on its own side of the boundary — their own database or a transcript their own host wrote. This reads the agent's transcript, which is the one shape with no precedent. It is strictly better than deciding nothing, but the real answer is a near-side record of the send, and that is not this PR.

Two guards worth reviewing closely

Because Claude doesn't echo our id, the fingerprint path is the entire risk surface — and a wrong "provably absent" means a delivered message gets sent again.

  1. A transcript stores a pasted image as base64, and the block decoder silently drops it for want of a url or path. Such a record would enter the window advertising a text-only fingerprint, where an unrelated text-only message with the same text could claim it. The window now inspects raw content before decoding and excludes any record a part would be dropped from.
  2. A message carrying an image can never match a transcript that keeps only base64, so it matches nothing by construction rather than by absence — and would fall straight through to "provably not delivered". Only text-only messages are handed to the reconciler.

Both fail a named test when removed.

Found and not fixed here: the block decoder silently dropping base64 images is a pre-existing bug with a blast radius beyond this feature. It deserves its own change.

Testing

pnpm tc clean. 23 new tests, each ablated. Two pre-existing failures, both verified: claude-structured-real-cli.test.ts fails identically on plain main (checked out detached to prove it), and claude-tui-resume-real-binary.integration.test.ts dies on ps -axo under load, passes alone, and is skip-gated so it never runs in CI.

claude-structured-session-adapter.ts hit its line cap when the history method was added, so path resolution was split into the window module rather than shaved.

Visual proof

N/A. No UI change — the visible effect is a delivery notice resolving itself on attach instead of persisting, which has no before/after frame that isn't simply the absence of a banner.

Notes

  • Wire: no new value. Messages move between states that already travel the wire.
  • Asymmetric risk respected: a wrong "not delivered" re-sends a message the model already has. That is why the boundary guards are load-bearing rather than defensive, and why both new guards exist.
  • Related: fix(codex): settle a structured send on admission, and stop minting a colliding identity #20138 leaves a population of journals already written with a colliding identity. This is the mechanism that could repair them.
  • One review finding against this PR — that "provably absent" is unreachable because the agent child spawns before attach — is a false positive. The history window is sampled before acquisition and passed in; the code carries a comment naming that exact hazard.

…story

`markPendingSubmissionsUnknown` flips every surviving `pending` submission
to `unknown` on attach and stops there. The module written to finish the job
describes the intended two-step in its own header -- "Every surviving
`pending` becomes `unknown` and is then matched against provider history" --
and only the first step ever shipped. `reconcileSubmissions` has been
imported by exactly one test file and nothing else.

So a message stranded by a dead child or a host restart had no recourse but
retyping: Retry correctly refuses to redeliver something that may already be
with the model, the outbox entry drops, and a transient error line is all
that remains. This wires the second step, so those are decided on evidence
instead of refused.

Caller placement is the design decision, because where it runs determines
what a consistent history boundary can mean. It runs in `attachJournal`,
immediately after the sweep: attach happens after the record store's CAS
hands this host the lease and before a provider child starts, so nothing can
append to provider history while it is read, and the window stays valid
until the resume consumes it. The three other settlement sites can all be
overtaken by a newly started child before the read is acted on.

The history source is the Claude project JSONL for the handle chain's
provider session id -- definitionally what a resume replays, which is what
makes absence meaningful. Boundary consistency reuses
`proveClaudeTranscriptBranchFromJsonl` rather than inventing a check:
a fork, a compacted log and a truncated tail each already throw there, and
each maps onto `boundaryConsistent: false`. A null leaf uuid is also false,
because there is no anchor to prove a start from.

Two guards were needed that the reconciler cannot enforce itself, because
Claude echoes no client message id and only the fingerprint pass can fire:

- A transcript records a pasted image as base64, and the block decoder drops
  it silently for want of a url or path. Such a record would enter the
  window advertising a text-only fingerprint, where an unrelated text-only
  submission with identical text could claim it. The window now inspects raw
  content parts before decoding and excludes any record a part would be
  dropped from.
- A submission carrying an image-ref path can never match a transcript that
  keeps only base64. Without a guard it matches nothing by construction
  rather than by absence and falls straight through to `not_delivered`, and
  a Retry would then redeliver an image already sent. Only text-only bodies
  are handed to the reconciler.

Both guards fail a named test when removed.

Limits, stated rather than implied. The exact-match tier needs the provider
to echo our id, which Codex does and Claude does not, so Claude resolves by
fingerprint alone -- and two identical prompts deliberately reach
`ambiguous_match` instead of guessing. Repeated one-word prompts therefore
stay unknown by construction. This decides what it can prove and refuses the
rest, which is the intended contract, not a shortfall in the wiring.

Found while doing this and not fixed here: the block decoder silently
dropping base64 images has a blast radius beyond reconciliation and deserves
its own change.
@coderabbitai

coderabbitai Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

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: 70f93233-cd10-4a3c-8375-ce02711a1a07

📥 Commits

Reviewing files that changed from the base of the PR and between e1b2e2f and 6815049.

📒 Files selected for processing (1)
  • src/main/claude/claude-structured-session-adapter.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/claude/claude-structured-session-adapter.ts

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


📝 Walkthrough

Walkthrough

Adds bounded Claude transcript history windows anchored to the durable leaf. Preserves raw prompt text for matching. Adds reconciliation for unresolved text-only submissions and ambiguous duplicate fingerprints. Exposes provider history through structured session adapters. Samples history during attach before acquiring a replacement child and preserves unknown submissions when history is unavailable or inconsistent.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 68150

No concrete merge-blocking risk remains from the reviewed change.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.54% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 37 functions across 13 files. 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 summarizes the main change: provider-history reconciliation for sends stranded by a restart.
Description check ✅ Passed The description is detailed and covers the problem, approach, scope, limitations, testing, visual proof, and risk considerations. It includes a related issue reference, although it does not use the te…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch

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

❤️ Share

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 89cb1dd1-324b-4080-85c9-f0e15f34a8eb

📥 Commits

Reviewing files that changed from the base of the PR and between cd9aa43 and ebd22d9.

📒 Files selected for processing (9)
  • 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-restart-reconciliation.test.ts
  • src/main/native-chat/agent-session-journal/journal-restart-reconciliation.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-reconciliation.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts

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

@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 rejected: not_delivered half of this reconciliation never fires in the cold-restart path it was built for — the provider child is spawned before attachJournal reads history, so the window always reports turnInFlight: true and only accepted resolution actually takes effect there.

Reviewed changes

  • Claude provider-history window (claude-structured-history-window.ts) — reads the Claude project JSONL, proves branch consistency via proveClaudeTranscriptBranchFromJsonl, walks leaf→anchor, and fingerprints each surviving text-only user prompt.
  • Reconciliation caller (journal-restart-reconciliation.ts) — feeds unknown submissions through the pre-existing reconcileSubmissions, restricted to text-only bodies and to history items the journal has not already committed.
  • Adapter + router surface — adds providerHistoryWindow? to StructuredAgentSessionAdapter, implements it on the Claude adapter using its sessions map for liveness, and routes it generically.
  • Attach integration (structured-agent-session-attach.ts) — runs the reconcile right after markPendingSubmissionsUnknown and drops settled ids from unconfirmedClientMessageIds.

⚠️ rejected: not_delivered is unreachable in the restart path

The window's turnInFlight flag is the only thing standing between "absent from history" and not_delivered, and it is wired to the Claude adapter's live-session map — which is populated by acquireOwner before attachJournal runs. The consequence: in the host-restart scenario this PR exists for, every stranded submission resolves to accepted (when it matches) or stays unknown (turn_in_flight), and the "provably absent → reject → re-sendable via Retry" case never fires. The message the PR description calls out ("a message stranded by a dead child … had no recourse but retyping") is only fixed for the delivered half; an undelivered stranded message still cannot be retried.

The attachJournal doc comment states it runs "before onAttached starts a provider child", but the child is actually spawned inside acquireOwner → adapter.acquire → acquireClaudeSession, which completes first.

Technical details
# `turnInFlight` is always true at attach time, so `not_delivered` is unreachable

## Affected sites
- `src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts:143` — `acquireOwner` runs before `attachJournal` (line 214) in `performAttach`.
- `src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts:39` — `acquireOwner` calls `adapter.acquire`, which spawns the child.
- `src/main/claude/claude-structured-session-acquisition.ts:291` — `sessions.set(sessionId, liveSession)` before `acquire` resolves.
- `src/main/claude/claude-structured-session-adapter.ts:174` — `hasLiveSession: this.sessions.has(...)`.
- `src/main/claude/claude-structured-history-window.ts:232` — `turnInFlight: hasLiveSession`.
- `src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts:177` — `if (history.turnInFlight) return unknown`, so `not_delivered` requires `turnInFlight === false`.

## Required outcome
- `turnInFlight` must mean "a turn is actually in flight", not "a child exists". At attach the freshly spawned child is idle — nothing is appending — so a provably absent message should reach `rejected: not_delivered` and become re-sendable via Retry.

## Suggested approach
- Derive `turnInFlight` from an actual in-flight dispatch (a running turn / an outstanding `pending` send) rather than `this.sessions.has(...)`.
- Alternatively, run the sweep + reconcile before `acquireOwner` spawns the child, while keeping the boundary-consistency reasoning the placement is meant to buy.

## Open questions for the human
- Is the intended primary path the cold restart (where the child is already spawned by `acquireOwner`), or the `settlement-retry` path (`structured-agent-session-settlement-retry.ts:28`, where no child is yet spawned and the reconcile *does* decide)? The description reads like the former.

ℹ️ Nitpicks

  • The fingerprint equality the window relies on is only pinned for whitespace-free text. claudeContentBlocks normalizes block-form text through extractString (.trim()), while the stored send body is fingerprinted from structuredAgentSessionSendBody (.trimEnd()); these diverge on leading whitespace, so a delivered message with leading whitespace stored as block content would fingerprint differently and be wrongly rejected. Worth a whitespace normalization test, given a wrong rejected is the one outcome that costs a duplicate.

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

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Electron CDP loaded-app evidence (PR worktree)

Launched this PR worktree with ORCA_BACKGROUND_LAUNCH=1 via config/scripts/run-electron-vite-dev.mjs on CDP 9336 / renderer 5175, attached with Playwright CDP, and confirmed window.api.app.getIdentity():

{"name":"Orca: brennanb2025/wire-submission-reconciler","isDev":true,"devLabel":"wire-submission-reconciler","devBranch":"brennanb2025/wire-submission-reconciler","devWorktreeName":"wire-submission-reconciler","devRepoRoot":"/Users/brennanbenson/orca/workspaces/orca/wire-submission-reconciler","dockBadgeLabel":null}

Visible state: isolated profile (no workspaces registered), full Orca chrome, and the Agents Session History surface (250 shown · 250 recent, including this worktree). Native chat transcript root was not mounted. Restart-stranded send reconciliation was not reproduced in UI — that path is main-process attach against provider history and is not a UI change; exercising it would require a stranded live session plus host restart.

Renderer console: 0 errors, 3 warnings (React DevTools hint, Radix tooltip controlled/uncontrolled, React Grab outdated). Main process: MaxListenersExceededWarning on BrowserWindow (11 closed listeners).

Loaded Orca home chrome (isolated profile)

Agent Session History with surrounding app chrome

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Electron CDP visual verification (follow-up)

Launched this PR worktree with ORCA_BACKGROUND_LAUNCH=1 (hidden CDP, no focus steal). Attached playwright-cli to this instance, not another worktree.

App identity (window.api.app.getIdentity()):

  • name: Orca: brennanb2025/wire-submission-reconciler
  • isDev: true
  • devLabel / worktree: wire-submission-reconciler
  • branch: brennanb2025/wire-submission-reconciler
  • repoRoot: /Users/brennanbenson/orca/workspaces/orca/wire-submission-reconciler
  • CDP 127.0.0.1:9334, renderer http://127.0.0.1:5174/, isolated ORCA_DEV_USER_DATA_PATH

What was exercised

  • Added this worktree as a project via IPC (no native folder picker).
  • Enabled Chat UI + structured native chat + default Chat UI view.
  • Opened a real Terminal 1 in brennanb2025/wire-submission-reconciler (prompt shows that path/branch).
  • Attempted a structured Claude native-chat launch (launchAgentInNewTab, focusAfterMenuClose: structured-session).

Visible UI

  • Sidebar: project wire-submission-reconciler, active worktree brennanb2025/wire-submission-reconciler, Linked PR feat(native-chat): decide a restart-stranded send against provider history #20139.
  • Terminal 1 at ~/orca/workspaces/orca/wire-submission-reconciler [brennanb2025/wire-submission-reconciler]. No Message delivery is unconfirmed. banner (none owed: no stranded outbox).
  • Settings → Experimental: Chat UI on, Default view Chat UI, Use updated structured native chat on.

Loaded workspace with this PR worktree

Terminal 1 in this worktree

Experimental Chat UI / structured native chat enabled

A collapsed Playwright inspector chip at the bottom center is CDP tooling, not product UI.

Gap (not fabricated)
Structured session did not come up. Renderer console: StructuredAgentSessionCreateUnknownOutcomeError: Claude did not finish starting session claude_7acf6104_… within 10 seconds. Verify the selected Claude account is signed in and CLAUDE_CONFIG_DIR contains valid credentials, then retry; no SessionStart or system/init proof arrived. Isolated profile has no signed-in Claude account. Restart-stranded pending → history reconcile → delivery notice resolving on attach therefore was not exercised: that needs a live structured journal with a stranded send plus attach against provider history. Would require killing a provider child mid-send or writing fake journals; neither was done.

Cleanup
Only this launch (runner PID 74159 / Electron 74513 on 9334/5174) is stopped after this comment. No source edits, no commits, no screenshots in the diff.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status: land after fixes

The review loop is clean and the finalized changes are pushed at 681504966669b4593e2ef46c968f2e63a7d73ac3.

Fixed the material restart-reconciliation issues found during review:

  • sample Claude history before acquiring a replacement child so not_delivered remains reachable;
  • resolve history from the session-pinned Claude account home;
  • keep provider exits live until tree-proof settlement completes;
  • exclude already accepted and committed provider items from new matching;
  • preserve transcript text for exact payload fingerprints;
  • treat duplicate fingerprint candidates and Claude multi-text prompts conservatively as ambiguous/unknown.

Validation passed locally:

  • 5 targeted suites: 65/65 tests;
  • pnpm tc;
  • changed-code quality, type-aware checks, and React Doctor: 0 new findings;
  • git diff --check;
  • commit hooks: oxlint, React Doctor, and oxfmt.

Electron CDP evidence is attached in the visual verification comment. It verified the exact PR worktree identity and rendered Orca chrome. The reconciliation path is main-process/backend behavior, so it was not reproduced in the UI; no mobile-facing surface is involved. Final readiness checklist: PASS, with no proven P0/P1/P2 findings. GitHub CI for this head is still in progress.

The pre-existing unrelated pnpm-lock.yaml working-tree edit was preserved and is not part of the pushed commits.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

The delta since the prior Pullfrog review (ebd22d9) hardens restart reconciliation and, critically, makes the rejected: not_delivered outcome reachable in the cold-restart path it was built for.

  • Provider history sampled before acquisition (structured-agent-session-attach-flow.ts) — performAttach now reads the window before acquireOwner and passes it into attachJournal, so the adapter's liveness map does not yet contain the new child and turnInFlight is false; a lease this process has not indexed still forces turnInFlight: true via ownerAlreadyAdmitted.
  • Account-home transcript resolution (claude-structured-history-window.ts, adapter + router + interface) — the window resolves the Claude JSONL from the record's accountHome.path instead of the process default, mirroring the existing readTranscriptLeaf resolution.
  • Whitespace-preserving fingerprints (claude-structured-history-window.ts) — rawTextParts recovers the raw text bytes the shared decoder would trim, and bails when raw parts and decoded blocks do not line up, closing the leading-whitespace mismatch the earlier nitpick raised.
  • Batch fingerprint matching (journal-submission-reconciler.ts) — identical unsettled sends sharing a fingerprint resolve as a group: a single candidate marks all of them ambiguous_match rather than assigning it to the first and rejecting the rest.
  • Single text-block comparability and committed-item exclusion (journal-restart-reconciliation.ts) — only single text-block bodies are reconciled, and history items already consumed by an accepted submission are excluded so an older identical message cannot stand in for a new send.
  • Test coverage — account-home resolution, whitespace preservation, batch ambiguity, multi-block sends, older accepted items, and the pre-acquisition sampling order (the last fails if the window is read after acquireOwner).

Both prior concerns — the unreachable not_delivered path and the whitespace fingerprint drift — are addressed, and I found no new issues in the delta.

Pullfrog  | View workflow run | Using openrouter/deepseek/deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@brennanb2025

Copy link
Copy Markdown
Contributor Author

CI update

The transient Node 24 shard failure was rerun successfully after its original npx pnpm ECONNRESET. The full PR workflow is now green: all test shards, static analysis, typecheck, cross-version wire compatibility, package, Windows package, and aggregate verify passed.

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