mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +00:00
* 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(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): 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): 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. --------- Co-authored-by: Claude <noreply@anthropic.com>
513 lines
33 KiB
Markdown
513 lines
33 KiB
Markdown
# Agent status store
|
|
|
|
## Status
|
|
|
|
The current boundary is PR 2A: structured sessions use the hook server's fully
|
|
scoped canonical store; unbound PTY/relay evidence remains in an isolated legacy
|
|
adapter. Do not remove the renderer bridge or its publication filters in this
|
|
slice: they still carry native-chat child rows.
|
|
|
|
The sections below record the original 2026-09-09 rollout. Its PR 1a and PR 1b
|
|
have landed; its proposed PR 2/3 sequence is superseded by that boundary:
|
|
|
|
1. main-only: every producer writes into one store and `worktree ps` reads it,
|
|
split into 1a (structured sessions join the store) and 1b (the runtime's
|
|
duplicate retained store is deleted);
|
|
2. renderer: the sidebar becomes a subscriber and stops re-deriving rows;
|
|
3. shared: one worktree-status rollup and one freshness rule for every reader.
|
|
|
|
## The problem this solves
|
|
|
|
Orca shows "what is this agent doing" in four places: the desktop sidebar, the
|
|
`orca worktree ps` command, the mobile app, and the agent dashboard. Before
|
|
#19217 those readers did not even share their inputs. After #19217 they share
|
|
the structured-session mapping and nothing else.
|
|
|
|
An audit on 2026-09-09 found six producers and three consumers, and three
|
|
separate copies of the same row inside the main process alone:
|
|
|
|
| Main-process copy | Keyed by | Owned by | Persisted | Evicted |
|
|
| --------------------------------- | --------- | --------------------------------------------------------------------------------- | ------------------ | ---------------------------- |
|
|
| hook server `lastStatusByPaneKey` | paneKey | `src/main/agent-hooks/server.ts` | `last-status.json` | tab close, pty exit, hydrate |
|
|
| runtime `RuntimeAgentRowStore` | paneKey | `runtime-agent-row-store.ts` (deleted in PR 1b) | no | pty exit only |
|
|
| structured feed `published` | sessionId | `src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts` | no | never (a broadcast cache) |
|
|
|
|
The second copy is a duplicate write: the OSC status parsed in main is
|
|
forwarded to the hook server _and_ retained in the runtime store from the same
|
|
call (`orca-runtime-create-terminal-side-effect-command-code-detector.ts`).
|
|
The third copy is keyed differently and never reaches the hook server at all,
|
|
which is why `worktree ps` grew its own adapter for it in #19217.
|
|
|
|
Each reader then applies its own precedence and freshness rules, so the same
|
|
pane can legitimately read differently on the desktop, on the phone, and in
|
|
the CLI.
|
|
|
|
## The rule
|
|
|
|
**The execution host owns agent status, in one store, and every reader
|
|
subscribes to it.** This follows the boundary in
|
|
[`ssh-execution-boundary.md`](./ssh-execution-boundary.md): the host that runs
|
|
the process is the only party that can observe it, and the client is never
|
|
authoritative for execution state.
|
|
|
|
Three consequences:
|
|
|
|
- One store per execution host. A remote host keeps its own store and the
|
|
client mirrors it down, as the web-session mirror already does. Mirroring is
|
|
not merging: a client never writes its observations back to a host.
|
|
- Precedence is decided once, at write time, with provenance recorded on the
|
|
row. Readers never re-adjudicate hook versus terminal versus structured.
|
|
- Readers keep only presentation policy and user facts: the 30-minute display
|
|
decay, acknowledgements, dismissals, unread. Those stay reader-side but
|
|
become one shared implementation (PR 3).
|
|
|
|
## The store already exists
|
|
|
|
The hook server's state is that store today for every PTY-based agent. The
|
|
audit established:
|
|
|
|
- hook HTTP posts, the WSL and SSH relay receivers, and main's own OSC parse
|
|
all converge on the same `applyNormalizedStatus` path, stamped with the
|
|
authority id `main-agent-hooks`;
|
|
- it alone holds pane authority: launch tokens and their hashed commitments,
|
|
retired-pane fences, pane-key aliases, per-connection ordering watermarks,
|
|
and the evidence-age map that must outlive a transport clear;
|
|
- it alone persists, with a seven-day hydrate window and the
|
|
`restoredUnconfirmed` stamp that keeps a hydrated row from ever reading as
|
|
live truth;
|
|
- it already fans out to both renderer windows over `agentStatus:set` and
|
|
`agentStatus:clear`, and serves `agentStatus:getSnapshot`.
|
|
|
|
Nothing else in main carries those guarantees, and building a second store
|
|
with them would be the wrong direction. So the design is not "add a store". It
|
|
is: **route the two producers that bypass the hook server through it, then
|
|
delete the copies.**
|
|
|
|
## PR 1a: structured sessions publish into the store
|
|
|
|
No renderer behavior changes. The sidebar keeps receiving the same IPC events
|
|
it receives today, plus structured-session rows it currently derives itself.
|
|
|
|
### Structured sessions publish into the hook server
|
|
|
|
The structured feed keeps its job of projecting a session's journal into a
|
|
summary and streaming it to subscribers. On every publish it additionally
|
|
ingests the summary into the hook server as a status row:
|
|
|
|
| Row field | From |
|
|
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `paneKey` | `structuredAgentSessionPaneKey(tabId, sessionId)`, the key the renderer already uses; its leaf is UUID-shaped so pane-key validation accepts it |
|
|
| `tabId` | `structuredAgentSessionTabId(sessionId)` |
|
|
| `worktreeId` | `summary.workspaceId` (a folder workspace id is a valid value) |
|
|
| `state` | `structuredAgentSessionAgentStatus(summary).state`: the lead's own status folded with its live `backgroundTasks`, so a settled lead whose subagent still runs reads `working` |
|
|
| `workingMode` | `'monitoring'` from the same fold when watch loops are the only live child work; omitted otherwise, which clears it on the row |
|
|
| `mainAgent` | the main agent's own state before the fold, its last-turn verdict (`summary.turnOutcome`, present only while idle) and its own clock; see "The main agent fact" below |
|
|
| `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it |
|
|
| prompt, tool, last message, model, provider session | the summary's fields |
|
|
|
|
Sessions with no request (`status === null`) produce no row. A request is a
|
|
turn record, an assistant message, a user message the provider journaled itself
|
|
(history, an older host), an accepted or unanswered send, or 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.
|
|
`summary.turnOutcome` is the latest request's verdict: its turn's outcome, or
|
|
`failure` for a send the agent or its start refused (a send that joined a running
|
|
turn is answered by that turn). The row also publishes `interrupted` from
|
|
`mainAgent.outcome`, exactly as the hook lanes do. When the host revokes live ownership the row is re-set
|
|
without the flag; when the host closes or evicts the session the row is
|
|
dropped. Both already exist as feed events (`revokeLive` and the roster
|
|
filter in `liveSessionSummaries`); PR 1 turns them into store writes.
|
|
|
|
Dropping the session from the host's map and dropping its row are one
|
|
operation, `forgetStructuredAgentSession`. The store keeps a row until told,
|
|
and a host-owned row bypasses the staleness check, so a deletion path that
|
|
forgot the row would strand a permanently working-looking agent.
|
|
|
|
Two rules the ingest must keep:
|
|
|
|
- **Never persist a structured row.** The journal is the durable truth for a
|
|
structured session and the host republishes on restore. A structured row in
|
|
`last-status.json` would hydrate as `restoredUnconfirmed` and then fight the
|
|
live republish. The serializer skips rows carrying `structuredHost`, and
|
|
hydrate drops any such row found on disk. Applying one therefore also skips
|
|
the persist schedule: the walk and stringify could only reproduce the file
|
|
that is already on disk, once per debounce window for every streaming chat.
|
|
- **Never let it fight a hook row.** A structured session has no PTY, so no
|
|
hook or OSC event carries its pane key. The ingest still goes through the
|
|
disposition gate so a retired pane key is refused like any other.
|
|
|
|
Applying one does still run both status fan-outs, and that is intended rather
|
|
than incidental. `notifyStatusChangeListeners` is what feeds
|
|
`agentAwakeService`'s power-save blocker, and `subscribeEnrichedStatus` is what
|
|
feeds `AgentSessionTransitionRecorder`'s stats, so joining the store enrolls
|
|
native chats in both. A working native chat is real work and should hold the
|
|
machine awake exactly like a PTY agent does.
|
|
|
|
The drop side routes through `dropStatusEntry`, not `clearPaneState`: a
|
|
pane-status-clear reaches the renderer, and until PR 2 the renderer's own feed
|
|
bridge is that pane key's writer. It also passes `preserveResumeIdentity:
|
|
false` — the `providerSessionOnly` remnant a dismissed pane keeps exists so the
|
|
agent can be resumed in that pane, and a structured session has no pane and
|
|
keeps its resume identity in the record store. Like every other
|
|
`dropStatusEntry` caller, it emits no pane clear, so a session dropped
|
|
mid-`working` leaves `AgentSessionTransitionRecorder` holding an open stats
|
|
session until its LRU evicts it; that gap is shared with the user-dismissal
|
|
path and is not specific to structured rows.
|
|
|
|
The ingest lives in the feed, not in `structured-agent-session-host.ts`, which
|
|
sits at the file-length cap.
|
|
|
|
### `worktree ps` becomes a reader
|
|
|
|
The structured adapter added in #19217 is deleted, and structured rows reach
|
|
`worktree ps` through the same snapshot as every other row. The
|
|
retained-versus-hook reconciliation in `collectRuntimeWorktreePtyAgentSources`
|
|
stayed until PR 1b removed the store that fed it. What this step settles is
|
|
the admission gate that decides which rows a worktree listing may show:
|
|
|
|
- a hook or OSC row needs its tab mirrored or a connected pty, as today, and
|
|
SSH rows stay exempt because their tabs may exist only remotely;
|
|
- a row carrying `structuredHost` is admitted while the host holds the session, and
|
|
the host's drop on close is what removes it. No tab-mirror requirement: a
|
|
structured session's tab lives in the renderer's own tab state, and a
|
|
headless host has no renderer to mirror it from. That argument only holds if
|
|
the headless host is itself wired to the store, which is a separate
|
|
obligation per entry point: the Electron hosts (desktop and `orca serve`)
|
|
share `main-process-runtime-service.ts`, and `orcad` constructs its own
|
|
runtime in `src/main/orcad/orcad-entry.ts`. A host missing that wiring lists
|
|
no agents at all, not just no structured ones, because `worktree ps` reads
|
|
the same snapshot for every row.
|
|
|
|
The freshness bypass for host-owned structured rows already exists in
|
|
`isFreshNonDoneAgentStatus`; with the flag now on the row it becomes the only
|
|
path, and the hand-rolled check in `runtime-worktree-agent-rows.ts` goes.
|
|
|
|
### Wire compatibility
|
|
|
|
`AgentStatusIpcPayload` gains one optional field, `structuredHost`, and the
|
|
`worktree ps` row gains `structuredHostOwned`. Under rule 1 of
|
|
[`remote-wire-compatibility.md`](./remote-wire-compatibility.md) both are safe:
|
|
an old client ignores them. `worktree ps` rows keep their shape and vocabulary,
|
|
so the mobile app sees no change.
|
|
|
|
Until PR 2 the main process does not forward structured rows to the renderer
|
|
over `agentStatus:set` or `agentStatus:getSnapshot`. The renderer's feed
|
|
bridge still writes those rows itself, and forwarding them too would give one
|
|
pane key two writers. Removing that filter is the first step of PR 2.
|
|
|
|
### The main agent fact
|
|
|
|
Claude, Codex and Grok hook rows and structured-session rows publish the combined
|
|
`state` and, beside it, the main agent's own state as `payload.mainAgent`. Other agents'
|
|
rows and terminal-title-only rows carry none, and readers fall back to `state`:
|
|
|
|
```ts
|
|
mainAgent?: { state: AgentStatusState; outcome?: AgentJournalTurnOutcome; stateStartedAt: number }
|
|
```
|
|
|
|
`state` still answers "what should the user see" and folds live child work in,
|
|
so a settled main agent whose subagent still runs reads `working`. `mainAgent` answers
|
|
"what is the main agent itself doing", which the fold used to destroy at publish
|
|
time; every guard that reconstructed a fragment of it (`fromChildWork`, the
|
|
persisted `claudeLeadBoundaryChildOnly` flag) now reads `mainAgent` instead of a
|
|
stored copy. A Claude row whose `mainAgent` is `done` while a child agent still
|
|
works (including a child's permission wait) refuses OSC, which carries no child
|
|
identity; the children's own lifecycle hooks settle it. `outcome` is the recorded verdict on
|
|
the main agent's most recent finished turn, present only while `mainAgent.state` is
|
|
`done`. It is reported by the provider, or is a `cancellation` Orca inferred
|
|
from the user's own interrupt keystroke (the journal's turn outcome, by
|
|
contrast, is never inferred). A plain end of turn carries none, because absent
|
|
means unknown and a provider that omits its interrupt flag must not turn a
|
|
cancel into a success.
|
|
In the Claude hook lane the cancellation comes primarily from Orca's own
|
|
inferred interrupt (`markClaudeLeadTurnInterrupted`), because current Claude
|
|
sends no hook at all on a cancel and no `is_interrupt` on Stop; that flag on a
|
|
turn boundary remains a secondary source for builds that send it, and
|
|
`StopFailure` maps to `failure`.
|
|
|
|
Readers decode the verdict through one accessor, `agentMainAgentVerdict`, which
|
|
reads the main agent's own state, not the combined row's: `mainAgent.outcome`
|
|
while `mainAgent.state` is `done`, then the legacy `interrupted` flag as a
|
|
cancellation, which alone needs the combined `done`. So a main agent that
|
|
failed while its subagents still run has a verdict on a `working` row. Every
|
|
copy of a row (state-history entries, sleep records, `worktree ps` rows) takes
|
|
the verdict through `agentVerdictFields`, which carries `interrupted` and the
|
|
whole `mainAgent` (state, outcome and its own clock) together, so a copy agrees
|
|
with the row and can date a failure by `mainAgent.stateStartedAt`.
|
|
|
|
Display reads the verdict through `agentVerdictDisplayMark`: a failure marks the
|
|
agent failed whatever the combined state, because it is news the user must see
|
|
even while subagents run; a stop marks it interrupted only on a `done` row, so
|
|
a stopped or finished main agent with live child work still reads working.
|
|
Each subagent keeps its own row and state. Container rollups (worktree card,
|
|
terminal tab, Cmd+J) rank a pending question first, then a failure, then live
|
|
work, then a stop, then done. On the worktree card, a failure retained after its
|
|
agent's pane went away has no expiry, so it ranks below live work and above a
|
|
stop. Lifecycle waiters keep reading the combined `state`.
|
|
|
|
Policy splits the verdict two ways. Clean-finish policy (hibernation, pane
|
|
ownership, the star-nag value moment) treats a failure like a cancellation
|
|
(`agentTurnEndedUncleanly`). Attention (completion time, Smart Sort, sticky
|
|
retention, Cmd+J Recent) demotes only a turn the user stopped
|
|
(`agentTurnStoppedByUser`); a failure ranks like a completion.
|
|
|
|
Admission is one function, `normalizeAgentStatusPayload`, on the relay wire,
|
|
IPC and disk. A malformed `mainAgent` drops the field and keeps the row. Old hosts
|
|
send none and readers fall back to `state`. Hook rows persist it inside the
|
|
payload; hydration maps an older row's `claudeLeadBoundaryChildOnly: true`
|
|
onto `mainAgent: { state: 'done' }` when the row has no `mainAgent`, and never writes the
|
|
flag again. Hydration seeds the Claude main agent record straight from a saved
|
|
`mainAgent` that is `done`, so the children's drain can still settle the row after
|
|
a restart. `claudeRunningNonAgentTask` is persisted alongside because it is the one
|
|
child-work fact `mainAgent` cannot express: a shell running beside the main agent,
|
|
whose liveness hydration does not restore. Hydration seeds only a row that says
|
|
`false`; a row silent about it stays unseeded. The row builder pairs the two facts in
|
|
one place: a listener event restates the shell fact, and any other write (an OSC
|
|
repaint, an inferred answer) keeps it only while `mainAgent` is unchanged. A child's
|
|
sticky permission prompt still records the main agent's own progress and background
|
|
evidence in the held row, and pushes the held row to subscribers when `mainAgent` changes.
|
|
|
|
Every lane, Codex included, combines through the fold. A child waiting on a
|
|
human is a fold input (`childWorkLiveness: 'waiting'`, derived from the child's
|
|
own `waiting` state; a child's `blocked` means it failed and stays live work)
|
|
and makes the row wait whatever the main agent is doing, unless the main agent
|
|
is itself asking. Only the Codex hook lane feeds that input today. Known
|
|
divergences, pinned by name in the parity table
|
|
(`src/shared/main-agent-status-parity.test.ts`) where they are reachable, so a
|
|
reader does not mistake them for drift:
|
|
|
|
- The Claude hook lane holds a child's permission wait in one slot on the
|
|
displaced main agent record (`waitingAgentId`, `stateBeforeWait`), not on
|
|
the child. It publishes the displaced state as `mainAgent`, but the next
|
|
main agent event overwrites the slot, so the row stops reading `waiting`
|
|
while the child is still asking, and a second asking child replaces the
|
|
first.
|
|
- The structured lane has no per-child wait: a child's pending prompt makes
|
|
the session `attention`, which reads as the main agent's own `blocked`.
|
|
- The Codex hook lane drops its roster on a root `Stop` when it tracks no
|
|
child transcripts, so a still-running or still-asking child stops holding
|
|
the row.
|
|
|
|
How the main agent's turn ended is not a fold input. A cancel is a verdict on
|
|
the main agent, carried as `mainAgent.outcome: 'cancellation'` (and, for
|
|
readers that predate `mainAgent`, as the row's `interrupted` flag on a `done`
|
|
row); it never retires a shell, scheduled check or subagent the turn left
|
|
running. That work leaves the row only when its own inventory omits it or the
|
|
session ends, so a cancelled turn with a still-running shell reads
|
|
`monitoring` in every lane, and the parity table in
|
|
`src/shared/main-agent-status-parity.test.ts` drives that story through all of
|
|
them. The same rule governs the cancel Orca infers from Ctrl+C: for any row
|
|
that publishes `mainAgent`, the inference is admitted only when
|
|
`mainAgent.state` is `working`, so Orca does not treat a Ctrl+C at the idle
|
|
prompt of a row held open by child work as a turn cancel (Codex also keeps the
|
|
child-evidence guard, and a row without `mainAgent` keeps only that guard).
|
|
The keypress itself is not inert, though: measured live, Claude 2.1.280 stops
|
|
its background subagents on a single idle-prompt Ctrl+C (shells survive) and
|
|
Codex 0.156.1 quits outright, so refusing the inference can leave the row
|
|
showing a subagent its CLI already stopped. The synthesized row is the fold
|
|
of the cancelled main agent with the child work the pane's owner can see: the
|
|
local listener's roster for a local pane, the row's own subagents and shell fact
|
|
for a relayed one, whose provider records live on the relay.
|
|
|
|
The store holds that verdict against restatements that predate it
|
|
(`server-cancel-verdict-latch.ts`), because a relay never learns of a cancel
|
|
the desktop infers and some TUIs emit late same-turn hooks. The hold is read
|
|
off the row (`mainAgent.outcome: 'cancellation'`), never stored beside it, and
|
|
dies on a new turn (a main agent prompt submission, a changed or explicit
|
|
prompt, a session start) or the provider's own settled `mainAgent`. Child and
|
|
replayed events under the hold keep the cancelled main agent and are re-folded
|
|
with their own child evidence.
|
|
|
|
## PR 1b: the runtime's retained row store is deleted
|
|
|
|
Landed. `RuntimeAgentRowStore` is gone, and with it the retained-versus-hook
|
|
reconciliation in `collectRuntimeWorktreePtyAgentSources`. The hook server's
|
|
store is now the only main-process copy of a PTY agent's row.
|
|
|
|
### The five call sites
|
|
|
|
| Call site | Before | After |
|
|
| ------------------------------------------------------------------------------ | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
| `orca-runtime-create-terminal-side-effect-command-code-detector.ts` `retain()` | second write of the OSC payload already sent to the hook server | deleted; the event now carries the pane's `terminalHandle` and the hook ingest keeps the only copy |
|
|
| `...command-code-detector.ts` `clearPty()` | drops rows on pty exit | deleted; pane teardown already clears the hook row |
|
|
| `orca-runtime-get-worktree-ps.ts` `values()` | fed `retainedSnapshots` | deleted; the reader keeps only `hookSnapshots` |
|
|
| `orca-runtime-serialize-agent-prompt-submission.ts` `getFreshExplicit()` | retained row first, hook rows second | `selectFreshExplicitAgentStatus`, hook rows only |
|
|
| `orca-runtime-prune-mobile-session-tab-group-layout.ts` `getFreshForMobile()` | pane key, then pty id | `selectFreshAgentRowForMobileTab`: pane key, then `terminalHandle` |
|
|
|
|
Both readers moved into `runtime-hook-agent-row-selection.ts`, which also owns
|
|
`RuntimeAgentRowSnapshot` now that nothing retains one.
|
|
|
|
### `terminalHandle` is the row's join back to its terminal
|
|
|
|
The retained store's only real extra was the pty id, and two readers used it.
|
|
The plan said to stamp the event's `ptyId` into `terminalHandle`; that was
|
|
wrong. A terminal handle (`term_<uuid>`) and a pty id are different
|
|
identifiers, and `getFreshExplicit` was already comparing hook rows against a
|
|
real handle. What landed instead:
|
|
|
|
- `AgentHookEventPayload` and the runtime's terminal-status event gained an
|
|
optional `terminalHandle`. The detector resolves it once per chunk through
|
|
`getAgentStatusTerminalHandleForPaneKey` — the same lookup the renderer-facing
|
|
IPC boundary already runs for every row, so the two surfaces cannot disagree
|
|
about which terminal a pane is.
|
|
- `applyNormalizedStatus` carries the handle forward when an incoming event
|
|
resolves none. Only main's OSC parse can resolve one, so an HTTP hook post for
|
|
the same pane would otherwise erase it.
|
|
- It is never persisted. A handle belongs to the runtime that issued it, and a
|
|
hydrated one could only rejoin a row to somebody else's terminal.
|
|
- `toAgentStatusIpcPayload` publishes it, which also makes `getFreshExplicit`'s
|
|
long-dead handle comparison live: the runtime reads raw snapshot rows, and
|
|
before this nothing ever stamped the field on them.
|
|
|
|
`worktree ps` uses it too. `ConnectedPtyEvidence` traded its flat `ptyIds` set
|
|
for `ptyIdByTerminalHandle`, so a row still resolves the connected PTY behind
|
|
it — which is both the working-terminal rollup's match key and the last rescue
|
|
for a row whose pane binding was nulled by a controller incarnation change.
|
|
|
|
### The change detector had to move with the store
|
|
|
|
`retain()` was not only a store: its boolean return was the signal that
|
|
republished `session.tabs` for a status-only transition, which no title change
|
|
covers (#7970). `hook-status-session-tabs-invalidation.ts` already mirrors that
|
|
projection change set, including restore provenance and terminal-handle joins,
|
|
so the replacement was to route the signal off the store rather than build a
|
|
second comparator.
|
|
`installHookStatusSessionTabsRepublish` now owns all three arms — enriched
|
|
status, pane clear, and the status-drop tap a dismissal emits — and both hosts
|
|
install it.
|
|
|
|
### Both hosts, not just the desktop one
|
|
|
|
`orcad` constructed its runtime with no `onTerminalAgentStatus`, so main's OSC
|
|
parse never reached the store there and the retained copy was the only carrier.
|
|
Deleting it without wiring orcad would have made a headless host list no PTY
|
|
agents at all. `orcad-entry.ts` now binds the producer and installs the
|
|
republish signal, alongside the snapshot and structured sink it already had.
|
|
|
|
### The intended behavior change
|
|
|
|
A row the user dismisses on the desktop leaves `worktree ps` and the phone at
|
|
once, instead of lingering until the pty exits. One store means one dismissal.
|
|
|
|
Legacy numeric pane keys remain a bounded compatibility case. Persisted layouts
|
|
register aliases to their stable leaf owners; an in-process OSC observation may
|
|
also retain a numeric key only when the runtime supplies the matching tab, PTY,
|
|
and terminal handle. HTTP and relay ingress still require a stable key or a
|
|
registered alias, and numeric rows are never persisted.
|
|
|
|
## PR 2: the renderer subscribes
|
|
|
|
With structured rows arriving over `agentStatus:set`, the renderer's
|
|
`StructuredAgentSessionStatusBridge` no longer needs to write status; its
|
|
unmount cleanup becomes a tab-close signal to the host. The IPC applicator is
|
|
the single writer for observed status. The 2026-09-09 audit sorted the other
|
|
writers:
|
|
|
|
| Writer | Disposition |
|
|
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
| Command Code output seeds, parked-pane seeds, pty-exit removal | delete; main already emits the same facts |
|
|
| structured bridge status writes | delete; main now publishes the row |
|
|
| launch placeholder seeds (a user launched an agent with a prompt) | keep for now; main holds the launch config and can seed later |
|
|
| dismissal, acknowledgement, unmount | keep; user facts and component lifecycle |
|
|
| remote-runtime OSC parse (bytes never transit local main) | keep, fenced behind the host's published row once the host is new enough; rule 3 of the wire doc applies |
|
|
| web-session mirror receipt clock | keep; the decay rule needs both clocks from one machine |
|
|
|
|
The Command Code done-settle window is renderer policy with no main
|
|
equivalent. PR 2 either moves it into main's detector or leaves it, and says
|
|
which.
|
|
|
|
## PR 3: one rollup, one clock
|
|
|
|
The worktree card status is derived three times: `lib/worktree-status.ts` in
|
|
the renderer, `runtime-worktree-status-projection.ts` in main, and
|
|
`agent-row-display.ts` in mobile, which hand-copies the 30-minute constant.
|
|
PR 3 moves the rollup and the decay into `src/shared` and makes all three
|
|
call it.
|
|
|
|
## What does not change
|
|
|
|
- The hook scripts, the OSC 9999 wire format, and the relay protocol.
|
|
- The status vocabulary. `working / blocked / done` for rows,
|
|
`working / attention / idle` for structured summaries, mapped once.
|
|
- The `live / unverifiable / exited` verdicts for remote work. Loss of contact
|
|
clears nothing; the SSH exemptions in the admission gate stay.
|
|
- Hydration honesty: a restored non-done row is `restoredUnconfirmed` and is
|
|
never fresh.
|
|
|
|
## PR 1b reliability contract
|
|
|
|
- **Invariant (`agent-session.status-host-ownership`):** each execution host has
|
|
one agent-status store; OSC, hooks, and structured sessions write it, while
|
|
desktop, `worktree ps`, and mobile only project it. Dismissal, certified PTY
|
|
exit, and provider-generation replacement remove the same row everywhere;
|
|
transport loss alone removes nothing.
|
|
- **Failure source:** the deleted runtime row store duplicated OSC observations,
|
|
keyed them by a different terminal identity, and outlived a dismissal from the
|
|
hook store. Relay replay could also make old evidence look fresh when readers
|
|
used its new delivery timestamp.
|
|
- **Oracle:** one OSC observation appears through the hook snapshot in
|
|
`worktree ps` and mobile, and one store dismissal removes it from both without
|
|
stopping the PTY. Focused tests also require leaf/incarnation-handle rejoin,
|
|
legacy numeric-pane compatibility, certified-exit and provider-generation
|
|
cleanup, evidence-age freshness, and exactly-once startup/stop teardown.
|
|
- **Gate:** `terminal-performance.osc-status-scan-budget` covers the unchanged
|
|
bounded OSC parser and the runtime projection. There is not yet a dedicated
|
|
blocking multi-surface status-store gate; the focused suites below are the
|
|
accepted gap until they accumulate reliability-gate soak evidence.
|
|
- **Provider/platform coverage:** local and daemon-backed PTYs are covered by
|
|
runtime tests, and SSH relay loss/replay semantics by relay integration tests.
|
|
The projection is shared by git worktrees and folder workspaces. WSL uses the
|
|
same store and admission code but has no live run here; Linux and Windows
|
|
runtime execution, native mobile clients, and mixed-version paired clients
|
|
remain validation gaps.
|
|
- **Performance budget:** publication stays event-driven with no new polling or
|
|
subprocesses. One mobile projection clones the status snapshot once, builds
|
|
pane/handle indexes once, and has a deterministic call-count test; lifecycle
|
|
cleanup is bounded by the existing status and handle inventories, and orcad
|
|
tests prove listeners clean up once on failed startup and repeated stop.
|
|
- **Diagnostics:** existing hook-listener errors name the pane and PTY, while
|
|
status-store tests pin delivery versus evidence clocks. No new telemetry or
|
|
raw terminal data is emitted.
|
|
- **Residual gaps:** rendered Electron/mobile behavior, live SSH reconnect, and
|
|
Linux/Windows/WSL execution require the platform QA pass. The current
|
|
cross-version gate does not cover `session.tabs` content.
|
|
|
|
## Verification
|
|
|
|
- Unit: ingest a structured summary and read it back through
|
|
`getStatusSnapshot`, `worktree ps`, and the mobile projection; assert the
|
|
serializer never writes a row carrying `structuredHost`; assert a hydrated
|
|
file that somehow contains one is dropped.
|
|
- Unit: the `worktree ps` suites written against the retained store are rewired
|
|
to a real `AgentHookServer` (`agent-status-store-wiring.test-fixture.ts`)
|
|
rather than deleted, so each still asserts the listing behavior it named. The
|
|
dismissal change is pinned end to end in
|
|
`orca-runtime-tests/worktree-ps-agent-row-dismissal.spec.ts`, which fails with
|
|
the retained store restored.
|
|
- Live: the parity check from #19217 (working, done, close, reload) repeated
|
|
against the merged store, with both surfaces read from the one row.
|
|
|
|
## Retired OMP pane recovery
|
|
|
|
A desktop renderer retirement carries an optional UUID through the existing
|
|
`agentStatus:retirePaneAuthority` IPC message. The hook server retains it with
|
|
its bounded retirement fence. A validated live OMP new turn consumes that UUID
|
|
and echoes `authorityRestartId` only in the live notification. Cached rows,
|
|
persistence and startup replay never carry the acknowledgement. Older peers
|
|
omit or ignore it and retain explicit attach restoration.
|
|
|
|
The renderer keeps the UUID in its existing non-persisted retirement tombstone;
|
|
every re-retirement mints a new one. A matching acknowledgement may clear that
|
|
tombstone only with a successful status write for the existing pane and matching
|
|
workspace/connection. Closed tombstones remain `true`, including after the tab
|
|
LRU evicts its entry. Closing a retired physical alias revokes its whole group.
|
|
This is control-plane retirement correlation, not a second agent-status store.
|
|
|
|
Fallback restores the hook server's recorded status aliases through the existing
|
|
attach-restoration path. The accepted renderer write restores the matching status
|
|
alias routes too, preserving group membership for the next retirement. It does
|
|
not restore orchestration or launch credentials.
|
|
It is scoped to the requesting desktop renderer. A different window's retirement
|
|
UUID cannot be cleared by the acknowledgement, and web mirrors keep their existing
|
|
host-snapshot/attach behavior.
|