Skip to content

fix(codex): settle a structured send on admission, and stop minting a colliding identity - #20138

Merged
brennanb2025 merged 12 commits into
mainfrom
brennanb2025/codex-admission-settlement-2
Sep 14, 2026
Merged

brennanb2025 merged 12 commits into
mainfrom
brennanb2025/codex-admission-settlement-2

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 15 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​468 $\color{#cf222e}{\Huge{\mathbf{−}}}$​84 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​384
Prod 17 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​337 $\color{#cf222e}{\Huge{\mathbf{−}}}$​124 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​213

ELI5

If you send a second message to Codex while it is still working on the first, both messages are filed in Orca's journal under the same join key — a durable record on disk that is simply wrong. This gives each message its own key.

Be clear about the impact: this is corrupt data, not a symptom you can currently see. On today's Codex (0.153.4) the bad key does not produce a visible duplicate, because Orca resumes with excludeTurns: true and so never replays provider history — the restored transcript is rebuilt from journal rows keyed by distinct client message ids. The corruption is latent. It becomes visible on any path that does replay history: an older provider that does not support excludeTurns, or the crash-boundary reconciler, where two rows both claiming :0 are genuinely ambiguous.

The critical change, in one sentence

The key that joins a message to the agent's copy of it is now learned from the agent's echo, instead of from the reply to the request that sent it.

To be precise about what is and is not changing: a message's durable identity is orca:<clientMessageId> — minted by the client, never derived from anything the provider says, and untouched by this PR. The Codex string codex:<thread>:<turn>:<ordinal> is a join key, stored as an alias pointing at that durable id. This PR fixes how that join key is derived. Nothing about message identity itself changes.

Before, identity was minted at dispatch time from the turn/start response, plus a hardcoded ordinal:

providerItemId = codex:<thread>:<turnId from the turn/start reply>:0

Both halves break when Codex coalesces. A mid-turn send returns the running turn's id, so turnId is the same for both messages — and :0 is hardcoded, so the ordinal is the same too. Two different messages, one identical durable key.

After, identity is minted at identityFor from the echoed user message — the same code path replay uses when it rebuilds the conversation:

providerItemId = codex:<thread>:<turn the echo names>:<that message's own ordinal>

The key point is not that the new value is more accurate. It is that the key is now produced by the same function restore uses, so the stored identity cannot drift from the one restore computes. The old design had two separate calculations that were only supposed to agree.

The 10-second stopwatch is deleted as part of this, because dispatch no longer needs the turn/start reply for anything but "the transport took it."

What you see today vs after

Send a message while Codex is still working, then restore the session (reopen it, reconnect, or restart Orca).

Today After
Journal join key for the two messages Identical — codex:<thread>:<turn>:0 for both Distinct, each message's own ordinal
Visible duplicate on today's Codex No — history replay is skipped, so the bad key stays latent No
If history replay is reachable (older provider, reconciler) Second message cannot be matched; renders as an extra item Matches correctly
On codex-cli 0.147.0 Worse — neither message can be matched back to history Not at all

Nothing changes if you send messages one at a time and wait for each to finish. This only bites when a send lands while a turn is already running — which is exactly what happens when you add a correction or a nudge mid-task.

Why

Codex coalesces a mid-turn send into the running turn — it does not start a second turn and does not refuse. Orca read the turn id from the turn/start reply and stamped every accepted send ordinal: 0. Two coalesced sends therefore got the same turn id back and were written with the same providerItemId.

That string is durable and it is the key restore uses to match a message against the agent's history. Two messages sharing one key means restore can't tell them apart, so the second message's real history row matches nothing and renders as an extra item.

On codex-cli 0.147.0 it was worse than a collision: the coalesced reply returned a turn id for a turn that never started and never completed, so the stored key named something absent from history entirely.

How we know: this was measured, not inferred. Three repo comments asserted Codex refuses a mid-turn send with "turn already running"; the only evidence behind that claim was a test fixture inventing the error string. Driving a real codex app-server across builds 0.147.0, 0.150.1 and 0.153.4 showed none of them refuse and none fire a second turn/started. Those three comments are corrected here — as a factual correction only; the policy they justified is untouched.

What changed in the code

  • Identity is minted from the echoed user message at identityFor — the same point replay uses — so the stored key can't drift from what restore computes.
  • Dispatch returns as soon as the transport takes the frame; identity settles when the echo arrives.
  • Sends are correlated by client message id instead of by queue position, which is what coalescing breaks.
  • Waiters are cleared on session close and child exit. Previously a timeout was the only thing that ever ended one.
  • The 10-second stopwatch is deleted. Measurement showed it was unreachable on every build tested — the turn id always came back — so it had been dead code since it was written. The comment justifying it ("older builds acknowledge before the id exists") describes no build that exists.

Design note: one assumption this rests on

The join key is recomputed at restore rather than trusted from storage, and live ingest and replay share one function so the two cannot drift. That shared-mint approach is deliberate, but it carries an assumption worth stating rather than leaving implicit.

The assumption: for a completed turn, the order of items in the history array the provider serves equals the order Orca first saw those items live. The ordinal is assigned next++ in first-seen order per (threadId, turnId) (codex-turn-ordinals.ts:118-126), and restore feeds items in plain array order (codex-structured-journal-translation-restore.ts:60-70). Those agree only if the provider's history serialization preserves emission order.

This is not hypothetical — the key deliberately encodes stream arrival order. codex-structured-dispatch-admission.test.ts:62-99 pins it: when two echoes arrive in the opposite order to their sends, the ordinals follow echo order, not send order. Restore must reproduce that same order to recompute the same key.

What breaks if it does not hold: the recomputed key differs from the stored one, the join fails, and the message cannot be matched back to its provider item — the same failure class this PR fixes, reached a different way. Nothing currently enforces the ordering; it is a property of the provider we depend on and do not control.

A second, narrower assumption: a live item's ordinal is never reassigned within a session. ordinalFor memoizes on item id (codex-turn-ordinals.ts:118-122), but that memo is bounded — under MAX_CODEX_TURN_ORDINAL_BYTES (512 KB), trimBytes can evict entries belonging to the current turn while next keeps advancing (:74-95), so an evicted item seen again would receive a higher ordinal. Extreme-memory-pressure path only, but the same failure mode.

Why not avoid the recompute entirely. Two obvious alternatives exist: always re-derive on read and store nothing, or derive once at write and never re-derive. Neither fits here. Orca must persist the key because the journal is the durable record, and it must re-derive at restore because the provider re-serves the same conversation and its item ids are not stable across that boundary. The shared function is what keeps the two derivations from disagreeing; the ordering assumption above is the price of that choice, and it is the part a future reader should check first if a join ever fails.

Known gap — please read before merging

This prevents new collisions. It does not repair journals already written with one. Those rows keep their wrong key permanently. That is invisible on today's Codex for the same reason the defect is — history is never replayed — but it is corrupt data that becomes visible on any path that does replay it.

There is no repair path today. Repairing such a journal would mean re-matching stored messages against the agent's history and rewriting the join key, and nothing in the tree does that.

#20139 does not repair them, for three independent reasons, any one of which is sufficient:

  1. It never runs for a Codex session — providerHistoryWindow has exactly one implementer, the Claude adapter, so the router yields null and no reconciliation is attempted.
  2. Its input filter keeps only pending and unknown submissions. A journal carrying a collided key has them in accepted, and the same module deliberately excludes accepted rows from the history it considers.
  3. The reducer treats accepted as terminal, so the key cannot be rewritten even if the first two were addressed.

An earlier revision of this description claimed #20139 was the repair. That was wrong and is corrected here.

Testing

pnpm tc clean. Codex + native-chat suites green. Five ablation arms, each re-run at the final head after an ablation-tooling bug was found and fixed mid-run (a git restore with an untracked pathspec silently reverts nothing, so the arms had been stacking — all results were re-derived from snapshots that assert both the mutation and the restore landed).

One test is a guard rather than a proof and is labelled so: session-close passes with the clear removed, because close also drops the session from the map.

Three integration tests were failing and are fixed here. They were asserting providerItemId: codex:<thread>:<turn>:0 — the :0 is the collision this PR removes, so those tests were pinning the defect as correct. Their fake provider also never sent the client_id echo that real Codex sends, so correlation couldn't fire against it; the fake is now faithful to the measured behaviour.

Visual proof

Two hidden-Electron runs exist, and it matters what they do and do not establish.

Fixed build (screenshots): mid-turn send, restart, restore — each message rendered exactly once, with distinct non-colliding identities.

Unfixed build, run afterwards against main as a control: the collision reproduced exactly — two accepted submissions with the identical codex:<thread>:<turn>:0, one turn-lifecycle row, no second turn/started, and Codex's rollout holding both user messages on that turn. But each message still rendered exactly once.

So the two arms produce the same observable, and the fixed-build run is not by itself evidence the fix works. What is demonstrated is the collision mechanism on disk; what is not demonstrated is a user-visible duplicate, because the replay path that would expose it is unreachable on current Codex. Stating that rather than letting the passing run imply more than it proves.

Notes

  • Wire: no new opcode, param, or capability. Codex joins the send state Claude already emits; older clients are unchanged via the existing compatibility path.
  • Client-held keys: a client that stored a colliding ...:0 key holds one the host will no longer mint. Those keys were already wrong — they are the defect, not a regression from this — but it does mean a mixed journal indefinitely, since nothing repairs the already-written rows.
  • Open question worth a reviewer's eye: the settled ordinal assumes echo order matches persisted history order. The measurement establishes coalescing and that the echo arrives when the running turn reaches the message; it does not explicitly pin that ordering. A one-line confirmation rather than a change.

… colliding identity

Two sends could be written into the journal under one durable identity.

Codex coalesces a mid-turn `turn/start` into the running turn rather than
refusing it -- measured against real `codex app-server` builds 0.147.0,
0.150.1 and 0.153.4, none of which refuse and none of which fire a second
`turn/started`. The dispatch path read the turn id from the turn/start
response and stamped every accepted send `ordinal: 0`. Since a coalesced
send gets the running turn's id back, two submissions persisted the same
`providerItemId`. That string is durable, and it is the key a restore uses
to match a submission against provider history, so the second message's real
history row matched nothing and rendered as an extra bubble on replay.

On 0.147.0 it is worse than a collision: the coalesced response returns a
turn id that never starts and never completes, so the persisted key named a
turn absent from history and NEITHER message could match.

Identity is now minted from the echoed user message at `identityFor` -- the
single point that mints the journal row's own identity -- so the settled key
is by construction the one replay computes, rather than a parallel
calculation that can drift.

Dispatch returns `admitted` when the transport write completes; identity
settles on the echo through a channel that did not previously exist for
Codex. Waiters are keyed by client message id instead of being shifted off
the front of an array by arrival order, and they are cleared on session
close and child exit -- previously a timeout was the only thing that ever
ended one.

`TURN_ID_WAIT_MS` is deleted. It was never reachable on any build measured:
`readCodexTurnId` returns non-null on all three, so the 10s wait never
fired. The comment justifying it claimed older builds acknowledge before the
id exists, which no tested build does.

Three comments asserting Codex answers a mid-turn send with `turn already
running` are corrected. Their only backing was a test fixture inventing that
error string. The correction is factual only -- every changed line in
`src/main/runtime/orchestration/` is a comment, and mid-turn delivery is
still refused for both providers. Whether that policy is right is a separate
question; it was resting on a false premise.

Known gap, stated rather than implied: this prevents new collisions and does
not repair journals already written with a colliding or phantom key. Those
conversations keep duplicating on restore. Repairing them means re-matching
persisted submissions against provider history and rewriting
`providerItemId` -- which is what `journal-submission-reconciler.ts` is
written for, and it still has no production caller.
@coderabbitai

coderabbitai Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

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: 85d64c7e-ced4-4938-9b0e-6a30e15fb814

📥 Commits

Reviewing files that changed from the base of the PR and between e980959 and b59a661.

📒 Files selected for processing (7)
  • src/main/codex/codex-structured-turn-start.ts
  • src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts
  • src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts
  • src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts
  • src/shared/structured-agent-session-dispatch-rejection.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/codex/codex-structured-turn-start.ts

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


📝 Walkthrough

Walkthrough

The Codex dispatch flow now reports admitted after turn/start succeeds. A bounded tracker matches later Codex userMessage echoes by client message ID and records provider identity. Session acquisition and journal translation wire this tracker into settlement callbacks. Session close clears pending echoes. Runtime late-settlement handling is shared by Codex and Claude. Tests cover correlation, rejection, cleanup, retries, and updated mid-turn behavior.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to b59a6

The updated Codex dispatch flow preserves distinct identities for mid-turn messages and handles later settlement without an identified merge-blocking risk.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.04% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 27 functions across 29 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 and concisely describes the main change: Codex structured sends now settle on admission and avoid identity collisions.
Description check ✅ Passed The description is detailed, focused, and covers the change, rationale, known gaps, testing, and visual proof. It does not include the required Linked Issue entry in the Fixes #... format, and it om…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

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

❤️ Share

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

@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: b0b898db-f52c-4b0d-aae7-0d5ad6a3344f

📥 Commits

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

📒 Files selected for processing (24)
  • src/main/codex/codex-requested-close-turn-timing.test.ts
  • src/main/codex/codex-structured-dispatch-admission.test.ts
  • src/main/codex/codex-structured-dispatch-echo.test.ts
  • src/main/codex/codex-structured-dispatch-echo.ts
  • src/main/codex/codex-structured-dispatch-test-support.ts
  • src/main/codex/codex-structured-journal-contracts.ts
  • src/main/codex/codex-structured-journal-items.ts
  • src/main/codex/codex-structured-journal-translation-turns.ts
  • src/main/codex/codex-structured-provider-events.ts
  • src/main/codex/codex-structured-session-acquire.ts
  • src/main/codex/codex-structured-session-adapter.test.ts
  • src/main/codex/codex-structured-session-cancel.test.ts
  • src/main/codex/codex-structured-session-close.test.ts
  • src/main/codex/codex-structured-session-close.ts
  • src/main/codex/codex-structured-session-options.test.ts
  • src/main/codex/codex-structured-session-state.ts
  • src/main/codex/codex-structured-turn-start.ts
  • src/main/codex/codex-turn-ordinals.ts
  • src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts
  • src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts
  • src/main/runtime/orchestration/structured-session-pointer-delivery.ts
  • src/main/runtime/orchestration/structured-worker-group-addressing.ts
  • src/main/runtime/structured-agent-session-runtime.ts
💤 Files with no reviewable changes (1)
  • src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 4 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.

✅ No new issues found — the fix is well-reasoned, empirically grounded, and cleanly removes the dead turnIdWaiters path. Two worth-noting observations below, neither merge-blocking.

Reviewed changes

The PR fixes a durable-journal identity collision in the Codex structured-session path by minting the settled providerItemId from the echoed user message instead of stamping ordinal: 0 on every accepted send.

  • Dispatch now returns admitted, identity settles on the echo. startCodexTurn/dispatchCodexTurn no longer read the turn id from the turn/start response or wait TURN_ID_WAIT_MS; the identity is minted at identityFor, the same point replay uses, so the settled key can't drift from what restore computes.
  • New CodexDispatchEchoes, keyed by client message id. arm/settle/disarm/clear correlate a send to its echo by clientMessageId rather than queue order (correct for coalesced turns), bounded to 256, and cleared on session close and child exit.
  • Settlement plumbed through the translator. CodexJournalItems.handle reads a live userMessage echo → onUserMessageEcho → onDispatchSettledLate → settleLateDispatch, and the runtime's onDispatchSettledLate closure is now shared by Codex and Claude.
  • Dead code deleted, comments corrected. turnIdWaiters, TURN_ID_WAIT_MS, and DISPATCH_DOUBT_CODEX_TURN_UNNAMED are removed; three "turn already running" comments are corrected to coalescing; CODEX_USER_MESSAGE_ORDINAL moves to codex-turn-ordinals.ts.

I verified the reducer makes accepted terminal (applyDispatch returns early on accepted/rejected), so a late echo settle cannot be clobbered by performSend's subsequent admitted/pending resolve — there's no settlement race. pnpm run typecheck:node is clean and the four new/changed codex test files pass (45 tests).

ℹ️ Settlement ordinal rests on live echo order matching persisted history order

CodexTurnOrdinals.ordinalFor assigns ordinals by arrival order (turn.next++ over distinct message item ids), and the settled key is exactly that ordinal. The correlates each send by client message id, not queue order test deliberately echoes client-2 before client-1 and asserts the resulting ordinals are [client-2 → 0, client-1 → 1]. If Codex ever emits item/started for coalesced messages out of history order in practice, the settled ordinal would diverge from the one replay computes and reproduce the duplicate-bubble defect this PR removes. The empirical evidence (0.147.0 / 0.150.1 / 0.153.4) establishes coalescing and that the echo arrives "once the running turn reaches it"; it does not explicitly pin that echo order always equals persisted order. Worth a one-line confirmation rather than a change.

ℹ️ Pre-existing colliding journals still duplicate until the reconciler lands

The fix prevents new collisions but does not repair journals already written with a colliding or phantom providerItemId, and a client that stored a ...:0 key holds one the host will no longer mint. journal-submission-reconciler.ts still has no production caller. The PR states this plainly; flagging it only so the reconciler stays visibly queued, since restored conversations keep duplicating until it lands.

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

…:0` from the integration fakes

Three tests in the structured-session integration suites encoded the dispatch
contract this branch replaces, and two of them pinned the defect it fixes.

They asserted `agentSession.send` answers `dispatchState: 'accepted'` carrying
`providerItemId: codex:<thread>:<turn>:0` at send time. That ordinal was never
observed; it was stamped on every accepted send, which is exactly the collision
this branch removes -- a send coalesced into a running turn is answered with the
running turn's id, so two submissions persisted one durable key.

The visible failure was a 30s timeout rather than a failed assertion. The fake
client advertised no `agent-session.pending-send-result.v1`, and without it the
host holds the reply until the send settles: a shim for clients too old to
render a pending bubble. The fake provider then echoed the user message with no
`clientId`, so nothing could correlate that echo back to the submission, and the
wait ran to its own 30s ceiling. Real Codex sends `clientId` on that echo, and
the fake now does too, which is what makes it a model of the provider rather
than a sketch of one.

The identity assertion is kept rather than dropped. Each send now asserts
`pending` with no identity at admission, then asserts the submission settles
`accepted` at `codex:<thread>:<turn>:0` once the echo lands. Same ordinal, but
earned from `identityFor` on the echo -- the key a replay recomputes -- instead
of guessed from the turn/start response. Ablated: removing `clientId` from the
two echoes leaves both submissions `pending` and fails both assertions, so the
assertion is load-bearing and not satisfied by something incidental.

Both suites' client fixtures now advertise the capability set the desktop
renderer sends in `src/main/ipc/runtime.ts`, which is what these suites mean by
a client. The older-client settlement wait keeps its own coverage in
`src/main/runtime/rpc/methods/structured-agent-session.test.ts`.

`structured-agent-session-runtime-exit.test.ts` asserts `pending` for the same
reason; it drives the host directly, so it never took the compatibility path,
and what proves delivery there is still the turn the reacquired provider starts.

The replay suite's "without dispatching it twice" property is untouched: one
`turn/start` call, one replayed ledger row.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

This run reviewed the one commit pushed since the last pullfrog review (a60b714), which adapts the structured-session integration suites to the admission-then-echo dispatch contract. No production files changed; the suite passes locally (3 files, 13 tests).

  • Integration fixtures advertise the pending-send capability. structured-agent-session-integration.test.ts and …-replay.test.ts clients now include AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, matching the desktop renderer in src/main/ipc/runtime.ts, so the host returns pending at admission instead of holding the reply until settlement — the hold that previously made the old assertions run to a 30s timeout.
  • Fake provider echoes clientId. The two item/completed user-message fixtures now carry clientId: sent.clientMessageId, the field readCodexDispatchEcho correlates on, so the echo actually settles the submission rather than being counted and dropped.
  • Identity assertions moved from send time to the echo. Both suites now assert pending with providerItemId: null at admission, then settle to accepted at codex:<thread>:<turn>:0 through a new submissionOf journal helper. The assertion is load-bearing: without clientId on the echo nothing settles and the submission stays pending.
  • Runtime-exit test asserts pending. structured-agent-session-runtime-exit.test.ts now expects pending at admission; delivery is still proven by the turn the reacquired provider starts.

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

@brennanb2025

Copy link
Copy Markdown
Contributor Author

QA evidence — mid-turn send restore (head 7d3c57c8de)

Drove a real Codex structured native-chat session on a hidden Electron instance of this worktree. Identity from window.api.app.getIdentity(): name=Orca: brennanb2025/codex-admission-settlement-2, devWorktreeName=codex-admission-settlement-2, devRepoRoot=/Users/brennanbenson/orca/workspaces/orca/codex-admission-settlement-2. Git HEAD at launch: 7d3c57c8deac108c378bd9967c5e45f83fa95b46.

Scenario

  1. New-tab menu → Codex (structured native chat).
  2. Sent QA-MSG-A2-20138 through the composer Send button.
  3. While data-native-chat-working=true, typed QA-MSG-B-20138 and submitted with Enter (Send had become Stop).
  4. Restarted the same isolated profile (quit the QA Electron tree, relaunch with the same ORCA_DEV_USER_DATA_PATH).
  5. Restored Codex Chat tab + journal; counted visible user bubbles and inspected agentSession.history submissions.

Visible result

Marker Live (mid-turn) After restore
QA-MSG-A2-20138 1 1
QA-MSG-B-20138 1 1

Each message is a single <p> in the transcript after restore. No second B bubble.

Journal / replay identities

Thread 01a094ac-f069-7990-827e-aec81ed17304. Distinct clientMessageIds; A2 and B were not both stamped …:0.

Send clientMessageId live dispatch live providerItemId restored dispatch restored itemId
A2 …06ce93f740ca4a318cd01a59dbb9cc44 accepted codex:<thread>:01a094af-dfaa-7060-a52e-0a23550f4c1b:0 accepted orca:1789200948887-06ce93f740ca4a318cd01a59dbb9cc44
B (mid-turn) …7b9fa175005847f49e224b73c375a415 pending / null — unknown / null orca:1789200949330-7b9fa175005847f49e224b73c375a415

B was admitted ~0.4s after A2 while the turn was running and kept its own client id instead of colliding on A2’s :0 key. After restart that unique orca:<clientMessageId> row is what restore rendered — once.

Screenshots

Composer ready (real Codex native chat):

00-composer-ready.png

Live mid-turn: A2 and B each once, turn still running (Stop):

01-live-midturn-A2-and-B.png

Same session immediately before restart:

02-before-restore-A2-B-once.png

After restart / restore — A2 and B still each once:

03-after-restore-A2-B-once.png

Console / logs

Renderer: unrelated flushSync lifecycle warnings. No session-restore or send-path errors. Main: a cancelled file-listing during explorer setup; nothing on the Codex journal path.

Gaps (not exercised)

  • No pre-fix baseline worktree. The live journal already shows the old colliding :0 stamp was not applied to B.
  • B’s provider echo had not settled before restart (long A2 turn still streaming), so restored B is dispatchState: unknown with the composer “Message delivery is unconfirmed” / Retry — that is the interrupted pending send, not a duplicate bubble.
  • No video of the restart itself; before/after full-app screenshots cover the restore end state.
  • Tiny playwright-cli overlay is visible at the bottom of some captures; it is not Orca UI.

Throwaway workspace orca-qa-codex-restore.* only; no private repo names or auth material in the shots.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

This run reviewed the one commit pushed since the last pullfrog review (7d3c57c), which turns the settled-correlation window into backpressure and retains a correlation across a timeout so a late echo can still prove delivery.

  • Capacity now refuses a new send instead of evicting an older one. CodexDispatchEchoes.arm returns false at MAX_CODEX_PENDING_DISPATCH_ECHOES rather than dropping the oldest entry, and startCodexTurn/dispatchCodexTurn surface rejected before any write. This closes the case where an admitted send's echo was silently dropped and its submission never settled.
  • Disarm narrowed to a provider decline. Only CodexAppServerRequestError/CodexAppServerUnsupportedError disarm (Codex answered, so no echo can arrive); a timeout or transport failure keeps the correlation armed so a later echo settles the unknown submission to accepted. The reducer treats unknown as non-terminal, so the late settle applies.
  • Tests cover both paths. New admission tests for overflow (an older admitted send still settles after a later refusal) and for a request that fails after its write may have landed; the echo unit test now asserts refusal at capacity instead of eviction.

Verified independently that CodexAppServerTimeoutError is a distinct class from CodexAppServerRequestError, so the retained-correlation path is actually reachable, and that a same-id Retry of a timeout is refused by the fail-closed doubt allowlist (no double-send). The capacity refusal is recorded rejected, but the renderer outbox already rotates the clientMessageId on Retry for a rejected submission (use-structured-agent-session-outbox.ts:278), so it stays recoverable. Changed-file tests pass: 33 tests across the dispatch/echo/send suites and 60 across the codex session, integration, replay, and runtime-exit suites.

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

@brennanb2025

Copy link
Copy Markdown
Contributor Author

QA follow-up — settled mid-turn B, then restore (head 7d3c57c8de)

This run waits until B is accepted with its own providerItemId and present in Codex provider history before restart. The prior capture restarted while B was still pending.

Identity (window.api.app.getIdentity()): Orca: brennanb2025/codex-admission-settlement-2, worktree codex-admission-settlement-2, repo root this worktree. HEAD: 7d3c57c8deac108c378bd9967c5e45f83fa95b46. Hidden Electron, ORCA_BACKGROUND_LAUNCH=1.

Scenario

  1. New-tab → Codex structured native chat.
  2. Composer Send: QA-MSG-A-20138 (count 1–80). data-native-chat-working=true.
  3. While that turn was still running, composer Enter: QA-MSG-B-20138.
  4. Waited until B dispatchState=accepted with a non-null providerItemId distinct from A, and Codex’s thread rollout contained both as UserMessage items (matching client ids).
  5. Restarted the same isolated profile. Restored Codex Chat.

Identities (same turn, not colliding :0)

Thread 01a094bf-4002-7e63-b90a-c9d77ef9ecc6, turn 01a094bf-c300-7a12-afb0-60e214b992a9.

clientMessageId dispatch providerItemId
A 1789201990330-2defda4d39c24e859b5f052a6b807c8c accepted codex:<thread>:<turn>:0
B (mid-turn) 1789201990426-66dbf5e33a5c4ae1920c3698b7abe75e accepted codex:<thread>:<turn>:2

Provider history: rollout for that thread has UserMessage / item_completed rows for both client ids. On-disk journal has both submission rows and both accepted dispatch rows.

Visible counts

Before restore After restore
QA-MSG-A-20138 1 1
QA-MSG-B-20138 1 1

Exactly two user <p> markers after restore. No duplicate B.

Screenshots

Composer:

00-composer-ready.png

Settled, immediately before restart (A and B each once; turn finished):

01-before-restore-settled.png

After restart / restore — still each once; both submissions still accepted with :0 vs :2:

02-after-restore-once.png

Logs

Restore renderer: no session errors (React Grab / DevTools only). Throwaway workspace orca-qa-codex-restore.* only.

No video; before/after full-app screenshots show the restore end state. Tiny playwright-cli overlay at the bottom of some frames is not Orca UI.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Final review status — code clean; landing blocked by unrelated base CI

Reviewed head 7d3c57c8de through three same-model adversarial review/fix rounds. The final round is clean. One focused follow-up commit was pushed with two meaningful fixes:

  • Preserve an admitted send's correlation after ambiguous timeout/transport failures, because the provider may still have received the frame and a later echo can prove delivery.
  • Reject a new send before delivery when the 256-entry correlation bound is full, instead of silently evicting an older admitted send.

Judgments

  • Functional correctness: clean. turn/start now proves admission only; the actual echoed user item supplies replay-stable identity and its client message ID selects the submission. This removes the collision mechanism instead of masking duplicates in the renderer.
  • Architecture: clean. Delivery admission remains adapter-owned, durable acceptance remains journal/host-owned, and Codex reuses the existing late-settlement path.
  • Performance/resources: clean. Correlation is bounded O(1) state, lifecycle-cleared on close/exit; the old per-send 10-second timer is removed. No polling, retry fanout, listener growth, renderer work, or subprocess churn was added.
  • Compatibility: no schema, wire opcode/field, mobile, path/shell, native-module, or platform-specific change.

Validation

  • Changed-surface suite: 12 files / 104 tests passed.
  • pnpm tc:node, changed-file code-quality checks, and git diff --check passed.
  • Opening and closing readiness gates found no P0/P1/P2 candidates; counsel was not needed.
  • Hidden Electron restore proof with full-app screenshots: A was accepted at ordinal :0, mid-turn B at :2 on the same provider turn, provider history contained both client IDs, and after restarting/restoring the same isolated profile each message remained accepted and rendered exactly once.

CI status

All applicable #20138 checks pass except test / tests node 24 3/8 (and aggregate verify). That shard failed twice on the same five unrelated TabBar.context-menu.test.ts assertions.

This is a base-branch regression from merge-base commit 923858e / #20311: it moved the static menu into a nested component while its shallow parent-tree test still expects those menu rows inline. #20311's own shard 3/8 check failed identically before merge. #20138 does not touch the TabBar surface, and that test file passes locally 11/11 on this branch.

Verdict: #20138's code is clean at 7d3c57c8de, but do not land while required CI is red. Fix the TabBar test regression on main in a separate focused change, refresh this PR's merge ref, and rerun CI; do not absorb that unrelated repair here. The known repair for journals already written with collisions remains intentionally in #20139.

Merge Sim added 2 commits September 13, 2026 12:00
main's new casting gate (#20367 base) flags type assertions on changed
lines. Replace them with checked types: the recording sink already
satisfies its interface, both CodexSession fixtures are now annotated and
carry real collaborators, the settlement assertion compares whole
identities, and the integration helper reads submissions through the
host's public journalSnapshot instead of its private session map.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

This run reviewed the one commit pushed since the last pullfrog review (e980959), a test-only change that replaces as unknown as fixture casts with checked types to satisfy the changed-code casting gate. No production behavior changed.

  • Dispatch settlement assertion compares whole identities. codex-structured-dispatch-admission.test.ts now toEquals the full {sessionId, clientMessageId, providerIdentity} objects instead of mapping out clientMessageId and casting providerIdentity to reach ordinal. Equivalent coverage, strictly stronger.
  • CodexSession fixtures are typed and carry real collaborators. codex-requested-close-turn-timing.test.ts and codex-structured-session-close.test.ts build CodexSession-annotated sessions with a complete CodexAppServerConnection and a real CodexPromptRegistry (the close test spies on clear), replacing a partial stub plus as unknown as cast.
  • recordingSink drops its cast. The test-support sink returns a plain object literal that structurally satisfies StructuredAgentSessionEventSink — appendItem/appendTombstone/publish are the only required members.
  • Integration helper reads through the public API. submissionOf now calls host.journalSnapshot(SESSION).submissions instead of casting into the private sessions map.

Verified independently: pnpm tc:node is clean, the four touched suites pass (23 tests), and pnpm run check:code-quality:changed reports 0 new findings across 26 changed files.

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

Resolved journal-dispatch-doubt-reasons.ts by dropping BOTH sides of the
conflict region: #20133 moved provider_write_failed into the new shared
rejection module, and this branch removed the codex-turn-unnamed reason
because Codex no longer settles a send on a deadline. Main's two tests
that imported that constant now spell the reason as a literal, since rows
written by an older Orca still carry it.

Routed the Codex queue-full rejection through the shared rejection module
so #20133's classifier does not print an internal marker to the user.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

This run reviewed the one commit since the last pullfrog review (b59a661), a merge of origin/main that rebases the admission-then-echo fix onto main's dispatch-state refactor (#20133, #20139). No new issues.

  • Codex queue-full reason moves into the shared rejection module. structured-agent-session-dispatch-rejection.ts gains DISPATCH_REJECTED_CODEX_QUEUE_FULL (value unchanged: codex structured dispatch queue is full), and dispatchRejectionReasonIsInternal now classifies it as internal so clients render user-facing copy instead of the raw marker.
  • The dead DISPATCH_DOUBT_CODEX_TURN_UNNAMED export is removed from journal-dispatch-doubt-reasons.ts. The crash-boundary test keeps the legacy string as a literal to prove rows already written under it still recover as unknown, never rejected.
  • Tests realign with main's vocabulary. The dispatch-admission, dispatch-echo, send, send-idempotency, adapter, session, and integration suites adopt main's state names and removal semantics, and codex-structured-turn-start.ts imports the shared constant.

Verified independently: pnpm tc:node clean; the 13 touched suites pass (138 tests); pnpm run check:code-quality:changed reports 0 new findings across 29 changed files. On the merge's main interaction — main's #20139 gives journal-submission-reconciler.ts a production caller in the attach path — the reducer's terminal-state rules keep a live pending send swept to unknown on attach non-terminal, so a late echo still settles it accepted; the reconciler only rejects when the history boundary is trustworthy and no turn is in flight.

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

…hind

Both sides added an import from journal-dispatch-doubt-reasons and the
merge kept both statements, which the whole-repo native plugin gate
refuses under --deny-warnings.
Five conflicts, all from #20506 (provider-aware Fast mode) landing in the
same Codex session files this branch rewrote. Resolved to keep both: the
CodexSession and CodexTurnHost types carry Fast mode's fastModeTierByModel
and widened reportedOptions alongside this branch's dispatchEchoes, with
turnIdWaiters gone.

The load-bearing one is outside any conflict region: startCodexTurn was
rewritten here, so it kept spreading host.options raw and would have
silently dropped Fast mode's serviceTier derivation. It now spreads
codexTurnOptions(host).
#20506 landed its Fast mode tests against the dispatch contract this
branch replaces: a Codex send now returns admitted and settles its
identity on the provider echo. The tier assertions the test exists for
are untouched.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

This run reviewed the two commits pushed since the last pullfrog review (b59a661): a merge of origin/main and one test-only follow-up.

  • Merged origin/main and reconciled Fast mode with the admission contract. The merge brings main's provider-aware Fast mode (#20506) onto the admission-then-echo branch. The reconciliation is clean: fastModeTierByModel stays on CodexTurnHost, codexTurnOptions(host) still composes the serviceTier override into the turn/start params, and dispatchEchoes replaces turnIdWaiters with no leftover references anywhere in the tree.
  • Fast-mode dispatch test now asserts the branch's outcome. codex-structured-fast-mode.test.ts expects { state: 'admitted' } from adapter.dispatch rather than accepted, matching the new contract; the serviceTier assertions the test exists for are untouched.

Verified independently: pnpm tc:node is clean; the fast-mode, dispatch-admission, dispatch-echo, and session-adapter suites pass (49 tests); the integration, replay, and runtime-exit suites pass (13 tests). No prior Pullfrog review threads remain open. One note: git diff main..HEAD is 32 files because this branch's merged main lineage diverges cosmetically on src/main/ai-vault-search/session-search-store.ts (reindentation only) — not a behavioral change and not from these commits.

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

Two conflicts, both where #20496 extended the Codex translator deps that
this branch also extends. Kept both: bindPromptItemId gains its turnId
parameter and clearPromptTurn lands alongside this branch's
onUserMessageEcho, in the type and at the one call site.
@pullfrog

pullfrog Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@brennanb2025
brennanb2025 merged commit 955051d into main Sep 14, 2026
27 of 28 checks passed
nwparker added a commit that referenced this pull request Sep 19, 2026
* fix: gate startup agent recovery on host inventory

* fix: defer recovery while a live agent lacks conversation ownership

* fix(mobile): two known main bugs the RPC migration preserved (#20563)

* fix(mobile): two known main bugs the RPC migration preserved

A malformed host `error` and a null settings result both reach a property read that throws.
Both are deliberate behaviour changes; the goldens move in the follow-up commit.

`hostReplyErrorTextOrFallback` passed a truthy non-string through under a `string` annotation.
Its one caller is the in-band `git.commit` failure, and every consumer of that text is display or
prompt copy: `use-mobile-create-pr-runner` and `PrSidebarCreateEmptyState` record it as a commit
failure, `use-mobile-commit-failure-recovery` hands it to `summarizeCommitFailure`, which starts
with `raw.slice(...).replace(...)`. So no consumer needs the value, and the decision is the
fallback rather than `String(value)` — the relay handler declares
`commit(): Promise<{ success: boolean; error?: string }>`, so a non-string is a malformed reply,
and `generatedCommitMessageReader` in the same domain already reads a non-string host error as
absent. The parameter stays `unknown`, which it honestly is, and the `SAFETY` cast is gone.

`useNewWorkspaceRuntimeContext` read settings through `settingsRead`, whose reader preserves
main's `boxed!.settings` throw, so a `null` or absent result threw a TypeError out of the effect —
losing the trusted-hooks publish and the available-provider computation that follow it, not just
the settings. It now uses `optionalSettingsRead`, the operation that already reads a null or
absent result as absent settings, so the reply degrades exactly the way a reply with no `settings`
member does. Reply-side only: same method, same params, same barrier, no wire change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the goldens the two bug fixes move

Baseline bumped to 3f71999237. Two goldens move an observation; the other 151 move only
`baseline` and `recorderSha256`, which `pilot-scenarios.json` is still digested into.

Observation moves, one claim each:

- `matrix-hostedreview.create-intent-git.commit-1`, partition `inner-false-object-error`:
  `settlements.run.value.error` and `state.outcome.error` go from `{"message":"inner refused"}` to
  `"Commit failed"`. A non-string in-band `git.commit` error is a malformed reply and now reads as
  the screen's copy, converging with `result-absent`, `result-null` and `outer-refused-no-message`,
  which already reported the fallback. The other ten partitions at this site are unchanged.
- `matrix-settings.workspace-context-settings.get-1`, partitions `result-null` and `result-absent`:
  the `unhandled-rejection` TypeError effect (`reading 'settings'`) is gone and `state.providers`
  goes from `[]` to `["github"]`. The effect no longer aborts the rest of the hook, so the
  provider computation runs; `state.settings` stays null because nothing was published, which is
  how a reply with no `settings` member already degraded. The other nine partitions are unchanged.

Header-only moves:

- 9 goldens of the `settings.workspace-context` family rename `namedDeltas` from
  `new-workspace-runtime-context-null-settings-typeerror` — the name now lies, the TypeError is
  fixed — to `new-workspace-runtime-context-null-settings-degrades-to-absent`.
- All 153 move `baseline` and `recorderSha256`. The digest covers `pilot-scenarios.json`, so the
  baseline bump and the rename re-digest every file.

No sender recording moved: both fixes are reply-side, and no golden's `sender` or `payloads` field
differs. The README paragraph that claimed the settings TypeError was preserved is updated, and
now records that the `ui.get` leg of the same hook still is.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): degrade a null ui.get result the way the settings leg now does

One host answers both legs of useNewWorkspaceRuntimeContext, so fixing only
settings.get left the likelier failure in place: a null or absent ui.get result
still threw `reading 'ui'` out of the effect, skipping the provider commit.

Review follow-ups on the same files: reply() returns the literal uncast and the
stub client is FakeSession, dropping two assertions and their SAFETY disables;
the degradation cases now assert absolute state instead of comparing mounts.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the goldens the ui.get leg fix moves

Observational move, 1 golden:

- matrix-settings.workspace-context-ui.get-1: the `result-absent` and
  `result-null` partitions drop their `reading 'ui'` unhandled-rejection effect
  and their state commits `providers: ["github"]` instead of `[]`, because the
  effect no longer throws before the provider commit.

Header-only moves, 153 goldens: `baseline` to the fix commit and `recorderSha256`,
which covers `pilot-scenarios.json` and so re-digests on the delta rename.

The delta is renamed `new-workspace-runtime-context-null-settings-degrades-to-absent`
-> `new-workspace-runtime-context-null-results-degrade-to-absent` (9 goldens): it
now covers both reads, not just settings. README updated to match.

No sender recording moved: resolving the value pool across all 153 goldens shows
`sender` and `payloads` byte-identical everywhere.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): name the ui.get result shape so the changed cast carries a rationale

The inline union wrapped over four lines and tripped the changed-code casting gate
as a new assertion; a named alias keeps the cast on one line under a SAFETY note.
No behaviour change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the golden baseline to the cast-rationale commit

Header-only: `baseline` on all 153 goldens. The re-record is inert — no golden
moves observationally and no field other than `baseline` changes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the ui.get trust blank, and correct two stale acceptance comments

The goldens cannot catch a regression to `if (uiResult?.result)`: the scenario's
success reply is `{"ui":{}}`, so every partition of
matrix-settings.workspace-context-ui.get-1 records the same `trust:{}` state. The
new case answers once with real trust and again with a null result on a fresh
client, which is the only shape where skipping the blank is observable —
trustedOrcaHooks gates the setup-hook approval prompt in
use-new-workspace-create-submit.ts, so a stale value would skip it.

settingsRead's comment still claimed workspace context, which this branch moved to
optionalSettingsRead; both comments now name their real callers.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the golden baseline to the trust-blank commit

The record fence rejected the previous pin ("Product sources or lockfile differ
from the pinned main baseline"), so the branch was no longer re-recordable.

Header-only: `baseline` and `recorderSha256` on all 153 goldens — the digest
covers pilot-scenarios.json, whose only edit is that baseline. The re-record is
inert: 0 goldens move observationally and no other field changes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): name the right operation per refuse-after-data probe

Three of the five probes read through optionalSettingsRead, not settingsRead:
repo metadata and resume metadata already did, and workspace context does as of
this branch. The sentence now splits them and states why the split does not move
what the probes record.

Markdown is excluded from recorderSha256, so no re-record.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the recorder's unhandled-rejection capture

This branch removed the last two goldens that recorded an unhandled-rejection
effect, so nothing exercised unhandled-recording.ts any more: gutting the emit to
`void captureError(error)` leaves all 153 goldens comparing clean. The unit test
drives a detached rejection through the window and asserts both the effect and
the listener restore. README says so where it describes the capture.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-digest the goldens for the new recorder test

Header-only: `recorderSha256` on all 153 goldens, which covers every non-markdown
file under rpc-recording/ and so moves for the added test file. The re-record is
inert: 0 goldens move observationally and no other field changes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin each RPC golden to its own scenario input, not the whole manifest (#20562)

* refactor(mobile): pin each golden to its own scenario input, not the whole manifest

`recorderSha256` covered the recorder directory plus `pilot-scenarios.json`, so every golden's
header was a function of every other family's scenarios. Adding a family for one domain re-digested
all 153 goldens and put a conflict on that line in every domain branch in flight, which serialized
the step-4 fan-out.

Split the two things it conflated. `recorderSha256` now covers the recorder directory only, with
unchanged semantics: a recorder edit still forces a full, deliberate re-record. A new
`scenarioSha256` pins the scenario input that golden was recorded from — every scenario
`runRecording` consumed for it, in order — canonicalised through `captureValue` so an
explicit-undefined param stays distinct from an absent one. `goldenRecording` takes that list
instead of just its first member.

The variants are hashed rather than the base they expand from because they are what was recorded: a
matrix site, its replayed normal result and its partition replies are all visible in them without
the derivation having to be restated. `derived-goldens.ts` is that derivation, extracted from
`family-recordings.test.ts` so the digest and the recording agree by construction — a property test
that restated how a matrix or schedule expands could agree with itself and with nothing else. It
reproduces exactly the 153 golden ids on disk, and the census the suite already ran (every family
matrixed, no stale normal-result inventory entry) now reads off its output.

`golden-header-digest.test.ts` pins the four properties:

- a new family in the manifest moves zero existing goldens' headers, and derives two of its own
- editing one field of `b1` moves exactly `b1` and its family's four matrix goldens — not the two
  other `legacy-inventory` scenarios, and not the goldens that expand from `inventory-lifecycle`
- editing a recorder file still moves every golden's `recorderSha256`, and no `scenarioSha256`
- `recorderSha256` is unchanged by the manifest's contents, and no longer reads the file at all

`GOLDEN_FORMAT_VERSION` goes to 4: a version-3 header has no `scenarioSha256`, and `compareGolden`
walks the expected header's keys, so a reader that accepted one would compare that golden's own
scenarios as though they were unpinned.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the 153 RPC goldens for the split digest

Recorder edit, so every golden needs rewriting. Recorded from the pinned baseline
`16d1ab81d3` with this branch's recorder overlaid, per the README's procedure: main has
moved past the baseline, so recording in place would have failed the product-source fence.

Three header fields moved and nothing else did:

- `recorderSha256` 6a12160a87… -> 2fda557f58…, one value across all 153 files
- `scenarioSha256` added, 153 distinct values
- `goldenFormatVersion` 3 -> 4

No observation, checkpoint, value-pool entry, `baseline`, `lockfileSha256` or `platform` changed:

    git diff -U0 -- mobile/rpc-foundation | grep -E '^[+-]' | grep -vE '^(\+\+\+|---)' \
      | grep -vcE 'recorderSha256|scenarioSha256|goldenFormatVersion'
    0

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): certify the pilot goldens from the derivation that digests them

`pilot-recordings.test.ts` restated `[scenario]` instead of consuming `pilotGoldens`, so the claim
that a golden's `scenarioSha256` is a function of the same derivation that records the file held
only for the 75 family goldens: dropping a scenario from `pilotGoldens` left the whole suite green
and put that golden outside the header oracle. The pilot suite now iterates `pilotGoldens`, and a
census fails if the derivation and the goldens directory disagree in either direction — which also
closes the pre-existing orphan-golden gap.

Also from review: pin the cross-sibling replay that hashing the generated variants buys (a matrix
golden's `normal` partition replays a sibling's recorded reply, so editing that sibling must move
it); state the real reason for the format bump, which is the diagnosis a version check gives rather
than a rejection the byte compare already made; drop the fourth property test, which re-proved what
tests 1 and 2 and `recording-runner`'s digest test already fail on; and drop a guard in
`scenarioSha256` that its only caller reaches after an identical one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the 153 RPC goldens for the review edits

Recorder files changed, so `recorderSha256` moved. Recorded from the pinned baseline with this
branch's recorder laid over it, per the README's migration-branch procedure. That one header field
is the only line that moved in all 153 files: `scenarioSha256` and `goldenFormatVersion` are
unchanged, and no observation moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): correct the recording suite's test count

Round-2 review: the README said 200 tests; the suite is 209 after the five
added here. Markdown is outside recorderSha256, so no golden moves.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the 153 goldens on the merged baseline

Four header fields moved and nothing else. Proven against origin/main: every
changed line in all 153 files is one of these, and the file set is unchanged.

- `recorderSha256` 70aa6f59e0 -> 58a461dbc9: this branch's recorder, and it now
  digests only the recorder directory, not the scenario manifest.
- `scenarioSha256` added, 153 distinct values over 153 goldens.
- `goldenFormatVersion` 3 -> 4 for that added field.
- `baseline` 5ec0b2698f -> e53f1557e1, the merge's repoint onto the real main
  commit. #20563's value was a branch commit the squash left unreachable, so the
  record fence's `git diff <baseline>` could not resolve it.

No checkpoint, value pool, effect or settlement byte moved, so main's recorded
behaviour is carried over intact.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): account for main's added recorder test in the suite count

The merge brought in `unhandled-recording.test.ts`, one test, so the recording
suite is 210 rather than the 209 this branch documented. Markdown is excluded
from `recorderSha256`, so no golden moves.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(native-chat): stop a bounded tail read from moving the chat cursor past unapplied rows (#20581)

* fix(native-chat): stop a bounded tail read from moving the chat cursor past unapplied rows

A structured chat pane could latch "Working for N" forever after the agent had
finished, showing the send arrow rather than Stop, while the sidebar and
`worktree ps` correctly read idle.

The client replica has one position (`state.cursor`) and one body. Two
operations keep those consistent: replace (both from one host snapshot) and
append (rows contiguous with the cursor). The `tail-page` branch was a third
thing: it took the cursor from the journal head, the items from a bounded page
(200 items, byte-capped), then merged retained client submissions over the
page's. Under continuous journal writes the client is always slightly behind,
so the branch ran on every window focus and on every pane re-activation. When
more than a page of rows had landed since a send, that send's user item fell
off the page, its submission was not carried, the retained `pending` survived,
and the cursor jumped past the dispatch-acceptance row. Nothing re-sends it: a
batch carries only touched items and that submission is never touched again.

Delete the third operation rather than guard it. A live subscription is now the
only thing that moves the cursor, and `subscribe({ cursor })` already replays
exactly the missed rows.

- remove the window `focus` listener and the owner/transport `refresh` contract
- skip warm hydration: a retained owner subscribes at its applied cursor
- cold hydration keeps its history read, applied as the existing `snapshot`
  (replace) event rather than `tail-page`
- delete the `tail-page` action and its reducer branch
- delete `resumeCursor` and `shouldAdvanceStructuredResumeCursor`; two cursors
  with two advancement rules were how position and body drifted apart

`older-page`/`loadOlder`, the unattached-refusal grace, generation guards and
the coalescer are unchanged. No host, wire or schema change.

Also fixes a second cost of the same branch: focus during a busy turn discarded
paged-in older items, shrinking the transcript to one bounded page mid-turn.

* fix(native-chat): preserve unavailable mixed-version session fences

* refactor(mobile): send the task workspace-creation domain through typed RpcOperations (#20568)

* test(mobile): record main's task workspace-creation RPC behaviour before migrating it

28 scenarios over nine task senders, recorded from main so the step-4 migration of
the workspace-creation half of src/tasks/ has a frozen answer to compare against.
Four senders mount as plain exported functions; three are model-chained hooks
mounted the way the settings adapters mount theirs.

The 153 existing goldens change header-only (`baseline`, `recorderSha256`): any new
scenario re-digests the recorder, and the pinned baseline had drifted from main
because the source-control migration landed. Content is byte-identical on all 153 —
verified field-by-field against HEAD.

`operation-module-loader.ts` now shares src/transport/rpc-delivery-ambiguity.ts with
mounted modules instead of evaluating a second copy. The mark is a WeakSet keyed on
the rejection object, so the copy the loader built had an empty registry and every
delivery-unknown rejection read as a definite failure inside the operation under
test — worktree.create's whole replay path was unreachable. With one registry,
`tw-create-retry-ambiguous-after-drop` records the create still pending at the
reconnect wait and abandoning at exactly 20000 ms, while the unstamped-create
scenario records the same rejection surfacing at 0 ms. No existing golden moves:
no other mounted module consumes the mark.

`task-preferences-optimistic` is re-anchored above the send rather than across it,
so migrating this file does not have to move the anchor. It still kills, and for
the same reason: the preset the screen shows no longer follows the tap.

Scenarios deliberately pin the empty-message refusals (`*-refused-empty-message`,
`*-empty-message`), because a refusal with no message falls back to the screen's
copy while a transport error with no message does not, and the two paths are easy
to collapse when a call site moves behind an acceptance policy.

Goldens: 153 -> 201, 2.9M -> 3.7M.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): format the recording manifest and re-digest the goldens

`oxfmt --check` from mobile/ collapses a one-element `sites` array in each new
scenario. The JSON value is unchanged — verified by comparing both files parsed
and key-sorted — but the manifest is inside `recorderSha256`, so all 201 goldens
carry a new digest. Every other field, header and observation alike, is
byte-identical.

Re-recorded in a separate worktree at the previous commit so the goldens stay
attributable to main's product source rather than to the migration that follows.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): separate the goldens from the migration, and re-digest

The previous commit accidentally carried the product migration alongside the
manifest format, which both broke the commit that is supposed to prove parity and
left the suite red: the digest was recorded without a comment move that a lint fix
had made inside the adapter, so all 201 goldens failed their `recorderSha256`
header.

This backs the product half straight out again — the next commit re-applies it
byte-for-byte — and re-records from the pinned baseline in a separate worktree
carrying this branch's recorder, per the procedure in the recording README. Every
field except `recorderSha256` is byte-identical to the previous commit's goldens on
all 201 files, so no observation moved in either direction. The suite is green here
with main's product source, which is what makes the next commit's "no golden
changed" claim mean something.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): send the task workspace-creation domain through typed RpcOperations

12 of src/tasks/'s 37 raw-port files now send through a declared operation instead of
the raw request port: 36 references to 0, leaving 25 files and 73 references for the
provider item/detail/mutation half. No golden moved — `git show --stat` on this commit
touches nothing under mobile/rpc-foundation/, which is the parity claim.

Twenty-two operations over twenty methods, in four modules named for what they send:
workspace create (create, PR/MR base resolution, create-time capabilities), workspace
source (SSH connect/state, agent detection, repo hooks, sparse presets, ref search),
task runtime (status, ui.get/ui.set, preflight, Linear status, settings.update) and the
Smart picker's provider reads.

Two methods carry two policies each, and both pairs are named. `status.get`: the Tasks
screen cannot hydrate without it and surfaces the host's message, while create-time
capability probing degrades to "no capabilities" and creates anyway — so one throws on
refusal and one skips. `ui.set`: two sites await it, one is fire-and-forget and never
interprets the reply at all. Both pairs share one reader, so no method has two readers.
No new acceptance policy.

worktree.create keeps its delivery-unknown contract. `request` returns the transport
promise itself, so the retry loop catches the object the transport marked; two new tests
assert `toBe(marked)` in one direction and that an unmarked rejection stays unmarked in
the other, because a mark added on the way out would replay a create the host never
received. `tw-create-retry-ambiguous-after-drop` records the create still pending at the
reconnect wait and abandoning at exactly 20000 ms.

Three sites still read the raw refusal envelope before interpreting, because the code or
the message decides the route and no acceptance policy carries either through: the create
retry needs the message for `isRetryableWorktreeCreateConflict`, and the paste lookup
needs `method_not_found` to retire the slug probe host-wide. Both are documented at the
site.

The hydration barrier keeps raw requests inside its `Promise.all`. main's group rejects as
soon as one leg rejects; `startRpcOperation` + `interpretAtRpcBarrier` would wait for the
slowest peer and let a later policy surface a different error. Interpretation stays after
the `stale` guard, where it was.

`WorkspaceCreateParams` is now `RpcSendParams<'worktree.create'>` rather than
`Record<string, unknown>`, which types the builder and the operation together; every field
the three builders already sent typechecks against the host schema unchanged.
`RpcSendArguments` now also makes params optional for a method whose params type has no
required field, because `preflight.check` is such a method and main sent it none —
requiring `{}` would have put a new object on the wire.

The Mobile Tasks source-parity hashes move for the same reason bound settings requests
moved them: the method string and the envelope read leave the screen. The signature diff
is evidence rather than a re-pin — `semantics` is a pure deletion of 22 `rpc:` call
signatures and 22 method literals with nothing added, statement/declaration/render/style
counts are unchanged, and render tokens, styles and declarations are byte-identical.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): split the task workspace adapters at the sender/hook seam

The single adapter file reached 344 lines against mobile's 300-line limit. CI lints
every file, so this is red there even though the changed-code gate does not report it.
Split along the seam the recording README already draws: exported async senders that
take a client and need no React host, and the drawer's three model-chained hooks.
No adapter body changed.

Both files are inside `recorderSha256`, so all 201 goldens carry a new digest. Every
other field is byte-identical, verified file by file. Re-recorded from the pinned
baseline in a separate worktree carrying this branch's recorder, so the goldens stay
attributable to main's product source.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): give the one-key unchecked reader a name

Four readers were the same three lines: read one property off the reply, wrap
it unchecked. `rpcUncheckedMemberReader` is the one-key sibling of the existing
`rpcUncheckedPayloadReader`, so the annotation and the closure go away at each
site. The pilot's `commitCompareEntriesReader` is converted too, so the helper
has no longhand twin left to copy from.

No behaviour change: the helper composes the same `rpcReadUnchecked` over
`rpcPayloadMember`, including the property-read exception on a null result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): record the local arm of workspace agent detection

`preflight.detectAgents` was the one migrated operation with no recorded
coverage: the ssh adapter hardcoded `connectionId: 'ssh-1'`, so the detection
effect's ternary only ever took the remote arm and the local call site could be
repointed at another method without a golden noticing.

The adapter now takes the connectionId as a parameter and registers twice;
`tasks.workspace-ssh-local` mounts the same hook with no connection, which is
the only difference the effect branches on. Recorded at the pinned baseline
with this branch's recorder laid over it, so the new golden is main's
behaviour and the migrated code has to reproduce it — it does.

Goldens: two added (`tw-workspace-ssh-local-agents` and its reply matrix). The
other 201 changed on `recorderSha256` only, because the adapter edit moves the
recorder digest every golden pins.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): drive workspace create and the Linear list to a recorded wire

Two operations passed a policy swap unnoticed, both because no golden reached
their acceptance branch.

`worktree.create`: the create hook's fixture resolved setup to a prompt, so all
three settings.task-workspace scenarios stopped before the request and the only
consumer that hands a refusal to interpret was never recorded. The adapter now
takes the setup resolution as a parameter and registers a second family that
resolves it, so createWorkspace runs to the wire. Two scenarios: a Linear item
that creates directly, and a GitHub pull request that resolves its base first,
which also puts this hook's built params — start point, generated display name,
agent launch fields — in a golden for the first time. The existing prompt
family is untouched, so its recordings still pin that branch.

`linear.listIssues`: it appeared only in a non-base scenario, and the matrix
reads the family base, so the family had no partition for it. The base now
lists assigned issues after searching.

Goldens: five added. Five moved beyond the digest, all derived from the
smart-search base that gained the list leg. The other 198 changed on
`recorderSha256` only. Recorded at the pinned baseline with this branch's
recorder laid over it, so every new golden is main's behaviour.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): drop the unreachable unmount branches from the task adapters

Nothing dispatches `unmount` to these three adapters: the only producer is
`lifecycleSchedules`, driven from a hardcoded five-id list that names no
task-workspace family, and it pushes a `remount` right after, which these
adapters would throw on. The branch read as lifecycle coverage that was never
wired up. `dispose: hook.unmount` already tears the mount down.

Goldens re-recorded at the pinned baseline because the recorder digest moved;
`recorderSha256` is the only line that changed in all 208.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the goldens at main's post-squash baseline

Recorded from a detached checkout of e53f1557e1 (main's unmigrated
product code) with this branch's recorder laid over it, so the parity
claim stays non-circular.

- `baseline` repinned to e53f1557e1 on all 208 goldens; main pinned
  5ec0b2698f, a pre-squash branch commit not reachable from main.
- `recorderSha256` moved on all 208 because this branch's adapters are
  in the whole-manifest digest.
- 55 task-workspace goldens re-recorded at the new baseline.
- No other line in any of main's 153 goldens changed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the goldens under #20562's per-scenario digest

Baseline repinned to 50e752fc66 and all 208 goldens recorded from that
commit's unmigrated product tree with this branch's recorder laid over it.
recorderSha256 moves on every golden because the task-workspace adapters
live in the recorder directory. scenarioSha256 does not move on any of
main's 153: the manifest only adds 31 scenarios and edits none, which is
the property #20562 was built to give.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(ai-vault-search): construct the session search indexer in the scanner service behind a setting (#20516)

* feat(ai-vault-search): persist agent-session search consent and retention

Two booleans and nothing else: `enabled` and `historyDays`, off by default
because building the index reads every transcript on the machine. No `paused` --
the PR 3 indexer is immutable, so every change is close-and-construct.

The settings IPC normalizes a write like every other field and hands the change
to the index; there is no UI for it until PR 8.

* feat(ai-vault-search): hold one indexer and engine pair per host

The object that owns a host's live index and the three recipes that change it.
The indexer is immutable, so a settings change is close-and-construct, disabling
is close with no replacement, and clearing is close, remove the database,
construct. The new instance's first sweep purges a narrowed window and admits a
widened one, so neither needs a code path.

The database sits beside the scanner's parse cache, one file per host. A runtime
with no node:sqlite can hold no index at all, which the Node 18 floor on orcad
and the relay makes a real case rather than a hypothetical one.

* feat(ai-vault): let the scanner child own the session search index

The transcript reader runs in that child, so the index consumer has to as well:
one read serves both the session list and the index. Three request operations
(search, status, reconcile) and one fire-and-forget settings message carry
everything a parent needs; main never opens the database file.

The init frame becomes a factory because it is read at every spawn, so a
respawned child sees current consent rather than the first frame's. A child
holding a running index is never idle from the parent's side, so idle retirement
is suppressed while the index is on -- retiring it would stop the reconcile loop
until some later scan happened to respawn one.

Both files this lands in were already at the max-lines ceiling, so three
collaborators move to where they belong rather than being disabled around: the
invalidation deadline into the class that owns invalidations, call cancellation
and the start requeue into the call-state module, and orcad's flag parsing into
its own file.

* feat(ai-vault-search): register a search service on every host that answers

Without a registered service a host answers no-service, which means "this host
does not have the feature" rather than "the index is off". All three hosts now
answer the second thing.

The desktop forwards to the scanner child. orcad and the SSH relay daemon have
no such child -- orcad ships only the watcher and daemon entries, and the relay's
AI Vault sidecar runs the remote scanner, which publishes nothing to the
transcript channel -- so on those two the index lives in the process that would
drive its reads, gated on a runtime that has node:sqlite at all.

The relay registers with consent off and no way to turn it on: nothing carries a
setting to a remote host yet. That is the honest state, and it is still worth
registering, because it is what tells a client the difference between off and
too old.

* test(ai-vault-search): price a warm pass over five thousand transcripts

The number the reconcile interval will be revisited against, measured rather
than argued: a warm sweep stats every file under every root, a warm cycle stats
the newest N per agent, and neither reads what the index already holds. It does
not tune the interval.

* fix(ai-vault-search): answer the casting gate without assertions

main's new type-assertion rule reaches every file this branch touches. All nine
sites drop the cast rather than carry a SAFETY: rationale: the operation guard
narrows with `in`, the sqlite probe narrows the builtin it loads, the child test
keeps the discriminated reply instead of widening it, and the settings resolver
takes `unknown` -- which is what it really reads, since a persisted profile can
hold a value no version of this code wrote.

* fix(ai-vault-search): let a refreshed scan root reach the live index

The parent re-resolves scan roots before every policy push, precisely so a
WSL distro or extra Codex home that appeared since the child spawned enters
the window. The child forwarded only the settings to a live instance and used
the roots solely in its `??=` initializer, so those roots were dropped for the
child's lifetime.

The indexer stays immutable: a structurally different root set closes the pair
and constructs a new one, the same way a changed databasePath already does.
Compare via `sameSessionSearchRoots` rather than a plain JSON compare, because
nothing fixes the key order two producers write; lists are sorted too, since
the indexer walks every root and a re-enumeration that reorders is not a
change. An unchanged set still never restarts a running index.

The orcad and relay in-process hosts resolve roots once at install and never
re-apply, so they have no such seam.

* fix(ai-vault): restart the scanner child the index is holding

Three review items.

The hold keeps a child alive for the index, but only a queued call ever
started one: `pump()` skipped a hold with an empty queue, so an idle indexing
child that crashed, or an `ensureChild()` that failed at start, left indexing
stopped until an unrelated request happened to arrive. `pump()` now starts the
child the hold requires, which is also the restart callback the fault policy
already schedules, so the existing delay and circuit bound the retry exactly as
they bound a queued call's start. `updateSessionSearch` goes through the same
seam instead of its own `ensureChild` call.

A search registers no AbortController, so a cancel sent for a search id was
added to the `cancelled` set and never consumed. Nothing can reach that today
-- no caller passes a signal and the child answers in milliseconds -- so this
is only a leak of ids: consume it when the search settles.

The orcad argument doc claimed a `--`-prefixed value stays a flag. The parser
takes the next token regardless, and orcad-launch-contract.test.ts pins that,
so the doc is what was wrong. Behaviour is unchanged.

* fix(ai-vault): recover search indexing and refresh scan roots

* fix(ai-vault): defer search refresh policy reads

* fix(session-search): stabilize paging and host enablement

* fix(session-search): refresh host roots within full sweeps

* docs(session-search): clarify initial root fallback

* fix(native-chat): hide activity while awaiting input (#20496)

* fix(native-chat): hide activity while awaiting input

* fix(native-chat): keep approval turns cancellable

* test(native-chat): satisfy split PR quality gate

* fix(native-chat): catalog approval cancellation label

* fix(native-chat): include approval cancellation runtime label

* fix(codex): settle prompts when cancelled turns complete

* fix(codex): settle prompt registry fallbacks

* test(native-chat): cover pending interaction fallbacks

* test(native-chat): split prompt state coverage

* test(native-chat): keep prompt state isolated

* fix(native-chat): bound prompt turn backfill

* refactor(codex): centralize prompt registry bounds

---------

Co-authored-by: Merge Sim <sim@local>

* Update README downloads badge

* Bump mobile app.json to 0.0.50 (#20661)

* fix(native-chat): bound a dispatch reason before it reaches the journal row (#20654)

`AgentJournalSubmission.reason` was the only unbounded field written by
Orca's own code. `dispatchSafely` sets it from the adapter's raw
`error.message` and `journalDispatchRowBuilder` stored it verbatim, so a
provider error carrying a multi-megabyte body -- a stringified HTTP error
payload, say -- reached the row at whatever length the provider sent, and
stayed on disk at that size for the life of the journal.

It now goes through `boundInlineText` with the journal's existing inline
limit, the same idiom already applied to arbitrary text on the Claude and
Codex translation paths.

The bound must stay head-preserving. `dispatchRejectionWasTransportWriteFailure`
prefix-matches the value, and `dispatchRejectionReasonIsInternal` builds on
it, so a bound that kept the tail instead would stop classifying a clipped
transport failure and render raw provider text to the user as an ordinary
rejection notice. A test pins that, and clipping stays marked rather than
silent so a truncated reason is never presented as the provider's complete
explanation.

Rows written before this keep their full text, so readers can still meet an
unbounded reason.

* fix(native-chat): keep a resumed transcript pinned to its end (#20651)

* fix(native-chat): keep a resumed transcript pinned to its end

Follow state was recomputed from distance on every scroll event, and a pin writes scrollTop itself, so the browser reports that write back as a scroll event a frame later. Once a resumed session's later history pages and settling row heights had moved the end away from it, that echoed event read as the reader leaving and the pin was dropped for good, stranding them mid-transcript. Measured in Chromium: a pin followed by same-task growth delivers a scroll event reading 2000px from the bottom, indistinguishable from a reader scrolling up.

Pins now go through the virtualizer instead of writing scrollTop directly, so both parties resolve the end through the same maximum rather than holding rival definitions of it. Whether the reader left is now a question of provenance rather than distance: an offset this transcript wrote is never a departure. The end test reads live geometry, because the virtualizer's own isAtEnd subtracts a cached offset from a live maximum and this handler runs before that cache is refreshed. overflow-anchor:none stops the engine moving scrollTop under a settling row, which would otherwise look like the reader.

This does not make ownership singular. The virtualizer still writes autonomously from several paths and those writes stay unattributed; what this removes is the rival definition of the end, not the second writer.

* fix(native-chat): cancel stale end reconciliation

* fix(native-chat): attribute scroll ownership centrally

* Surface stage, unstage and discard failures with retry capability (#20423)

* fix(source-control): surface stage, unstage and discard failures

* fix(source-control): use single slot for entry failure toasts

- Consolidate entry failures to one stable slot instead of per-worktree
- Handle stale retries inline at click time rather than via a cleanup hook
- Remove retry button from discard failures to prevent destructive accidents

* test: improve type safety and mock patterns in source-control tests

- Add proper type definitions for toast options and test data instead of using `as never`
- Replace `mock.calls.at(-1)` with safer `mock.lastCall` pattern
- Create `entry()` helper to construct typed test entries
- Add explicit type annotations to mocked functions for better IDE support

* test: extract shared toast options type for source control tests

Consolidate duplicate `ToastOptions` type definitions across three test files into a single `SourceControlToastTestOptions` type, reducing duplication and improving consistency.

* fix(source-control): separate refresh failures from mutation failures

Post-mutation refresh failures are logged separately, not surfaced as toasts
(mutation already succeeded). Use preventDefault() on retry to prevent sonner's
auto-dismiss from swallowing re-raised failures. Consolidate stage/unstage into
a shared handler to reduce duplication.

* fix(source-control): only dismiss entry failures from the owning worktre

Track which worktree owns the shared entry-failure toast slot. When a mutation
completes, only dismiss the slot if the completing worktree is the one that
raised the failure — a slow retry in one worktree should not erase a failure
another worktree has since raised into the slot.

* Remove entry mutation status refresh helper

Inlined into the caller during consolidation of failure handling and
tracking in the source-control entry mutations flow.

* Simplify entry mutation refresh without wrapper

Call refreshActiveGitStatusAfterMutation directly instead of through the
refreshEntryMutationStatus helper. This ensures refresh failures propagate
directly from the callback without being caught as mutation failures.
Remove tests that validated the wrapper's error handling.

* Add copy button to code blocks (#20357)

* feat(native-chat): add copy button to code blocks

Enable users to copy code snippets directly from chat messages via a dedicated copy button on fenced code blocks. Supports language detection and integrates with markdown rendering via a `renderCodeBlock` prop.

* i18n: add English copy code button label

* refactor: use React.isValidElement type parameters for type narrowing

- Specify props types as type parameters to React.isValidElement instead
  of casting after the fact
- Allows TypeScript to narrow element.props type automatically
- Eliminates manual type assertions in extractCodeText and extractCodeFenceLanguage

* fix(source-control): prevent text wrapping in section headers and action buttons (#20046)

* fix(source-control): prevent text wrapping in section headers and action

Use flex layout constraints (flex-1, shrink-0) and text truncation instead of
wrapping to keep section labels and action buttons on a single line in the
right sidebar.

* test(source-control): add section action button alignment tests

Ensure View all button stays on single line with icon actions in
crowded section headers. Pin layout constraints (shrink-0, flex-wrap,
whitespace-nowrap) to prevent regression.

* Rely on Button base styles for action label wrapping

Remove redundant shrink-0 and whitespace-nowrap utilities from
section action buttons. These should be supplied by the Button
component's base variant, not duplicated at each usage site.

* fix(store): stop two no-op writes from re-running every selector in the app (#20641)

* fix(store): stop two no-op writes from re-running every selector in the app

zustand bails out of a `set` only when `Object.is(next, state)`. Two updaters that
mean "nothing changed" hand it a fresh reference instead:

- `setWorkspacePortScanRefreshing` wrote unconditionally — the one action in its
  file that did; its four siblings all early-return `state`.
- `applyGitHubPRRefreshEvent` ended its no-op branch with `: {}`, and
  `Object.assign({}, state, {})` reproduces every field unchanged while still
  notifying. ~20 sibling sites in the same store already use `return state`.

Both rebuild the root and wake every subscribed selector (~2.2k per the listener
census). Renders are unaffected — the selection is unchanged — so the cost is
wasted selector evaluation, not commit pressure. The resulting state looks
identical either way, which is why it goes unnoticed; both tests therefore count
subscriber notifications rather than asserting state.

* test(store): use checked initial state in notification regression

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>

* fix(ai-vault): bound streamed remote JSONL records (#20700)

* fix(ci): stop hourly versions dropping below a tagged or already-shipped build (#20699)

* fix(ci): stop hourly versions dropping below a tagged or already-shipped build

Hourly/daily/adhoc based their X.Y.Z on GitHub releases, not git tags. When
v1.4.202 was tagged and then its GitHub release vanished, the next hourlies
shipped as 1.4.202-hourly — below both stable 1.4.202 and the 1.4.203-hourly
builds already installed, so electron-updater stopped offering updates.

Read main's v* tags and already-published channel tags instead.

* docs(ci): record that 1.4.202's release was unpublished for a bug

The leftover tag is what hourly must still honor; this was not a failed cut.

* fix(store): preserve state identity for no-op updater branches (#20703)

* fix(codex): settle a structured send on admission, and stop minting a colliding identity (#20138)

* fix(codex): settle a structured send on admission, and stop minting a colliding identity

Two sends could be written into the journal under one durable identity.

Codex coalesces a mid-turn `turn/start` into the running turn rather than
refusing it -- measured against real `codex app-server` builds 0.147.0,
0.150.1 and 0.153.4, none of which refuse and none of which fire a second
`turn/started`. The dispatch path read the turn id from the turn/start
response and stamped every accepted send `ordinal: 0`. Since a coalesced
send gets the running turn's id back, two submissions persisted the same
`providerItemId`. That string is durable, and it is the key a restore uses
to match a submission against provider history, so the second message's real
history row matched nothing and rendered as an extra bubble on replay.

On 0.147.0 it is worse than a collision: the coalesced response returns a
turn id that never starts and never completes, so the persisted key named a
turn absent from history and NEITHER message could match.

Identity is now minted from the echoed user message at `identityFor` -- the
single point that mints the journal row's own identity -- so the settled key
is by construction the one replay computes, rather than a parallel
calculation that can drift.

Dispatch returns `admitted` when the transport write completes; identity
settles on the echo through a channel that did not previously exist for
Codex. Waiters are keyed by client message id instead of being shifted off
the front of an array by arrival order, and they are cleared on session
close and child exit -- previously a timeout was the only thing that ever
ended one.

`TURN_ID_WAIT_MS` is deleted. It was never reachable on any build measured:
`readCodexTurnId` returns non-null on all three, so the 10s wait never
fired. The comment justifying it claimed older builds acknowledge before the
id exists, which no tested build does.

Three comments asserting Codex answers a mid-turn send with `turn already
running` are corrected. Their only backing was a test fixture inventing that
error string. The correction is factual only -- every changed line in
`src/main/runtime/orchestration/` is a comment, and mid-turn delivery is
still refused for both providers. Whether that policy is right is a separate
question; it was resting on a false premise.

Known gap, stated rather than implied: this prevents new collisions and does
not repair journals already written with a colliding or phantom key. Those
conversations keep duplicating on restore. Repairing them means re-matching
persisted submissions against provider history and rewriting
`providerItemId` -- which is what `journal-submission-reconciler.ts` is
written for, and it still has no production caller.

* test(codex): drop the synchronous-accept contract and the colliding `:0` from the integration fakes

Three tests in the structured-session integration suites encoded the dispatch
contract this branch replaces, and two of them pinned the defect it fixes.

They asserted `agentSession.send` answers `dispatchState: 'accepted'` carrying
`providerItemId: codex:<thread>:<turn>:0` at send time. That ordinal was never
observed; it was stamped on every accepted send, which is exactly the collision
this branch removes -- a send coalesced into a running turn is answered with the
running turn's id, so two submissions persisted one durable key.

The visible failure was a 30s timeout rather than a failed assertion. The fake
client advertised no `agent-session.pending-send-result.v1`, and without it the
host holds the reply until the send settles: a shim for clients too old to
render a pending bubble. The fake provider then echoed the user message with no
`clientId`, so nothing could correlate that echo back to the submission, and the
wait ran to its own 30s ceiling. Real Codex sends `clientId` on that echo, and
the fake now does too, which is what makes it a model of the provider rather
than a sketch of one.

The identity assertion is kept rather than dropped. Each send now asserts
`pending` with no identity at admission, then asserts the submission settles
`accepted` at `codex:<thread>:<turn>:0` once the echo lands. Same ordinal, but
earned from `identityFor` on the echo -- the key a replay recomputes -- instead
of guessed from the turn/start response. Ablated: removing `clientId` from the
two echoes leaves both submissions `pending` and fails both assertions, so the
assertion is load-bearing and not satisfied by something incidental.

Both suites' client fixtures now advertise the capability set the desktop
renderer sends in `src/main/ipc/runtime.ts`, which is what these suites mean by
a client. The older-client settlement wait keeps its own coverage in
`src/main/runtime/rpc/methods/structured-agent-session.test.ts`.

`structured-agent-session-runtime-exit.test.ts` asserts `pending` for the same
reason; it drives the host directly, so it never took the compatibility path,
and what proves delivery there is still the turn the reacquired provider starts.

The replay suite's "without dispatching it twice" property is untouched: one
`turn/start` call, one replayed ledger row.

* fix(codex): preserve unsettled dispatch correlations

* test(codex): type the dispatch fixtures instead of asserting over them

main's new casting gate (#20367 base) flags type assertions on changed
lines. Replace them with checked types: the recording sink already
satisfies its interface, both CodexSession fixtures are now annotated and
carry real collaborators, the settlement assertion compares whole
identities, and the integration helper reads submissions through the
host's public journalSnapshot instead of its private session map.

* fix(test): merge the duplicate doubt-reasons import the merge left behind

Both sides added an import from journal-dispatch-doubt-reasons and the
merge kept both statements, which the whole-repo native plugin gate
refuses under --deny-warnings.

* test(codex): a Fast mode turn is admitted, not accepted

#20506 landed its Fast mode tests against the dispatch contract this
branch replaces: a Codex send now returns admitted and settles its
identity on the provider echo. The tier assertions the test exists for
are untouched.

---------

Co-authored-by: Merge Sim <sim@local>

* test: add a verified OMP native-chat mock scenario (#20655)

Co-authored-by: plotarmordev <plotarmordev@users.noreply.github.com>

* fix(omp): preserve status after terminal title owner rewrite (#20610)

Validated and independently reviewed OMP integration fix.

Co-authored-by: shahidbeig-a11y <258701601+shahidbeig-a11y@users.noreply.github.com>

* fix(omp): preserve zsh startup with global aliases (#20621)

Validated and independently reviewed OMP integration fix.

* fix(git): avoid Windows tree kills after the command has exited (#20606)

Validated and independently reviewed OMP integration fix.

* fix(terminal): retain renames before renderer pane hydration (#20619)

* fix(terminal): retain renames before renderer pane hydration

* test(terminal): keep late renames from recreating closed tabs

* fix(agents): find OMP by its full project name (#20647)

* fix(agents): find OMP by its full project name

* test(agents): make picker baseline proof omit OMP aliases

* style(test): brace picker baseline condition

* feat(runtime): stream file uploads instead of buffering whole files (#16106)

* feat(runtime): stream file uploads instead of buffering whole files

Staging read each dropped file whole with readFile(), base64-encoded it
(a 4/3 expansion), and passed the string through IPC to the renderer,
which re-chunked it. Peak memory was ~2.3x the file size before a byte
moved, so a 25 MB per-file cap existed to protect the heap.

Staging now records identity only. The byte pump moves into main, where
the file handle and the runtime socket both live: 384 KiB slices (512 KiB
once base64-encoded, matching the chunk size the renderer used) appended
through the existing files.writeBase64Chunk RPC. Peak memory is one slice
regardless of file size, so the ceilings become user-safety limits on an
unattended transfer — 2 GB per file, 8 GB per drop — and over-limit errors
name both the size and the limit.

Because staging and streaming are separate calls, the staged entry carries
size, inode, device and mtime, and the streamer re-checks all four against
the pre-open lstat and against the handle it actually reads. A source
replaced or rewritten at the same size between the two calls is refused
rather than uploaded under the original name. The post-read check compares
mtime as well as size, so an in-place rewrite mid-transfer aborts before
commitUpload renames anything into place.

O_NOFOLLOW, realpath containment and stat identity are preserved, and the
pairing revision plus the runtime id ride every chunk, so a re-pair or a
replacement runtime aborts instead of appending the rest of the file to a
different host.

No wire change: files.writeBase64Chunk and its params are untouched, so
old and new hosts behave identically. The SSH import path is separate and
unchanged. The web client has no local filesystem to stream from and says
so instead of failing obscurely.

* fix(runtime): close the empty-upload and per-drop budget holes

Two gaps the first pass left open.

A zero-byte source returned before the post-transfer identity check, so a
file that gained content during the empty write's round trip committed as
an empty file at the user's chosen name. The empty chunk now falls through
to the same final check the slice loop uses.

Each staged source also started its own byte counter, so the 8 GB ceiling
capped one source rather than the drop: five 2 GB files staged cleanly at
10 GB total. The IPC handler now carries one budget across sourcePaths and
adds only what each source actually staged. The per-file ceiling is still
re-enforced where the bytes move; the drop total holds at staging because
identity enforcement means each file streams exactly the bytes measured.

* docs(runtime): name the invariants the upload helpers carry

* fix(runtime): name the source in errors and stop uploads with their window

Three problems an independent review turned up.

A dropped file's relative path is '', so the over-limit error read "'' is
3 GB, over the 2 GB per-file remote import limit" — the message this change
exists to fix, naming nothing. Errors now fall back to the file's own name;
the staged entry keeps '' so the destination path is unaffected. The
streamer had the same shape, falling back to the hidden .orca-upload-<nonce>
temp destination, a path the user never chose.

The byte loop used to live in the renderer and died with it. Moving it into
main meant closing or reloading the window left the rest of a multi-GB
transfer running, with the renderer's temp cleanup never reaching its
finally. An AbortSignal now rides the caller's lifetime and every chunk, is
re-checked per slice, and main sweeps the abandoned temp path itself when
the renderer is no longer there to do it.

Upload failures also reached the import result wrapped in Electron's
"Error invoking remote method '...'" prefix, because the throw crossed IPC
instead of happening in-renderer; extractIpcErrorMessage unwraps it.

An existing staging test asserted the empty-name message, so it encoded the
bug rather than catching it; it now asserts the file name.

* test(runtime): cover the containment check and the per-chunk host guards

The "escapes the dropped root" test only reached the lstat symlink guard,
so assertEntryInsideRoot had no coverage at all. The shape that actually
needs it is a regular file under a symlinked intermediate directory: lstat
sees a plain file, and realpath containment is the only thing that refuses
it. Disabling the guard now fails this test and nothing else.

Nothing asserted that the SSH target, connection generation and execution
host reach the writeBase64Chunk params either — the renderer tests stop at
the IPC boundary, so the streamer's half of that contract was untested.

* fix(runtime): survive a straggling append when sweeping an aborted upload

Aborting rejects the in-flight chunk locally, but the host may still apply
that append, and appends open with flag 'a' — which recreates the file the
sweep just deleted. The delete and the straggler also race: they are
separate calls on a queue that is not ordered between them.

Slices are strictly sequential, so at most one append can be outstanding.
A second pass after it has had time to land is therefore sufficient, not
merely a heuristic. The sweep moves out of filesystem-mutations.ts into its
own module so the behaviour is testable directly.

Found by an independent review pass, which also pointed out that the
"escapes the dropped root" test only reached the lstat symlink guard.

* fix(runtime): abort uploads only when the document commits, and honour manual disconnect per chunk

did-start-navigation fires before will-navigate blocks an external link or a
stray file drop, and the renderer survives those (verified against Electron 43
with a hidden window). Aborting there killed a healthy upload with a misleading
'window went away' error. did-navigate fires only once a new document has
replaced the caller.

The renderer's per-chunk calls used to go through the IPC handler that refuses
a manually disconnected environment; the loop in main made no such check, so a
disconnect mid-upload kept pushing the rest of the file. The handler now
resolves the selector to an environment id and the streamer checks it per slice.

Adds slice-boundary coverage against the real chunk schema and host write
flags, staging-to-stream on a real filesystem, and handler-level lifetime tests.

---------

Co-authored-by: Neil <neil@stably.ai>

* fix(omp): preserve saved conversation names in session history (#20636)

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>

* fix(ui): contain idle caret paint so agent panes stop burning CPU (#10554)

An idle agent pane kept ~40% of a core busy just by being frontmost. The
xterm cursor and the native chat caret blink with no paint-containment
boundary, so Chromium treated each blink as damage to the whole pane
ancestry and re-rasterized it twice a second.

- `.xterm-container` and the native composer's input shell get
  `contain: paint`, bounding blink damage to the surface that blinks.
- The mention hint gains `z-20` to match the slash picker: a contained
  element becomes a stacking context and paints at z-index 0 in tree
  order, which would otherwise cover the hint's drop shadow.

Also records that DECSCUSR pins `decPrivateModes.cursorBlink`, which wins
over the option in `_updateCursorBlink` — so parking `cursorBlink` does not
reliably stop a hidden pane blinking. Pre-existing, documented only.

Co-authored-by: Wooseong Kim <innocarpe@users.noreply.github.com>

* feat(relay): expose preloaded PostgreSQL statement statistics (#20712)

* fix(runtime): apply the tui-idle evidence ranking to mailbox delivery (#20578)

fix(runtime): retry a delivery that the idle gate refused

Gates delivery at the two points where each implementation commits to typing into
the pane, rather than at each caller, and parks-and-re-offers a refusal so late
idle evidence cannot strand a queued message.

Refs #6011

* fix(agent-session): honour the backup-recovery fence floor on surface release (#20708)

* Fix surface release fence recovery floor

* fix(agents): advance backup recovery floor past lost mint

* fix(hooks): actually terminate a timed-out hook's process tree (#20576)

Repairs #20559, whose termination was a no-op: `detached` is a spawn-only option and `exec` ignored it, so the shell never became a group leader. Verified against real processes.

Refs #19334

* fix(worktrees): safely remove prunable git-file registrations (#20617)

Preserve checkout files and the named branch when removing a positively attested malformed Git-file registration. Reject file/symlink targets in deferred directory deletion.

Verified exact head with 75 focused tests including actual Git malformation, preserved marker/file bytes and branch HEAD. Independent review and complete product CI passed. WSL routing is covered by unit tests; direct SSH fails safely without local recovery.

Fixes #17316

* fix: keep OMP terminals when folder workspaces become Git repos (#20653)

Preserve the original folder locator through Git upgrade and subsequent listing, persistence, and removal decisions after proving it still names the same checkout.

Independently reviewed with 60 focused persistence/listing/removal tests and six native Windows real-Git/NTFS cases covering case/slashes, junction retention and retargeting, remote-host isolation and unrelated checkout preservation. Prior source-connected native OMP proof confirms process survival. Full PR CI passed; no rebuilt full-app after-proof claimed.

* fix(native-chat): cancel pending prompts precisely (#20601)

* fix(native-chat): hide activity while awaiting input

* fix(native-chat): keep approval turns cancellable

* test(native-chat): satisfy split PR quality gate

* fix(native-chat): catalog approval cancellation label

* fix(native-chat): include approval cancellation runtime label

* fix(codex): settle prompts when cancelled turns complete

* fix(codex): settle prompt registry fallbacks

* test(native-chat): cover pending interaction fallbacks

* test(native-chat): split prompt state coverage

* test(native-chat): keep prompt state isolated

* fix(native-chat): bound prompt turn backfill

* refactor(codex): centralize prompt registry bounds

* fix(native-chat): cancel pending prompts precisely

* fix(native-chat): consolidate capability imports

* fix(native-chat): harden precise prompt cancellation

* fix claude cancellation teardown races

* retry claude prompt lifecycle admission

* bound claude prompt cancellation retry work

* fix(codex): bound prompt turn identity on registration

* fix(native-chat): route rejected late dispatch settlements

* fix(codex): retain exact cancellable prompt turn ids

---------

Co-authored-by: Merge Sim <sim@local>

* fix(omp): resume independent child sessions from history (#20629)

Add Resume to eligible local OMP child history rows. Resolve lazy child targets from their own cwd and host, never an unrelated active workspace. Unresolved folder-only targets stay disabled; copy-command remains available.

Verified production map/resume resolver regression before/after; 50 focused tests and independent 40-test review, web types and code quality passed. Actual OMP storage/CLI smoke confirms distinct child/grandchild sessions. No native Windows or live SSH launch claim.

Addresses #12885 Scope 1.

* fix(ai-vault): expand nested OMP session history (#20663)

Expand saved OMP descendants lazily while preserving exact c…
ivanvykopal pushed a commit to ivanvykopal/orca that referenced this pull request Sep 20, 2026
… colliding identity (stablyai#20138)

* fix(codex): settle a structured send on admission, and stop minting a colliding identity

Two sends could be written into the journal under one durable identity.

Codex coalesces a mid-turn `turn/start` into the running turn rather than
refusing it -- measured against real `codex app-server` builds 0.147.0,
0.150.1 and 0.153.4, none of which refuse and none of which fire a second
`turn/started`. The dispatch path read the turn id from the turn/start
response and stamped every accepted send `ordinal: 0`. Since a coalesced
send gets the running turn's id back, two submissions persisted the same
`providerItemId`. That string is durable, and it is the key a restore uses
to match a submission against provider history, so the second message's real
history row matched nothing and rendered as an extra bubble on replay.

On 0.147.0 it is worse than a collision: the coalesced response returns a
turn id that never starts and never completes, so the persisted key named a
turn absent from history and NEITHER message could match.

Identity is now minted from the echoed user message at `identityFor` -- the
single point that mints the journal row's own identity -- so the settled key
is by construction the one replay computes, rather than a parallel
calculation that can drift.

Dispatch returns `admitted` when the transport write completes; identity
settles on the echo through a channel that did not previously exist for
Codex. Waiters are keyed by client message id instead of being shifted off
the front of an array by arrival order, and they are cleared on session
close and child exit -- previously a timeout was the only thing that ever
ended one.

`TURN_ID_WAIT_MS` is deleted. It was never reachable on any build measured:
`readCodexTurnId` returns non-null on all three, so the 10s wait never
fired. The comment justifying it claimed older builds acknowledge before the
id exists, which no tested build does.

Three comments asserting Codex answers a mid-turn send with `turn already
running` are corrected. Their only backing was a test fixture inventing that
error string. The correction is factual only -- every changed line in
`src/main/runtime/orchestration/` is a comment, and mid-turn delivery is
still refused for both providers. Whether that policy is right is a separate
question; it was resting on a false premise.

Known gap, stated rather than implied: this prevents new collisions and does
not repair journals already written with a colliding or phantom key. Those
conversations keep duplicating on restore. Repairing them means re-matching
persisted submissions against provider history and rewriting
`providerItemId` -- which is what `journal-submission-reconciler.ts` is
written for, and it still has no production caller.

* test(codex): drop the synchronous-accept contract and the colliding `:0` from the integration fakes

Three tests in the structured-session integration suites encoded the dispatch
contract this branch replaces, and two of them pinned the defect it fixes.

They asserted `agentSession.send` answers `dispatchState: 'accepted'` carrying
`providerItemId: codex:<thread>:<turn>:0` at send time. That ordinal was never
observed; it was stamped on every accepted send, which is exactly the collision
this branch removes -- a send coalesced into a running turn is answered with the
running turn's id, so two submissions persisted one durable key.

The visible failure was a 30s timeout rather than a failed assertion. The fake
client advertised no `agent-session.pending-send-result.v1`, and without it the
host holds the reply until the send settles: a shim for clients too old to
render a pending bubble. The fake provider then echoed the user message with no
`clientId`, so nothing could correlate that echo back to the submission, and the
wait ran to its own 30s ceiling. Real Codex sends `clientId` on that echo, and
the fake now does too, which is what makes it a model of the provider rather
than a sketch of one.

The identity assertion is kept rather than dropped. Each send now asserts
`pending` with no identity at admission, then asserts the submission settles
`accepted` at `codex:<thread>:<turn>:0` once the echo lands. Same ordinal, but
earned from `identityFor` on the echo -- the key a replay recomputes -- instead
of guessed from the turn/start response. Ablated: removing `clientId` from the
two echoes leaves both submissions `pending` and fails both assertions, so the
assertion is load-bearing and not satisfied by something incidental.

Both suites' client fixtures now advertise the capability set the desktop
renderer sends in `src/main/ipc/runtime.ts`, which is what these suites mean by
a client. The older-client settlement wait keeps its own coverage in
`src/main/runtime/rpc/methods/structured-agent-session.test.ts`.

`structured-agent-session-runtime-exit.test.ts` asserts `pending` for the same
reason; it drives the host directly, so it never took the compatibility path,
and what proves delivery there is still the turn the reacquired provider starts.

The replay suite's "without dispatching it twice" property is untouched: one
`turn/start` call, one replayed ledger row.

* fix(codex): preserve unsettled dispatch correlations

* test(codex): type the dispatch fixtures instead of asserting over them

main's new casting gate (stablyai#20367 base) flags type assertions on changed
lines. Replace them with checked types: the recording sink already
satisfies its interface, both CodexSession fixtures are now annotated and
carry real collaborators, the settlement assertion compares whole
identities, and the integration helper reads submissions through the
host's public journalSnapshot instead of its private session map.

* fix(test): merge the duplicate doubt-reasons import the merge left behind

Both sides added an import from journal-dispatch-doubt-reasons and the
merge kept both statements, which the whole-repo native plugin gate
refuses under --deny-warnings.

* test(codex): a Fast mode turn is admitted, not accepted

stablyai#20506 landed its Fast mode tests against the dispatch contract this
branch replaces: a Codex send now returns admitted and settles its
identity on the provider echo. The tier assertions the test exists for
are untouched.

---------

Co-authored-by: Merge Sim <sim@local>
EgoSay added a commit to EgoSay/orca that referenced this pull request Sep 22, 2026
* fix(mobile): give the page its height and its long press; C1.7 device proof (OTA phase C, C1.9) (#21589)

* fix(mobile): give the Route A document the height its mounted tree measures against (OTA phase C, C1.9)

The document this builder emits carries no stylesheet, so `html`, `body` and `#root` have no
height, and every box react-native-web lays out below the mount is `flex: 1` against a parent
that measures 0. The collapse is silent in every check that existed: the entry stamps `mounted`,
the route tree commits, `innerText` holds every row, and the accessibility tree reports each one
at the offset it would have had. Nothing is painted below the header, and nothing takes a tap —
the list sits inside a scroller the collapse clipped, and a phone reads it to VoiceOver while no
row responds. Lane C1.7 found it on both an iPhone 17 Pro simulator and a Pixel 9 Pro emulator,
and the same bytes reproduce it in headless Chromium.

The fix is the reset Expo's own web template ships for a react-native-web root, emitted inline
because the shell's CSP already allows `style-src 'unsafe-inline'` for the sheet react-native-web
injects at runtime; a linked asset would paint the collapsed layout until it landed.

The render check gains the assertion that would have caught it: the root's box measured against
the viewport, and the one control this route paints with no RPC answered — the New Workspace
button, positioned against the bottom of the root, which the collapse moved to y = -72 — asked
for by `elementFromPoint` at its own centre. Laid out is not reachable, so the check is a hit
test and not another read of the DOM. Without the reset it fails `expected +0 to be 844`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let the page have the long press WebKit was taking (OTA phase C, C1.9)

The shell's WKWebView is built with the default text interaction, so WebKit installs its selection
assistant over the page. A hold on a worktree row raises the selection loupe over the row's own
text and the touch is cancelled before the page's responder sees it, which leaves every
long-press action in the page dead on iOS while a tap works. Lane C1.7 measured it: the same
injected hold opens the row action sheet on the native list and on the page in the Android
WebView, and does nothing in the page on iOS.

It is not the document's to fix, which the device disproved one rule at a time:
`-webkit-touch-callout: none`, `-webkit-user-select: none`, and both together all left the loupe
and left the hold undelivered, and headless Chromium confirms the property computes to `none` on
the page's text, so the CSS reaches it and WebKit's own gesture wins anyway.

The cost is real and named here rather than discovered later: the page has no text selection on
iOS, so selectable `Text` — markdown, diff rows, file preview, chat — cannot be selected there
until a page-side copy affordance exists. Everything the shell already forbids is unchanged, and
Android is untouched.

No unit test: the module's Swift checks compile the seven WebKit-free logic files and never import
WebKit, so a `WKWebViewConfiguration` cannot be built in them. The device proof stands in.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make the root reset the template's bytes, not a copy with an addition

The comment said the reset is what Expo's web template ships, and `margin:0` was not in it.
`@expo/cli@55.0.36/static/template/index.html` carries height, `overflow` and the root's flex box
and nothing else, and react-native-web emits `body{margin:0}` in the sheet it injects at runtime,
so the addition only covered the frames before that sheet landed. Nothing pinned it either:
removing it left all 60 tests green, which is the other way of saying it was never load-bearing.

Dropping it makes the string one thing with one source instead of a copy to keep in step with two.
The pins on the rest of the reset are unchanged, and so is the frame that mattered: the root still
has a definite height before the first paint, which is what the collapse needed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(mobile): format the render check with the root formatter

`config/scripts` is formatted by the root oxfmt, not mobile's, and CI checks neither, so a 102-char
line I added sat over the root's `printWidth: 100` with nothing to catch it. Reflowed by
`./node_modules/.bin/oxfmt --write` from the repo root; no behaviour change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say what the root reset shares with Expo's template, not that it is its bytes

"The bytes Expo's web template ships" is false and checkable: the template's own block is
pretty-printed with comments and trailing semicolons at 410 bytes, and this string is 112. What is
actually true, and what the next reader needs, is that it carries the same declaration set and the
same `id="expo-reset"`, minified.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin every rule of the root reset, not two substrings of itself

The check read the constant back against itself: `toContain(MOBILE_WEB_APP_ROOT_RESET)` plus two
substrings taken off that same constant. A rule dropped from it took the assertion with it, so
`body{overflow:hidden}`, `flex:1` and the `expo-reset` id were unpinned — and the render check
stays green without the overflow rule, so nothing else held them either.

Each rule is now a literal written here, named one at a time so a failure says which one went, and
the id is pinned beside them. Verified red-first: removing the overflow rule, the `flex:1`, or the
id each fails this test and only this test.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(runtime): detect a same-size terminal artifact swap the granted stat cannot see (#21436)

* fix(runtime): detect a same-size terminal artifact swap the granted stat cannot see

A local terminal-artifact grant pinned the file as `dev:ino:nlink:size:mtimeMs`.
On Linux every one of those can survive an unlink+recreate: ext4 reuses the
just-freed inode (measured: 100% of the time), nlink and size are unchanged for a
same-size replacement, and the mtime clock is tick-quantized to 1ms, so a swap
inside one tick produces a byte-identical identity string. The grant then served
the attacker's bytes as if nothing had changed.

Local grants now also pin a sha256 of the artifact's content, taken from the same
handle as the stat so nothing can swap the file between them, and every local
read, preview and write re-checks it before returning or committing content.

The stat identity string itself is unchanged: the relay recomputes it verbatim to
honour `expectedStatIdentity`, so its format is a wire contract. Remote grants
keep the stat-only check and are untouched.

This is also the mechanism behind the intermittent
`orca-runtime-files-terminal-artifact-io.test.ts` failure on
`rejects stale absolute terminal artifact previews before returning changed
content`: it replaces an 8-byte artifact with 8 different bytes, so whenever the
two writes shared a 1ms tick the product genuinely could not tell them apart.

* docs(runtime): record what the terminal artifact grant checks do not close

The digest makes the same-size swap detectable; it does not make the sequence
atomic. A reader arriving at the access module would reasonably assume otherwise,
so write down the measured limits of the stat identity, why the identity string
cannot change, and the four windows that stay open — the write path's surviving
rename() gap above all.

* fix(remote): reassert terminal geometry after reveal (#21568)

* fix(remote): reassert terminal geometry after reveal

* test(remote): expose layout queues through fixture

* test(remote): narrow layout target assertion

* fix(omp): submit large prompts in one PTY frame (#21573)

* fix(omp): join prompt submit with large paste

* test(omp): cover joined submit timing

* ci: rerun PR checks after timing test fix

* test(omp): acknowledge joined submit activity

* fix(mobile): let the shell's page paint a file preview (OTA phase C, C3.0) (#21591)

* fix(mobile): let the shell's page paint a file preview

A file preview has one shape on the wire: the desktop answers a base64 body
and `normalizeMobileFilePreviewResult` composes `data:<mime>;base64,<content>`
for React Native Web's `Image`. Under `img-src 'self'` the browser refuses to
load it, so every image preview in the page paints nothing — reproduced in the
render check, which logged the refusal naming `img-src 'self'` before this.

`data:` is granted to images and to nothing else, so what it admits is what the
page itself composed out of a reply it already holds; `script-src 'self'` and
`connect-src 'self'` are untouched, and `blob:` is not added because nothing in
the closure needs one. Both platform pins narrow from "the header contains no
`data:`" to "`data:` appears on `img-src` and nowhere else", which is the check
that still fails if a later directive grows one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say what the data: image case actually loads through

Round 1 is right on both counts. In react-native-web 0.21.2 the hidden <img>
the Image component renders carries `alt`, `style`, `draggable`, `ref` and
`src` and no load handlers at all — it is there for the browser's image context
menu and for `getBackgroundSize()`. The load signal comes from
`ImageLoader.load`, which is `new window.Image()` with `onload`/`onerror` on it,
so the `new Image()` in this case is the same mechanism the screen's own load
runs through rather than a stand-in for it.

And the screen maps `onImageError` to "Unable to load preview"
(`MobileFilePreviewScreen.tsx:282`); "Binary preview unavailable" is the
normalizer's `binary_file` branch, which a CSP refusal never reaches.

Comment only. No assertion moves.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): state the img-src data: bound as the destination, not provenance

"admits only what the page itself built" read as a provenance guarantee, and CSP
has none to give: `data:` is matched as a scheme, so the directive admits any
`data:` image URL and the browser cannot tell one the page composed from one it
was handed. Nor is the content the page's own — the mime type and the base64
body both come from the host, and `normalizeImagePreviewResult` only checks the
mime type is a non-empty string.

The true bound is where the URL goes: it is never fetched as anything but an
image, `img-src` is the only directive admitting it, an image fetch executes
nothing (an SVG inside an `<img>` runs no script), and `script-src 'self'`,
`connect-src 'self'` and `object-src 'none'` are untouched.

Both copies reworded identically, since they are kept in step by the render
check's own policy comparison.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* Update README downloads badge

* fix(claude): stream transcript ancestry proofs

Stream transcript ancestry proofs without loading whole histories into memory.

* fix: read crash diagnostics without loading whole dumps into memory

Read crash diagnostics incrementally to avoid whole-dump memory spikes.

* ci: cache cloud workspace packages in unit tests

Include the cloud lockfile in the unit-test dependency cache key so repeated runs reuse cloud package downloads.

* fix(editor): share code-fence boundaries for Markdown links (#21574)

* fix(editor): share fence boundaries for Markdown links

* test(editor): assert highlighted lines across Monaco token spans

* perf(editor): scan fence lines without substring allocations

* fix(editor): match fence suffix whitespace rules

* ci: isolate cloud relay tests from unit shards

Run cloud relay integration tests once and exclude them from general shards, while caching the cloud lockfile inputs.

* fix(omp): acknowledge completion delivery and retire stale retries

* fix(omp): acknowledge completion delivery and retire stale retries

Co-authored-by: Tim Maximilian Lucas <7103424+timaxlucas@users.noreply.github.com>

* test(omp): exercise completion recovery over native HTTP in platform CI

* test(omp): verify rendered status clears after completion retry

---------

Co-authored-by: Tim Maximilian Lucas <7103424+timaxlucas@users.noreply.github.com>

* fix(omp): load task drafts through the status extension

* fix(omp): load task prefill through the status extension

* test(omp): capture installed runtime task prefill in hidden Orca

* fix(omp): resolve transcripts from active XDG and profile roots (#20633)

* fix(omp): resolve XDG session root fallback

* fix(omp): share profile-aware transcript root resolution

* test: verify actual OMP XDG transcript discovery and reading

* test(omp): satisfy reader smoke loop lint

* test(omp): resolve vault fixture paths without redundant cache

* fix(omp): refuse normalized Windows drive-relative roots

* fix: check host ownership before recovering OMP sessions (#20628)

* fix: gate startup agent recovery on host inventory

* fix: defer recovery while a live agent lacks conversation ownership

* fix(mobile): two known main bugs the RPC migration preserved (#20563)

* fix(mobile): two known main bugs the RPC migration preserved

A malformed host `error` and a null settings result both reach a property read that throws.
Both are deliberate behaviour changes; the goldens move in the follow-up commit.

`hostReplyErrorTextOrFallback` passed a truthy non-string through under a `string` annotation.
Its one caller is the in-band `git.commit` failure, and every consumer of that text is display or
prompt copy: `use-mobile-create-pr-runner` and `PrSidebarCreateEmptyState` record it as a commit
failure, `use-mobile-commit-failure-recovery` hands it to `summarizeCommitFailure`, which starts
with `raw.slice(...).replace(...)`. So no consumer needs the value, and the decision is the
fallback rather than `String(value)` — the relay handler declares
`commit(): Promise<{ success: boolean; error?: string }>`, so a non-string is a malformed reply,
and `generatedCommitMessageReader` in the same domain already reads a non-string host error as
absent. The parameter stays `unknown`, which it honestly is, and the `SAFETY` cast is gone.

`useNewWorkspaceRuntimeContext` read settings through `settingsRead`, whose reader preserves
main's `boxed!.settings` throw, so a `null` or absent result threw a TypeError out of the effect —
losing the trusted-hooks publish and the available-provider computation that follow it, not just
the settings. It now uses `optionalSettingsRead`, the operation that already reads a null or
absent result as absent settings, so the reply degrades exactly the way a reply with no `settings`
member does. Reply-side only: same method, same params, same barrier, no wire change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the goldens the two bug fixes move

Baseline bumped to 3f71999237. Two goldens move an observation; the other 151 move only
`baseline` and `recorderSha256`, which `pilot-scenarios.json` is still digested into.

Observation moves, one claim each:

- `matrix-hostedreview.create-intent-git.commit-1`, partition `inner-false-object-error`:
  `settlements.run.value.error` and `state.outcome.error` go from `{"message":"inner refused"}` to
  `"Commit failed"`. A non-string in-band `git.commit` error is a malformed reply and now reads as
  the screen's copy, converging with `result-absent`, `result-null` and `outer-refused-no-message`,
  which already reported the fallback. The other ten partitions at this site are unchanged.
- `matrix-settings.workspace-context-settings.get-1`, partitions `result-null` and `result-absent`:
  the `unhandled-rejection` TypeError effect (`reading 'settings'`) is gone and `state.providers`
  goes from `[]` to `["github"]`. The effect no longer aborts the rest of the hook, so the
  provider computation runs; `state.settings` stays null because nothing was published, which is
  how a reply with no `settings` member already degraded. The other nine partitions are unchanged.

Header-only moves:

- 9 goldens of the `settings.workspace-context` family rename `namedDeltas` from
  `new-workspace-runtime-context-null-settings-typeerror` — the name now lies, the TypeError is
  fixed — to `new-workspace-runtime-context-null-settings-degrades-to-absent`.
- All 153 move `baseline` and `recorderSha256`. The digest covers `pilot-scenarios.json`, so the
  baseline bump and the rename re-digest every file.

No sender recording moved: both fixes are reply-side, and no golden's `sender` or `payloads` field
differs. The README paragraph that claimed the settings TypeError was preserved is updated, and
now records that the `ui.get` leg of the same hook still is.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): degrade a null ui.get result the way the settings leg now does

One host answers both legs of useNewWorkspaceRuntimeContext, so fixing only
settings.get left the likelier failure in place: a null or absent ui.get result
still threw `reading 'ui'` out of the effect, skipping the provider commit.

Review follow-ups on the same files: reply() returns the literal uncast and the
stub client is FakeSession, dropping two assertions and their SAFETY disables;
the degradation cases now assert absolute state instead of comparing mounts.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the goldens the ui.get leg fix moves

Observational move, 1 golden:

- matrix-settings.workspace-context-ui.get-1: the `result-absent` and
  `result-null` partitions drop their `reading 'ui'` unhandled-rejection effect
  and their state commits `providers: ["github"]` instead of `[]`, because the
  effect no longer throws before the provider commit.

Header-only moves, 153 goldens: `baseline` to the fix commit and `recorderSha256`,
which covers `pilot-scenarios.json` and so re-digests on the delta rename.

The delta is renamed `new-workspace-runtime-context-null-settings-degrades-to-absent`
-> `new-workspace-runtime-context-null-results-degrade-to-absent` (9 goldens): it
now covers both reads, not just settings. README updated to match.

No sender recording moved: resolving the value pool across all 153 goldens shows
`sender` and `payloads` byte-identical everywhere.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): name the ui.get result shape so the changed cast carries a rationale

The inline union wrapped over four lines and tripped the changed-code casting gate
as a new assertion; a named alias keeps the cast on one line under a SAFETY note.
No behaviour change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the golden baseline to the cast-rationale commit

Header-only: `baseline` on all 153 goldens. The re-record is inert — no golden
moves observationally and no field other than `baseline` changes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the ui.get trust blank, and correct two stale acceptance comments

The goldens cannot catch a regression to `if (uiResult?.result)`: the scenario's
success reply is `{"ui":{}}`, so every partition of
matrix-settings.workspace-context-ui.get-1 records the same `trust:{}` state. The
new case answers once with real trust and again with a null result on a fresh
client, which is the only shape where skipping the blank is observable —
trustedOrcaHooks gates the setup-hook approval prompt in
use-new-workspace-create-submit.ts, so a stale value would skip it.

settingsRead's comment still claimed workspace context, which this branch moved to
optionalSettingsRead; both comments now name their real callers.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the golden baseline to the trust-blank commit

The record fence rejected the previous pin ("Product sources or lockfile differ
from the pinned main baseline"), so the branch was no longer re-recordable.

Header-only: `baseline` and `recorderSha256` on all 153 goldens — the digest
covers pilot-scenarios.json, whose only edit is that baseline. The re-record is
inert: 0 goldens move observationally and no other field changes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): name the right operation per refuse-after-data probe

Three of the five probes read through optionalSettingsRead, not settingsRead:
repo metadata and resume metadata already did, and workspace context does as of
this branch. The sentence now splits them and states why the split does not move
what the probes record.

Markdown is excluded from recorderSha256, so no re-record.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the recorder's unhandled-rejection capture

This branch removed the last two goldens that recorded an unhandled-rejection
effect, so nothing exercised unhandled-recording.ts any more: gutting the emit to
`void captureError(error)` leaves all 153 goldens comparing clean. The unit test
drives a detached rejection through the window and asserts both the effect and
the listener restore. README says so where it describes the capture.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-digest the goldens for the new recorder test

Header-only: `recorderSha256` on all 153 goldens, which covers every non-markdown
file under rpc-recording/ and so moves for the added test file. The re-record is
inert: 0 goldens move observationally and no other field changes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin each RPC golden to its own scenario input, not the whole manifest (#20562)

* refactor(mobile): pin each golden to its own scenario input, not the whole manifest

`recorderSha256` covered the recorder directory plus `pilot-scenarios.json`, so every golden's
header was a function of every other family's scenarios. Adding a family for one domain re-digested
all 153 goldens and put a conflict on that line in every domain branch in flight, which serialized
the step-4 fan-out.

Split the two things it conflated. `recorderSha256` now covers the recorder directory only, with
unchanged semantics: a recorder edit still forces a full, deliberate re-record. A new
`scenarioSha256` pins the scenario input that golden was recorded from — every scenario
`runRecording` consumed for it, in order — canonicalised through `captureValue` so an
explicit-undefined param stays distinct from an absent one. `goldenRecording` takes that list
instead of just its first member.

The variants are hashed rather than the base they expand from because they are what was recorded: a
matrix site, its replayed normal result and its partition replies are all visible in them without
the derivation having to be restated. `derived-goldens.ts` is that derivation, extracted from
`family-recordings.test.ts` so the digest and the recording agree by construction — a property test
that restated how a matrix or schedule expands could agree with itself and with nothing else. It
reproduces exactly the 153 golden ids on disk, and the census the suite already ran (every family
matrixed, no stale normal-result inventory entry) now reads off its output.

`golden-header-digest.test.ts` pins the four properties:

- a new family in the manifest moves zero existing goldens' headers, and derives two of its own
- editing one field of `b1` moves exactly `b1` and its family's four matrix goldens — not the two
  other `legacy-inventory` scenarios, and not the goldens that expand from `inventory-lifecycle`
- editing a recorder file still moves every golden's `recorderSha256`, and no `scenarioSha256`
- `recorderSha256` is unchanged by the manifest's contents, and no longer reads the file at all

`GOLDEN_FORMAT_VERSION` goes to 4: a version-3 header has no `scenarioSha256`, and `compareGolden`
walks the expected header's keys, so a reader that accepted one would compare that golden's own
scenarios as though they were unpinned.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the 153 RPC goldens for the split digest

Recorder edit, so every golden needs rewriting. Recorded from the pinned baseline
`16d1ab81d3` with this branch's recorder overlaid, per the README's procedure: main has
moved past the baseline, so recording in place would have failed the product-source fence.

Three header fields moved and nothing else did:

- `recorderSha256` 6a12160a87… -> 2fda557f58…, one value across all 153 files
- `scenarioSha256` added, 153 distinct values
- `goldenFormatVersion` 3 -> 4

No observation, checkpoint, value-pool entry, `baseline`, `lockfileSha256` or `platform` changed:

    git diff -U0 -- mobile/rpc-foundation | grep -E '^[+-]' | grep -vE '^(\+\+\+|---)' \
      | grep -vcE 'recorderSha256|scenarioSha256|goldenFormatVersion'
    0

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): certify the pilot goldens from the derivation that digests them

`pilot-recordings.test.ts` restated `[scenario]` instead of consuming `pilotGoldens`, so the claim
that a golden's `scenarioSha256` is a function of the same derivation that records the file held
only for the 75 family goldens: dropping a scenario from `pilotGoldens` left the whole suite green
and put that golden outside the header oracle. The pilot suite now iterates `pilotGoldens`, and a
census fails if the derivation and the goldens directory disagree in either direction — which also
closes the pre-existing orphan-golden gap.

Also from review: pin the cross-sibling replay that hashing the generated variants buys (a matrix
golden's `normal` partition replays a sibling's recorded reply, so editing that sibling must move
it); state the real reason for the format bump, which is the diagnosis a version check gives rather
than a rejection the byte compare already made; drop the fourth property test, which re-proved what
tests 1 and 2 and `recording-runner`'s digest test already fail on; and drop a guard in
`scenarioSha256` that its only caller reaches after an identical one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the 153 RPC goldens for the review edits

Recorder files changed, so `recorderSha256` moved. Recorded from the pinned baseline with this
branch's recorder laid over it, per the README's migration-branch procedure. That one header field
is the only line that moved in all 153 files: `scenarioSha256` and `goldenFormatVersion` are
unchanged, and no observation moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): correct the recording suite's test count

Round-2 review: the README said 200 tests; the suite is 209 after the five
added here. Markdown is outside recorderSha256, so no golden moves.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the 153 goldens on the merged baseline

Four header fields moved and nothing else. Proven against origin/main: every
changed line in all 153 files is one of these, and the file set is unchanged.

- `recorderSha256` 70aa6f59e0 -> 58a461dbc9: this branch's recorder, and it now
  digests only the recorder directory, not the scenario manifest.
- `scenarioSha256` added, 153 distinct values over 153 goldens.
- `goldenFormatVersion` 3 -> 4 for that added field.
- `baseline` 5ec0b2698f -> e53f1557e1, the merge's repoint onto the real main
  commit. #20563's value was a branch commit the squash left unreachable, so the
  record fence's `git diff <baseline>` could not resolve it.

No checkpoint, value pool, effect or settlement byte moved, so main's recorded
behaviour is carried over intact.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): account for main's added recorder test in the suite count

The merge brought in `unhandled-recording.test.ts`, one test, so the recording
suite is 210 rather than the 209 this branch documented. Markdown is excluded
from `recorderSha256`, so no golden moves.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(native-chat): stop a bounded tail read from moving the chat cursor past unapplied rows (#20581)

* fix(native-chat): stop a bounded tail read from moving the chat cursor past unapplied rows

A structured chat pane could latch "Working for N" forever after the agent had
finished, showing the send arrow rather than Stop, while the sidebar and
`worktree ps` correctly read idle.

The client replica has one position (`state.cursor`) and one body. Two
operations keep those consistent: replace (both from one host snapshot) and
append (rows contiguous with the cursor). The `tail-page` branch was a third
thing: it took the cursor from the journal head, the items from a bounded page
(200 items, byte-capped), then merged retained client submissions over the
page's. Under continuous journal writes the client is always slightly behind,
so the branch ran on every window focus and on every pane re-activation. When
more than a page of rows had landed since a send, that send's user item fell
off the page, its submission was not carried, the retained `pending` survived,
and the cursor jumped past the dispatch-acceptance row. Nothing re-sends it: a
batch carries only touched items and that submission is never touched again.

Delete the third operation rather than guard it. A live subscription is now the
only thing that moves the cursor, and `subscribe({ cursor })` already replays
exactly the missed rows.

- remove the window `focus` listener and the owner/transport `refresh` contract
- skip warm hydration: a retained owner subscribes at its applied cursor
- cold hydration keeps its history read, applied as the existing `snapshot`
  (replace) event rather than `tail-page`
- delete the `tail-page` action and its reducer branch
- delete `resumeCursor` and `shouldAdvanceStructuredResumeCursor`; two cursors
  with two advancement rules were how position and body drifted apart

`older-page`/`loadOlder`, the unattached-refusal grace, generation guards and
the coalescer are unchanged. No host, wire or schema change.

Also fixes a second cost of the same branch: focus during a busy turn discarded
paged-in older items, shrinking the transcript to one bounded page mid-turn.

* fix(native-chat): preserve unavailable mixed-version session fences

* refactor(mobile): send the task workspace-creation domain through typed RpcOperations (#20568)

* test(mobile): record main's task workspace-creation RPC behaviour before migrating it

28 scenarios over nine task senders, recorded from main so the step-4 migration of
the workspace-creation half of src/tasks/ has a frozen answer to compare against.
Four senders mount as plain exported functions; three are model-chained hooks
mounted the way the settings adapters mount theirs.

The 153 existing goldens change header-only (`baseline`, `recorderSha256`): any new
scenario re-digests the recorder, and the pinned baseline had drifted from main
because the source-control migration landed. Content is byte-identical on all 153 —
verified field-by-field against HEAD.

`operation-module-loader.ts` now shares src/transport/rpc-delivery-ambiguity.ts with
mounted modules instead of evaluating a second copy. The mark is a WeakSet keyed on
the rejection object, so the copy the loader built had an empty registry and every
delivery-unknown rejection read as a definite failure inside the operation under
test — worktree.create's whole replay path was unreachable. With one registry,
`tw-create-retry-ambiguous-after-drop` records the create still pending at the
reconnect wait and abandoning at exactly 20000 ms, while the unstamped-create
scenario records the same rejection surfacing at 0 ms. No existing golden moves:
no other mounted module consumes the mark.

`task-preferences-optimistic` is re-anchored above the send rather than across it,
so migrating this file does not have to move the anchor. It still kills, and for
the same reason: the preset the screen shows no longer follows the tap.

Scenarios deliberately pin the empty-message refusals (`*-refused-empty-message`,
`*-empty-message`), because a refusal with no message falls back to the screen's
copy while a transport error with no message does not, and the two paths are easy
to collapse when a call site moves behind an acceptance policy.

Goldens: 153 -> 201, 2.9M -> 3.7M.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): format the recording manifest and re-digest the goldens

`oxfmt --check` from mobile/ collapses a one-element `sites` array in each new
scenario. The JSON value is unchanged — verified by comparing both files parsed
and key-sorted — but the manifest is inside `recorderSha256`, so all 201 goldens
carry a new digest. Every other field, header and observation alike, is
byte-identical.

Re-recorded in a separate worktree at the previous commit so the goldens stay
attributable to main's product source rather than to the migration that follows.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): separate the goldens from the migration, and re-digest

The previous commit accidentally carried the product migration alongside the
manifest format, which both broke the commit that is supposed to prove parity and
left the suite red: the digest was recorded without a comment move that a lint fix
had made inside the adapter, so all 201 goldens failed their `recorderSha256`
header.

This backs the product half straight out again — the next commit re-applies it
byte-for-byte — and re-records from the pinned baseline in a separate worktree
carrying this branch's recorder, per the procedure in the recording README. Every
field except `recorderSha256` is byte-identical to the previous commit's goldens on
all 201 files, so no observation moved in either direction. The suite is green here
with main's product source, which is what makes the next commit's "no golden
changed" claim mean something.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): send the task workspace-creation domain through typed RpcOperations

12 of src/tasks/'s 37 raw-port files now send through a declared operation instead of
the raw request port: 36 references to 0, leaving 25 files and 73 references for the
provider item/detail/mutation half. No golden moved — `git show --stat` on this commit
touches nothing under mobile/rpc-foundation/, which is the parity claim.

Twenty-two operations over twenty methods, in four modules named for what they send:
workspace create (create, PR/MR base resolution, create-time capabilities), workspace
source (SSH connect/state, agent detection, repo hooks, sparse presets, ref search),
task runtime (status, ui.get/ui.set, preflight, Linear status, settings.update) and the
Smart picker's provider reads.

Two methods carry two policies each, and both pairs are named. `status.get`: the Tasks
screen cannot hydrate without it and surfaces the host's message, while create-time
capability probing degrades to "no capabilities" and creates anyway — so one throws on
refusal and one skips. `ui.set`: two sites await it, one is fire-and-forget and never
interprets the reply at all. Both pairs share one reader, so no method has two readers.
No new acceptance policy.

worktree.create keeps its delivery-unknown contract. `request` returns the transport
promise itself, so the retry loop catches the object the transport marked; two new tests
assert `toBe(marked)` in one direction and that an unmarked rejection stays unmarked in
the other, because a mark added on the way out would replay a create the host never
received. `tw-create-retry-ambiguous-after-drop` records the create still pending at the
reconnect wait and abandoning at exactly 20000 ms.

Three sites still read the raw refusal envelope before interpreting, because the code or
the message decides the route and no acceptance policy carries either through: the create
retry needs the message for `isRetryableWorktreeCreateConflict`, and the paste lookup
needs `method_not_found` to retire the slug probe host-wide. Both are documented at the
site.

The hydration barrier keeps raw requests inside its `Promise.all`. main's group rejects as
soon as one leg rejects; `startRpcOperation` + `interpretAtRpcBarrier` would wait for the
slowest peer and let a later policy surface a different error. Interpretation stays after
the `stale` guard, where it was.

`WorkspaceCreateParams` is now `RpcSendParams<'worktree.create'>` rather than
`Record<string, unknown>`, which types the builder and the operation together; every field
the three builders already sent typechecks against the host schema unchanged.
`RpcSendArguments` now also makes params optional for a method whose params type has no
required field, because `preflight.check` is such a method and main sent it none —
requiring `{}` would have put a new object on the wire.

The Mobile Tasks source-parity hashes move for the same reason bound settings requests
moved them: the method string and the envelope read leave the screen. The signature diff
is evidence rather than a re-pin — `semantics` is a pure deletion of 22 `rpc:` call
signatures and 22 method literals with nothing added, statement/declaration/render/style
counts are unchanged, and render tokens, styles and declarations are byte-identical.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): split the task workspace adapters at the sender/hook seam

The single adapter file reached 344 lines against mobile's 300-line limit. CI lints
every file, so this is red there even though the changed-code gate does not report it.
Split along the seam the recording README already draws: exported async senders that
take a client and need no React host, and the drawer's three model-chained hooks.
No adapter body changed.

Both files are inside `recorderSha256`, so all 201 goldens carry a new digest. Every
other field is byte-identical, verified file by file. Re-recorded from the pinned
baseline in a separate worktree carrying this branch's recorder, so the goldens stay
attributable to main's product source.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): give the one-key unchecked reader a name

Four readers were the same three lines: read one property off the reply, wrap
it unchecked. `rpcUncheckedMemberReader` is the one-key sibling of the existing
`rpcUncheckedPayloadReader`, so the annotation and the closure go away at each
site. The pilot's `commitCompareEntriesReader` is converted too, so the helper
has no longhand twin left to copy from.

No behaviour change: the helper composes the same `rpcReadUnchecked` over
`rpcPayloadMember`, including the property-read exception on a null result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): record the local arm of workspace agent detection

`preflight.detectAgents` was the one migrated operation with no recorded
coverage: the ssh adapter hardcoded `connectionId: 'ssh-1'`, so the detection
effect's ternary only ever took the remote arm and the local call site could be
repointed at another method without a golden noticing.

The adapter now takes the connectionId as a parameter and registers twice;
`tasks.workspace-ssh-local` mounts the same hook with no connection, which is
the only difference the effect branches on. Recorded at the pinned baseline
with this branch's recorder laid over it, so the new golden is main's
behaviour and the migrated code has to reproduce it — it does.

Goldens: two added (`tw-workspace-ssh-local-agents` and its reply matrix). The
other 201 changed on `recorderSha256` only, because the adapter edit moves the
recorder digest every golden pins.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): drive workspace create and the Linear list to a recorded wire

Two operations passed a policy swap unnoticed, both because no golden reached
their acceptance branch.

`worktree.create`: the create hook's fixture resolved setup to a prompt, so all
three settings.task-workspace scenarios stopped before the request and the only
consumer that hands a refusal to interpret was never recorded. The adapter now
takes the setup resolution as a parameter and registers a second family that
resolves it, so createWorkspace runs to the wire. Two scenarios: a Linear item
that creates directly, and a GitHub pull request that resolves its base first,
which also puts this hook's built params — start point, generated display name,
agent launch fields — in a golden for the first time. The existing prompt
family is untouched, so its recordings still pin that branch.

`linear.listIssues`: it appeared only in a non-base scenario, and the matrix
reads the family base, so the family had no partition for it. The base now
lists assigned issues after searching.

Goldens: five added. Five moved beyond the digest, all derived from the
smart-search base that gained the list leg. The other 198 changed on
`recorderSha256` only. Recorded at the pinned baseline with this branch's
recorder laid over it, so every new golden is main's behaviour.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): drop the unreachable unmount branches from the task adapters

Nothing dispatches `unmount` to these three adapters: the only producer is
`lifecycleSchedules`, driven from a hardcoded five-id list that names no
task-workspace family, and it pushes a `remount` right after, which these
adapters would throw on. The branch read as lifecycle coverage that was never
wired up. `dispose: hook.unmount` already tears the mount down.

Goldens re-recorded at the pinned baseline because the recorder digest moved;
`recorderSha256` is the only line that changed in all 208.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the goldens at main's post-squash baseline

Recorded from a detached checkout of e53f1557e1 (main's unmigrated
product code) with this branch's recorder laid over it, so the parity
claim stays non-circular.

- `baseline` repinned to e53f1557e1 on all 208 goldens; main pinned
  5ec0b2698f, a pre-squash branch commit not reachable from main.
- `recorderSha256` moved on all 208 because this branch's adapters are
  in the whole-manifest digest.
- 55 task-workspace goldens re-recorded at the new baseline.
- No other line in any of main's 153 goldens changed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the goldens under #20562's per-scenario digest

Baseline repinned to 50e752fc66 and all 208 goldens recorded from that
commit's unmigrated product tree with this branch's recorder laid over it.
recorderSha256 moves on every golden because the task-workspace adapters
live in the recorder directory. scenarioSha256 does not move on any of
main's 153: the manifest only adds 31 scenarios and edits none, which is
the property #20562 was built to give.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(ai-vault-search): construct the session search indexer in the scanner service behind a setting (#20516)

* feat(ai-vault-search): persist agent-session search consent and retention

Two booleans and nothing else: `enabled` and `historyDays`, off by default
because building the index reads every transcript on the machine. No `paused` --
the PR 3 indexer is immutable, so every change is close-and-construct.

The settings IPC normalizes a write like every other field and hands the change
to the index; there is no UI for it until PR 8.

* feat(ai-vault-search): hold one indexer and engine pair per host

The object that owns a host's live index and the three recipes that change it.
The indexer is immutable, so a settings change is close-and-construct, disabling
is close with no replacement, and clearing is close, remove the database,
construct. The new instance's first sweep purges a narrowed window and admits a
widened one, so neither needs a code path.

The database sits beside the scanner's parse cache, one file per host. A runtime
with no node:sqlite can hold no index at all, which the Node 18 floor on orcad
and the relay makes a real case rather than a hypothetical one.

* feat(ai-vault): let the scanner child own the session search index

The transcript reader runs in that child, so the index consumer has to as well:
one read serves both the session list and the index. Three request operations
(search, status, reconcile) and one fire-and-forget settings message carry
everything a parent needs; main never opens the database file.

The init frame becomes a factory because it is read at every spawn, so a
respawned child sees current consent rather than the first frame's. A child
holding a running index is never idle from the parent's side, so idle retirement
is suppressed while the index is on -- retiring it would stop the reconcile loop
until some later scan happened to respawn one.

Both files this lands in were already at the max-lines ceiling, so three
collaborators move to where they belong rather than being disabled around: the
invalidation deadline into the class that owns invalidations, call cancellation
and the start requeue into the call-state module, and orcad's flag parsing into
its own file.

* feat(ai-vault-search): register a search service on every host that answers

Without a registered service a host answers no-service, which means "this host
does not have the feature" rather than "the index is off". All three hosts now
answer the second thing.

The desktop forwards to the scanner child. orcad and the SSH relay daemon have
no such child -- orcad ships only the watcher and daemon entries, and the relay's
AI Vault sidecar runs the remote scanner, which publishes nothing to the
transcript channel -- so on those two the index lives in the process that would
drive its reads, gated on a runtime that has node:sqlite at all.

The relay registers with consent off and no way to turn it on: nothing carries a
setting to a remote host yet. That is the honest state, and it is still worth
registering, because it is what tells a client the difference between off and
too old.

* test(ai-vault-search): price a warm pass over five thousand transcripts

The number the reconcile interval will be revisited against, measured rather
than argued: a warm sweep stats every file under every root, a warm cycle stats
the newest N per agent, and neither reads what the index already holds. It does
not tune the interval.

* fix(ai-vault-search): answer the casting gate without assertions

main's new type-assertion rule reaches every file this branch touches. All nine
sites drop the cast rather than carry a SAFETY: rationale: the operation guard
narrows with `in`, the sqlite probe narrows the builtin it loads, the child test
keeps the discriminated reply instead of widening it, and the settings resolver
takes `unknown` -- which is what it really reads, since a persisted profile can
hold a value no version of this code wrote.

* fix(ai-vault-search): let a refreshed scan root reach the live index

The parent re-resolves scan roots before every policy push, precisely so a
WSL distro or extra Codex home that appeared since the child spawned enters
the window. The child forwarded only the settings to a live instance and used
the roots solely in its `??=` initializer, so those roots were dropped for the
child's lifetime.

The indexer stays immutable: a structurally different root set closes the pair
and constructs a new one, the same way a changed databasePath already does.
Compare via `sameSessionSearchRoots` rather than a plain JSON compare, because
nothing fixes the key order two producers write; lists are sorted too, since
the indexer walks every root and a re-enumeration that reorders is not a
change. An unchanged set still never restarts a running index.

The orcad and relay in-process hosts resolve roots once at install and never
re-apply, so they have no such seam.

* fix(ai-vault): restart the scanner child the index is holding

Three review items.

The hold keeps a child alive for the index, but only a queued call ever
started one: `pump()` skipped a hold with an empty queue, so an idle indexing
child that crashed, or an `ensureChild()` that failed at start, left indexing
stopped until an unrelated request happened to arrive. `pump()` now starts the
child the hold requires, which is also the restart callback the fault policy
already schedules, so the existing delay and circuit bound the retry exactly as
they bound a queued call's start. `updateSessionSearch` goes through the same
seam instead of its own `ensureChild` call.

A search registers no AbortController, so a cancel sent for a search id was
added to the `cancelled` set and never consumed. Nothing can reach that today
-- no caller passes a signal and the child answers in milliseconds -- so this
is only a leak of ids: consume it when the search settles.

The orcad argument doc claimed a `--`-prefixed value stays a flag. The parser
takes the next token regardless, and orcad-launch-contract.test.ts pins that,
so the doc is what was wrong. Behaviour is unchanged.

* fix(ai-vault): recover search indexing and refresh scan roots

* fix(ai-vault): defer search refresh policy reads

* fix(session-search): stabilize paging and host enablement

* fix(session-search): refresh host roots within full sweeps

* docs(session-search): clarify initial root fallback

* fix(native-chat): hide activity while awaiting input (#20496)

* fix(native-chat): hide activity while awaiting input

* fix(native-chat): keep approval turns cancellable

* test(native-chat): satisfy split PR quality gate

* fix(native-chat): catalog approval cancellation label

* fix(native-chat): include approval cancellation runtime label

* fix(codex): settle prompts when cancelled turns complete

* fix(codex): settle prompt registry fallbacks

* test(native-chat): cover pending interaction fallbacks

* test(native-chat): split prompt state coverage

* test(native-chat): keep prompt state isolated

* fix(native-chat): bound prompt turn backfill

* refactor(codex): centralize prompt registry bounds

---------

Co-authored-by: Merge Sim <sim@local>

* Update README downloads badge

* Bump mobile app.json to 0.0.50 (#20661)

* fix(native-chat): bound a dispatch reason before it reaches the journal row (#20654)

`AgentJournalSubmission.reason` was the only unbounded field written by
Orca's own code. `dispatchSafely` sets it from the adapter's raw
`error.message` and `journalDispatchRowBuilder` stored it verbatim, so a
provider error carrying a multi-megabyte body -- a stringified HTTP error
payload, say -- reached the row at whatever length the provider sent, and
stayed on disk at that size for the life of the journal.

It now goes through `boundInlineText` with the journal's existing inline
limit, the same idiom already applied to arbitrary text on the Claude and
Codex translation paths.

The bound must stay head-preserving. `dispatchRejectionWasTransportWriteFailure`
prefix-matches the value, and `dispatchRejectionReasonIsInternal` builds on
it, so a bound that kept the tail instead would stop classifying a clipped
transport failure and render raw provider text to the user as an ordinary
rejection notice. A test pins that, and clipping stays marked rather than
silent so a truncated reason is never presented as the provider's complete
explanation.

Rows written before this keep their full text, so readers can still meet an
unbounded reason.

* fix(native-chat): keep a resumed transcript pinned to its end (#20651)

* fix(native-chat): keep a resumed transcript pinned to its end

Follow state was recomputed from distance on every scroll event, and a pin writes scrollTop itself, so the browser reports that write back as a scroll event a frame later. Once a resumed session's later history pages and settling row heights had moved the end away from it, that echoed event read as the reader leaving and the pin was dropped for good, stranding them mid-transcript. Measured in Chromium: a pin followed by same-task growth delivers a scroll event reading 2000px from the bottom, indistinguishable from a reader scrolling up.

Pins now go through the virtualizer instead of writing scrollTop directly, so both parties resolve the end through the same maximum rather than holding rival definitions of it. Whether the reader left is now a question of provenance rather than distance: an offset this transcript wrote is never a departure. The end test reads live geometry, because the virtualizer's own isAtEnd subtracts a cached offset from a live maximum and this handler runs before that cache is refreshed. overflow-anchor:none stops the engine moving scrollTop under a settling row, which would otherwise look like the reader.

This does not make ownership singular. The virtualizer still writes autonomously from several paths and those writes stay unattributed; what this removes is the rival definition of the end, not the second writer.

* fix(native-chat): cancel stale end reconciliation

* fix(native-chat): attribute scroll ownership centrally

* Surface stage, unstage and discard failures with retry capability (#20423)

* fix(source-control): surface stage, unstage and discard failures

* fix(source-control): use single slot for entry failure toasts

- Consolidate entry failures to one stable slot instead of per-worktree
- Handle stale retries inline at click time rather than via a cleanup hook
- Remove retry button from discard failures to prevent destructive accidents

* test: improve type safety and mock patterns in source-control tests

- Add proper type definitions for toast options and test data instead of using `as never`
- Replace `mock.calls.at(-1)` with safer `mock.lastCall` pattern
- Create `entry()` helper to construct typed test entries
- Add explicit type annotations to mocked functions for better IDE support

* test: extract shared toast options type for source control tests

Consolidate duplicate `ToastOptions` type definitions across three test files into a single `SourceControlToastTestOptions` type, reducing duplication and improving consistency.

* fix(source-control): separate refresh failures from mutation failures

Post-mutation refresh failures are logged separately, not surfaced as toasts
(mutation already succeeded). Use preventDefault() on retry to prevent sonner's
auto-dismiss from swallowing re-raised failures. Consolidate stage/unstage into
a shared handler to reduce duplication.

* fix(source-control): only dismiss entry failures from the owning worktre

Track which worktree owns the shared entry-failure toast slot. When a mutation
completes, only dismiss the slot if the completing worktree is the one that
raised the failure — a slow retry in one worktree should not erase a failure
another worktree has since raised into the slot.

* Remove entry mutation status refresh helper

Inlined into the caller during consolidation of failure handling and
tracking in the source-control entry mutations flow.

* Simplify entry mutation refresh without wrapper

Call refreshActiveGitStatusAfterMutation directly instead of through the
refreshEntryMutationStatus helper. This ensures refresh failures propagate
directly from the callback without being caught as mutation failures.
Remove tests that validated the wrapper's error handling.

* Add copy button to code blocks (#20357)

* feat(native-chat): add copy button to code blocks

Enable users to copy code snippets directly from chat messages via a dedicated copy button on fenced code blocks. Supports language detection and integrates with markdown rendering via a `renderCodeBlock` prop.

* i18n: add English copy code button label

* refactor: use React.isValidElement type parameters for type narrowing

- Specify props types as type parameters to React.isValidElement instead
  of casting after the fact
- Allows TypeScript to narrow element.props type automatically
- Eliminates manual type assertions in extractCodeText and extractCodeFenceLanguage

* fix(source-control): prevent text wrapping in section headers and action buttons (#20046)

* fix(source-control): prevent text wrapping in section headers and action

Use flex layout constraints (flex-1, shrink-0) and text truncation instead of
wrapping to keep section labels and action buttons on a single line in the
right sidebar.

* test(source-control): add section action button alignment tests

Ensure View all button stays on single line with icon actions in
crowded section headers. Pin layout constraints (shrink-0, flex-wrap,
whitespace-nowrap) to prevent regression.

* Rely on Button base styles for action label wrapping

Remove redundant shrink-0 and whitespace-nowrap utilities from
section action buttons. These should be supplied by the Button
component's base variant, not duplicated at each usage site.

* fix(store): stop two no-op writes from re-running every selector in the app (#20641)

* fix(store): stop two no-op writes from re-running every selector in the app

zustand bails out of a `set` only when `Object.is(next, state)`. Two updaters that
mean "nothing changed" hand it a fresh reference instead:

- `setWorkspacePortScanRefreshing` wrote unconditionally — the one action in its
  file that did; its four siblings all early-return `state`.
- `applyGitHubPRRefreshEvent` ended its no-op branch with `: {}`, and
  `Object.assign({}, state, {})` reproduces every field unchanged while still
  notifying. ~20 sibling sites in the same store already use `return state`.

Both rebuild the root and wake every subscribed selector (~2.2k per the listener
census). Renders are unaffected — the selection is unchanged — so the cost is
wasted selector evaluation, not commit pressure. The resulting state looks
identical either way, which is why it goes unnoticed; both tests therefore count
subscriber notifications rather than asserting state.

* test(store): use checked initial state in notification regression

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>

* fix(ai-vault): bound streamed remote JSONL records (#20700)

* fix(ci): stop hourly versions dropping below a tagged or already-shipped build (#20699)

* fix(ci): stop hourly versions dropping below a tagged or already-shipped build

Hourly/daily/adhoc based their X.Y.Z on GitHub releases, not git tags. When
v1.4.202 was tagged and then its GitHub release vanished, the next hourlies
shipped as 1.4.202-hourly — below both stable 1.4.202 and the 1.4.203-hourly
builds already installed, so electron-updater stopped offering updates.

Read main's v* tags and already-published channel tags instead.

* docs(ci): record that 1.4.202's release was unpublished for a bug

The leftover tag is what hourly must still honor; this was not a failed cut.

* fix(store): preserve state identity for no-op updater branches (#20703)

* fix(codex): settle a structured send on admission, and stop minting a colliding identity (#20138)

* fix(codex): settle a structured send on admission, and stop minting a colliding identity

Two sends could be written into the journal under one durable identity.

Codex coalesces a mid-turn `turn/start` into the running turn rather than
refusing it -- measured against real `codex app-server` builds 0.147.0,
0.150.1 and 0.153.4, none of which refuse and none of which fire a second
`turn/started`. The dispatch path read the turn id from the turn/start
response and stamped every accepted send `ordinal: 0`. Since a coalesced
send gets the running turn's id back, two submissions persisted the same
`providerItemId`. That string is durable, and it is the key a restore uses
to match a submission against provider history, so the second message's real
history row matched nothing and rendered as an extra bubble on replay.

On 0.147.0 it is worse than a collision: the coalesced response returns a
turn id that never starts and never completes, so the persisted key named a
turn absent from history and NEITHER message could match.

Identity is now minted from the echoed user message at `identityFor` -- the
single point that mints the journal row's own identity -- so the settled key
is by construction the one replay computes, rather than a parallel
calculation that can drift.

Dispatch returns `admitted` when the transport write completes; identity
settles on the echo through a channel that did not previously exist for
Codex. Waiters are keyed by client message id instead of being shifted off
the front of an array by arrival order, and they are cleared on session
close and child exit -- previously a timeout was the only thing that ever
ended one.

`TURN_ID_WAIT_MS` is deleted. It was never reachable on any build measured:
`readCodexTurnId` returns non-null on all three, so the 10s wait never
fired. The comment justifying it claimed older builds acknowledge before the
id exists, which no tested build does.

Three comments asserting Codex answers a mid-turn send with `turn already
running` are corrected. Their only backing was a test fixture inventing that
error string. The correction is factual only -- every changed line in
`src/main/runtime/orchestration/` is a comment, and mid-turn delivery is
still refused for both providers. Whether that policy is right is a separate
question; it was resting on a false premise.

Known gap, stated rather than implied: this prevents new collisions and does
not repair journals already written with a colliding or phantom key. Those
conversations keep duplicating on restore. Repairing them means re-matching
persisted submissions against provider history and rewriting
`providerItemId` -- which is what `journal-submission-reconciler.ts` is
written for, and it still has no production caller.

* test(codex): drop the synchronous-accept contract and the colliding `:0` from the integration fakes

Three tests in the structured-session integration suites encoded the dispatch
contract this branch replaces, and two of them pinned the defect it fixes.

They asserted `agentSession.send` answers `dispatchState: 'accepted'` carrying
`providerItemId: codex:<thread>:<turn>:0` at send time. That ordinal was never
observed; it was stamped on every accepted send, which is exactly the collision
this branch removes -- a send coalesced into a running turn is answered with the
running turn's id, so two submissions persisted one durable key.

The visible failure was a 30s timeout rather than a failed assertion. The fake
client advertised no `agent-session.pending-send-result.v1`, and without it the
host holds the reply until the send settles: a shim for clients too old to
render a pending bubble. The fake provider then echoed the user message with no
`clientId`, so nothing could correlate that echo back to the submission, and the
wait ran to its own 30s ceiling. Real Codex sends `clientId` on that echo, and
the fake now does too, which is what makes it a model of the provider rather
than a sketch of one.

The identity assertion is kept rather than dropped. Each send now asserts
`pending` with no identity at admission, then asserts the submission settles
`accepted` at `codex:<thread>:<turn>:0` once the echo lands. Same ordinal, but
earned from `identityFor` on the echo -- the key a replay recomputes -- instead
of guessed from the turn/start response. Ablated: removing `clientId` from the
two echoes leaves both submissions `pending` and fails both assertions, so the
assertion is load-bearing and not satisfied by something incidental.

Both suites' client fixtures now advertise the capability set the desktop
renderer sends in `src/main/ipc/runtime.ts`, which is what these suites mean by
a client. The older-client settlement wait keeps its own coverage in
`src/main/runtime/rpc/methods/structured-agent-session.test.ts`.

`structured-agent-session-runtime-exit.test.ts` asserts `pending` for the same
reason; it drives the host directly, so it never took the compatibility path,
and what proves delivery there is still the turn the reacquired provider starts.

The replay suite's "without dispatching it twice" property is untouched: one
`turn/start` call, one replayed ledger row.

* fix(codex): preserve unsettled dispatch correlations

* test(codex): type the dispatch fixtures instead of asserting over them

main's new casting gate (#20367 base) flags type assertions on changed
lines. Replace them with checked types: the recording sink already
satisfies its interface, both CodexSession fixtures are now annotated and
carry real collaborators, the settlement assertion compares whole
identities, and the integration helper reads submissions through the
host…
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