A concurrency slot is charged per job, not per core, and the account's cap is
the scarce resource: standard runner minutes are free and unlimited on a public
repository. Two paths spent slots that bought nothing.
The unit matrix ran eight fixed shards averaging 6.5 minutes each, 3384
job-slots a day and 68% of all slot demand, while the arm pool queued 10.5
minutes at p95 — the queue was the oversharding. Five shards run the same work
in ~10.5 minutes each for three fewer slots per run.
Bun profile persistence escalated to all six platforms on `config/`,
`resources/` and `.github/` wholesale, which took 36.5% of the last 1100
commits through the full matrix where a platform-flavoured predicate takes 19%.
A pull request now qualifies one platform unless the change is platform-
flavoured, and the push to main re-qualifies all six, so an unescalated miss
surfaces minutes after merge rather than at the next cron. Missing changed-file
evidence and an unavailable dependency graph still fail closed to all six.
#23801 removed the child-path reading of Codex's turn-ending `error` along with
the import of the module #23682 deleted. That was the wrong half to remove: the
primary journal path can rely on Codex's failed `turn/completed` arriving within
~32 ms, which is what #23682 established, but a child turn has no such
guarantee, and without the error as its end the child's lifecycle row latches on
`working` for the life of the session. Three tests assert exactly that and could
not run, because the unresolved import had been skipping the unit matrix since
#23682 merged.
The reading is restored inline against `readCodexErrorWillRetry`, itself restored
to `codex-structured-thread-facts.ts`, rather than by reviving the deleted
module: its `thread-stopped-running` arm lost its only consumer when #23682
rewrote the primary path, so restoring the file would re-add dead code.
Also drops `pr-workflow-parallelism.test.mjs`'s read of
`.github/workflows/track-community-prs.yaml`, which #23796 deleted while leaving
the assertion behind. Same failure class, and it fails the same shard.
#23799 removed both `waitForSetupBeforeAgentHelp` call sites but left the entry
in all six locale catalogs. The runtime-required generator treats an entry with
no literal-default call site as one only the catalog can serve, so the two dead
entries had to ship in the boot bundle for the catalog check to pass, and
`verify:localization-catalogs` failed on every pull request until they did.
Deleting them is the resolution rather than regenerating
`en-runtime-required.json`: no call site can reach either string, so shipping
them would add dead weight to the boot bundle to satisfy a check about what the
bundle must contain. The sibling `waitForSetupBeforeAgent` heading keys stay;
#23799 removed only the help paragraphs.
A start refused for a reason only the person can fix (not signed in, history too large,
a managed-account problem) or one the host stopped because it never came is held like a
failed start: the mail waits for the person's next message, which also retries the start.
An account switch still in progress is transient, so it stays ungated and the first edge
after the switch settles points the mail. One exhaustive record places every rejection
kind, so a new kind does not compile until it is placed.
#23682 established that only `turn/completed` ends a Codex turn and deleted
`codex-structured-journal-provider-verdicts.ts`, but the child-turn reader kept
importing `readCodexProviderVerdict` and branching on its `turn-failed` verdict.
The module is gone, so the import resolves to nothing: typecheck and static
analysis fail on every pull request, which skips the whole unit matrix behind
them, and the esbuild pass behind Bun profile scope detection throws, so every
run falls back to all six platforms.
A closed thread is now the only child-turn end without `turn/completed`, which
is what #23682 intended: Codex follows a turn-ending `error` with a failed
`turn/completed` for the same turn 0-32 ms later, and that completion carries
the duration and receipt time the error does not.
A pointer whose provider died before echoing it is given back. The death's own status
edge then redrove the mail, and since a send starts the agent, a provider that died on
every turn was restarted about once a second for as long as the mail was unread. The
pointer lane now reads, on every attempt, whether the session's latest send never ran
because its provider exited or could not start, and holds the mail until a later send
runs. Every trigger passes through that gate: a parked retry, the idle edge's re-derive
and new mail. A rejection for any other reason still points at the next idle edge.
selection_evidence is continue-on-error on both the job and its comparison step,
so it can never fail a PR -- it downloads the shard reports, compares selection
against the full results and uploads a review artifact. But a caller's
`needs: test` waits for every job in the called workflow, so living inside
unit-tests.yml it held verify for ~36s after the last shard finished.
It moves to its own reusable workflow called as a sibling, so it still runs on
every PR and still uploads its artifact, but verify no longer waits for it. It is
deliberately absent from verify's needs, and a contract test pins both that and
its advisory status so it cannot drift back onto the critical path.
Measured on a recent run: the shards finished, then selection_evidence ran 36s,
then verify 3s. Only the last of those gates anything.
* fix(runtime): settle a quiet Codex composer as tui-idle on every version
Codex 0.158 dropped `model:`/`directory:` from its startup header, which both
Codex readiness rules require, so `worker-start --agent codex` timed out; an
idle Codex pane after a turn also had no readiness signal once the header left
the screen.
Generalize the Muse tier-1b lane into a quiet-ready-screen lane: a Codex (or
agent-unknown) pane whose live screen shows the empty composer placeholder,
no `to interrupt)` status row, no header `loading`, and no dialog wording in
its live window, settles once the stream has been quiet for the tui-idle
quiescence window. Additive only: the tier-1 rules and the Muse rule are
unchanged. Fixtures: codex 0.150.1-0.158.0 captures at 120x40, including
chunk-timed turns.
* refactor(runtime): anchor the Codex quiet lane to the empty composer line
Move the Codex screen rules into codex-terminal-readiness.ts and the quiet-screen
body beside isKnownReadyPromptBody. The composer rule now matches only the
`› Ask Codex to do anything` line and drops its dialog markers: every Codex
dialog replaces the composer, and an answer ending "Would you like to…?" above a
live composer must not hold the lane forever. The quiet lane checks quiescence
before reading the screen. Trim the redundant startup and untimed turn fixtures.
* fix(runtime): read Codex's busy row above the composer and scope the lane to codex panes
* fix(runtime): read only Codex's live status row above the composer
* fix(native-chat): treat a folded mid-turn Claude replay as a receipt, not a turn boundary
A message sent while a Claude turn runs is folded into the running turn by the
CLI and replayed mid-turn with the client uuid. The replay-driven opener treated
that replay as a new turn: the running turn was marked interrupted and the
'Worked for' bar split. The dispatch waiter now captures the open turn at write
time (sentDuringTurnId, volatile); a replay that adopts the client uuid while
that exact turn is still open settles delivery and opens no boundary. Plural
result user_message_uuids settle each waiter under its own uuid; every other
relation (miss, replaced turn, provider-resumed root, idle write, fresh replay
uuid) keeps the opener path. Replay/turn resolution split out of the dispatch
module to hold the line budget.
* fix(native-chat): state the fold receipt's uuid-adoption limit as unmeasured
The receipt admits only a replay that adopts the client uuid. The comment and
test named a fresh replay uuid as "the CLI starting the queued send's own
turn", but the measured miss case adopts the client uuid too, so adoption does
not tell a fold from a later turn. Say what the rule actually is: the measured
fold shape qualifies, and an unmeasured fresh-uuid replay keeps the opener path
it always had. No behavior change.
* fix(native-chat): decide Claude fold receipts from the provider's own request cycle
Measured (p3 captures, CLI 2.1.280): the CLI folds any send that arrives while
its request cycle runs — including one written before the first replay — and it
announces every new cycle (sequential turn, queued turn, background wake,
/compact) with a root system/init; each result names the sends its cycle ran in
user_message_uuids. So the fold decision now reads provider state: an adopted
replay while the open turn's cycle is still live (no root init since it opened)
is a delivery receipt. The send-time bookkeeping (sentDuringTurnId) is removed;
it missed the measured early-steer fold and guessed at what the provider states
outright. A lost result is covered by the init staleness mark, and CLIs below
the per-turn-init version floor (or with no reported version) keep today's
opener path via an explicit gate on the init frame's claude_code_version.
* fix(native-chat): read Claude fold membership from the cycle's own work
A finished background task revises its row before the wake cycle's init,
so the wake turn opened ahead of that init and was marked stale by it: a
send folded into the wake still split the bar and interrupted the wake
turn (captured p3-background-wake order). A send replayed after the
current cycle's first root work (send echo or model output) is the fold;
the cycle's first send is its opener. Init and every settle start a
cycle with no work.
* fix(native-chat): adopt the CLI version from any init frame, not only the startup proof
Live sessions prove the session from a SessionStart hook frame that arrives
BEFORE system/init, so startup facts read a frame with no claude_code_version
and the fold receipt's version gate never passed: the real app still split the
bar and marked the running turn interrupted while every fixture test — whose
harness proves startup with the init frame itself — stayed green. The version
is now adopted from whichever init frame carries it, at the same site that
already adopts the per-turn model report, and the startup proof never clobbers
a version a real init already reported. Pinned twice: a fixture run whose
startup proof is the SessionStart hook frame, and a real-CLI fold test (skipped
without a signed-in CLI) that fails on the unfixed branch at the interrupted
assertion and passes with the fix.
* test(native-chat): run the real-CLI fold test in the config dir it probed
The connection strips an inherited CLAUDE_CONFIG_DIR and inherited auth
vars, and with no launch env the pin compared against that same inherited
value and emitted nothing, so the fold test always ran against ~/.claude
whatever CLAUDE_CONFIG_DIR the availability probe checked. It also relied on
the user's own SessionStart hooks for the live proof order and on their
permission rules for the Bash steps; an isolated home hung at startup.
The test now launches with the probed home and env auth, and pins a
SessionStart hook and the sleep permission itself.
* fix(native-chat): drop the CLI-version gate and prove the fold against full adapter captures
The fold rule stands on frame-derived facts alone: an adopted client uuid
(the capability check, read off the replay itself) while the open turn's cycle
has done root work. The claude_code_version floor guarded only an unobserved
triple fault — an adopting CLI without per-turn init AND a lost result — and
its cost was a silent latch that already fired once live; all cliVersion
plumbing is removed. Mid-turn auto-compaction was measured (forced via
CLAUDE_CODE_AUTO_COMPACT_WINDOW): it emits status/compact_boundary and a
synthetic continuation but NO root init, so the init cycle reset stays and the
capture is pinned. The fake connection now defaults to the live startup shape
(SessionStart hook proof; one init when the first command starts a cycle;
capabilities on the initialize result), with init-at-startup an explicit
unmeasured opt-in. Full frame streams recorded through Orca's own adapter
against the real CLI are committed and replayed verbatim, asserting the
provider's membership fact (each result's user_message_uuids) rather than
design internals.
* test(native-chat): drop the removed CLI-version gate from fold test comments
Also reattaches the fake harness's initProof doc to initProof (it had
landed on contextUsage, replacing that field's own doc) and renames a
plural-result test whose title described a retired waiter it never
creates.
* test(native-chat): name the fold test's settings for their role
* chore: take main's lockfile back after the merge
* test(claude): route the real-CLI probe through the spawn chokepoint; give the slow-init test a live startup report
The shared real-CLI availability probe moved out of a test file, so the
child_process and CLI-runtime-pairing ratchets now scan it. It spawns through
runProcessSync with the CLI paired to its own node, as the structured launch does.
The provider-started test's CLI default now reaches startup the live way:
get_settings reports it, since system/init only arrives with the first command.
A message sent while a structured turn runs appears in the transcript at
once, so "the newest user message" is not the running turn's owner. The
live "Working for" bar moved to the mid-turn message and counted from the
earlier prompt's start, and a send queued behind the running turn counted
its wait twice: once in the previous turn and again from its own send.
Derive both from the host's turn records in one ordered pass:
- The running turn's bar belongs to the user message its lifecycle row
names (resolved exactly as settled timing resolves it). A message sent
mid-turn gets no bar until its own turn opens; a send folded into the
running turn never gets one. Surfaces fall back to the latest user
message only when the host names no opener.
- A turn counts from its send, but never before the previous turn in the
journal ended (its recorded end, else its row's last host revision),
capped at the turn's own start. The same origin feeds the live counter
and the settled duration.
Desktop and mobile share the derivation; no wire, host, or storage change.
* fix(ci): keep a squash-merged RPC recording pin reachable through its pull request
Main's "RPC recording pin" check has been red since #22762: that branch pinned
the recording corpus to its own commit 03995ae, and the squash-merge left that
commit out of main's history. Every behaviour-change squash did the same, and
each needed a hand-made repin PR to clear it (#23565, #23535, #23046 and more).
The guard now accepts a pin that is either in this history or in the head of the
pull request whose squash wrote it into the manifest. It finds that pull request
from the `(#n)` subject of the commit that added the pin and fetches
`refs/pull/<n>/head`, which GitHub keeps after the branch is deleted. The
reproduce step uses the same lookup, so it can still check the pinned tree out.
* fix(ci): give the recording pin lookup room to walk a blobless clone
In CI's blobless clone, `git log -S` fetches the manifest's blobs one commit at a
time, a few seconds each. Under the 30 s process default the walk was killed after
a handful of manifest commits, which main's history already exceeds (up to 7
manifest commits between a pin landing and the next pin change), and the guard
then failed with an empty "Could not find the commit that pinned" error. The
lookup and the pull request fetch now carry explicit budgets and say when they
timed out.
The not-an-ancestor instruction now names the pull request whose head was
checked, or says the commit that pinned it names none.
Adds the two merge-preview shapes the guard runs on: a branch opened after a
squash resolves main's pin through the squash's pull request, and a branch whose
rebase dropped its own pinned commit fails on its pull request instead of on main.
* fix(mobile): tell a missing recording pin apart from product drift
After a squash the pinned commit can live only in its pull request's head, so a
clone that never fetched it makes `git diff --quiet <baseline>` exit 128. The
recorder reported that as "Product sources or lockfile differ from the pinned
main baseline", which sends the developer to repin a tree that may match. It now
prints git's error and the command that fetches the pin.
* fix(ci): ask GitHub which pull request holds a squash-dropped recording pin
The recording pin guard found the pull request that keeps a squash-dropped
pin by walking main's first-parent history for the commit that wrote the pin
into the manifest and reading "(#n)" off its subject. A merger who edits the
squash title loses the number, and the push to main turns red anyway. That
already happened on main: of the 22 squashes that left a pin outside main's
history, #21674's title had no "(#n)".
The guard now asks GitHub for the pull requests associated with the pinned
commit (GET /repos/{owner}/{repo}/commits/{sha}/pulls) and, for each in turn,
fetches refs/pull/<n>/head and accepts only when git proves the pin is an
ancestor of that head. GitHub only nominates candidates, so a wrong answer can
fail the guard but never pass it. The endpoint named the right pull request
for all 22 historical cases, #21674 included, and names none for commits a
force-push orphaned.
This removes the pickaxe walk, its 600 s budget and its lazy blob fetches in
a blobless clone, the first-parent subtlety, and the subject regex. A revert
that restores an older pull-request-only pin now resolves too, because the
lookup is by the pin itself rather than by the commit that last wrote it.
CI passes the job token to both guard steps and grants the job
pull-requests: read. Local runs work without a token on this public repo and
send GITHUB_TOKEN or GH_TOKEN when set. A failed lookup throws with the HTTP
status, and names the rate limit when an unauthenticated call is refused.
* fix(mobile): unsubscribe session tabs by request on the direct connection
* fix(mobile): hold a direct session tabs unsubscribe until the first snapshot
The desktop registers a tab-list stream only as it emits the first snapshot. A direct
unsubscribe sent before that found nothing, and with per-request addressing no later
worktree-wide sweep collects the late stream, so it kept its desktop listener until the
socket closed. Hold the unsubscribe until the snapshot arrives, as the relay connection does.
* test(mobile): cover a held session tabs unsubscribe whose subscribe fails
* fix(mobile): keep the session tabs hold within the registry line budget after merging main
Move the pre-snapshot hold into the session tabs stream module, note that only older hosts need it, and give the unsubscribe test the real registration version now that a worktree-wide unsubscribe spares later streams.
* fix(opencode): keep OpenCode 2 panes Working across plugin reloads
OpenCode 2 disposes and re-sets-up every plugin whenever its plugins dir
changes, while sessions keep running. The status plugin published a final
Idle on dispose, so a pane read Done mid-turn. Orca also rewrote the plugin
file on every PTY spawn, so opening any terminal triggered that reload.
Dispose now releases the factory's bookkeeping without publishing a
verdict; the next lifecycle event settles the pane, and Orca's ended-process
reconciliation still retires panes whose agent exited. The plugin file is
written only when its bytes differ.
* fix(opencode): skip rewriting an unchanged plugin in the SSH relay install too
The relay's canonical-config install still unlinked and rewrote the status plugin on every OpenCode launch over SSH, which restarts every plugin in a remote OpenCode 2 server. Share one install-currency check (lstat + the existing byte comparison) between the local and relay writers, and pin write-if-changed with mtime so the tests also fail on filesystems that reuse a freed inode.
* fix(opencode): keep the final Idle when OpenCode 1 tears its instance down
OpenCode 1 disposes a plugin only when it tears the instance down, and that
teardown cancels every running session, so the Idle published on dispose is
true there; the cancelled run's own idle may never reach the plugin. Only
OpenCode 2 disposes on a hot reload while turns keep running. The generated
module serves both hosts, so the OpenCode 2 setup() entry point now tells the
shared factory that sessions outlive disposal; the server() path keeps the
previous disposal behaviour, including the hand-off to a surviving factory.
* fix(opencode): compare a symlinked plugin by its target before rewriting
OpenCode 2 loads plugins through file-level symlinks and reads the revision
from the target's mtime, so a user whose Orca plugin file is a symlink (per-file
dotfile managers) failed the regular-file check and got a write through the
link, and a reload, on every spawn. The config-dir and relay installs now skip
the write when the resolved target already has Orca's bytes; when stale they
behave as before. Only the per-source overlay keeps the regular-file check,
since a link there mirrors a user entry. Installers also skip the write inside
a guarded block rather than returning early, so later install steps still run.
* test(opencode): skip the plugin symlink tests on Windows like their neighbours
Creating a file symlink on Windows needs Developer Mode or admin rights.
* test(opencode): stub fetch without a type assertion in the dispose host test
* fix(browser): give a tab's identity one owner so viewport presets stop dropping client hints
A desktop viewport preset installed a CDP user-agent override with no
userAgentMetadata. Chromium then drops navigator.userAgentData and every
sec-ch-ua header for that tab: a Chrome UA with no client hints. Identity was
decided separately by the session request hook, the Google sign-in switch and
the viewport code, and nothing decided per tab who it should claim to be.
resolveBrowserTabIdentity now derives it from the process identity mode,
whether the URL is a Google auth host, and whether a mobile preset is
requested. applyTabIdentity is the one writer: it keeps the WebContents UA on
the process or Firefox identity and clears the CDP override whenever that
layer already presents the identity. Viewport emulation only records the
requested preset; its metrics and touch steps log failures independently, so
a rejected step can no longer skip the identity restore.
* test(browser): read the presented identity instead of casting the guest stub
* test(browser): drop a comment that described desktop presets writing a UA
* fix(browser): keep same-document navigations and unapplied presets off the tab identity
A same-document navigation (pushState/replaceState) now never rewrites the
WebContents user agent. Chromium reloads a still-loading document when its
user agent changes, so an OAuth callback that strips its code with
replaceState after a redirect off Google sign-in was requested twice,
replaying the one-time code. Measured on Electron 43.7.5: the callback URL
hits the server twice with the write, once without.
The session request hook now derives the mobile identity from the CDP
override the tab actually holds instead of the requested preset. A preset
whose write never landed (debugger attach refused while DevTools is open, a
failed write, a detach) no longer puts the iPhone user agent and mobile
client hints on the wire while the document reports desktop.
* fix(browser): restore identity after a failed navigation without reloading the error page
did-fail-load fires while the failed URL's error page is still loading, and
WebContents.setUserAgent() at that moment makes Chromium reload it. After a
redirect onto or off the Google sign-in host (identity moved over CDP only),
the restore rewrote the WebContents UA there and replayed the failed request.
The restore now goes over CDP; the next navigation rewrites the WebContents UA.
* refactor(browser): let only a navigation start write the WebContents user agent
Two review rounds each found a caller that asked the identity writer to
rewrite the WebContents UA at a moment Chromium reloads or cancels the page
(a same-document navigation, a failed load). A boolean at every call site
left that decision to the callers. The writer now has two entry points:
presentTabIdentityAtNavigationStart, the only one that may write the
WebContents UA and only for a cross-document navigation, and
retargetTabIdentity, which goes over CDP only and serves redirects, failed
loads and preset changes. A table test pins the rule for every entry point.
* test(browser): reject touch emulation regardless of payload in the identity-restore test
The mock rejected only maxTouchPoints 0, so the test would stop exercising a
failed touch step once the touch payload is fixed.
* fix(codex): the provider supervisor outlives its provider group when stopped
A signalled supervisor forwards the signal to the provider group, escalates to
SIGKILL after the grace, and exits only once the group is gone, so recovery's
proof that the recorded pid is dead also proves the provider is. It refuses to
spawn when its parent is already not the owner named in its spec, and watches
that owner rather than whichever parent it first saw. The grace is a spec
field. Recovery's SIGTERM stage now outlasts the supervisor's own stop, since a
SIGKILL that lands first cannot be handled and leaves the group running.
* fix(codex): a closed owner pipe no longer ends the supervisor before its provider group
When Orca dies, the supervisor's stdout pipe has no reader. Provider output in the
window before the parent-death watch fired raised an unhandled EPIPE that exited the
supervisor with the provider group still running.
* fix(codex): bound the supervisor grace so recovery's SIGTERM stage always covers it
Recovery sized its SIGTERM stage from the default grace, so a launch with a
longer grace would be SIGKILLed mid-stop and orphan its group with no test
noticing. The spec now refuses any grace above one exported maximum, and
recovery derives its SIGTERM stage from that maximum.
* fix(codex): every supervisor stop asks the provider with SIGTERM first
Owner death, stdin end after the grace, and a signal to the supervisor now all
take one path: SIGTERM the provider group, SIGKILL it after the grace, and exit
only once it is gone. The signal handlers are registered before the provider
is spawned, so a stop that lands in the spawn window still reaps it. The
longest stop grows to two graces plus the reap wait, and both recovery's
SIGTERM stage and the connection's graceful close now wait that long before
forcing, since forcing the supervisor sooner can orphan its group.
* fix(claude): run the structured Claude child under the POSIX provider supervisor
A close now stops Claude with a SIGTERM through the supervisor instead of letting
stdin end finish the turn, and Orca's death stops it through the supervisor.
* test(claude): pin the supervised stop against a real Claude CLI, opt-in
* test(claude): a requested stop reads interrupted through the frames the supervised SIGTERM makes Claude emit
* test(claude): show what the real CLI did when it never ran the tool
* test(claude): Orca's death now reaps Claude's own tool through its SIGTERM
* refactor(claude): take supervision from the spawn spec so the close ladder cannot disagree with the spawn
createProviderSpawnSpec now reports whether it wrapped the provider in the supervisor, and the Claude spawner reads that instead of repeating the platform check. The close ladder's SIGTERM follows the process actually spawned.
* fix(native-chat): derive quit's chat-eviction bound from the longest supervised provider close
Quit's child-eviction phase was a hand-picked 8 s. It is now the sink drain plus the longest
supervised close over Claude and Codex plus a named 1 s margin, so a provider close that grows
widens it instead of silently outrunning it. A close's tree-kill fallback stays outside the bound:
once main exits, the supervisor stops its provider group on owner death, which a new test now
proves for a clean owner exit, and next launch's recovery settles the lease.
* fix(native-chat): a turn a proven crash cut short reads interrupted, ending when it was last seen working
* fix(native-chat): end a probe-proven turn at the last row the journal wrote live
A revised item keeps its first sighting's timestamp, so a long command or a streamed reply read as ending when it started. The reducer now tracks the latest live row the same way it tracks all activity.
* test(native-chat): give the unexpected-exit fake journal its live-activity read
* refactor(native-chat): read the journal's live bound only for a probe-proven death
* fix(native-chat): mark what a journal open settles for a gone host as crash reconciliation
A crashed host's working roster is retired when the journal reopens. That row was
written live, so a probe-proven turn ended at the relaunch and counted the downtime.
* fix(native-chat): bound a probe-proven death with the last time Orca proved the owner alive
A crash mid-tool left the turn ending at the tool call's start, because Claude writes nothing
while a Bash call runs. The death evidence now records the lease's last renewal before the
death as lastProvenAliveAt, and the turn ends at the later of that and the last live row,
capped at the probe.
Parking a lease in recovery no longer stamps lastRenewedAt, since nothing proved the owner
alive then; a child that outlived Orca would otherwise have its turn count the downtime.
* test(native-chat): a failed acquisition parked in recovery keeps its last proof of life, and the timing read goes through the display selector
* refactor(native-chat): move the submission dispatch folds out of the journal reducer
Main grew the reducer to its line limit, so the live-activity bound tipped it over. The dispatch
row and echo-acceptance folds move unchanged into their own module.
* test(native-chat): a send or reader that opens a crashed chat settles a proven death interrupted
Main's open-time settle test still asserted the old rule, where only a watched exit proved a death.
* docs(native-chat): say which proofs of death record a last proof of life
* fix(native-chat): a proof of life bounds only the owner that wrote the turn
A start after a crash that spawned a child and then failed without proving it gone parks that
child for recovery; when recovery finds it gone, the proof of death carries its proof of life,
which is after the crash. The older turn then ended there and counted the downtime.
The journal now derives the fence of its newest live writer, and the lease that holds the proof
names the owner it released by the fence it moved to. The last renewal counts only when that
move was one step past the writer of the turn.
* test(native-chat): a crash with a send in doubt still ends at the last proof of life
The reopen settles that send at the new fence, so the owner check must read the fence of live rows only.
* fix(native-chat): a proof of death judges only the turn its own owner wrote
The settle read the record's latest proof of death for whatever turn a gone generation left
running. After a crash, a start that reserved a new fence cleared the relaunch's proof, and if
it then failed, its own child's death (a watched exit at the failure, or a probe finding the
child it left for recovery gone) ended the older turn an hour after the crash.
Every proof of death now records ownerFence, the fence of the owner or reservation it is about;
a fence names exactly one owner. The journal derives the fence each item was created at, and a
running turn is interrupted only by a proof naming its own owner; otherwise it is unverifiable.
Evidence older builds wrote keeps their rule. This replaces the derived one-step fence check.
* test(native-chat): every writer of a watched exit names the owner it released
A watched exit that names no owner reads the older rule, so the settle alone cannot tell a
dropped field; the writers are pinned directly, including past a recovery floor.
* test(native-chat): give the fake journal's cast its safety rationale
* fix(native-chat): a proof of death written after a chat opened revises the turn it left unverifiable
On desktop the chat on screen at relaunch opens before the startup reconcile has probed its owner,
so the open settles the cut-off turn unverifiable. When the reconcile then records the proof, it
re-runs the same settle for every open conversation, which revises that owner's unverifiable turns
to interrupted with the proof's end. Any later open re-runs it too, so a failed write converges.
Only upward, only for a proof that names the turn's own owner.
* test(native-chat): a chat read before the reconcile reads unverifiable, then interrupted
Covers the reconcile revising an open chat to the last renewal (27 s) and a subscriber being sent
both states, a start after the crash whose running turn the queued revision leaves alone, a failed
revision write converging at the next open, a proof about another owner or from an older build
never revising, and a second settle writing nothing.
* fix(native-chat): revise an open chat's turn wherever a proof of death is written
The store tells its listeners, once committed, of each record a transaction gave a new proof of
death, so every writer (the startup reconcile, a recovery that stopped a child which outlived
Orca, a failed start, a watched exit) triggers the same serialized resettle for a chat already
open. The reconcile's own callback is gone. Quit stops listening first, and a queued resettle is
drained with the starts.
* test(native-chat): a failed exit settlement is retried in place once the exit is recorded
Recording a watched exit now queues the same settle an open runs, so the turn converges without
waiting for the chat to be reopened. The reopen and read-after-restart cases now refuse that retry
too, so they still pin the open's own settle.
* test(native-chat): tests that pin a send settling a failed exit refuse the in-place retry too
The exit's release now queues the same settle, so two tests named for the send's settle refuse that
retry as well; the comments that said nothing retries it now say what does.
* fix(native-chat): name the explanation row by the death it explains, so a retried settle adds no second row
* fix(native-chat): end a crashed turn at its owner's provider output, never at a later send
A send accepted into a crashed chat before the proof of death wrote a submission row live, and the journal-wide last-live-row bound counted it, so the revised turn ended at the send and counted Orca's downtime. The bound is now the last row the owner's provider child wrote, per writer fence: submissions, dispatch rows and crash reconciliation are Orca's or the user's, and a newer owner's work says nothing about the dead one.
* fix(native-chat): end a crashed turn at its last proof of life, never at a timeline row
The end of a probe-proven death was the later of the last renewal and the last live timeline row. A send accepted into a crashed chat before the proof writes a row live, so the revised turn ended at the send and counted Orca's downtime. Rows cannot tell the agent's output from Orca's or the user's, so the end is now the last renewal alone, never after the probe, and never before the turn began. The journal's live-activity bound and the reopen's recovered marker, which existed only for it, are gone.
* test(native-chat): drop a stale reference to the removed live-activity bound
* fix(native-chat): stop flashing "still starting" on every chat launch
Every structured chat passes through a short startup phase, and the pane
showed "<agent> is still starting…" for all of it, so a normal launch
flashed the notice for a fraction of a second. The notice now goes through
a keyed delayed status: it appears only once startup outlasts a grace
period, stays up for a minimum time once shown, and resets per session.
* fix(native-chat): reset startup notice for each provider child
* fix(native-chat): the sink queue keeps a settlement's first batch, as the journal does
The journal applies a lifecycle batch's settlement id once and skips any later
batch with the same id. The deferred sink queue coalesced the same key the other
way: a second batch replaced the first while it was still queued. So which
record survived depended on whether the first had drained yet.
A lifecycle batch now keeps the queued operation with its key, and a later one
is accepted and dropped, which is what the journal does once the first is
written.
* fix(native-chat): only turn/completed ends a Codex turn
Codex follows every turn-ending `error` (willRetry=false) with a failed
`turn/completed` for the same turn, 0-32 ms later. That was captured from the
real app-server on 0.141.0 and 0.158.0 across eight failure scenarios, and it is
how Codex builds a failed turn: it records the error as the turn's last error,
records any pending input, and then derives `failed` from that error when it
completes the turn.
The translator ended the turn twice: once on the error, and again on the
completion, with a guard to make the first end final. Ending on the error threw
away what only the completion carries: Codex's duration, and the completion's
receipt time. It also forgot the turn before Codex recorded the turn's pending
input.
Now the error is only the row the user reads, inside the still-open turn, and
`turn/completed` is the turn's only live end. A process exit between the two is
the existing exit sweep's observed end, recorded as interrupted.
A failed completion is stored as completed with outcome failure, live and on
restore alike. Only `interrupted` maps to the interrupted state.
The first-end-final guard is gone. Codex sends one completion per turn, the only
redelivery Orca has is the retry of a refused frame (which changes nothing), and
the settlement id already keeps the first record in the queue and the journal.
* refactor(codex): delete the unreachable oversized-notification settlement
The translator settled a streamed item when the transport rejected its
notification as oversized. Nothing can produce that frame. The Codex stdio
reader frames with `maxLineBytes: Number.POSITIVE_INFINITY`
(codex-app-server-record-reader.ts), which it has done since the app-server
records were uncapped. With an infinite limit the framer never reports
`line-too-long`: no line, pending suffix or paused queue can exceed it. So the
dispatcher never emits `frame:oversized-notification`, and the arm that settles
it never runs.
The arm, its helper module and its test go. In place of the test, the
connection test now proves the reason: a notification past the old 16 MiB wire
limit arrives whole, and no oversized frame is reported.
* test(codex): replace the captured ids in the turn-endings fixture with synthetic ones
The replay reads ids only to group frames, so the real thread, turn and
response ids from the capture account carry nothing the test needs. The
fixture moves beside the Codex tests that read it.
* test(codex): use a neutral made-up status as the unknown-status example
'cancelled' read as a stop being recorded as a completion.
* test(codex): a restored turn with a status Orca cannot place ends with no verdict
Codex's history carries the same status field as the live completion, so the
restore path is pinned to the same mapping: completed, and no outcome.
* feat(agent-launch): every launch carries the surface that started it
The host now attributes every agent it builds to the surface that asked for
it, resolving a missing or unrecognized surface to 'unknown' in one place
instead of silently skipping it. The CLI names itself on worktree.create and
orchestration workers name themselves host-side.
* fix(agent-launch): attribute the agent a startup-draft create launches
The host builds a third kind of agent launch: a worktree.create with a
startupDraft and no startupAgent, where the host picks the agent itself.
It carried no launch record at all and ignored the caller's launchSource.
Route it through the same resolver as the other two builders, and derive
the startupAgent terminal record only from the resolver so no prebuilt
record can stand in for it.
* fix(agent-launch): attribute the agent a host-built agent session launches
terminal.createAgentSession builds a fresh agent's launch on the host, like the
other startup builders, but spawned it with no launch record, so those launches
were never counted. Record them through the same resolver; the request names no
surface, so they count as unknown.
* test(agent-launch): require an attribution decision for every host-built agent startup
* feat(mobile): tell users when a newer app binary is installable
With OTA page updates, store releases get rare and users stop looking.
The shell now asks the channel that installed it. Android sideload
reads GitHub's mobile-android-v* tag refs and proves the release has
an APK. iOS reads the App Store lookup. A home card above Desktops,
dismissible per version, and Settings rows surface the result. The
check runs on the desktop updater's cadence: cold start, foreground
once 24 h have passed, and a 1 h retry after a failure.
The releases atom feed was not used because it lists only the 10
newest releases, which are all desktop builds, so it never carries a
mobile tag.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): parse update replies with zod schemas
The anti-slop gate refuses Reflect.get on dynamic input. The GitHub
refs, the release, the App Store lookup and the stored update record
are now parsed into named schemas before they are read.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): say why the Android update source reads tag refs
Record why the Android source reads tag refs. The releases atom feed
and /releases?per_page=100 are both newest-first windows that desktop
releases fill. Either would silently report "current" once a run of
desktop builds pushes the newest mobile release out.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): load update state once and apply review rulings
Every check and dismissal now awaits one shared store load. This
replaces the merge that guessed whether a check had landed during the
load. A manual check that fails while the store loads therefore keeps
its 1 h retry instead of re-checking at once.
- checking is derived from the in-flight check.
- start() uses a per-start flag, so a StrictMode double start applies
one load.
- A check that finishes after stop() writes nothing.
- A corrupt stored update record loses only itself.
- Tag refs are parsed with a single schema.
- The runtime wiring is folded into one file, and the card moves to
home/.
- The recorded App Store fixture is oxfmt-formatted, with the same
parsed value.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): keep the update timer armed across a stop and restart
A check that stayed in flight across stop and restart returned
'failed' without rescheduling. The restart skipped arming because a
check was in flight, which left a live checker with no timer until
the next foreground. The stop counter is removed. schedule() already
arms nothing while no start is active, and saving a real result after
a stop is harmless.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* chore: retrigger CI after the RPC recording repin (#23757) landed on main
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): trim the update checker and Settings rows
- The load sets prefs and the due time only. start() re-arms the
schedule after it.
- The Settings result hides through one effect keyed on the result.
- onUpdate receives the URL.
- The version row is bound once.
- The retry and timeout constants are no longer exported.
- The unused AppUpdateChecker type is deleted.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): run the update check when its timer fires
The armed timer is the due time. Re-checking the wall clock when the
timer fired meant a clock stepped back skipped the check and re-armed
nothing. The due-time guard now applies only on foreground.
The binary version still comes from expoConfig.version. SDK 55
removed Constants.nativeAppVersion, so the no-expo-updates invariant
is now named in the comment.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): recover from a future check time and use Apple's page URL
If the device clock was ahead when a check ran and was corrected
later, the stored check time is in the future. Cold starts then armed
a timer for the whole skew, and foreground never came due. The stored
state now reads as never checked in that case.
The iOS link is the lookup's trackViewUrl instead of a URL built from
trackId.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): fit the future-check-time comment in the print width
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
A caller's `needs` gate the whole called workflow, so while the plan job lived in
unit-tests.yml it could not start until static analysis and typecheck had both
finished and passed -- and the shard matrix then waited on it. The two hops were
serial when they did not need to be: planning reads the checkout, a git diff
against HEAD^1, the import graph and the checked-in timing baseline in
config/scripts/ci-shard-timings.json, and consumes nothing that static analysis,
typecheck or the native-cache primer produce.
Planning moves to its own reusable workflow so pr.yml can run it against
code_paths alone, overlapping it with the gate. Measured across 99 runs, the
shard matrix is created a median 93s earlier (p25 47s, p90 241s, never later).
Planning stays a required predecessor of the shards, so an empty assignment
cannot expand the matrix.
The gate itself is deliberately left in place. It fires on 22% of runs, and the
shard queue wait knees hard above ~9 concurrent ARM jobs -- 4s median below that
against 218s at 15-19 -- so admitting 8 doomed shards per failed run would cost
more in queue pressure than it returns in latency.
Cost is one 37s ubuntu-latest job, which does not touch the ARM pool the shards
contend for.
A planning failure still fails the PR: the shards are skipped, and verify's
check_job requires success whenever the classifier says tests should run, so it
reports `test: expected success, got skipped`.
#22762 squash-merged as 29c7d5d983 with the corpus pinned to its branch
commit 03995ae29d, which the squash left unreachable from main. Repin
baseline to main's tip and re-record the full corpus in place; only the
baseline header moves on every golden.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(agents): integrate CodeBuddy launch, status and session history
* docs: record CodeBuddy lifecycle verification
* fix(codebuddy): backfill scoped history and negotiate remote resume
* test(cli): include CodeBuddy in known search agents
Main's host no longer has hold/release: an accepted send starts the agent itself. The
pointer lane's wake step called host.hold, which no longer exists, so it is deleted
from the pointer host, the delivery lane and their tests. An idle or evicted chat gets
its pointer through the same send a user message takes; the claim is still consumed
only on accepted and given back otherwise.
* fix(runtime): reopen the quiet-foreground tui-idle lane for agents with no other rest signal
A tui-idle wait could never settle on a pane running amp, goose, crush, kimi,
qwen-code, rovo, auggie and other agents whose titles Orca cannot classify:
the quiet-foreground lane was closed for every launched agent, and it was the
only lane those agents could reach, so worker start failed at agent_readiness
after 60s.
Model each agent's rest signal, derived from the tables that already encode
it (synthetic ready titles, the title classifier, the DSH hook and Muse ready
screen lanes). The lane stays closed where a stronger signal will arrive and
reopens for agents with none. On a reopened lane, silence counts only after
the TUI has painted: an agent that has painted nothing is still booting.
Linear: STA-7440
* fix(runtime): count only the command's own output as an agent's paint on the tui-idle foreground lane
The after-paint lane accepted any output, and the shell's prompt and echoed launch
command always land before the agent starts, so a silently booting agent could still
settle and lose its first prompt. The runtime now reads the shell integration's
command-start marker and requires visible output after it; panes whose shell emits
no marker keep the any-output rule.
Also skip the foreground-process read while the pane cannot settle, and register the
new title-classifier call site in the pane agent identity inventory.
* fix(runtime): classify Freebuff's rest signal and skip the backward marker scan on chunks without one
Main added the Freebuff agent after this branch point, so the full per-agent rest-signal table no longer matched on the merge ref. Freebuff derives `none`: its screen reports a first-party `done`, which tui-idle trusts only for DSH, so the quiet-foreground lane is its only one.
The command-start scan ran a backward search over every PTY chunk; a forward check first cuts that to the cost of a plain substring test on chunks with no marker.
* fix(runtime): classify Qoder's rest signal after merging main
Main added Qoder with its own readiness branch returning a boolean quiet-foreground
flag; map it to the lane type and classify Qoder by its ready screen so the full
rest-signal table and lane-agreement check stay exhaustive. Say what `none`
actually means: no stronger lane tui-idle trusts, not no hooks at all.
* refactor(runtime): track command paint with the shared OSC 133 scanner
The command-paint tracker had its own split-unsafe 133;C parser. Reuse the
chunk-boundary-safe scanner, which now reports where in the chunk the marker
ended, so a marker split across reads is still found. Correct the unmarked-launch
list: bash and zsh mark typed launches after the echo.
* fix(runtime): drop command-paint state on an output gap or a new process
A dropped chunk can cut a command-start marker in half, and the scanner's
carry then completes it on unrelated output after the gap, leaving the
pane waiting for a paint that already happened. Reset it with the other
cross-chunk carries.
* fix(terminal): keep the command-start offset out of renderer lifecycle callbacks
* fix(runtime): retire an exited terminal before its stream end
An exit's durable retirement became asynchronous, so onPtyExit released
the terminal stream before the retirement landed. A paired client answers
a stream end by re-activating its pane; that activation still found the
exited leaf, materialized it under the same session id, and registerPty
dropped the pending retirement. The exited split pane came back as a
fresh shell.
The exit now stages the retirement into the in-memory session and
publishes it synchronously, then notifies exit listeners, and only then
makes it durable. A failed durable write is logged and left in memory for
the next profile write instead of being rolled back, since the process is
gone either way. This removes the pending-retirement latch and its
post-await incarnation fence: there is no longer a window for them to
guard.
* test(runtime): a failed exit retirement still reaches disk
Pins the no-rollback contract through a real Store and SQLite authority:
when the retirement's own durable write fails, the in-memory retirement
is carried by the next unrelated profile write, and by the app-quit
flush when no other write happens. The delayed authority fixture can now
fail its next write, and the acknowledged-retirement fixture reads the
database a relaunch would load and models the quit flush.
* test(runtime): a stream end observes the exit retirement already published
The re-activation check alone passes with the listener ordering reverted,
because activation awaits before its lookup. Record the session binding
and publication count at the moment the exit listener fires so the
ordering itself is pinned.
* fix(runtime): an exit cleanup fault still ends the terminal stream
* perf(runtime): exits retired together share one durable write
* test(runtime): a refused staging write still retires the pane and ends the stream
* refactor(runtime): describe exit retirement as staged, not durably accepted
The retirement result is staged in memory before any write, and the removable-surface comment and the replacement-admission test name still described the old publish-after-durable rule.
* fix(codex): the provider supervisor outlives its provider group when stopped
A signalled supervisor forwards the signal to the provider group, escalates to
SIGKILL after the grace, and exits only once the group is gone, so recovery's
proof that the recorded pid is dead also proves the provider is. It refuses to
spawn when its parent is already not the owner named in its spec, and watches
that owner rather than whichever parent it first saw. The grace is a spec
field. Recovery's SIGTERM stage now outlasts the supervisor's own stop, since a
SIGKILL that lands first cannot be handled and leaves the group running.
* fix(codex): a closed owner pipe no longer ends the supervisor before its provider group
When Orca dies, the supervisor's stdout pipe has no reader. Provider output in the
window before the parent-death watch fired raised an unhandled EPIPE that exited the
supervisor with the provider group still running.
* fix(codex): bound the supervisor grace so recovery's SIGTERM stage always covers it
Recovery sized its SIGTERM stage from the default grace, so a launch with a
longer grace would be SIGKILLed mid-stop and orphan its group with no test
noticing. The spec now refuses any grace above one exported maximum, and
recovery derives its SIGTERM stage from that maximum.
* fix(codex): every supervisor stop asks the provider with SIGTERM first
Owner death, stdin end after the grace, and a signal to the supervisor now all
take one path: SIGTERM the provider group, SIGKILL it after the grace, and exit
only once it is gone. The signal handlers are registered before the provider
is spawned, so a stop that lands in the spawn window still reaps it. The
longest stop grows to two graces plus the reap wait, and both recovery's
SIGTERM stage and the connection's graceful close now wait that long before
forcing, since forcing the supervisor sooner can orphan its group.
* fix(codex): give the provider 1 s after stdin end and 3 s after SIGTERM to flush before SIGKILL
The supervisor's stop was stdin end, 1.25 s, SIGTERM, 1.25 s, SIGKILL. Codex
now gets 3 s after SIGTERM to flush its state. The two graces are separate
constants, the longest stop they derive becomes 5.5 s, and a test keeps it
inside quit's 8 s child-eviction bound.
* test(codex): count eviction's pre-stop drain in the quit budget test
Eviction drains the sink for up to 1 s before it stops the child, inside the same 8 s bound.
Main made journalSnapshot async and delivers an accepted send once the host hands it over, so the fixture awaits the snapshot and waits for the provider's turn before echoing it.
* fix(ssh): don't overwrite remote agent config after a failed read
A flaky read was treated as an empty file, wiping the user's config.
Fixes#22638
* test(runtime): model remote missing-config reads as relay ENOENT errors
The runtime harness stubbed isENOENT as code-only, and the remote Codex
startup specs rejected with a generic error that only passed while any
read failure seeded an empty config. Use the real isENOENT and the
message-only shape the relay actually delivers.
---------
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Status-row change detection stringified two full IPC payloads on every
status write, including an up-to-8 KB lastAssistantMessage re-posted on
every OpenCode streamed part. Compare the same published field set with
the existing structural-equality helper, with a same-reference fast path.
Linear: STA-7432
* fix(tab-group): measure fallback pane geometry once per tab group, only while visible
* refactor(tab-group): derive the shared resize listener's lifetime from the source map
The map already drops empty groups, so a separate counter was a second copy that could disagree.
The CLI entry compared a restated --from/--terminal with the injected session id as a
plain string, so a cleared chat restating the address it had before the clear (its lineage
root, the address it keeps) was refused. Only the host's session records know the lineage,
so a session address that is not this session's own spelling is now sent as the caller
param, where the host's canonical-id check accepts it or refuses it before any effect.
Plain restatements are still dropped and any other name is still refused at the entry.
* fix(native-chat): a subagent's words are presented as that subagent's, never the parent's
The journal already names the agent that produced every row, but the transcript
projection dropped it, so a subagent's prose rendered as the parent's reply, its
tool calls folded into the parent's runs, and a settled turn could fold down to
a subagent's words as its only visible answer.
The transcript message now keeps the row's producer. The fold keeps each agent's
calls in that agent's own run, a turn's answer is the session's own agent's last
prose, and a subagent's row names the subagent on desktop, mobile and a worker's
transcript text.
* test(native-chat): give the window fixture's slot the attribution field it now carries
* fix(mobile): read the subagent label the row is given, and pin the caption
* fix(native-chat): keep interleaved agents in order and each agent's own run live
Review follow-ups:
- the fold is main's adjacency fold plus one condition: a row never folds into
another agent's run, so an agent's later call stays below its subagent's work
instead of jumping back into its earlier row
- each agent has its own live frontier, so a parent still inside its spawn call
reads as running while its subagent works below it
- mobile names no one on a row whose only content is hidden behind its settled turn
- a pending question from a subagent keeps its producer
- worker reads serve only the producing agent's id, bounded like the roster key
that names it, and drop the provenance fields
- the single-message worker formatter is private, so no caller can drop names