mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 09:37:33 +00:00
a76a0bcddc6d06ffc09db97dbb221ca6013d5d19
1463
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0b79720c2e |
feat(native-chat): the chat strip and the sidebar read the host's child records (#22614)
* feat(native-chat): publish the host's child records to the status summary and the chat strip
The status summary and the background-task channel now read a session's child
records from the host's canonical store, through the status sink its row
landed in, and derive the legacy task and subagent shapes from the same views.
The parent row folds its child-work liveness from those records at ingest,
not from the summary's task list. The adapters no longer push their task DTO
to clients: the onBackgroundTasksChanged path is gone, and a child-work ingest
is what republishes both the summary and the strip. Finished children stay
listed until the session's own next turn starts. A reader that predates child
views never receives a roster whose rows are all settled.
* feat(native-chat): the chat strip reads the host's child records with its parent's verdict
The strip's roster now renders from the child views its channel carries, and
passes the verdict the session's own status row gives its children, built the
way the sidebar builds it (the row's freshness and the status feed's
observation). So one child reads the same in the strip and the sidebar, live,
after the transport drops, and once the row goes stale. A roster of finished
children stays shown until the next turn but no longer animates the monitoring
indicator or blocks conversation commands.
Tests: an end-to-end run on a host with no renderer (a real hook server as the
status sink) shows the summary and the strip channel carrying the same records
at every step, the parent row folded from them, retention, and an older
client's task list holding live work only; a wired renderer test shows both
surfaces agree when live, lost and stale.
* test(native-chat): a newer host's view degrades, an older reader keeps its live roster, a finished roster holds nothing open
- The view decoder ignores unknown keys, degrades unknown kinds, states,
outcomes and memberships, and drops only rows it cannot identify.
- At the RPC boundary a reader that predates child views gets no strip for a
roster of finished children and never the views themselves; a stop-only
reader keeps rows whose host offers no targeted stop.
- The strip shows finished children without reading them as live work.
- The row keeps its child list's identity when a summary repeats it.
* refactor(native-chat): the summary's task list is the legacy projection's live rows, unfiltered
* test(native-chat): type the switch tests' mocks instead of asserting them
* test: remote clients advertise reading child views
* docs(agent-status): the structured row folds the store's child records
* fix(agent-status): keep the view reader's header from reading as a value import to the renderer boundary
The renderer node-builtin boundary test scans raw text, so a header comment
that said "imports" ahead of the import block turned the type-only import of
agent-status-child-work into a value edge that reaches node:crypto.
* refactor(native-chat): the status summary's broadcast equality gets its own module
The status feed crossed the file-size limit once the summary gained the main agent's turn
outcome beside the child views. Which summary changes reach every session list now lives in
structured-agent-session-status-summary-equality.ts.
* fix(native-chat): command admission reads the strip's child records
A conversation command was refused on the provider tracker's own roster
while the strip read the host's child records, so a drift between the two
rule sets could refuse /clear with a stop instruction the strip had no
button for. Admission now reads the same records through the same read as
the strip, uses the strip's liveness fold, and asks for a stop only when
the strip renders one. The adapter contract no longer exposes the tracker
roster, so no host decision can read it.
Also records when the legacy child shapes die, every earlier death of a
settled child, and the display-precision invariant behind the summary's
clock tolerance.
* refactor(native-chat): command admission takes only what it reads of a turn
* fix(native-chat): the session list drops a session's children when the store does
A session's end no longer removes its child records: a child still running
settles with an outcome nobody reported, and a finished one stays listed.
Records now leave only at the session's own next turn, at the cap on settled
records, or when the host lets go of the session and its row leaves the store.
The summary kept after the host lets go used to strip its children on close,
a rule of its own. It now re-reads them from the store when the row leaves,
through the same read every live summary uses, so the session list and the
chat strip list the same children at each step, including a forget with no
close. Closing only revokes ownership, as before the child records existed.
* test(native-chat): write the Codex frame script's parent row out step by step
Once every surface reads the child records, the provider tracker's roster is
no oracle: it and the records read the same child executions, so agreeing
with it cannot catch a defect in either. Each frame now states the child
liveness and the parent row it must fold to.
* fix(native-chat): the idle sweep and the restart snapshot read the host's child records
The idle sweep (keep an agent running while its subagents or commands run) and
the restart-resume snapshot (what a chat was doing when Orca stopped it) both
read the provider tracker's roster through the adapter interface, which no
longer carries it. Both now take the host's one child-record read, the same one
the status summary, the chat strip and command admission use.
The snapshot's working test also folded that roster through the shared fold's
old `backgroundTasks` input, which the fold no longer reads, so a settled lead
whose subagent was still running would have been offered nothing. It now hands
the fold the records.
* test(native-chat): the child-record tests follow the merged command lifecycle
A command is a live child record from its start and is removed, not settled,
when it stops, whatever Codex tagged it. The end-to-end switch now shows the
child's `npm test` as a live row beside its dev server, and both are gone once
they exit; only the finished subagent stays listed until the next turn. Letting
go of the session is its tab closing, since a closed conversation whose tab
remains keeps its row.
Command admission's finished row is a subagent, the one kind that settles, and
the failed-verdict row test admits its live subagent as a host record, the only
thing the row folds.
* refactor(native-chat): the status feed's journal projection cache gets its own module
The status feed crossed the file-size limit once the child records joined the
agent-start signal and the completion feed's status read. The per-journal
projection, cached per commit, now lives in
structured-agent-session-status-journal-projection.ts.
* test(native-chat): the admission test's compaction resolves with a real outcome
Main's compaction result is a tagged outcome; the host-level admission test
resolved its mock compaction with an empty object.
* feat(native-chat): the sidebar lists running subagents; the strip, running then the newest finished
The host keeps every child record; what each surface lists is picked from them on every read, so
nothing is stored twice. The status summary, which every session list reads, now carries only
running children (and a finished one whose shell still runs, which reads monitoring): a finished
or failed subagent leaves the sidebar and stays in the chat's strip. The strip lists every running
child, then the newest finished ones, 100 rows in all; more than 100 running all show.
This matches common practice: sidebars show live subagents, and finished ones stay in the chat's
panel, newest first. No wire field is added. An older client reads fewer rows: its legacy task
lists were already live-only in the summary, and the strip's settled tasks come from the same
bounded roster.
* fix(sidebar): one rule for what the worktree sidebar lists: running children, from every source
`worktreeSidebarListsChild` is the one definition: a child that runs, counting a finished one
whose own shell still runs (it reads monitoring). The sidebar's row builder applies it to every
child source it reads, a terminal agent's hook roster and a chat session's records alike, and the
host's status summary applies the same predicate, so the sidebar's payload stays small. The chat's
strip keeps finished children, newest first.
A terminal agent's hook roster already drops a child on its own stop, so nothing changes there:
a teammate between turns and a child gone quiet still run, and still show. The selection module
moves to `agent-child-work-listing.ts`, since it now covers every source, not only chat sessions.
* test(native-chat): the switch test passes the startup child key main's status bar takes
* fix(native-chat): a finished child stays until the user's next send, not a turn Claude opens on its own
Claude wakes the agent on its own when a background task ends, and that wake is a
new root turn. Keying retention on the newest root turn retired every finished
child about two seconds after a background agent or shell finished, so its
outcome never showed in the strip.
Retention now keys on the user's newest send the provider accepted (a message, a
steer or a command), with the journal epoch so a rewind still retires. A turn the
provider opens itself and a subagent's turn carry no send. Replayed captured wake
orders through the real adapter, hook server and status feed.
* fix(native-chat): a background Stop reaches the tasks the child records show
The strip draws a row's Stop, and /clear, /compact and rewind wait for background
work, from the host's child records, but the Claude adapter still resolved which
tasks a Stop reached from its own tracker's roster, and refused to stop at all
once that roster was empty. A task the records kept live after the roster dropped
it showed a Stop that sent nothing and blocked those commands until the chat tab
closed.
The host now resolves the provider ids a Stop sends from the records (the same
per-row rule the strip and admission use; every such row for stop-all), and the
adapter stops exactly those, with no tracker guard. An acknowledged stop ends the
record: a running task sends its own stopped frame first, and the CLI answers
success with no frame for a task it no longer knows. A refused stop leaves the
record live. No production code reads the tracker's roster any more.
* fix(native-chat): one rule for a finished child that still owns live work, at any depth
The listing kept a finished child whose work ran through any depth of ownership,
but retention at the user's next turn protected only the direct owner, so a
finished agent whose finished subagent still ran a shell was removed and that
subagent jumped to the top level. Both now read settledOwnersOfLiveWork.
* fix(native-chat): an older client sees a Codex child's shell as it did before views
Clients that predate child views read a flat task roster derived from the views.
It listed a Codex child agent's shell as an extra row beside the running agent,
then as a bare command once the agent finished. The derivation now hides a
running agent's commands and names a finished agent's as "<agent> — <command>",
as the Codex tracker did; the label rule moves to a shared module both use.
* fix(native-chat): the chat decodes a roster's child rows once, as the frame arrives
The client reducer compared raw wire rows, so a row shaped by a newer host could
throw there, and the strip decoded a new array on every render, which defeated its
grouped-rows memo while a turn streamed. Rows are now decoded where the frame
enters the reducer, an unchanged roster keeps its identity, and the strip's parent
context is rebuilt only when one of its values changes.
* fix(native-chat): the strip channel forgets a closed conversation's roster
It kept the last roster fingerprint of every conversation for the host's lifetime.
The conversation map now tells observers when one leaves it. Also corrects the
summary's children comment: it carries running children only.
* docs(native-chat): rewrap the retention comment
* fix(native-chat): a task's own ending replaces a Stop's, and a child finished after the user wrote stays
Two lifecycle gaps from the round-1 fixes.
A Stop acknowledged ahead of the task's own ending relabelled it. The SDK hands
Orca a control answer as soon as it reads it and queues other frames, so when a
task finished just as the user pressed Stop, the acknowledgement arrived before
the task's completion the CLI wrote first, and the task read "Stopped" with its
result lost. An acknowledged Stop now ends a record provisionally (outcome basis
`stop-acknowledged`); the task's own terminal frame replaces it, and nothing
replaces an ending the task reported itself.
A finished child still vanished with no new action from the user when the send
the provider took was written before the child finished: a steer Claude takes at
its next boundary, or a queued draft handed over at the end of the turn. The
user's next turn now carries when they acted (the send's written time, or the
draft's queued time, both on the host clock), and only children that finished at
or before it retire; a later one stays until the user's next send. A rewind
still retires every finished child.
Also: a Stop-all keeps stopping the remaining tasks after one request fails, then
reports the failure.
* fix(native-chat): the strip keeps one empty list for a roster that omits one
A roster with only running or only finished rows made a new empty array on every
render, so the strip regrouped its rows each time. One shared empty list keeps
its memo.
* fix(native-chat): a strip row whose owner the 100-row budget cut renders under the main agent
The budget can keep a finished child and cut its finished owner. The child still
named that owner, so it rendered nowhere. The selection now clears an owner it
did not keep, as the view contract says for an owner outside the projection.
* fix(native-chat): a stop-all that times out stops asking, and "no children" is sent once
A stop-all kept asking after a request timed out, so a Claude CLI that stopped
answering control requests cost one full deadline per task, while the chat's
sends, Stop and /compact waited behind it. A timeout now ends the loop; other
request failures still let the remaining tasks be stopped.
The chat strip channel never remembered that it had sent "no children", so
every change in a chat with none re-sent that frame to each subscriber. It now
remembers it, and forgets only when the conversation closes.
Also renames agent-child-work-stop.ts to agent-child-work-stop-targets.ts, which
says what it answers: the provider ids a background Stop reaches.
* fix(native-chat): the status feed reads a journal snapshot with no submissions, and e2e tests use the child-work reader
CI on
|
||
|
|
24540300f0 |
fix(agent-status): preserve hook presence when process checks cannot answer (step 1 of 3) (#23947)
* fix(agent-status): admit hook process presence on the execution host * fix(agent-status): restrict process checks to real hook ingress * fix(agent-status): keep presence checks from causing false exits or losing real ones - Pin the macOS process start time to UTC on both the hook and the host so a shell TZ or a time-zone change cannot turn a live Claude into an exit. - An unanswered process check falls back to the foreground confirmation, so Codex, SSH and Windows panes still leave the agent state on a real exit and the Codex late-completion recovery still runs. - A nested agent that inherits the pane key cannot take over the pane's presence; a retired session is replaced by the next session even when its SessionStart was lost. - Drop the unused Windows process read (no Windows hook captures an identity yet) so shared code no longer imports main-process modules. - Skip the capture outside Orca panes, gate relay re-checks to real title changes, and list the capture module in the CLI project. * refactor(agent-status): own pane presence by the agent's process, not its session Presence now exists only when a hook carries the agent's process identity, and only that process's evidence changes it: its SessionEnd ends the pane, its /clear and /resume keep it, and hooks from any other process (a nested agent inheriting the pane key) update status without taking ownership. Hooks without an identity (Windows, sessions started before the capture) behave exactly as before, so a nested agent can no longer end a pane it does not own. An ended owner stops answering 'exited', and the host probes the owner only when another process reports in the pane. * fix(agent-status): close round-3 review gaps in presence handling - Relay retries and transcript polls schedule against the row the relay cached, and identity-less events pass through the transition unchanged, so SSH Grok replies and Codex transcript polls deliver again. - A live process check restores the runtime's agent status and releases queued orchestration mail, like a foreground read that finds the agent. - An answered foreground read naming a non-agent (wsl.exe, tmux) is still an exit; only silence is not. - A suspended (Ctrl-Z) agent is unverifiable, not live, so the foreground read decides as before. - An agent of another type started mid-turn cannot own or end the pane. - Replayed spool hooks check each pane once, and SessionEnd ends presence only for reasons that end the process. * refactor(agent-status): record the pane's owning agent even without a process id Ownership is now decided only by comparing the recorded owner with the sender, never from the row's agent type or turn state (which identity resolution rewrites). The first agent hook in an empty pane claims it; a live owner keeps it against any other agent (a nested claude -p, a Codex started inside Claude or the reverse); only the owner's own proven process can end it. An owner no hook identified never ends from a hook and is not probed, so those panes behave as before. * test(agent-status): read the optional process id in the relay presence test * fix(agent-status): other agents' SessionEnd hooks settle their status again Only an admitted exit (Claude's process-ending SessionEnd, or a host-proved exit) is marked ended on the event, and ownership keys on that marker, not the hook name. Devin, Qoder, CodeBuddy and Copilot SessionEnd hooks are ordinary status updates again, locally and through the relay. * test(agent-status): cover the owner's own SessionEnd through the relay * fix(agent-status): rows without a process identity keep today's command-finished cleanup The renderer's command-finished cleanup kept every row when its shell check could not answer, which left Codex, hookless-agent and old-relay rows over SSH showing done after a real exit. It now asks the host whether the pane's agent process can be checked: only a pane with an identified, running owner keeps its row on an unanswered check; every other pane drops it exactly as before. The drop stays armed while the host answers, so a new command still cancels it, and a missing or failing answer (web client, older host) keeps today's behaviour. * fix(agent-status): panes without an identified owner keep today's exit confirmation The title-driven exit confirmation applied 'silence is never an exit' to every pane. It now applies only when the pane has an identified owner whose process cannot be checked right now; a pane with no process identity (Codex, hookless agents, Windows, old relays, sessions started before the update, or an owner that already ended) confirms exits exactly as before. |
||
|
|
6f2a7d05c9 |
fix(worktrees): let git delete removed checkouts so chat sends never wait behind them (#23837)
* fix(worktrees): delete removed checkouts in git, not in Orca's file pool Local worktree removal renamed the checkout into a sibling trash root and deleted it in the background with a recursive fs.rm in the main process. That queued one request per entry on libuv's shared 4-thread file pool, so for minutes every other async fs call in the main process (the agent-session store behind chat sends, file explorer reads) waited behind the delete. `git worktree remove` now deletes the checkout inline in git's own process again, so the card stays in its Deleting state for the length of the delete while Orca's file pool stays free. No timeout applies to the call, so a large delete is never killed halfway. If git reports success but the path still exists (Git for Windows leaves junctions and their parent directories in place), the leftover is deleted with the existing removeHostTree; WSL checkouts stay with the distro. Nothing creates trash any more: the scheduling queue, rename/restore helpers and the trash_rename span are gone. The startup sweep stays to drain entries older releases left behind, and now removes each emptied trash root so the obligation ends. * fix(worktrees): let Git delete Windows checkouts with long paths enabled Removal now always runs Git's own recursive delete, and worktree creation checks out with core.longpaths on Windows, so a deep checkout Orca created could fail to delete with "Filename too long" (#6433). The Windows recovery then finishes the delete but keeps the branch. Pass the same command-scoped core.longpaths option to `git worktree remove` so Git can delete what it created. Also point the CI shard timing entry at the renamed real-git removal suite. * fix(worktrees): keep an inherited GIT_ASK_YESNO out of the worktree delete Git for Windows asks $GIT_ASK_YESNO whether to retry when a file stays locked during a recursive delete. Orca's git env inherits the user's environment, so an inherited value would run an arbitrary prompt program in the middle of a removal. Drop it for the removal call only. * perf(worktrees): run worktree deletes under their own limit, outside git admission `git worktree remove` now deletes the whole checkout in Git's own process, which takes 20-35 s on a large tree. It took a general git admission slot at status tier for that whole time, and that cap is as small as two slots on a machine with six or fewer cores, so two deletes blocked every status read. Deletes now skip general admission and queue under their own limit of two per host instead: two concurrent deletes already saturate one disk, and more only slow each other down. Leftover cleanup runs inside the same slot. * fix(worktrees): delete removed checkouts in the background and mark them removing Since the checkout is deleted by `git worktree remove` in Git's own process, a large delete takes 20-35 s. Answering the request only after that made web and mobile (30 s), paired desktop (60/180 s) and the CLI (60 s) report a failure for a delete that was still going, and mobile silently re-showed the row. The request now does everything that can refuse (lock, cleanliness, archive hook, watcher/terminal gate, terminal stop, shared-link unlink), records the removal in an in-memory table on the host and answers `removing: true`. The delete, branch cleanup and metadata purge run after it in the same order as before, and the watcher/terminal gate stays held until they finish. - Listings mark rows in the table `removing` for clients that advertise `worktree.background-removal.v1` (the desktop renderer, paired desktop and web), and leave them out for everyone else (older clients, mobile, the CLI), which already dropped the row when the request answered. - The outcome (removed, with any preserved branch, or the error) rides the existing worktrees-changed event as an optional field, sent after the row has left the table. - A repeat delete while Git runs joins it. A create at the same path or with the same branch is refused with "Cleanup is pending; try again shortly"; create's name search skips the path, so generated names move on. - Nothing is persisted: after a quit or crash Git still lists the checkout and it can be deleted again. WSL checkouts still delete inline. - `orca worktree rm` says the checkout is still being deleted. * fix(worktrees): keep the existing Deleting card until the host's Git finishes The host now answers a local worktree delete on acceptance and deletes in the background. The renderer keeps the existing delete state set until the host publishes how it ended: - The delete that asked waits for the outcome on the worktrees-changed event (local IPC or the paired runtime's client event), then runs the same teardown, preserved-branch toast and card error an inline delete did. If that event is lost to a dropped connection, a listing that shows the row gone after it was marked removing finishes the wait, and one that shows it back without the marker fails it. - Any other renderer (a reload, a paired desktop, web) sets the same delete state from the host's `removing` marker and clears it when the marker goes. A failure the host publishes lands on that card's existing error. - Web advertises `worktree.background-removal.v1` so the host sends it the marker; paired desktop does through the Electron capability list. No new component, style or state: the card reads the delete state it always did. A host that predates this answers when done without `removing`, and the renderer takes that as finished, as before. * test(worktrees): type the removal harness and projection for the node typecheck * fix(worktrees): don't fail a delete retry with an earlier attempt's buffered failure A background removal's outcome that reached this renderer with no waiter (another client's delete, a host-marked card, or one already settled from listings) was buffered for 60 s and consumed by the next delete of the same workspace, so retrying a failed delete failed at once with the old error while the host was deleting. Drop the buffered outcome before sending the request; only an outcome that arrives after it can belong to it. * fix(worktrees): let only a gap in host events settle a background delete from listings Git unlists the checkout before the host deletes the branch, cleans the push target and purges metadata, and the worktree-directory watcher refetches within 250 ms. The renderer read the missing row as a finished delete, so the waiter resolved without the preserved branch (no toast) and a failure in those last steps showed as success; the real outcome was then dropped. The listing fallback exists only for a lost outcome event, so it now applies only after this host's event stream had a gap: a new subscription or a replay after reconnect. * perf(worktrees): let a bulk delete start each same-repo checkout delete once the host accepts the last A bulk delete ran one worktree at a time per repo (#2259, for packed-refs and ref-lock races in branch cleanup). With Git now deleting each checkout for 20-35 s before the request settles, N worktrees in one repo took N times that. The renderer now queues same-repo deletes only until the host accepts each one; a parent still waits for its nested children to finish. The host serializes the branch cleanup step per repo itself, which also covers removals started by different clients. * test(worktrees): pin the host platform in the mocked removal suites so they pass on Windows Removal now passes -c core.longpaths=true on Windows, so the exact-argv assertions and command-keyed mocks never matched there (17 failures on a Windows host). Pin darwin as the add-worktree suites already do, and drive the one Windows-specific case through the same spy. * test(worktrees): type the blocked git remove result instead of a broad object The anti-slop static-analysis gate rejects `object` parameters. * test(worktrees): clear the changed-code quality gate in the removal suites Merge the duplicate node:fs import, build the mock child without a cast, read worktrees:list rows through one typed helper, and give the remaining casts a SAFETY line. * fix(worktrees): record each background delete durably and finish it after a quit or crash A quit mid-delete left git to finish the checkout on its own while the branch delete and metadata purge never ran; a crash left a normal-looking row. Each accepted local removal now writes a record beside the profile state before git starts, clears it on success or failure, and the host runs the same delete again for any record left at startup, re-deriving what remains from git and disk. An orderly quit stops the checkout delete without waiting for it. * test(worktrees): type the interrupted-removal assertions for the node typecheck * fix(worktrees): finish an interrupted delete that already removed the checkout's .git file Quit stops git worktree remove mid-delete, and Git deletes the checkout's .git file wherever it falls in directory order. Git then refuses the checkout ("validation failed ... .git does not exist") on every retry, so the startup finish failed and the row could never be deleted from Orca. A registered checkout this record owns that has lost its .git file now finishes like an unregistered one: leftover files, prune, then the branch. * fix(worktrees): let Git finish an interrupted delete, and never take a different checkout A quit or crash that stops `git worktree remove` after it deleted the checkout's .git file left a registered checkout Git refuses to remove. The previous fix deleted that leftover inside Orca's process, which is the bulk delete this change exists to avoid (and on Windows the leftover can be most of the checkout). The startup finish now rewrites the missing .git file from Git's own admin entry for that path and lets `git worktree remove --force` delete it. `git worktree repair` is not used: it also re-points every other registered path, including a checkout another repository now owns there. Orca deletes the leftover itself only when no admin entry claims the path. The startup finish forces, so it now leaves the path alone when the checkout there is not the one recorded: a registered worktree on a different branch or head, or a `.git` at a path Git already unregistered. The record is dropped and the card shows why. The record write before Git starts is now bounded (2 s, logged when exceeded) so a stalled disk cannot hold the delete, and the outcome is published before the record's clear reaches disk. * test(worktrees): compare worktree paths by value and tear down with Windows lock retries Git prints forward slashes in `git worktree list` on Windows, so the real-Git removal suites never found a joined path there: positive checks failed and negative ones passed without proving anything. They now compare Git's parsed rows by value. Teardown uses the shared retrying removeTree, since Windows can hold the deleted checkout busy for a moment after Git exits. Adds a relative-path worktree case for the .git restore (skipped before Git 2.48). * fix(worktrees): reply to a worktree delete when it has finished, not on a broadcast event A current client's delete request now waits for the host's background delete and gets its real result (removed, a preserved branch, or the error) as the reply, the way it did before the delete moved off the request. A request that arrives while the delete runs joins it and gets the same result. Every other view keeps reading the host's `removing` marker: the row leaving means the delete finished, and the row listed again without the marker shows "The delete did not finish. Try again." on a card that view had marked Deleting. A request whose reply is lost (a timeout or a dropped connection) settles the same way from a fresh listing instead of reporting a failure. Clients without the background-removal capability (mobile, the CLI, older desktops) are still answered on acceptance and have rows under removal left out of their listings. This removes the outcome on worktreesChanged and everything it needed: the renderer's outcome waiters, early-outcome buffer and TTL, per-host event-gap generations, the request pre-registration, and the accept callback bulk delete used. Bulk delete runs same-repo deletes in parallel only on this machine, whose host serializes branch cleanup per repo; SSH and paired hosts stay serialized. * test(worktrees): type the pending-removal host id in the background-removal suite * fix(worktrees): answer a delete request even when a concurrent removal of the same worktree replaced its record The desktop app's removal and the runtime removal (CLI, paired clients) coalesce separately, so both can be accepted for one worktree. The second replaced the first's record, and the first delete then finished without resolving the request waiting on it, leaving the desktop card on Deleting indefinitely. Each delete now settles the request it was started for. * fix(worktrees): run same-repo removal archive hooks and teardown one at a time on the host Local bulk delete now sends same-repo removals in parallel, so their archive hooks, terminal teardown and preflight ran at once; a hook that writes refs can race the repo's ref locks (#2259). The host now serializes each local removal up to acceptance per repo, for every client; Git's checkout delete still runs in parallel under the delete limit. * fix(runtime): keep waiting worktree deletes out of a host's foreground call slots worktree.rm now replies only after Git deletes the checkout (up to minutes), so on paired desktop and web each waiting delete held one of the host's 8 foreground call slots, and a bulk delete queued listing refreshes and every other foreground call behind it. Deletes now run in their own lane with the same bound; the 2-slot background lane stays for status polls. * fix(worktrees): join a same-worktree delete accepted while a removal waited its repo turn The desktop app and the runtime (CLI, paired clients, web) check for a running delete before they queue for the repo's acceptance turn. A delete of the same worktree from the other path, accepted while this one queued, was missed: this request re-ran the archive hook, stopped the terminals again and started a second `git worktree remove` on the directory Git was deleting. The queued acceptance now re-checks and joins the running delete. * fix(worktrees): fence a resumed delete's checkout from startup, and drop rows a listing read before the delete finished A delete a quit or crash interrupted took its terminal and file-watcher gate only when the resume job ran, after the first window was shown; session restore could open a shell or watcher inside the half-deleted checkout first, and on Windows that handle can fail the resumed git delete. Loading the records now fences each recorded path, and the resumed job takes the fence over in the same tick it takes its own gate. A listing that read git's registration before a delete finished, and replied after the removal record cleared, returned the row unmarked, so other views briefly showed "The delete did not finish". Listings now capture the pending removals before reading git and leave out a row whose delete finished successfully since; a row whose delete failed stays listed as before. * test(worktrees): keep git's auto-maintenance out of the real-git removal suite CI's Git 2.55 failed the file-pool test in teardown with ENOTEMPTY on the scratch repo's objects/pack after the test body passed: the 3,000-file commit's detached auto-maintenance was still writing a pack. The scratch repo now disables auto-maintenance and auto-gc. * fix(worktrees): one archive-hook approval covers a same-repo bulk delete again Local same-repo deletes now start together, so each queued its trust prompt with a state snapshot taken before the first prompt was answered; approving the first still showed the same prompt once per remaining worktree. The queued check now reads the store when its turn comes. |
||
|
|
b594898533 |
test: stop main failing on three tests the store and close-cause changes crossed (#24231)
* test: two tests catch up with the journal-database store and the close cause #24006 moved the session store into the chat journal database, and #23467 made close take a cause; #23684's and an older tab-table test still used the old calls, so main's typecheck fails. * test: the startup-reconcile close test passes the close cause too |
||
|
|
3727100cc9 |
fix(opencode): report OpenCode 2 status from each pane's own TUI (#23722)
* fix(opencode): report OpenCode 2 status from each pane's TUI OpenCode 2 serves every pane from one shared server whose env names only the pane that started it, so every same-folder pane's work showed on that pane, and the plugin's single aggregate swallowed the Idle of a pane whose turn ended while another pane was busy. Install the status plugin a second time as an OpenCode 2 TUI plugin (plugins/<name>-tui/tui.js, written only when its bytes differ, locally, in overlays and in the SSH/WSL relay installs). In a TUI process setup() runs the same engine, fed through the same event translation as the server, but only for root sessions this pane owns: the route's session from when it starts (or when the route reaches it while running, hydrated from the TUI's session status) until it settles, is deleted, or the TUI exits. The server plugin stands down in any serve process when the TUI copy is installed beside it; a relay that predates the TUI copy keeps the old behavior. OpenCode 1 loads no plugin directories and is unchanged. Delete the session-to-pane binder, registry, client sweep and ingest reattribution: with every post stamped by its own pane nothing is left for them to correct. Known gap: OpenCode 2 `opencode run` in a pane has no TUI, so it reports no pane status. * fix(opencode): settle missed OpenCode 2 turn ends and order TUI installs - The TUI copy now settles an owned root whose run the TUI's session data reports ended, with no pending permission or form, when the engine still holds it busy. An execution end missed across a service restart or reconnect no longer leaves the pane Working until the TUI exits. - The TUI copy stays idle without ORCA_PANE_KEY. post() cannot report without it, so OpenCode 2 TUIs outside Orca no longer run the route poll and engine. - Installers write the TUI copy before the server plugin file. The server decides at load whether to stand down, so a reload between the two writes now finds the TUI copy. * fix(opencode): reconcile OpenCode 2 TUI status only once queued events drain The TUI's session data applies each event before this plugin's queued handling reaches it, so settling against it mid-backlog published a false Done before a fast turn's later steps (Working, Done, Working, Done). Reconcile only when no event is queued; a mismatch then is a start or end missed across a reconnect, and both directions are now re-derived (a missed start left the pane on Done). Also install the TUI copy beside the server plugin in the retired shared hooks dir, so a TUI or service still loading that dir reports per pane instead of leaving the service reporting under its starter pane. * fix(opencode): keep OpenCode 2 TUI panes silent on plugin dispose A TUI plugin hot reload disposes the plugin while the pane's turn keeps running. The server path already passes sessionsOutliveDispose so dispose publishes nothing; the TUI adapter now does the same, or every TUI reload would still show a false Done. Also refreshes the generated-bytes digest after rebasing onto the write-if-changed installers. * fix(opencode): refresh installed OpenCode status plugins at app start After an Orca upgrade, an OpenCode 2 service that was already running kept the previous plugin, and with it the old wrong-pane status, until any new terminal pane rewrote the file. OpenCode 2 reloads a plugin whose file changes, so Orca now refreshes its existing installs once after the first window shows: the global config dir, source overlays and the retired shared dir, TUI copy first. It reuses the per-pane writers, which skip unchanged files, never creates an install the user did not have, and honours the status-hook and per-agent switches. An SSH relay does the same for its canonical install when Orca connects and ships the plugin sources. The plugin source assembly moves to its own module (re-exported unchanged) to keep hook-service.ts under the line limit. * fix(opencode): derive each OpenCode 2 pane's status from its TUI session data The TUI copy of the status plugin translated OpenCode events into the server-side engine and then patched the engine's latches back toward the TUI's own session data: a settle on every tick, a queued-event counter, a re-assert, synthetic Busy and Idle. Each review found another place where the two copies disagreed: a missed end left the pane Working, a backlog of slow posts flickered Done, a missed start showed Done mid-turn, a turn held only by a subagent never settled, and a request answered while disconnected pinned Needs input. The TUI reporter now reads the pane's level straight from the session data OpenCode keeps current (and re-hydrates on reconnect) on every event and on a 100 ms tick: for each root session this pane owns, Needs input (an open permission, else form, anywhere in its family while it runs) outranks Working (any family member running) outranks Done. It posts one status per level change through the plugin's existing delivery functions (retry, dedupe, message-part throttle, ordering), so the wire and Orca's ingest are unchanged. Ownership is kept in OpenCode's storage.memory, which survives a plugin hot reload, so a turn that ends during a reload still shows Done; dispose publishes nothing, and a level the old generation could not deliver is re-posted by the next. On reconnect it re-syncs blockers for the owned sessions OpenCode would not re-sync itself, ignoring requests already answered. Events are handled synchronously, so a slow post can no longer hold up event processing. The server path, OpenCode 1 and mimo are unchanged. * fix(opencode): keep an OpenCode 2 pane on Needs input while its root streams text Orca treats every OpenCode MessagePart as Working. While a background subagent waits on a permission or form, the root session can keep streaming reply text, and the TUI reporter forwarded that text as a MessagePart. The pane then flipped from Needs input to Working, and nothing restored it until the level changed, so the user could miss the open request. Skip reply text while the pane's level is Needs input, as the reporter already does for queued prompts. * fix(opencode): leave OpenCode 2 step events to the server path's own change The shared-server translation re-derived Working from session.step.started. The pane reporter no longer uses it, so it only changed the server path and duplicated a separate open change. Drop it; server behaviour matches main. * fix(opencode): reset an OpenCode 2 pane to idle when its TUI starts Before this change, a pane could keep a status an earlier process left on it. The case that matters is an upgrade mid-turn: the old shared-service plugin posted pane B's turn under pane A's key, then stood down, and pane A's TUI loaded the new reporter owning nothing, so it never posted and A showed a wrong Working until its own next turn. A freshly started reporter that owns no running turn now posts the host's existing session-start boundary once, which the host shows as connected idle: no completion, no notification, and an unseen Done it lands on stays unread. A plugin hot reload keeps its memory and skips it, and where OpenCode keeps no plugin memory it is never sent. * fix(opencode): keep OpenCode 1 serve + attach sessions on the attaching pane OpenCode 1 `opencode serve` in one pane plus `opencode attach` in others reports every session from the serve process, whose environment names only the serve pane. Before this branch the session-to-pane binder moved those posts to the attaching pane; deleting it for OpenCode 2 put them back on the serve pane. Restore the binder, registry, correlation and client sweep for OpenCode 1 (and mimo-code, which it also served), gated so OpenCode 2 never uses it: - The status plugin now sends `opencodeMajor: 2` on every post from a process whose loader called setup(), which only OpenCode 2 does. The host skips the binder (no rewrite, no kicked round) for any post that carries it. OpenCode 1 and older plugins send nothing and keep the previous behaviour. - The binder reads only OpenCode 1's `session` table. OpenCode 2 writes `session_v2`, so its sessions never bind; on a database both versions wrote, OpenCode 1 sessions are no longer hidden behind the v2 table. OpenCode-1-only; it goes when OpenCode 1 support is removed. * fix(opencode): show OpenCode 2 `opencode run` as Working, then Done, on its own pane OpenCode 2's `opencode run` loads no plugin, and the shared service that runs its session cannot tell which pane it belongs to, so a pane running it showed nothing. The runtime now reports it from the pane's own process lifetime. On the pane's OSC 133 command start (main already parses 133 for every local PTY; the start callback was never wired), after the pane tracker's 350 ms settle it reads the foreground process name through the existing foreground reader, and only when that name is OpenCode, the foreground command line from one fresh shell-foreground process-table capture. An `opencode run` posts Working through the existing terminal-status path into the hook server's store; the pane's 133;D (or the daemon's background fact) posts Done, marked interrupted on Ctrl-C. No polling. Exclusive with plugin reporting: each write carries the command's start time, and the store drops it once a hook has written the pane since then, so an OpenCode 1 `run` (in-process plugin) or a server plugin without the TUI copy owns its command alone and there is never a second Done. Local macOS and Linux panes only; SSH, WSL and Windows panes stay silent because their foreground cannot be read on this host. * fixup! fix(opencode): show OpenCode 2 `opencode run` as Working, then Done, on its own pane Type the selected descendants as process-table rows; ReturnType of the generic collector widened them to bare identity rows. * fix(opencode): keep an `opencode run` pane's Done after the command exits The run's Done was published inside the chunk that carried its OSC 133;D, before the chunk's command-finished fact. The renderer drops an exited agent's row on command-finished when the row has not changed since that fact arrived, so it took the Done as the stale row and dropped it, in the renderer and in main. Publish the run's Done after the chunk's side-effect facts are emitted (and after the daemon's background fact). The renderer then sees command-finished while the row is still Working, and the Done that follows counts as a change, so it stays, the same way a hook Done that lands after exit already does. * fix(opencode): let an `opencode run` revive a pane Orca retired When an agent Orca launched exits, command completion retires the pane, and only a hook new-turn event revived it. A later `opencode run` in that pane posts no hook, so its process-lifetime Working was refused and the pane stayed silent. A process-lifetime Working is posted only after a fresh OSC 133;C and the pane's own foreground argv prove a new OpenCode run, which is at least as strong as a hook new turn. The store now revives the retired pane on it the same way: it clears the retirement, drops the launch-token fence, and rebinds the observation. OSC status and a lone process Done still cannot write a retired pane, and a closed tab stays closed. * fix(opencode): report `opencode run` on local Windows panes too The run producer skipped every Windows pane, although Orca already resolves a Windows pane's foreground agent from the native process table. On main an OpenCode 2 `run` showed there (on the service's pane); on this branch it was silent. The argv read now has a Windows branch: one fresh native table read (windows-process-table, no interpreter spawn), the same foreground identity the Windows resolver already computes, and the command line of the process that identity names. It runs only after the name read says OpenCode. Local Windows panes whose shell prints OSC 133 C/D (PowerShell with PSReadLine, Git Bash with Orca's wrapper) now go Working, then Done; cmd.exe prints no markers and stays silent. SSH and WSL panes stay silent. * fix(opencode): re-read an `opencode run` foreground on the pane tracker's ladder The producer read the pane's foreground once, 350 ms after the command started. A wrapper, a shim or `sleep 1; opencode run` execs OpenCode later, so those runs stayed silent. The renderer's pane tracker already re-reads a command's foreground at 350, then 1200, then 6000 ms for exactly this. Move those delays to one shared module and use it from both. The producer re-reads only while the foreground is a non-shell process that is not OpenCode, and at most on those three rungs; no new polling. * fix(opencode): end an armed `opencode run` when the next command starts A new OSC 133;C in a pane whose `opencode run` was still armed dropped the armed state without a Done, so a run whose 133;D never arrived left the pane on Working. A new command start proves the previous command ended, so it now posts that run's Done (not marked interrupted: no exit code is known) before the new command is inspected. * fix(opencode): skip the session-start row inside an OpenCode 1 `run` process OpenCode 1 `run` loads the status plugin in its own process, and the plugin posts SessionStart when the run's session is created. The host lands that as an idle session boundary, so a pane the run producer had already shown as Working blinked idle before the plugin's Busy. The plugin now skips SessionStart when its own process is a `run`, read from its argv the same way isOpenCodeRunCommand reads a pane's foreground (first positional after global options, past the compiled binary's entry path). A `run` session goes Busy at once, and its first prompt part still revives a retired pane and resets the turn caches. The TUI and `serve` still post it, and OpenCode 2's `run` loads no plugin. * chore(opencode): say where the plugin's opencodeMajor comes from The field is set when OpenCode's loader calls setup(), which only OpenCode 2 does; the plugin never reads OpenCode's version. Say so at the field. Comment only; the generated plugin is unchanged. * test(opencode): type the late-Done pane fixture without bare casts The new renderer test passed its fixtures with `as never`; use the checked fixture-tuple cast with a SAFETY note, like the sibling pty-connection tests. * fix(opencode): keep `opencode run` silent when OpenCode status is turned off The run producer ignored the status-hooks switch, so a user who turned status off globally or for OpenCode (#23667) still got a run's Working and Done. It now checks isAgentStatusHooksEnabledForAgent for the run's agent (opencode or opencode2), the same predicate the plugin install honours, before reading argv or posting anything. * test(opencode): read the late-Done row without an untyped property access The mock store types agentStatusByPaneKey values as unknown, so reading `.state` failed tc:web. Assert the row with toMatchObject instead. * perf(opencode): read a command's foreground only while it could become `opencode run` Two costs the run producer paid on every command in every local pane: - The retry ladder re-read the foreground (a process-table capture on the local provider) for any non-shell program still running, including other agents, editors and dev servers, up to three times. It now re-reads only while the foreground is still the shell (the command has not exec'd yet) or an unrecognised launcher that may still exec OpenCode (node, bun, bunx, npx, npm, pnpm, pnpx, yarn). Any other program is read once. - With OpenCode status turned off for both opencode and opencode2, it still set the timer and read the foreground only to discard the result. It now returns before any timer or read. The per-agent check after the name read stays for the case where only one of them is off. * fix(opencode): let a new hook turn end a pane's process-exit completion A confirmed agent process exit records a pane-wide completion identity that names only the agent. Hook Dones are matched against it by agent alone, and only a working title cleared it, so in a pane whose agent paints no working title (OpenCode) every later hook-reported Done was treated as already notified: no notification and no unread mark. An unreported exit (a status-off `opencode run`, or quitting an idle client) was enough to set it. A fresh hook Working now clears a process-exit identity, since a new turn cannot be a duplicate of an earlier exit. Hook identities stay, so same-turn and replay dedupe is unchanged. * refactor(opencode): share the foreground read schedule as one value The pane tracker's import of the two shared foreground-read delays took four lines where its old local constants took three, which put the file one line over max-lines once merged with main. Export the settle delay and retry ladder as one object so each consumer imports a single name. No behaviour change: same 350 ms settle and 1200/6000 ms retries. |
||
|
|
24edf0f64b |
fix(agent-status): a turn a crash cut off reads Interrupted, an unproven end Couldn't confirm (#23467)
* refactor(native-chat): remove the unused terminal handoff
No client ever called agentSession.requestHandoff or mounted the handoff
chrome. Delete the handoff coordinator, the terminal-owner runtime, the
proof write path and the unmounted UI. Keep agentSession.handoffStatus,
which released desktop clients read for worktree activation, and let
records an older build left mid handoff reconcile through the ordinary
restart and recovery paths.
* fix(native-chat): never let the pre-stop snapshot hold a chat's stop
Eviction now drains delivered events before quit's resume-offer snapshot. An
unbounded wait there sits ahead of the provider stop, so a sink whose journal
write stalls kept the child running until the step deadline aborted the
eviction. The offer is advisory: bound the drain and stop the child regardless.
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor(native-chat): drop helpers only the terminal handoff called
`claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and
`queryWindowsProcessRowsFresh` lost their last caller with the handoff. The
fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`,
the teardown path that still depends on that contract.
Co-Authored-By: Claude <noreply@anthropic.com>
* docs(native-chat): stop citing the removed handoff in lifecycle comments
Six comments still named the handoff coordinator, a handoff suspend, or a
terminal-owned session as live participants in the flows they describe.
Co-Authored-By: Claude <noreply@anthropic.com>
* test(native-chat): type the stalled snapshot drain without a cast
Co-Authored-By: Claude <noreply@anthropic.com>
* test(native-chat): pin that a start dead before proving owes no settlement
The removed restart handoff test pinned this branch; nothing else did.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(native-chat): keep the owner-status read behind an in-flight attach
The handoff removal dropped the per-session queue from `handoffStatus`, so a
read landing mid-start reported the reservation (no owner) instead of the
settled chat owner, and shipped desktop clients blocked worktree activation on
it. The read is queued again, as it was before the removal.
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor(terminal): remove the agent-session PTY write gate
The gate only refused a write when a PTY had been bound to a chat session, and the
only code that ever bound one was the terminal handoff this branch removes. With it
gone, every admit/readmit returned "admitted" unconditionally, so the checks on the
renderer write path, the runtime controller backstop, terminal.send, agent prompts,
preview input and orchestration pointers, the refusal fields on terminal.send and
worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane
orchestration routing could no longer run. Ordinary writes take the same path in
the same order as before.
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor(native-chat): drop the transcript helpers only the handoff called
appendLegacyTranscriptMessages fed the terminal transcript catch-up and
proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost
their last caller with the handoff. Their tests now go through the live entry
points instead: the roster bounds through the legacy import, the pinned-read and
growth tests through the ancestry replay the history window uses, and the marker
rules through the string proof in their own file rather than the session-file
resolver's.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(native-chat): stop calling a starting chat "mid-handoff"
A send refused because the chat's owner is not settled showed "The session is
mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that
reach it are a chat that is still starting, or one whose previous agent process
has not yet been confirmed stopped. The message now says which of the two it is.
The refusal code is unchanged.
Co-Authored-By: Claude <noreply@anthropic.com>
* test(native-chat): type the stand-in roster decoder without a cast
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor(codex): name the pinned rollout lookup for what it does
With the terminal handoff gone, the module named codex-tui-rollout-proof holds
only the pinned rollout lookup that structured Codex launches use to resume a
thread, so the name described code that no longer exists. Rename the module and
its options type. Also drop a mobile allowlist assertion that pinned the
removed agentSession.requestHandoff method, which no longer exists to allow.
* refactor(native-chat): type the owner-status reply as the host sends it
The handoffStatus reply type still listed the terminal handoff's fields and
states (terminal placement, host label, proof retry, queued and waiting phases,
the to-terminal direction). No host writes them any more and the only client
reader parses the reply as unknown, so they described nothing. The reply on the
wire is unchanged.
* refactor(native-chat): normalize terminal-handoff lease values once at decode
Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the
handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types
still admitted them, so readers across the host kept branches for values no
path produces and the compiler could not point at them.
The store now validates the on-disk shape, which still accepts those values so
an older record is not quarantined, and maps them once while parsing:
- `preparing` and `old-owner-stopped` become `recovering`
- a `tui` lease becomes `native`; when it records a process it also becomes
`conflicted`, the claim every build probes but never stops. A plain native
owner would be stopped by restart recovery, here and in older builds.
Revisions are taken over the normalized state on both sides of every compare,
and the mapped record reaches disk with the store's first transaction, the
same way the tab-id backfill does.
The in-memory types narrow to what this build writes, and the branches that
existed only for the removed values go. Structured-worker identity keeps its
verdict for a former terminal owner by refusing a conflicted claim rather
than a non-native kind.
* refactor(native-chat): stop threading the owner kind through a reservation
A reservation only ever names a native owner now, so the request no longer
carries a kind and the reserved lease records `native` directly. The attach
params keep `runtimeKind`: agentSession.ensure and create accept it, and the
operation fingerprint stored in the ledger covers it.
* test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else
Hiding a tab also committed the visibility index, so the no-op transaction
wrote the file even when its open-time revision was wrong. Committing the index
first leaves the pending rewrite as the only reason to write.
* fix(native-chat): name a chat write by its target, not the owner generation
A write carried the fence of the last frame the pane read, and the host refused it
unless that fence was still current. An idle release and the restart after it each
move the fence, and the release publishes nothing, so a send after a release was
refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a
cold start was refused as stale.
Every write already names what it acts on: a send its conversation, a cancel its
turn, a prompt answer its item revision, a rewind its epoch; an option is
last-writer-wins. So admission stops comparing the client's fence, and the rebase
that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it.
The writer-lease check stays, and so does the attach's compare-and-swap.
Frames now stamp the fence read when each frame is sent instead of a copy each
subscriber kept, which went stale on the same release.
* fix(native-chat): every journal append reaches the chats that are open
A journal write and its delivery to open readers were two calls, and some
writers made only the first. A failed start whose lease could not be handed
back, a provider revision with no frame behind it, and eviction's settlement
were all journaled without reaching an open chat.
A journal handle now reports every durable change, and the host's session map
binds that report to the session's readers when the handle is set. Writers no
longer publish what they append; the per-writer publish calls are deleted.
* test(native-chat): an epoch replacement reaches the open chat
* test(native-chat): each row reaches an open chat once, and a live handle enters only through the map
* test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite
The seeded record had no surface tab id, so the next open backfilled one and
that rewrite alone made the no-op transaction write. The test passed with the
legacy-lease rewrite signal removed.
* test(worktree-activation): restore the OMP surfaced-agent resume test
The handoff removal deleted it alongside the terminal-owner tests, but it
covers the surfaced-PTY block that still guards resume, including an agent
whose ownership is unknown.
* perf(native-chat): a publish behind a delivered commit reads nothing
Each commit now delivers itself, so the publish a provider frame still sends
afterwards found every reader caught up but still read rows and rebuilt the
timeline for each one. A caught-up reader now skips the read.
* test(native-chat): state why the teardown test's fake journal is safe to cast
* docs(native-chat): say mutation admission checks only the writer lease
* docs(native-chat): drop the send rebase from comments that still described it
* fix(native-chat): a message is accepted, then delivered
A send to a chat with no running agent restarted the agent inside the send
call, before the message was recorded, so the client waited for the whole
start and a failed restart refused the message. Claude held prompts sent
during startup, and those could settle as "unconfirmed".
A send is now accepted inside the session's serialized queue: one ledger row
and one submission row marked handoverRecorded, published, answered pending.
A per-session delivery loop exists while a message is queued. It starts the
agent through the same serialized attach a hold uses, waits outside the queue
for a Claude child to prove its start, and hands the oldest queued message
over as its own serialized step, writing dispatch{pending} before the adapter
call. A start it needed and did not get writes one error-tone row and rejects
every queued message with the same words; a start Stop cancelled writes none.
Settlement follows from the rows. A queued message is provably unwritten, so a
close, an eviction or an exit rejects it. A handed-over message stays in doubt.
A queued row at or below the sequence a handle found when it opened was left
by an earlier process and is rejected at open, with no latch. Stop withdraws
queued messages with no writer lease and no fence. An attach failure keeps the
conversation open, and the attach adopts its journal. Owed work counts the
loop and queued rows.
A compaction or rewind found prepared when a conversation opens was started
under a child this process no longer has, so the open settles it rather than
leaving it to refuse every send until a view attaches. The open cursor is
scoped to its epoch, because sequences restart when an epoch is replaced.
Deleted: restart-before-admission, recordFailedRestart, the fence rebase,
Claude's startup gate, the attach's forget on failure and its own crash
boundary. Clients without agent-session.accepted-send.v1 get their reply held
until the handover; the desktop and paired desktop lists advertise it.
* fix(native-chat): settle queued messages only for the child that ended
A child that proved its start and then exited before its message was handed
over left the message queued: the exit settlement returned early when nothing
else was in flight. Delivery then started another child for it, and a child
that died the same way started another, without end and without a row.
A retried settlement for an earlier generation, run by the attach that
delivery started, did the opposite: with that generation's turn unfinished it
rejected the message queued for the child being attached.
The settlement now takes the rejection for queued messages from its caller.
The unexpected exit and the eviction pass one, and it applies even with no
other work in flight; the retry for an earlier generation passes none.
* fix(native-chat): an adoption that fails to import keeps the conversation open
The attach now writes into the conversation's own open journal, but a failed
transcript import still closed it as if it were the attach's provisional one.
The conversation stayed indexed with a closed journal, so every later send
answered "could not be recorded" and every attach failed again until the app
restarted. The import now closes only a journal the attach opened for itself.
* perf(native-chat): the recovering open reads the journal once
Every conversation open now goes through the recovering open, including the
read restore of every chat at startup, which used to replay its journal once.
The recovering open replayed it twice: once to probe it and again inside the
open. The probe is now handed to the open as its load.
* fix(native-chat): an attach that fails after indexing its child leaves no child behind
A failed attach now keeps the conversation open, but a failure after
`onAttached` indexed the child (the rewind or compaction recovery, or the
attach's own success record) left that entry claiming a child the failure
path had already released. The next send found the phantom, skipped the start,
and wrote at a fence the journal had moved past, so the message stayed queued
for good. The entry now drops the released child and its event sink, and
follows the record's fence, as a failure before indexing already did.
* fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer
The error strip for a message the host accepted and then did not deliver matched the entry before
the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not
send your message" with nothing to retry. It now reads the reconciled entry.
A rejection the journal records before the send's own pending answer lands is final as well:
that answer no longer puts the entry back to dispatching with no Retry.
* fix(orchestration): a structured worker whose agent outlasts the preamble wait is left unknown, not torn down
The preamble waits for its submission to be delivered while the worker's agent starts. When that
wait ran out it threw operation_unknown, and the failed-start teardown then closed the session,
which rejected the very preamble the host was about to deliver. It now reports a turn start
nobody observed yet: the worker is start-unknown with its session kept, the host delivers the
preamble when the agent starts, and the worker's report settles the dispatch as for any
unobserved start. The receipt no longer suggests reading a screen a structured worker lacks.
* fix(native-chat): a message rejected while its chat was closed reads as not sent
A remount reads an entry it left dispatching as unconfirmed. When the journal had rejected it
meanwhile, as a failed start or a quit now does, the reconcile left it unconfirmed: it blocked
every later message behind a Retry and no reason, and the delivery probe, seeing the journal
already answered, never ran. The reconcile now settles it as rejected like a dispatching one.
* test(orchestration): name why the readiness settlement fakes are cast
* fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent
* docs(native-chat): drop the fence from the admission the send effects run behind
* docs(native-chat): give the fence move on release the reason that still holds
* docs(native-chat): stop citing a write fence check in launch and mailbox comments
Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease.
* refactor(native-chat): the provider child is its own record
A conversation now outlives any number of provider children, so the child is one record on the
conversation's entry instead of five loose fields beside its journal. It is written in one place:
indexed only once an attach has fully succeeded, and ended through one function that an exit, a
failed re-attach, a Stop and an eviction all share, matched on the child's generation and fence.
- A failed attach writes no child, so there is nothing to unwind: the field unwind and the fence
patch after it are gone.
- Conversation writes read the record's fence, the way mutation admission already does; a child's
own writes use its fence. The four stored-fence patches, and the settlement retry's overwrite of
the conversation's fence, are gone.
- The owed wind-down is its own tombstone, carrying the child it is owed for, and is no longer
dropped when an attach replaced the whole entry.
- Stop on a child still proving its start stops only the child: its lease goes back and the chat
is told it is idle, but the journal, the holders and the readers stay. Close is that stop plus
the conversation's close.
- The settlement retry uses the conversation's own journal, opened through the host's one open.
* fix(native-chat): the delivery loop alone settles a message its start or child failed
A queued message was settled by whichever path happened to end the child first: the loop, the
unexpected exit, eviction's work settlement, the open's leftover rule, and the startup branch that
rejected every pending row. That gave two failure rows with different tones for one start, a loop
that could hand over to a different child than the one it waited on, and a Claude start that died
while starting reading unlike every other failed start.
- The loop remembers the child it waited on. At handover, if that child is gone or replaced, it
reads how it ended: a Stop continues; anything else writes one failure row and rejects every
queued message with the same words, then stops. A child still starting whose start the adapter
says did not land fails the same way. The exit, eviction and the settlement retry only settle
the handed-over and legacy rows of the child that ended.
- One failure row, always an error, keyed by the start. A start a view began that dies with
nothing queued writes the same row through the same builder, so a second report revises it.
- The open no longer rejects leftovers; the loop's first step does, and the open wakes it.
- `awaitStarted` answers why a start did not land, so the row says it even when the loop sees the
failure before the exit is processed.
- Quit closes every conversation the way closing a chat does: what is still queued is rejected as
closed, with or without a child, and a start the loop already has in flight is waited for so the
child it produces is stopped rather than left behind.
* refactor(native-chat): a stopped child ends on the one reading of its stop
The eviction step reads a stop's result through `stopAgentSessionProviderRoot` and hands that
verdict to the child's ending, so the host never forms a second view of whether the root is gone.
Every ending carries it: a stop's comes from that reading, an exit's root is gone by definition,
and a failed re-attach passes what its release saw. The end-of-child record can therefore also
carry a stop whose root was not seen to go, which nothing ends on yet.
* feat(native-chat): the host says it accepts a send before any agent has it
The host now lists agent-session.accepted-send.v1 among its own runtime capabilities, the same
string capable clients already send. A client can then tell a host that answers a send at
acceptance, and admits a Stop with no writer before a turn starts, from an older one that still
restarts the agent inside the send. Additive: an older client ignores a capability it does not
know.
* refactor(native-chat): an attach never opens a journal of its own
The attach adopts the conversation's open journal, which outlives it, so it no longer opens one
for a direct caller either. That leaves nothing for a failed adopted import to close, and the flag
that told the two cases apart is gone. Tests that attach without a host open the conversation the
way a host does.
* fix(native-chat): a moved fence resends nothing on a host that accepts first
The outbox treated any fence change as a new owner: it dropped the answer of a send in flight,
queued that send to go out again under the same id, and unblocked a refused head. On an older
host that is how a send the restart refused, unrecorded, gets another try. On a host that records
every send before it starts an agent, a fence moves because that start ran, so the same rule
resent into every failed start. With a fence stamped on every frame, that became a loop.
The outbox now reacts to a fence change only when the host has not advertised that it accepts a
send before any agent has it. On such a host, only a Retry or a new send goes out, and a failed
start reaches the client as a rejected message it keeps with its Retry. Against an older host, or
before one has answered, the outbox behaves as it did. Desktop and paired web share this hook.
* refactor(native-chat): a child's end says whether the user or the host stopped it
The end-of-child record's cause now tells a user's Stop from the host stopping the child for a
cause of its own: `user-stop` and `host-stop` replace `stop`. The delivery loop goes on after a
user's Stop, as before, and fails the start it was waiting on after a host stop, with the one
error row and every queued message rejected, in the stop's reason when it gave one. The reason
stays description only. Stop passes `user-stop`; nothing passes `host-stop` yet.
* fix(native-chat): a chat whose only work is a queued message is not offered for resume
A message accepted while the agent was starting counts as working in the chat, and quit rejects it
as never sent. The teardown snapshot read the same working rule, so a relaunch offered to resume a
chat whose agent never had the message. The snapshot now reads only what was handed over.
* test(native-chat): type the queued-message fixtures in the resume-offer tests
* fix(native-chat): a start that dies while a message waits on it is that message's failed start
Opening a chat's tab starts an agent for the view, and a send accepted meanwhile waits on it. When
that start died, its exit wrote the start's error row and left the message queued, so the delivery
loop started a second agent into the same failure and wrote a second row. A child's end now records
where the conversation's journal stood, and the loop settles a message accepted before a failed
start ended with that start: one row, under its key, and no second start. A message sent after the
failure still gets a fresh start.
* fix(native-chat): a request that failed reads as failed
A structured chat whose only message the agent's start refused read as a
green finish, and a cancelled structured turn did too: the host published a
verdict only for turn records, and structured rows carried no `interrupted`.
The host projection now reads the session's latest request: its turn's
outcome, or `failure` for a send the agent or its start refused. A send
that was withdrawn, or left undelivered by a restart or a close, fails
nobody and makes nothing listable. The ingest publishes `interrupted` as the
hook lanes do, and every reader decodes the verdict through one accessor, so
a failure reads Failed on the dot, the rollups, history and `worktree ps`,
behaves like a cancellation in every clean-finish policy, and notifies as
"failed".
* docs(native-chat): say what an attach's open conversation and unconfirmed ids are now
* test(native-chat): a verdict change republishes the mobile status projection
* refactor(native-chat): the store's retention trigger keeps its flag compare
A verdict change always moves the completion clock the same check already
reads, so a second verdict compare there caught nothing new.
* test(native-chat): a user message the provider journaled keeps its session listed
* test(native-chat): pin what a failed start settles, and what a resume offer names
A view's child that dies while a sent message waits settles that message only when it died starting
and no child has taken its place: a proven child's crash, or a second start since, gets the message
delivered. The resume offer names the handed-over message, never a newer one still queued.
* test(native-chat): the failed-start pins fail on what the message became, not on a timeout
* fix(native-chat): a late provider-session update keeps a failed recovery record failed
A provider-session heartbeat that rewrites a completed recovery record kept
its interrupted flag but dropped the outcome it was copied with, so a live
failed checkpoint read as a clean finish until the next status write.
* test(orchestration): the preamble's host stub is typed, not cast
The preamble send now takes only what it reads of the host, the send, the settlement wait and the
record's fence, so its test builds that host with real types instead of `as never`.
* test(native-chat): the terminal-bell check asserts the renamed verdict field
The bell notification test still checked for agentInterrupted, which no
longer exists, so it could not catch a verdict leaking into a bell dispatch.
* fix(native-chat): a failed turn ranks like a completion for attention
Attention readers (completion time, Smart Sort, sticky retention, Cmd+J
Recent) now demote only a turn the user stopped. A failure is news the
user has not seen, so it keeps its completion time, ranks in the Done
class, stays retained after its pane goes away, and a retained failure
reads failed in the worktree rollup instead of done. Clean-finish
policy (hibernation, pane ownership, the value moment) still treats a
failure like a stop.
The retention trigger compares verdicts again: success -> failure no
longer moves the completion clock.
* fix(native-chat): a failed main agent reads failed while its subagents still work
The verdict is now read from the main agent's own state, not the folded
row: a main agent that is done and failed has a verdict even while its
subagents keep the row working. Without mainAgent (history, worktree ps,
older hosts) the old combined-done rule stands.
Display marks the verdict through agentVerdictDisplayMark: a failure
outranks every combined state on the agent's dot, label, tab badge,
dashboard and activity rows; a stop marks only a done row, so a
successful or stopped main agent with live subagents still reads
working. Subagent rows keep their own state. The worktree card, terminal
tab and Cmd+J rollups share one pane fold and rank a pending question,
then failed, then working, monitoring, interrupted and done.
worktree ps publishes the main agent's outcome on a working row, and the
mobile mirror reads it. The store's change check, the paired-client
mirror's equality and its epoch now see a verdict change on a working
row, which otherwise moves no state or clock and left the worktree card
reading working. Clean-finish policy is unchanged: a working row is never
hibernated and has no completion time.
* docs(native-chat): the worktree ps outcome comment no longer claims old hosts send it
The field is new: an old host sends no outcome at all, so a reader falls
back to interrupted. The removed clause said old hosts send it on done
rows, which never shipped.
* docs(native-chat): the status-store listing rule names provider-journaled user messages
* fix(native-chat): a refused send notifies failed through the completion feed
The host's completion feed followed only the newest turn, so a send the
agent or its start refused, which creates no turn, read Failed on its row
but sent no notification. The feed now follows the session's latest
request, read from the projection the status feed already makes for the
commit: a turn keeps its id, a refused send is named by its journal item
key. It announces only while the session is idle, as the row reports a
verdict, so queued sends refused one commit at a time notify once, and a
withdrawn send falls back to a request already announced.
* fix(native-chat): every copy of a row carries the main agent's own status
History entries, sleep records and `worktree ps` rows carried a flattened
top-level `outcome`, copied under different gates and without the main agent's
clock. They now carry `mainAgent` (state, outcome, stateStartedAt), the type
the live row already persists and sends, and every copy site takes it with
`interrupted` through one function, `agentVerdictFields`.
- The accessor reads `mainAgent` then the legacy flag; the mobile mirror
matches it line for line.
- Sleep records admit `mainAgent` with `normalizeMainAgentStatusField`, so a
malformed value drops the field, never the record.
- Mobile dates a main agent that failed under live subagents by its own clock,
as desktop does, and its row equality compares `mainAgent`.
- The activity feed reads a history entry's own `mainAgent` instead of
rebuilding one; the sync key and history equality compare it.
* test(native-chat): pin the worktree ps verdict across host and phone versions
Pairs the real v1.4.212 host and phone row reader with this build: an old phone
reads a new host's rows by `interrupted`, a new phone reads an old host's rows
(no `mainAgent`) the same way, and a new phone reads a failure under live
subagents as Failed, dated by `mainAgent.stateStartedAt`. The release checkout
now carries the phone's self-contained row reader, and the lane runs when the
`worktree ps` row producers change.
* test(mobile): name the parity table's row for its role
* fix(native-chat): a request that settles while the user is asked something notifies once
The completion edge waited for an idle session, and a pending prompt (including a
subagent's approval) is not idle. Structured chat has no other attention producer,
so a main turn that finished while a subagent waited on the user sent nothing
until the prompt was answered.
The edge now waits only on owed work (a running turn or an unanswered send), which
the projection reports even beneath a pending prompt. A request that settles with
a prompt pending announces once; the renderer words it "needs input" from the
host status mirror's `attention`, and answering the prompt keeps the same request
identity, so it does not announce again. The wire shape is unchanged.
* fix(native-chat): the completion says when the user is being asked
A request that settles while a prompt waits on the user was worded "needs input"
from the renderer's status-feed mirror. Remote clients receive the status and
completion streams over separate sockets, so they can arrive in either order and
the wording could be wrong both ways.
The host already knows at emit time, so the completion now carries an optional
`awaitingUser: true` in that case and omits it otherwise. The renderer words the
notification from that field alone and no longer reads the status mirror. Old
clients ignore the field and word by outcome; old hosts never send it.
* fix(worktree-status): a departed agent's failure yields to live work on the worktree card
A retained failed agent has no expiry, so ranking it with a live failure pinned the card to Failed over other panes' live work. It now ranks below working, monitoring and permission, and above every finished outcome.
* docs(agent-status): a departed agent's failure ranks below live work on the worktree card
* fix(native-chat): a view never restarts a chat whose last start failed
A Claude chat whose CLI exits during startup left one red row per start, and
every time a view bound to it (the chat opening right after its create died,
or the user switching back to it) the hold started the CLI again, so the same
launch-failure row repeated. Only a send retries a failed start now, the same
rule provider-exit recovery already applied; the rule lives in one predicate
the hold, exit recovery and the delivery loop share.
* test(native-chat): start the child the loop waits on with an attach, not a second view
A view no longer starts a child whose last start failed, so the R2 case that
waits on a child started since the failure now gets that child from a client
attach, the one non-send starter left.
* fix(native-chat): settle a gone generation's turn wherever a conversation opens
A send that opens a chat this process had not read yet (after a crash, from a
phone or the CLI) went through the delivery open, which never settled what the
dead generation left running; only the read restore and a successful acquire
did. When the send's start then failed, the turn stayed running for every
reader. The settlement now runs in the one journal open, at the crash boundary,
for every opener except an acquisition, which settles from the evidence it read
before its reserve; the read restore's separate step is gone.
* test(native-chat): prove the next child's start settles the turn an earlier child left
The R1 case lost its only settlement assertion when the latch it checked was
deleted. It now seeds the running turn the earlier child left and asserts it
ends at the exit's receipt, with the exit's row, before the message is handed
to the new child.
* test(native-chat): count a failed start's rows by row, not by text
Comparing the set of texts passed when two different rows carried the same
words, which is the duplicate the test exists to catch.
* test(cross-version): load the phone row readers without mobile's toolchain
Vite transforms a file against its nearest tsconfig, and mobile/tsconfig.json
extends expo/tsconfig.base.json, which the root-only cross-version lane never
installs. The worktree ps verdict suite imported the current phone row reader
from mobile/ directly, so CI failed with TSConfckParseError before any test ran.
The harness now imports a copy of the working-tree reader placed under the
checkout cache, where the root tsconfig applies, as it already does for the
release checkout's copy. Both readers are still the real files.
* test(cross-version): keep the checkout path-guard message and justify the copy import's cast
* test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget
* test(native-chat): pin the open's and the send's start and row counts, however the view binds
Opening a fresh chat whose starts fail makes one start and one row, with two
views bound before or after the create's child died; one send makes one more
of each.
* fix(agent-status): a turn a crash cut off reads Interrupted, an unproven end Couldn't confirm
When the provider gave no verdict, the structured status projection now derives
one from the newest turn's lifecycle: interrupted -> interruption, unverifiable ->
unconfirmed. Nothing new is journaled, the completion feed stays provider-only, and
the legacy interrupted flag stays a user stop only. Every verdict reader handles
both arms explicitly.
* fix(native-chat): settle a gone generation's turn at every open but an acquisition's
The journal open skipped the settlement whenever the lease read reserved or
live, to leave an acquisition's own open to the acquisition. But a lease a
crashed process left in recovery also reads live, until the next acquire
resolves it. A send that opened such a chat, from a phone or the CLI after a
crash on a host that could not prove the old owner gone, skipped the
settlement; when its start then failed, the dead turn stayed running for every
reader. The acquisition now says it is the opener, and every other open
settles, whatever the lease still claims.
* fix(native-chat): a folded turn a crash cut off reads Interrupted after N
The settled-turn timing now carries the turn's verdict, derived by the same
agentTurnVerdict the status row uses. A turn that ended interrupted with no
provider verdict heads its fold 'Interrupted after N'; a user's stop keeps
'Worked for N'.
* test(native-chat): hold the create's start open until the views bind
The "view binds while the create is still starting" case gave the create a
300 ms head start and asserted the views bound before it died. On a loaded
runner the holds took longer, the create's exit landed first, and the case
failed its own precondition. The create's initialize now waits on a gate the
test releases once the views are bound.
* fix(native-chat): the user's close of a chat records the turn it cuts short as their cancellation
The expected-close settle writes outcome cancellation when the user aimed the
stop at this chat: a Stop while the agent starts, the chat's tab closed (the
agentSession.close RPC, or session.tabs.close with a user reason), or /clear.
A quit, an idle eviction, a worktree teardown or an orchestration stop leaves the
turn with no verdict, so it still reads Interrupted.
* test(native-chat): a Claude turn a newer send superseded reads Interrupted
The supersede fires for any send Orca dispatched, the user's or another agent's,
and nothing at that site records the sender, so the turn keeps no verdict and
folds as Interrupted after N.
* fix(native-chat): the user's close records cancellation on the turn the provider settled on its way out
The Codex and Claude adapters settle their open turn as interrupted, with no
verdict, while the host stops them. The expected-close settle then found no
running turn, so a user's close of a mid-turn chat read Interrupted. The stop
now reads the running turns before it reaches the provider and records the
user's cancellation on each one it cut short, unless the provider gave a
verdict of its own. The close-verdict test's adapter now settles its turn on
close the way the real adapters do.
* test(native-chat): update the close and settled-turn expectations for the host-observed verdict
agentSession.close now passes the user's word to the host, and a settled
interrupted turn with no provider verdict carries `interruption`. Also merge a
duplicate import the code-quality gate rejects.
* refactor(native-chat): drop the composer's second error formatter
After the merge with main, every chat write in the composer path reports its
failure as a typed outcome worded by the refusal-notice table, so the send's
catch sees only a local throw. The {code, message} formatter this branch added
for it has no payload left to format, and its claim to be the one way a chat
words a failure is no longer true. The composer send is main's again.
* test(native-chat): pin the reason on a message rejected while its chat was closed
The reopen test checked only that the message reads as not sent; it now also
checks the Retry row carries the host's reason.
* fix(native-chat): the user's close cancels a turn whose start landed as the provider stopped
The close read which turns were running before the stop. A turn whose start was
still in flight (a send echo not yet journaled) was absent from that read, so the
provider's verdict-less settle on the way out left it Interrupted. The close now
reads which turns were already over instead, and records the user's cancellation
on every other turn the stop left running or interrupted with no verdict. A turn
cut off earlier, or finished during the stop, keeps its end.
* fix(native-chat): a send the provider never received after a restart has no verdict
Restart reconciliation rejects a crash-stranded send that is absent from a
trustworthy provider history with reason 'not_delivered'. Nobody failed that
send, but the verdict allowlist did not name it, so after a crash the chat
read Failed, was listed, and could notify "failed". Give the reason a shared
constant (persisted value unchanged), add it to the no-verdict set, and treat
it as an internal marker so the Retry row no longer shows the raw string.
* refactor(native-chat): the adapter settles the turn a stop cuts with the stop's typed cause
The host hands its stop's cause to the adapter's close. Each adapter settles its own
open turn on 'ended' through one mapping, turnVerdictForChildEnd, and the host's
dead-generation fallback uses the same mapping for any turn no adapter settled. A
user's close or stop of this chat is their cancellation; a quit, eviction, teardown
or an exit the adapter saw first is news.
Deletes the snapshot-and-diff reconstruction (endedTurnItemIds,
userStoppedTurnRevisions, settleUserStoppedTurns) and the requestedByUser flag.
host.close now takes a required cause.
* test(native-chat): a user's close drops the chat's status row like an eviction
* test(native-chat): expect the eviction cause on the host closes of idle release, worker stop and worker discard
The stop's typed cause now travels into host.close and the adapter's close, so these
three non-user closes assert the 'evict' they pass.
* refactor(native-chat): every stop names its cause, so none defaults to the user's cancellation
stopStructuredAgentSessionAgentUnderSerialize defaulted its ending to 'user-stop', which now
settles the cut turn as the user's cancellation. Every caller already passes a cause; the
parameter is now required, and a type-level test fails to compile if the default returns.
* test(native-chat): pin who a chat's session.tabs.close speaks for, older clients' reasonless close included
The mapping lived inline in a type-unchecked file, and only the explicit user reason had a test: an older client's reasonless close, or a lifecycle echo read as the user's, stayed green. It is now one exhaustive, type-checked function with a case per reason.
* style(mobile): draw the unconfirmed dot in the theme's status amber, not an inline hex
* docs(agent-status): the main agent's outcome also carries the host-observed end, interruption or unconfirmed
* fix(native-chat): a chat the user closed while its agent started is not a failed start
A still-starting child the user's close cut counted as a failed start, since only 'user-stop' was excluded: a start-failure row, and queued messages rejected as a provider failure. Whether an ending fails its start is now one exhaustive switch, shared by the failed-start read and the delivery loop's handover: a user's stop or close never does; an exit, a failed attach and the host's own stops still do.
* fix(native-chat): a chat the user closed closes its queued messages, and starts no agent for them
After
|
||
|
|
5cda0f4508 |
refactor(native-chat): keep agent-session records in the chat journal database (#24006)
* fix(native-chat): report a failed startup chat reconcile instead of failing app startup At startup the chat host re-checks every saved chat's lease and writes the result to agent-sessions.json. If that write failed (the file lock gave up, the file could not be written, or the file was written by a newer Orca and is read-only here), reconcileRestartLeases rejected, the startup IPC call rejected, and the renderer fell into its degraded "Session restore failed. Changes won't be saved until restart" mode. The reconcile is bookkeeping: a lease left unreconciled grants no writer, and every attach, send and read of a chat reconciles its own lease again. So the startup reconcile now reports its failure through a new optional host dependency, onStartupReconcileFailure, and resolves. The runtime routes it to its onError sink under the scope structured-agent-session-startup-reconcile, or logs it when no sink is installed (the desktop installs none). * fix(native-chat): read restored chats without waiting on lease bookkeeping With native chat on and a chat tab open at quit, the renderer's startup also awaits the chat tab restore (session.tabs.listAll). That restore re-ran the lease reconcile before reading each chat and rethrew its store failure, then recorded each restored tab as visible through a store transaction that throws on a held lock or a read-only store. Either one failed the restore, so startup still fell into "Session restore failed". Reading a chat grants no writer, so the reconcile startup and the restore run is now a reader's: createReaderReconcile never throws, answers whether every lease is settled (recovery is resolved only then; the journal opens either way), and reports each distinct failure once until a reconcile settles. Attach and agent start keep the strict reconcile. The restore's tab republish logs a failed visibility write and still publishes the tab, since a client drops every unpublished chat tab; user-driven publishes still refuse. The host dependency is renamed onLeaseReconcileFailure (scope structured-agent-session-lease-reconcile), since it now also reports for reads. * fix(native-chat): keep every record-store write off the startup chat read path Round-2 review found two more writes on the startup chat restore that could still fail it and put the app into "Session restore failed": republishing a /clear replacement recorded its tab visibility strictly, and resolving a chat's recovery rethrew its store error. The restore also paid one lock wait per tab and per batch of chats while the lock stayed held. The restore now derives tabs from state it already holds: - publishStructuredAgentSessionTab splits into the strict write and projectStructuredAgentSessionTab, which only updates the runtime's snapshot. The restore and /clear replacements only project: a saved tab index already lists every restored chat, and a /clear moves the tab in the same write that commits it. visibilityWriteMayFail is gone. - Chats a legacy profile restores that the index does not list are recorded in one best-effort transaction (store.showSessionTabs), so a failure leaves the index absent to seed again rather than partial. - The read restore's recovery resolution is caught and reported through onLeaseReconcileFailure, deduplicated with the reconcile's reports. - Once lease bookkeeping fails in a restore pass, the rest of that pass skips it, so a held lock costs one wait for the startup reconcile and one for the restore, however many chats are open. User actions (create, reveal, attach, send, the /clear commit) keep their strict writes. * test: open, seed and read the agent-session record store through one harness Tests that open the durable agent-session record store, seed it, or read back what it persisted now go through agent-session-record-store-test-harness.ts instead of calling AgentSessionRecordStore.open or touching agent-sessions.json themselves. A later change that moves the store into the chat database then changes the harness instead of every test. No production code changes. Tests whose subject is the JSON file itself (its .bak recovery, salvage, schema versions, permissions, and what older builds read back) keep reading and writing the file directly; the storage move rewrites or deletes them. * fix(native-chat): start each restore pass from one lease check and stop its bookkeeping at the first failure The restore now runs one reader lease check for the pass and lets each chat re-check and resolve recovery only while the pass is still settled. The first refusal or failed write clears it for the rest of the pass, and every chat is still opened for reading. With another process holding the lock, startup waits on it once in prepare and once in the restore, however many chats are open; a legacy profile waits once more for its tab-index seed. * docs(native-chat): correct restore comments and a test name to match the final design * test: address the record-store harness by the host's state directory The harness took the store's own folder, so each caller picked one (join(root, 'store'), or 'agent-sessions' where a test read the store the runtime owns). A later change that moves the store into the state directory's journal database could not tell those apart, and would have had to edit every caller again. Every harness function now takes the state directory, the one the test's journal database and recovery capsule already live in, and keeps the store in the same subfolder the runtime uses. Callers pass that directory; store-only tests pass their temp directory unchanged. Format tests that share a directory with harness calls take the file path from testAgentSessionStoreFilePath. The folder name moves from a private constant in the runtime to AGENT_SESSION_STORE_DIR_NAME beside the store's file name, so the harness shares it without importing the runtime. Its value and every path built from it are unchanged. * refactor(native-chat): keep agent-session records in the chat journal database The record store's records, operation ledger, retired claim keys and chat tab index become tables in agent-session-journal.db (user_version 4). The version-4 migration copies agent-sessions.json in its own transaction and never writes, renames or deletes that file or its .bak. Each store write is one journal transaction over exactly the rows it changed, checked with the load rules; the file lock, the external-change refresh and its hash, the .bak rotation, salvage and the hot-path recovery fence are gone from the store. * wip: importer tests * test(native-chat): cover the records migration, the import, row writes and read-only records * docs(native-chat): retire comments that describe the records file as the live store * test(native-chat): drop the record-store harness's leftover file path and type the import fixture * test(native-chat): let the host harness cleanup wait out a recovery-offer read's lock * fix(native-chat): let Stop reach the agent when its ledger row cannot be written Stop's operation-ledger row now shares the database with the chat history, so damage, a full disk or a stranded transaction on that write refused the Stop before the interrupt. A cancel plan now takes its decision from the committed ledger in memory, runs without settling, and warns that the row was skipped. Other mutations answer proven damage with the typed "Unable to load this chat." refusal instead of the raw SQLite error. * fix(native-chat): answer whether a profile holds chats from the database's rows Every host install creates agent-session-journal.db, chats or not, and the version probe created it too, so its mere existence made every profile that ever installed the host wait on host install and reconcile at startup. The check now opens the database read-only and looks for a record or tab row, lets the records file answer while its import is still owed, and counts an unreadable database as present. The version probe no longer creates the file. * fix(native-chat): open a chat from history when its tab index cannot be written Over records a newer Orca wrote, every write is refused, so opening a closed chat from Agent Session History failed on the tab-visibility write and the chat read as unreachable. Like closing a tab, opening one now reports a failed restore-index write and still publishes the tab. * fix(native-chat): keep the records import owed when the backup read fails transiently A torn records file whose .bak could not be read (EACCES, EIO) was reported as unusable, so the migration completed with nothing copied and never retried. A non-ENOENT read failure of either copy now carries its cause, which the importer classifies as a read that can clear. * test(native-chat): pin that an unreadable records file never falls back to its backup * fix(native-chat): restore imported chats' tabs when the records file had no tab index A chat created while the import was owed recorded a tab index holding only itself. When the file it later imported had no index, that index still read as recorded, so the imported chats' tabs never came back. The import now clears the recorded marker in that case, and restore falls back to the profile's tabs. * refactor(native-chat): drop the unused in-transaction store write Nothing called it, and it bypassed the write queue and the read-only refusal. * docs(native-chat): say that an unusable records file is left untouched but never re-imported * refactor(native-chat): keep the provider handle chain check as main has it The chain-validation refactor has no measured need in this change. * docs(native-chat): retire lease-renewer comments that describe the records file as the live store * fix(native-chat): keep a throwing failure sink from failing the startup chat read The lease bookkeeping failure reporter called the host's failure sink directly, so a sink that threw turned a reported, recoverable store failure back into a rejected startup reconcile or read restore. The reporter now catches a sink throw and logs both the original failure and the sink error with console.warn. * test(native-chat): wait for a replaced host's restart-offer writes before cleanup A restart test replaces the host without tearing the old one down, so the old host's fire-and-forget restart-offer withdrawal could still hold the recovery capsule's lock directory when cleanup removed the test directory (ENOTEMPTY). The harness now hands hosts a capsule that tracks running operations and waits for them before removing the directory, replacing the rm retries. * docs(native-chat): retire the abandon helper's note that the store re-creates its directory * fix(native-chat): restore a chat opened while the import was owed beside the profile's chats When the imported records file had no tab index, restore fell back to the profile's saved tabs, which never list a Claude chat, and the seed then rewrote the tab table without the chat opened while the import was owed. The tab rows that chat left are now loaded as unrecorded, restore takes them together with the profile's chats, and the seed keeps their tab ids. * test(native-chat): pin that a create whose tab index write fails still opens the chat * docs(native-chat): say why restore puts chats opened while the import was owed first * test(native-chat): replace a ledger row rather than change it in place in the Send-now rerun test The record store freezes published rows in tests, so setting a row's outcome in place threw; the test now swaps in a changed copy, as its sibling cases do. |
||
|
|
007b7c0d32 |
fix(claude): end a message Claude started but never confirmed, and keep Claude running while it holds one (#23898)
* fix(claude): settle a queued send the CLI withdrew from its own cancelled frame Claude reports each uuid-stamped command's lifecycle (queued, started, completed, cancelled). A send it withdraws from its queue gets `cancelled` before the interrupt or cancel_async_message answer, so a lost or failed answer no longer leaves that send pending: it settles as withdrawn, with the same reason and words as the receipt path. A command the CLI already started also ends `cancelled` when its turn is interrupted or fails, so `cancelled` after `started` is not a withdrawal; an echoed send has left the waiter lists and is never reached. Tests replay real 2.1.280 captures, scrubbed. * fix(claude): release a doubted send when the CLI reports its session idle A Claude send whose write ended in doubt is recorded `unknown`, and a live `unknown` reads as work still owed, so the chat showed Working until the child exited. Claude sends `session_state_changed idle` only once its whole queue has drained, so it can no longer be holding that send. The runtime now routes that report to the host's existing release, the same one Codex's thread-stopped report uses; it retires `unknown` only, never `pending`. * fix(claude): keep a command's started mark when a redelivery re-emits queued; fixtures name msg_lifecycle_v1 * fix(claude): settle every terminal lifecycle state of a send the CLI never echoed A send the CLI started, then cancelled before any echo, stayed pending: it may already be in the conversation, so it is released as doubt (unknown, recovered), never withdrawn and never re-sent. A late echo still accepts it. The 2.1.280 schema has two more terminal states. `discarded` (the CLI ended its session with the send still queued) settles as not delivered; `refused` (declined before it queued) settles as not accepted by the provider. After `started`, either one is doubt, as `cancelled` is. The late-settlement path gains an `unknown` outcome, which the host records as released doubt. * fix(claude): release a send the CLI took but left unanswered when it goes idle `session_state_changed idle` comes only once the CLI's queue has drained, so a send it took that is still unanswered there got no echo and never will: a turn that throws can leave `started` with no terminal state. Idle releases it as doubt. What proves the CLI took a send is its lifecycle frame. On a CLI that reports no lifecycle, it is the send's place on stdin: one whose write finished before an interrupt went out was read before the interrupt was, so the first idle after that interrupt releases it too. A send armed ahead of the interrupt but written after it is left alone, since the CLI may still run it. * fix(native-chat): keep the idle sweep off a Claude child that holds a send A Claude retrying a rate-limited request has taken the send but echoes nothing, so no turn row exists yet and the sweep rested the child after the idle window, turning the send into doubt. The adapter now reports whether the CLI holds a send (lifecycle `queued` or `started`, not yet echoed or ended), derived from the live waiters, and owed work counts it. Nothing is stored: every held send leaves the live set on its echo, its terminal lifecycle state, the CLI's idle, or the child's exit, so the hold ends with the send. * fix(claude): count only a started send at idle and as a held send 2.1.280's end-of-turn cleanup can report idle before it re-reads its queue, so a send read in that window goes queued, idle, started. Releasing every taken send at idle doubted that live send and dropped Working. Only a `started` send is released at idle or keeps the child from the idle sweep; a `queued` one ends by starting and echoing, by a terminal lifecycle frame, or with the child. The stdin-order path for CLIs without lifecycle frames is removed: a doubted send retired there disables content matching on CLIs that mint their own echo ids, and no Orca failure called for it. Those CLIs keep the earlier behaviour. Comments that said only a failed write or child exit ends a waiter, or that idle comes only once the queue has drained, now say what ends one. * docs(claude): say only what the CLI's lifecycle frames and idle actually prove * fix(claude): hold the idle sweep while Claude has a send queued, not only started The sweep rested a child whose CLI had queued a follow-up behind a turn, dropping the send it had already taken. The hold now spans the CLI reporting it took the send until its echo, a terminal lifecycle state, or the child's exit. The idle release still covers only started sends: 2.1.280 can report idle before it re-reads its queue. * refactor(native-chat): give provider-proven late dispatch settlement its own module * test(claude): pin a steer a Stop interrupts after it started as doubt, not withdrawn |
||
|
|
ba39c6d5fd |
fix(native-chat): a failed startup chat-lease save no longer puts the app into "Session restore failed" (#23964)
* fix(native-chat): report a failed startup chat reconcile instead of failing app startup At startup the chat host re-checks every saved chat's lease and writes the result to agent-sessions.json. If that write failed (the file lock gave up, the file could not be written, or the file was written by a newer Orca and is read-only here), reconcileRestartLeases rejected, the startup IPC call rejected, and the renderer fell into its degraded "Session restore failed. Changes won't be saved until restart" mode. The reconcile is bookkeeping: a lease left unreconciled grants no writer, and every attach, send and read of a chat reconciles its own lease again. So the startup reconcile now reports its failure through a new optional host dependency, onStartupReconcileFailure, and resolves. The runtime routes it to its onError sink under the scope structured-agent-session-startup-reconcile, or logs it when no sink is installed (the desktop installs none). * fix(native-chat): read restored chats without waiting on lease bookkeeping With native chat on and a chat tab open at quit, the renderer's startup also awaits the chat tab restore (session.tabs.listAll). That restore re-ran the lease reconcile before reading each chat and rethrew its store failure, then recorded each restored tab as visible through a store transaction that throws on a held lock or a read-only store. Either one failed the restore, so startup still fell into "Session restore failed". Reading a chat grants no writer, so the reconcile startup and the restore run is now a reader's: createReaderReconcile never throws, answers whether every lease is settled (recovery is resolved only then; the journal opens either way), and reports each distinct failure once until a reconcile settles. Attach and agent start keep the strict reconcile. The restore's tab republish logs a failed visibility write and still publishes the tab, since a client drops every unpublished chat tab; user-driven publishes still refuse. The host dependency is renamed onLeaseReconcileFailure (scope structured-agent-session-lease-reconcile), since it now also reports for reads. * fix(native-chat): keep every record-store write off the startup chat read path Round-2 review found two more writes on the startup chat restore that could still fail it and put the app into "Session restore failed": republishing a /clear replacement recorded its tab visibility strictly, and resolving a chat's recovery rethrew its store error. The restore also paid one lock wait per tab and per batch of chats while the lock stayed held. The restore now derives tabs from state it already holds: - publishStructuredAgentSessionTab splits into the strict write and projectStructuredAgentSessionTab, which only updates the runtime's snapshot. The restore and /clear replacements only project: a saved tab index already lists every restored chat, and a /clear moves the tab in the same write that commits it. visibilityWriteMayFail is gone. - Chats a legacy profile restores that the index does not list are recorded in one best-effort transaction (store.showSessionTabs), so a failure leaves the index absent to seed again rather than partial. - The read restore's recovery resolution is caught and reported through onLeaseReconcileFailure, deduplicated with the reconcile's reports. - Once lease bookkeeping fails in a restore pass, the rest of that pass skips it, so a held lock costs one wait for the startup reconcile and one for the restore, however many chats are open. User actions (create, reveal, attach, send, the /clear commit) keep their strict writes. * fix(native-chat): start each restore pass from one lease check and stop its bookkeeping at the first failure The restore now runs one reader lease check for the pass and lets each chat re-check and resolve recovery only while the pass is still settled. The first refusal or failed write clears it for the rest of the pass, and every chat is still opened for reading. With another process holding the lock, startup waits on it once in prepare and once in the restore, however many chats are open; a legacy profile waits once more for its tab-index seed. * docs(native-chat): correct restore comments and a test name to match the final design * fix(native-chat): keep a throwing failure sink from failing the startup chat read The lease bookkeeping failure reporter called the host's failure sink directly, so a sink that threw turned a reported, recoverable store failure back into a rejected startup reconcile or read restore. The reporter now catches a sink throw and logs both the original failure and the sink error with console.warn. |
||
|
|
3047353017 |
fix(orchestration): worker-abandon settles a stuck worker and records who did it (#23983)
* fix(orchestration): worker-abandon settles a stuck worker and records who abandoned it worker-abandon refused or no-oped in the states it exists to escape: a stop stranded by a dead runtime, an active attempt that was no longer the Task's latest, and settled workers whose terminal release was stuck at requested or unknown. It now settles every non-terminal worker except this runtime's own in-flight stop, records who abandoned it, and retains (never closes) an owned terminal whose release is not already in flight. Part of STA-8833. * refactor(orchestration): abandon retains through worker-retain's rule; record cancellations - One retain helper serves worker-retain and worker-abandon. A committed release (releasing, unknown) keeps its state and archive, since the tab may already be closed; a retained terminal drops its stale archive. - worker-abandon keeps the published stale field (this attempt was not the Task's current one) on the worker path. - task-list shows a failed Task's reason, whitespace-collapsed; task-update help and the recovery guide document cancel = failed + --result cancelled. * test(orchestration): drop duplicate abandon asserts; leave task-list output unchanged Cancellation stays documented in task-update help and the recovery guide. * fix(orchestration): abandoning an already-settled worker changes nothing Only the settling path retains an owned terminal. * fix(orchestration): attribute abandon only to a verified caller and keep the prior diagnostic * fix(orchestration): report an already-settled abandon as stale, as main did |
||
|
|
9afd1101ff |
fix(orchestration): stop minting and printing the dispatch capability (#23994)
* fix(orchestration): authorize worker reports without the dispatch capability Worker lifecycle reports and questions no longer depend on the per-dispatch capability token that lives only in the agent's conversation. The host now: - ignores capability_hash/capability_revoked_at for authorization on every row and checks the exact worker process instead (ask gains that check); - refuses a report whose calling terminal is provably another orchestration party (a Run coordinator or another Dispatch's worker), treating env that names no live pane here as absent; - applies one worker-state rule locally and remotely: a stop in flight refuses, while stop_unknown and start_unknown accept and settle. Minting and printing the flag are unchanged, so an older host and older preambles keep working. * fix(orchestration): stop minting the dispatch capability Dispatches no longer mint a per-Dispatch token, and preambles, the bundled skill guide and the ask resume hint stop printing --dispatch-capability. The consumer-generation bump and delivery fence that minting carried stay, now as setDispatchConsumer. Readers that inferred meaning from capability_hash read what they meant instead: worker-show's injected stage comes from the attached consumer, and a failed start copies custody identity only when no authority was ever attached. The CLI keeps accepting and forwarding the flag for older hosts. Cancelling a Task is recorded as failed with a reason; task-list now shows that reason and the guide and task-update notes document the recipe. * fix(orchestration): name the fenced party without implying which Dispatch it owns * refactor(orchestration): one worker report rule, fence only a different party - One module owns the unproven/settleable worker states and the refusal rule; local send records it, ask and remote throw it. A stale process is worker_identity_changed on every path. - The caller fence passes the worker's own terminal when its --from handle went stale. - Document the shared-tmux-server limit; drop the dead dispatch_capability_invalid rejection member; tests assert dispatch state, not the capability column. * refactor(orchestration): drop setDispatchConsumer and the dead capability retention - dispatch --inject no longer re-points the row createDispatchContext just wrote; worker-show reports every worker-less Dispatch as context_only, since Orca keeps no record of the paste. Tests re-point through a fixture. - failWorkerStart always records when the lifecycle closed; nothing authorizes on it. - Restore the ask resume hint's echo of a passed --dispatch-capability: an old host checks it before --resume. - Move the cancellation convention to #23983. * test(orchestration): drop capability-era assertions other tests already cover * refactor(orchestration): drop the host-side capability field and no-op test fixtures - RpcRequest and the SSH bridge stop carrying orchestrationCapability; the CLI's wire field stays for older hosts. - Fixtures pass identity to createRootDispatch instead of re-pointing to the same values; drop absence checks for a flag that can no longer be produced. * test(orchestration): cover a current process whose terminal moved to another pane * chore(orchestration): finish the capability cleanup in test stubs and skill wording * test(orchestration): drop needless response casts; mark the db stub cast safe |
||
|
|
e03870403e |
fix(orchestration): accept worker reports without the dispatch capability (#23982)
* fix(orchestration): authorize worker reports without the dispatch capability Worker lifecycle reports and questions no longer depend on the per-dispatch capability token that lives only in the agent's conversation. The host now: - ignores capability_hash/capability_revoked_at for authorization on every row and checks the exact worker process instead (ask gains that check); - refuses a report whose calling terminal is provably another orchestration party (a Run coordinator or another Dispatch's worker), treating env that names no live pane here as absent; - applies one worker-state rule locally and remotely: a stop in flight refuses, while stop_unknown and start_unknown accept and settle. Minting and printing the flag are unchanged, so an older host and older preambles keep working. * fix(orchestration): name the fenced party without implying which Dispatch it owns * refactor(orchestration): one worker report rule, fence only a different party - One module owns the unproven/settleable worker states and the refusal rule; local send records it, ask and remote throw it. A stale process is worker_identity_changed on every path. - The caller fence passes the worker's own terminal when its --from handle went stale. - Document the shared-tmux-server limit; drop the dead dispatch_capability_invalid rejection member; tests assert dispatch state, not the capability column. * test(orchestration): drop capability-era assertions other tests already cover * test(orchestration): cover a current process whose terminal moved to another pane |
||
|
|
85f8d6b5f5 |
test: retire long-tail cases whose assertion is decided by the test itself (#24132)
Resumes the backlog sweep at a chunk size that actually gets read. Six auditors, 84 files
each, and all six read their full scope case-by-case against production — the first wave
where every chunk closed with no gap. 33 case declarations removed across 22 files, 1 test
file deleted, 826 lines gone. No production code touched.
This wave exists because a conclusion of mine was wrong. I had recorded that yield collapsed
~36x and that deletion was no longer the high-value work. I was dividing cases removed by
files IN SCOPE while the fraction auditors actually READ fell from 100% to about 4%, because
I kept handing them 300-800 files. Recomputed against files read, yield has been flat at 4-7
per 100 with no downward trend. This wave came in at 8.2.
The most instructive removal looked like the most valuable test in scope.
`orchestration-worker-release-reap-fixed.func.test.ts` cites a production bug by two
identifiers, describes orphaned PTYs accumulating until `TasksMax=4096` aborts processes on
EAGAIN, and advertises itself as the functional tier wiring the real orchestration RPC
surface, the real `OrchestrationDb` and the real release modules. Deleting it leaves no
reference to that bug anywhere in `src`.
It still had to go: its fake runtime performed the fence it asserted —
if (pty.incarnationId !== inc) { return null }
handleTable.set('term_reminted', { ptyId, epoch: rendererGraphEpoch })
— so the case checking that a reused ptyId with a mismatched incarnation does not resolve was
checking a decision its own spy made twenty lines earlier. The real fence is owned by
`orca-runtime-terminal-handle-incarnation.test.ts:257`, and the other two cases replay
`orchestration-worker-release-incarnation-fallback.test.ts` (which uses a plain
`mockReturnValue` rather than reimplementing the remint) and `worker/worker-release.test.ts:23`.
"Integration test" and "wires real modules" describe the scaffolding, not the asserted step.
Other removals: a self-comparison disguised by an alias, where
`export const getIssueOwnerRepo = getOwnerRepo` makes a case asserting the two "agree" into
`f(x) === f(x)`; four cases whose `vi.mock` of `resolveIssueSource` made both the preference
value and the topology inert; five verdict-precedence cases owned by a verdict-agnostic block;
three call-shape probes on one-line store pass-throughs whose real contracts are driven by
behavioural neighbours; and a `export type _Ref = [...]` declaration whose own comment admits
it exists only to preserve test-only module-surface references.
Kept after checking production rather than shape. An auditor found two near-identical
ten-reconnect loops and kept both: one uses a test-local live-lease filter, the other the
shipped `sshRemotePtyLeaseAllowsReattach` predicate, and the file's own comment explains the
duality is deliberate "so the two cannot drift". Another kept a paths-alignment case that
looks like a validator tested against its own list, because adding a generated file without
registering its path does fail it — and `shellReadyWrappersExist` uses that registered list to
decide whether a partial tree needs regeneration.
Production duplication is now confirmed four times over, and it is why mirrored tests exist:
`createUpdateWorktreeLineage`/`createAssignWorktreeParent` differ by one `console.error`
string; `terminal-path-tap.ts` and `document/path-tap.ts` carry hand-maintained copies of
`matchFilePathAtColumn` under a docblock reading "keep the two in sync". In those cases both
test sides are load-bearing and the duplication belongs on a refactor list.
`mobile/tests-typecheck-baseline.txt` loses one entry. Trimming
`relay-host-signed-out-verdict.test.ts` made it typecheck clean, so the ratchet required
pruning its grandfathered entry — the file graduates from exempt to enforced. Baseline is now
124 entries, down from 125.
Verified: 690 test files / 7,560 cases pass across the touched desktop areas; the modified
mobile files pass (162 cases); `check-tests-typecheck-ratchet.mjs` OK (898 files in program,
124 grandfathered); `check-reliability-gates.mjs` 140 gates; the deleted file is absent from
the gate manifest, `cloud/package.json` and the mobile baseline; nothing under
`mobile/src/test-support/rpc-recording/` or `mobile/rpc-foundation/goldens/` touched.
|
||
|
|
a781a602a8 |
test: retire duplicate cases that replay an owner across a re-export or provider shim (#24114)
Resolves 208 candidate pairs where the same case title appears verbatim in two or more
files, produced by a repo-wide scan calibrated against a known positive. 46 case
declarations removed across 32 files, 798 lines gone. No file deleted whole, no
production code touched.
The headline result is the measurement, not the deletions: across the three buckets that
reported in detail, the signal ran roughly 86% false-positive (3/42, 9/42, and the rest).
It has good recall and poor precision, and it reorders a reading queue rather than
replacing one. Calibrating a detector against a known positive proves recall, not
precision.
What the deletions were:
- Duplicate invocation through a re-export shim. `native-chat-tool-summary.ts` is a
ten-line `export {...} from '../../../../shared/native-chat-tool-summary'`, and
`agent-status.ts:161` is `export { isExplicitAgentStatusFresh } from
'./pane-agent-evidence'`. Cases on the shim side were byte-equivalent to the owner's
with no rendering or transport hop.
- Provider-local replays of a shared helper: three `repository-ref` providers that are
each `createRemoteRefProbeCache(parseXRef)` and contribute nothing to transient
handling; two `local-pty` and `daemon/session` tables replaying
`shell-startup-output-scanner`, whose owner additionally checks every split point.
- A reader-side replay of store policy. `runtime-worktree-agent-rows-structured.test.ts`
asserted an attention-to-blocked mapping; the reader contains zero `attention` or
`blocked` tokens and copies `state` through. The mapping lives in
`structuredAgentSessionAgentStatus`. Consistent with
`docs/reference/agent-status-store.md`: readers keep only presentation policy.
- Constructor-only subclass duplication: the shared capability-cache case is covered by
`codex-app-server-capability-cache.test.ts`, whose ten cases include the identical
title plus all four risks `docs/reference/git-compatibility.md` names — first fallback,
later cached call, concurrent probes, per-host isolation.
- A private predicate duplicated at a real boundary, varying only a path passed straight
into the shared predicate.
Why most pairs were KEPT, because the false positives are principled rather than noise:
- Two independent execution hosts. `src/relay/git-handler-*` and `src/main/git/*` are
separate Git implementations that cannot import each other and hold separate capability
caches, exactly as the compatibility doc requires; the repo already ships
`status-branch-line-total-relay-parity.test.ts` to pin the duality deliberately. Neither
side's argv, timeout or cache regression is visible to the other.
- Deliberately duplicated production siblings: Codex vs Claude (different account fields,
different CLIs, different wire protocols), gitea vs bitbucket (`/pulls/42` vs
`/pullrequests/42`), gl-utils vs gh-utils (separate in-flight maps). Same contract
shape, different implementations — an identical title is the correct naming.
- Shared-predicate consumers: one side tests the predicate, the other tests a caller's
wiring to it. A caller that forgot to call the predicate passes the shared test.
In a codebase with intentional provider and host symmetry, identical test titles are
expected, and the signal cannot distinguish "copied" from "parallel by design" because
both produce the same prose. Only reading both bodies separates them.
Verified: 6,968 desktop test files pass; the three modified mobile files pass (39 cases);
`check-reliability-gates.mjs` 140 gates; nothing under
`mobile/src/test-support/rpc-recording/` or `mobile/rpc-foundation/goldens/` touched.
62 local failures across 12 files were each accounted for and none is caused by this
change: `browser-manager-tab-identity`, `browser-manager-viewport-ownership`,
`session-scanner-codex-workers` and `managed-hook-script-refresh` all fail identically in
a pristine `origin/main` worktree; five `mobile-web-app-*-render` tests need Playwright
browsers this machine lacks; `structured-agent-session-restart-ownership` and
`ssh-remote-commands` pass in isolation and fail only under concurrent load.
|
||
|
|
f4092c06d6 |
fix(runtime): treat Hermes session start as idle, not a running turn (#24064)
Hermes fires `on_session_start` when a session is opened, switched or reset; its payload carries no turn. Orca mapped it to `working`, so a freshly launched Hermes held a fresh first-party `working` row for the whole 30-minute staleness window: `terminal wait --for tui-idle` never settled against an idle composer, and the sidebar span a phantom spinner. Map it to `done` with `sessionBoundary`, matching what Claude and the compatible-lifecycle providers already do for SessionStart, and let the tui-idle evidence lane settle on a fresh boundary row. A boundary row claims a new session owns the pane and awaits its first input, which cannot arrive mid-turn, so it carries none of the #6011 risk that scoped that lane to DSH; a turn-end `done` still does not settle. Fixes #13653. Co-authored-by: Brian Grablin <5216789+bgrablin@users.noreply.github.com> |
||
|
|
c7218f20dc |
test: retire src/main/runtime cases that re-prove an owned contract (#24043)
Completes the `src/main` audit. Sweep over `src/main/runtime` (flat, rpc,
orchestration, relay, push) and flat `src/main`. 42 case declarations removed
across 24 files, 783 lines gone. No file deleted whole, no production code touched.
Highest-yield area by far was `runtime/orchestration` (21 cases from 151 files);
the rest of runtime measured under 1%.
What went, by pattern:
- Tests whose subject is the test itself: a source-scanning boundary test with no
production import at all, three of whose cases checked its own regex against
strings it declares; and a benchmark whose own simulation contains the
short-circuit it asserts, with `expect(wouldHaveBeen).toBe(60000)` comparing the
test's own arithmetic.
- Identity copiers: seven db cases where every asserted value is the literal input
(`type: 'question'` in, `type === 'question'` out).
- Permanently skipped tests for behavior that does not exist — two cases carrying
`// TODO: inline restore on re-subscribe not yet implemented`. A skipped test for
an unimplemented feature can never fail; it is a note in test syntax.
- Duplicate invocations of a contract owned at a stronger boundary, including three
reset scopes and two dependency-promotion cases owned by dedicated suites.
- Registration manifests whose every entry is referenced by other tests.
- Table rows and cases varying a field production never reads: a mobile tab-restore
case varying `clientCapabilities`, which the mobile branch does not consult, and
a Windows worker case in a module with no platform input at all.
- Names promising more than the input exercises: a case titled for forged AppImage
variables whose body only removes `AppRun`, byte-identical to a row of the
`it.each` table twenty lines above.
- Negative controls passing for an unrelated reason: a foreign-pane rejection whose
fixture also differs in sender handle, so the handle guard can reject it.
- Self-comparisons, including `format(m, { authority: 'current' }) === format(m)`.
Two deletions were justified by the wrong argument and kept only after checking a
better one. "A generated-catalog check gates registration" is false: that script
prevents the catalog and dispatcher from drifting apart, so a developer who removes
a method regenerates the catalog and the check passes. Like a type annotation over
an interface and its implementing class, it verifies internal consistency and
cannot see a declaration and its use removed together. Both inventories go on
per-entry evidence instead — every name is referenced by other tests.
The same rule kept a 37-entry terminal-method inventory in the same wave, because
some of its entries are pinned solely by it. An inventory is a ratchet if and only
if at least one entry is pinned solely by it; that is evidence per entry, not a
verdict by shape.
Kept deliberately: everything a gate cites, checked by case title and not only by
file path; source-scanning ratchets that pair their negative assertion with a
positive one against real source (the surviving boundary case asserts the pattern
still matches the writer module, so a silently-broken regex goes red); a
destructive-delete PTY waiver guard; `it.fails` markers, which go red if the bug is
fixed; and `it.skipIf(platform)` cases, which do run on other hosts.
Coverage is partial and stated as such: 1,195 files in scope, roughly 660 read
case-by-case, the remainder title-scanned and mechanically triaged. Unread paths are
recorded for a later sweep rather than assumed clean.
Verified: `check-reliability-gates.mjs` (140 gates), `check:code-quality:changed`
(0 new findings). No `pnpm tc` needed since no production file changed. A local run
of `src/main` surfaced 42 failures in three files this change does not touch
(`browser-manager-tab-identity`, `browser-manager-viewport-ownership`,
`session-scanner-codex-workers`); all 42 reproduce on a pristine `origin/main`
worktree, so they predate this wave and are environment-dependent locally — CI was
green on main at `2d85fdc753e2`.
|
||
|
|
5b3366f78e |
test: unit tests can no longer write a developer's real agent or Orca settings (#23979)
* fix(agent-trust): write per-user trust under the home the launched agent reads The Cursor, Copilot, Qoder and Antigravity writers and the local Codex config list resolved ~ with os.homedir() at write time, so any test that reached them wrote into the developer's real ~/.codex, ~/.cursor, ~/.copilot or ~/.gemini. Each writer now takes the home, derived once from the launch env (HOME, or USERPROFILE on Windows, else this host's home) by launchedAgentHome, which the SSH relay already used. * test: give tests that wrote the real agent or Orca home a temp one The structured Codex adoption replay pre-trusted /repos/workspace-1 in the real ~/.codex/config.toml; it now runs with a temp HOME and userData. The Codex session-resume and WSL hook tests created Orca's managed Codex home under the live userData, and the Claude Agent Teams tests wrote their tmux shim into ~/.orca; each now runs against a temp userData or HOME. * test: fail any unit test that writes the real agent or Orca home A vitest setup file wraps the node:fs mutating calls and refuses a target under the account's real ~/.codex, ~/.claude(.json), ~/.orca, ~/.cursor, ~/.copilot, ~/.gemini, ~/.qoder or Orca userData, found through os.userInfo() so a test that swaps HOME cannot hide it. The refusal is recorded and rethrown after the test, since trust writers swallow errors. Reads are untouched. It stands down only while an opted-in real-agent suite's own switch is set. It also unsets what an Orca terminal exports toward the live app (userData, Codex and Claude homes, and the Codex launch preflight CLI, which a shell test would otherwise run), so a local run matches CI. * test: type the guarded fs call from its narrowed original |
||
|
|
21124db4d5 |
refactor(native-chat): a subagent's rows live in its own section, not in the parent's conversation (#23752)
* refactor(native-chat): a subagent's rows live with that subagent, not in the conversation
A subagent's rows were drawn in its parent's conversation, each captioned with
the subagent's name. They now belong to the subagent: the transcript projection
keeps the session's own rows as the conversation and each subagent's rows apart,
keyed by the agent id its roster entry already carries, folded on their own.
Desktop: a subagent's rows open in a section under the roster row that names it,
from that agent's roster entry, and are windowed like any other rows. A subagent
no loaded roster names opens where its first row happened, inside the section of
the agent that spawned it or in the conversation. Its edits still count in the
turn they were made, and revealing one opens the sections around it.
Mobile shows the conversation, with each spawn's roster line. Worker reads and
structured terminal reads serve the worker's own rows.
Removes what the move makes redundant: the per-row caption and its copy, the
producer check in the tool fold and the turn answer, the per-agent frontier
interleaved in the conversation, worker-text subagent tags, and the agent id on
worker-read messages.
* refactor(native-chat): a diff target names the sections its row sits in
Revealing a subagent's edit opens the sections around it from the target the
rollup already holds, instead of looking the row up at click time. The section
head keeps to the agent's name and dot; its state in words stays on the roster
entry. The worker page test stubs the host through its module rather than a cast.
* fix(native-chat): a working subagent's section is open; a worker page windows its own rows
A subagent's section is open while its agent works and closes once it settles,
the way the turn's own live run does; a section the reader opened or closed by
hand keeps that choice. A subagent another subagent spawned opens inside that
one's section, so a working grandchild shows inside its working parent. Openness
is derived from the roster's state and the reader's choices; nothing stores an
automatic open.
A worker page is now the newest page of the worker's own rows. The host windows
the read over them before the limit, so a subagent's burst can no longer crowd
the worker's rows off the page, and "older" still means older worker rows. The
scope is an in-process argument of the host's history read; no wire request
carries it.
* fix(native-chat): a subagent section head names the turn it sits in, for the outline rail
* fix(native-chat): a subagent section's rows sit in the turn the section is shown in, for the outline rail
A background subagent's rows written during a later turn carried that later
turn onto their slots, so scrolling through its section lit the later turn's
rail tick and then snapped back. The rollup still counts each edit in the turn
it was made; only the slot, which the rail reads, takes the shown turn.
* fix(mobile): Load earlier reads past pages that hold only a subagent's rows
Mobile draws only the session's own rows, so an older page made entirely of a
subagent's rows landed as nothing: the reader tapped Load earlier, saw the
spinner, and got the same transcript back. One load now reads on (up to 8 pages)
until a page holds a row of the session's own, then applies the pages in order.
* test(mobile): stub the RPC client the way the other structured-session hook tests do
* perf(native-chat): order subagent rows for the changed-files rollup once per change to them
The rollup flattened and re-sorted every subagent row on each update, including
every token the parent streamed. The ordering now keys on the projection's
subagent rows, which keep their identity while only the conversation changes.
* refactor(native-chat): order subagent rows in the sections hook, keeping the list under its line limit
* fix(mobile): a transcript whose newest page is only a subagent's rows reads back on its own
Opened while a subagent is busy, the newest page can hold nothing but that
subagent's rows. Mobile draws none of them, so the reader saw an empty chat with
a Load earlier button, and an empty list cannot be scrolled to page. The hook now
reads back once from each such head, and the read runs on to the session's own rows.
* fix(native-chat): count the live window in the session's own rows, so a subagent's burst keeps its roster
The live window kept the newest 1,024 rows of every agent. A subagent writing
more than that trimmed its own spawn's roster row and the prompt, and its
section fell back to a closed, unnamed header. The window now keeps the newest
1,024 of the session's own rows and everything after, with an 8,192-row cap on
every agent's rows as the memory backstop. A transcript with no subagent rows
trims exactly as before.
* fix(agent-session): window history pages by the session's own rows, with a subagent's rows riding along
A history page held the newest 200 rows of every agent, so a subagent's burst
could fill a page on its own: the phone opened on an empty chat and "Load
earlier" landed nothing. A page now starts at the oldest of the newest `limit`
rows of the session's own and serves every row from there, so the subagent's
rows come with the conversation they happened in. The page stays contiguous,
the cursor still names its first row, and the byte bound still applies. A
transcript with no subagent rows gets the same pages as before.
Clients already take a page larger than its limit: both reducers raise their
retained window to the page's size. The mobile read-on and read-back stay for
older hosts.
* test(agent-session): a page reaches back to the start rather than leaving a subagent-only page
* fix(native-chat): an own-row trim takes a trimmed roster's subagent rows with it
The live window trimmed to just after the own row it dropped, so a subagent
whose roster row went kept its rows at the top as an unnamed section until
the parent wrote again. Trim to the oldest own row kept instead; it still
fires only once an own row passes the limit, so a paged-in run of subagent
rows at the head stays until then. With no subagent rows nothing changes.
* perf(native-chat): cap the live window at 4,096 rows, bounding each delta's re-derivation
Every live batch re-derives the transcript over every retained row. On the
largest real window (7,374 rows) that cost 7-8 ms a delta on desktop against
0.6 ms at the old 1,024-row window, and held about 26 MB of row content.
4,096 halves both. The most rows any local journal puts between a roster and
its subagent's last row, with the parent inside its own-row limit, is 3,005,
so no observed subagent loses its roster to the lower cap.
* fix(native-chat): a subagent section opens only while its roster is the running scope's live frontier
A section used to open whenever its roster said the subagent was working, anywhere
in the transcript and whether or not the session was running, so a background
subagent's section stayed open and grew mid-transcript while the parent moved on.
It now opens by default only while the session runs and the roster row naming the
subagent is the newest thing the parent produced, user rows aside. Newer parent
output closes it even while the subagent still works; the roster row keeps
showing that live state. A subagent still working is a running scope of its own
for the sections it spawned; a settled one closes its scope. Derived every
render, no latch; the reader's own open or close still wins.
* fix(native-chat): name a subagent's section from a client roster the window never trims
A section took its name and state from a roster row in the loaded window. Once a
burst trimmed that row, or the row sat on an older page, the section fell back to
an unnamed, closed "Subagent" header.
The shared reducer now keeps a roster keyed by agent id, folded from every roster
row and revision the client receives: pages, older pages and live batches,
including revisions of roster rows outside the window, which live batches already
carry. The first roster naming an agent wins and its revisions update it; a
removed roster row drops its entries; it is rebuilt on every page that replaces
the window and bounded to 512 agents. Sections take their name, state and
live-frontier place from it; placement stays under the loaded roster row, else
at the section's first loaded row. Only a subagent no roster ever named stays
unnamed.
* feat(agent-session): a history page names the subagents whose roster row is older than it
A page is a contiguous run of the journal whose older-page cursor is its first
item, so it cannot pull an older roster row in without skipping the rows between.
When a page held a subagent's rows but not the roster row naming it (about 11% of
the moments a reader could open a session on local journals), that subagent drew
as an unnamed "Subagent" header.
History and hydration pages now carry an optional `subagentRoster`: the first
roster entry naming each subagent whose rows are on the page and whose roster row
is not, with the row's id, sequence and revision; bounded to 64 entries and
16 KB. Items and cursor are unchanged. The client seeds its roster from it.
Rule 1 in docs/reference/remote-wire-compatibility.md: an optional field on an
existing frame, no capability gate. An older client ignores it (the released
reducer reads a page with it exactly as one without); against an older host the
field is absent and the section falls back to an unnamed header.
* Revert "fix(native-chat): an own-row trim takes a trimmed roster's subagent rows with it"
This reverts commit
|
||
|
|
75040eba5a |
test: open, seed and read the agent-session record store through one test harness (#23986)
* test: open, seed and read the agent-session record store through one harness Tests that open the durable agent-session record store, seed it, or read back what it persisted now go through agent-session-record-store-test-harness.ts instead of calling AgentSessionRecordStore.open or touching agent-sessions.json themselves. A later change that moves the store into the chat database then changes the harness instead of every test. No production code changes. Tests whose subject is the JSON file itself (its .bak recovery, salvage, schema versions, permissions, and what older builds read back) keep reading and writing the file directly; the storage move rewrites or deletes them. * test: address the record-store harness by the host's state directory The harness took the store's own folder, so each caller picked one (join(root, 'store'), or 'agent-sessions' where a test read the store the runtime owns). A later change that moves the store into the state directory's journal database could not tell those apart, and would have had to edit every caller again. Every harness function now takes the state directory, the one the test's journal database and recovery capsule already live in, and keeps the store in the same subfolder the runtime uses. Callers pass that directory; store-only tests pass their temp directory unchanged. Format tests that share a directory with harness calls take the file path from testAgentSessionStoreFilePath. The folder name moves from a private constant in the runtime to AGENT_SESSION_STORE_DIR_NAME beside the store's file name, so the harness shares it without importing the runtime. Its value and every path built from it are unchanged. |
||
|
|
b7209b5ae9 |
perf(git): relist only the repo whose worktrees changed, and stop blocking main on sync git (#23998)
* perf(git): stop blocking main on the open-on-remote git cascade
`getRemoteFileUrl` ran up to 6 sequential `gitExecFileSync` calls on the Electron
main thread — `remote get-url`, then `getDefaultBaseRef`'s `symbolic-ref` plus up
to four `rev-parse --verify` probes — each with its own 15s timeout and no yield
between them.
A complete async twin already existed (`getDefaultBaseRefAsync` ->
`resolveDefaultBaseRefViaExec`, sharing DEFAULT_BASE_REF_PROBES), so the sync
cascade is deleted rather than converted. `getRemoteUrl`, `getRemoteFileUrl` and
`getRemoteCommitUrl` become async; all four downstream callers were already async
(`filesystem-git-url-handlers` inside `ipcMain.handle`, `runtime-git-diff-commands`
async methods) and the provider contract already typed both wrappers
`Promise<string | null>`, so no new async plumbing was needed.
Removes 3 of the 10 `gitExecFileSync` sites and the confusing name collision with
the unrelated async `getDefaultBaseRef` in hosted-review-creation-git-state.
The base-ref regression tests keep their coverage, repointed at the public async
`getBaseRefDefault`.
* perf(git): resolve the repo root in one sync spawn instead of two
getGitRepoRoot ran `rev-parse --is-inside-work-tree` and then `rev-parse
--show-toplevel` as separate blocking spawns. Each sync git call holds the main
thread for up to its whole 15s timeout, so the spawn count is the cost — and this
function is called twice per "Add Project" on a linked worktree, once directly and
once through getLinkedWorktreeMainRepoRoot's self-recursion.
Combined into one invocation. Safe only here: in a bare repo the combined form
exits non-zero, and both that throw and the plain `false` already land on the same
marker-scan fallback. probeGitRepo deliberately does NOT combine — it has to read
`false` cleanly to go on and detect a bare repo, which the combined form's exit 128
would misread as indeterminate.
* perf(git): rebuild only the repos whose authorized roots actually changed
One worktree create called `invalidateAuthorizedRootsCache()`, which dirties every
registered owner. The next authorization-requiring IPC then rebuilt by listing EVERY
repo — and the rebuild never consulted `dirty` when choosing what to list, so `dirty`
gated only whether a rebuild ran, not its scope. At 58 repos that is 58
`git worktree list` spawns, roughly ten seconds of git wall-clock through an
admission budget of four, to rediscover roots one repo changed.
Both halves were needed; scoping the invalidation alone changed nothing.
- `markAuthorizedRootsOwnerDirty` dirties a single owner, reusing the per-owner
primitives `registerWorktreeRootsForRepo` already used. It leaves `baseRevision`
and the per-repo revision map alone — that pair is the global side-effect-token
fence, and bumping it would retire in-flight tokens for untouched repos.
- `rebuildAuthorizedRootsCache(store, onlyDirty)` re-lists only owners that are
dirty, have no listing yet, or still hold recovered roots (those are retired by
comparison against a fresh listing, so skipping them would strand them as
authorized). Only `ensureAuthorizedRootsCache` passes `onlyDirty`; an explicit
rebuild keeps re-listing everything because callers use it to force a refresh —
`filesystem-auth.test.ts` pins that contract.
`invalidateAuthorizedRootsCacheForRepo` wraps the primitive and falls back to the
global form for an unknown owner or a missing store, rather than silently skipping an
invalidation and leaving a stale allowlist. Applied to the worktree-create path.
Changes that can alter the owner SET (store swap, host/WSL re-routing, nested-repo
import, folder->git upgrade) stay global. Removal paths are not converted yet.
The allowlist contents are unchanged and the failure direction is a false denial
rather than a false allow. The relist predicate is split into its own module so it is
testable alone and the cache file stays inside its line budget without a suppression.
* test(perf): measure what git orchestration actually costs the main thread
The existing churn probe (ORCA_MAIN_THREAD_DIAGNOSTICS=1) reported spawn-initiation
cost for git/gh/glab only — its 7 call sites all sit inside git/command-runner — so
it was blind to `spawnProcess`/`runProcess`, the repo's own mandated wrapper, and to
the blocking `execFileSync('ps')` per PTY resize. That understated total churn across
115 main call sites.
- `spawn-observer.ts`: a settable seam, since shared code cannot import src/main.
Unregistered in the daemon/relay/CLI, where it costs one boolean check.
- `spawnProcess` brackets `nodeSpawn` and reports; exec-file-capture's own report is
removed because it routes through runProcess and would double-count.
- `posix-pty-foreground-group` now reports its full blocking duration. Note this
lands on the daemon, not main, whenever the daemon hosts the PTY.
- `ORCA_UNMINIFIED_MAIN=1` build flag, because a minified main bundle cannot
attribute CPU-profile self time to real function names. Defaults unchanged.
- `main-thread-git-cost.spec.ts` + `analyze-main-cpuprofile.mjs`: sweeps concurrency
against real registered repos, captures the churn lines and a V8 CPU profile of
main per phase.
What it found, which is why this is worth keeping: at the width-4 admission ceiling
(~90 git:status/s) main sees ZERO event-loop gaps over 50ms and a worst gap of 23ms,
and is 85% idle. Git orchestration does not stall the main thread. Of the cost it
does incur, spawn-init is 58%, parse 5%, stdout drain 4%.
* test(perf): name the inspector params type the anti-slop gate requires
The broad `object` parameter trips anti-slop(no-object-parameters); the only
Profiler call that passes params sends `{ interval }`.
|
||
|
|
fb67d5d7c3 |
fix(runtime): stop a busy Codex 0.150-0.157 pane reading as tui-idle (#23805)
* fix(runtime): stop a busy Codex 0.150-0.157 pane reading as tui-idle The startup header box (OpenAI Codex / model: / directory:) stays on screen and in the tail for the whole session, so as tier-1 evidence it settled tui-idle mid-turn. For a codex pane it now counts only in the quiet lane, held to the same quiescence as the composer. * fix(runtime): keep a restored Codex pane's header as tier-1 readiness A restored or reattached pane has no lastOutputAt, so the quiet lane that now holds a Codex header can never fire and the wait sat pending until timeout, where main settled it. Gate the Codex tier-1 veto on the output clock rather than the agent name, and share one settled-prompt helper. * test(runtime): read no screen in the restored Codex pane test * test(runtime): pin the clock in the clockless Codex header cases * test(runtime): name the screen-readiness comparison for what it proves |
||
|
|
59ef74876f |
fix(terminal): the terminal's owner answers colour queries for the terminal's whole life (#23925)
* fix(terminal): the PTY owner answers OSC 10/11 for the terminal's whole life Codex and Claude's `theme: auto` ask the terminal for its foreground and background colours (OSC 10/11) and pick their colours from the reply. Orca answered in the process that owns the PTY only for agent launches and only for 5 s; after that the query was handed to whichever viewer was attached. On Windows ConPTY the owner kept swallowing the query but stopped answering it, so a Codex started from an older shell tab lost its message shading (#22332). On a headless `orca serve` host no viewer existed yet, so a Codex started before anyone attached got no reply at all (#22500). The owner (in-process provider, terminal daemon, SSH relay) now answers every OSC 10/11 query for the PTY's whole life and strips it, so no downstream view ever sees one to answer twice. It answers from, in order: the host-wide viewer theme pushed to that process, the creating viewer's colours sent at spawn (now for every PTY, not only agents), and Orca's default dark theme. The desktop pushes its renderer theme to every owner on change and on (re)connect: a daemon request gated on protocol v38, and an SSH relay notification that older relays ignore. The answer-once rule, the 5 s colour window and the colour-authority handoff are removed; Kitty keyboard queries keep their startup window. Viewer-side answerers (renderer xterm, main's hidden-pane model responder, the mobile webview) stay as the fallback for older owners, which still hand queries off; they are never reached for a new owner. * fix(terminal): answer OSC 10/11 with the colours the pane is really painted with Review follow-ups to the lifetime PTY-owner colour answerer. - The theme catalog moves to src/shared so the renderer and the PTY owners read one source; the owner's last-resort default is derived from it rather than copied. - Main seeds every owner from the host's saved theme settings (light or dark, custom themes, colour overrides) at startup, so a headless host and a desktop pane that queries before the renderer's first push are not told dark to a light-theme user. The renderer's push replaces it. - Colours an app sets with OSC 10/11, and clears with OSC 110/111, are tracked per terminal and reported back, as a viewer paints them; a theme change drops them, as a viewer's theme apply does. - A terminal a paired client created with its own colours answers with those, not the host's theme (`colorSource: 'remote-viewer'` on the spawn intent), so a light client on a dark host is told light. - After the 5 s startup window a reply's echo is watched for 512 bytes instead of 256 KB, and a torn query candidate is released after 500 ms rather than held indefinitely. * fix(terminal): keep the long echo watch for relayed replies; one theme lookup The 512-byte post-startup echo watch now applies only to replies the PTY owner produced itself. A viewer's reply relayed through answerLiveQueryReply keeps the 256 KB watch, because a cooked-mode app can keep printing after it queries and the echo then trails that output. The renderer's getTerminalTheme now calls the shared lookupTerminalTheme, so the custom-vs-built-in theme lookup exists once. * perf(terminal): scan colour overrides in one pass over each PTY chunk Two indexOf searches per OSC went quadratic on long runs of ST-terminated hyperlinks, and the tracker now sees every chunk of every terminal. * fix(terminal): one host viewer colour value, set by whichever viewer acted last A paired client's colours reached the host only as frozen spawn colours on terminal.create, tagged remote-viewer. UI-started agent sessions on a headless host answered OSC 10/11 with the host's saved theme, and a client's theme flip never reached panes it had created. The host now holds one viewer colour value that every PTY owner answers with. The desktop renderer's push, a window focus on the host, the new terminal.setViewerColors RPC, and terminal.create colours from older clients all set it; equal values do not re-notify daemons or relays. The remote-viewer tag (colorSource / terminalColorQuerySource / spawnFromRemoteViewer) is gone; it never shipped in a release. * fix(terminal): paired clients push their terminal colours on connect, change and focus The renderer publisher now hands each published fg/bg to subscribers. A new remote-runtime-terminal-color-push module calls terminal.setViewerColors on every host this client is connected to when it connects (or the host restarts), when the colours change, and when the window gains focus. A host that answers method_not_found or forbidden is not asked again until it reconnects. The app shell also republishes terminal view attributes on settings and system theme changes, so a theme change reaches main and paired hosts with no terminal pane open. * fix(terminal): a host with its own window answers OSC 10/11 with its own theme Round 1 kept one host-wide viewer colour value set by whichever viewer acted last, so a paired client's push (reconnect after sleep, a dusk theme flip) took over the host desktop's own panes until its window regained focus. The value is now derived: this host's renderer colours when a local window has pushed, otherwise the last paired client's push (terminal.setViewerColors or terminal.create colours), otherwise the saved theme. Only a headless host takes a client's theme. The window-focus reassert and the identical-re-push takeover are gone; owners are notified only when the derived value changes. * perf(terminal): scan only OSC starts for colour queries once the Kitty window closes The PTY owner answers OSC 10/11 for the terminal's whole life, and it tried every ESC as a query start: a 240 KB SGR-heavy read cost about 2 ms and 256 KB of bare ESC about 15 ms, long after startup. Once the Kitty query window closes only an OSC colour query can match, so the scan jumps between ESC ] starts, plus a trailing lone ESC so a query torn right after its ESC still resolves on the next read. Output and replies are unchanged. |
||
|
|
1aa943798a |
fix(codex): a Codex Stop is the interrupt alone, so it no longer says "Cancellation was not confirmed" (#23850)
* fix(native-chat): a Codex Stop that ended its turn no longer reads "not confirmed" Codex answers a turn's interrupt only as that turn ends, then sends the turn's own end. Orca also required its sweep of the turn's processes to prove them gone, and when that sweep could not read the process table it wrote "Cancellation was not confirmed." a few milliseconds before the turn's interrupted end arrived. A Stop that named its turn said "The provider had already finished this turn." in the same case. Codex's answer is now what confirms the Stop. The sweep still runs and still holds the turn's end until it finishes, but its result is logged, not shown. A Stop Codex never answers, which is a turn that never ends, still reads "Cancellation was not confirmed." * docs(codex): say why an interrupted turn's un-echoed send is withdrawn, for a steer and for a turn's own input * fix(codex): a Codex Stop is the interrupt alone A Codex Stop sent turn/interrupt and, alongside it, swept the turn's processes: it compared a snapshot of the app-server's descendants taken at each send and compaction against a fresh one and killed what was new, then waited for those processes to exit. The turn's end was held back until the sweep finished. Codex already owns this. It kills a turn's one-shot commands on interrupt and deliberately keeps unified-exec background terminals running across one, so the sweep killed work Codex means to keep, read the process table on every send, and delayed the interrupted end behind a kill-and-wait. A Stop is now turn/interrupt only. Codex's answer confirms it and the turn's end publishes the moment it arrives, through the same path as any other notification. A long-running command started in that turn keeps running until the thread ends, as it does in Codex itself. Removed: the turn-process module and its integration test, the snapshot taken at dispatch and compaction, the held turn/completed, the adapter dependencies that injected both, and the prompt-claim re-check that only covered the wait for the snapshot. * docs(crash-reporting): justify the self-kill ring size by the documented window-close burst |
||
|
|
7980ab9942 |
test: remove mock echoes and duplicate contracts that only reading finds (#23953)
* test: remove mock echoes and duplicate contracts that only reading finds
Two veins in one wave, both requiring the production path to be read rather than
pattern-matched.
Mock echoes (36 strongest candidates reviewed, 2 real): the flagged shape —
literals shared between a mock factory and an expect matcher — is almost always
a test feeding an input and asserting a transform. The two genuine echoes are in
`pty-management.test.ts`, where the handler returns
`getDaemonFolderAccessMismatch(identity)` verbatim, so asserting the mock's own
`evidence('allowed')` object and its `null` proved only the mock. One case's own
comment conceded the handler makes no decision. The branch-exercising cases in
that file stay.
Semantic sweep of 80 files no detector flagged, 27 junk cases removed. What it
found has no mechanical signature:
- a handler that is literally `() => getComputerUsePermissionStatus()`, so
`resolves.toBe(result)` guarded nothing;
- "does not mutate a stale registration off Linux" whose refusal came from a
DIFFERENT guard — `cli.ts` has no platform check, and the test's own mock made
`resolveAppImageRuntimeIdentity` return null, which a sibling case already owns;
- "keeps probing a host that is still retained" asserting a no-op, because
`retainScopes` only cancels queued probes and stores no state;
- two cases comparing against `referenceAllowedRoots`, a verbatim copy of the
pre-change algorithm kept in the test file — expected values produced by the
thing under test. The third such case stays: it calls the old algorithm to
COUNT its work (100_000 containment checks vs 100), a real bound on the
authorization hot path;
- a remote-folder refusal that came from the fake provider's own rejection
message rather than any Orca guard;
- "advertises each capability once", where a duplicate entry is inert in
production because membership is `includes`;
- an Antigravity `scaffold self-check` built on a hand-typed five-line screen —
the exact fixture shape docs/reference/antigravity-readiness-evidence.md blames
for five failed detector attempts — whose banner had already drifted to
`Antigravity CLI 1.0.3` against a real captured `1.2.0`. The raw-capture
provenance guard and the transcript checklist ratchet in that file stay.
No production file is touched and no test file is deleted.
* test: retire duplicate daemon and filesystem cases the sweep found
Continues the semantic sweep into src/main/ipc filesystem handlers and
src/main/daemon. 16 cases removed across 13 files; no production file touched.
The recurring shape is a case that reaches the same branch as its sibling by a
different-looking route:
- `parseArgs` is a flag-scanning loop, so "handles flags in any order" asserts the
identical result object as the in-order case, and "throws with no args" lands on
the same `Usage:` throw the two missing-flag cases already reach;
- an ENOENT case with "no code at all" and its sibling with a numeric transport
code both fall through to the same `ENOENT_MESSAGE.test` branch;
- "establishes connection with hello handshake" and "receives stream events" are
strict subsets of cases that require two matching authenticated sockets and a
frame split inside a multibyte character.
Two were vacuous rather than duplicated: a fixture self-comparison asserting
`String.normalize` gives different NFC and NFD spellings, and a
`not.toThrow` batch case whose 200k events are truncated by
`MAX_BATCHED_WATCHER_EVENTS = 5_000` long before the argument spread it was
written to exercise.
One whole-file deletion was reversed: `freebuff-detection.test.ts` looked like a
table restatement, but the sibling detection test covers only dependency ordering
and platform gating, not freebuff/codebuff independence — and those two names
share a suffix, so it is the only guard against one shadowing the other.
|
||
|
|
fed1eca486 |
test: stop restating internal tuning constants, keep the ones that are contracts (#23950)
Removes ~74 assertions of the form `expect(SOME_CONSTANT).toBe(<literal>)` where the literal is an internal tuning value — a timeout, retry count, debounce interval, cache TTL, circuit-breaker window, Tailwind class string. Those cannot fail for any reason a user would notice: they fail only when someone deliberately changes the number, and then the test is simply updated. They are copies of the declaration. The same pattern is NOT junk when the exact value is observable outside this process, so those were deliberately kept: - terminal byte contracts: `\r`, `\x03` ETX, Kitty escapes, `\x1b[?1;2c`; - wire and capability values: `agent.launch.v2`, protocol 3 / min-compatible 2, daemon per-feature boundary versions (a daemon survives app updates, so those pin what an old field daemon may be trusted with), relay header tokens; - security invariants: the `127.0.0.1` bind default, an empty iframe `sandbox`; - values external processes read: exit code 78 (EX_CONFIG) and exit code 3 (systemd `RestartPreventExitStatus`), `ORCA_AGENT_SESSION_SPAWN_TOKEN`, `npx skills …` commands users paste, on-disk journal schema versions, the `orca_<hash>` filename prefix the fish sweeper matches; - third-party names: expo-router's `unstable_settings` / `ErrorBoundary`, iOS Safari's 16px zoom threshold. Where a case asserted a relation rather than a literal — `A < B`, a sum of parts, a cap compared against a sibling budget — the relation stays and only the literal went. Test-only changes: no production file is touched and no test file is deleted. |
||
|
|
59b746ff3c |
feat(native-chat): one structured-chat journal database per host, owned by one process (#23613)
* feat(native-chat): one structured-chat journal database per host, owned by one process
Every structured chat on a state directory now lives in one SQLite file,
agent-session-journal.db, opened once by the process holding
agent-session-journal.owner: an empty SQLite file whose held BEGIN EXCLUSIVE is a
kernel byte-range lock, refused while another process holds it and released when
the holder dies.
- Stores own no connection: the per-chat handle, its close contract and the
close-retry registry are gone; closing a conversation drains its writes, and the
one connection closes last at teardown.
- The owner lock is taken at runtime start, before orca-runtime.json is written;
a process that does not own the chats is not published and refuses every
structured request with journalUnavailable and words that say what to do. It
retries the lock with backoff and runs the full install once it holds it.
- A journal that will not open fails the host install: every chat says "Unable
to load this chat." (journalCorrupt), and nothing is renamed, deleted or
rebuilt. A newer build's database is refused and left byte-identical.
- An append is one INSERT. The listing status is a column, written after the
rows it describes and keyed by (epoch, sequence).
- A per-chat journal from an earlier build is copied in verbatim (epoch UUID and
every sequence) on that chat's first open, and its directory is retired only
after the copy commits.
- auto_vacuum = INCREMENTAL, with freed pages handed back in bounded steps after
every delete.
* perf(native-chat): key journal rows by block so one chat's rows sit together
Each chat's live epoch owns a block of row ids, block * 2^32 + seq, so a chat's
rows share leaf pages with nobody else's, a replay is one range scan, and
replacing or rewinding a chat deletes one contiguous range. Measured on the
largest real chat (61 MB) beside 19 interleaved peers: 39 ms and 7.5 MB of WAL,
against 214 ms and 102 MB for a (session_id, epoch, seq) key.
- Ids are computed in Number arithmetic, never bitwise. A sequence is refused
outside [1, 2^32) and a block at 2^21, which keeps every id below 2^53.
- A replace, roll or import allocates a fresh block, moves the chat's pointer,
and deletes the old block in the same transaction, so no orphan block exists.
- The listing status write moves into its own writer beside the column.
* feat(native-chat): copy a chat's per-chat journal again when an older Orca wrote it after a downgrade
A per-chat journal.db that reappears after its chat was copied in is the newer
history: an older build, run after a downgrade, attached the chat and wrote it.
- journal_imports records the (epoch, tip) each chat was copied from, in the
copy's own transaction. A file already copied is never copied again, across
any number of restarts after a failed rename; a file that differs always is.
- Newest writer wins, per chat, with a row saying the chat was continued in an
older version of Orca. When both builds wrote past the recorded tip under one
epoch, the copy takes a fresh epoch, so readers reset instead of skipping rows.
- Each copied directory retires to its own .imported-<epoch8>-<ms> name, so a
second downgrade and re-upgrade never collides with the first.
* test(native-chat): fixture deps match the host journal database shape
Attach-flow and reconcile-attach fixtures stop passing a journal database those inputs do not take, and host and restore fixtures pass the one they now require instead of the removed journal root.
* test(native-chat): state why the runtime-state fixtures' existing casts are safe
* fix(native-chat): start up normally when this process cannot open the chat journal
A process refused the chat journal, because another Orca owns it or because its own journal will not open, failed startup restoration: the window booted in degraded no-save mode and a paired phone could not list any tabs. Startup restoration now treats the refusal structured requests are getting as having no structured host; terminals, tabs and saving go on, structured requests are still refused by the gate, and the install is retried on the next one. Any other install error fails startup as before.
* test(native-chat): name the owner-lock sweep test after the two sweeps it runs
* fix(native-chat): session history and terminal resume work while chats are refused
Session history (listing and preparing a resume) and a terminal typing a resume command only check whether a structured chat owns a provider session. In a process refused the chat journal they failed outright. They now take the refusal chats are getting as having no structured host, the same treatment startup restoration gets, through one shared helper; any other install failure still fails them. Chat requests keep the gate's refusal.
* test(native-chat): the first-work rename's fake journal saves the listing status
* fix(native-chat): open a chat whose per-chat journal file never got its schema
A crash between creating a chat's journal.db and creating its tables left an empty or schema-less file. Each chat used to open that file as an empty chat; the importer instead refused the open as "try again" forever. A file with no journal_sessions table is now read as never written, the same as one with no rows. A file that is not a database, or whose read fails, is still refused.
* fix(native-chat): let the event loop run between chats during startup restore
Opening a chat's journal is synchronous SQLite now that no per-chat directory
is created first, so the restore of every visible chat ran as one main-thread
task. Each chat now waits for a macrotask before it opens.
* fix(native-chat): import a per-chat journal in bounded batches
The one-time copy of an earlier build's per-chat journal ran as one
transaction, which blocked the main thread for 650 ms on the largest chat.
Rows now copy 512 at a time, each batch its own transaction, yielding to the
event loop between batches. The rows go into a block journal_import_blocks
reserves, which no reader follows and no other chat is allocated; the last
batch publishes the chat's pointer, repair marker and import marker together
and releases the reservation. A copy that stops midway leaves only that
block, which the next open clears and copies again. Two opens of one chat
import one after the other.
* fix(native-chat): refuse chats when the owner lock file cannot be opened
A lock file that is not a database, or cannot be opened, made the claim throw
before any refusal was recorded, so startup restoration failed on every
launch. The claim now sits in the same try as the database open and records
the same typed refusal.
* fix(native-chat): finish reclaiming pages a delete frees during a running pass
A reclaim pass ended as soon as the freelist stopped shrinking between steps,
so a second delete that freed more than one step's worth mid-pass ended it
early and left those pages on the freelist. A pass now ends only when a step
itself frees nothing, or the freelist is empty.
* test(native-chat): desktop session history is served while chats are refused
* fix(native-chat): a send to a chat holding a newer Orca's rows says to update
A chat opened read-only because a newer Orca wrote rows to it answered a send
with the generic write failure. It now refuses the way a database a newer Orca
wrote does, with the same reason and words.
* fix(native-chat): keep chat tabs while this process cannot list its chats
A process whose chats another Orca owns, or whose chat journal will not
open, has no structured host. Its session-tabs inventory still answered,
with no chat rows, and the renderer read that as "every chat was closed":
it removed the restored chat tabs and the next session save persisted
their placement away.
The inventory now says `agentSessionsUnverifiable` when the last tab
restore ran with chats on disk but no host to list them. The flag is set
and cleared at the per-client projection point beside the client-hosted
page hold, and the restore is memoised only once a host answered, so a
later lock takeover or journal open republishes the chats and clears it.
The renderer keeps agent-session tabs, and keeps cancellation tombstones,
against an inventory that does not affirm its chat set.
* fix(native-chat): say chats are open in another Orca, with this process's way past it
A process refused because another Orca owns the profile's chats sent the
generic `journalUnavailable` reason, so current desktop and phone surfaces
said "couldn't open this chat's history right now. Try again." — a step
that never helps while the other Orca runs.
The refusal now names its own reason, `journalOwnedElsewhere`, with the
refused process's kind (dev desktop, packaged, orcad) as a fact. Each kind
gets its own step: quit the other Orca, or give this one its own profile
(ORCA_DEV_USER_DATA_PATH) or data folder (ORCA_USER_DATA). The sentences
are added to the shared notice copy, the desktop catalogs in all six
locales, and the boot catalog.
A client that predates the reason reads it as none and keeps the code's
words; an unknown kind reads as the packaged app's step. The `message`
released clients print is unchanged. Which requests refuse does not change.
* fix(native-chat): restore chats on taking ownership, without a list to ask
A refused startup kept its hostless result, so after the owner quit this
process never installed a host, never reconciled restart leases, and kept
telling clients it could not list its chats until a desktop chat request.
Taking the lock now reruns startup restoration once and pushes the chats.
* test(native-chat): a navigation reply says chats are unverifiable while refused
* fix(native-chat): retry a refused owner lock at most every 5 seconds
The lock frees as its holder exits, but a refused process only learns that
on its next retry, and the 30 s cap left a second Orca refusing chats for up
to half a minute after the owner quit. One retry is an open and BEGIN
EXCLUSIVE on an empty file.
* fix(native-chat): install before deciding whether a takeover must republish chats
A list that landed on the refused startup after the lock was taken finished
after the takeover had already checked, so nothing republished. The takeover
now installs first, waits for any restore in flight, and restores only then;
the restore that clears "cannot tell" pushes the frames itself, so a list
that heals the inventory first reaches subscribers too.
* fix(native-chat): no takeover lands a host after the runtime stop
Quitting cancelled a refused claim's retry only at its end, so a retry firing
during the stop's awaits took the lock and installed a host the stop never
tore down, and the lock was then released under an open journal. The stop
now cancels the retry first, keeping the refusal, and repeats its teardown
while an install that began during it (a takeover already under way) is
pending, so no journal connection outlives the lock.
* fix(native-chat): show a thrown refusal in its own words, not its code
A refusal the host throws reaches the client as an RPC error whose message is
the bare code; its reason and facts ride only in the error's data, which no
client read. The chat pane's status line therefore printed
agent_session_journal_unreadable, a send took the bare "not sent" path, and
other writes said the outcome was unconfirmed.
One shared reader, agentSessionThrownRefusal, now reads the refusal from the
error data. A failed history read shows the refusal's read-history words, a
send keeps the refusal behind its Retry exactly as a returned refusal does, and
the other writes (desktop and phone) name the refusal instead of doubting the
outcome. The phone's read failure goes through the same reader.
* fix(native-chat): log a failed journal open once per distinct failure
Every chat request retries a journal open that failed, which is intended, but
each retry also logged the failure with its full stack: a junk database file
logged the same "file is not a database" error 189 times in a minute. The open
now logs a failure only when its code and message differ from the last one
logged, and forgets it once an open succeeds. The retry is unchanged.
The open moves to its own module beside the runtime, which had no room left.
* fix(native-chat): restore lists a chat from its per-chat file and copies it on first use
Startup restore opened every restored chat, and that open copied the chat's
per-chat file into the host database, so the first boot after an upgrade paid
the whole one-time copy before the chat list appeared.
A restore open now reads a chat that is still in its per-chat file straight
from that file, read-only, with the importer's own reader, and closes the file
before moving on. That read drives the listing, the status row and the
restart offer, as it did when every chat had its own file. The copy becomes
owed work on the chat's write queue: it runs before the chat's first write,
and a reader that reaches the chat awaits it. A chat the host already holds,
or that was copied before, still opens through the import and its reimport
rules, and so does a file whose read needs a repair written.
* fix(native-chat): no host stays registered after a stop an install spanned
Each teardown pass clears the registered host before it awaits an install in
flight, and that install registers its host when it finishes. The pass then
tore the host down but left it registered, so a request after the stop was
served by a host whose journal was closed. The stop now clears the slot once
its passes are done.
* fix(native-chat): checkpoint the journal with a full flush on macOS
synchronous = FULL fsyncs each commit, but macOS fsync leaves the drive cache
unflushed, so FULL alone does not survive a power loss there. With
checkpoint_fullfsync, each checkpoint uses F_FULLFSYNC; elsewhere it is a no-op.
The comment that said FULL alone was enough is corrected.
* fix(native-chat): delete a per-chat journal once its copy verifies
An imported chat's per-chat file was kept under an `.imported-*` name, which
doubled the disk its history takes. The copy now reads back from the host
database before it is published: its items, submissions, epoch and tip must
match the file's. Only then does one transaction publish the chat with its
import marker, and the file and its WAL files are deleted, the directory too
when nothing else is in it (a pre-SQLite transcript there is kept).
A copy that does not match is never published: the file stays, the chat is
refused as unreadable ("Unable to load this chat."), and the mismatch is logged
once. A file left behind by a failed delete or a crash matches the marker, so
the next open deletes it rather than copying it again; a file an older build
wrote after a downgrade still differs, and is still copied again.
* fix(native-chat): verify an imported chat a batch at a time
The check that a copied chat reads back as its per-chat file folded both whole,
each in one synchronous task: over half a second on the largest chat. Both
reads now go a batch at a time between turns of the event loop, like the copy
itself, and count rows as well, so a copy that lost a row with no item in it
is caught too.
* fix(native-chat): restore reads a chat's per-chat file a batch at a time
Restore folded a chat still in its per-chat file in one task, so the largest
chat's file held the main thread for about half a second at startup. The fold
now takes the file a batch of rows per turn of the event loop, into the same
fold a replay uses, and nothing reads it before it is done. The file is still
closed before restore moves on.
* fix(native-chat): end a per-chat copy on a turn of its own
A chat's first open ran the copy's last steps (the verified publish and the
per-chat file delete) and the replay of what was copied in one task. The copy
now yields before it returns, so the replay, which every open runs, is a task
of its own.
* fix(native-chat): commit a per-chat copy's batches without an fsync each
Each 512-row batch of a chat's first-use copy committed under synchronous =
FULL, so a large chat paid one fsync per batch, about a quarter of its first
open. The batches now commit under NORMAL, set and restored in the batch's own
task so no other chat's commit runs under it. The publish that makes the copy
visible still commits under FULL, and under WAL that sync makes every earlier
batch durable with it. A crash before it leaves only the unpublished block,
which the next open clears and copies again.
* fix(native-chat): roll back a chat journal transaction whose COMMIT fails
The shared connection's transaction rolled back only when its body threw. A
COMMIT that failed left the transaction open, so every later write, for any
chat, failed with "cannot start a transaction within a transaction", and reads
saw rows that never committed. Under the unsynced copy the failure also tried
to restore the sync level inside the open transaction, which SQLite refuses,
so the caller got that error instead of the COMMIT's.
One transaction helper now covers the body and the COMMIT, rolls back whatever
transaction survives, and rethrows the original error. Schema creation uses it
too. If that ROLLBACK fails as well, the connection is marked stranded: each
later use retries the ROLLBACK, and until one goes through every chat gets the
same "history unavailable, try again" refusal a journal that will not open
gives. The rollback that frees it also restores the FULL sync level.
* fix(native-chat): keep the chat journal connection until its close succeeds
Closing the journal dropped its connection handle before closing it. A close
that failed left the database reporting itself closed with the connection still
open, so the stop that retried the teardown found nothing to close and released
the owner lock over a live connection.
The handle is now dropped only once the close succeeds. A failed close keeps
the runtime pending and the lock held, and the next stop closes that same
connection before it releases the lock.
* fix(native-chat): publish the runtime only once it holds the chat journal lock
When this process could not open the owner lock file at all (a permission
error, or a file that is not a database), the runtime counted that as owning
the chats and wrote orca-runtime.json. That overwrote the real owner's entry,
so the CLI was sent to a process that cannot serve its chats.
A claim that throws is now refused like one another process holds: the runtime
starts but does not publish, the claim's existing retry keeps asking for the
lock, and discovery publishes once the retry takes it. Chats still get the
refusal for the failure itself, and startup restoration reruns on the takeover
the same way it does after another owner quits. A sole process whose lock file
never opens is not found by the CLI until it does.
* fix(native-chat): keep a chat's history when an older build started it over
The first copy deletes a chat's per-chat file, so an older build run after a
downgrade finds no file and starts the chat from nothing. On the re-upgrade that
fresh file was copied in as the newer history, replacing everything the shared
database held for the chat, and then deleted.
A file whose epoch is not the one last copied and that opens with
`session_created` is now kept: neither copied nor deleted, and the chat keeps
the history it has. A file that carried the copied epoch on is still copied
again, as before.
* test(native-chat): pin which chats startup restore copies
Restore copies a chat still in its per-chat file only when restore itself has
to write to it: settling what the last run left open, here a running tool call
or a send handed over and never answered. Every other restored chat stays in
its file until its first use.
* test(native-chat): pin the copy wait on a read that opens a chat restore opened
A read queued behind restore's open of the same chat reaches the conversation
through its own open rather than the listing. It must still wait for the
owed copy, or it reads the chat before its history is in the one database.
* fix(native-chat): record a set-aside per-chat file so no later open reads it
Setting aside a file an older build started over is decided once and kept in
the new `journal_set_aside` table (schema 2, additive), with the file's epoch
and tip as they were. Every later open of the chat skips the file without
opening it, across restarts and after the older build writes more to it:
anything written there grows from that build's own start, never from this
build's history.
The best-effort delete moves beside the per-chat file reader.
* fix(native-chat): set aside any per-chat file at an epoch this build never copied
A chat's per-chat file is deleted once its copy verifies, so a file that
reappears at another epoch was never this build's history, whatever its first
row says: an older build started the chat over, possibly rewinding it after
(`handle_forked`), or rolled the epoch of a file whose delete had failed.
Copying any of them would replace everything the chat holds, so each is set
aside. Only a file still at the copied epoch is copied again (it grew) or
deleted (it did not). The first-row check is gone.
* fix(native-chat): copy a reappearing per-chat file again only while this build has not written past the copy
A per-chat file that an older build carried on under the copied epoch was
copied again even when this build had also written to the chat since the
copy, or had rolled its epoch. The second copy replaced the chat's block,
so what was sent in this build after the copy was gone for good.
Now the file is copied again only when the chat still stands exactly as it
was copied: the same epoch and tip the import marker recorded. Otherwise it
is set aside like any other file that is not this build's history, left on
disk untouched and recorded so no later open reads it. A second copy
therefore never replaces rows this build wrote, keeps the file's own epoch,
and the fresh-epoch rewrite goes away. The row it adds now says the history
includes what the older version recorded, not that anything was replaced.
* test(native-chat): pin that a chat founded here keeps its history, and the v1 schema upgrade
A chat this build founded has a pointer and no import marker, so a per-chat
file an older build later starts for it is set aside. Nothing pinned that
half of the rule: letting such a chat be copied again replaced its history
and every test still passed. A second test pins that a database written at
schema version 1 upgrades in place, gaining the set-aside table and keeping
its import markers.
* test(native-chat): drop a lost copied row by patching the source, not wrapping it
* chore(mobile): restore the mobile lockfile to main's
* fix(native-chat): pass a classified journal refusal through a send or Stop unchanged
* fix(native-chat): refuse a read whose owed copy fails as a failed open does
* test(native-chat): measure only the replace's WAL in the block-key case
Opening the chats starts a free-page pass that waits one event-loop turn,
and the seed never yields one, so that pass was still pending when the
replace committed. It woke during the async stat and reclaimed the pages
the replace freed, adding ~500 KB of WAL whenever the stat lost the race
(Linux CI). Drain that pass before measuring and stub the replace's own.
* test(native-chat): the RPC fixture's status journal can save its listing status
The status feed now hands every projection to the journal, which decides whether it is worth saving.
* test(native-chat): state why the RPC fixture's status journal cast is safe
* fix(native-chat): refuse a per-chat copy whose rows differ from the file, not only its counts
* fix(bench): build the replay benchmark's baseline arm from the base tree and release its handles on failure
* fix(native-chat): retry a failed listing status save on the next read of a cached status
* refactor(native-chat): drop the chat journal owner lock; the process instance lock already guards the profile
The journal carried its own exclusive lock, with a retry loop, an in-process
takeover, lock-gated runtime discovery and a "chats are open in another Orca"
refusal. Every shipped process kind (packaged desktop, serve mode, orcad)
already refuses a second instance on one profile before the journal opens, so
the lock only ever mattered for dev desktops, which the next commit covers at
the process level instead.
The host now opens its one journal connection at install with no lock. What a
sole process whose journal will not open needs stays: the install refusal
recorded for the gate, the no-host startup path, and the unverifiable chat
inventory, now in structured-agent-session-host-refusal.ts. The unreleased
journalOwnedElsewhere reason, its processKind fact and their copy are removed.
* fix(startup): dev desktops take the single-instance lock, and a second one says why it quit
Dev skipped Electron's single-instance lock so parallel `pnpm dev` runs from
several worktrees would not quit silently, but two dev processes on the
default orca-dev profile then write the same stores at once. Dev now takes
the lock like packaged builds: a second launch on the same profile focuses
the first window and exits with code 3, printing one stderr line that names
the taken profile and how to run another copy (ORCA_DEV_USER_DATA_PATH).
Serve mode, the macOS diagnostic bypass and the E2E harness are unchanged:
an E2E launch still skips the lock unless it sets
ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK=1.
* refactor(native-chat): key journal rows by chat, epoch and sequence
Rows in the host's journal database are now addressed by the chat's own
identity, with `(session_id, epoch, seq)` as the primary key, the same
shape each per-chat file already used. The block-keyed layout goes with
everything built on it: the block column and its allocator, the 2^21
block ceiling, and the import's reserved block table.
A first-use copy writes its rows under the file's epoch, which the chat's
pointer does not name until the verified copy publishes it, so no reader
sees a half-copied chat. A try that stopped midway leaves only rows no
pointer names, and the next try deletes them before it copies again.
Replace, rollover and repair delete by (chat, epoch).
This build's history always wins: once a chat was copied or founded here,
any per-chat file that reappears is set aside, and the same-epoch copy
again after a downgrade is removed.
The bounded free-page reclaim after every delete is dropped;
`auto_vacuum = INCREMENTAL` stays at file creation, so a later periodic
reclaim can still be added. Session search keeps its own step.
The schema moves to version 3. Versions 1 and 2 were written only by
unreleased builds of this change and are refused as found, not migrated.
* fix(native-chat): open a chat journal a newer Orca wrote read-only instead of refusing it
After a downgrade, the host's journal database carries a newer user_version. It was refused
outright, so every chat's history disappeared. It now opens on a read-only connection, as the
per-chat journals did: each chat shows what this build can read, from the database or a per-chat
file never copied in, and every write is refused with "Chats were saved by a newer Orca. Update
Orca to keep using them." Nothing is written, copied, repaired or founded, and the file stays
byte-identical. A table the newer schema changed reads as the same read-only refusal, not damage.
* refactor(native-chat): leave the saved listing status to the change that reads it
Nothing in this change reads the per-chat listing status column: it was a stored copy of a fact
the status feed derives, written after every turn end and cleared on every epoch change. The
status_json / status_seq columns, their writer, the saved-status type, the status feed's save and
its retry on a cached projection all go, with their tests. The change that lists chats from a
saved status adds the column back beside its reader.
* fix(native-chat): a chat saved by a newer Orca says to update Orca, not to try again
When a newer Orca wrote the chat journal, this build opens it read-only. A send or a Stop was
refused with the reason `journalUnavailable`, so today's desktop and phone clients chose the
words for an open that can clear: "Orca couldn't open this chat's history right now. Try again."
Retrying never cleared it; only updating Orca does.
The refusal now names its own reason, `journalWrittenByNewerOrca`, whose words are "Chats were
saved by a newer Orca. Update Orca to keep using them." A read refused the same way names it
too. An older client does not know the reason, drops it, and falls back to the code's words
("Orca couldn't read this chat's saved history."), and released clients still print the message.
* fix(native-chat): a chat journal from an unreleased build reads as unusable, not as retryable
A chat journal database stamped with schema 1 or 2 was written only by unreleased development
builds of this change. Opening it threw a plain error, which every chat reported as "Orca couldn't
open this chat's history right now. Try again." Retrying never cleared it.
It now throws a named error that is classified as unusable, so every chat says "Unable to load
this chat." The one log line names the file, says an unreleased development build wrote it, and
says to move it aside. Nothing migrates or renames it.
* docs(native-chat): drop the second-Orca-owns-the-chats case from three comments
The chat-only owner lock is gone, so only a chat journal that will not open leaves a runtime
unable to list its chats.
* docs(native-chat): correct three chat-journal comments the redesign left behind
A per-chat file left without its WAL is set aside, not copied again; nothing runs an incremental
vacuum yet, so the auto_vacuum mode is kept for a later pass; and the idle sweep drops a chat's
in-memory fold, since a chat holds no journal connection.
* refactor(native-chat): stop exporting chat-journal names nothing imports
Each is used only inside its own module now; the teardown's export served a deleted test.
* test(native-chat): name the version-0 test for what it covers, and check every journal table
The test named 'migrates an older user_version forward' covers only a version-0 file that already
has its tables; versions 1 and 2 are refused. The table test now also checks journal_imports and
journal_set_aside.
* fix(startup): a second dev launch's exit line no longer claims it focused a window
The running dev instance may be a background launch or a server, which show no window. The line
now says only that this launch passed its request to that instance.
* fix(native-chat): a failed structured-chat install closes the journal connection it opened
The install opened the chat journal database and closed it only if the record store then failed
to open. A later failure, such as the model catalog wiring or the host constructor, left the
connection open, and the next install opened a second one in the same process. Every failure
after the open now closes it.
|
||
|
|
2a001b43e5 |
fix(mobile): stop a closing phone stream from ending the terminal or chat feed that replaced it (#22939)
* fix(mobile): don't unsubscribe a terminal stream that already ended * fix(mobile): keep the stream registry under the line cap and expect no error after a streamed end The streamed `end` now closes a direct stream, so a later reply for that id is unrouted instead of reaching the listener as an error; the subscription recording test expected the old error. Inline the error wrapper so the registry fits the 300-line cap, and shorten the comments. * fix(mobile): reopen a native chat stream the host ended while the screen still shows it * fix(mobile): only the focused screen takes back a host-ended chat feed, and a reopen keeps loaded history * fix(mobile): give each native chat subscription its own token, and reopen with the first page * test(mobile): pin the reopened chat subscribe params without a cast * fix(mobile): show a host-ended chat feed as an error instead of reopening it With a token per subscription, the desktop ends a phone chat feed only when the phone closes it or the socket drops: no client sends the connection-wide chat unsubscribe, a socket close delivers no end, and every desktop with mobile chat keys feeds by the client's token. The reopen-with-backoff layer and the history-keeping reopen merge guarded a trigger that no longer exists, so they are removed. An end that still reaches the screen settles the feed as an error, so a dead feed is never shown as live; leaving and re-entering the chat subscribes again with a fresh token. * test(mobile): re-record the RPC goldens for the per-subscription chat token Each nativeChat.subscribe now sends its own token, so the native chat scenarios expect `claude:session-1:<id>` in the subscribe params, and the four native chat recordings (native-chat-page-earlier and the session.native-chat-page matrix) record that token in their subscribe and unsubscribe payloads. Every other golden moved only its `baseline` header, repinned to the commit recorded from. * test(mobile): repin RPC recordings to the merge with main and re-record Merging main moved the product tree the recordings are pinned to, so the baseline is repinned to the merge commit and the whole corpus re-recorded. Only the four native chat recordings change content, because each nativeChat.subscribe now carries a per-subscription token; every other recording moves only its baseline header. * test(mobile): repin RPC recordings to the merge with main and re-record Merging main brought #22762's recordings, #23757's repin and #23720's recorder change, so the corpus is repinned to the merge commit, the last commit to touch a recorded path, and re-recorded whole. Against main, 786 recordings move only their baseline header and 4 native chat recordings change content: their nativeChat.subscribe and nativeChat.unsubscribe carry the per-subscription token instead of claude:session-1, which also moves their scenarioSha256. |
||
|
|
21d4ae9448 |
feat(agents): pre-trust the folder wherever Orca starts an agent (#23744)
* feat(claude): pre-trust worktrees Orca creates Claude Code asks "Do you trust this folder?" on first launch in any folder it has not seen, which blocks unattended launches in worktrees Orca itself made. Orca now records where a worktree's content came from when it creates it, and before each Claude launch writes Claude's own folder-trust entry for that worktree's root (never the main checkout) when the new setting is on and the content is the user's repository. Forks, bare commits, folder workspaces and external checkouts keep Claude's prompt. The write takes Claude's lock, never creates or breaks the file, runs on the SSH host itself, and is revoked when the worktree is removed or the setting is turned off. Launches that already pass --dangerously-skip-permissions also skip the trust prompt for that one process only, via CLAUDE_CODE_SANDBOXED=1 on the command. * fix(claude): parse the relay trust request with a schema and ship its search keys * fix(claude): never write a WSL guest's trust into the Windows config A WSL worktree's Claude reads the guest's own config. Two paths still wrote its trust into the Windows host's ~/.claude.json instead: the Claude auth prep's fallback (runtime 'wsl' but the host config dir, when the WSL home cannot be resolved), which wrote a Linux-path key the removal revoke can never delete; and the Agent Teams leader, which passes no auth or distro and wrote a UNC key. Require the guest's own config dir, and treat any WSL worktree path as guest-only. * revert(claude): drop the skip-permissions trust shortcut Pre-trust stays limited to worktrees Orca creates from the user's own repository. The per-launch CLAUDE_CODE_SANDBOXED prefix skipped Claude's trust question in every folder for launches carrying the skip-permissions flag (Orca's default Claude args), including the user's own folders and fork PR worktrees, and the setting could not turn it off. Remove the prefix, the inherited-variable strip that existed only for it, and the Agent Teams leader-to-teammate propagation; restore the tests that pinned the prefixed launch string. * fix(claude): revoke SSH trust in the config file the grant used At spawn the relay resolves Claude's config from the launch env, which carries a CLAUDE_CONFIG_DIR set in Orca's Claude default env. claudeTrust.converge had only the relay's own process env, so removing the worktree or turning the setting off revoked in the default file and the grant outlived the worktree. Send the config-file keys with the request, as the local revoke already uses. * i18n(settings): translate the Claude worktree trust setting * fix(settings): say Claude trust applies when Orca starts Claude The description said Claude skips its trust prompt in any worktree Orca created. Trust is written only when Orca itself starts Claude there, so a `claude` typed by hand in a fresh worktree still asks. Say that, and bring the es/fr/ja/ko/zh translations in line with the new text. * fix(worktrees): treat a base on an Orca-added fork remote as fork content A worktree based on a named ref was always stamped as the repository's own content, so picking the fork remote Orca adds for a pull request (or a local branch tracking it) as the base made a fork's code eligible for Claude trust. At create time, read the repo's `remote.<name>.orca-created` markers and each branch's tracked remote in one `git config` call; a base on such a remote is stamped as a fork's content, and a read failure is not vouched for. Remotes the user added, such as `upstream`, stay first-party. * perf(claude): revoke worktree trust once per config file, not per worktree Turning "Trust worktrees Orca creates for Claude" off read and parsed the whole Claude config once per Orca worktree on the main process. Group the revocations by config file locally and by SSH connection, and make claudeTrust.converge take a batch of requests. * feat(settings): one agent-wide "trust the folder" setting in Settings > Agents Replace the Claude-only worktree trust toggle with a single setting, agentWorkspaceTrustEnabled (on by default; unreleased, so no migration). The row says what it does for every agent: agents Orca starts skip their "trust this folder?" prompt in that worktree or folder, turning it off stops new trust while existing trust stays, and while it is off unattended launches (orchestration workers, automations, the phone) stop at the agent's trust question until someone answers. Translations for es/fr/ja/ko/zh. Also restores the `awaitingUnnamed` chat catalog keys an earlier merge of main dropped from this branch. * feat(agent-trust): pre-trust the workspace for every preset agent at PTY spawn Every Orca-started agent PTY passes through one of the two spawn builders with its declared launchAgent, which survives setup-script wrapping. The builders now call one hook that, for a fresh launch (never a reattach or restored pane) with the setting on, applies the agent's trust preset to the worktree, folder workspace or main checkout it starts in. - One dispatcher, applyAgentWorkspaceTrust(preset, workspacePath, launch context), carries what a writer needs: the final spawn env, the Claude managed-account auth prep, the WSL distro and the SSH connection. - Claude joins the presets on both the claude and claude-agent-teams entries. Its writer stays grant-only in claude-folder-trust-file.ts: the file Claude reads (CLAUDE_CONFIG_DIR / custom-OAuth suffix / legacy .config.json / a WSL guest's own file), Claude's <file>.lock never broken and taken only when a write is due, atomic temp+rename keeping mode and symlinks, never creating the file, NFC + realpath keys. - SSH Claude launches forward the optional claudeFolderTrust spawn field so the relay grants with its own spawn env; old relays ignore it and Claude asks. Other presets keep the SFTP writer. A WSL launch never writes the Windows home: non-Claude presets skip it, Claude writes the guest's file or nothing. - Codex keeps the 20 s deadline its shared config lane needs; every other preset gets 1.5 s. A miss means the agent asks; trust bookkeeping never fails or blocks a launch. Removes the Claude-only machinery this replaces: the eligibility/host/ lifecycle/spawn modules, the persisted creation content-origin field and its classification, revoke-on-removal, the setting-off sweep, the claudeTrust.converge relay method and the Agent Teams leader special case (the leader pane now spawns through the hook with the claude preset). The agent config types move to tui-agent-config-types.ts so the config table stays under the line budget. * refactor(agent-trust): delete the pre-spawn trust writes the spawn hook replaces The spawn hook is now the only owner of agent folder trust, so remove every other writer: - the agentTrust:markTrusted IPC channel, its preload bridge and types, and all renderer callers (agent-trust-preflight and its callers in the background session, work-item direct launch, session continuation, worktree creation, folder workspace composer and session fork); - the main pre-spawn sites: the createdWithAgent preflight in worktree-remote.ts, markLocalWorktreeTrusted/markRemoteWorktreeTrusted and the runtime's markWorkspaceTrustedForAgent family with the markTrusted ports of the runtime create flows; - Codex's own launch-prep and resume-prep trust writes. Each of those launches reaches a spawn builder with launchAgent set, so the hook covers it. This also fixes a live gap: the worktree-remote.ts copy of the preset switch omitted Antigravity, so an agy agent started from a desktop worktree create still asked; the single dispatcher covers it. Trust is also written on the host the PTY actually spawns on, which removes the #11163 class of writing the wrong host's config. * test(agent-trust): type the spawn-builder trust fixtures and prove the spawn waits for trust The builder test passed untyped args (a string launchAgent) and cast its deps, which failed tc:node. It now builds both spawn states from a fully typed deps fixture and a typed restored pane, with no casts. Adds a case that holds the trust write pending and checks the builder does not finish until it settles, the ordering the deleted renderer and launch-prep tests used to cover. * fix(agent-trust): give SSH trust writes the 20 s deadline again The dispatcher gave every non-Codex preset a 1.5 s budget, including the SSH writers for Cursor, Copilot and Qoder, which make several round trips over the link. Before this PR those writes had 20 s (desktop) or no limit (runtime), so on a slow link an unattended SSH worker would now stop at the agent's trust question. SSH writes get the 20 s deadline back; local non-Codex writers keep the short budget, and Codex keeps 20 s. The relay's Claude grant keeps the short budget: it writes the relay host's own disk and does not cross the link. * fix(agent-trust): never pre-trust a home folder or a filesystem root A folder workspace can be the user's home folder or a disk root. Claude and Copilot let a trusted folder cover every folder under it, so pre-trusting one of those would silently trust everything on the machine for those agents. One check, isHomeOrFilesystemRoot, now refuses them for every preset: the dispatcher checks roots and this machine's homes (including the spawn env's HOME and a cached WSL guest home), the SSH writer checks the remote home it already resolves, and the relay checks its own home. The agent then asks, as it would without Orca. * refactor(agent-trust): drop the Codex launch plumbing that only carried trust The spawn hook replaced the trust writes in Codex launch prep and resume prep, which left the fields that fed them unread: CodexHomeLaunchContext.workspacePath and .launchAgent, the resume prep's workspacePath, and the structured Codex launch input's workspacePath (plus the extra target lookup that produced it). Remove them and their plumbing; unavailableManagedHomePath stays. Also removes test stubs of runtime trust methods this PR deleted, whose not-called assertions could no longer fail, and two comments that still described the old trust preflight. * chore(reliability-gates): point the trust gate at the spawn-time trust tests The agent-session trust gate still listed three test files this PR deleted (the renderer preflight, the Codex launch-prep deadline and the e2e trust completion suites), so check:reliability-gates, which runs in PR CI and in pnpm lint, failed on missing files. Its invariant also described the deleted IPC handler and pre-spawn writers. The gate now covers what replaced them: the spawn builders holding the spawn until trust settles, the fresh-launch and setting gates, the per-preset deadlines, and the home and root refusal. * fix(agent-trust): skip the relay Claude grant for a WSL shell On a Windows SSH host whose pane shell is wsl.exe, Claude runs inside the WSL guest and reads the guest's config. The relay still granted trust in the Windows host's own .claude.json, writing the Windows home for a WSL launch, which the local path never does. The relay now skips the grant there, so that Claude asks, as a local WSL launch does when Orca cannot reach the guest file. * perf(agent-trust): only agent launches wait on the trust hook Both spawn builders awaited the trust hook on every spawn, including plain shells, reattaches and agents without a preset. Awaiting even a resolved promise adds microtask ticks ahead of the pane-spawn reservation check, and this handler already keeps non-Codex spawns off an await because an extra tick reorders those reservation races. The hook now returns null when there is nothing to write, and the builders await only a real trust write. * test(agent-trust): keep the home and root cases off any real Claude config The home and root cases ran the real Claude writer with the test process's env, so a regression in the guard would have written trust for the home folder and / into whatever Claude config that env named. They now point CLAUDE_CONFIG_DIR at a folder that does not exist, and the writer never creates a config. * test(runtime): drop needless casts from the launch-host test The renamed launch-host test kept three `as never` casts on launch options that already match launchAgentTerminal's parameter type. The changed-lines casting gate reads the renamed file as new and failed on them. * test(agent-trust): type the Claude grant mock with the real writer's signature The mock took an unknown target, so installing the real writer as its implementation would not typecheck under strict function types. * fix(codex): drop the launch context the trust move left unread in local spawn env * fix(agent-trust): queue Claude grants per config file so a launch burst keeps them all Concurrent grants in one process retried Claude's file lock in lockstep, so each retry round admitted about one winner. Starting 12 Claude agents at once left 6 of them at the trust question with nothing logged. Grants for one config file now queue in-process; only Claude's own writes contend for the lock. The relay shares the writer, so bursts of SSH launches are covered too. * fix(agent-trust): never pre-trust a folder above a home either The guard refused only an exact home or a filesystem root. A folder workspace at /Users, /home or C:\Users was still pre-trusted, and Claude walks up parent folders for a non-git folder, so every non-git folder in the user's home became trusted. The guard now also refuses any folder that contains a home, on every host, and is renamed to say what it decides. * perf(agent-trust): skip the SSH round trips for Antigravity, which has no remote writer Every Antigravity launch over SSH now reaches the remote trust writer, which resolved the remote home and realpath'd the workspace over the link before writing nothing (the known remote gap). That delayed each launch by two SSH round trips, and up to the 20 s deadline on a stalled link. It now returns first. * fix(settings): keep the hidden folder trust row out of web-client settings search The paired web client hides the host-only "Trust the folder" row, but settings search still listed it, so searching "trust" opened the Agents pane with no matching row. Its search entry is now filtered the same way as Agent Awake. * fix(agent-trust): a failing breadth guard skips trust instead of failing the spawn * docs(qoder): New Tab now pre-trusts through the agent-wide spawn hook * fix(agent-trust): never pre-trust a home reached through a symlink Every trust writer stores the workspace's resolved path, but the breadth guard compared only the path as given. A folder workspace that is a symlink to the home folder (or a real home picked while HOME names a symlinked one, as on distros that link /home to /var/home) passed the guard, and Claude, Copilot and Cursor then trusted the home itself. The local dispatcher and the relay now compare given and resolved forms of both the workspace and each home. The SSH writer resolves the remote home alongside the workspace, in parallel, so it adds no round trip. Local non-Claude WSL launches still skip before any filesystem call. * fix(relay): a failing breadth guard skips Claude trust instead of failing the SSH spawn The relay ran its home/root guard and homedir() before its catch, so a throw there rejected the relay's terminal spawn. Same fix as the main dispatcher's: the whole grant, guard included, is best-effort. * fix(agent-trust): guard the path each writer stores, not the path Orca was asked to trust The breadth guard checked the launch's workspace while each writer stored a transformed path, so every new transformation opened a hole. Codex stores a linked worktree's main checkout: with a git repo rooted at the home, a Codex launch in one of its worktrees wrote trust for the whole home. One relay-safe host module now computes the stored path (Codex's main-checkout hop, then given and resolved forms of it and of each home), refuses a root, a home or a folder above one, and only then writes. Main uses it for local and WSL launches and the relay for Claude. An unknown home writes nothing, and the WSL home cache is keyed case-insensitively by distro. * fix(ssh): the relay writes every preset's trust on the SSH host itself Codex, Cursor, Copilot and Qoder trust over SSH was written from the desktop over SFTP: four or five round trips per launch, so it needed a 20 s deadline that outlasted the 8 s draft paste, the 10 s phone wait and the 15 s web-client create. It also skipped Claude's atomic rename, ignored CODEX_HOME, and stored the worktree where local Codex stores the main checkout. The unreleased `claudeFolderTrust` spawn field becomes `agentWorkspaceTrust`, sent for every preset. The relay derives the preset from the `launchAgent` it already receives and runs the same host writer main uses, on its own disk, within 1.5 s and with no extra round trip. Antigravity still returns early on the relay (its writer is unverified on SSH hosts), a WSL shell still skips, and any throw means the agent asks. Deleted: the SFTP preset writer, the remote Qoder writer, the SSH deadline clause and the desktop-side SSH root pre-check. * test(e2e): keep CLAUDE_CONFIG_DIR out of isolated Electron launches The spawn hook now writes Claude folder trust into the config CLAUDE_CONFIG_DIR names, so an e2e run started from a shell that sets it could add trust entries to the developer's real Claude config. Also drops a stale comment that still named Codex launch prep as the trust owner. * fix(agent-trust): guard Claude's resolve() form of the workspace too Claude's writer stores both resolve(path) and the realpath. The breadth guard compared only the given path and its realpath, so a workspace path that does not exist and climbs back with `..` (for example <home>/missing/..) passed the guard while Claude stored a key for the home itself. The guard now also compares resolve(path), so it sees every form a writer stores. * test(relay): pty.spawn writes agent trust before the agent's process starts Nothing exercised the relay handler's call into the trust writer, so removing that call, or no longer awaiting it, left every suite green while SSH launches silently stopped pre-trusting. The new case holds the trust call pending and checks the spawn waits for it, and that the call gets the request, the declared agent and the final spawn env. The reliability gate lists the new suite and records the resolve() form the breadth guard now compares. * fix(agent-trust): refuse a home only for agents that inherit trust from it The home and root refusal applied to every preset, so Codex, Cursor and Antigravity started asking in a home folder workspace, where they did not before. Only Claude, Copilot and Qoder let trust on a folder cover the folders below it; Codex matches its start folder or that folder's repo root, Antigravity the exact folder, and Cursor itself never inherits from a home, a folder above one or a shallow path. The refusal now reads a per-preset table in the host module, so the local and relay writers share the rule. * fix(agent-trust): trust Codex at the folder it starts in, as before Before this PR, Codex launch prep trusted the spawn's start folder. The spawn hook trusted only the workspace root and skipped terminals with no workspace, so Codex began asking in a floating terminal and in a subfolder of a non-git folder workspace: its lookup checks the start folder, then that folder's repo root, and a plain folder above it is neither. The hook now passes the resolved start folder for presets marked as keyed by it (Codex only), falling back to the workspace root. * fix(agent-trust): pre-trust a structured Codex chat's folder, as before Before this PR, creating a structured (native) Codex chat pre-wrote Codex trust for its folder through launch preparation. The PR removed that write and routed trust through the PTY spawn builders, which a structured chat never passes. Codex's app-server trusts the folder itself only when the chat's permissions can write it, so a read-only chat started running untrusted and ignored the project's .codex config. Creating the chat now calls the same dispatcher, behind the same setting, before launch prep. * fix(settings): plainer folder trust setting text |
||
|
|
70475e0228 |
test: stop testing private internals through exports no caller needs (#23829)
Third audit wave. The detector looked for production modules exporting three
or more symbols that no production file imports — only tests do. That shape is
the authoring gate's fourth question failing: a test needing a production seam
no caller needs belongs at the real boundary instead.
Most hits were detector false positives and were left alone; the scanner misses
re-export barrels and dynamic imports, so every module was re-verified with rg
before any edit. Where a private predicate's behavior was already covered
through the module's real entry point, the duplicate cases are gone and the
symbol is module-private again. Where it was NOT covered anywhere else, the test
stays — this audit removes tests, it does not author replacements.
Production code deleted where tests were its only callers: the superseded
`filesystem-directory-listing-limit` module, the unused
`format{Hourly,Daily,Adhoc}Version` helpers and their orphaned prerelease
identifiers, the dead `filterByAutomationListSearch*` family superseded by
`matchAutomationListSearchRowKeys`, and the dead
`getAiVaultResumeWorktreeTargetStatus` copy of the live workspace branch.
Also drops two call-shape source greps in `relay-sweep-schedule.test.ts` that
asserted `index.ts` spells `jitteredSweepIntervalMs(30_000)`; the jitter math
has a behavioral owner at the top of the same file. The structural census that
counts role-gated vs total `setInterval(` calls stays — an ungated sweep runs in
every cell, and nothing else can catch that.
|
||
|
|
29c49aec31 |
feat(native-chat): hold mid-turn messages in a host-owned queue (#23726)
* refactor(native-chat): remove the unused terminal handoff No client ever called agentSession.requestHandoff or mounted the handoff chrome. Delete the handoff coordinator, the terminal-owner runtime, the proof write path and the unmounted UI. Keep agentSession.handoffStatus, which released desktop clients read for worktree activation, and let records an older build left mid handoff reconcile through the ordinary restart and recovery paths. * fix(native-chat): never let the pre-stop snapshot hold a chat's stop Eviction now drains delivered events before quit's resume-offer snapshot. An unbounded wait there sits ahead of the provider stop, so a sink whose journal write stalls kept the child running until the step deadline aborted the eviction. The offer is advisory: bound the drain and stop the child regardless. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(native-chat): drop helpers only the terminal handoff called `claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and `queryWindowsProcessRowsFresh` lost their last caller with the handoff. The fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`, the teardown path that still depends on that contract. Co-Authored-By: Claude <noreply@anthropic.com> * docs(native-chat): stop citing the removed handoff in lifecycle comments Six comments still named the handoff coordinator, a handoff suspend, or a terminal-owned session as live participants in the flows they describe. Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): type the stalled snapshot drain without a cast Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): pin that a start dead before proving owes no settlement The removed restart handoff test pinned this branch; nothing else did. Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): keep the owner-status read behind an in-flight attach The handoff removal dropped the per-session queue from `handoffStatus`, so a read landing mid-start reported the reservation (no owner) instead of the settled chat owner, and shipped desktop clients blocked worktree activation on it. The read is queued again, as it was before the removal. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(terminal): remove the agent-session PTY write gate The gate only refused a write when a PTY had been bound to a chat session, and the only code that ever bound one was the terminal handoff this branch removes. With it gone, every admit/readmit returned "admitted" unconditionally, so the checks on the renderer write path, the runtime controller backstop, terminal.send, agent prompts, preview input and orchestration pointers, the refusal fields on terminal.send and worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane orchestration routing could no longer run. Ordinary writes take the same path in the same order as before. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(native-chat): drop the transcript helpers only the handoff called appendLegacyTranscriptMessages fed the terminal transcript catch-up and proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost their last caller with the handoff. Their tests now go through the live entry points instead: the roster bounds through the legacy import, the pinned-read and growth tests through the ancestry replay the history window uses, and the marker rules through the string proof in their own file rather than the session-file resolver's. Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): stop calling a starting chat "mid-handoff" A send refused because the chat's owner is not settled showed "The session is mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that reach it are a chat that is still starting, or one whose previous agent process has not yet been confirmed stopped. The message now says which of the two it is. The refusal code is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): type the stand-in roster decoder without a cast Co-Authored-By: Claude <noreply@anthropic.com> * refactor(codex): name the pinned rollout lookup for what it does With the terminal handoff gone, the module named codex-tui-rollout-proof holds only the pinned rollout lookup that structured Codex launches use to resume a thread, so the name described code that no longer exists. Rename the module and its options type. Also drop a mobile allowlist assertion that pinned the removed agentSession.requestHandoff method, which no longer exists to allow. * refactor(native-chat): type the owner-status reply as the host sends it The handoffStatus reply type still listed the terminal handoff's fields and states (terminal placement, host label, proof retry, queued and waiting phases, the to-terminal direction). No host writes them any more and the only client reader parses the reply as unknown, so they described nothing. The reply on the wire is unchanged. * refactor(native-chat): normalize terminal-handoff lease values once at decode Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types still admitted them, so readers across the host kept branches for values no path produces and the compiler could not point at them. The store now validates the on-disk shape, which still accepts those values so an older record is not quarantined, and maps them once while parsing: - `preparing` and `old-owner-stopped` become `recovering` - a `tui` lease becomes `native`; when it records a process it also becomes `conflicted`, the claim every build probes but never stops. A plain native owner would be stopped by restart recovery, here and in older builds. Revisions are taken over the normalized state on both sides of every compare, and the mapped record reaches disk with the store's first transaction, the same way the tab-id backfill does. The in-memory types narrow to what this build writes, and the branches that existed only for the removed values go. Structured-worker identity keeps its verdict for a former terminal owner by refusing a conflicted claim rather than a non-native kind. * refactor(native-chat): stop threading the owner kind through a reservation A reservation only ever names a native owner now, so the request no longer carries a kind and the reserved lease records `native` directly. The attach params keep `runtimeKind`: agentSession.ensure and create accept it, and the operation fingerprint stored in the ledger covers it. * test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else Hiding a tab also committed the visibility index, so the no-op transaction wrote the file even when its open-time revision was wrong. Committing the index first leaves the pending rewrite as the only reason to write. * fix(native-chat): name a chat write by its target, not the owner generation A write carried the fence of the last frame the pane read, and the host refused it unless that fence was still current. An idle release and the restart after it each move the fence, and the release publishes nothing, so a send after a release was refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a cold start was refused as stale. Every write already names what it acts on: a send its conversation, a cancel its turn, a prompt answer its item revision, a rewind its epoch; an option is last-writer-wins. So admission stops comparing the client's fence, and the rebase that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it. The writer-lease check stays, and so does the attach's compare-and-swap. Frames now stamp the fence read when each frame is sent instead of a copy each subscriber kept, which went stale on the same release. * fix(native-chat): every journal append reaches the chats that are open A journal write and its delivery to open readers were two calls, and some writers made only the first. A failed start whose lease could not be handed back, a provider revision with no frame behind it, and eviction's settlement were all journaled without reaching an open chat. A journal handle now reports every durable change, and the host's session map binds that report to the session's readers when the handle is set. Writers no longer publish what they append; the per-writer publish calls are deleted. * test(native-chat): an epoch replacement reaches the open chat * test(native-chat): each row reaches an open chat once, and a live handle enters only through the map * test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite The seeded record had no surface tab id, so the next open backfilled one and that rewrite alone made the no-op transaction write. The test passed with the legacy-lease rewrite signal removed. * test(worktree-activation): restore the OMP surfaced-agent resume test The handoff removal deleted it alongside the terminal-owner tests, but it covers the surfaced-PTY block that still guards resume, including an agent whose ownership is unknown. * perf(native-chat): a publish behind a delivered commit reads nothing Each commit now delivers itself, so the publish a provider frame still sends afterwards found every reader caught up but still read rows and rebuilt the timeline for each one. A caught-up reader now skips the read. * test(native-chat): state why the teardown test's fake journal is safe to cast * docs(native-chat): say mutation admission checks only the writer lease * docs(native-chat): drop the send rebase from comments that still described it * fix(native-chat): a message is accepted, then delivered A send to a chat with no running agent restarted the agent inside the send call, before the message was recorded, so the client waited for the whole start and a failed restart refused the message. Claude held prompts sent during startup, and those could settle as "unconfirmed". A send is now accepted inside the session's serialized queue: one ledger row and one submission row marked handoverRecorded, published, answered pending. A per-session delivery loop exists while a message is queued. It starts the agent through the same serialized attach a hold uses, waits outside the queue for a Claude child to prove its start, and hands the oldest queued message over as its own serialized step, writing dispatch{pending} before the adapter call. A start it needed and did not get writes one error-tone row and rejects every queued message with the same words; a start Stop cancelled writes none. Settlement follows from the rows. A queued message is provably unwritten, so a close, an eviction or an exit rejects it. A handed-over message stays in doubt. A queued row at or below the sequence a handle found when it opened was left by an earlier process and is rejected at open, with no latch. Stop withdraws queued messages with no writer lease and no fence. An attach failure keeps the conversation open, and the attach adopts its journal. Owed work counts the loop and queued rows. A compaction or rewind found prepared when a conversation opens was started under a child this process no longer has, so the open settles it rather than leaving it to refuse every send until a view attaches. The open cursor is scoped to its epoch, because sequences restart when an epoch is replaced. Deleted: restart-before-admission, recordFailedRestart, the fence rebase, Claude's startup gate, the attach's forget on failure and its own crash boundary. Clients without agent-session.accepted-send.v1 get their reply held until the handover; the desktop and paired desktop lists advertise it. * fix(native-chat): settle queued messages only for the child that ended A child that proved its start and then exited before its message was handed over left the message queued: the exit settlement returned early when nothing else was in flight. Delivery then started another child for it, and a child that died the same way started another, without end and without a row. A retried settlement for an earlier generation, run by the attach that delivery started, did the opposite: with that generation's turn unfinished it rejected the message queued for the child being attached. The settlement now takes the rejection for queued messages from its caller. The unexpected exit and the eviction pass one, and it applies even with no other work in flight; the retry for an earlier generation passes none. * fix(native-chat): an adoption that fails to import keeps the conversation open The attach now writes into the conversation's own open journal, but a failed transcript import still closed it as if it were the attach's provisional one. The conversation stayed indexed with a closed journal, so every later send answered "could not be recorded" and every attach failed again until the app restarted. The import now closes only a journal the attach opened for itself. * perf(native-chat): the recovering open reads the journal once Every conversation open now goes through the recovering open, including the read restore of every chat at startup, which used to replay its journal once. The recovering open replayed it twice: once to probe it and again inside the open. The probe is now handed to the open as its load. * fix(native-chat): an attach that fails after indexing its child leaves no child behind A failed attach now keeps the conversation open, but a failure after `onAttached` indexed the child (the rewind or compaction recovery, or the attach's own success record) left that entry claiming a child the failure path had already released. The next send found the phantom, skipped the start, and wrote at a fence the journal had moved past, so the message stayed queued for good. The entry now drops the released child and its event sink, and follows the record's fence, as a failure before indexing already did. * fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer The error strip for a message the host accepted and then did not deliver matched the entry before the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not send your message" with nothing to retry. It now reads the reconciled entry. A rejection the journal records before the send's own pending answer lands is final as well: that answer no longer puts the entry back to dispatching with no Retry. * fix(orchestration): a structured worker whose agent outlasts the preamble wait is left unknown, not torn down The preamble waits for its submission to be delivered while the worker's agent starts. When that wait ran out it threw operation_unknown, and the failed-start teardown then closed the session, which rejected the very preamble the host was about to deliver. It now reports a turn start nobody observed yet: the worker is start-unknown with its session kept, the host delivers the preamble when the agent starts, and the worker's report settles the dispatch as for any unobserved start. The receipt no longer suggests reading a screen a structured worker lacks. * fix(native-chat): a message rejected while its chat was closed reads as not sent A remount reads an entry it left dispatching as unconfirmed. When the journal had rejected it meanwhile, as a failed start or a quit now does, the reconcile left it unconfirmed: it blocked every later message behind a Retry and no reason, and the delivery probe, seeing the journal already answered, never ran. The reconcile now settles it as rejected like a dispatching one. * test(orchestration): name why the readiness settlement fakes are cast * fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent * docs(native-chat): drop the fence from the admission the send effects run behind * docs(native-chat): give the fence move on release the reason that still holds * docs(native-chat): stop citing a write fence check in launch and mailbox comments Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease. * refactor(native-chat): the provider child is its own record A conversation now outlives any number of provider children, so the child is one record on the conversation's entry instead of five loose fields beside its journal. It is written in one place: indexed only once an attach has fully succeeded, and ended through one function that an exit, a failed re-attach, a Stop and an eviction all share, matched on the child's generation and fence. - A failed attach writes no child, so there is nothing to unwind: the field unwind and the fence patch after it are gone. - Conversation writes read the record's fence, the way mutation admission already does; a child's own writes use its fence. The four stored-fence patches, and the settlement retry's overwrite of the conversation's fence, are gone. - The owed wind-down is its own tombstone, carrying the child it is owed for, and is no longer dropped when an attach replaced the whole entry. - Stop on a child still proving its start stops only the child: its lease goes back and the chat is told it is idle, but the journal, the holders and the readers stay. Close is that stop plus the conversation's close. - The settlement retry uses the conversation's own journal, opened through the host's one open. * fix(native-chat): the delivery loop alone settles a message its start or child failed A queued message was settled by whichever path happened to end the child first: the loop, the unexpected exit, eviction's work settlement, the open's leftover rule, and the startup branch that rejected every pending row. That gave two failure rows with different tones for one start, a loop that could hand over to a different child than the one it waited on, and a Claude start that died while starting reading unlike every other failed start. - The loop remembers the child it waited on. At handover, if that child is gone or replaced, it reads how it ended: a Stop continues; anything else writes one failure row and rejects every queued message with the same words, then stops. A child still starting whose start the adapter says did not land fails the same way. The exit, eviction and the settlement retry only settle the handed-over and legacy rows of the child that ended. - One failure row, always an error, keyed by the start. A start a view began that dies with nothing queued writes the same row through the same builder, so a second report revises it. - The open no longer rejects leftovers; the loop's first step does, and the open wakes it. - `awaitStarted` answers why a start did not land, so the row says it even when the loop sees the failure before the exit is processed. - Quit closes every conversation the way closing a chat does: what is still queued is rejected as closed, with or without a child, and a start the loop already has in flight is waited for so the child it produces is stopped rather than left behind. * refactor(native-chat): a stopped child ends on the one reading of its stop The eviction step reads a stop's result through `stopAgentSessionProviderRoot` and hands that verdict to the child's ending, so the host never forms a second view of whether the root is gone. Every ending carries it: a stop's comes from that reading, an exit's root is gone by definition, and a failed re-attach passes what its release saw. The end-of-child record can therefore also carry a stop whose root was not seen to go, which nothing ends on yet. * feat(native-chat): the host says it accepts a send before any agent has it The host now lists agent-session.accepted-send.v1 among its own runtime capabilities, the same string capable clients already send. A client can then tell a host that answers a send at acceptance, and admits a Stop with no writer before a turn starts, from an older one that still restarts the agent inside the send. Additive: an older client ignores a capability it does not know. * refactor(native-chat): an attach never opens a journal of its own The attach adopts the conversation's open journal, which outlives it, so it no longer opens one for a direct caller either. That leaves nothing for a failed adopted import to close, and the flag that told the two cases apart is gone. Tests that attach without a host open the conversation the way a host does. * fix(native-chat): a moved fence resends nothing on a host that accepts first The outbox treated any fence change as a new owner: it dropped the answer of a send in flight, queued that send to go out again under the same id, and unblocked a refused head. On an older host that is how a send the restart refused, unrecorded, gets another try. On a host that records every send before it starts an agent, a fence moves because that start ran, so the same rule resent into every failed start. With a fence stamped on every frame, that became a loop. The outbox now reacts to a fence change only when the host has not advertised that it accepts a send before any agent has it. On such a host, only a Retry or a new send goes out, and a failed start reaches the client as a rejected message it keeps with its Retry. Against an older host, or before one has answered, the outbox behaves as it did. Desktop and paired web share this hook. * refactor(native-chat): a child's end says whether the user or the host stopped it The end-of-child record's cause now tells a user's Stop from the host stopping the child for a cause of its own: `user-stop` and `host-stop` replace `stop`. The delivery loop goes on after a user's Stop, as before, and fails the start it was waiting on after a host stop, with the one error row and every queued message rejected, in the stop's reason when it gave one. The reason stays description only. Stop passes `user-stop`; nothing passes `host-stop` yet. * fix(native-chat): a chat whose only work is a queued message is not offered for resume A message accepted while the agent was starting counts as working in the chat, and quit rejects it as never sent. The teardown snapshot read the same working rule, so a relaunch offered to resume a chat whose agent never had the message. The snapshot now reads only what was handed over. * test(native-chat): type the queued-message fixtures in the resume-offer tests * fix(native-chat): a start that dies while a message waits on it is that message's failed start Opening a chat's tab starts an agent for the view, and a send accepted meanwhile waits on it. When that start died, its exit wrote the start's error row and left the message queued, so the delivery loop started a second agent into the same failure and wrote a second row. A child's end now records where the conversation's journal stood, and the loop settles a message accepted before a failed start ended with that start: one row, under its key, and no second start. A message sent after the failure still gets a fresh start. * docs(native-chat): say what an attach's open conversation and unconfirmed ids are now * test(native-chat): pin what a failed start settles, and what a resume offer names A view's child that dies while a sent message waits settles that message only when it died starting and no child has taken its place: a proven child's crash, or a second start since, gets the message delivered. The resume offer names the handed-over message, never a newer one still queued. * test(native-chat): the failed-start pins fail on what the message became, not on a timeout * test(orchestration): the preamble's host stub is typed, not cast The preamble send now takes only what it reads of the host, the send, the settlement wait and the record's fence, so its test builds that host with real types instead of `as never`. * fix(native-chat): a Stop that names no turn stops what the conversation has in flight Between handing a message to the agent and the agent opening its turn, there is no turn id a client could name, so a Stop in that gap was refused as "already finished" while the agent went on to answer. A cancel's turn id is now an optional precondition instead of its target: with none, the host withdraws what is queued and, when the journal still reads working, asks the adapter to stop whatever the child has in flight. Claude's interrupt is session-scoped, so it is guarded by fence and acquisition generation rather than a turn identity. Codex interrupts the turn its latest turn/start answered with until the journal shows one. A cancel that names its turn behaves exactly as before. * fix(native-chat): Stop is there from the moment a message is sent The composer showed Stop only once the agent had opened a turn, so for the second or two after a send the chat read "thinking" with no way to stop it. Against a host that takes a Stop naming no turn, Stop now shows whenever the chat reads working (a turn, a queued message, or a handed-over one still unanswered) or this client still has a message on its way. Pressing it, or Escape, first drops every outbox entry the journal does not hold yet, so nothing goes out after the Stop, then sends the conversation-wide cancel. A send already on its way reaches the host ahead of the cancel, which withdraws it there. Against an older host Stop still needs a running turn. The unconfirmed-send probe moves into its own hook so the outbox hook stays in budget. * fix(native-chat): Stop before a turn is gated on its own host capability A host that accepts sends first (agent-session.accepted-send.v1) can still predate the cancel that names no turn and would refuse it as invalid, since clients and hosts ship independently. Hosts that take that cancel now advertise agent-session.conversation-stop.v1, and the renderer shows Stop before a turn opens, and sends the no-turn cancel, only to a host advertising it. Every other host keeps a Stop that needs, and names, a running turn. The host capability probe the accepted-send hook used is generalized so both read one path. * test(native-chat): a build advertises conversation stop exactly where its cancel may name no turn * fix(native-chat): a view never restarts a chat whose last start failed A Claude chat whose CLI exits during startup left one red row per start, and every time a view bound to it (the chat opening right after its create died, or the user switching back to it) the hold started the CLI again, so the same launch-failure row repeated. Only a send retries a failed start now, the same rule provider-exit recovery already applied; the rule lives in one predicate the hold, exit recovery and the delivery loop share. * test(native-chat): start the child the loop waits on with an attach, not a second view A view no longer starts a child whose last start failed, so the R2 case that waits on a child started since the failure now gets that child from a client attach, the one non-send starter left. * fix(native-chat): settle a gone generation's turn wherever a conversation opens A send that opens a chat this process had not read yet (after a crash, from a phone or the CLI) went through the delivery open, which never settled what the dead generation left running; only the read restore and a successful acquire did. When the send's start then failed, the turn stayed running for every reader. The settlement now runs in the one journal open, at the crash boundary, for every opener except an acquisition, which settles from the evidence it read before its reserve; the read restore's separate step is gone. * test(native-chat): prove the next child's start settles the turn an earlier child left The R1 case lost its only settlement assertion when the latch it checked was deleted. It now seeds the running turn the earlier child left and asserts it ends at the exit's receipt, with the exit's row, before the message is handed to the new child. * test(native-chat): count a failed start's rows by row, not by text Comparing the set of texts passed when two different rows carried the same words, which is the duplicate the test exists to catch. * test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget * test(native-chat): pin the open's and the send's start and row counts, however the view binds Opening a fresh chat whose starts fail makes one start and one row, with two views bound before or after the create's child died; one send makes one more of each. * fix(native-chat): settle a gone generation's turn at every open but an acquisition's The journal open skipped the settlement whenever the lease read reserved or live, to leave an acquisition's own open to the acquisition. But a lease a crashed process left in recovery also reads live, until the next acquire resolves it. A send that opened such a chat, from a phone or the CLI after a crash on a host that could not prove the old owner gone, skipped the settlement; when its start then failed, the dead turn stayed running for every reader. The acquisition now says it is the opener, and every other open settles, whatever the lease still claims. * test(native-chat): hold the create's start open until the views bind The "view binds while the create is still starting" case gave the create a 300 ms head start and asserted the views bound before it died. On a loaded runner the holds took longer, the create's exit landed first, and the case failed its own precondition. The create's initialize now waits on a gate the test releases once the views are bound. * fix(native-chat): Stop reads the one working rule every session list reads While Claude retries a rate-limited request it never echoes the message, so no turn opens: the sidebar read Working from the unanswered send while the composer showed Send. The chat's working state, the host's session-list status and the host's no-turn Stop check now call one shared rule instead of three copies. * test(native-chat): a rate-limit retry pins only that no turn opens, not how its rows are kept * fix(native-chat): Stop leaves a message waiting on its Retry, and does not show for one A send that failed holds the queue until the user retries it, and one the host restarted under is parked the same way. Stop counted both as still on their way, so it showed in an idle chat and could never go away, and pressing it dropped the failed message along with its Retry. * test(native-chat): the chat's Stop and a session list read the main agent alike over their own copies The chat reduces its stream and a list reads the status feed. Driven through the real host for a rate-limit retry with no turn, a subagent still running after the main turn, and the handed-over child exiting. * refactor(mobile): the chat reads the main agent's working state through the shared rule Behaviour is unchanged: the same two terms, now from the one function the host projection and the desktop chat read. * fix(codex): a Stop naming no turn never interrupts an earlier turn It fell back to the id an earlier turn/start answered with when the latest start went unanswered, or when the journal showed a compaction Codex had not started, and reported that as stopped. * fix(native-chat): a Stop naming no turn never says a turn had already finished When the provider found nothing left to stop, for instance a turn that ended between the host's check and the interrupt, the chat got "The provider had already finished this turn." for a turn the Stop never named. It now ends quietly, as a Stop with nothing in flight does. * fix(native-chat): one Stop the host could not settle no longer refuses every later one A Stop naming no turn has one operation key per session. When the host could not settle one, it answered every later Stop under the same id as unknown until the id expired. Once the host says so, the next press is a new Stop; transport doubt still replays the same id. * refactor(native-chat): drop the composer's second error formatter After the merge with main, every chat write in the composer path reports its failure as a typed outcome worded by the refusal-notice table, so the send's catch sees only a local throw. The {code, message} formatter this branch added for it has no payload left to format, and its claim to be the one way a chat words a failure is no longer true. The composer send is main's again. * test(native-chat): pin the reason on a message rejected while its chat was closed The reopen test checked only that the message reads as not sent; it now also checks the Retry row carries the host's reason. * test(native-chat): read Stop operation ids without a cast * fix(native-chat): a Stop whose answer was lost no longer swallows the next one A Stop that names no turn has one operation key per chat. When its answer was lost in transit, the chat kept the id, so every later Stop replayed it; the host answers a replay as already handled, so for up to a day Stop stopped nothing. The id is now dropped once the call settles, however it settles. A second press while the first is still on its way still shares its id. * refactor(native-chat): a Stop naming no target keeps its operation id only for its own call The chat kept each write's operation id per payload across calls, and dropped it only on some settle paths. That is right for a write naming what it acts on, but a Stop naming no turn, and a stop of every background task, share one payload with every later one, so any path that kept the id made the next Stop replay as already handled and stop nothing. One path was still open: an answer that arrived after the chat moved to a new fence. Whether a write names its target is now decided once, before its id is picked. One that names none keeps its id only while its call is in flight, so a press made meanwhile joins it, and releases it when the call settles, however it settles. The release runs only while the key still holds that call's id, so a joined call settling late cannot drop a newer one's. This replaces the per-path exceptions for a thrown call. * test(native-chat): read the Stop fences without a cast * test(native-chat): pin the new id for a named cancel the host could not settle After the Stop naming no turn moved to a per-call id, the only test of the unknown-refusal release was gone, and the half that stays, for a cancel naming its turn, could be removed with every test green. * fix(native-chat): a Stop pressed after a new message stops it, even while the last Stop is unanswered A Stop naming no turn shared its operation id with any press made while it was still in flight. The host runs a chat's writes in order, so a message sent between two presses was accepted after the first Stop ran, and the second press replayed that Stop as already handled and left the message running, although the chat had already withdrawn it from the outbox. A write naming no target now gets a new id on every press and is never kept, so each Stop acts on whatever is running when the host reaches it. A write naming its target keeps its id exactly as before. A double press can ask the provider to stop the same turn twice, which it tolerates. * fix(native-chat): Stop no longer blinks off as Claude opens the turn for a message Claude's echo of a sent message both answers the send and opens its turn. The echo settled the send first, so the host published the message as answered one frame before the turn it opened, and for that frame the chat read nothing running: Stop turned back into Send, and Working blinked off in every session list, for tens of milliseconds on each turn. The echo now settles the send after the turn it opens has been emitted, so the running turn is published first. * fix(native-chat): a message a Stop withdrew comes back to its sender's composer A Stop withdraws every message the host holds but has not run, and S also drops the ones this client had not handed over yet. Either way the message left the chat and its text survived only in a hidden journal row and the in-memory ArrowUp history. The sending client now puts the withdrawn text and images back in that pane's composer, after whatever is typed there. Withdrawn is read from the rejection reason through one shared check, which the outbox reconcile now uses too. The composer is written before the entry leaves storage, so a failure between the two repeats the text instead of losing it, and an entry storage no longer holds is never given back again, so a replay, a second view or a remount restores it once. Only this client's outbox holds the entry, so other viewers still see the message disappear. A failed Stop withdraws nothing on the host and gives nothing back. * fix(native-chat): withdrawn text put back during an IME composition is not lost While the IME owns the field, the composer ignores a programmatic draft, and the next composed keystroke wrote the draft without the restored text, after its outbox entry had already been dropped. The composer now holds text appended mid-composition, keeps it in the cache after each composed write, and shows it once the composition settles, the way attachments that land mid-composition already wait for it. * test(native-chat): pin that only a withdrawn message comes back to the composer * test(native-chat): set up the composer's window API for every describe in the composition-race file * docs(native-chat): note that the withdrawn check reads the legacy reason until a typed category lands * test(native-chat): pin that text put back mid-composition shows once, even beside a mid-composition clear * feat(native-chat): host-owned queued-message draft store in the session journal A queued mid-turn message is a draft row in the session's journal.db, created idempotently at every writable open with no user_version bump so a downgrade stays writable. Consume converts one draft into an ordinary submission inside the journal writer's own transaction (exactly-once), and a standing writer hook returns a consumed draft only when a committed row newly settles its current consumed submission to a non-withdrawn rejection — the same decision the reducer folds rows through. Open-time repair re-derives returned state behind the stored fact; retention never prunes a row whose refusal could still return it. * feat(native-chat): queued-messages wire contract, dark capability, and send classifiers The send result becomes a union: today's submission arm unchanged, plus a capability-gated queued arm only clients that sent delivery:'queue-if-active' ever receive. Whole-list queuedMessages fields ride the subscribe events and history pages; Stop gains withdrawQueued with the withdrawn bodies in its result; clear's result carries withdrawn drafts too. Both classifiers treat queued as accepted/spent. agent-session.queued-messages.v1 is defined but deliberately NOT advertised: the rollout prerequisites (Claude fold receipt, integrated Codex steer matrix) are not in this host. * feat(native-chat): queue a capable mid-turn send as a draft, drain it at turn end, and let Stop and clear return its text A send carrying delivery:'queue-if-active' while the session owes work — or behind an actionable backlog — becomes a host-held draft instead of a submission. A serialized drain woken by journal commits, draft mutations and conversation opens re-derives its gates from live facts (streamed-event barrier first, backlog never a gate) and converts the oldest actionable draft through the exactly-once consume; from that instant today's delivery pipeline runs unchanged. Stop pauses the withdrawable frontier at the stop step (a process-level pause set that survives handle eviction and, via the per-process host instance, restarts), then withdraws it with the text in the result for capable clients; /clear does the same for the superseded source. The draft list publishes whole per emit with identity dedup, rides only the final catch-up page, and attaches to history pages. queuedMessageSend overrides queue policy only; queuedMessageDelete hands the body back. Replays for all of it answer from op-stamped tombstone receipts. * test(native-chat): pin mid-turn queueing against the real host Accept (working/backlog/text-only/budget/replay), the one-per-settle drain, returned cards with N1 overtake and the N4 re-send loop, Stop withdraw with tombstone replays, the process-level pause across evict/reopen, Delete receipts, /clear returning the withdrawn text, and publication (hydration, unchanged-cursor insert, same-frame consume, identity dedup). * test(native-chat): read the queued receipt ids before the wait closures * chore(native-chat): SAFETY rationales on the sqlite row casts and a cast-free mobile narrowing * fix(native-chat): queued-draft bookkeeping never costs a publish, an open, a clear or a history read - Cache the draft list per draft-table revision. The drain re-checks on every journal publish, so each streamed delta was running a SELECT and parsing every draft body the handle had ever written (tombstones included). - Open-time repair/prune failures are reported and skipped; they no longer fail opening the chat. - /clear on a source with no drafts answers exactly as before: no empty `withdrawnQueued`, no empty write transaction, no extra publish. A draft read failure after the committed clear no longer turns it into a refusal. - History pages read drafts through the same guarded reader as subscribers. - Publication moves to its own module; the held-draft rule lives with the pause state; one pending-prompt check; drop an export nothing calls. - Tests: restart-held drafts, pre-consume failure pause + Send retry, failed open repair, clear with no drafts. * fix(native-chat): a Stop that withdraws a consumed draft's send gives its text back A queued draft converted into a submission leaves the sender's outbox, so when a Stop withdrew that submission before the agent received it, the text had no holder: the draft stayed `dispatched` forever and nothing restored it. - The returned-card rule now follows every effective `rejected` settlement of a consumed draft's submission, a Stop's withdrawal included, with the withdrawal reason stored as the fact (`dispatchWasWithdrawn`). The writer hook and the open-time repair share the rule, so no rejected submission can leave its draft `dispatched`. - A capable Stop withdraws the cards it returned itself along with its frontier, stamped with its caller-scoped key: the text comes back once in `withdrawnQueued` and replays from the tombstone. An old client's Stop leaves a returned card. - Stop's draft steps move to structured-agent-session-queued-stop.ts. - Tests: Stop between consume and the agent's receipt for both client kinds, its replay, a crash after the withdrawal, restart in the window, and the repair of a hookless withdrawal. * perf(native-chat): the queued-draft drain takes no serialized step while the agent works The drain was woken by every journal publish and, with a draft waiting, queued a serialized step (streamed-event flush included) per publish, only to find the session still working. During a streamed turn that is one step per delta, contending with Stop and every other mutation for the session's queue. The pre-check now also skips while the session is working. Whatever ends the work is itself a commit that schedules again, and the step still re-reads every gate after its flush, so no wake is lost. - Test: queued sends during a turn take no drain step; settling the turn drains. * fix(native-chat): a clear withdraws queued text only for a caller that can take it back; paused reasons are markers An older client running /clear had its source's waiting and returned drafts withdrawn and their text returned in a `withdrawnQueued` field it does not read, so the text was lost. Clear now mirrors Stop: `withdrawQueued: true` on `agentSession.conversationCommand` (strict params, sent only when the queued-messages capability is advertised) withdraws the drafts and returns their text once, replaying from the tombstones. Without it the source keeps its cards: the supersession fence already blocks the drain, and Delete still hands the text back. A paused card's reason was host-authored English on the wire. It is now a typed marker (`send_failed`) the client localizes, like `returnedReason`; a client treats an unknown marker as a plain pause. - Tests: an old client's clear leaves the cards and its replay stays field-free, then Delete returns the text; a capable clear returns the text once and replays it; the paused marker. * fix(native-chat): a draft pause that commits no journal row still reaches live subscribers A pause writes no journal row, so it reaches subscribers only on the next publish. Two pauses had none behind them: the drain's pre-consume failure (the session is idle by then, so nothing else commits) and an old client's Stop that interrupted nothing. A live card kept reading as waiting, with no failure marker, until some unrelated commit arrived. The drain now publishes after pausing a draft it failed to convert, and an old client's Stop publishes when it paused a frontier. - Tests: a failed conversion and an idle old-client Stop each reach a live subscriber as a paused card; both fail without the fix. * fix(native-chat): a failed clear wakes the queued drain, a failed Stop withdrawal still publishes its pause A conversation command can settle on the record alone (a retried clear that fails), so drafts held behind its prepared phase waited for an unrelated journal commit; the command controller now re-derives the drain when any command finishes. A capable Stop whose withdrawal write failed never published the pause it set, and a publish failure after a committed withdrawal (Stop or clear) dropped the bodies from the answer; publishing now happens outside the withdrawal and can no longer discard its result. Tests reset the process-level pause set between cases: operation ids repeat per test, so a shuffled order held later tests' drafts. * refactor(native-chat): the draft store notifies through the journal's commit listener, the hold is a stored row fact, and one typed gate decides every queue hold R1: every standalone draft-table transaction that changed rows (insert, withdraw, hold, open-time repair) fires the journal's own commit listener after COMMIT, so a draft or hold change publishes and wakes the drain through the same path a journal row does — no call site can forget. All hand-written publish/wake plumbing for draft changes is deleted; wakeQueuedDrain survives only as the record-input wake (a conversation command can settle on the record alone). R2: the process-level pause set becomes a hold_reason column on the draft row (pre-ship, so no migration): holds survive eviction and restart, keep their send-failed marker across restarts, die with the session's journal, and are cleared by consume and withdraw in their own UPDATE. The host-instance derivation stays the one restart mechanism. R3: one typed structuredQueueHold (blocked | command | prompt | working) consumed by admission, the drain step and Send-now, with each caller's override set written beside it. A capable send during a late-result /compact now queues instead of being refused (PLAN §3.1); the dead prepared-command branches and the drain's duplicated gate list are gone. prompt outranks working so Send-now's one override cannot swallow it. R4: one isUnsettledQueuedMessage predicate for the withdrawable/budget filters. Loop 4: a replayed send whose draft was refused answers with the returned card, never the rejected submission, so the text cannot render twice. Rewind completion was verified to publish after the record clears (the rewind path's own publish; the open path's recovery precedes the open snapshot). * fix(native-chat): a Stop with no drafts writes nothing, and a failed hold still lets a capable Stop withdraw The stored hold turned Stop's in-memory pause into a draft-table write, so every Stop (drafts or not, capability advertised or not) opened a BEGIN IMMEDIATE/COMMIT. An empty hold now returns before the serialized write. A hold that threw also emptied the frontier, so a capable Stop withdrew only returned cards and left the waiting drafts unheld to auto-send after the interrupt. The frontier is read once and survives a failed hold. * fix(native-chat): a capable Stop with no drafts writes nothing The empty-hold guard from the previous fix did not reach withdraw, so every capable Stop still opened a write transaction after the interrupt, and a closed handle turned its empty answer into a missing field. The draft store now answers an empty withdraw without a transaction, for every caller. * refactor(native-chat): Stop and /clear never withdraw queued drafts; no text rides the wire back Adopt the host-owned-queue model end to end: a Stop holds the waiting frontier ('stopped') for EVERY client and interrupts — the cards stay published as paused, Send-now overrides per card, and the pause dies when the user next starts a turn (an ordinary dispatched send lifts 'stopped' holds in the same serialized step; 'send_failed' holds still need their explicit Send). /clear carries the source's unsettled drafts to the replacement session as born-held rows — identical for every client version — then tombstones the source. Delete answers with no body: the card leaving the published list is the outcome. Removed (never shipped; the capability was dark and unadvertised, so no wire compatibility is affected): CancelParams.withdrawQueued and its refine, ConversationCommandParams.withdrawQueued, CancelResult.withdrawnQueued, ConversationCommandResult.withdrawnQueued, AgentSessionWithdrawnQueuedMessage, the Delete result body, settleStopQueuedWithdrawal and the cancel finisher, withdrawClearedSourceQueuedMessages, replayWithdrawnQueuedMessages, and cancelPlan's tombstone replay. This also removes the defect where a withdrawal took every row regardless of which client sent it (a phone Stop pulled desktop-typed text): nothing moves text anymore, so a Stop from one client can never relocate another client's drafts. Hold and carry writes are bookkeeping: a failure is logged and never gates the interrupt or the clear. * feat(native-chat): a restart hold lifts like a Stop's, and paused cards say why The user's next dispatched send lifts every stop-shaped hold in one UPDATE: stored 'stopped' rows, and restart-held rows (host_instance mismatch), which are adopted into the running instance — the same fact the derivation reads, so no second copy of the hold exists. 'send_failed' still requires its explicit Send. Publication now marks stop/restart holds with pausedReason 'stopped' (an additive optional value on a dark capability), so clients can caption them "sends after your next message" and keep "couldn't send" for 'send_failed'. * fix(native-chat): only a client's own send lifts a Stop's queue pause The lift ran for every accepted host send, so orchestration mail, a restart continuation and a launch prompt released drafts the user had stopped (and adopted restart-held rows into the running instance). The client-facing agentSession.send RPC now marks its sends as the user's own; host-internal senders leave the pause alone. Also drops comments still describing the withdrawn return-text rule. * fix(native-chat): a Stop's queue pause lifts when the user's send starts its turn The pause lifted as soon as the host accepted a user send, so a send the provider then refused (a failed child start, a refused turn/start) had already released the stopped drafts into the same failure. The host now remembers a client's own send, in memory, until the provider answers it: acceptance lifts the stop-shaped holds, a refusal forgets it with the holds intact, and a later Stop supersedes it. Nothing is persisted, so a restart between the send and its turn start leaves the cards held for the user's next send rather than sending them unasked. * fix(native-chat): a consumed draft's turn starting lifts a Stop's queue pause Drafts are only ever a client's own sends, so a drained draft or a Send-now is a user send for the pause: its submission joins the same in-memory set a direct send uses, and the provider accepting it lifts the stop-shaped holds. Before, a message typed while a stopped turn wound down drained as a draft and left the older stopped cards held, so their "sends after your next message" caption was false. A refused consumption lifts nothing, a later Stop still clears the set, and orchestration mail and restart continuations still never lift. * fix(native-chat): queue a capable send behind a /compact and re-scope /clear's carried drafts - A text send with queue-if-active during a /compact in flight is admitted on the compact's side lane as a held draft instead of being refused; it may only become a draft, so one the gate no longer holds is refused rather than dispatched. - Drafts /clear carries to the replacement are fingerprinted for the replacement session, so the provider's echo folds into the sent bubble. - The in-memory set of user sends awaiting their turn is capped; sends settling unknown no longer grow it without bound. - Correct the userSend comment: the renderer's launch prompt goes through the client RPC and does set it. * fix(native-chat): a returned queued card carries the typed rejection fact, like a rejected submission A consumed draft the agent never ran comes back as a returned card. The card kept only the rejection's sentence, while its submission now also records the typed fact a client classifies from. A host-restart rejection's sentence carries no legacy marker, so such a card could not be told apart from a provider's refusal. The draft table stores the submission's fact next to its reason (`returned_rejection`, written by the same settlement that sets the reason, and read back with the reducer's own fact reader), and the card publishes it as `returnedRejection`. Both are overwritten on every return, so a re-sent card never keeps an earlier refusal's fact, and a /clear carry inserts a plain held draft with neither. Retention moves to queued-message-retention.ts to keep the table module within max-lines. * fix(native-chat): fit the queue to main's typed rejections and compaction result Main (#23026) dropped the disposition's fresh-id retry field, gives a rejected dispatch a typed sentence plus fact, and types /compact's result. The queued-draft disposition and the queue tests now use those shapes. * fix(native-chat): draft bookkeeping can never roll back the journal row it rides The queued-draft returned transition runs inside every journal append's transaction. A throw there (a draft table an earlier build created without the returned_rejection column) rolled back the journal's own rejection row, so a Stop, a failed start or a provider refusal could not be recorded. The standing hook now runs in its own savepoint: its failure is logged and rolls back alone, and the open-time repair re-derives the missed transition from the committed row. The draft table also gains any missing nullable column at open. * fix(native-chat): a draft a Stop or restart took back waits again instead of blocking the queue Cards A, B and C wait; the turn ends and the drain consumes A, but the agent has not taken it yet. A Stop then pauses B and C and withdraws A's submission, which made A a returned card. The user's next send lifted B and C, yet a returned card blocks everything behind it, so B and C never sent although they read "sends after your next message". A restart or close before hand-over did the same. Nobody failed the user there, so the draft now goes back to waiting at its own position, under the hold that same event put on the drafts behind it: a Stop's 'stopped', or no stored hold after a restart, whose hold derives from the host instance. It carries no refusal, and records its spent submission id in consumed_as, so its next consume (the drain, or Send on the card) mints a fresh id through the same path a returned card's re-send uses. Provider refusals and other failures still return the card. The live settlement hook and the open-time repair share one decision. After a Stop and the user's next turn, A drains first, then B, then C, one per turn. * fix(native-chat): Delete and Send on a queued card answer at once during a /compact A /compact holds the chat's serialized lane for its whole provider call, and the queued-card Delete and Send ran on that lane, so both hung until the compaction finished. They now run on the side lane a draft-only send already uses while a compaction is in flight: Delete completes at once, and Send reaches its readable "wait for the conversation operation" refusal at once. The drain stays on the main lane and keeps its command hold, so nothing sends until the compaction settles. * fix(native-chat): a re-sent returned card drops the refusal it came back with Re-consuming a returned card left returned_reason and returned_rejection on the now-dispatched row, so the row described a refusal that no longer applied. The consume clears both in the same update that moves the card to dispatched. * perf(native-chat): the queue gate reads pending prompts without rendering the journal The prompt check ran on every send admission and drain step, and read journal.snapshot(), which copies and sorts every item in the chat. It now walks the reduced items in place with journal.visitItems; the answer is the same, since the snapshot only sorts those items. * fix(native-chat): a Stop that fails leaves the queued cards as it found them Stop holds the waiting cards before it withdraws queued sends and interrupts the agent. When a later step threw or the Stop was refused, the cards stayed paused ("sends after your next message") although a failed Stop is meant to change nothing. A failed Stop now undoes exactly what it added: each card it held gets back the hold it replaced, a consumed card its withdrawal sent back to waiting is released, and the user sends it had set aside can again lift the pause. Holds an earlier Stop or a restart put on the cards stay. The hold SQL moves to its own module, and the draft store's standalone transactions share one helper. * docs(native-chat): confirmed cancellation is no longer a queue rollout prerequisite Stop withdrawing queued sends with a typed cancellation landed on main with #23026. The comment gating the queued-messages capability now lists only what remains: the Codex steer matrix (#21062), the Claude fold receipt, turn-owner bars, and the desktop and phone clients. * docs(native-chat): the Claude fold receipt and turn-owner bars have landed; Codex steer and the clients remain * fix(native-chat): Send on a queued card during a /compact is refused before it takes a lane Send-now chose its lane once, at entry. During a /compact it took the side lane, where it could wait behind a Stop, then run after the compaction had settled and append a real submission unserialized against the main lane. While a compaction is in flight, Send-now is now answered with the "wait for the conversation operation" refusal before entering any lane, and otherwise it runs on the main lane. Only Delete keeps the side lane, whose compare-and-set withdrawal is safe on either. * fix(native-chat): a Stop that fails after reaching the agent keeps the queue paused A failed Stop undid its queue holds whenever it threw, including after the interrupt had already gone to the provider (a status-note write failing after cancelTurn, or after stopping a starting agent). The turn could be stopped while the cards drained as if no Stop was pressed. The Stop now marks the step that reaches the provider, and undoes its holds only when it failed before that. A Stop the agent refused answers ok and keeps its holds; the comment no longer claims otherwise. * fix(native-chat): a skipped draft settlement heals on the next drain step, not only at reopen The draft settlement rides each journal append as bookkeeping, and a failure there is logged and skipped. Only the open-time repair re-derived it, so a consumed draft whose submission was rejected stayed dispatched (invisible, and blocking nothing it should) until the chat reopened. The re-derivation is now its own function, shared by the open-time repair and the drain: whenever a dispatched draft's submission is already rejected, the drain step applies the owed settlement first. * fix(native-chat): one id is never recorded as a submission twice A second submission row under an id the journal already holds replaces the submission with a fresh pending one, so a rejected message could be handed over again under its own id. Send on a queued card could do exactly that: if the host died after it consumed the card under the operation's id but before its answer settled, the rerun consumed again under the same id. The journal now refuses a submission under an id it already records, so no id is delivered twice whatever the caller does. And a Send-now rerun that finds the card consumed under its own operation id answers with that submission instead of consuming again. * fix(native-chat): a waiting draft whose first send the agent echoed is withdrawn, never resent A consumed draft goes back to waiting when its submission is rejected as never delivered (a Stop's withdrawal, a restart, a close), and then sends again automatically. That rests on the "never delivered" claim. If the provider then echoes that message, the first delivery happened, and the automatic resend would give the agent the same message twice. The reducer already keeps such an echo apart, since a rejected submission may not claim it, so the draft store reads it from the appended row itself: a provider echo of a user message that no live submission claims, matching a waiting draft whose spent submission is rejected, withdraws that draft the way a Delete would. The echo-claiming rule is split out of the reducer's aliasing so both read the same decision, and the per-row draft hook moves beside the settlement re-derivation. * feat(native-chat): a submission names the queued draft it hands off Clients told a queued card's hand-off apart from other sends by comparing the draft's id with the submission's id. That holds only for a draft's first hand-off: a re-send, or a draft that goes back to waiting and drains again, goes out under a fresh id, and the clients showed the card and the sent message together, or restored text the host still held. Every submission the host creates by handing off a draft now carries queuedMessageId, the draft's id. It is written on the submission's journal row as an optional key (older readers keep it and ignore it), carried by the reducer, listed in the published submission schema (which otherwise strips it), and stamped where the row is built from the consume itself, so no hand-off path can leave it off; a caller naming a different draft is refused. A direct send names none. The queued-messages capability comment makes the link part of v1. * refactor(native-chat): every queued draft goes out under a fresh submission id A draft's first hand-off reused the draft's own id as the submission id, so comparing a draft id with a submission id looked right in every first-send test and failed only on a re-send or a requeued draft. Every hand-off now uses a fresh id (the drain mints one; Send on a card uses its operation's id), so id equality is never true and a reader must use the submission's queuedMessageId. The host gets simpler: queuedMessageNeedsFreshSubmissionId is gone, consumed_as is set on every dispatched row and cleared when a withdrawal sends the draft back to waiting (its spent submissions stay findable by their link), the consume refuses the draft's own id, and the consumedAs ?? messageId fallbacks collapse. The delivered-echo check finds spent hand-offs by link. A send this host queued, asked again (a lost answer's replay, or a rerun the operation ledger no longer covers), answers from its draft and then from the hand-off that names it, through one function. The rerun path used to be kept from sending twice only because a submission sat under the send's own id; with fresh ids that guard is now explicit. A Send-now rerun recognises its own consume by the link instead of consumed_as. * fix(native-chat): an echo withdraws a draft only if its rejected hand-off reached the agent The delivered-echo rule withdrew a waiting draft when a provider echo matched any rejected hand-off of it, including one a Stop rejected before it was ever handed over. That hand-off is provably unwritten, so a matching unclaimed echo is some other message, and the rule silently deleted the card. Only a hand-off that was handed over and then rejected as never delivered can be disproved by an echo now. * fix(native-chat): a skipped echo withdrawal is re-derived before the draft can send again The delivered-echo withdrawal rides each journal append as bookkeeping, and a skipped hook left the draft waiting, so it later sent the same message a second time. Nothing re-derived it. The draft store now also withdraws, in its owed-settlement pass, each waiting draft that an echo already in the journal proves delivered: an unclaimed provider user message (still stored under its own id), carrying the draft's payload, appended after a hand-off that was handed over and rejected. The live hook and the re-derivation share one predicate. The pass runs at open and in the drain step, right before a draft would send; it reads every item, so it never runs per streamed row. * fix(native-chat): a rolled-back journal append leaves no draft state cached The draft store caches its row list by revision. The per-row hook read that list eagerly inside the append's transaction, after the consume in the same transaction had already written and bumped the revision, so a failed COMMIT left the cache showing a hand-off that never happened. The hook now reads the drafts only once a row holds an unclaimed echo, and any rollback of a journal append or of its bookkeeping savepoint invalidates the cache, so no other read inside the transaction can leave it stale either. * fix(native-chat): a replay of a deleted queued card answers withdrawn, not refused Once a deleted card's tombstone is pruned, a replay of the send that queued it found the draft through its last hand-off. When that hand-off had been rejected (the card came back, and the user then deleted it), the replay answered with the rejected submission, which clients show as a failed send with a Retry. Only a withdrawn row is pruned while its last hand-off stands rejected, so the replay now answers queued, withdrawn. * refactor(native-chat): name the queue's pause-lift for what it releases * chore(native-chat): one import of the mutation helpers * feat(native-chat): a Stop pauses the whole queue, derived from the journal, with an explicit Resume After a Stop, each waiting card was held on its own row ('stopped'), lifted when the host saw, in memory, that a user send made after the Stop had its turn accepted. The cards read "sends after your next message" one by one, there was no way to resume the queue without sending something, and the in-memory record of user sends was lost on a restart or eviction. The pause is now the queue's, and derived rather than stored as a flag: - 'stopped': the user's last Stop took effect at a recorded journal position and no turn a person asked for has started since. "A person asked for it" is the new `origin: 'client'` on the submission row (a send over the client send RPC, or a card they sent now); orchestration mail, a restart continuation, a host-sent launch prompt and the queue's own drain record `host` and never lift it. - 'restarted': a waiting card was written by another host process and no person's turn has started since this conversation opened. Resume (`agentSession.queuedMessagesResume`) lifts either. Send-now sends one card; the rest stay paused until that card's turn starts, which is a person's turn like any other. The journal's row kinds are closed (an older build truncates a journal at a row kind it does not know), so the one event the journal cannot carry, where the Stop took effect, is recorded beside the drafts in `queued_message_pauses`; everything after it is read from the journal. A Stop records it only once it takes effect (after withdrawing queued sends, as it reaches the agent), so a Stop that fails first leaves nothing to undo, and the per-row hold, its undo and `userSendsAwaitingTurn` are gone. A card keeps a hold of its own only when its conversion failed ('send_failed'). The pause is published once, as `queuePause` beside `queuedMessages`, on live frames, catch-up and history. A /clear starts its replacement paused, as after a Stop, since the carried cards were written for the context it discarded. * feat(native-chat): a /clear's replacement queue reads paused because of the clear, not an interrupt The replacement's pause was recorded as 'stopped', which clients show as "Queue paused because you interrupted" although the user cleared the chat. It is now its own reason, 'cleared', on queuePause.reason ('stopped' | 'restarted' | 'cleared'). It lifts and resumes exactly like a Stop's: through Resume, or the user's next turn starting on the replacement. * fix(native-chat): a queue pause covers only the cards it paused A Stop recorded its pause fact even when the queue had no cards, and the fact outlived the cards it did pause. The published list hid a pause over no cards, but the drain still treated the queue as paused, so a card typed much later — during an orchestration-mail turn, or a correction typed before the stopped turn ended — sat under "paused because you interrupted" with no Stop of its own. A Stop now records its pause only if the queue holds a card when the Stop takes effect (the hand-offs its withdrawal sent back included). The fact is retired in the same transaction as the Delete, consume or withdrawal that empties the queue, never from an async publish. A /clear's carry now lands each card with its 'cleared' pause in one transaction, so a failed insert leaves no pause over an empty replacement. * perf(native-chat): the queue's pause reads the latest person's turn in O(1) The pause is derived on every publish, per subscriber, and each derivation copied and scanned every submission to find a person's accepted turn after the Stop. The reducer now keeps that fact as it folds rows: the submission row of the latest accepted turn whose origin is `client`. The Stop's and the restart's lift both read it directly. * fix(native-chat): a card handed off after a restart belongs to the process that sent it A draft's host_instance was only ever the process that first wrote it (or adopted it while waiting). A returned card from before a restart, sent again in this process and then withdrawn back to waiting, still carried the old process, so it raised a 'restarted' pause although no restart happened since it was sent. Every hand-off (the drain, Send on a card) now stamps the handing-off process on the draft in the consume's own update. * fix(native-chat): a queue pause shows only while Resume would send something After a Stop whose only remaining card was a returned one, or after a restart with only a card held by its own failed send, the queue published a pause with a Resume that could send nothing: a returned card waits for the user anyway, and a held one for its own Send. The pause is now published, recorded by a Stop, and kept only over a card it can hold back — waiting, with no hold of its own. The fact is retired in the same transaction as the write that removes the last such card, a hold or a refusal included. The publication's dedup also compared only the pause's reason, so a pause appearing or clearing with no readable reason could read as unchanged; it now compares presence first. * fix(native-chat): a queue pause counts only cards Resume would actually send A waiting card behind a returned one is blocked until the user acts on the returned card — the drain never sends past it — so a pause over only such cards still offered a Resume that sent nothing. The rule for "a card Resume would send" is now one function: waiting, no hold of its own, and not behind a returned card. The publication, a Stop's record and the fact's retirement all read it; retirement reads the rows in position order inside the same transaction as the write that took the last such card. * fix(native-chat): a returned card that blocks the paused cards hides the pause but keeps it The last change retired a Stop's pause as soon as a returned card blocked every paused card. Deleting that returned card then sent the cards behind it at once, with no Resume — not what the user asked for. The two rules are now separate. The pause is KEPT (recorded by a Stop, retired in the same transaction as the write that takes the last one) while any waiting card with no hold of its own exists, wherever it sits. It is PUBLISHED only while such a card is not behind a returned one, so the header never offers a Resume that sends nothing. Deleting the blocking card shows the pause again, and the cards behind it wait for Resume or the user's next turn. * fix(native-chat): a Stop pauses a card its withdrawal sent back even when that settlement was skipped The Stop checked the draft table for a card to pause. When the per-row hook that settles a withdrawn hand-off was skipped, that card was still 'dispatched', so the Stop recorded no pause; the drain later healed it back to waiting and sent it, although the user had pressed Stop. Retirement had the same blind spot and could drop a pause while such a card was owed. What a pause holds back is now one predicate, judged inside the transaction that records or retires it: a waiting card with no hold of its own (one SQL EXISTS), or a dispatched card whose consumed submission was rejected with a settlement back to waiting (read against the journal's submissions). The Stop first runs the owed settlement, as the drain does; if that fails, the owed card still counts, so the pause is recorded rather than skipped. recordPause now checks inside its own transaction and returns whether it recorded, and any draft-table write (and the per-row hook, the consume and the open-time repair) retires a pause that no longer holds anything back. * test(native-chat): pin the per-row hook's pause retirement; skip the judgement when no pause exists The retirement test recorded its second pause over a queue with nothing to hold back, so the recording returned false and the "retired" assertion proved nothing; ablating the per-row hook's retirement passed every test. The hold case now asserts the pause was recorded, and a new test has a delivered echo, through the per-row hook, withdraw the last card a recorded pause holds back. Retirement runs on every appended journal row, so it now checks the pause row by key first and judges nothing when no pause is recorded. Two comments were brought in line with the owed-hand-off rule and rewrapped. * test(native-chat): match main's append and dispatch shapes in the queue tests * fix(native-chat): read a compaction's settled submission through the send-result union --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
18327d9665 |
fix(claude): a queued message Claude withdrew is settled from Claude's own cancelled event (#23862)
* fix(claude): settle a queued send the CLI withdrew from its own cancelled frame Claude reports each uuid-stamped command's lifecycle (queued, started, completed, cancelled). A send it withdraws from its queue gets `cancelled` before the interrupt or cancel_async_message answer, so a lost or failed answer no longer leaves that send pending: it settles as withdrawn, with the same reason and words as the receipt path. A command the CLI already started also ends `cancelled` when its turn is interrupted or fails, so `cancelled` after `started` is not a withdrawal; an echoed send has left the waiter lists and is never reached. Tests replay real 2.1.280 captures, scrubbed. * fix(claude): release a doubted send when the CLI reports its session idle A Claude send whose write ended in doubt is recorded `unknown`, and a live `unknown` reads as work still owed, so the chat showed Working until the child exited. Claude sends `session_state_changed idle` only once its whole queue has drained, so it can no longer be holding that send. The runtime now routes that report to the host's existing release, the same one Codex's thread-stopped report uses; it retires `unknown` only, never `pending`. * fix(claude): keep a command's started mark when a redelivery re-emits queued; fixtures name msg_lifecycle_v1 |
||
|
|
d60f999f94 |
fix(native-chat): turn facts come from the turn record, and /compact is a message the chat sends (#23059)
* refactor(native-chat): remove the unused terminal handoff No client ever called agentSession.requestHandoff or mounted the handoff chrome. Delete the handoff coordinator, the terminal-owner runtime, the proof write path and the unmounted UI. Keep agentSession.handoffStatus, which released desktop clients read for worktree activation, and let records an older build left mid handoff reconcile through the ordinary restart and recovery paths. * fix(native-chat): never let the pre-stop snapshot hold a chat's stop Eviction now drains delivered events before quit's resume-offer snapshot. An unbounded wait there sits ahead of the provider stop, so a sink whose journal write stalls kept the child running until the step deadline aborted the eviction. The offer is advisory: bound the drain and stop the child regardless. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(native-chat): drop helpers only the terminal handoff called `claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and `queryWindowsProcessRowsFresh` lost their last caller with the handoff. The fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`, the teardown path that still depends on that contract. Co-Authored-By: Claude <noreply@anthropic.com> * docs(native-chat): stop citing the removed handoff in lifecycle comments Six comments still named the handoff coordinator, a handoff suspend, or a terminal-owned session as live participants in the flows they describe. Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): type the stalled snapshot drain without a cast Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): pin that a start dead before proving owes no settlement The removed restart handoff test pinned this branch; nothing else did. Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): keep the owner-status read behind an in-flight attach The handoff removal dropped the per-session queue from `handoffStatus`, so a read landing mid-start reported the reservation (no owner) instead of the settled chat owner, and shipped desktop clients blocked worktree activation on it. The read is queued again, as it was before the removal. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(terminal): remove the agent-session PTY write gate The gate only refused a write when a PTY had been bound to a chat session, and the only code that ever bound one was the terminal handoff this branch removes. With it gone, every admit/readmit returned "admitted" unconditionally, so the checks on the renderer write path, the runtime controller backstop, terminal.send, agent prompts, preview input and orchestration pointers, the refusal fields on terminal.send and worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane orchestration routing could no longer run. Ordinary writes take the same path in the same order as before. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(native-chat): drop the transcript helpers only the handoff called appendLegacyTranscriptMessages fed the terminal transcript catch-up and proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost their last caller with the handoff. Their tests now go through the live entry points instead: the roster bounds through the legacy import, the pinned-read and growth tests through the ancestry replay the history window uses, and the marker rules through the string proof in their own file rather than the session-file resolver's. Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): stop calling a starting chat "mid-handoff" A send refused because the chat's owner is not settled showed "The session is mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that reach it are a chat that is still starting, or one whose previous agent process has not yet been confirmed stopped. The message now says which of the two it is. The refusal code is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): type the stand-in roster decoder without a cast Co-Authored-By: Claude <noreply@anthropic.com> * refactor(codex): name the pinned rollout lookup for what it does With the terminal handoff gone, the module named codex-tui-rollout-proof holds only the pinned rollout lookup that structured Codex launches use to resume a thread, so the name described code that no longer exists. Rename the module and its options type. Also drop a mobile allowlist assertion that pinned the removed agentSession.requestHandoff method, which no longer exists to allow. * refactor(native-chat): type the owner-status reply as the host sends it The handoffStatus reply type still listed the terminal handoff's fields and states (terminal placement, host label, proof retry, queued and waiting phases, the to-terminal direction). No host writes them any more and the only client reader parses the reply as unknown, so they described nothing. The reply on the wire is unchanged. * refactor(native-chat): normalize terminal-handoff lease values once at decode Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types still admitted them, so readers across the host kept branches for values no path produces and the compiler could not point at them. The store now validates the on-disk shape, which still accepts those values so an older record is not quarantined, and maps them once while parsing: - `preparing` and `old-owner-stopped` become `recovering` - a `tui` lease becomes `native`; when it records a process it also becomes `conflicted`, the claim every build probes but never stops. A plain native owner would be stopped by restart recovery, here and in older builds. Revisions are taken over the normalized state on both sides of every compare, and the mapped record reaches disk with the store's first transaction, the same way the tab-id backfill does. The in-memory types narrow to what this build writes, and the branches that existed only for the removed values go. Structured-worker identity keeps its verdict for a former terminal owner by refusing a conflicted claim rather than a non-native kind. * refactor(native-chat): stop threading the owner kind through a reservation A reservation only ever names a native owner now, so the request no longer carries a kind and the reserved lease records `native` directly. The attach params keep `runtimeKind`: agentSession.ensure and create accept it, and the operation fingerprint stored in the ledger covers it. * test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else Hiding a tab also committed the visibility index, so the no-op transaction wrote the file even when its open-time revision was wrong. Committing the index first leaves the pending rewrite as the only reason to write. * fix(native-chat): name a chat write by its target, not the owner generation A write carried the fence of the last frame the pane read, and the host refused it unless that fence was still current. An idle release and the restart after it each move the fence, and the release publishes nothing, so a send after a release was refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a cold start was refused as stale. Every write already names what it acts on: a send its conversation, a cancel its turn, a prompt answer its item revision, a rewind its epoch; an option is last-writer-wins. So admission stops comparing the client's fence, and the rebase that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it. The writer-lease check stays, and so does the attach's compare-and-swap. Frames now stamp the fence read when each frame is sent instead of a copy each subscriber kept, which went stale on the same release. * fix(native-chat): every journal append reaches the chats that are open A journal write and its delivery to open readers were two calls, and some writers made only the first. A failed start whose lease could not be handed back, a provider revision with no frame behind it, and eviction's settlement were all journaled without reaching an open chat. A journal handle now reports every durable change, and the host's session map binds that report to the session's readers when the handle is set. Writers no longer publish what they append; the per-writer publish calls are deleted. * test(native-chat): an epoch replacement reaches the open chat * test(native-chat): each row reaches an open chat once, and a live handle enters only through the map * test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite The seeded record had no surface tab id, so the next open backfilled one and that rewrite alone made the no-op transaction write. The test passed with the legacy-lease rewrite signal removed. * test(worktree-activation): restore the OMP surfaced-agent resume test The handoff removal deleted it alongside the terminal-owner tests, but it covers the surfaced-PTY block that still guards resume, including an agent whose ownership is unknown. * perf(native-chat): a publish behind a delivered commit reads nothing Each commit now delivers itself, so the publish a provider frame still sends afterwards found every reader caught up but still read rows and rebuilt the timeline for each one. A caught-up reader now skips the read. * test(native-chat): state why the teardown test's fake journal is safe to cast * docs(native-chat): say mutation admission checks only the writer lease * docs(native-chat): drop the send rebase from comments that still described it * fix(native-chat): a message is accepted, then delivered A send to a chat with no running agent restarted the agent inside the send call, before the message was recorded, so the client waited for the whole start and a failed restart refused the message. Claude held prompts sent during startup, and those could settle as "unconfirmed". A send is now accepted inside the session's serialized queue: one ledger row and one submission row marked handoverRecorded, published, answered pending. A per-session delivery loop exists while a message is queued. It starts the agent through the same serialized attach a hold uses, waits outside the queue for a Claude child to prove its start, and hands the oldest queued message over as its own serialized step, writing dispatch{pending} before the adapter call. A start it needed and did not get writes one error-tone row and rejects every queued message with the same words; a start Stop cancelled writes none. Settlement follows from the rows. A queued message is provably unwritten, so a close, an eviction or an exit rejects it. A handed-over message stays in doubt. A queued row at or below the sequence a handle found when it opened was left by an earlier process and is rejected at open, with no latch. Stop withdraws queued messages with no writer lease and no fence. An attach failure keeps the conversation open, and the attach adopts its journal. Owed work counts the loop and queued rows. A compaction or rewind found prepared when a conversation opens was started under a child this process no longer has, so the open settles it rather than leaving it to refuse every send until a view attaches. The open cursor is scoped to its epoch, because sequences restart when an epoch is replaced. Deleted: restart-before-admission, recordFailedRestart, the fence rebase, Claude's startup gate, the attach's forget on failure and its own crash boundary. Clients without agent-session.accepted-send.v1 get their reply held until the handover; the desktop and paired desktop lists advertise it. * fix(native-chat): settle queued messages only for the child that ended A child that proved its start and then exited before its message was handed over left the message queued: the exit settlement returned early when nothing else was in flight. Delivery then started another child for it, and a child that died the same way started another, without end and without a row. A retried settlement for an earlier generation, run by the attach that delivery started, did the opposite: with that generation's turn unfinished it rejected the message queued for the child being attached. The settlement now takes the rejection for queued messages from its caller. The unexpected exit and the eviction pass one, and it applies even with no other work in flight; the retry for an earlier generation passes none. * fix(native-chat): an adoption that fails to import keeps the conversation open The attach now writes into the conversation's own open journal, but a failed transcript import still closed it as if it were the attach's provisional one. The conversation stayed indexed with a closed journal, so every later send answered "could not be recorded" and every attach failed again until the app restarted. The import now closes only a journal the attach opened for itself. * perf(native-chat): the recovering open reads the journal once Every conversation open now goes through the recovering open, including the read restore of every chat at startup, which used to replay its journal once. The recovering open replayed it twice: once to probe it and again inside the open. The probe is now handed to the open as its load. * fix(native-chat): an attach that fails after indexing its child leaves no child behind A failed attach now keeps the conversation open, but a failure after `onAttached` indexed the child (the rewind or compaction recovery, or the attach's own success record) left that entry claiming a child the failure path had already released. The next send found the phantom, skipped the start, and wrote at a fence the journal had moved past, so the message stayed queued for good. The entry now drops the released child and its event sink, and follows the record's fence, as a failure before indexing already did. * fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer The error strip for a message the host accepted and then did not deliver matched the entry before the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not send your message" with nothing to retry. It now reads the reconciled entry. A rejection the journal records before the send's own pending answer lands is final as well: that answer no longer puts the entry back to dispatching with no Retry. * fix(orchestration): a structured worker whose agent outlasts the preamble wait is left unknown, not torn down The preamble waits for its submission to be delivered while the worker's agent starts. When that wait ran out it threw operation_unknown, and the failed-start teardown then closed the session, which rejected the very preamble the host was about to deliver. It now reports a turn start nobody observed yet: the worker is start-unknown with its session kept, the host delivers the preamble when the agent starts, and the worker's report settles the dispatch as for any unobserved start. The receipt no longer suggests reading a screen a structured worker lacks. * fix(native-chat): a message rejected while its chat was closed reads as not sent A remount reads an entry it left dispatching as unconfirmed. When the journal had rejected it meanwhile, as a failed start or a quit now does, the reconcile left it unconfirmed: it blocked every later message behind a Retry and no reason, and the delivery probe, seeing the journal already answered, never ran. The reconcile now settles it as rejected like a dispatching one. * test(orchestration): name why the readiness settlement fakes are cast * fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent * docs(native-chat): drop the fence from the admission the send effects run behind * docs(native-chat): give the fence move on release the reason that still holds * docs(native-chat): stop citing a write fence check in launch and mailbox comments Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease. * refactor(native-chat): the provider child is its own record A conversation now outlives any number of provider children, so the child is one record on the conversation's entry instead of five loose fields beside its journal. It is written in one place: indexed only once an attach has fully succeeded, and ended through one function that an exit, a failed re-attach, a Stop and an eviction all share, matched on the child's generation and fence. - A failed attach writes no child, so there is nothing to unwind: the field unwind and the fence patch after it are gone. - Conversation writes read the record's fence, the way mutation admission already does; a child's own writes use its fence. The four stored-fence patches, and the settlement retry's overwrite of the conversation's fence, are gone. - The owed wind-down is its own tombstone, carrying the child it is owed for, and is no longer dropped when an attach replaced the whole entry. - Stop on a child still proving its start stops only the child: its lease goes back and the chat is told it is idle, but the journal, the holders and the readers stay. Close is that stop plus the conversation's close. - The settlement retry uses the conversation's own journal, opened through the host's one open. * fix(native-chat): the delivery loop alone settles a message its start or child failed A queued message was settled by whichever path happened to end the child first: the loop, the unexpected exit, eviction's work settlement, the open's leftover rule, and the startup branch that rejected every pending row. That gave two failure rows with different tones for one start, a loop that could hand over to a different child than the one it waited on, and a Claude start that died while starting reading unlike every other failed start. - The loop remembers the child it waited on. At handover, if that child is gone or replaced, it reads how it ended: a Stop continues; anything else writes one failure row and rejects every queued message with the same words, then stops. A child still starting whose start the adapter says did not land fails the same way. The exit, eviction and the settlement retry only settle the handed-over and legacy rows of the child that ended. - One failure row, always an error, keyed by the start. A start a view began that dies with nothing queued writes the same row through the same builder, so a second report revises it. - The open no longer rejects leftovers; the loop's first step does, and the open wakes it. - `awaitStarted` answers why a start did not land, so the row says it even when the loop sees the failure before the exit is processed. - Quit closes every conversation the way closing a chat does: what is still queued is rejected as closed, with or without a child, and a start the loop already has in flight is waited for so the child it produces is stopped rather than left behind. * refactor(native-chat): a stopped child ends on the one reading of its stop The eviction step reads a stop's result through `stopAgentSessionProviderRoot` and hands that verdict to the child's ending, so the host never forms a second view of whether the root is gone. Every ending carries it: a stop's comes from that reading, an exit's root is gone by definition, and a failed re-attach passes what its release saw. The end-of-child record can therefore also carry a stop whose root was not seen to go, which nothing ends on yet. * feat(native-chat): the host says it accepts a send before any agent has it The host now lists agent-session.accepted-send.v1 among its own runtime capabilities, the same string capable clients already send. A client can then tell a host that answers a send at acceptance, and admits a Stop with no writer before a turn starts, from an older one that still restarts the agent inside the send. Additive: an older client ignores a capability it does not know. * refactor(native-chat): an attach never opens a journal of its own The attach adopts the conversation's open journal, which outlives it, so it no longer opens one for a direct caller either. That leaves nothing for a failed adopted import to close, and the flag that told the two cases apart is gone. Tests that attach without a host open the conversation the way a host does. * fix(native-chat): a moved fence resends nothing on a host that accepts first The outbox treated any fence change as a new owner: it dropped the answer of a send in flight, queued that send to go out again under the same id, and unblocked a refused head. On an older host that is how a send the restart refused, unrecorded, gets another try. On a host that records every send before it starts an agent, a fence moves because that start ran, so the same rule resent into every failed start. With a fence stamped on every frame, that became a loop. The outbox now reacts to a fence change only when the host has not advertised that it accepts a send before any agent has it. On such a host, only a Retry or a new send goes out, and a failed start reaches the client as a rejected message it keeps with its Retry. Against an older host, or before one has answered, the outbox behaves as it did. Desktop and paired web share this hook. * refactor(native-chat): a child's end says whether the user or the host stopped it The end-of-child record's cause now tells a user's Stop from the host stopping the child for a cause of its own: `user-stop` and `host-stop` replace `stop`. The delivery loop goes on after a user's Stop, as before, and fails the start it was waiting on after a host stop, with the one error row and every queued message rejected, in the stop's reason when it gave one. The reason stays description only. Stop passes `user-stop`; nothing passes `host-stop` yet. * fix(native-chat): a chat whose only work is a queued message is not offered for resume A message accepted while the agent was starting counts as working in the chat, and quit rejects it as never sent. The teardown snapshot read the same working rule, so a relaunch offered to resume a chat whose agent never had the message. The snapshot now reads only what was handed over. * fix(native-chat): the conversation outlives its agent Opening a chat no longer starts its agent. A conversation is reached through one host accessor that opens its journal at rest, and a send is what starts the agent, through the delivery loop. One idle sweep, every five minutes, stops an agent that has been quiet for thirty minutes and owes no work, then drops an open journal handle that is only a cache. Its record, tab, status row and readers stay. - hold and release are no-ops; hold still builds the host for shipped mobile builds. - The holders, the holds, the release clock and the exit respawn are deleted. - Options, the model list, the goal and the context meter answer at rest; a model pick at rest is recorded as intent for the next start. - Compact, rewind, clear and goal changes start the agent first. A send does too when a rewind is still in doubt after the conversation opens. - Orchestration routes mail and group addresses on ownership (the record plus the chat tab), not on whether the process runs. An open dispatch keeps its worker running. - The restart continuation is a send; Resume all holds each slot until the message is handed over or rejected. - A read error never replaces a loaded transcript, and shows the host's own words. * test(native-chat): type the queued-message fixtures in the resume-offer tests * fix(native-chat): a start that dies while a message waits on it is that message's failed start Opening a chat's tab starts an agent for the view, and a send accepted meanwhile waits on it. When that start died, its exit wrote the start's error row and left the message queued, so the delivery loop started a second agent into the same failure and wrote a second row. A child's end now records where the conversation's journal stood, and the loop settles a message accepted before a failed start ended with that start: one row, under its key, and no second start. A message sent after the failure still gets a fresh start. * fix(native-chat): a request that failed reads as failed A structured chat whose only message the agent's start refused read as a green finish, and a cancelled structured turn did too: the host published a verdict only for turn records, and structured rows carried no `interrupted`. The host projection now reads the session's latest request: its turn's outcome, or `failure` for a send the agent or its start refused. A send that was withdrawn, or left undelivered by a restart or a close, fails nobody and makes nothing listable. The ingest publishes `interrupted` as the hook lanes do, and every reader decodes the verdict through one accessor, so a failure reads Failed on the dot, the rollups, history and `worktree ps`, behaves like a cancellation in every clean-finish policy, and notifies as "failed". * docs(native-chat): say what an attach's open conversation and unconfirmed ids are now * test(native-chat): a verdict change republishes the mobile status projection * refactor(native-chat): the store's retention trigger keeps its flag compare A verdict change always moves the completion clock the same check already reads, so a second verdict compare there caught nothing new. * test(native-chat): a user message the provider journaled keeps its session listed * test(native-chat): pin what a failed start settles, and what a resume offer names A view's child that dies while a sent message waits settles that message only when it died starting and no child has taken its place: a proven child's crash, or a second start since, gets the message delivered. The resume offer names the handed-over message, never a newer one still queued. * test(native-chat): the failed-start pins fail on what the message became, not on a timeout * fix(native-chat): a restart offer ends when the chat's agent starts again The offer used to end only when the chat's newest user message changed, because opening a chat started its agent and that start could not be told apart from real activity. Opening a chat starts nothing now, so the host reads the fact it already publishes: a chat's status row goes from not host-owned to host-owned exactly when its agent is started. At that edge the offer and any failure record for the chat are withdrawn, unless the start is a resume action's own (its continuation is the oldest undelivered message). A continuation and a message racing to be first are decided at acceptance: the continuation is refused, quietly and with nothing filed, when any other message was accepted since the restart. A failed continuation start leaves the offer retryable, and each resume action sends its own message id. Deleted: the newest-user-message comparison, its journal reader, the continuation filter, and the failure ledger's own "answered by the chat" check. The marker still carries its message id for one release, so the previous build can read it. * fix(runtime): end a transcript stream when its client unsubscribes Desktop: the IPC subscription controller was dropped as soon as the streaming handler returned, which for most streams is right after it binds. A later runtime:unsubscribe then found nothing to abort, so the host kept the subscriber and derived and sent every publish to a channel no one listened to. The controller now lives until the renderer unsubscribes, resubscribes the same id, or goes away. Mobile: disposing an agentSession.subscribe stream now sends agentSession.unsubscribe with the stream's frame id, so the host ends that subscriber and leaves a sibling stream on the same socket running. The direct path now passes the frame id the relay path already passed. * fix(native-chat): a late provider-session update keeps a failed recovery record failed A provider-session heartbeat that rewrites a completed recovery record kept its interrupted flag but dropped the outcome it was copied with, so a live failed checkpoint read as a clean finish until the next status write. * test(orchestration): the preamble's host stub is typed, not cast The preamble send now takes only what it reads of the host, the send, the settlement wait and the record's fence, so its test builds that host with real types instead of `as never`. * test(native-chat): the terminal-bell check asserts the renamed verdict field The bell notification test still checked for agentInterrupted, which no longer exists, so it could not catch a verdict leaking into a bell dispatch. * fix(native-chat): a failed turn ranks like a completion for attention Attention readers (completion time, Smart Sort, sticky retention, Cmd+J Recent) now demote only a turn the user stopped. A failure is news the user has not seen, so it keeps its completion time, ranks in the Done class, stays retained after its pane goes away, and a retained failure reads failed in the worktree rollup instead of done. Clean-finish policy (hibernation, pane ownership, the value moment) still treats a failure like a stop. The retention trigger compares verdicts again: success -> failure no longer moves the completion clock. * fix(native-chat): one fact ends a restart offer: the chat moved on since the restart The offer is live while no other message has been accepted in the chat since the restart and its agent has not proved a start since. The offer list, the resume's reservation check and the continuation's acceptance check all read that one fact, so a message whose start then failed withdraws the offer too, and a stale click finds nothing to act on. The fact is read off the conversation's open handle, which the restart closed, so it is retired durably whenever it may have changed: a message accepted, a start proven. A close and reopen within the same run therefore cannot bring the offer back. A continuation rejected before it reached the agent does not count, so a retry after a failed start still runs. Deleted: the quit-time gate on withdrawal, which changed nothing because the withdrawal and the quit's own offer write share one queue; the per-action "withdrawn" flag and the separate acceptance check it paired with. * test(native-chat): an older build reads the restart offer this build records The offer lives in a file the previous release reads after a downgrade. Pin that against the pinned release's own capsule, and run the lane when the marker or the capsule changes. * fix(native-chat): read a restart offer against where the journal stood when it was taken "Since the restart" was read off the conversation's open handle, which the idle sweep closes: after a reopen, a message the user had already sent looked older than the handle and the withdrawn offer came back. The offer now records the journal position (epoch and sequence) at the moment it is taken, and a message accepted after that position, or a journal on another epoch, means the chat moved on. That is derived from the journal, so it holds across any number of closes and reopens. An older build's offer has no position; only a start withdraws it. Because the message half is now durable, the offer is no longer rewritten in the recovery file on every accepted message; a proven start still writes it, since only the host that saw the start knows of it. * test(native-chat): wait for the listing's retire write before reading the recovery file * refactor(native-chat): every journal row states which turn it belongs to Rows gain a turn scope stated by the write that creates them: the open root turn, or the conversation. A queued message takes its scope from its handover. Rows stored before scopes existed are placed on replay by the root turn open when they were created, so no persisted state is needed for them. Rewind keeps each retained row's scope and producer, so a subagent's row stays its own. * fix(native-chat): keep the terminal-backed chat's read error over its local echoes Messages winning over a read error is right for the structured chat, whose read retries and whose messages came from the transcript. The terminal-backed view assembles its list from local echoes too (a launch prompt, a pending send), so a failed read there showed only those bubbles and no error. Only the structured pane now keeps messages over an error. * fix(native-chat): a start retries the exit settlement a failed journal write left owed An agent exit whose journal settlement write failed releases the lease latched until a retry lands. Reopening the chat used to be that retry; with reveal now only opening the journal, nothing retried it before the next app launch, and every send was refused. The start the send needs now runs the retry first, where the attach would. * fix(native-chat): a failed main agent reads failed while its subagents still work The verdict is now read from the main agent's own state, not the folded row: a main agent that is done and failed has a verdict even while its subagents keep the row working. Without mainAgent (history, worktree ps, older hosts) the old combined-done rule stands. Display marks the verdict through agentVerdictDisplayMark: a failure outranks every combined state on the agent's dot, label, tab badge, dashboard and activity rows; a stop marks only a done row, so a successful or stopped main agent with live subagents still reads working. Subagent rows keep their own state. The worktree card, terminal tab and Cmd+J rollups share one pane fold and rank a pending question, then failed, then working, monitoring, interrupted and done. worktree ps publishes the main agent's outcome on a working row, and the mobile mirror reads it. The store's change check, the paired-client mirror's equality and its epoch now see a verdict change on a working row, which otherwise moves no state or clock and left the worktree card reading working. Clean-finish policy is unchanged: a working row is never hibernated and has no completion time. * perf(native-chat): answer the owner check without opening the chat Worktree activation calls agentSession.handoffStatus for every chat tab in the worktree, and the answer comes from the session record alone. Reaching it through the accessor opened each resting chat's journal (a full read, the crash-boundary write and a restored status publish), then kept it open for the idle window. It now checks the record and the adapter's support, as before this series, and opens nothing. * fix(native-chat): a read waiting on the session lock opens nothing once quit began The accessor checked for quit before queueing the open, so a read queued behind a session task ran its open after teardown had begun and indexed a journal no teardown step would close. The check now runs at the open itself. * test(native-chat): pin stated turn scopes, the upcast of unscoped rows, and rewind attribution * fix(native-chat): /compact is a message the chat sends, run as a turn of its own The conversation command RPC now accepts /compact into the queue like any send and answers once it is handed over. The delivery loop opens the command's own turn, starts the provider on it, and waits for the provider's end off the session's queue, so messages typed meanwhile are held and delivered after it, even when it fails. It settles by re-reading the journal: a child that died meanwhile already wrote the verdict. Stop ends the command at once. The 180 s completion window, the unconfirmed row and the recovery of an older build's compaction record are gone; that record no longer gates anything. On Codex the provider turn the command opens is claimed into the command's turn. * fix(native-chat): read a failed resume's chat before calling it retryable Whether a failed resume is retryable is the offer's own rule: the chat has not moved on since the restart, read from its journal. The failure list read it only for a chat already open, so once the idle sweep closed a chat the user had moved on in, its failure showed Retry again, and the click did nothing. The list now opens the failed chats first, as the offer list does. * test(native-chat): type the provider event sink the settlement test reaches for * docs(native-chat): the worktree ps outcome comment no longer claims old hosts send it The field is new: an old host sends no outcome at all, so a reader falls back to interrupted. The removed clause said old hosts send it on done rows, which never shipped. * fix(native-chat): say the structured read keeps trying only where it does The structured pane's "Orca keeps trying to load it" line never showed: the view state filled in an untranslated fallback whenever the read error had no text, and the empty state prefers any message. The view state now leaves the message out, so the structured pane shows that line and the terminal-backed pane its own translated one. Mobile's structured lane does not resubscribe after an error frame, so it no longer makes the claim. * fix(native-chat): rows group under the turn their record names, not the one above them Each row's turn is the turn its stated scope names, anchored on the entry that opened it, or on the turn itself when the provider opened it unasked. So /compact groups its own rows and the previous turn is untouched, a message typed into a running turn joins it, and a provider-resumed turn folds under its own Worked-for. A row reporting how a turn ended, an error or the compaction separator, never folds. Desktop and mobile read the same keys; a host that states no scope keeps today's positional grouping. * test(native-chat): await the send's settlement instead of polling for the start The at-rest send tests polled for the provider start with vi.waitFor's one-second default, which a loaded machine outran. They now await the host's own settlement of the message. * docs(native-chat): the status-store listing rule names provider-journaled user messages * fix(native-chat): a restart offer resumes any time after the quit, and knows its own continuations The continuation's message id was dated by the quit, and the ledger refuses a new id dated more than a day back, so Resume or Retry a day after quitting was always refused (on main too). It is now dated by the resume action. Telling a rejected continuation from the user's own message read the operation ledger, whose rows expire after about a day; after that a failed resume stopped being retryable. The offer now records the continuation each action sends on its own capsule entry, bounded to the newest 16, so the ids end with the offer. The ledger read is deleted. * fix(native-chat): a /compact is not a request the sidebar, notifications or restart resume report The sidebar's prompt, preview, verdict and instant, the turn-completion feed, and the restart-resume marker read past a conversation command and its turn to the last real request, so a /compact neither notifies nor re-dates the row, and a command in flight is never offered as work to resume. An older client shown a command's turn in the legacy form names the session's own agent. * fix(orchestration): route no mail to a structured worker its orchestration released A structured worker is routed on ownership, and a resting worker's lease is released, so ownership held while its chat tab stayed listed. A worker the coordinator abandoned and then released, found at rest by the release, therefore still took peer mail and @worktree: broadcasts, and each one restarted its agent. Routing now also reads the orchestration's own resource row: once it is released, direct mail, group addressing and worker-show's addressable answer drop the worker, as they would a terminal worker whose terminal closed. The chat tab stays, and nothing new is stored. * fix(native-chat): a failed retry names the user's prompt, not Orca's continuation A resume's continuation is written to the chat before its start, so after a failed attempt the chat's newest user message is that rejected continuation. A second failure then showed Orca's own restart text as the chat's prompt. A retry now keeps the prompt its first failure named. * test(native-chat): pin what a conversation command's admission refuses at rest and at handover * test(native-chat): tests merged from the base state which turn their rows belong to * fix(native-chat): a refused send notifies failed through the completion feed The host's completion feed followed only the newest turn, so a send the agent or its start refused, which creates no turn, read Failed on its row but sent no notification. The feed now follows the session's latest request, read from the projection the status feed already makes for the commit: a turn keeps its id, a refused send is named by its journal item key. It announces only while the session is idle, as the row reports a verdict, so queued sends refused one commit at a time notify once, and a withdrawn send falls back to a request already announced. * fix(orchestration): read the released row optionally, as the authority does worker-show's observation called the row lookup directly, which a runtime double without it threw on and failed the structured tab-retirement release. * chore(native-chat): one import per module and no unexplained casts in the turn-scope changes * test(claude): pin which turn a Claude row joins, including a subagent's after the turn ends * fix(native-chat): the status bar drops a restart offer the chat moved on from The renderer re-read the host's restart offer only when a failed chat showed activity, so after a message withdrew a pending offer the host answered no chats while the status bar kept counting one, and clicking it opened nothing. The same watch now covers pending offers: a status change in an offered chat asks the host again, once. * fix(native-chat): a refused steer is read from the turn its handover named The latest-request reader decided whether a refused send had joined a running turn by comparing host clocks: its handover time against the previous turn's end. The handover row now states the turn it delivered into, so the reader reads that instead and the clock comparison goes. A journal written before handover rows stated a turn is scoped on replay from the turn open when each row was written, which can differ from the clock reading only when a send and a turn's end share a millisecond. * fix(mobile): the native-chat controller contract carries the turn journal The controller and overlay already pass nativeChatTurnJournal, but the contract type never declared it, so mobile failed to typecheck. * fix(native-chat): the live turn is the running turn, not the newest user row A turn the provider opened on its own (a background wake, a resumed turn) anchors on its own record, but the list still treated the newest user row as the live turn. While such a turn ran, the settled user turn before it lost its duration and the running turn's own rows were drawn as settled, so its tool calls lost their live state. nativeChatTurnMembership now answers both questions from the turn record: each row's turn, and the live turn (the running root turn's anchor, else the newest user row, which is also all an unscoped host has). Desktop and mobile key liveness, the timing clock and the live status's row on it. * test(native-chat): a turn the provider opened keeps its own clock Pins that the local turn clock follows the live turn, so a wake after a settled turn does not restart that turn's clock when no host durations are recorded. * fix(native-chat): a running turn no message opened draws its status on no row Its live status belongs to the transcript-tail indicator alone. Once it settles, its duration draws at its first row as before; a running turn a message opened still draws on that message. * fix(native-chat): every copy of a row carries the main agent's own status History entries, sleep records and `worktree ps` rows carried a flattened top-level `outcome`, copied under different gates and without the main agent's clock. They now carry `mainAgent` (state, outcome, stateStartedAt), the type the live row already persists and sends, and every copy site takes it with `interrupted` through one function, `agentVerdictFields`. - The accessor reads `mainAgent` then the legacy flag; the mobile mirror matches it line for line. - Sleep records admit `mainAgent` with `normalizeMainAgentStatusField`, so a malformed value drops the field, never the record. - Mobile dates a main agent that failed under live subagents by its own clock, as desktop does, and its row equality compares `mainAgent`. - The activity feed reads a history entry's own `mainAgent` instead of rebuilding one; the sync key and history equality compare it. * test(native-chat): pin the worktree ps verdict across host and phone versions Pairs the real v1.4.212 host and phone row reader with this build: an old phone reads a new host's rows by `interrupted`, a new phone reads an old host's rows (no `mainAgent`) the same way, and a new phone reads a failure under live subagents as Failed, dated by `mainAgent.stateStartedAt`. The release checkout now carries the phone's self-contained row reader, and the lane runs when the `worktree ps` row producers change. * test(mobile): name the parity table's row for its role * test(native-chat): a roster of idle or finished children does not keep an agent awake The sweep reads owed background work through the shared child-work liveness that upstream's release clock adopted; a child that went idle or finished is not work the agent still owes. * fix(native-chat): a request that settles while the user is asked something notifies once The completion edge waited for an idle session, and a pending prompt (including a subagent's approval) is not idle. Structured chat has no other attention producer, so a main turn that finished while a subagent waited on the user sent nothing until the prompt was answered. The edge now waits only on owed work (a running turn or an unanswered send), which the projection reports even beneath a pending prompt. A request that settles with a prompt pending announces once; the renderer words it "needs input" from the host status mirror's `attention`, and answering the prompt keeps the same request identity, so it does not announce again. The wire shape is unchanged. * fix(orchestration): a task dispatched into a resting structured worker keeps it running The sweep's open-dispatch check read only the worker-start dispatch that owns the worker's terminal resource, so a task later dispatched to the same worker (orchestration dispatch --to, which writes a dispatch with no worker row) did not count: after thirty quiet minutes the worker was stopped while that task was open, and its coordinator read exited. Any unsettled dispatch addressed to the worker's process incarnation now counts, derived from the existing rows. * fix(native-chat): a command's wait ends when its child does The delivery loop waited for a /compact only on the adapter's compaction tracker, which learns of the child's end only on some exit paths: a Codex exit or close, and a Claude close, never reach it. The wait then never ended, so nothing queued behind the command was delivered again, Stop had no child to answer through, and the tracker's leftover entry refused the next /compact. Every way a child ends passes endProviderChild, so the host now offers a per-child end signal there. The loop races the tracker against it (the dead-generation settlement has already written the command's verdict), and on that end asks every adapter to release the command, so a later command runs and no later provider turn is claimed into the dead one. The adapters' own exit-time releases were unreachable (Codex) or covered one path of several (Claude), and are removed. The Codex RPC test harness moves to its own module so the exit can be driven through the real adapter's connection callback. * fix(native-chat): keep refusing sends during a command on an older host An older host's controller still refuses a send while a conversation command runs, so dropping the client's block turned every message typed during /compact into a 'not sent' row with Retry there. The block stays for hosts that do not run the command as a send-path turn, and goes only for those that do. The signal is one the client already holds: a host that runs /compact on the send path states a turn scope on every journal row it writes, the same fact turn membership uses to tell it from an older host. Both now read it from one predicate. On an empty conversation, or one whose rows all predate the upgrade, the signal is absent until the command's own entry streams in, so that brief window keeps the old local refusal; no capability or wire field is added. * docs(native-chat): comments stop describing the hold this PR removed Eight comments still justified orderings and teardown choices by a viewer or dispatch hold that pinned the provider child. Nothing holds any more; the orderings stand for the binding's redrive subscription and parked mail, and a chat's agent runs from a send until the idle sweep rests it. Comment-only. * fix(native-chat): the completion says when the user is being asked A request that settles while a prompt waits on the user was worded "needs input" from the renderer's status-feed mirror. Remote clients receive the status and completion streams over separate sockets, so they can arrive in either order and the wording could be wrong both ways. The host already knows at emit time, so the completion now carries an optional `awaitingUser: true` in that case and omits it otherwise. The renderer words the notification from that field alone and no longer reads the status mirror. Old clients ignore the field and word by outcome; old hosts never send it. * fix(native-chat): a restart offer keeps the start its own continuation made Whose start ended an offer was decided at read time, from whether the offer's continuation was still the queued message. Once the provider refused that continuation, the child it had started read as someone else's start, so the offer ended and its failure showed no Retry. The delivery loop now records which queued message a start is for on the in-memory child, and the child's end carries it; the offer counts a start as its own when that message is one of its continuations. * fix(native-chat): a rewound turn still names the message that opened it A Codex rewind rebuilds the epoch without submissions, so each sent message survives only under its provider key. The kept turn records still named the submission key, so each turn anchored on itself and its rows grouped apart from the message that opened it. The rewind now renames the turn's opener along with the message. * fix(native-chat): Stop ends only the command it names Stop on a command turn abandoned whatever compaction the session had pending, so a late Stop for an earlier /compact cancelled the one running now. The tracker now ends a command only when the Stop names its turn, and the cancel reply reports whether it did. * fix(native-chat): an agent gets a full idle window after its owed work ends The sweep measured quiet only from the last journal row, so once a subagent, command, monitor or dispatch that had outlived the window ended, the agent was stopped at the next tick. A child can read done before the lead's wake-up turn writes anything, and stopping in that gap loses the wake-up. The sweep now counts owed work it observes as activity, which gives the agent the full window afterwards, as the release clock it replaced did. * test(claude): the options-read fixture runs a live child The fixture marked its conversation running with a hasProviderChild field the session type does not have, so the read took the at-rest path and refused a session with no record. It now carries a child, which is what the read checks. * test(native-chat): host tests reach its collaborators through a typed seam The rest-test rig and three test files read the host's private members with Reflect.get and cast the result. The host now exposes one test-only accessor, collaboratorsForTests(), and the subscribers class a subscriberCountForTests() beside its existing retainedActivityCountForTests(), so the tests are checked against the real types and the casts are gone. * fix(worktree-status): a departed agent's failure yields to live work on the worktree card A retained failed agent has no expiry, so ranking it with a live failure pinned the card to Failed over other panes' live work. It now ranks below working, monitoring and permission, and above every finished outcome. * refactor(orchestration): one owner answers a structured worker's custody Routing, group addressing, worker-show and the idle sweep each composed their own reading of whether orchestration still holds a structured worker, so each new obligation or retirement state had to be added to every reader. structured-worker-custody now derives both answers from the worker-terminal list state coordinators see in worker-list: addressable is owned and not released, and owed work is an active custody or an unsettled task dispatched to the same incarnation. The owner's state is read through the remote dispatch attachment too, as the terminal transfer lookup already does. Behaviour is unchanged; a settled worker awaiting its coordinator still rests. * refactor(orchestration): owed work is an open dispatch on the worker's incarnation A supervised worker's own dispatch context stays open exactly while the worker is active, so the separate active-custody branch only repeated it. Owed work is now one fact, which also states the policy that a worker awaiting its coordinator's decision may rest, and both custody decisions are written once at the top of the module. * docs(agent-status): a departed agent's failure ranks below live work on the worktree card * fix(native-chat): a restart offer knows its continuations by a tag in their id The offer recorded each continuation id in a list on its capsule entry, capped at 16, and a running action's id in memory. Both could disagree with the journal: past the cap an old rejected continuation read as the chat moving on, and a crash during a retry restored the failure's older entry, which lacked the retry's id. Each continuation id now carries a tag derived from the offer (its teardown and chat), then the action's own part, so any continuation of this offer, queued or rejected, is recognised from the journal row and the marker alone. The persisted list, its cap and the in-memory action map are deleted; the agent-start withdrawal keeps an offer whose own continuation the start was for, read against the stored marker. * test(runtime): the legacy-worker reveal test judges its stale snapshot inside the wait The tui-idle probe reads through readTerminal, which now awaits the structured worker check before the PTY read, so the probe's snapshot request starts a microtask later. vi.waitFor missed it on its first check and polled again at 50 ms, the same moment the wait's own 50 ms timeout fired. The stale snapshot then resolved after the wait had already timed out, so the test passed without judging it, and the rejection landed before any handler was attached. Vitest reported that as an unhandled error and failed the shard. Polling every 1 ms sees the request within a few ms, so the snapshot is judged while the wait is still pending. * fix(native-chat): a message held behind /compact is drawn where it was handed over A message typed while /compact runs was drawn above the compaction's result, between itself and its own answer. The reducer kept every item at the sequence and timestamp of the row that created it, and a queued message is created at acceptance, long before the command it waits behind writes its result. The phone orders by that sequence and the desktop by that timestamp, so both put the message first. A queued message now takes its position from its handover row, the same row that already states its turn scope. Everything the agent did before the handover, a command it waited behind included, draws above it. This holds for every held message, not only /compact's, and needs no client change: every client, older builds included, reads the position the host publishes. A live batch already carries the item when its dispatch row lands, and history pages cut the reduced timeline by sequence, so paging stays contiguous. * fix(native-chat): a phone's send during /compact answers without waiting out the compaction A client that predates accepted-send replies, which is every phone build, has its send reply held until the host hands the message over. A message sent during /compact is not handed over until the compaction ends, so the phone's 15 s request timeout fired first and showed the message as unconfirmed. That wait now also ends once the message is queued behind a running command. This is read from the journal's running turn and needs no new state. Every other wait still ends at the handover: behind a starting child or an ordinary turn, and for restart resume, the command front door and orchestration, which keep the plain handover point. * perf(native-chat): a rewind places provider items with one pass over the merged rows A Codex rewind gives each provider item the old epoch never held the turn record for its provider turn. It found that record by scanning every merged row, restoring each row's body, once per provider item. That is quadratic, and it runs on the host's main thread up to the journal's 10,000-row cap, twice per rewind. A rewind record written before rows carried their scope holds no scope for any provider item, so it paid the full cost. The merge now indexes turn records by provider turn id once, keeping the first match as the scan did, and each provider item looks its record up. * fix(native-chat): a view never restarts a chat whose last start failed A Claude chat whose CLI exits during startup left one red row per start, and every time a view bound to it (the chat opening right after its create died, or the user switching back to it) the hold started the CLI again, so the same launch-failure row repeated. Only a send retries a failed start now, the same rule provider-exit recovery already applied; the rule lives in one predicate the hold, exit recovery and the delivery loop share. * fix(native-chat): a message waiting behind /compact is drawn after it until it is sent A message sent while /compact runs is placed where it was handed over. It was still drawn where it was accepted until then. /compact writes its result one step before the handover, so for that step the waiting message sat above the compaction's separator. A message the host accepted but has not handed over is not part of the conversation yet, so both clients now draw it after everything the agent has done. The shared projection moves it to the end, which is the order the phone draws. The desktop ranks it with the other not-yet-sent rows, after the streaming preview. At handover it takes its place from its handover row, which is also after the separator, so it never appears above the compaction it waited for. * fix(native-chat): the idle sweep reads owed work every tick Owed work counted as activity, but the sweep read it only once the idle window had elapsed, so it refreshed the clock at most once a window. Work that ended just before the next read left the agent to be stopped at that read, moments after the work ended, which is the gap the refresh was meant to cover. The sweep now reads owed work on every tick for a started agent, so the window always runs from the last tick that saw work owed. * fix(native-chat): a continuation handed to the agent stays sent The offer read its own continuation as not reaching the agent while its dispatch was pending, which also covered one already handed over and still unanswered. When the wait for that answer ended first, the failure it filed read as retryable, and a retry sent a second continuation to an agent that may have acted on the first. Only a continuation still queued, or rejected, is now read as unsent. * test(native-chat): start the child the loop waits on with an attach, not a second view A view no longer starts a child whose last start failed, so the R2 case that waits on a child started since the failure now gets that child from a client attach, the one non-send starter left. * fix(native-chat): settle a gone generation's turn wherever a conversation opens A send that opens a chat this process had not read yet (after a crash, from a phone or the CLI) went through the delivery open, which never settled what the dead generation left running; only the read restore and a successful acquire did. When the send's start then failed, the turn stayed running for every reader. The settlement now runs in the one journal open, at the crash boundary, for every opener except an acquisition, which settles from the evidence it read before its reserve; the read restore's separate step is gone. * test(native-chat): prove the next child's start settles the turn an earlier child left The R1 case lost its only settlement assertion when the latch it checked was deleted. It now seeds the running turn the earlier child left and asserts it ends at the exit's receipt, with the exit's row, before the message is handed to the new child. * test(native-chat): count a failed start's rows by row, not by text Comparing the set of texts passed when two different rows carried the same words, which is the duplicate the test exists to catch. * test(cross-version): load the phone row readers without mobile's toolchain Vite transforms a file against its nearest tsconfig, and mobile/tsconfig.json extends expo/tsconfig.base.json, which the root-only cross-version lane never installs. The worktree ps verdict suite imported the current phone row reader from mobile/ directly, so CI failed with TSConfckParseError before any test ran. The harness now imports a copy of the working-tree reader placed under the checkout cache, where the root tsconfig applies, as it already does for the release checkout's copy. Both readers are still the real files. * test(cross-version): keep the checkout path-guard message and justify the copy import's cast * fix(native-chat): a command ends only by its own provider answer or its child's end Stop no longer settles a conversation command. It interrupts it like any turn, and when the provider cannot take that (Codex has not opened the command's turn yet, or Claude refuses the interrupt) it stops the child, whose dead-generation settlement writes the verdict. The pending command now lives on the provider child's own session instead of an adapter-wide map keyed by session, so it dies with the child and nothing has to release it. Claude's /compact is sent under a uuid the slot records, and only a root result naming that input (or naming none) ends it; its outcome is read with the ordinary result reading, so a stopped /compact is a cancellation. * fix(native-chat): a command's settle answers its message before ending its turn The two writes are not one batch. Writing the message's answer first means a crash between them leaves a running command turn, which the stale-turn sweep already settles, instead of an ended turn whose message reads as in flight forever. The settle now writes only while the command turn is still running. * fix(native-chat): "Worked for" counts from the handover, not the send A message held behind /compact, or behind a cold start, used to count the wait as the agent's work, although its row is drawn at the handover. Every handed-over submission's turn, the command's own included, now starts at the handover row's instant, falling back to the send time for a host that recorded none. * test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget * test(native-chat): the interrupted create's own retry continues again The merge of main's lease-latch fix replaced that test's retry of the interrupted create, under its own operation id, with a fresh start whose result nothing read. That fresh start passes with the released-reservation continuation deleted, so the case the fix exists for went untested. The retry and its assertion are main's again. * docs(native-chat): three comments that still had views starting agents A start with nothing queued now comes from a command, goal change or rewind; an interrupted compaction left alone would refuse every send, so no agent would ever start to finish it; and a current host raises the unattached read refusal only once quit began, with the attach window belonging to an older host. * test(native-chat): pin the open's and the send's start and row counts, however the view binds Opening a fresh chat whose starts fail makes one start and one row, with two views bound before or after the create's child died; one send makes one more of each. * fix(native-chat): a second Stop on a command ends its child; one compaction verdict for every provider A Stop's note now names itself in its key, so a later Stop on a command still running reads, from the journal, that the provider was already asked and never answered, and stops the child instead of interrupting again. Nothing is held in memory for it. Adds the rule both translators will read a compaction's end by: only a compaction the provider reported is a success; none after Orca's interrupt is a cancellation; anything else is a failure. A real Claude capture, pinned as a fixture, is why: a stopped /compact ends in the same success result as a finished one. * test(native-chat): a reader's open settles the turn a failed exit settlement left running An exit whose settlement write failed leaves its turn running in the open journal. PR 1's open now settles it, and this pins the two reads that reach it here: a reader reopening a chat the idle sweep closed, and a read that opens the chat before the restart restore reaches it. * test(native-chat): the view-start test's starting window outlasts two subscriptions on a loaded runner A subscription reads the conversation before it returns, so under load the two views took longer than the create child's 300 ms start, which then exited before the test checked that it had not. The child now takes a second to fail. * fix(native-chat): settle a gone generation's turn at every open but an acquisition's The journal open skipped the settlement whenever the lease read reserved or live, to leave an acquisition's own open to the acquisition. But a lease a crashed process left in recovery also reads live, until the next acquire resolves it. A send that opened such a chat, from a phone or the CLI after a crash on a host that could not prove the old owner gone, skipped the settlement; when its start then failed, the dead turn stayed running for every reader. The acquisition now says it is the opener, and every other open settles, whatever the lease still claims. * test(native-chat): hold the create's start open until the views bind The "view binds while the create is still starting" case gave the create a 300 ms head start and asserted the views bound before it died. On a loaded runner the holds took longer, the create's exit landed first, and the case failed its own precondition. The create's initialize now waits on a gate the test releases once the views are bound. * refactor(native-chat): the provider's translator ends a command's turn; the loop holds no command state A conversation command is now a turn of the provider child's own journal pipeline. The adapter-wide tracker, its promise and the loop's settle step are gone. - Codex: the translator claims the provider turn that carries the command, scopes its rows to the command's turn, and writes the command's end in the same batch that settles that turn. Codex's own compaction marker is the success row. - Claude: the command's turn is the translator's open turn until the result that answers the /compact input ends it. The command's own frames, such as the continuation summary, its echo and "Compaction canceled.", draw nothing. - Both read the end with the one compaction rule: success needs the provider's report of the compaction; none after Orca's interrupt is a cancellation. - The message resolves at the provider's receipt, as any send does: the Codex ack, or the Claude slash-command waiter on its result. The host writes a command's end only when the provider never took it. - The delivery loop stops while a command's turn runs, and every journal commit re-wakes it through the session's serialize, so an end that lands while a step decides to stop is never lost. A child that ends first is settled with it. * test(native-chat): pin a command's end to real /compact frames and to each path it threads The captured /compact frames drive the Claude translator's command turn: a finished compaction ends as a success with only the separator drawn; a stopped one ends as a cancellation with no failure row, and the next send answers in its own turn; a result naming another input ends nothing. The command's end is checked at each point the ordinary result path threads through: the reopen latch after a failure, the settling of a child still working, the context facts the result reports, and the provider's own error row. On the host: a message held behind a command is handed over when the command ends just as the loop stops for it, a refused command settles as a failure and the loop moves on, and a Claude child that exits mid-command settles the command and hands what waited to a fresh child. * test(native-chat): tests merged from the base state which turn their rows belong to * refactor(native-chat): drop the child-end waiter nothing waits on A command no longer waits for its child here: its turn ends from the provider's frames or from that child's settlement, and the delivery loop is woken by the commit. The waiter and its test were left from the earlier shape. * fix(native-chat): a command holds the queue only while its child runs it The delivery loop stopped whenever the journal showed a command's turn running. When the command's child ended and its settlement could not be written, that turn stayed running with no child to end it, and the loop's gate kept it from ever starting the next child, which is what settles a gone generation's leftovers. Every later send was held for good, and Stop had no child to end. The gate now holds only while the conversation has a child: with none, the command belongs to a gone generation, and the loop's start settles it like any turn a dead child left running. * fix(native-chat): a Claude /compact succeeds only on its compaction boundary The command's evidence counted Claude's `compact_result: 'success'` status as the compaction done. That status comes before the boundary that replaces the history, so a Stop landing between the two read as a finished compaction even though no boundary was ever written. Only the boundary now counts, as the rule for both providers states; the capture's finished compaction carries one, so it still reads as a success. * fix(native-chat): a Claude child's exit says why the turn it ended stopped When a Claude child exited mid-/compact, the command showed "Worked for 0s" and no reason. The child's translator ends its open turn the moment the exit is reported, stamped with the exit's instant, so by the time the exit settlement ran nothing was running. The settlement recognises a turn the exit already ended by that same instant, but the Claude lifecycle event dropped it on the way to the host, which then used its own clock, matched nothing, and wrote no row. When the clocks did agree, the row was scoped to the running turn, of which there was none, so it landed outside the turn it explained. The exit's instant now reaches the host, and the exit row belongs to the turn the exit ended: still running, or ended by the translator at that instant. * fix(native-chat): a message waiting behind /compact draws below its live activity A message sent while /compact runs waits on the host until the command ends. Both clients moved it to the end of the transcript rows, but the running turn's live activity line ("Compacting the conversation") draws after every row, so the waiting message sat between the command and its own live status. A row that is queued, and not what the live turn is for, now draws after that live activity: on desktop outside the transcript window, below the activity line; on the phone in the list footer, below the live status. A message whose own start is pending still draws above the activity that start reports. * fix(native-chat): only a running command holds a message below its live activity A message is accepted, then handed over a moment later, and in between it reads as waiting. Every message waiting behind a live turn drew below that turn's activity line, so an ordinary message sent while the agent was working crossed below "Thinking" and jumped back up once it was handed over, on desktop and phone. Only a conversation command's turn holds the queue on the host. A message now waits below the live activity only while the running turn is one a command opened, read from the entry that opened it. The phone test also typechecks, which the mobile test ratchet requires. * test(codex): the claim test names its notification params as a record * test(native-chat): a read that reaches a crashed chat before the startup reconcile settles its turn On desktop the chat on screen at relaunch reads before startup reconciles the leases, while the dead process's lease still reads live. The open settles the turn it left running anyway, and the restore that follows finds it settled. * refactor(native-chat): drop the composer's second error formatter After the merge with main, every chat write in the composer path reports its failure as a typed outcome worded by the refusal-notice table, so the send's catch sees only a local throw. The {code, message} formatter this branch added for it has no payload left to format, and its claim to be the one way a chat words a failure is no longer true. The composer send is main's again. * test(native-chat): pin the reason on a message rejected while its chat was closed The reopen test checked only that the message reads as not sent; it now also checks the Retry row carries the host's reason. * docs(native-chat): drop the removed dispatch hold from six comments A worker's session no longer takes a dispatch hold, and no release clock rests a chat by visibility; the agent-launch comments, the abandon test, the teardown test and the refusal census still said so. * test(native-chat): rest the owner-status chat through the idle sweep, not a hold The activation-gate test from #22808 put its chat at rest by holding and releasing it, and passed the release-clock grace. This branch deleted both, so the case threw before it reached its assertions. It now moves the host's clock past the idle window and lets the sweep stop the agent and close the conversation, then asserts the same owner answer and activation gate. * fix(native-chat): show the structured pane's retrying line when a read fails The read transport always hands the pane the host's words, so the error state's "Orca keeps trying to load it" line, which showed only when there were none, was never seen: the pane showed the host's text twice, as its subtitle and on the status line under it. The structured pane now always says its read keeps retrying, and the host's text stays on the status line. The terminal-backed chat is unchanged. * fix(native-chat): a send the provider never received after a restart has no verdict Restart reconciliation rejects a crash-stranded send that is absent from a trustworthy provider history with reason 'not_delivered'. Nobody failed that send, but the verdict allowlist did not name it, so after a crash the chat read Failed, was listed, and could notify "failed". Give the reason a shared constant (persisted value unchanged), add it to the no-verdict set, and treat it as an internal marker so the Retry row no longer shows the raw string. * fix(native-chat): a failed Codex compaction's late completion writes no turn of its own Codex ends a failed turn with an error and then still completes it as failed. The error settled the compaction and released its claim on the provider turn, so the completion read that turn as an ordinary one and wrote a stray record. The claim now lasts until the completion, which adds nothing to a command the error already ended. * test(native-chat): the mid-command exit case resumes its next child as a real one does The case's fake started every child as a newly created thread with the same generation. The store refuses a created link once the conversation has a thread, so the next child's start failed and wrote its own error row, which landed before or after the case read the journal. The next child now resumes the thread under its own generation, and the case reads the journal once the waiting message is delivered, which also proves the loop moved on. * test(native-chat): wait for a send's background start before the refusal oracle removes its store An accepted send wakes the delivery loop, which starts the agent in the background. The oracle's teardown disposed the loop but did not wait for that start, so its lease write could create a temp file in the store directory while the directory was being removed, failing the test with ENOTEMPTY about one run in four. The teardown now drains tracked starts before it closes the journals. * fix(native-chat): a start a message waited on gets one failure row, the delivery loop's When a queued message's start failed, two writers could report it under the same row: the delivery loop, when the adapter settled the start without proving it, and the exit settlement, when the child's exit landed. The last one won, so the chat's row could name a different cause than the one the message was rejected with, or be written twice. The exit settlement now writes the start's row only when no message is queued and the loop has not already recorded that start. A start for a command, goal change or rewind, with nothing queued, still gets its row from the exit. * fix(native-chat): a /compact whose start failed says to run /compact again The failure-words context named only /clear as a command to retry, so a /compact whose agent failed to start read "Send your message to try again." on its row, its rejected message and the command reply. The context now carries any conversation command; the host derives it from the oldest message still waiting on the provider, which is the one a failed start fails first, and the /compact reply names it directly. * fix(native-chat): a Codex /compact ends only on its turn's completion, below Codex's own error row Since only turn/completed ends a Codex turn, Codex's turn-ending `error` is a row inside the still-open command turn, and the failed completion that follows it is the command's end: completed, outcome failure, at the completion's receipt time. The command's own "Compaction failed" row was written on that completion too, so a failed /compact read its reason twice. The command turn now notes when Codex's turn-ending error for the turn it carries was written as a row, and its end then adds no second row. A retried stream error ends nothing and is not counted. The flag that let the error end the command and kept the claim until the completion is gone with the error-driven end. A test replays the captured failed compaction from the real app-server through a claimed command turn. * test(native-chat): main's crash-turn test states its row's turn, and a dead /compact settles on its recorded exit Two tests the main merge brought together: - The crash-turn test from #23456 writes a turn record through the event sink without options; every row here states its turn scope, and a turn record's is the thread. - The /compact whose exit settlement could not be written no longer stays running until the next start: main now settles an open chat from the exit it recorded, so the command reads interrupted before the next message, which is then delivered. * test(native-chat): main's new journal tests state each row's turn The crash-turn, stale-turn and sink-queue tests main added wrote rows without a turn scope, which every item write now states. Rows written inside a running turn name that turn; the sink-queue batch and a send handed over with no live turn name the thread. * fix(native-chat): draw a queued turn's message after the earlier turn's rows A message sent while A runs is written to the journal when it is sent. When the provider queues it (Claude answers it after A), A's remaining rows - its last tool run and its answer - are written after that message, and the message's own turn opens only after them. Grouping put those rows in A's turn, but the transcript still drew them in journal order, below B's bubble and bar, where A's answer read as B's reply. This is the residual #23671 left open. A message that opened a turn now draws after the earlier turns' rows the journal wrote after it, just before its own turn's rows (nativeChatTurnDrawOrder, returned by nativeChatTurnMembership as drawOrder). Desktop and mobile both draw in that order. A steer, and a message that has opened no turn yet, stay where they were written. It applies on hosts that state turn scopes and, through journal order, on older ones. * test(native-chat): run #23026's Stop tests against #23059's command turns Two of #23026's tests call APIs #23059 changed, and failed after the merge: - codex-structured-conversation-stop: a compaction now goes through adapter.compact with the command run the host wrote (#23059), not a bare turn id, and answers with the provider's receipt. With the command claimed, a Stop that names no turn while the compaction's provider turn has not opened still interrupts nothing. - main-agent-working-agreement: a provider row states its turn scope (#23059's appendItem contract); the retry and subagent rows are conversation-scoped. * fix(native-chat): typecheck main's Stop and restore-grouping code against #23059 A Stop's compaction interrupt reads the narrowed requested turn, and the restore-grouping test states whether each row reports its turn's outcome. --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
b18434b5f5 |
feat(explorer): scope file trees to sparse checkout directories (#18750)
Co-authored-by: Neil <neil@stably.ai> |
||
|
|
62e3a5a9a2 |
fix(codex): turning Codex off per agent keeps its hook entry off (#23667)
* refactor(agent-hooks): one predicate for whether an agent's status hooks are on "Global switch on and this agent not turned off" was spelled out separately in the startup controls, the settings reconcile, the retained-home reconcile, the WSL preflight RPC, the CLI preflight and the OpenCode plugin selection. They now share one function, in a module light enough for the CLI's per-launch Codex preflight to load. The PTY spawn env derives the Codex flag from the switch and opt-out list it already carries, the same way it does for OpenCode and Pi, instead of receiving a second copy. * fix(codex): launch and resume prep honour Codex's per-agent hook opt-out Turning Codex off in the per-agent hook settings removes Orca's Codex hook entry, but launch prep and session resume read only the global hooks switch, so the next Codex launch or resume wrote the entry straight back into the real ~/.codex or the account's home. Both now read the per-agent predicate, which the PTY spawn env and startup already honoured. * fix(codex): turning Codex off per agent clears the real ~/.codex entry While the real-home lane owns ~/.codex/hooks.json, the legacy system-home sweep stands down. That gate read only the global switch, so turning Codex off per agent ran remove() with the sweep still suppressed and left Orca's entry in the real ~/.codex. The gate now reads the per-agent predicate, the same as turning every hook off. * test(codex): cover the system ~/.codex sweep gate for Codex turned off The gate that lets the legacy system-home sweep run was an inline closure in startup, so reverting it to the global switch left CI green. It is now a pure function beside the gate it feeds, with a table test and a remove() test on a seeded ~/.codex: turning Codex off strips Orca's entry and keeps user hooks; with Codex on the entry stays. * fix(cli): keep the agent-status hooks predicate loadable by the packaged CLI The CLI's prepare-codex handler imported the predicate from src/main, but the Electron build rebuilds out/main from its declared entries only, so the packaged `orca agent hooks` commands could not load it (package jobs and the CLI bundle-parity test were red). The predicate reads only settings, so it now lives in src/shared, which the CLI compiles itself. |
||
|
|
c1628b4e65 |
feat(orchestration): deliver worker results to a structured chat coordinator (#22631)
* feat(orchestration): deliver worker results to a structured chat coordinator
Resolve a Run's handle-less session coordinator and a session:<id> mailbox to
the live session, wake an evicted session for the delivery, redrive a session's
own mail on its idle edge, route a terminal view's pointer through its PTY, and
accept session:<id> (or a bare Orca session id) as a recipient.
* test(orchestration): pin coordinator delivery through the real session host, wake, idempotence and session addresses
* test(orchestration): type the coordinator mail fixture's attach params
* fix(orchestration): refuse session recipients with the caller codes, and treat a worker without its identity as undeliverable
* test(orchestration): pin a chat's terminal view reading the chat's coordinator mail
* test(orchestration): read coordinator mail fixtures through checked guards instead of assertions
* fix(orchestration): name a structured session's CLI by $ORCA_CLI_COMMAND in its pointer turn
* fix(orchestration): render the pointer's CLI invocation for the shell the session runs in
* fix(orchestration): address a session recipient where its check reads, so a structured worker gets its mail
* test(orchestration): pin the runtime's own idle-edge redrive wiring; say a released session is not running, not ended
* fix(orchestration): point mail that has not been pointed, not mail nobody has acked, naming the ack a held batch needs
* feat(orchestration): hand a /clear-replaced chat's Runs and unread mail to the session that replaced it
* feat(orchestration): adopt a /clear predecessor's Runs at the clear's commit, the edge its replacement's own status misses
* fix(orchestration): log a wake that could not resume a session, instead of retaining silently
* test(orchestration): give coordinator mail waits a budget that holds under a loaded parallel run
* refactor(orchestration): narrow a retained pointer's dispatch state by type guard instead of a cast
* fix(orchestration): give back a pointer whose admitted turn never ran
A pending send stamped its rows delivered and dropped its operation row, so a
provider that died before echoing left the last result pointed at nobody. The
lane now awaits the admitted turn's settlement: accepted consumes the claim,
anything else returns the rows and drops this send's operation row, so the
re-point on the next edge is a new send rather than a replay of unknown.
* fix(native-chat): keep a committed /clear from failing on its replacement observer
The observer runs after the clear's durable commit; a throwing adoption turned a
committed clear into a failed RPC. It is now best-effort and logged, like the
status feed's observer, and the successor's idle edges re-derive the adoption.
* test(orchestration): pin /clear adoption across a chain of clears and a predecessor's check
A session cleared twice before any edge hands both predecessors' Runs and mail
to the end of the chain. A predecessor still live in the clear's tail reads
none of the re-addressed mail: a session's direct mailbox is consume-on-read
and holds no replayable batch.
* test(orchestration): type the pending-settlement host test's send input without an assertion
* fix(orchestration): keep a chat's orchestration address across /clear by deriving its lineage
A chat's orchestration address is now the first session of its /clear lineage. Every session of the
lineage resolves to that one actor when it acts and when it is reached, and delivery goes to the
lineage's live session. Nothing is rewritten at a clear, so the predecessor-adoption path is gone:
the commit-edge observer, the idle-edge rebind, and the unread-mail re-address. That path could
unbind the successor's own Run and orphan all but one Run of a chain.
The idle edge now opens the orchestration database through its lazy getter and logs when it cannot,
instead of reading a field that stays null until the first orchestration call after a restart.
* fix(orchestration): give back a structured pointer claim an earlier process left open
A pointer the host admits as pending stamps its batch delivered, and only an in-memory settlement
waiter gives it back if no turn ran. A process that died in that window left the batch stamped with
nothing to release it, so the last result on that mailbox was never pointed again. When the
database opens, every surviving pointer operation row is from an earlier process: its stamped batch
is found by the row's fingerprint, released, and the row dropped, before the restored-mailbox scan
points it again. A row whose batch was never stamped keeps its id for the retry.
* test(orchestration): pin that a cleared chat's sends carry its conversation's address
* fix(orchestration): open the orchestration database at an idle edge only when it already exists
A profile with no orchestration database has no mail to redrive, so a structured chat's idle edge no
longer creates one, and says nothing. An existing database is still opened lazily there.
* fix(orchestration): read a chat-coordinated Run's session through the actor's generation
A handle-less Run names its coordinator session only by an actor that still counts at the Run's
current generation, the same rule every other binding read uses; an actor an older binary's rebind
or unbind left behind no longer routes the Run's mail. A test pins that a cleared chat's run-create
and run-use write its conversation's root actor at the Run's current generation.
* refactor(orchestration): address a session's conversation by its bare root Orca session id
Carries the Orca session id rename into coordinator delivery. A session's orchestration
identity holds its conversation's bare Orca session id, the /clear lineage root's, and
its mail address is derived from it by formatOrcaSessionAddress; a Run a cleared chat
creates or uses stores that bare root id. A session recipient carries the parsed id and
its address as distinct types, a handle-less coordinator's session is read through
currentRunCoordinatorOrcaSessionId, and session ids read from records or PTY bindings
are checked with isOrcaSessionId before they become an identity. The session address
prefix comes from the one exported constant.
* refactor(orchestration): canonicalize a cleared session through the one id hook and the party resolver
- canonicalOrcaSessionId now walks a session's /clear lineage to its root; the
parallel session identity and lost-worker rule are deleted, so the caller
resolver, recipient routing, reach and idle-edge mailboxes all resolve a
session through resolveOrcaSessionParty.
- A Dispatch row's assignee_orca_session_id goes through the same hook.
- The terminal-view delivery lane is gone with the terminal handoff: no PTY is
bound to a session, so a chat's mail is always a session turn.
- Pins a send to a Run-less chat's session address after a restart, when the
send must start the agent-session host before routing reads its record.
* fix(orchestration): point a structured session with the PTY lane's exact text
A chat or structured worker is now told what a terminal agent is told: the pointer is
formatMessagePointer with the CLI name the PTY lane resolves for a local terminal (orca, or
orca-dev in a dev build), with no shell-specific invocation and no ack lesson. The lane still
excludes the batch a reader holds unacknowledged and points newer mail; that stays host-side,
and the reader's own check replays the held batch and names its ack as it does for a terminal.
* fix(orchestration): deliver a cleared structured worker's mail to its live successor
A terminal keeps its handle across /clear; a structured worker's successor now does the same.
Mail at the worker's handle, its dispatch mailbox, and a Run it coordinates resolved to the
session minted for the worker, which /clear replaced. Each now walks the /clear lineage forward
to the live session, which the caller resolver already treats as the worker.
* test(orchestration): compare a chat's pointer turn to the PTY lane's text for this build's CLI name
* refactor(orchestration): resolve the local CLI name once, for the PTY lane and the structured lane alike
* fix(orchestration): let a /clear-ed chat restate its address, placed by the host's lineage
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.
* test(orchestration): read the coordinator journal through the async snapshot, and wait for the held turn's handover
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.
* refactor(orchestration): point a chat whose agent is not running through the plain send
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(orchestration): leave a chat whose provider died stopped instead of respawning it for mail
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.
* fix(orchestration): hold mail after a start the person must fix, placing every failure kind
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.
* fix(orchestration): retry a structured pointer under its own id instead of gating on the failure reason
A pointer send that failed was given back and re-sent under a fresh operation id,
so every status edge after a provider death was a new send that started the provider
again. A reason-string gate held some of those deaths, but missed a Codex crash with
turn/start in flight (it settles unknown with the connection's error), latched all later
mail after one transient death, and did not keep a user's Stop.
A retry now reuses the mailbox's operation id, which the host answers by replaying the
recorded verdict without reaching the provider. The id is re-minted only for new mail,
after a later send ran, for a row an earlier process left, or once an account switch
settles. Rows are stamped only on accepted, so the admitted-stage claim, its give-back
and the restart claim-release scan are gone.
* test(orchestration): pin that a Stop keeps a pointer unsent before the next status edge, too
* fix(orchestration): point a structured chat's mail by the same rule as a terminal's
The chat lane pointed newer mail past a batch its reader had checked and not
acknowledged, while the terminal lane skips a mailbox until that batch is acked. A chat
now waits for the ack the same way a terminal does, and the lookup that let the chat lane
filter the held batch out is removed.
* fix(orchestration): refuse a session address that gate-list or task-list --run would drop
The CLI entry lets a `session:` address that is not the session's own spelling through
for the host to place, but gate-list and task-list send no caller when --run names the
Run, so `--from session:<other>` was silently ignored. With --run they now refuse it
(consumer_fenced) before any request, the same as a conflicting terminal handle.
* fix(orchestration): keep a failed mail redrive from skipping a chat's first-turn workspace rename
A structured session's status callback redrives its mail before the first-turn workspace
auto-rename, outside any try, so a database error there threw past the rename. The redrive
now logs its failure and returns.
* chore: take main's pnpm-lock.yaml the merge of origin/main left stale
* fix(orchestration): give a stamp or park decision its own variant so the send branch narrows
* fix(orchestration): re-mint a held structured pointer from facts that cannot strand it
A pointer row whose id had no submission in the journal was always resent under that id,
before any re-mint test ran. After a rewind rebuilt the journal, or a send refused before it
was recorded aged past the host's 24h admission window, the mail was held forever: neither
the person's next turn nor a restart pointed it again.
The re-mint tests now run first. "The agent has run since" is any accepted send submitted
after the row was minted; "an earlier process minted it" is a row whose id this lane did not
send, not a wall-clock comparison a clock step could fool; and a row the host never recorded
is re-minted once it is too old for the host to admit. The account-switch exception is gone:
nothing tells the lane when a switch ends, and each outside edge during one added another
pointer and failure to the chat. A parked pointer now retains as turn-unsettled.
* fix(orchestration): let the host check a session caller that gate-list or task-list --run names
|
||
|
|
31012aeb09 |
test: remove assertion-free probes, copied inventories and export-shape checks (#23816)
Second audit wave, targeting three more junk patterns: - assertion-free cases that run code and assert nothing, so they pass no matter what the code does; - inventory literals re-typed from a production declaration, where the only way the assertion can fail is someone editing one of the two copies; - export key-set and export-shape loops (`typeof x === 'function'` over every export) that restate what TypeScript already enforces. Yield is much smaller than wave 1 on purpose: the assertion-free scanner has a high false-positive rate, because many flagged blocks assert through a shared helper or their oracle is "this must not throw". Those were kept. `mobileWebCheckArgs` in `config/scripts/run-mobile-web-app-checks.mjs` is de-exported — after the inventory comparison went away, nothing outside the module read it. |
||
|
|
c49388cd33 |
fix(native-chat): Stop is there from the moment a message is sent (#23026)
* refactor(native-chat): remove the unused terminal handoff No client ever called agentSession.requestHandoff or mounted the handoff chrome. Delete the handoff coordinator, the terminal-owner runtime, the proof write path and the unmounted UI. Keep agentSession.handoffStatus, which released desktop clients read for worktree activation, and let records an older build left mid handoff reconcile through the ordinary restart and recovery paths. * fix(native-chat): never let the pre-stop snapshot hold a chat's stop Eviction now drains delivered events before quit's resume-offer snapshot. An unbounded wait there sits ahead of the provider stop, so a sink whose journal write stalls kept the child running until the step deadline aborted the eviction. The offer is advisory: bound the drain and stop the child regardless. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(native-chat): drop helpers only the terminal handoff called `claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and `queryWindowsProcessRowsFresh` lost their last caller with the handoff. The fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`, the teardown path that still depends on that contract. Co-Authored-By: Claude <noreply@anthropic.com> * docs(native-chat): stop citing the removed handoff in lifecycle comments Six comments still named the handoff coordinator, a handoff suspend, or a terminal-owned session as live participants in the flows they describe. Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): type the stalled snapshot drain without a cast Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): pin that a start dead before proving owes no settlement The removed restart handoff test pinned this branch; nothing else did. Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): keep the owner-status read behind an in-flight attach The handoff removal dropped the per-session queue from `handoffStatus`, so a read landing mid-start reported the reservation (no owner) instead of the settled chat owner, and shipped desktop clients blocked worktree activation on it. The read is queued again, as it was before the removal. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(terminal): remove the agent-session PTY write gate The gate only refused a write when a PTY had been bound to a chat session, and the only code that ever bound one was the terminal handoff this branch removes. With it gone, every admit/readmit returned "admitted" unconditionally, so the checks on the renderer write path, the runtime controller backstop, terminal.send, agent prompts, preview input and orchestration pointers, the refusal fields on terminal.send and worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane orchestration routing could no longer run. Ordinary writes take the same path in the same order as before. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(native-chat): drop the transcript helpers only the handoff called appendLegacyTranscriptMessages fed the terminal transcript catch-up and proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost their last caller with the handoff. Their tests now go through the live entry points instead: the roster bounds through the legacy import, the pinned-read and growth tests through the ancestry replay the history window uses, and the marker rules through the string proof in their own file rather than the session-file resolver's. Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): stop calling a starting chat "mid-handoff" A send refused because the chat's owner is not settled showed "The session is mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that reach it are a chat that is still starting, or one whose previous agent process has not yet been confirmed stopped. The message now says which of the two it is. The refusal code is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): type the stand-in roster decoder without a cast Co-Authored-By: Claude <noreply@anthropic.com> * refactor(codex): name the pinned rollout lookup for what it does With the terminal handoff gone, the module named codex-tui-rollout-proof holds only the pinned rollout lookup that structured Codex launches use to resume a thread, so the name described code that no longer exists. Rename the module and its options type. Also drop a mobile allowlist assertion that pinned the removed agentSession.requestHandoff method, which no longer exists to allow. * refactor(native-chat): type the owner-status reply as the host sends it The handoffStatus reply type still listed the terminal handoff's fields and states (terminal placement, host label, proof retry, queued and waiting phases, the to-terminal direction). No host writes them any more and the only client reader parses the reply as unknown, so they described nothing. The reply on the wire is unchanged. * refactor(native-chat): normalize terminal-handoff lease values once at decode Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types still admitted them, so readers across the host kept branches for values no path produces and the compiler could not point at them. The store now validates the on-disk shape, which still accepts those values so an older record is not quarantined, and maps them once while parsing: - `preparing` and `old-owner-stopped` become `recovering` - a `tui` lease becomes `native`; when it records a process it also becomes `conflicted`, the claim every build probes but never stops. A plain native owner would be stopped by restart recovery, here and in older builds. Revisions are taken over the normalized state on both sides of every compare, and the mapped record reaches disk with the store's first transaction, the same way the tab-id backfill does. The in-memory types narrow to what this build writes, and the branches that existed only for the removed values go. Structured-worker identity keeps its verdict for a former terminal owner by refusing a conflicted claim rather than a non-native kind. * refactor(native-chat): stop threading the owner kind through a reservation A reservation only ever names a native owner now, so the request no longer carries a kind and the reserved lease records `native` directly. The attach params keep `runtimeKind`: agentSession.ensure and create accept it, and the operation fingerprint stored in the ledger covers it. * test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else Hiding a tab also committed the visibility index, so the no-op transaction wrote the file even when its open-time revision was wrong. Committing the index first leaves the pending rewrite as the only reason to write. * fix(native-chat): name a chat write by its target, not the owner generation A write carried the fence of the last frame the pane read, and the host refused it unless that fence was still current. An idle release and the restart after it each move the fence, and the release publishes nothing, so a send after a release was refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a cold start was refused as stale. Every write already names what it acts on: a send its conversation, a cancel its turn, a prompt answer its item revision, a rewind its epoch; an option is last-writer-wins. So admission stops comparing the client's fence, and the rebase that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it. The writer-lease check stays, and so does the attach's compare-and-swap. Frames now stamp the fence read when each frame is sent instead of a copy each subscriber kept, which went stale on the same release. * fix(native-chat): every journal append reaches the chats that are open A journal write and its delivery to open readers were two calls, and some writers made only the first. A failed start whose lease could not be handed back, a provider revision with no frame behind it, and eviction's settlement were all journaled without reaching an open chat. A journal handle now reports every durable change, and the host's session map binds that report to the session's readers when the handle is set. Writers no longer publish what they append; the per-writer publish calls are deleted. * test(native-chat): an epoch replacement reaches the open chat * test(native-chat): each row reaches an open chat once, and a live handle enters only through the map * test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite The seeded record had no surface tab id, so the next open backfilled one and that rewrite alone made the no-op transaction write. The test passed with the legacy-lease rewrite signal removed. * test(worktree-activation): restore the OMP surfaced-agent resume test The handoff removal deleted it alongside the terminal-owner tests, but it covers the surfaced-PTY block that still guards resume, including an agent whose ownership is unknown. * perf(native-chat): a publish behind a delivered commit reads nothing Each commit now delivers itself, so the publish a provider frame still sends afterwards found every reader caught up but still read rows and rebuilt the timeline for each one. A caught-up reader now skips the read. * test(native-chat): state why the teardown test's fake journal is safe to cast * docs(native-chat): say mutation admission checks only the writer lease * docs(native-chat): drop the send rebase from comments that still described it * fix(native-chat): a message is accepted, then delivered A send to a chat with no running agent restarted the agent inside the send call, before the message was recorded, so the client waited for the whole start and a failed restart refused the message. Claude held prompts sent during startup, and those could settle as "unconfirmed". A send is now accepted inside the session's serialized queue: one ledger row and one submission row marked handoverRecorded, published, answered pending. A per-session delivery loop exists while a message is queued. It starts the agent through the same serialized attach a hold uses, waits outside the queue for a Claude child to prove its start, and hands the oldest queued message over as its own serialized step, writing dispatch{pending} before the adapter call. A start it needed and did not get writes one error-tone row and rejects every queued message with the same words; a start Stop cancelled writes none. Settlement follows from the rows. A queued message is provably unwritten, so a close, an eviction or an exit rejects it. A handed-over message stays in doubt. A queued row at or below the sequence a handle found when it opened was left by an earlier process and is rejected at open, with no latch. Stop withdraws queued messages with no writer lease and no fence. An attach failure keeps the conversation open, and the attach adopts its journal. Owed work counts the loop and queued rows. A compaction or rewind found prepared when a conversation opens was started under a child this process no longer has, so the open settles it rather than leaving it to refuse every send until a view attaches. The open cursor is scoped to its epoch, because sequences restart when an epoch is replaced. Deleted: restart-before-admission, recordFailedRestart, the fence rebase, Claude's startup gate, the attach's forget on failure and its own crash boundary. Clients without agent-session.accepted-send.v1 get their reply held until the handover; the desktop and paired desktop lists advertise it. * fix(native-chat): settle queued messages only for the child that ended A child that proved its start and then exited before its message was handed over left the message queued: the exit settlement returned early when nothing else was in flight. Delivery then started another child for it, and a child that died the same way started another, without end and without a row. A retried settlement for an earlier generation, run by the attach that delivery started, did the opposite: with that generation's turn unfinished it rejected the message queued for the child being attached. The settlement now takes the rejection for queued messages from its caller. The unexpected exit and the eviction pass one, and it applies even with no other work in flight; the retry for an earlier generation passes none. * fix(native-chat): an adoption that fails to import keeps the conversation open The attach now writes into the conversation's own open journal, but a failed transcript import still closed it as if it were the attach's provisional one. The conversation stayed indexed with a closed journal, so every later send answered "could not be recorded" and every attach failed again until the app restarted. The import now closes only a journal the attach opened for itself. * perf(native-chat): the recovering open reads the journal once Every conversation open now goes through the recovering open, including the read restore of every chat at startup, which used to replay its journal once. The recovering open replayed it twice: once to probe it and again inside the open. The probe is now handed to the open as its load. * fix(native-chat): an attach that fails after indexing its child leaves no child behind A failed attach now keeps the conversation open, but a failure after `onAttached` indexed the child (the rewind or compaction recovery, or the attach's own success record) left that entry claiming a child the failure path had already released. The next send found the phantom, skipped the start, and wrote at a fence the journal had moved past, so the message stayed queued for good. The entry now drops the released child and its event sink, and follows the record's fence, as a failure before indexing already did. * fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer The error strip for a message the host accepted and then did not deliver matched the entry before the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not send your message" with nothing to retry. It now reads the reconciled entry. A rejection the journal records before the send's own pending answer lands is final as well: that answer no longer puts the entry back to dispatching with no Retry. * fix(orchestration): a structured worker whose agent outlasts the preamble wait is left unknown, not torn down The preamble waits for its submission to be delivered while the worker's agent starts. When that wait ran out it threw operation_unknown, and the failed-start teardown then closed the session, which rejected the very preamble the host was about to deliver. It now reports a turn start nobody observed yet: the worker is start-unknown with its session kept, the host delivers the preamble when the agent starts, and the worker's report settles the dispatch as for any unobserved start. The receipt no longer suggests reading a screen a structured worker lacks. * fix(native-chat): a message rejected while its chat was closed reads as not sent A remount reads an entry it left dispatching as unconfirmed. When the journal had rejected it meanwhile, as a failed start or a quit now does, the reconcile left it unconfirmed: it blocked every later message behind a Retry and no reason, and the delivery probe, seeing the journal already answered, never ran. The reconcile now settles it as rejected like a dispatching one. * test(orchestration): name why the readiness settlement fakes are cast * fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent * docs(native-chat): drop the fence from the admission the send effects run behind * docs(native-chat): give the fence move on release the reason that still holds * docs(native-chat): stop citing a write fence check in launch and mailbox comments Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease. * refactor(native-chat): the provider child is its own record A conversation now outlives any number of provider children, so the child is one record on the conversation's entry instead of five loose fields beside its journal. It is written in one place: indexed only once an attach has fully succeeded, and ended through one function that an exit, a failed re-attach, a Stop and an eviction all share, matched on the child's generation and fence. - A failed attach writes no child, so there is nothing to unwind: the field unwind and the fence patch after it are gone. - Conversation writes read the record's fence, the way mutation admission already does; a child's own writes use its fence. The four stored-fence patches, and the settlement retry's overwrite of the conversation's fence, are gone. - The owed wind-down is its own tombstone, carrying the child it is owed for, and is no longer dropped when an attach replaced the whole entry. - Stop on a child still proving its start stops only the child: its lease goes back and the chat is told it is idle, but the journal, the holders and the readers stay. Close is that stop plus the conversation's close. - The settlement retry uses the conversation's own journal, opened through the host's one open. * fix(native-chat): the delivery loop alone settles a message its start or child failed A queued message was settled by whichever path happened to end the child first: the loop, the unexpected exit, eviction's work settlement, the open's leftover rule, and the startup branch that rejected every pending row. That gave two failure rows with different tones for one start, a loop that could hand over to a different child than the one it waited on, and a Claude start that died while starting reading unlike every other failed start. - The loop remembers the child it waited on. At handover, if that child is gone or replaced, it reads how it ended: a Stop continues; anything else writes one failure row and rejects every queued message with the same words, then stops. A child still starting whose start the adapter says did not land fails the same way. The exit, eviction and the settlement retry only settle the handed-over and legacy rows of the child that ended. - One failure row, always an error, keyed by the start. A start a view began that dies with nothing queued writes the same row through the same builder, so a second report revises it. - The open no longer rejects leftovers; the loop's first step does, and the open wakes it. - `awaitStarted` answers why a start did not land, so the row says it even when the loop sees the failure before the exit is processed. - Quit closes every conversation the way closing a chat does: what is still queued is rejected as closed, with or without a child, and a start the loop already has in flight is waited for so the child it produces is stopped rather than left behind. * refactor(native-chat): a stopped child ends on the one reading of its stop The eviction step reads a stop's result through `stopAgentSessionProviderRoot` and hands that verdict to the child's ending, so the host never forms a second view of whether the root is gone. Every ending carries it: a stop's comes from that reading, an exit's root is gone by definition, and a failed re-attach passes what its release saw. The end-of-child record can therefore also carry a stop whose root was not seen to go, which nothing ends on yet. * feat(native-chat): the host says it accepts a send before any agent has it The host now lists agent-session.accepted-send.v1 among its own runtime capabilities, the same string capable clients already send. A client can then tell a host that answers a send at acceptance, and admits a Stop with no writer before a turn starts, from an older one that still restarts the agent inside the send. Additive: an older client ignores a capability it does not know. * refactor(native-chat): an attach never opens a journal of its own The attach adopts the conversation's open journal, which outlives it, so it no longer opens one for a direct caller either. That leaves nothing for a failed adopted import to close, and the flag that told the two cases apart is gone. Tests that attach without a host open the conversation the way a host does. * fix(native-chat): a moved fence resends nothing on a host that accepts first The outbox treated any fence change as a new owner: it dropped the answer of a send in flight, queued that send to go out again under the same id, and unblocked a refused head. On an older host that is how a send the restart refused, unrecorded, gets another try. On a host that records every send before it starts an agent, a fence moves because that start ran, so the same rule resent into every failed start. With a fence stamped on every frame, that became a loop. The outbox now reacts to a fence change only when the host has not advertised that it accepts a send before any agent has it. On such a host, only a Retry or a new send goes out, and a failed start reaches the client as a rejected message it keeps with its Retry. Against an older host, or before one has answered, the outbox behaves as it did. Desktop and paired web share this hook. * refactor(native-chat): a child's end says whether the user or the host stopped it The end-of-child record's cause now tells a user's Stop from the host stopping the child for a cause of its own: `user-stop` and `host-stop` replace `stop`. The delivery loop goes on after a user's Stop, as before, and fails the start it was waiting on after a host stop, with the one error row and every queued message rejected, in the stop's reason when it gave one. The reason stays description only. Stop passes `user-stop`; nothing passes `host-stop` yet. * fix(native-chat): a chat whose only work is a queued message is not offered for resume A message accepted while the agent was starting counts as working in the chat, and quit rejects it as never sent. The teardown snapshot read the same working rule, so a relaunch offered to resume a chat whose agent never had the message. The snapshot now reads only what was handed over. * test(native-chat): type the queued-message fixtures in the resume-offer tests * fix(native-chat): a start that dies while a message waits on it is that message's failed start Opening a chat's tab starts an agent for the view, and a send accepted meanwhile waits on it. When that start died, its exit wrote the start's error row and left the message queued, so the delivery loop started a second agent into the same failure and wrote a second row. A child's end now records where the conversation's journal stood, and the loop settles a message accepted before a failed start ended with that start: one row, under its key, and no second start. A message sent after the failure still gets a fresh start. * docs(native-chat): say what an attach's open conversation and unconfirmed ids are now * test(native-chat): pin what a failed start settles, and what a resume offer names A view's child that dies while a sent message waits settles that message only when it died starting and no child has taken its place: a proven child's crash, or a second start since, gets the message delivered. The resume offer names the handed-over message, never a newer one still queued. * test(native-chat): the failed-start pins fail on what the message became, not on a timeout * test(orchestration): the preamble's host stub is typed, not cast The preamble send now takes only what it reads of the host, the send, the settlement wait and the record's fence, so its test builds that host with real types instead of `as never`. * fix(native-chat): a Stop that names no turn stops what the conversation has in flight Between handing a message to the agent and the agent opening its turn, there is no turn id a client could name, so a Stop in that gap was refused as "already finished" while the agent went on to answer. A cancel's turn id is now an optional precondition instead of its target: with none, the host withdraws what is queued and, when the journal still reads working, asks the adapter to stop whatever the child has in flight. Claude's interrupt is session-scoped, so it is guarded by fence and acquisition generation rather than a turn identity. Codex interrupts the turn its latest turn/start answered with until the journal shows one. A cancel that names its turn behaves exactly as before. * fix(native-chat): Stop is there from the moment a message is sent The composer showed Stop only once the agent had opened a turn, so for the second or two after a send the chat read "thinking" with no way to stop it. Against a host that takes a Stop naming no turn, Stop now shows whenever the chat reads working (a turn, a queued message, or a handed-over one still unanswered) or this client still has a message on its way. Pressing it, or Escape, first drops every outbox entry the journal does not hold yet, so nothing goes out after the Stop, then sends the conversation-wide cancel. A send already on its way reaches the host ahead of the cancel, which withdraws it there. Against an older host Stop still needs a running turn. The unconfirmed-send probe moves into its own hook so the outbox hook stays in budget. * fix(native-chat): Stop before a turn is gated on its own host capability A host that accepts sends first (agent-session.accepted-send.v1) can still predate the cancel that names no turn and would refuse it as invalid, since clients and hosts ship independently. Hosts that take that cancel now advertise agent-session.conversation-stop.v1, and the renderer shows Stop before a turn opens, and sends the no-turn cancel, only to a host advertising it. Every other host keeps a Stop that needs, and names, a running turn. The host capability probe the accepted-send hook used is generalized so both read one path. * test(native-chat): a build advertises conversation stop exactly where its cancel may name no turn * fix(native-chat): a view never restarts a chat whose last start failed A Claude chat whose CLI exits during startup left one red row per start, and every time a view bound to it (the chat opening right after its create died, or the user switching back to it) the hold started the CLI again, so the same launch-failure row repeated. Only a send retries a failed start now, the same rule provider-exit recovery already applied; the rule lives in one predicate the hold, exit recovery and the delivery loop share. * test(native-chat): start the child the loop waits on with an attach, not a second view A view no longer starts a child whose last start failed, so the R2 case that waits on a child started since the failure now gets that child from a client attach, the one non-send starter left. * fix(native-chat): settle a gone generation's turn wherever a conversation opens A send that opens a chat this process had not read yet (after a crash, from a phone or the CLI) went through the delivery open, which never settled what the dead generation left running; only the read restore and a successful acquire did. When the send's start then failed, the turn stayed running for every reader. The settlement now runs in the one journal open, at the crash boundary, for every opener except an acquisition, which settles from the evidence it read before its reserve; the read restore's separate step is gone. * test(native-chat): prove the next child's start settles the turn an earlier child left The R1 case lost its only settlement assertion when the latch it checked was deleted. It now seeds the running turn the earlier child left and asserts it ends at the exit's receipt, with the exit's row, before the message is handed to the new child. * test(native-chat): count a failed start's rows by row, not by text Comparing the set of texts passed when two different rows carried the same words, which is the duplicate the test exists to catch. * test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget * test(native-chat): pin the open's and the send's start and row counts, however the view binds Opening a fresh chat whose starts fail makes one start and one row, with two views bound before or after the create's child died; one send makes one more of each. * fix(native-chat): settle a gone generation's turn at every open but an acquisition's The journal open skipped the settlement whenever the lease read reserved or live, to leave an acquisition's own open to the acquisition. But a lease a crashed process left in recovery also reads live, until the next acquire resolves it. A send that opened such a chat, from a phone or the CLI after a crash on a host that could not prove the old owner gone, skipped the settlement; when its start then failed, the dead turn stayed running for every reader. The acquisition now says it is the opener, and every other open settles, whatever the lease still claims. * test(native-chat): hold the create's start open until the views bind The "view binds while the create is still starting" case gave the create a 300 ms head start and asserted the views bound before it died. On a loaded runner the holds took longer, the create's exit landed first, and the case failed its own precondition. The create's initialize now waits on a gate the test releases once the views are bound. * fix(native-chat): Stop reads the one working rule every session list reads While Claude retries a rate-limited request it never echoes the message, so no turn opens: the sidebar read Working from the unanswered send while the composer showed Send. The chat's working state, the host's session-list status and the host's no-turn Stop check now call one shared rule instead of three copies. * test(native-chat): a rate-limit retry pins only that no turn opens, not how its rows are kept * fix(native-chat): Stop leaves a message waiting on its Retry, and does not show for one A send that failed holds the queue until the user retries it, and one the host restarted under is parked the same way. Stop counted both as still on their way, so it showed in an idle chat and could never go away, and pressing it dropped the failed message along with its Retry. * test(native-chat): the chat's Stop and a session list read the main agent alike over their own copies The chat reduces its stream and a list reads the status feed. Driven through the real host for a rate-limit retry with no turn, a subagent still running after the main turn, and the handed-over child exiting. * refactor(mobile): the chat reads the main agent's working state through the shared rule Behaviour is unchanged: the same two terms, now from the one function the host projection and the desktop chat read. * fix(codex): a Stop naming no turn never interrupts an earlier turn It fell back to the id an earlier turn/start answered with when the latest start went unanswered, or when the journal showed a compaction Codex had not started, and reported that as stopped. * fix(native-chat): a Stop naming no turn never says a turn had already finished When the provider found nothing left to stop, for instance a turn that ended between the host's check and the interrupt, the chat got "The provider had already finished this turn." for a turn the Stop never named. It now ends quietly, as a Stop with nothing in flight does. * fix(native-chat): one Stop the host could not settle no longer refuses every later one A Stop naming no turn has one operation key per session. When the host could not settle one, it answered every later Stop under the same id as unknown until the id expired. Once the host says so, the next press is a new Stop; transport doubt still replays the same id. * refactor(native-chat): drop the composer's second error formatter After the merge with main, every chat write in the composer path reports its failure as a typed outcome worded by the refusal-notice table, so the send's catch sees only a local throw. The {code, message} formatter this branch added for it has no payload left to format, and its claim to be the one way a chat words a failure is no longer true. The composer send is main's again. * test(native-chat): pin the reason on a message rejected while its chat was closed The reopen test checked only that the message reads as not sent; it now also checks the Retry row carries the host's reason. * test(native-chat): read Stop operation ids without a cast * fix(native-chat): a Stop whose answer was lost no longer swallows the next one A Stop that names no turn has one operation key per chat. When its answer was lost in transit, the chat kept the id, so every later Stop replayed it; the host answers a replay as already handled, so for up to a day Stop stopped nothing. The id is now dropped once the call settles, however it settles. A second press while the first is still on its way still shares its id. * refactor(native-chat): a Stop naming no target keeps its operation id only for its own call The chat kept each write's operation id per payload across calls, and dropped it only on some settle paths. That is right for a write naming what it acts on, but a Stop naming no turn, and a stop of every background task, share one payload with every later one, so any path that kept the id made the next Stop replay as already handled and stop nothing. One path was still open: an answer that arrived after the chat moved to a new fence. Whether a write names its target is now decided once, before its id is picked. One that names none keeps its id only while its call is in flight, so a press made meanwhile joins it, and releases it when the call settles, however it settles. The release runs only while the key still holds that call's id, so a joined call settling late cannot drop a newer one's. This replaces the per-path exceptions for a thrown call. * test(native-chat): read the Stop fences without a cast * test(native-chat): pin the new id for a named cancel the host could not settle After the Stop naming no turn moved to a per-call id, the only test of the unknown-refusal release was gone, and the half that stays, for a cancel naming its turn, could be removed with every test green. * fix(native-chat): a Stop pressed after a new message stops it, even while the last Stop is unanswered A Stop naming no turn shared its operation id with any press made while it was still in flight. The host runs a chat's writes in order, so a message sent between two presses was accepted after the first Stop ran, and the second press replayed that Stop as already handled and left the message running, although the chat had already withdrawn it from the outbox. A write naming no target now gets a new id on every press and is never kept, so each Stop acts on whatever is running when the host reaches it. A write naming its target keeps its id exactly as before. A double press can ask the provider to stop the same turn twice, which it tolerates. * fix(native-chat): Stop no longer blinks off as Claude opens the turn for a message Claude's echo of a sent message both answers the send and opens its turn. The echo settled the send first, so the host published the message as answered one frame before the turn it opened, and for that frame the chat read nothing running: Stop turned back into Send, and Working blinked off in every session list, for tens of milliseconds on each turn. The echo now settles the send after the turn it opens has been emitted, so the running turn is published first. * fix(native-chat): a message a Stop withdrew comes back to its sender's composer A Stop withdraws every message the host holds but has not run, and S also drops the ones this client had not handed over yet. Either way the message left the chat and its text survived only in a hidden journal row and the in-memory ArrowUp history. The sending client now puts the withdrawn text and images back in that pane's composer, after whatever is typed there. Withdrawn is read from the rejection reason through one shared check, which the outbox reconcile now uses too. The composer is written before the entry leaves storage, so a failure between the two repeats the text instead of losing it, and an entry storage no longer holds is never given back again, so a replay, a second view or a remount restores it once. Only this client's outbox holds the entry, so other viewers still see the message disappear. A failed Stop withdraws nothing on the host and gives nothing back. * fix(native-chat): withdrawn text put back during an IME composition is not lost While the IME owns the field, the composer ignores a programmatic draft, and the next composed keystroke wrote the draft without the restored text, after its outbox entry had already been dropped. The composer now holds text appended mid-composition, keeps it in the cache after each composed write, and shows it once the composition settles, the way attachments that land mid-composition already wait for it. * test(native-chat): pin that only a withdrawn message comes back to the composer * test(native-chat): set up the composer's window API for every describe in the composition-race file * docs(native-chat): note that the withdrawn check reads the legacy reason until a typed category lands * test(native-chat): pin that text put back mid-composition shows once, even beside a mid-composition clear * fix(native-chat): land a late settlement from a streamed turn's end after that turn's rows A settlement that says a streamed turn ended waits for the session's event sink to drain before writing its dispatch row. The journal reducer still refuses to overwrite an accepted or rejected send. * fix(codex): settle a send from the end of the turn Codex answered it into The turn/start answer names the turn that holds a send. The send's echo entry now keeps that binding, in memory only. If the bound turn is interrupted without echoing the send, the send is withdrawn: Codex clears a turn's pending input on interrupt, so the model never saw it. If the turn fails first, the send is rejected in Codex's words. A completed turn settles nothing, since Codex records pending input when it finishes and the echo is still due. An answer read after its turn already ended is settled by that end. The echo is still the acceptance and carries the item key. * test(codex): a send settles from the end of the turn Codex answered it into The fake Codex keeps 0.157's turn bookkeeping, and can deliver the turn/start answer after turn/started or after turn/completed. The tests cover: - a Stop before any echo withdraws the send, and the working rule reads idle; - a steered follow-up is withdrawn when the turn is interrupted; - a failed turn rejects the send in Codex's words; - a completed turn leaves the send to its echo; - a normal echo and a late echo; - two steered sends in one turn; - an answer read after the turn ended; - a timed-out answer; - child-thread turns; - how a binding dies. * refactor(native-chat): drop the stream flush before a late turn-end settlement Nothing reads the order of a dispatch row against the turn's terminal row: the reducer keeps a settled send terminal and the working state is derived from both. The echo acceptance on the same path never waited either, and the wait could drop the settlement on a failed sink barrier. * fix(codex): settle a failed turn's sends at its end, not at its error Codex keeps a failed turn's pending input and records it after the error frame, before turn/completed. Settling at the error rejected a steered follow-up the model had in fact received, so a Retry would send it twice. * docs(codex): say a completed turn echoes what it took before it ends Codex records a completed turn's pending input before `turn/completed`, so a bound send that turn never echoed is left for recovery, not awaiting an echo. The comments and one test title said the echo was still due. * test(codex): settle a send whose answer is read after its turn failed or completed A failed turn that ended before the answer rejects the send in Codex's words, once; a completed one leaves it admitted and still armed for its echo. * refactor(codex): read a failed turn's reason with the typed thread-fact reader * fix(native-chat): a Stop that names no turn and ends nothing says why The host sends a Stop naming no turn to the agent only while the chat reads working. When the agent ended nothing, the Stop wrote no row, so it looked ignored. It now writes one: Stop could not reach the agent, in the agent's own words when it refused the interrupt. * fix(codex): hold a cold send until Codex opens its turn, and stop the turn it opened Codex answers turn/start before it opens the turn, and refuses an interrupt until then. A Stop queued behind a cold send ran in that gap, named the answered turn, and was refused. The send's handover now lasts until Codex opens that turn, or provably will not: the turn ended, the primary thread stopped running, or the child ended, bounded by the turn/start deadline. A steered send, whose turn is already open, does not wait. A Stop naming no turn now interrupts the turn the journal shows, else the primary-thread turn Codex reported started and not yet ended. The per-start answered id is gone: it was never cleared at a turn's end. * fix(native-chat): give back a send already on its way only when the host withdraws it Stop took the in-flight send out of the outbox and put its text back in the composer at once. The send still landed ahead of the Stop, so the chat read working for a moment before the host withdrew it, and a send the agent had already taken came back as well. The in-flight send now stays until the host answers it, and the withdrawn-message restore gives it back from that answer. * refactor(native-chat): read a Stop's withdrawal through the rejection classifier dispatchWasWithdrawn matched the legacy reason string. It now asks the classifier, which reads the typed fact first and keeps that string only as its own fallback, so a withdrawal written as a fact with a sentence is still given back to the composer. * fix(native-chat): a Stop Codex took but Orca could not confirm is not reported as reaching nothing When Codex acknowledged the interrupt but Orca could not verify the turn's processes ended, a Stop naming no turn wrote "it had no turn running to stop", though the turn then ended as interrupted. The adapter now says the Stop was taken but unconfirmed, and the row says Cancellation was not confirmed. * fix(codex): a held cold send never delays closing the chat or quitting A cold send's handover waits for Codex to open its turn, and that wait sat in the session queue ahead of the close and quit eviction, so either could wait out the 30 s request deadline. The host now releases those waits before it queues a close or starts quit teardown, and the adapter releases them when it is asked to close the child and on every exit, including one whose end publication is backpressured. * fix(native-chat): a refused Stop says the agent declined, and names it "Stop could not reach the agent" was wrong: the agent was reached and declined. The row now reads "Codex had no turn running to stop." or "Codex didn't stop: <Codex's words>.", naming the chat's agent. * fix(codex): a Stop waits for the turn Codex answered to open; the send no longer does Codex answers turn/start before it opens the turn and refuses turn/interrupt until then, so a Stop naming no turn in that window was lost. The send's handover used to wait for the turn to open, and a close or quit needed its own release to get past that wait. Now only the Stop waits. A Stop naming no turn, finding no journal turn and no open one, reads the turn Codex answered the latest pending send into and has neither opened nor ended, and waits for it: bounded at 5 s, under the quit eviction budget, and ended by that turn opening or ending, the thread going idle or failing, or the child exiting. If the turn opened it is stopped; otherwise the Stop reports that Codex had no turn running. The send returns at Codex's answer, so a close or quit with no Stop pending is never delayed, and the release plumbing through the adapter, router, close and quit is gone. * fix(codex): the Stop's wait reads a stopped thread from the thread-facts reader main kept --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
6e1b7e7fa3 |
test: remove junk tests that assert source text instead of behavior (#23815)
Deletes 101 test files and trims 112 more, all matching documented junk patterns: exact source/import/string greps, copied inventories and export lists, duplicate invocations of a contract another test already owns, typeof-shape checks TypeScript already enforces, and self-comparisons. The largest group read a production `.ts` file and asserted on its text — for example a TaskPage test that required the source to contain `selectedRepos.find((r) => r.id === newIssueRepoId) ?? selectedRepos[0] ?? null`. Any behavior-preserving rename broke it; no behavior change ever did. Production-side follow-through: exports that only these tests imported are de-exported or deleted, stale comments pointing at removed censuses are dropped, and the reliability-gate registry, `cloud/package.json` test lists, and orphaned source-reading helpers are updated so nothing references a deleted file. Two files kept their real coverage and lost only the census scaffolding: `agent-status-producer-census.test.ts` now drives all five producers end to end instead of grepping the source tree, and `config-toml-trust-stale-writes` replaces an export-list parity check. |
||
|
|
36c473ea1a |
fix(runtime): wait out Codex 0.157's startup screen, and stop at Codex's startup dialogs, before typing a worker brief (#23745)
* fix(runtime): wait for Codex's live chat before typing a worker brief Codex 0.157 draws a provisional startup screen (header reads model: loading) and discards typed input while it starts its shared daemon behind it; a fresh Codex home makes that window seconds long, so worker-start pasted briefs that were truncated or never submitted. Codex 0.158 dropped the header labels Orca matched, so worker-start stopped seeing Codex as ready at all. Readiness now requires Codex's live chat on both layouts: the provisional header vetoes a text match unless the live status row is already painted, and 0.158's greeting layout counts once that status row appears. Codex 0.158's model announcement dialog is reported as a blocked prompt instead of receiving the brief. * fix(runtime): recognise Codex's provisional screen from the text copy and the screen probe Live worker-start on a fresh Codex 0.157 home still typed during the daemon start: Codex leaves its alternate screen for that window, so the live screen showed no header and the screen-based veto never fired. The text copy keeps the provisional header until the live chat paints its status row, so the veto now reads it there. The tui-idle visible-screen probe used the bare text rule on the rendered screen; it now goes through the same body rule. * test(daemon): register the new Codex captures' known serializer divergences The serialize round-trip replay picks up every fixture under runtime/__fixtures__, and the three new Codex 0.157/0.158 captures showed 48/9/8 "new-fail" checkpoints against an expected 0, turning CI red. They are the existing live-pen colour leak on restored cells, the same class as the other Codex and DSH entries; this branch changes no serializer code. * fix(runtime): keep Qoder off the Codex screen probe change; drop an unbacked row filter - The tui-idle visible-screen probe now classified Qoder panes with isQoderComposerReady, which skips the working veto evaluateTuiIdle applies first. Qoder paints its composer mid-turn, so an adopted Qoder pane whose hooks said "working" settled the wait immediately. Only Codex and unknown panes take the body rule there; every other agent keeps its old verdict. - The live status-row check skipped rows containing "waiting for startup", a string Codex 0.157/0.158's TUI never prints. The line-folded text copy keeps a whole screen on one line, so the filter could only ever veto the real status row. It is now a bounded includes() with no split. - Lowercase the wait text once in isKnownReadyPromptBody. - Restore the per-frame "screen never takes a settled header away" check, guarded on the provisional veto, instead of checking the final frame only. * fix(runtime): stop reporting Codex 0.158's model announcement once it is answered The announcement's choices stay in the text copy after the user answers it, and the existing dismissal check needed the model:/directory: labels that 0.158's header lacks, so tui-idle waits and the agent-status query kept reporting codex-model-migration-prompt over a live chat. Codex repaints its whole screen, header included, when a startup dialog closes, so the header after the dialog now marks it answered. Also corrects the live-chat marker comments: the middle dot also comes from the daemon session's agents hint row and the warnings notice, not only the status row. * docs(runtime): note that Codex startup dialogs also draw the live-footer dot * fix(runtime): recognise Codex 0.157/0.158 startup dialogs by the rows they really print Codex 0.157 and 0.158 no longer print `Press enter to continue/confirm` on their startup dialogs; they print key rows instead (`enter continue · esc skip`, `enter confirm · esc skip`, `enter/esc continue · ctrl+c quit`). The update, hooks-review and model-migration matchers still required the old wording, so none of these dialogs was reported as blocked. On 0.157 the dialog's `·` also satisfied the live-footer check, so a tui-idle wait read the update dialog as ready and worker-start would type the brief into it, where Enter picks "Update now" (npm install -g, Codex exits). On 0.158 the wait timed out instead of reporting blocked. The matchers now accept the old wording or the new row, tolerating the spaces the line-folded text copy drops around `·`. Each one matches from the dialog's first `·` (for the update dialog that is its title row, `Update available · 0.157.0 → …`), so the dialog is blocked from the same character that would otherwise make the provisional header read as live. The retired-model notice without choices has a catalog-supplied heading (`GPT-5.4 is no longer available`), so it is matched by its own key row. No new blocked-reason value. The startup-dialog matchers move to startup-dialog-blocked-signals.ts to keep terminal-wait-detection.ts under the line limit. Backed by six real captures (update available, hooks review, retired model without choices, each on 0.157.0 and 0.158.0), replayed frame by frame and through a tui-idle wait; the serializer round-trip replay registers their existing live-pen colour divergences. * fix(runtime): keep reporting Codex's retired-model notice after a relaunch in the same pane The retired-model notice is matched by its key row alone, and the matcher took the first `enter/esc continue ·` in the live window while every other startup-dialog matcher takes the last. Quitting Codex from the notice and relaunching it in the same pane leaves the old copy ahead of the new launch's header, so the header read as having dismissed the new notice: 0.157 then read ready and a worker brief would be typed into the dialog. Take the last key row, and replay each captured dialog quit-and-relaunched to pin all six. * fix(runtime): match Codex startup dialogs by the rows the text copy keeps intact Codex 0.157+ paints each startup dialog over its startup screen by cell diff, so Orca's line-folded text copy can drop letters and spaces from a heading: #23765's 0.157.1 capture reads `Updat available`. The update matcher needed `update available`, so on that capture tier 1 read the dialog's own `·` as the live chat's footer and a tui-idle wait settled ready on the update dialog, whose Enter picks "Update now". Match each dialog from its first `·` by rows Codex prints as fixed literals: `available · <version>` and `enter continue · esc skip` (update), `enter confirm ·` (hooks review), `enter/esc continue|confirm ·` (model notices, which also covers 0.158's new-model announcement, so its choice-text matcher goes). Legacy `Press enter to …` wording still matches. Add the new rows to the blocked-signal prefilter, and replay #23765's 0.157.1 update capture in the dialog suite. * fix(runtime): don't name Codex's mid-session pickers a hooks review Codex's rate-limit reset popup (and its other pickers) end their key row with `enter confirm · esc back`, which the hooks-review row matched now that it no longer needs the heading. Exclude `esc back` instead of requiring `esc skip`, so a half-painted hooks-review row still blocks. |
||
|
|
c8af48d8a4 |
fix(runtime): settle a quiet Codex composer as ready on every version (#23765)
* 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 |
||
|
|
c30c8f9d77 |
fix(native-chat): a message Claude folds into its running turn no longer splits the turn (#23621)
* 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. |
||
|
|
e899809ff8 |
fix(mobile): unsubscribe session tabs by request on the direct connection (#22943)
* 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. |
||
|
|
3976ad4c59 | perf(test): remove obsolete structural snapshots (#23777) | ||
|
|
2dc2693953 |
fix(native-chat): a turn a proven crash cut short reads interrupted (#23456)
* 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 |
||
|
|
b283a09688 |
fix(native-chat): stop flashing "still starting" on every chat launch (#23666)
* 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 |
||
|
|
a5ce8251e3 |
Agent launches carry the surface that started them (#23697)
* 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 |
||
|
|
4b622e1b13 |
fix(runtime): reopen the quiet-foreground tui-idle lane for agents with no other rest signal (#23598)
* 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 |
||
|
|
2aed2cf64a |
fix(terminal): reveal splits while the source pane binds (#23692)
* test(e2e): observe passive terminal restoration before activation * fix(terminal): reveal persisted splits during source binding publication * test(terminal): handle nullable persisted layout roots |
||
|
|
d68eee3757 |
fix(runtime): retire an exited terminal before its stream end (#23492)
* 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. |
||
|
|
64569a8183 |
fix(ssh): don't overwrite remote agent config after a failed read (#22644)
* 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> |