Skip to content

perf(native-chat): parse a Claude transcript on attach only when a send is in doubt - #23606

Closed
brennanb2025 wants to merge 1 commit into
brennanb2025/sta-7416-delivery-by-message-idfrom
brennanb2025/sta-7434-attach-lazy-transcript
Closed

brennanb2025 wants to merge 1 commit into
brennanb2025/sta-7416-delivery-by-message-idfrom
brennanb2025/sta-7434-attach-lazy-transcript

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 8 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​422 $\color{#cf222e}{\Huge{\mathbf{−}}}$​211 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​211
Prod 11 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​307 $\color{#cf222e}{\Huge{\mathbf{−}}}$​351 $\color{#cf222e}{\Huge{\mathbf{−}}}$​44

Stacked on #23604. This PR targets brennanb2025/sta-7416-delivery-by-message-id. Retarget it to main after #23604 merges.

ELI5

When Orca reopens a Claude chat, it can check Claude's saved transcript to see whether a message sent just before a crash actually reached Claude. Before this change, it read and parsed the whole transcript on every reopen, even when no message needed checking, and when one did it read the file two or three times. Now it only takes the file's size up front. It reads the transcript only when a message really is in doubt, and then it reads it once.

What Changed

Before: every Claude chat attach streamed the whole project transcript before looking at whether anything needed deciding. The branch proof parsed every line, a second pass re-parsed every line to collect the prompts after the anchor, and the id lookup from #23604 opened the file a third time and parsed every line again. All of this ran on the main process during startup restore, once per restored chat.

After:

  • Sampling. Before a new Claude child is started, the attach records only what has to be fixed at that moment: whether a turn is running, the transcript path, and its size and file identity. That is one stat, with no open and no parse.
  • Reading. Restart reconciliation calls the sample's read() only after the journal shows a send that is pending or unknown. That read is one pass over exactly the pinned byte range. Each line is parsed once and feeds the branch proof, the anchored window and the id lookup together. Each prompt is kept as one small fingerprinted row under the first line that carries its uuid. The window is picked out once the proof has named the chain.
  • Adapter method. providerHistoryWindow is renamed to sampleProviderHistory and now returns { read() }. The Claude adapter, the router and the test harness follow.

Measured on a synthetic 50.6 MB Claude transcript (8,000 records, 2,000 prompts), median of 7 runs on an M-series Mac:

Attach Before After
Nothing in doubt (the usual case) 117.5 ms wall, 146 ms CPU 0.1 ms wall, 0.1 ms CPU
A send in doubt 117.5 ms wall, 146 ms CPU 43.7 ms wall, 54 ms CPU

Both runs produced the same window (2 items, boundary consistent) and the same id set (2,000 user records).

Why

Why the sample is taken before acquiring the owner: the ordering is load-bearing, so it stays.

  • Once acquireOwner starts the new child, the adapter's liveness signal becomes conservative, so a missing prompt can no longer prove it was never delivered.
  • The new child may also append to the transcript.

What is taken early, and what is left late:

  • Liveness and the byte range are fixed before acquisition, and nothing appended after the pin is ever read.
  • The parse is the expensive part, and it is the only part moved after the journal is open, where "is anything in doubt?" can be answered.
  • A file that was replaced (different device or inode), truncated, or deleted between the pin and the read is refused. Those sends stay unconfirmed, which is the safe outcome.

Alternatives considered:

  • Open the journal before sampling. This would answer "in doubt?" first. It would reorder the superseded-journal handoff (onAcquiring / beforeJournalOpen) around acquisition, which is riskier than deferring the parse.
  • Keep the two-pass read and only add the gate. This fixes the common case, but an attach that does need a decision would still parse the file three times. The single pass keeps one small row per prompt, and the id lookup already keeps one entry per user record, so the extra memory is the same order as what fix(native-chat): after a crash, show an unconfirmed message as sent with one chat notice instead of guessing from the transcript #23604 already holds.
  • Hold an open file handle across acquisition. This would pin the exact bytes even through a rename. But it creates a handle that every attach failure path must close. A stat identity check gives the same safety with nothing to clean up.

Behavior differences, all in the conservative direction:

  • Torn tail during the read. The old read could re-read once at a larger size to finish a torn tail that was still being written. The deferred read cannot tell those bytes apart from the new child's, so it never reads past the pin. A torn tail at the pin now leaves the boundary unproven, so sends stay unconfirmed.
  • Running turn with an unprovable boundary. In fix(native-chat): after a crash, show an unconfirmed message as sent with one chat notice instead of guessing from the transcript #23604, a window whose boundary could not be proven reported "no turn running" even when a turn was running. Then a handed-over send absent from the whole file could be rejected while its turn was still running. The window now always carries the real running-turn fact, so that send stays unconfirmed.

SSH and remote: Claude structured sessions only run on the execution host's own local filesystem (non-WSL), so the transcript is always local to the process doing the read. Nothing new is persisted and nothing changes on the wire.

Linked Issue

Linear: STA-7434

Visual Proof

N/A. This changes when and how often a file is read on the host, and nothing on screen changes.

Testing

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

New structured-agent-session-attach-transcript-reads.test.ts drives attachJournal with the real Claude sampler over a real transcript and counts opens and streams of that file:

Updated tests:

  • structured-agent-session-acquisition-options.test.ts asserts the sample fixes liveness before acquisition, read is never called for the first attach, and it is called exactly once for the attach with a crashed send.
  • claude-transcript-branch-streaming.test.ts and claude-history-window-source-budget.test.ts now pin the new read contract: one stream over one descriptor, bytes appended after the pin are never read, and a file replaced, truncated or unlinked after the pin is refused.
  • journal-restart-reconciliation.test.ts gains "reads no history when nothing is in doubt" and "leaves a send in doubt when reading history fails".

Commands:

Not done: live Electron QA of a crash-and-restore flow.

Review

Agent skill upstream boundary

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

Notes

Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance

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)

…nd is in doubt

Every Claude chat attach read the whole provider transcript before checking
whether any message needed deciding: once for the branch proof, once more to
replay the anchored window, and a third time for the id lookup, each parsing
every line. Most attaches have nothing in doubt and paid for all of it.

The attach now samples only what must be fixed before a new child starts: the
running-turn fact, the transcript path, and its size and file identity (one
stat). Reconciliation reads the sample only after the journal shows a send in
doubt, and that read is one pass over exactly the pinned bytes, parsing each
line once for the branch proof, the anchored window and the id lookup alike.
Bytes appended after the pin, which the new child may have written, are never
read, and a file replaced or truncated since the pin is not read as evidence.

Linear: STA-7434

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

ℹ️ Minor suggestions only.

Reviewed changes

  • Lazy transcript read contract — ProviderHistorySample (journal-submission-reconciler.ts) replaces the eager ProviderHistoryWindow; the adapter method becomes sampleProviderHistory, and reconcileJournalSubmissionsAgainstHistory now calls history.read() only after comparableSubmissions is non-empty.
  • One-stat pin — sampleClaudeProviderHistory resolves the path and pins { size, dev, ino } with a single stat; readPinnedClaudeTranscript re-verifies identity, streams exactly the pinned byte range once, and refuses a replaced/truncated/unlinked file.
  • Single shared pass — createClaudeBranchAncestryPass + createClaudeHistoryPass feed the branch proof, the recorded-history id lookup, and the anchored window from one parse per line; the old two-pass replay, createWindowCollector/createRecordedCollector, and readClaudeRecordedHistory are removed.
  • Conservative behavior changes — no growth-retry (bytes appended after the pin are never evidence), and inconsistent() now carries the real turnInFlight, so a handed-over send absent from a whole file during a running turn stays unknown instead of rejected.

I traced the refactor against #23604's collectors and found the recorded-history/window semantics preserved (first-seen uuid tie-break, harness-injected filter, fingerprint map, whole flag), with the read-error and replacement paths strictly more conservative. The lazy gate is genuinely covered: structured-agent-session-acquisition-options.test.ts fails if the sample is taken after acquisition, and the new read-count test fails if the parse is eager. Focused suites pass locally (99 tests).

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

state.observedStatBytes = snapshot.size
stat: async (options?: Parameters<FileHandle['stat']>[0]) => {
const snapshot = await handle.stat(options)
state.observedStatBytes = Number(snapshot.size)

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 state.growth machinery in this mock (declared at line 16, reset in beforeEach, and consumed in the if (state.growth) block just below) is now dead — no test sets it after the growth-retry tests were removed. Dropping it keeps the mock honest about what it actually simulates.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status: recommend closing this PR. #23604 now does its job.

#23604 moved while this PR was under review. Its commit 07368bb reads the Claude transcript on attach only when a send is actually in doubt, and it also postpones the transcript path lookup until then. Its commit 50b9bf2 makes a restart only ever confirm a send: a send found under its own frame id is delivered, anything else stays unconfirmed. After that change, the byte range this PR pins before starting the new Claude process no longer protects anything, because extra bytes can only add true matches.

I merged the new base locally. Every conflict came down to the base's version, and the merged tree is identical to #23604, so nothing was pushed.

Measured on a synthetic 52 MB transcript (8,000 records, 2,000 prompts), median of 7-9 runs, new base vs this PR's single-pass read:

The only remaining wins are for sends written before #23604, and they last only until those are gone. Getting them would mean rewriting the branch-proof code #23604 just stabilized, which isn't worth a PR.

Live QA on a remote Mac (before the base moved; 42 MB transcripts):

Found during QA on the old #23604 head (not caused by this PR, and not yet re-checked on the new #23604 head):

  • A send stranded by a crash is not reconciled on relaunch. Only the next send into that chat attaches.
  • Retry on an unconfirmed message does not attach either. It changes the note to "unconfirmed and Orca will not send it again".
  • A message Claude never received lost its "unconfirmed" note after one more relaunch, so it looked sent.

Not done: the end-of-review readiness pass, and screenshots, since there is no change left to ship.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Closing: superseded by #23604, which removes the transcript-based delivery decision after a restart entirely (a message Orca can't confirm is shown as sent with one session-level notice), so this change is no longer needed.

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