From 24edf0f64bb213c3c3a207fe279122cecf717c0d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:02:44 -0700 Subject: [PATCH] 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 * 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 * 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 * test(native-chat): type the stalled snapshot drain without a cast Co-Authored-By: Claude * 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 * 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 * 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 * 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 * 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 ()." 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 * test(native-chat): type the stand-in roster decoder without a cast Co-Authored-By: Claude * 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 876b6989f1 a user's close of a still-starting chat went on like a Stop, so when the close did not complete the delivery loop started a new agent for the message queued behind it. A child's end now has three dispositions, not a failed-start boolean: a user's Stop lets the queue go on, the user's close closes what was queued before it, and any other end fails it. The close is the one a completed close does (the provider-closed rejection, no verdict), applied at the top of each delivery step and ordered against the close so a later send still goes on. * fix(activity): a crash-cut turn draws the interrupted glyph; only a user's Stop keeps the done check The Activity page drew every Interrupted row with the done check, which #2569 chose for a user's Stop. With a crash now reading Interrupted, that put a green check on a turn nobody asked to stop. The row's glyph is now an exhaustive switch over the verdict: a cancellation keeps the done check, and an interruption draws the existing interrupted dot. An unconfirmed end already drew its own glyph. * fix(activity): the Interrupted group header draws the done check only when every row is a user's Stop A user's Stop and a crash share the Interrupted group, and its header took its newest row's glyph, so a Stop newer than a crash put a green check over the crash. The header is now folded over the group's rows: the done check only when every row draws it, the interrupted dot otherwise. * fix(native-chat): a failed close of what the user closed starts no agent for it Rejecting the messages a user's close left queued swallowed a journal write failure, so the delivery step went on to start an agent for a message in a chat the user closed. The rejection now reports whether it landed, and a step whose rejection failed stops instead; the next wake re-derives and retries it. Also pins that the ordering against the close holds only within its epoch, since a later epoch's sequences restart. * test(native-chat): the idle sweep's stop is an eviction, so its close carries that cause Main's idle sweep now stops an idle agent through the conversation lifetime, which this branch gives the 'evict' cause; its expectations name it. * fix(native-chat): a retried stop keeps the cause of the stop it finishes A user's Stop or close whose wind-down failed after the child was proven gone was finished by the idle sweep as an eviction, so the turn it cut read Interrupted. The owed wind-down now carries its stop's cause, and a retry with no child settles with it. * test(native-chat): the idle sweep's close of a retrying Claude chat carries the eviction cause Main's new test expected the adapter close with the session id alone; every stop now names its cause, and the idle sweep's is 'evict'. * fix(status): a user's Stop marks done on the tab and sidebar; red Interrupted is only a turn cut short by something else The tab, the worktree card and the sidebar rows drew a Stop with the same red dot as a crash. The verdict mark now maps a cancellation to done, still saying "Interrupted by user" in the row text, and the mobile mirror follows. The Activity page keeps grouping a Stop under Interrupted with the done check, as before. * test(cross-version): a new phone reads a user's Stop as done; an old phone still draws it interrupted * fix(native-chat): a user's Stop inside a live Claude chat reads as their cancellation Stopping a running Claude turn interrupts it and keeps the session, so the turn's end comes from the CLI's result frame. Claude CLIs before 2.1.91 send that frame with no terminal_reason, and later ones may still omit it, so the user's own Stop was recorded as a failure with an error row. Orca now records the stop on the open turn when it sends the interrupt. An error result for that turn reads as the user's cancellation whatever reason the CLI gives. The stop belongs to that one turn, so it cannot reach the next, and it is withdrawn when the CLI refuses the interrupt. * docs(agent-status): a user's stop marks done; name the tab close cause by its type The reference still said a stop marks a row interrupted and ranks between live work and an unconfirmed end. A cancellation now marks done, and only a turn cut short by something else ranks as interrupted. The runtime's tab close restated the close cause's union; it now uses the type. * test(native-chat): a proven crash reads as an interruption on the status feed and in the chat A crash the relaunch proves now settles its turn interrupted, and the status feed works the verdict out from that record, so the restart test expects interruption for a proven crash and unconfirmed for one it cannot prove, never a cancellation. A chat read before the proof lands reports unconfirmed, then interruption and a folded "Interrupted after 27s" once the proof revises it. * fix(status): a user's Stop reads Interrupted, and a turn anything else cut short reads Failed The verdict mark now maps a cancellation, the user's own Stop, to interrupted, and an interruption, a turn cut short by a crash or a killed agent, to failed, the same as a failure, which outranks live subagent work. An unconfirmed end is unchanged. This applies to every agent, in a terminal or a chat, on the tab, the sidebar rows and worktree card, the dashboard row, Cmd+J and the phone. A Stop is not news, so the rollups rank it below an unconfirmed end, and notifications word an interruption "failed". Recording is unchanged. * fix(activity): group a user's Stop under Interrupted and a crash with failures A user's Stop draws the interrupted glyph and sits alone in Interrupted, and a turn anything else cut short sits in Failed, titled "Agent failed". Every row in a status group now draws the group's own glyph, so the header is the group's status and the rule that folded a Stop's done check into the header is gone. Interrupted ranks below an unconfirmed end, as in the sidebar. * fix(status): draw a user's Stop in the muted tone, not the fault red The interrupted dot, which now means only a user's Stop, draws in the muted foreground token on the agent rows, the sidebar card and the phone. Red stays for a failure or a turn cut short by anything else, and green for a finish. * fix(native-chat): fold a stopped turn as "Interrupted after N" and a failed or crash-cut one as "Failed after N" The settled turn header now follows the verdict mark: a user's Stop reads "Interrupted after N", and a failure or a turn anything else cut short reads "Failed after N", under the new key components.native-chat.status.failedAfter in all six catalogs and the boot catalog. Desktop and phone share the one description, so they agree. * docs(agent-status): describe the Interrupted and Failed marks The reference and the phone's turn bar still described a user's stop as done and a crash as interrupted. A fault now reads failed, a user's stop reads interrupted in the muted tone, and the rollups rank an unconfirmed end above a stop. * test(status): a crash the relaunch recovers marks failed The recovery test still expected a recovered interruption to mark interrupted; it now marks failed, as a failure does. Formatting only elsewhere. * fix(native-chat): record a turn a newer request replaced as superseded, and show it Interrupted A Claude turn that a newer send replaced before its result arrived was recorded as interrupted with no verdict, which reads as a turn cut short by something else, now "Failed". It is now recorded with its own outcome, `superseded`, where the replacement is detected. That outcome names no sender, so a dispatch from another agent is never recorded as the user's Stop, and it sets no legacy flag. Every reader handles it in an exhaustive switch: it draws the muted Interrupted mark with the plain text "Interrupted", folds as "Interrupted after N", and attention demotes it with a Stop, through the renamed agentTurnEndedOnRequest. Older builds read an arm they do not know as no verdict, which is what this turn carried before, so their rows keep reading done; the cross-version suites pin an older desktop's journal and status readers and an older phone. * refactor(status): name the attention predicate for a turn ended on purpose agentTurnEndedOnRequest becomes agentTurnEndedOnPurpose: a user's Stop or a newer request's replacement, never a fault. The Claude turn-end comment no longer says a replaced turn carries no verdict. * test(native-chat): a turn cut off by a restart or by quitting Orca reads Failed after N On main a restart-cut turn shows the done tick. Pin the chat's turn bar and the tab's mark for both cuts, through the recovery settlement and the quit's child-end mapping, and pin the quit's turn bar through the host's own quit. * style(native-chat): format the superseded turn-bar expectations * fix(claude): a Stop that names no turn is the user's stop of the open turn The chat's Stop button names no turn. Claude's conversation Stop recorded the user's stop only for a named turn, so an older CLI's error result after that Stop read Failed. It now records it on the open turn through the same intent, dropped when Claude refuses the interrupt and never carried to the next turn. * fix(native-chat): keep the attach context's publishStatus required The lifetime context type makes publishStatus optional, so the attach context that spreads it no longer satisfied its own type once its duplicate publishStatus went. The host's lifetime context is now inferred, and checked with satisfies, so the spread carries the member it always sets. * fix(activity): rank a user's Stop below live work in the status grouping The Activity page's status grouping put the Interrupted group (a user's Stop, or a turn a newer request replaced) above Working and Monitoring, so a Stop still sorted like news there while the sidebar, worktree card and Cmd+J rank it below live work. It now follows live work and stays above Done; Failed and Couldn't confirm keep their places above live work. * docs(agent-status): say which turn outcomes the journal records and which are derived The journal now records superseded as well as the provider's verdict and a stop; interruption and unconfirmed are derived on read. The resume row no longer claims interrupted renders red. * test(native-chat): the idle sweep's held-send rest closes with the evict cause, like its siblings --------- Co-authored-by: Claude --- docs/reference/agent-status-store.md | 45 ++- mobile/src/components/AgentStateDot.tsx | 11 +- .../agent-monitoring-indicators.test.ts | 15 + .../src/session/MobileNativeChatMessage.tsx | 1 + .../MobileNativeChatTurnStatus.test.ts | 19 + .../session/MobileNativeChatTurnStatus.tsx | 11 +- ...bile-structured-agent-turn-timing.test.tsx | 2 +- mobile/src/worktree/agent-row-display.test.ts | 45 ++- mobile/src/worktree/agent-row-display.ts | 49 ++- .../claude-api-retry-idle-sweep.test.ts | 2 +- .../claude/claude-close-stop-cause.test.ts | 92 +++++ .../claude-journal-translator-contract.ts | 6 + src/main/claude/claude-open-turn.ts | 39 +- src/main/claude/claude-result-journaling.ts | 6 +- src/main/claude/claude-result-outcome.ts | 13 +- .../claude-structured-control-actions.test.ts | 2 + .../claude-structured-control-actions.ts | 13 +- .../claude-structured-in-turn-stop.test.ts | 182 ++++++++++ ...ed-journal-translation-turn-timing.test.ts | 1 + .../claude-structured-journal-translation.ts | 13 +- .../claude-structured-prompt-ownership.ts | 15 +- .../claude-structured-provider-fallback.ts | 6 +- .../claude-structured-session-adapter.ts | 23 +- .../claude/claude-structured-session-close.ts | 5 + .../claude/claude-structured-session-state.ts | 7 +- src/main/claude/claude-turn-lifecycle-item.ts | 12 +- src/main/claude/claude-turn-outcome.test.ts | 190 +++++++++- src/main/claude/claude-turn-ownership.test.ts | 2 + src/main/codex/codex-close-stop-cause.test.ts | 158 ++++++++ .../codex-structured-journal-translation.ts | 20 +- .../codex/codex-structured-session-adapter.ts | 9 +- .../codex/codex-structured-session-close.ts | 16 +- .../codex/codex-structured-session-state.ts | 7 +- .../codex-structured-session-teardown.ts | 6 +- src/main/ipc/notification-options.ts | 22 +- .../notifications-message-formatting.test.ts | 4 + ...-agent-session-accept-then-deliver.test.ts | 42 +-- ...tured-agent-session-adapter-router.test.ts | 3 +- ...structured-agent-session-adapter-router.ts | 18 +- .../structured-agent-session-adapter.ts | 11 +- ...red-agent-session-claude-root-exit.test.ts | 3 +- ...ctured-agent-session-close-verdict.test.ts | 340 ++++++++++++++++++ ...ured-agent-session-command-readers.test.ts | 2 +- ...d-agent-session-conversation-close.test.ts | 6 +- ...red-agent-session-conversation-lifetime.ts | 16 +- ...tured-agent-session-crash-turn-end.test.ts | 36 +- .../structured-agent-session-delivery-loop.ts | 37 +- .../structured-agent-session-eviction.test.ts | 2 +- .../structured-agent-session-eviction.ts | 9 +- .../structured-agent-session-host-delivery.ts | 7 + .../structured-agent-session-host-lifetime.ts | 52 ++- .../structured-agent-session-host-teardown.ts | 6 +- ...ed-agent-session-host-test-abandon.test.ts | 2 +- .../structured-agent-session-host-types.ts | 18 +- .../structured-agent-session-host.ts | 14 +- ...tructured-agent-session-idle-sweep.test.ts | 18 +- ...ured-agent-session-late-settlement.test.ts | 4 +- ...ed-agent-session-option-settlement.test.ts | 10 +- ...gent-session-provider-child-record.test.ts | 100 +++++- ...red-agent-session-provider-started.test.ts | 2 +- ...session-queued-message-rig.test-fixture.ts | 2 +- ...ured-agent-session-queued-messages.test.ts | 4 +- ...ed-agent-session-queued-newer-orca.test.ts | 2 +- ...ed-agent-session-queued-pause-lift.test.ts | 2 +- ...agent-session-recovered-turn-clock.test.ts | 110 +++++- ...ctured-agent-session-refusal-retry.test.ts | 2 +- ...red-agent-session-send-preparation.test.ts | 2 +- ...ctured-agent-session-stale-turn-verdict.ts | 53 ++- .../structured-agent-session-stop-cause.ts | 15 + ...red-agent-session-surface-lifetime.test.ts | 22 +- ...ured-agent-session-turn-completion-feed.ts | 4 +- ...t-session-wedged-profile-migration.test.ts | 6 +- ...structured-conversation-compaction.test.ts | 2 +- ...e-structured-resumed-start-failure.test.ts | 2 +- ...ude-structured-session-integration.test.ts | 2 +- .../orca-runtime-close-mobile-session-tab.ts | 6 +- ...time-close-structured-agent-session-tab.ts | 8 +- ...runtime-structured-session-restore.test.ts | 3 +- ...orchestration-structured-worker-session.ts | 2 +- ...structured-agent-session-admission.test.ts | 6 +- ...ured-agent-session-adoption-replay.test.ts | 2 +- .../structured-agent-session-hold.test.ts | 6 +- .../methods/structured-agent-session.test.ts | 2 +- .../rpc/methods/structured-agent-session.ts | 5 +- .../structured-worker-stop-receipt.test.ts | 2 +- .../structured-worker-tab-retirement.test.ts | 2 +- .../runtime/structured-agent-session-close.ts | 3 +- ...-session-codex-turn-end-settlement.test.ts | 4 +- ...t-session-runtime-provider-started.test.ts | 2 +- ...ured-agent-session-tab-close-cause.test.ts | 13 + ...tructured-agent-session-tab-close-cause.ts | 17 + .../structured-chat-coordinator-mail.test.ts | 6 +- .../structured-claude-pending-rewind.test.ts | 2 +- .../runtime/structured-worker-at-rest.test.ts | 2 +- .../src/components/AgentStateDot.test.ts | 11 +- src/renderer/src/components/AgentStateDot.tsx | 14 +- .../NativeChatResumeOnRestartAgentRow.tsx | 5 +- ...ivityPrototypePage.thread-grouping.test.ts | 34 +- .../activity/activity-clear-completed.ts | 6 +- .../activity-status-group-header.test.ts | 77 ++++ .../activity/activity-thread-controls.tsx | 4 +- ...ivity-thread-grouping.status-order.test.ts | 55 ++- .../activity/activity-thread-grouping.ts | 20 +- .../activity/activity-thread-presentation.ts | 59 +-- .../dashboard/DashboardAgentRow.test.tsx | 55 ++- .../dashboard/DashboardAgentRow.tsx | 9 +- .../dashboard/DashboardAgentRowMessage.tsx | 19 +- .../dashboard/agent-finished-timestamp.ts | 4 +- .../components/dashboard/useRetainedAgents.ts | 4 +- ...MessageList.interrupted-turn-fold.test.tsx | 139 +++++++ .../native-chat/NativeChatTranscriptRow.tsx | 1 + .../native-chat/NativeChatWorkingStatus.tsx | 57 ++- .../sidebar/StatusIndicator.test.ts | 9 +- .../components/sidebar/StatusIndicator.tsx | 24 +- .../smart-attention-host-observed-end.test.ts | 52 +++ .../src/components/sidebar/smart-attention.ts | 4 +- .../sidebar/use-worktree-activity-status.ts | 3 + .../sidebar/use-worktree-activity-statuses.ts | 2 + .../worktree-agent-activity-summary.test.ts | 13 +- .../worktree-agent-activity-summary.ts | 4 + .../worktree-card-agent-summary.test.ts | 38 +- .../sidebar/worktree-card-agent-summary.ts | 4 + .../worktree-card-compact-agent-row.tsx | 11 +- .../terminal-tab-activity-status.test.ts | 32 +- .../tab-bar/terminal-tab-activity-status.ts | 16 +- .../worktree-jump-palette-recent-inclusion.ts | 8 +- .../src/i18n/en-runtime-required.json | 2 + src/renderer/src/i18n/locales/en.json | 3 + src/renderer/src/i18n/locales/es.json | 5 +- src/renderer/src/i18n/locales/fr.json | 3 + src/renderer/src/i18n/locales/ja.json | 3 + src/renderer/src/i18n/locales/ko.json | 3 + src/renderer/src/i18n/locales/zh.json | 3 + .../src/lib/activity-thread-display.ts | 11 +- .../src/lib/agent-pane-activity-flags.ts | 20 +- .../src/lib/agent-verdict-status-line.ts | 21 ++ .../src/lib/recent-workspace-tab-rows.test.ts | 27 +- .../src/lib/recent-workspace-tab-rows.ts | 4 + src/renderer/src/lib/worktree-status.ts | 9 +- ...paired-agent-row-turn-outcome-arms.test.ts | 138 +++++++ .../store/slices/agent-status-drop-actions.ts | 4 +- .../agent-status-recovery-collection.ts | 4 +- .../agent-status-worktree-drop-actions.ts | 4 +- src/shared/agent-completion-time.ts | 6 +- .../main-agent-turn-state.ts | 14 +- src/shared/agent-lead-status-fold.test.ts | 8 + src/shared/agent-main-agent-verdict.test.ts | 87 +++-- src/shared/agent-main-agent-verdict.ts | 83 +++-- src/shared/agent-session-wire.ts | 17 +- src/shared/agent-status-types.test.ts | 16 + src/shared/agent-status-types.ts | 4 +- src/shared/agent-turn-outcome.ts | 59 ++- src/shared/main-agent-status.ts | 12 +- src/shared/native-chat-turn-status.test.ts | 15 + src/shared/native-chat-turn-status.ts | 46 ++- src/shared/notification-settings-types.ts | 4 +- src/shared/plugins/plugin-events.ts | 3 +- ...structured-agent-session-latest-request.ts | 13 +- ...tructured-agent-session-projection.test.ts | 39 +- .../structured-agent-session-projection.ts | 13 +- ...structured-agent-session-turn-bars.test.ts | 15 +- ...ructured-agent-session-turn-timing.test.ts | 33 +- .../structured-agent-session-turn-timing.ts | 15 +- ...pace-session-schema.sleeping-agent.test.ts | 38 ++ ...ersion-host-observed-turn-end.unit.test.ts | 159 ++++++++ ...s-version-worktree-ps-verdict.unit.test.ts | 34 +- ...-chat-owner-status-activation.unit.test.ts | 2 +- 167 files changed, 3424 insertions(+), 518 deletions(-) create mode 100644 src/main/claude/claude-close-stop-cause.test.ts create mode 100644 src/main/claude/claude-structured-in-turn-stop.test.ts create mode 100644 src/main/codex/codex-close-stop-cause.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts create mode 100644 src/main/runtime/structured-agent-session-tab-close-cause.test.ts create mode 100644 src/main/runtime/structured-agent-session-tab-close-cause.ts create mode 100644 src/renderer/src/components/activity/activity-status-group-header.test.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.interrupted-turn-fold.test.tsx create mode 100644 src/renderer/src/components/sidebar/smart-attention-host-observed-end.test.ts create mode 100644 src/renderer/src/lib/agent-verdict-status-line.ts create mode 100644 src/renderer/src/runtime/paired-agent-row-turn-outcome-arms.test.ts create mode 100644 tests/e2e/cross-version-wire/cross-version-host-observed-turn-end.unit.test.ts diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index b7ce4fa16dd..585c5ebc43c 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -112,7 +112,10 @@ 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 +turn is answered by that turn). A turn the provider gave no outcome reads as its +host-observed end through `agentTurnVerdict`: `interruption` for an `interrupted` +lifecycle, `unconfirmed` for an `unverifiable` one. It is derived on each read, +never journaled. 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 @@ -202,7 +205,7 @@ Claude, Codex and Grok hook rows and structured-session rows publish the combine rows and terminal-title-only rows carry none, and readers fall back to `state`: ```ts -mainAgent?: { state: AgentStatusState; outcome?: AgentJournalTurnOutcome; stateStartedAt: number } +mainAgent?: { state: AgentStatusState; outcome?: AgentTurnOutcome; stateStartedAt: number } ``` `state` still answers "what should the user see" and folds live child work in, @@ -215,8 +218,12 @@ 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 +from the user's own interrupt keystroke, or a `superseded` the host recorded when +a newer request replaced a structured Claude turn before it ended (it names no +sender, and sets no legacy flag), or, on a structured row whose turn the +provider gave no verdict, is what the host observed of its end: `interruption` +(a proven death nobody asked for) or `unconfirmed` (an end it cannot prove, +never success). The journal's turn outcome, by contrast, stores only recorded verdicts: the provider's, a `cancellation`, or the host's `superseded`; `interruption` and `unconfirmed` are derived from the turn's lifecycle state and never stored. 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 @@ -235,21 +242,31 @@ 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 +Display reads the verdict through `agentVerdictDisplayMark`. A fault 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. +even while subagents run: a `failure`, and an `interruption`, a turn cut short +by anything other than the user or a newer request. A user's stop (`cancellation`) +marks it interrupted, drawn in the muted tone with the row text "Interrupted by user"; +a turn a newer request replaced (`superseded`) marks it interrupted in the same muted +tone with the row text "Interrupted"; and `unconfirmed` marks it unconfirmed, all only +on a `done` row, so a stopped +or finished main agent with live child work still reads working. The folded +turn header follows the same mark: "Failed after N", "Interrupted after N", or +"Worked for N". 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`. +work, then an unconfirmed end, then a user's 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 an unconfirmed end. 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. +ownership, the star-nag value moment) treats a failure, an interruption and an +unconfirmed end like a cancellation (`agentTurnEndedUncleanly`). Attention +(completion time, Smart Sort, sticky retention, Cmd+J Recent) demotes only a +turn ended on purpose, the user's stop or a newer request that replaced it +(`agentTurnEndedOnPurpose`); a failure, an interruption or +an unconfirmed end 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 diff --git a/mobile/src/components/AgentStateDot.tsx b/mobile/src/components/AgentStateDot.tsx index 80ef9d9d239..6912f6c1b2a 100644 --- a/mobile/src/components/AgentStateDot.tsx +++ b/mobile/src/components/AgentStateDot.tsx @@ -2,18 +2,21 @@ import { useEffect, useRef } from 'react' import { Activity } from 'lucide-react-native' import { Animated, Easing, StyleSheet, View } from 'react-native' import type { AgentDotState } from '../worktree/agent-row-display' +import { colors } from '../theme/mobile-theme' // Per-agent state indicator, 1:1 with desktop AgentStateDot // (src/renderer/src/components/AgentStateDot.tsx): yellow spinner for 'working', -// emerald for 'done', red for blocked/waiting/interrupted/failed (attention), neutral -// for idle. Distinct from the worktree-level AgentSpinner, which collapses the -// agent vocabulary into the 5-state rollup the sidebar dot uses. +// emerald for 'done', red for blocked/waiting/failed (attention), muted for a user's Stop +// ('interrupted'), amber for 'unconfirmed' (desktop's missing-evidence tone), neutral for idle. Distinct from the +// worktree-level AgentSpinner, which collapses the agent vocabulary into the 5-state +// rollup the sidebar dot uses. const DOT_COLORS: Record, string> = { done: '#10b981', blocked: '#ef4444', waiting: '#ef4444', - interrupted: '#ef4444', + interrupted: colors.textMuted, failed: '#ef4444', + unconfirmed: colors.statusAmber, idle: 'rgba(115,115,115,0.4)' } const WORKING_COLOR = '#eab308' diff --git a/mobile/src/components/agent-monitoring-indicators.test.ts b/mobile/src/components/agent-monitoring-indicators.test.ts index eefa2137f14..16c453b27cf 100644 --- a/mobile/src/components/agent-monitoring-indicators.test.ts +++ b/mobile/src/components/agent-monitoring-indicators.test.ts @@ -3,12 +3,14 @@ import { act, create } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentSpinner } from './AgentSpinner' import { AgentStateDot } from './AgentStateDot' +import { colors } from '../theme/mobile-theme' const DESKTOP_WORKING_COLOR = '#eab308' type MonitoringTestRenderer = { readonly root: { findByType(type: string): { props: Record } + findAllByType(type: string): { props: Record }[] } unmount(): void } @@ -84,4 +86,17 @@ describe('mobile monitoring indicators', () => { expect(animationTiming).toHaveBeenCalledOnce() expect(animationLoop).toHaveBeenCalledOnce() }) + + it.each([ + // A user's Stop is not news: muted, never the fault red a failure draws. + ['interrupted', colors.textMuted], + ['failed', '#ef4444'] + ] as const)('draws %s with its own dot colour', async (state, color) => { + await act(async () => { + renderer = create(createElement(AgentStateDot, { state })) + }) + + const dot = renderer?.root.findAllByType('View').find((view) => Array.isArray(view.props.style)) + expect(dot?.props.style).toContainEqual({ backgroundColor: color }) + }) }) diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index bb9d962107a..9c894e3cd7f 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -123,6 +123,7 @@ function MobileNativeChatMessageImpl({ onToggleTurn(turnKey) : undefined} /> diff --git a/mobile/src/session/MobileNativeChatTurnStatus.test.ts b/mobile/src/session/MobileNativeChatTurnStatus.test.ts index e99412b42bf..d2069a134b2 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.test.ts +++ b/mobile/src/session/MobileNativeChatTurnStatus.test.ts @@ -47,6 +47,7 @@ describe('MobileNativeChatTurnStatus', () => { function render(props: { startedAt: number | null workedSeconds?: number | null + verdict?: 'interruption' | 'cancellation' expanded?: boolean onToggleExpanded?: () => void }): ReactTestRenderer { @@ -77,6 +78,24 @@ describe('MobileNativeChatTurnStatus', () => { expect(onToggleExpanded).toHaveBeenCalledOnce() }) + it('heads a turn a crash cut off as failed, and a turn the user stopped as interrupted', () => { + const crashed = render({ + startedAt: Date.now(), + workedSeconds: 12, + verdict: 'interruption', + onToggleExpanded: vi.fn() + }) + expect(labels(crashed.root)).toEqual(['Failed after 12s']) + act(() => crashed.unmount()) + const stopped = render({ + startedAt: Date.now(), + workedSeconds: 12, + verdict: 'cancellation', + onToggleExpanded: vi.fn() + }) + expect(labels(stopped.root)).toEqual(['Interrupted after 12s']) + }) + it('stays a plain row when the settled turn has nothing to disclose', () => { const tree = render({ startedAt: Date.now(), workedSeconds: 5 }) expect(tree.root.findAllByType('Pressable' as never)).toHaveLength(0) diff --git a/mobile/src/session/MobileNativeChatTurnStatus.tsx b/mobile/src/session/MobileNativeChatTurnStatus.tsx index 25003685240..a39e404c639 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.tsx +++ b/mobile/src/session/MobileNativeChatTurnStatus.tsx @@ -7,6 +7,7 @@ import { NATIVE_CHAT_TURN_STATUS_COPY, nativeChatElapsedSeconds } from '../../../src/shared/native-chat-turn-status' +import type { AgentTurnOutcome } from '../../../src/shared/agent-turn-outcome' import { colors, spacing, typography } from '../theme/mobile-theme' /** Seconds tick only while a turn is actually counting, so a settled transcript @@ -27,22 +28,26 @@ function useElapsedSeconds(startedAt: number | null, counting: boolean): number } /** The turn bar under the user's message: "Working for 12s" while the turn runs, - * settling in place to a tappable "Worked for 3m 4s" that discloses the turn's - * tool activity. Desktop parity: `NativeChatWorkingStatus`. */ + * settling in place to a tappable "Worked for 3m 4s" ("Interrupted after" for a Stop, + * "Failed after" for a fault) that discloses the turn's tool activity. Desktop parity: + * `NativeChatWorkingStatus`. */ export function MobileNativeChatTurnStatus({ startedAt, workedSeconds, + verdict, expanded = false, onToggleExpanded }: { startedAt: number | null workedSeconds?: number | null + /** How a settled turn ended; it picks the settled label. */ + verdict?: AgentTurnOutcome expanded?: boolean onToggleExpanded?: () => void }): React.JSX.Element { const settled = workedSeconds != null const elapsedSeconds = useElapsedSeconds(startedAt, !settled) - const label = formatNativeChatTurnStatusLabel({ workedSeconds, elapsedSeconds }) + const label = formatNativeChatTurnStatusLabel({ workedSeconds, elapsedSeconds, verdict }) if (settled && onToggleExpanded) { return ( diff --git a/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx b/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx index bc14028c872..9795c008540 100644 --- a/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx @@ -115,7 +115,7 @@ describe('useMobileStructuredAgentTurnTiming', () => { expect(timing?.workingStartedAt).toBe(CLIENT_NOW - 2_500) // The row's provider key resolves through the submission alias, not journal order. expect([...timing!.settledTurns]).toEqual([ - ['orca:first', { startedAt: HOST_START, workedSeconds: 61 }], + ['orca:first', { startedAt: HOST_START, workedSeconds: 61, verdict: 'interruption' }], ['u2', null] ]) diff --git a/mobile/src/worktree/agent-row-display.test.ts b/mobile/src/worktree/agent-row-display.test.ts index 47898017ff8..c3fa8c84b6d 100644 --- a/mobile/src/worktree/agent-row-display.test.ts +++ b/mobile/src/worktree/agent-row-display.test.ts @@ -4,7 +4,7 @@ import { agentMainAgentVerdict, agentVerdictDisplayMark } from '../../../src/shared/agent-main-agent-verdict' -import { AGENT_JOURNAL_TURN_OUTCOMES } from '../../../src/shared/agent-turn-outcome' +import { AGENT_TURN_OUTCOMES } from '../../../src/shared/agent-turn-outcome' import { AGENT_STATUS_STALE_AFTER_MS, agentDisplayLabel, @@ -16,7 +16,7 @@ import { formatTimeAgo } from './agent-row-display' -type Outcome = (typeof AGENT_JOURNAL_TURN_OUTCOMES)[number] +type Outcome = (typeof AGENT_TURN_OUTCOMES)[number] const mainAgentDone = (outcome: Outcome, stateStartedAt = 0) => ({ mainAgent: { state: 'done' as const, outcome, stateStartedAt } }) @@ -50,19 +50,48 @@ describe('agentDotState', () => { expect(agentDotState(row({ state: 'unknown-state' as never }), 0)).toBe('idle') }) - it('reports the verdict of a done row: failed, interrupted, or an old host legacy flag', () => { + it("reports the verdict of a done row: failed, or a user's Stop (also an old host's flag) as interrupted", () => { expect(agentDotState(row({ state: 'done', interrupted: true }), 0)).toBe('interrupted') expect(agentDotState(row({ state: 'done', ...mainAgentDone('failure') }), 0)).toBe('failed') expect( agentDotState(row({ state: 'done', ...mainAgentDone('cancellation'), interrupted: true }), 0) ).toBe('interrupted') expect(agentDotState(row({ state: 'done', ...mainAgentDone('success') }), 0)).toBe('done') + // A turn a newer request replaced reads as a Stop does. + expect(agentDotState(row({ state: 'done', ...mainAgentDone('superseded') }), 0)).toBe( + 'interrupted' + ) + }) + + it('reads a crash-cut turn as failed and an unproven end as unconfirmed', () => { + expect(agentDotState(row({ state: 'done', ...mainAgentDone('interruption') }), 0)).toBe( + 'failed' + ) + expect(agentDisplayLabel(row({ state: 'done', ...mainAgentDone('interruption') }), 0)).toBe( + 'Failed' + ) + expect(agentDotState(row({ state: 'done', ...mainAgentDone('unconfirmed') }), 0)).toBe( + 'unconfirmed' + ) + expect(agentDisplayLabel(row({ state: 'done', ...mainAgentDone('unconfirmed') }), 0)).toBe( + 'Couldn’t confirm' + ) + }) + + // Rows arrive unparsed, so an arm a newer host adds must read as the done it always did. + it('reads a done row carrying an outcome it cannot name as done', () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an arm from a newer host, which the unparsed wire row can carry. + const future = mainAgentDone('from-a-newer-host' as Outcome) + expect(agentDotState(row({ state: 'done', ...future }), 0)).toBe('done') }) it('shows a main agent that failed while its subagents still run as failed', () => { expect(agentDotState(row({ state: 'working', ...mainAgentDone('failure') }), 0)).toBe('failed') expect(agentDotState(row({ state: 'waiting', ...mainAgentDone('failure') }), 0)).toBe('failed') - // Only a failure outranks live work; a success or a stop with live subagents reads working. + expect(agentDotState(row({ state: 'working', ...mainAgentDone('interruption') }), 0)).toBe( + 'failed' + ) + // Only a fault outranks live work; a success or a stop with live subagents reads working. expect(agentDotState(row({ state: 'working', ...mainAgentDone('success') }), 0)).toBe('working') expect( agentDotState( @@ -78,7 +107,7 @@ describe('agentDotState', () => { const mainAgents = [ undefined, ...states.flatMap((state) => - [undefined, ...AGENT_JOURNAL_TURN_OUTCOMES].map((outcome) => ({ + [undefined, ...AGENT_TURN_OUTCOMES].map((outcome) => ({ state, ...(outcome ? { outcome } : {}), stateStartedAt: 0 @@ -112,9 +141,9 @@ describe('agentDotState', () => { ).toBe('working') // 'done' never decays, and neither does its verdict. expect(agentDotState(row({ state: 'done', updatedAt: 0 }), stale)).toBe('done') - expect(agentDotState(row({ state: 'done', updatedAt: 0, interrupted: true }), stale)).toBe( - 'interrupted' - ) + expect( + agentDotState(row({ state: 'done', updatedAt: 0, ...mainAgentDone('interruption') }), stale) + ).toBe('failed') }) }) diff --git a/mobile/src/worktree/agent-row-display.ts b/mobile/src/worktree/agent-row-display.ts index da93aa39220..87b07abfbfb 100644 --- a/mobile/src/worktree/agent-row-display.ts +++ b/mobile/src/worktree/agent-row-display.ts @@ -1,5 +1,5 @@ import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' -import type { AgentJournalTurnOutcome } from '../../../src/shared/agent-turn-outcome' +import type { AgentTurnOutcome } from '../../../src/shared/agent-turn-outcome' // Mirrors the desktop AGENT_STATUS_STALE_AFTER_MS (src/shared/agent-status-types.ts: // 30 min). Defined locally rather than imported because a runtime-value import @@ -19,26 +19,55 @@ export type AgentDotState = | 'idle' | 'interrupted' | 'failed' + | 'unconfirmed' type AgentRowVerdictSource = Pick +// Mirrors AGENT_TURN_OUTCOMES (src/shared/agent-turn-outcome.ts), which mobile cannot import as a +// value (see AGENT_STATUS_STALE_AFTER_MS above). The rows arrive unparsed, so a newer host's arm +// has to read as no verdict here. +const AGENT_TURN_OUTCOMES: readonly AgentTurnOutcome[] = [ + 'success', + 'failure', + 'cancellation', + 'superseded', + 'interruption', + 'unconfirmed' +] + // Mirrors desktop agentMainAgentVerdict and agentVerdictDisplayMark // (src/shared/agent-main-agent-verdict.ts); a parity test runs both over one table. `mainAgent` is // the main agent's own status, sent also while subagents hold the row working; an old host sends none. -export function agentRowVerdict(row: AgentRowVerdictSource): AgentJournalTurnOutcome | null { +export function agentRowVerdict(row: AgentRowVerdictSource): AgentTurnOutcome | null { if (row.mainAgent && row.mainAgent.state !== 'done') { return null } - return row.mainAgent?.outcome ?? (row.state === 'done' && row.interrupted ? 'cancellation' : null) + const outcome = row.mainAgent?.outcome + if (outcome !== undefined) { + return AGENT_TURN_OUTCOMES.find((known) => known === outcome) ?? null + } + return row.state === 'done' && row.interrupted ? 'cancellation' : null } -// A failure outranks every state; a stop marks only a row that is itself done. -export function agentRowVerdictMark(row: AgentRowVerdictSource): 'failed' | 'interrupted' | null { - const verdict = agentRowVerdict(row) - if (verdict === 'failure') { - return 'failed' +// A fault (a failure, or a turn cut short by something other than the user) reads failed and +// outranks every state; a user's Stop, a turn a newer request replaced, and an unproven end mark +// only a row that is itself done. +export function agentRowVerdictMark( + row: AgentRowVerdictSource +): 'failed' | 'interrupted' | 'unconfirmed' | null { + switch (agentRowVerdict(row)) { + case 'failure': + case 'interruption': + return 'failed' + case 'cancellation': + case 'superseded': + return row.state === 'done' ? 'interrupted' : null + case 'unconfirmed': + return row.state === 'done' ? 'unconfirmed' : null + case 'success': + case null: + return null } - return verdict === 'cancellation' && row.state === 'done' ? 'interrupted' : null } export function agentDotState( @@ -85,6 +114,8 @@ export function agentStateLabel(state: AgentDotState): string { return 'Interrupted' case 'failed': return 'Failed' + case 'unconfirmed': + return 'Couldn’t confirm' case 'done': return 'Done' case 'idle': diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts index 7f351193029..bac1da60fe7 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -123,7 +123,7 @@ describe('a Claude retrying a refused request', () => { // Once the frames stop, the same clock does let the sweep close it. clock += STRUCTURED_AGENT_SESSION_IDLE_MS await vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict') expect(host.hasSession(SESSION)).toBe(false) }) }) diff --git a/src/main/claude/claude-close-stop-cause.test.ts b/src/main/claude/claude-close-stop-cause.test.ts new file mode 100644 index 00000000000..ba93c53a3d8 --- /dev/null +++ b/src/main/claude/claude-close-stop-cause.test.ts @@ -0,0 +1,92 @@ +// Closing a Claude child settles its open turn in the adapter, with the cause the host handed the +// close. Only a stop the user aimed at this chat reads as their cancellation; an exit the adapter +// saw before the close settles as that exit. + +import { describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalTurnLifecycle +} from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { + PROVIDER_SESSION_ID, + adapterFor, + fakeClaude, + identityFor, + tick +} from './claude-structured-session-test-support' + +function turnSink() { + const turns: AgentJournalTurnLifecycle[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (_identity, body: AgentJournalItemBody) => { + const turn = readAgentJournalTurn(body) + if (turn) { + turns.push(turn) + } + }, + appendTombstone: () => {}, + publish: () => {} + } + return { sink, turns } +} + +/** A live Claude child inside a turn the provider opened on its own (a background-task wake). */ +async function childInsideTurn() { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + const { sink, turns } = turnSink() + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink }) + const connection = claude.connections[0]! + connection.handlers.onMessage?.({ + session_id: PROVIDER_SESSION_ID, + type: 'assistant', + uuid: 'assistant-1', + parent_tool_use_id: null, + message: { role: 'assistant', content: [{ type: 'text', text: 'working' }] } + }) + await tick() + expect(turns.at(-1)).toMatchObject({ state: 'running' }) + return { adapter, connection, events, turns } +} + +describe('a Claude close settles the open turn with the host-named cause', () => { + it("records the user's close of this chat as their cancellation", async () => { + const { adapter, events, turns } = await childInsideTurn() + + await expect(adapter.closeSession('session-1', 'user-close')).resolves.toBe(true) + + expect(turns.at(-1)).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(events.find((event) => event.type === 'ended')).toMatchObject({ + stopCause: 'user-close' + }) + }) + + it('leaves an eviction as news', async () => { + const { adapter, turns } = await childInsideTurn() + + await adapter.closeSession('session-1', 'evict') + + expect(turns.at(-1)).toMatchObject({ state: 'interrupted' }) + expect(turns.at(-1)).not.toHaveProperty('outcome') + }) + + it('leaves a crash it saw before the user closed the chat as news', async () => { + const { adapter, connection, events, turns } = await childInsideTurn() + // The child dies on its own first; the user's close arrives while that exit is still settling. + connection.handlers.onExit?.(new Error('provider exited')) + + await adapter.closeSession('session-1', 'user-close') + await tick() + + const settled = turns.filter((turn) => turn.state !== 'running') + expect(settled).toEqual([expect.objectContaining({ state: 'interrupted' })]) + expect(settled[0]).not.toHaveProperty('outcome') + expect(events.filter((event) => event.type === 'ended')).toEqual([ + expect.objectContaining({ cause: 'unexpected-exit' }) + ]) + }) +}) diff --git a/src/main/claude/claude-journal-translator-contract.ts b/src/main/claude/claude-journal-translator-contract.ts index 45feeb84364..ed508b30846 100644 --- a/src/main/claude/claude-journal-translator-contract.ts +++ b/src/main/claude/claude-journal-translator-contract.ts @@ -2,6 +2,7 @@ import type { AgentSessionContextReport } from '../../shared/agent-session-context-usage' import type { StructuredAgentSessionSinkAdmission } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeChildToolQueries } from './claude-child-tool-queries' import type { ClaudeContextReportPart, ClaudeContextReportTarget } from './claude-context-facts' import type { ClaudeJournalPrompts } from './claude-structured-journal-prompts' @@ -14,6 +15,11 @@ export type ClaudeJournalTranslator = { /** The open turn's provider id — the same id its journal row carries, and the one * a client's Stop names. Sole owner: no reader keeps a copy to disagree with. */ readonly currentTurnId: string | null + /** Orca is stopping this turn; its error end reads as the user's cancellation when they asked. + * False when the turn is no longer open. */ + recordTurnStop: (turnId: string, cause: StructuredAgentSessionStopCause) => boolean + /** The provider refused the stop. */ + withdrawTurnStop: (turnId: string) => void /** The open turn's id while it is a conversation command's. */ readonly commandTurnId: string | null /** Makes the host's command turn the open one until the command's result ends it. */ diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index 864624fe59e..5aca644143b 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -12,6 +12,7 @@ import { type AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { claudeCurrentTurnIdentity, claudeTurnLifecycleItem, @@ -32,6 +33,9 @@ export type ClaudeOpenTurnDeps = { export class ClaudeOpenTurn { private current: ClaudeCurrentTurn | null = null + /** The stop Orca sent, held against the turn it was sent to: it reads only while that turn is open. */ + private sentStop: { turn: ClaudeCurrentTurn; cause: StructuredAgentSessionStopCause } | null = + null /** Provider output may not reopen a turn after the session ended or a turn * failed: nothing would ever close the turn it opened, and the row would read * working for the life of the session. Only an accepted send lifts it. */ @@ -86,6 +90,26 @@ export class ClaudeOpenTurn { return this.current !== null } + get stop(): StructuredAgentSessionStopCause | null { + return this.current && this.sentStop?.turn === this.current ? this.sentStop.cause : null + } + + /** Orca is stopping `turnId`. False when that turn is no longer the open one. */ + recordStop(turnId: string, cause: StructuredAgentSessionStopCause): boolean { + if (this.current?.turnId !== turnId) { + return false + } + this.sentStop = { turn: this.current, cause } + return true + } + + /** The provider refused the stop, so the turn goes on as if none was sent. */ + withdrawStop(turnId: string): void { + if (this.current?.turnId === turnId) { + this.sentStop = null + } + } + /** Whether a turn is open inside a provider request cycle that has already * done work — the state in which the CLI folds an arriving send into it. A * cycle's first send is its opener, never a fold. */ @@ -105,12 +129,17 @@ export class ClaudeOpenTurn { /** Open a turn, ending whichever one was still open. A new turn starting is the * only end the previous one gets when its result never arrives; settling it - * later would sweep THIS turn. */ + * later would sweep THIS turn. The replaced turn is recorded superseded: a newer + * request ended it, whoever sent that request. */ open(turn: ClaudeCurrentTurn, observedAt: number): void { this.deps.onOpen?.() if (this.current) { this.deps.settleChildren(this.groupKey) - this.publish(this.current, { state: 'interrupted', completedAt: observedAt }) + this.publish(this.current, { + state: 'interrupted', + completedAt: observedAt, + outcome: 'superseded' + }) } this.current = turn this.publish(turn) @@ -123,7 +152,11 @@ export class ClaudeOpenTurn { this.deps.onOpen?.() if (this.current) { this.deps.settleChildren(this.groupKey) - this.publish(this.current, { state: 'interrupted', completedAt: turn.startedAt }) + this.publish(this.current, { + state: 'interrupted', + completedAt: turn.startedAt, + outcome: 'superseded' + }) } this.current = turn this.deps.sink.setActivity?.(null) diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts index 14fd2e94eb6..1fe79b62606 100644 --- a/src/main/claude/claude-result-journaling.ts +++ b/src/main/claude/claude-result-journaling.ts @@ -56,13 +56,15 @@ export function journalClaudeResult( } // Read before the settle below closes it: the result reports that turn's end. const endedTurnScope = turn.turnScope + // The stop Orca sent that turn: after the user's own, an error end is their cancellation. + const stop = settlesTurn ? turn.stop : null if (settlesTurn) { prompts.retryPendingCancellations() turn.suppressReopenOnFailure(message.is_error === true) // The turn is over however it ended, so a foreground child still // reported as working will never be settled by an event. subagents.settleTurn(turn.groupKey) - context.settle(message, commandEnd ?? claudeTurnEndForResult(message, observedAt)) + context.settle(message, commandEnd ?? claudeTurnEndForResult(message, observedAt, stop)) // The turn is over. A block still awaiting its final keeps the text the // flush above journaled, but its live state goes: an interrupted turn // would otherwise retain that text for the life of the session. @@ -70,7 +72,7 @@ export function journalClaudeResult( streamedText.settle() } const kind = claudeProviderFrameKind(message) - const failure = claudeResultFailure(message) + const failure = claudeResultFailure(message, stop) if (failure || !isSettledClaudeResultKind(kind)) { providerFallback.append( kind, diff --git a/src/main/claude/claude-result-outcome.ts b/src/main/claude/claude-result-outcome.ts index 5dbfbfffc71..cdbf5a529bf 100644 --- a/src/main/claude/claude-result-outcome.ts +++ b/src/main/claude/claude-result-outcome.ts @@ -6,6 +6,8 @@ // the user's or the provider's. import type { AgentJournalTurnOutcome } from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { stopIsTheUsers } from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' import { claudeText } from './claude-structured-item-translation' /** The SDK reports the user's stop as an error result, so `is_error` alone cannot @@ -13,11 +15,18 @@ import { claudeText } from './claude-structured-item-translation' const CLAUDE_ABORTED_TERMINAL_REASONS = new Set(['aborted_streaming', 'aborted_tools']) /** A success-subtype result still carries `is_error` for an API error, so the flag - * is what decides, never the subtype. */ -export function claudeResultOutcome(message: Record): AgentJournalTurnOutcome { + * is what decides, never the subtype. `stop` is the stop Orca sent for this turn: an error + * end after the user's own is their cancellation, since older CLIs name no reason. */ +export function claudeResultOutcome( + message: Record, + stop: StructuredAgentSessionStopCause | null = null +): AgentJournalTurnOutcome { if (message.is_error !== true) { return 'success' } + if (stop !== null && stopIsTheUsers(stop)) { + return 'cancellation' + } const reason = claudeText(message.terminal_reason) return reason !== null && CLAUDE_ABORTED_TERMINAL_REASONS.has(reason) ? 'cancellation' : 'failure' } diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index 4ba08d3b642..e4aa15352c3 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -249,6 +249,8 @@ describe('answerClaudePrompt', () => { resolve: resolvePrompt }, currentTurnId: null, + recordTurnStop: () => true, + withdrawTurnStop: () => {}, commandTurnId: null, beginCommand: vi.fn(), forgetCommand: vi.fn(), diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index 8ec1aa2e9c4..412439a0be9 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -4,6 +4,7 @@ import { ClaudeControlRequestError } from './claude-stream-json-connection' import { settleCancelledClaudeDispatchWaiters } from './claude-structured-dispatch' import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' import type { ClaudeSession } from './claude-structured-session-state' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' const INTERRUPT_CANCEL_QUEUED_CAPABILITY = 'interrupt_cancel_queued_v1' @@ -25,7 +26,8 @@ export async function cancelClaudeTurn( session: ClaudeSession, timeoutMs: number | undefined, isCurrent: ClaudeTurnCancellationGuard = () => true, - onDispatchSettledLate?: ClaudeLateDispatchSettlement + onDispatchSettledLate?: ClaudeLateDispatchSettlement, + stopped?: { turnId: string; cause: StructuredAgentSessionStopCause } ): Promise<{ cancelled: boolean }> { // The SDK interrupt is session-scoped. Re-check the caller's turn/fence // immediately before issuing it so a delayed request cannot stop a later turn. @@ -33,6 +35,10 @@ export async function cancelClaudeTurn( return { cancelled: false } } const cancelQueued = supportsClaudeQueuedInterruptCancellation(session) + // Recorded before the interrupt goes out, so the result it provokes finds it. + if (stopped) { + session.translator?.recordTurnStop(stopped.turnId, stopped.cause) + } try { const receipt = await session.connection.interrupt({ ...(cancelQueued ? { cancelQueued: true } : {}), @@ -52,6 +58,11 @@ export async function cancelClaudeTurn( return { cancelled: true } } catch (error) { if (error instanceof ClaudeControlRequestError) { + // The CLI refused, so the turn runs on and its own end means what it says. Any other error + // leaves the interrupt's effect unknown, and the stop the user asked for stands. + if (stopped) { + session.translator?.withdrawTurnStop(stopped.turnId) + } return { cancelled: false } } throw error diff --git a/src/main/claude/claude-structured-in-turn-stop.test.ts b/src/main/claude/claude-structured-in-turn-stop.test.ts new file mode 100644 index 00000000000..c5bbc51d8bc --- /dev/null +++ b/src/main/claude/claude-structured-in-turn-stop.test.ts @@ -0,0 +1,182 @@ +// A user's Stop inside a live Claude chat interrupts the turn and keeps the session. The turn's end +// then comes from the CLI's result frame, which CLIs before 2.1.91 send with no terminal_reason. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { ClaudeControlRequestError } from './claude-stream-json-connection' +import { + PROVIDER_SESSION_ID, + USER_MESSAGE, + adapterFor, + fakeClaude, + identityFor, + type FakeConnection +} from './claude-structured-session-test-support' + +const CUT_SHORT = { + type: 'result', + subtype: 'error_during_execution', + is_error: true, + session_id: PROVIDER_SESSION_ID, + parent_tool_use_id: null +} + +async function runningChat(claude: ReturnType): Promise<{ + adapter: ReturnType + bodies: Map + connection: FakeConnection + turnId: string +}> { + const bodies = new Map() + const adapter = adapterFor(claude) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: { + appendItem: (identity, body) => bodies.set(agentJournalItemKey(identity), body), + appendTombstone: (identity) => bodies.delete(agentJournalItemKey(identity)), + publish: vi.fn() + } + }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-a', + body: USER_MESSAGE, + fence: 7 + }) + const connection = claude.connections[0]! + // Claude adopts the client uuid for the echo that opens the turn. + connection.handlers.onMessage?.({ ...connection.sent.at(-1)! }) + const turnId = [...bodies.values()] + .map((body) => readAgentJournalTurn(body)) + .find((turn) => turn?.state === 'running')?.turnId + if (!turnId) { + throw new Error('expected a running turn') + } + return { adapter, bodies, connection, turnId } +} + +function settled(bodies: Map, turnId: string) { + return [...bodies.values()] + .map((body) => readAgentJournalTurn(body)) + .find((turn) => turn?.turnId === turnId && turn.state !== 'running') +} + +function providerRows(bodies: Map): string[] { + return [...bodies.keys()].filter((key) => key.includes('provider-frame')) +} + +describe("a user's Stop inside a live Claude chat", () => { + it('records the turn the interrupt cut as their cancellation when the CLI names no reason', async () => { + const claude = fakeClaude({ + routes: { + // The CLI aborts the turn, then acknowledges the interrupt. + interrupt: () => { + claude.connections[0]!.handlers.onMessage?.(CUT_SHORT) + return undefined + } + } + }) + const { adapter, bodies, turnId } = await runningChat(claude) + + await expect(adapter.cancelTurn({ sessionId: 'session-1', turnId, fence: 7 })).resolves.toEqual( + { cancelled: true } + ) + + expect(settled(bodies, turnId)).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) + expect(providerRows(bodies)).toEqual([]) + }) + + // The chat's Stop button names no turn: it stops whatever the conversation has open. + it('records the open turn a Stop naming no turn cut as their cancellation', async () => { + const claude = fakeClaude({ + routes: { + interrupt: () => { + claude.connections[0]!.handlers.onMessage?.(CUT_SHORT) + return undefined + } + } + }) + const { adapter, bodies, turnId } = await runningChat(claude) + + await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + cancelled: true + }) + + expect(settled(bodies, turnId)).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) + expect(providerRows(bodies)).toEqual([]) + }) + + it('reads the same result with no Stop as a failure', async () => { + const { bodies, connection, turnId } = await runningChat(fakeClaude()) + + connection.handlers.onMessage?.(CUT_SHORT) + + expect(settled(bodies, turnId)).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(providerRows(bodies)).toHaveLength(1) + }) + + it('keeps a Stop naming no turn off the turn after it', async () => { + const claude = fakeClaude({ + routes: { + interrupt: () => { + claude.connections[0]!.handlers.onMessage?.(CUT_SHORT) + return undefined + } + } + }) + const { adapter, bodies, connection } = await runningChat(claude) + await adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) + + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-b', + body: USER_MESSAGE, + fence: 7 + }) + connection.handlers.onMessage?.({ ...connection.sent.at(-1)! }) + const nextTurnId = [...bodies.values()] + .map((body) => readAgentJournalTurn(body)) + .find((turn) => turn?.state === 'running')?.turnId + if (!nextTurnId) { + throw new Error('expected the next turn running') + } + connection.handlers.onMessage?.(CUT_SHORT) + + expect(settled(bodies, nextTurnId)).toMatchObject({ state: 'completed', outcome: 'failure' }) + }) + + it.each([ + ['naming the turn', true], + ['naming no turn', false] + ] as const)( + 'keeps a failure the turn reaches after the CLI refused the interrupt, %s', + async (_label, named) => { + const claude = fakeClaude({ + routes: { + interrupt: () => { + throw new ClaudeControlRequestError('interrupt', 'not running') + } + } + }) + const { adapter, bodies, connection, turnId } = await runningChat(claude) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', ...(named ? { turnId } : {}), fence: 7 }) + ).resolves.toEqual({ cancelled: false }) + connection.handlers.onMessage?.(CUT_SHORT) + + expect(settled(bodies, turnId)).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(providerRows(bodies)).toHaveLength(1) + } + ) +}) diff --git a/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts b/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts index ee0f4970f4d..35f3422c187 100644 --- a/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts +++ b/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts @@ -256,6 +256,7 @@ describe('Claude structured turn timing', () => { { turnId: 'user-1', state: 'interrupted', + outcome: 'superseded', startedAt: 1_000, completedAt: 3_000, userItemId: USER_1_KEY diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index f49eed242d5..dcf3a3a6720 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -1,3 +1,7 @@ +import { + childEndCauseOfEndedEvent, + turnVerdictForChildEnd +} from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' @@ -185,8 +189,11 @@ export function createClaudeJournalTranslator( streamedText.flush() subagents.settleSession() backgroundTasks.settleSession() - // The host saw the child end, so the turn's end is observed, not lost. - turn.settle({ state: 'interrupted', completedAt: event.observedAt ?? Date.now() }) + // The host saw the child end, so the turn's end is observed, not lost; its verdict is only + // what the host's own cause says, a user's stop of this chat or else news. + turn.settle( + turnVerdictForChildEnd(childEndCauseOfEndedEvent(event), event.observedAt ?? Date.now()) + ) // A frame that arrives after the child is gone must not open a turn no // event can close. turn.suppressReopen() @@ -275,6 +282,8 @@ export function createClaudeJournalTranslator( get currentTurnId() { return turn.id }, + recordTurnStop: (turnId, cause) => turn.recordStop(turnId, cause), + withdrawTurnStop: (turnId) => turn.withdrawStop(turnId), get commandTurnId() { return turn.command ? turn.id : null }, diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index 78c63af8075..1014455324e 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -75,7 +75,16 @@ function cancelClaudeConversation( session.fence === request.fence && session.acquisitionGeneration === acquisitionGeneration && (claudeLiveTurnId(session, request) !== null || session.dispatchWaiters.length > 0) - return cancelClaudeTurn(session, timeoutMs, isCurrent, onDispatchSettledLate) + // A turn is cancelled only at a client's request, so the stop is the user's: on the named turn, + // else on whatever turn is open. + const stoppedTurnId = request.turnId ?? session.translator?.currentTurnId ?? null + return cancelClaudeTurn( + session, + timeoutMs, + isCurrent, + onDispatchSettledLate, + stoppedTurnId === null ? undefined : { turnId: stoppedTurnId, cause: 'user-stop' } + ) } export async function cancelClaudeStructuredTurn(input: { @@ -166,6 +175,7 @@ export async function cancelClaudeStructuredTurn(input: { : compactionOwnsTurn() || (ownsRequestedTurn() && dispatchAdmissionAllowsCancellation())) let interruptConfirmed = false try { + // A turn is cancelled only at a client's request, so the stop is the user's. const result = await cancelClaudeTurn( session, timeoutMs, @@ -177,7 +187,8 @@ export async function cancelClaudeStructuredTurn(input: { } return current }, - input.onDispatchSettledLate + input.onDispatchSettledLate, + { turnId: requestedTurnId, cause: 'user-stop' } ) if (result.cancelled && claim && cancellationObserved) { interruptConfirmed = true diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts index cd5441d7ec1..0da42846fe5 100644 --- a/src/main/claude/claude-structured-provider-fallback.ts +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -16,6 +16,7 @@ import { type ClaudeMessageEnvelope } from './claude-structured-item-translation' import { claudeResultOutcome } from './claude-result-outcome' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeRowStamp } from './claude-provisional-row-corrections' import { CLAUDE_API_RETRY_FRAME_KIND, @@ -51,11 +52,12 @@ export function isSettledClaudeResultKind(kind: string): boolean { * would only be noise. */ export function claudeResultFailure( - message: Record + message: Record, + stop: StructuredAgentSessionStopCause | null = null ): { text: string | null } | null { // A cancellation is not a fault and earns no error row; the outcome classifier // owns that distinction so this reader cannot drift from the turn's verdict. - if (claudeResultOutcome(message) !== 'failure') { + if (claudeResultOutcome(message, stop) !== 'failure') { return null } const result = claudeText(message.result)?.trim() diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index d2788bf4027..43576cc44e9 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -3,7 +3,8 @@ import { dispatchClaudeCommand } from './claude-structured-command-dispatch' import type { AgentSessionAcquisition, StructuredAgentSessionAcquireInput, - StructuredAgentSessionAdapter + StructuredAgentSessionAdapter, + StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { stopClaudeBackgroundTasks } from './claude-structured-control-actions' import { dispatchClaudeTurn } from './claude-structured-dispatch' @@ -282,20 +283,30 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda ...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {}) }) - closeSession = (sessionId: string): Promise => + closeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => // After the close, not before: releasing an exit still settling settles it on the way. - this.closeSessionProcess(sessionId).finally(() => this.settledExitErrors.delete(sessionId)) + this.closeSessionProcess(sessionId, cause).finally(() => + this.settledExitErrors.delete(sessionId) + ) - private closeSessionProcess(sessionId: string): Promise { + private closeSessionProcess( + sessionId: string, + cause: StructuredAgentSessionStopCause | undefined + ): Promise { + // An exit seen first settles as that exit, whoever asked for the close after it. if (this.exits.has(sessionId)) { return this.releaseAcquisition({ sessionId }) } - return this.afterClose(sessionId, () => this.closeProviderSession(sessionId)) + return this.afterClose(sessionId, () => this.closeProviderSession(sessionId, cause)) } - private closeProviderSession = (sessionId: string): Promise => + private closeProviderSession = ( + sessionId: string, + stopCause?: StructuredAgentSessionStopCause + ): Promise => closeClaudeSession({ sessionId, + ...(stopCause ? { stopCause } : {}), sessions: this.sessions, acquisitions: this.acquisitions, ...(this.deps.persistHandle ? { persistHandle: this.deps.persistHandle } : {}), diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index f49b6a8d88f..91a0df1e2b1 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -6,6 +6,7 @@ import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' import { cancelClaudeAcquisitionAttempt } from './claude-structured-session-state' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { AgentSessionAcquisitionExitUnprovenError, AgentSessionAcquisitionRootExitObservedError, @@ -77,6 +78,8 @@ export function settleClaudeExitedSession(session: ClaudeSession): void { type CloseClaudePublishedSessionInput = { sessions: Map sessionId: string + /** Who asked for the close; the translator settles the open turn with it. */ + stopCause?: StructuredAgentSessionStopCause persistHandle?: (handle: { sessionId: string providerSessionId: string @@ -135,6 +138,7 @@ async function finalizeClaudePublishedSession( type: 'ended', sessionId: input.sessionId, reason: 'claude session closed', + ...(input.stopCause ? { stopCause: input.stopCause } : {}), observedAt: Date.now() } as const let callbackError: unknown @@ -242,6 +246,7 @@ export function closeClaudePublishedSessionForDeps( export async function closeClaudeSession(input: { sessionId: string + stopCause?: StructuredAgentSessionStopCause sessions: Map acquisitions: ClaudeAcquisitionRegistry persistHandle?: (handle: { diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 0d43dbb99ec..fd51d2987e0 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -5,7 +5,10 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { StructuredAgentSessionStartedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + StructuredAgentSessionStartedEvent, + StructuredAgentSessionStopCause +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection, openClaudeStreamJsonConnection @@ -73,6 +76,8 @@ export type ClaudeStructuredSessionEvent = failure?: SubmissionRejectionFact /** Present for first-hand child exits so the host can fence recovery. */ cause?: 'unexpected-exit' | 'requested-close' + /** Who asked for a close; the translator settles the open turn with it. */ + stopCause?: StructuredAgentSessionStopCause fence?: number acquisitionGeneration?: string /** Host clock when the end was observed. */ diff --git a/src/main/claude/claude-turn-lifecycle-item.ts b/src/main/claude/claude-turn-lifecycle-item.ts index be7150531fe..da3af882309 100644 --- a/src/main/claude/claude-turn-lifecycle-item.ts +++ b/src/main/claude/claude-turn-lifecycle-item.ts @@ -5,6 +5,7 @@ import type { } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionAppendOptions } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { claudeResultOutcome } from './claude-result-outcome' import type { ClaudeCommandTurn } from './claude-command-turn' @@ -31,9 +32,9 @@ export function claudeCurrentTurnIdentity(turn: ClaudeCurrentTurn): AgentJournal export type ClaudeTurnEnd = { state: 'completed' | 'interrupted' completedAt: number - /** Only an end the PROVIDER reported carries one. An end the host inferred — - * the child going away, a new turn superseding this one — leaves it absent, - * which reads as unknown rather than claiming the turn worked. */ + /** An end the provider reported carries its verdict, and a turn a newer one + * replaced carries `superseded`. The child going away leaves it absent, which + * reads as unknown rather than claiming the turn worked. */ outcome?: AgentJournalTurnOutcome /** The SDK's own measured turn duration; only a result frame carries one. */ durationMs?: number @@ -44,9 +45,10 @@ export type ClaudeTurnEnd = { * is still a turn the host watched finish, and only `outcome` says it failed. */ export function claudeTurnEndForResult( message: Record, - completedAt: number + completedAt: number, + stop: StructuredAgentSessionStopCause | null = null ): ClaudeTurnEnd { - const outcome = claudeResultOutcome(message) + const outcome = claudeResultOutcome(message, stop) const durationMs = message.duration_ms return { state: outcome === 'cancellation' ? 'interrupted' : 'completed', diff --git a/src/main/claude/claude-turn-outcome.test.ts b/src/main/claude/claude-turn-outcome.test.ts index 8223652458b..c8de34cee08 100644 --- a/src/main/claude/claude-turn-outcome.test.ts +++ b/src/main/claude/claude-turn-outcome.test.ts @@ -1,12 +1,16 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody, - AgentJournalItemIdentity + AgentJournalItemIdentity, + AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { readAgentJournalTurn, readAgentJournalTurnOutcome } from '../../shared/agent-session-turn-record' +import { describeNativeChatTurnStatus } from '../../shared/native-chat-turn-status' +import { selectStructuredAgentSettledTurns } from '../../shared/structured-agent-session-turn-timing' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { claudeResultOutcome } from './claude-result-outcome' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' @@ -162,23 +166,12 @@ describe('claudeTurnLifecycleItem', () => { }) describe('a turn end the host inferred', () => { - it.each([ - [ - 'the child ending', - (translator: ReturnType) => - translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' }) - ], - [ - 'a new turn superseding it', - (translator: ReturnType) => - translator.handle(userTurn('user-2')) - ] - ])('records no outcome for a turn ended by %s', (_label, end) => { + it('records no outcome for a turn ended by the child ending', () => { const state = sinkState() const translator = createClaudeJournalTranslator({ sink: state.sink }) translator.handle(userTurn('user-1')) - end(translator) + translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' }) // No result frame arrived, so the provider never said what became of the // turn. The host observed the END — the arm stays `interrupted` — but the @@ -194,4 +187,173 @@ describe('a turn end the host inferred', () => { expect(settled?.body).not.toHaveProperty('outcome') expect(readAgentJournalTurnOutcome(readAgentJournalTurn(settled?.body))).toBeNull() }) + + // The supersede fires for any send Orca dispatched, and nothing here says whether the user or + // another agent sent it, so it is recorded as replaced, never as the user's stop or a fault. + it('records a turn a newer send superseded as superseded, folded as interrupted', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + let observedAt = 1_000 + translator.handle({ ...userTurn('user-1'), observedAt }) + observedAt = 13_000 + translator.handle({ ...userTurn('user-2'), observedAt }) + + // The journal as a reader holds it: each turn row at its newest revision, after the user row + // it names (the host journals that row, not this translator). + const rows = new Map() + state.items.forEach((item, index) => { + const itemId = agentJournalItemKey(item.identity) + rows.set(itemId, { itemId, revision: index, sequence: index, observedAt, body: item.body }) + }) + const items = [...rows.values()].flatMap((row): AgentJournalRenderItem[] => { + const userItemId = readAgentJournalTurn(row.body)?.userItemId + return userItemId + ? [ + { + ...row, + itemId: userItemId, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] } + }, + row + ] + : [row] + }) + const superseded = items.find((item) => readAgentJournalTurn(item.body)?.turnId === 'user-1') + expect(readAgentJournalTurn(superseded?.body)).toMatchObject({ + state: 'interrupted', + outcome: 'superseded' + }) + const settled = selectStructuredAgentSettledTurns(items).get( + readAgentJournalTurn(superseded?.body)?.userItemId ?? '' + ) + expect(settled).toMatchObject({ verdict: 'superseded', workedSeconds: 12 }) + expect( + settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) + ).toMatchObject({ key: 'interruptedAfter', duration: '12s' }) + }) +}) + +describe("a user's Stop inside a live turn", () => { + // Claude CLIs before 2.1.91 send no terminal_reason, and later ones may omit it. + const cutShort = { type: 'result', subtype: 'error_during_execution', is_error: true } + + function settledTurn(items: ReturnType['items'], turnId: string) { + return items + .map((item) => readAgentJournalTurn(item.body)) + .findLast((turn) => turn?.turnId === turnId && turn.state !== 'running') + } + + function providerRows(items: ReturnType['items']): number { + return items.filter( + (item) => + item.identity.provider === 'orca' && + item.identity.clientMessageId.startsWith('provider-frame:') + ).length + } + + it('reads an error result with no terminal reason as the cancellation it asked for', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + + expect(translator.recordTurnStop('user-1', 'user-stop')).toBe(true) + translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) + + expect(settledTurn(state.items, 'user-1')).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) + expect(providerRows(state.items)).toBe(0) + }) + + it.each([ + ['no terminal reason', cutShort], + [ + 'a failure terminal reason', + { ...cutShort, terminal_reason: 'api_error', result: 'API Error' } + ] + ])('keeps a result with %s and no Stop a failure', (_label, frame) => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + + translator.handle({ type: 'message', sessionId: 'orca-session', message: frame }) + + expect(settledTurn(state.items, 'user-1')).toMatchObject({ + state: 'completed', + outcome: 'failure' + }) + expect(providerRows(state.items)).toBe(1) + }) + + it('keeps a turn that finished during the Stop a success', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + + translator.recordTurnStop('user-1', 'user-stop') + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'result', subtype: 'success', is_error: false } + }) + + expect(settledTurn(state.items, 'user-1')).toMatchObject({ outcome: 'success' }) + }) + + it('does not carry a Stop onto the next turn', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + translator.recordTurnStop('user-1', 'user-stop') + // Superseded before its result: the Stop was for user-1 only. + translator.handle(userTurn('user-2')) + translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) + // A late Stop names a turn that already ended. + expect(translator.recordTurnStop('user-2', 'user-stop')).toBe(false) + translator.handle(userTurn('user-3')) + translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) + + expect(settledTurn(state.items, 'user-2')).toMatchObject({ outcome: 'failure' }) + expect(settledTurn(state.items, 'user-3')).toMatchObject({ outcome: 'failure' }) + }) + + it('ends the Stop with its turn, so a result after the turn settled reads on its own', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + translator.recordTurnStop('user-1', 'user-stop') + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'system', subtype: 'session_state_changed', state: 'idle' } + }) + + translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) + + expect(providerRows(state.items)).toBe(1) + }) + + it('forgets a Stop the CLI refused', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + + translator.recordTurnStop('user-1', 'user-stop') + translator.withdrawTurnStop('user-1') + translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) + + expect(settledTurn(state.items, 'user-1')).toMatchObject({ outcome: 'failure' }) + }) + + it('does not read a host stop as the user asking', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + + translator.recordTurnStop('user-1', 'host-stop') + translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) + + expect(settledTurn(state.items, 'user-1')).toMatchObject({ outcome: 'failure' }) + }) }) diff --git a/src/main/claude/claude-turn-ownership.test.ts b/src/main/claude/claude-turn-ownership.test.ts index 6bfcb32cb86..a7255aab558 100644 --- a/src/main/claude/claude-turn-ownership.test.ts +++ b/src/main/claude/claude-turn-ownership.test.ts @@ -95,6 +95,8 @@ function sessionHoldingTurn(turnId: string | null): ReturnType true, + withdrawTurnStop: () => {}, commandTurnId: null, beginCommand: vi.fn(), forgetCommand: vi.fn(), diff --git a/src/main/codex/codex-close-stop-cause.test.ts b/src/main/codex/codex-close-stop-cause.test.ts new file mode 100644 index 00000000000..69618f22dd8 --- /dev/null +++ b/src/main/codex/codex-close-stop-cause.test.ts @@ -0,0 +1,158 @@ +// Closing a Codex child settles its open turn in the adapter, with the cause the host handed the +// close. Only a stop the user aimed at this chat reads as their cancellation. + +import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { CodexPromptRegistry } from './codex-structured-prompt-replies' +import { + closeCodexPublishedSession, + handleCodexSessionExit +} from './codex-structured-session-close' +import { CodexAcquisitionRegistry, type CodexSession } from './codex-structured-session-state' +import { CodexStructuredSessionTeardown } from './codex-structured-session-teardown' + +/** A live Codex child whose primary thread is inside `turn-1`, and what its ended batch wrote. */ +function sessionWithRunningTurn() { + const turnBodies: AgentJournalItemBody[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {}, + tryAppendLifecycleBatch: (_id, mutations) => { + for (const mutation of mutations) { + if (mutation.kind === 'item' && mutation.body.kind === 'turn') { + turnBodies.push(mutation.body) + } + } + return { accepted: true } + } + } + const translator = createCodexJournalTranslator({ + sink, + sessionId: 'session-1', + primaryThreadId: () => 'thread-1', + now: () => 2_000 + }) + translator.handle({ + type: 'notification', + sessionId: 'session-1', + threadId: 'thread-1', + method: 'turn/started', + params: { turn: { id: 'turn-1' } }, + observedAt: 1_000 + }) + const session: CodexSession = { + connection: { + pid: 4321, + closed: false, + request: async () => ({}), + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + }, + backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), + ended: false, + requestedClose: false, + fence: 7, + acquisitionGeneration: 'generation-1', + threadId: 'thread-1', + historyPath: null, + prompts: new CodexPromptRegistry(), + options: new Map(), + reportedOptions: {}, + fastModeTierByModel: new Map(), + dispatchEchoes: createCodexDispatchEchoes(), + turnOpenWaits: createCodexTurnOpenWaits(), + translator + } + return { sessions: new Map([['session-1', session]]), session, turnBodies } +} + +describe('a Codex close settles the open turn with the host-named cause', () => { + it.each(['user-close', 'user-stop'] satisfies StructuredAgentSessionStopCause[])( + "records the user's %s as their cancellation", + async (stopCause) => { + const { sessions, turnBodies } = sessionWithRunningTurn() + const onEvent = vi.fn() + + await expect( + closeCodexPublishedSession(sessions, 'session-1', onEvent, { stopCause }) + ).resolves.toBe(true) + + expect(turnBodies).toEqual([ + expect.objectContaining({ turnId: 'turn-1', state: 'interrupted', outcome: 'cancellation' }) + ]) + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'ended', cause: 'requested-close', stopCause }) + ) + } + ) + + it.each([['evict'], ['host-stop'], [undefined]] as const)( + 'leaves a close for %s as news', + async (stopCause) => { + const { sessions, turnBodies } = sessionWithRunningTurn() + + await closeCodexPublishedSession( + sessions, + 'session-1', + undefined, + stopCause ? { stopCause } : {} + ) + + expect(turnBodies).toEqual([ + expect.objectContaining({ turnId: 'turn-1', state: 'interrupted' }) + ]) + expect(turnBodies[0]).not.toHaveProperty('outcome') + } + ) + + it('leaves a crash it saw before the user closed the chat as news', async () => { + const { sessions, session, turnBodies } = sessionWithRunningTurn() + // The child died on its own first; the user's close then finds it already ended. + handleCodexSessionExit({ + sessions, + sessionId: 'session-1', + connection: session.connection, + error: new Error('app-server exited') + }) + + await closeCodexPublishedSession(sessions, 'session-1', undefined, { stopCause: 'user-close' }) + + expect(turnBodies).toEqual([ + expect.objectContaining({ turnId: 'turn-1', state: 'interrupted' }) + ]) + expect(turnBodies[0]).not.toHaveProperty('outcome') + }) + + it('never carries a user cause onto a close forced after a sink failure', async () => { + const { sessions, turnBodies } = sessionWithRunningTurn() + + await closeCodexPublishedSession(sessions, 'session-1', undefined, { + requestedClose: false, + stopCause: 'user-close' + }) + + expect(turnBodies[0]).not.toHaveProperty('outcome') + }) + + it("carries the host's cause from the adapter's close to the ended batch", async () => { + const { sessions, turnBodies } = sessionWithRunningTurn() + const teardown = new CodexStructuredSessionTeardown({ + sessions, + acquisitions: new CodexAcquisitionRegistry(), + forgetNotificationRetries: () => {} + }) + + await expect(teardown.close('session-1', 'user-close')).resolves.toBe(true) + + expect(turnBodies[0]).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) +}) diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index be5e5368088..0129a56319b 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -1,3 +1,7 @@ +import { + childEndCauseOfEndedEvent, + turnVerdictForChildEnd +} from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' import { createCodexProviderActivityReader } from '../native-chat/agent-session-wire/provider-frame-activity' import { CODEX_TOKEN_USAGE_METHOD } from './codex-subagent-activity' import { @@ -157,14 +161,18 @@ export function createCodexJournalTranslator( currentTurnIds: activeTurns.byThread, primaryThreadId: deps.primaryThreadId?.() ?? null, ordinals: items.ordinals, - // The host saw the child go, not what Codex made of the turn, so the row - // carries no outcome: the end is observed, the verdict is unknown. + // The host saw the child go, not what Codex made of the turn: the verdict is only what + // the host's own cause says, a user's stop of this chat or else news. settledTurnLifecycle: (threadId, turnId) => turnBoundaries.ownsRecord(threadId, turnId) - ? turnBoundaries.settled(threadId, turnId, { - state: 'interrupted', - completedAt: event.observedAt ?? deps.now?.() ?? Date.now() - }) + ? turnBoundaries.settled( + threadId, + turnId, + turnVerdictForChildEnd( + childEndCauseOfEndedEvent(event), + event.observedAt ?? deps.now?.() ?? Date.now() + ) + ) : null, attributionFor }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index e0b1a93b395..2ab884d7cd1 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -9,7 +9,8 @@ import type { AgentSessionDispatchOutcome, StructuredAgentSessionAcquireInput, StructuredAgentSessionAdapter, - StructuredAgentSessionSetOptionInput + StructuredAgentSessionSetOptionInput, + StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation' import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' @@ -285,9 +286,11 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap identity: AgentSessionJournalIdentity }): Promise => this.sessions.get(input.identity.sessionId)?.historyPath ?? null - closeSession = (sessionId: string): Promise => this.teardown.close(sessionId) + closeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => + this.teardown.close(sessionId, cause) forceCloseSession = (sessionId: string): Promise => this.teardown.forceClose(sessionId) - disposeSession = (sessionId: string): Promise => this.teardown.close(sessionId) + disposeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => + this.teardown.close(sessionId, cause) closeAll = (): Promise => this.teardown.closeAll() releaseAcquisition = (input: { sessionId: string }): Promise => this.teardown.close(input.sessionId) diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 5bf973308b1..0a017f37f19 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -8,7 +8,10 @@ import { type CodexStructuredSessionAdapterDeps, type CodexStructuredSessionEvent } from './codex-structured-session-state' -import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + StructuredAgentSessionEndedEvent, + StructuredAgentSessionStopCause +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' export function handleCodexSessionExit(input: { sessions: Map @@ -40,6 +43,9 @@ export function handleCodexSessionExit(input: { ? agentSessionFailureFact('hostFault') : agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(input.error) }), cause: session.requestedClose ? 'requested-close' : 'unexpected-exit', + ...(session.requestedClose && session.closeStopCause + ? { stopCause: session.closeStopCause } + : {}), fence: session.fence, acquisitionGeneration: session.acquisitionGeneration, observedAt: session.exitObservedAt @@ -74,6 +80,8 @@ export async function closeCodexPublishedSession( options?: { allowFailedSettlement?: boolean requestedClose?: boolean + /** Who asked for a requested close; the translator settles the open turn with it. */ + stopCause?: StructuredAgentSessionStopCause expectedFence?: number expectedAcquisitionGeneration?: string unexpectedReason?: Error @@ -93,6 +101,7 @@ export async function closeCodexPublishedSession( // Sink-failure recovery force-closes the child but must preserve the // observed-exit cause so host lease settlement runs as an unexpected death. session.requestedClose = options?.requestedClose ?? true + session.closeStopCause = options?.stopCause // Keep the session indexed until the child exit is observed. A timeout or // failed kill must leave the live connection available for a safe retry. const exited = await session.connection.close() @@ -124,7 +133,8 @@ export async function closeCodexSession( sessionId: string, sessions: Map, acquisitions: CodexAcquisitionRegistry, - onEvent?: (event: CodexStructuredSessionEvent) => void + onEvent?: (event: CodexStructuredSessionEvent) => void, + stopCause?: StructuredAgentSessionStopCause ): Promise { const attempt = acquisitions.get(sessionId) if (!(await cancelCodexAcquisitionAttempt(attempt))) { @@ -133,7 +143,7 @@ export async function closeCodexSession( if (attempt) { acquisitions.deleteIfCurrent(sessionId, attempt) } - return closeCodexPublishedSession(sessions, sessionId, onEvent) + return closeCodexPublishedSession(sessions, sessionId, onEvent, stopCause ? { stopCause } : {}) } export async function closeAllCodexSessions( diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index fcfb120fe82..78864fe9044 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -19,7 +19,10 @@ import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexJournalTranslator } from './codex-structured-journal-translation' -import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + StructuredAgentSessionEndedEvent, + StructuredAgentSessionStopCause +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' import type { AgentModelCatalogSessionAccess, @@ -111,6 +114,8 @@ export type CodexSession = { /** First observed child exit survives rejected settlement admission. */ exitObservedAt?: number requestedClose: boolean + /** Who asked for the requested close in flight, carried onto its `ended`. */ + closeStopCause?: StructuredAgentSessionStopCause fence: number acquisitionGeneration: string threadId: string diff --git a/src/main/codex/codex-structured-session-teardown.ts b/src/main/codex/codex-structured-session-teardown.ts index dcd38eb82fe..f302e15b367 100644 --- a/src/main/codex/codex-structured-session-teardown.ts +++ b/src/main/codex/codex-structured-session-teardown.ts @@ -5,6 +5,7 @@ // proven stopped — a refused close leaves the session indexed for a retry. import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { closeAllCodexSessions, closeCodexPublishedSession, @@ -30,12 +31,13 @@ export type CodexStructuredSessionTeardownDeps = { export class CodexStructuredSessionTeardown { constructor(private readonly deps: CodexStructuredSessionTeardownDeps) {} - close = async (sessionId: string): Promise => { + close = async (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => { const closed = await closeCodexSession( sessionId, this.deps.sessions, this.deps.acquisitions, - this.deps.onEvent + this.deps.onEvent, + cause ) return this.settled(sessionId, closed) } diff --git a/src/main/ipc/notification-options.ts b/src/main/ipc/notification-options.ts index 24d43545e13..b06987c4b75 100644 --- a/src/main/ipc/notification-options.ts +++ b/src/main/ipc/notification-options.ts @@ -76,12 +76,24 @@ function formatAgentNotificationStatusText(args: NotificationDispatchRequest): s if (args.agentState === 'working') { return translateMain('notifications.agentStatus.working', 'working') } - if (args.agentState === 'done' && args.agentTurnOutcome === 'failure') { - return translateMain('notifications.agentStatus.failed', 'failed') + if (args.agentState !== 'done') { + return translateMain('notifications.agentStatus.finished', 'finished') + } + switch (args.agentTurnOutcome) { + // A turn cut short by anything but the user is a fault, as a failure is. + case 'failure': + case 'interruption': + return translateMain('notifications.agentStatus.failed', 'failed') + // Why: a Stop the user asked for, a turn a newer request replaced, or an end Orca cannot + // prove, still never reads finished. + case 'cancellation': + case 'superseded': + case 'unconfirmed': + return translateMain('notifications.agentStatus.stopped', 'stopped') + case 'success': + case undefined: + return translateMain('notifications.agentStatus.finished', 'finished') } - return args.agentState === 'done' && args.agentTurnOutcome === 'cancellation' - ? translateMain('notifications.agentStatus.stopped', 'stopped') - : translateMain('notifications.agentStatus.finished', 'finished') } function formatNotificationWorktreeContext(args: NotificationDispatchRequest): string { diff --git a/src/main/ipc/notifications-message-formatting.test.ts b/src/main/ipc/notifications-message-formatting.test.ts index 3d519293f3c..bf96745fb4f 100644 --- a/src/main/ipc/notifications-message-formatting.test.ts +++ b/src/main/ipc/notifications-message-formatting.test.ts @@ -319,6 +319,10 @@ describe('registerNotificationHandlers', () => { it.each([ { agentTurnOutcome: 'cancellation', word: 'stopped' }, { agentTurnOutcome: 'failure', word: 'failed' }, + // A turn cut short by anything but the user is a fault, worded as one. + { agentTurnOutcome: 'interruption', word: 'failed' }, + { agentTurnOutcome: 'unconfirmed', word: 'stopped' }, + { agentTurnOutcome: 'superseded', word: 'stopped' }, { agentTurnOutcome: 'success', word: 'finished' }, { agentTurnOutcome: undefined, word: 'finished' } ] as const)('words a $agentTurnOutcome finish as $word', async ({ agentTurnOutcome, word }) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index b7371ee40f3..a80c147607d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -231,7 +231,7 @@ function deferred() { async function writeAsEarlierProcess( write: (journal: AgentSessionJournal, fence: number) => Promise ): Promise { - await host.close(SESSION) + await host.close(SESSION, 'evict') const record = store.getRecord(SESSION)! const params = attachParamsForRecord(record, { clientOperationId: 'earlier', @@ -261,7 +261,7 @@ function earlierSubmission(id: string, text: string, handoverRecorded?: true) { describe('a send is answered at acceptance', () => { it('answers before the child starts, then an open chat sees the handover and the reply (W2)', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') const starting = deferred() acquire.mockImplementationOnce(async (input) => { await starting.promise @@ -289,7 +289,7 @@ describe('a send is answered at acceptance', () => { }) it('accepts a second send while the first one starts the child, before handing either over (W6)', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') const starting = deferred() acquire.mockImplementationOnce(async (input) => { await starting.promise @@ -320,7 +320,7 @@ describe('a send is answered at acceptance', () => { describe('a start the chat needed and did not get', () => { it('writes one error row and rejects every queued message with it; the next send starts (W3)', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) const first = await accept('first') const second = await accept('second') @@ -350,7 +350,7 @@ describe('a start the chat needed and did not get', () => { }) it('draws the messages it failed above the error row, since they were accepted first', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) const first = await accept('first') const second = await accept('second') @@ -369,7 +369,7 @@ describe('a start the chat needed and did not get', () => { }) it('notifies failed once for the queued messages one start failure refused', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) const completions: AgentSessionTurnCompletionEvent[] = [] host.subscribeTurnCompletions({ id: 'dot-1', emit: (event) => completions.push(event) }) @@ -434,7 +434,7 @@ describe('a start the chat needed and did not get', () => { } ] ])('writes one row a live chat sees for a %s refusal (W14)', async (_source, arrange, row) => { - await host.close(SESSION) + await host.close(SESSION, 'evict') arrange() await host.flushAllStreamedEvents() await startHost() @@ -455,7 +455,7 @@ describe('a start the chat needed and did not get', () => { }) it('names the start failure on queued messages when the attach fails after acquiring (W4′a)', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') const id = await accept('hello') // The attach's own success record is the post-acquisition step that fails. const record = vi.spyOn(store, 'recordOperationOutcome') @@ -487,7 +487,7 @@ describe('a start the chat needed and did not get', () => { describe('an attach that fails after indexing its child', () => { it('leaves no child behind, so the next send starts one and is delivered', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') const owned: boolean[] = [] host.subscribeStatus({ id: 'list-1', @@ -578,7 +578,7 @@ describe('a child that exits before its message is handed over', () => { }) }) adapterExtras = { awaitStarted } - await host.close(SESSION) + await host.close(SESSION, 'evict') await startHost() const id = await accept('hello') @@ -602,7 +602,7 @@ describe('a start whose failure the delivery loop settles before the exit is pub ...(await spawnChild(input)), providerChildPhase: 'starting' as const })) - await host.close(SESSION) + await host.close(SESSION, 'evict') await startHost() const first = await accept('first') @@ -659,7 +659,7 @@ describe('Stop withdraws what is queued', () => { }) it('withdraws a message whose start holds the queue: nothing is handed over (W17b)', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') const starting = deferred() acquire.mockImplementationOnce(async (input) => { await starting.promise @@ -685,7 +685,7 @@ describe('Stop withdraws what is queued', () => { return true }) adapterExtras = { awaitStarted, closeSession } - await host.close(SESSION) + await host.close(SESSION, 'evict') await startHost() acquire.mockImplementationOnce(async (input) => ({ ...(await spawnChild(input)), @@ -713,12 +713,12 @@ describe('an eviction between acceptance and handover', () => { it('rejects the message as not sent, never leaves it in doubt (W24)', async () => { const started = deferred() adapterExtras = { awaitStarted: () => started.promise } - await host.close(SESSION) + await host.close(SESSION, 'evict') await startHost() const id = await accept('hello') await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) // Between the delivery loop's start step and its handover step. - await host.close(SESSION) + await host.close(SESSION, 'evict') started.resolve() expect(await reopened(id)).toMatchObject({ @@ -732,7 +732,7 @@ describe('an eviction between acceptance and handover', () => { const second = deferred() const awaitStarted = vi.fn(async (): Promise => undefined) adapterExtras = { awaitStarted } - await host.close(SESSION) + await host.close(SESSION, 'evict') await startHost() dispatch.mockResolvedValueOnce({ state: 'admitted' }) const handed = await accept('handed over') @@ -741,7 +741,7 @@ describe('an eviction between acceptance and handover', () => { const queued = await accept('still queued') await eventually(async () => expect(awaitStarted).toHaveBeenCalledTimes(2)) - await host.close(SESSION) + await host.close(SESSION, 'evict') second.resolve() expect(await reopened(queued)).toMatchObject({ @@ -756,7 +756,7 @@ describe('an eviction between acceptance and handover', () => { idleMs = 0 const started = deferred() adapterExtras = { awaitStarted: () => started.promise } - await host.close(SESSION) + await host.close(SESSION, 'evict') await startHost() const id = await accept('hello') await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) @@ -775,7 +775,7 @@ describe('an eviction between acceptance and handover', () => { // leaves every queued message rejected as closed, never blamed on the provider. describe('a close that stops the child and then fails', () => { const END_CHILD = { - evict: () => host.close(SESSION) + evict: () => host.close(SESSION, 'evict') } satisfies Partial Promise>> it.each([ @@ -792,7 +792,7 @@ describe('a close that stops the child and then fails', () => { throw new Error('release acknowledgement failed') }) } - await host.close(SESSION) + await host.close(SESSION, 'evict') await startHost() acquire.mockImplementationOnce(async (input) => ({ ...(await spawnChild(input)), @@ -816,7 +816,7 @@ describe('a close that stops the child and then fails', () => { describe('a compaction or rewind an earlier child left prepared', () => { async function leftPrepared(prepare: (fence: number) => Promise): Promise { - await host.close(SESSION) + await host.close(SESSION, 'evict') await prepare(store.getRecord(SESSION)!.lease.runtimeFence) // A new process: nothing is open and no view attaches. await host.flushAllStreamedEvents() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts index e08c289c87a..fb6c001b320 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts @@ -161,7 +161,8 @@ describe('StructuredAgentSessionAdapterRouter optional lifecycle methods', () => const stopSession = router[method] await expect(stopSession('session-1')).resolves.toBe(true) - expect(closeSession).toHaveBeenCalledWith('session-1') + // A host with no cause to name passes none; the adapter settles its turn as news. + expect(closeSession).toHaveBeenCalledWith('session-1', undefined) } ) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 9a759ba0411..72291de4824 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -4,7 +4,10 @@ import type { AgentSessionAccountHome, AgentSessionExecutionLocation } from '../../../shared/agent-session-record' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { + StructuredAgentSessionAdapter, + StructuredAgentSessionStopCause +} from './structured-agent-session-adapter' type RoutedAgent = 'claude' | 'codex' type SessionRoute = { adapter: StructuredAgentSessionAdapter; state: 'live' | 'stopped' } @@ -148,20 +151,21 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi accountHome: AgentSessionAccountHome }) => this.requireAgent(input.identity).providerHistoryWindow?.(input) ?? Promise.resolve(null) - closeSession = (sessionId: string): Promise => - this.stopSession(sessionId, (adapter) => adapter.closeSession) + closeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => + this.stopSession(sessionId, (adapter) => adapter.closeSession, cause) forceCloseSession = (sessionId: string): Promise => this.stopSession(sessionId, (adapter) => adapter.forceCloseSession ?? adapter.closeSession) - disposeSession = (sessionId: string): Promise => - this.stopSession(sessionId, (adapter) => adapter.disposeSession ?? adapter.closeSession) + disposeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => + this.stopSession(sessionId, (adapter) => adapter.disposeSession ?? adapter.closeSession, cause) private async stopSession( sessionId: string, selectStop: ( adapter: StructuredAgentSessionAdapter - ) => NonNullable | undefined + ) => NonNullable | undefined, + cause?: StructuredAgentSessionStopCause ): Promise { const route = this.routes.get(sessionId) if (!route) { @@ -174,7 +178,7 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return true } const stop = selectStop(route.adapter) - const stopped = await stop?.call(route.adapter, sessionId) + const stopped = await stop?.call(route.adapter, sessionId, cause) if (stopped === true) { route.state = 'stopped' return true diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 5b8a9b190ff..6d19c8523bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -38,6 +38,11 @@ import type { ProviderDiagnostic, SubmissionRejectionFact } from '../../../shared/agent-session-failure' +import type { StructuredAgentSessionStopCause } from './structured-agent-session-stop-cause' +export type { + StructuredAgentSessionChildEndCause, + StructuredAgentSessionStopCause +} from './structured-agent-session-stop-cause' import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' @@ -186,6 +191,8 @@ export type StructuredAgentSessionEndedEvent = { * Orca fault. Absent reads as a provider exit with nothing to add. */ failure?: SubmissionRejectionFact cause: 'unexpected-exit' | 'requested-close' + /** With `requested-close`: who asked for it. Absent when the host named no cause. */ + stopCause?: StructuredAgentSessionStopCause fence: number acquisitionGeneration: string /** Host receipt of the child exit: the end time of a turn it interrupted. */ @@ -375,11 +382,11 @@ export type StructuredAgentSessionAdapter = { /** Gracefully stops the structured owner after its event stream is drained. */ /** Returns true only after the provider child exit is proven. A root-exit or processless verdict * is thrown only once the session is finalized; read it through `stopAgentSessionProviderRoot`. */ - closeSession?(sessionId: string): Promise + closeSession?(sessionId: string, cause?: StructuredAgentSessionStopCause): Promise /** Stops a provider after a sink failure; the resulting exit is recovered as unexpected. */ forceCloseSession?(sessionId: string): Promise /** Stops a provider child for teardown without requiring a future-resume cursor. */ - disposeSession?(sessionId: string): Promise + disposeSession?(sessionId: string, cause?: StructuredAgentSessionStopCause): Promise /** Host acknowledgement that the proven-dead child, lease and journal owner are released. */ acknowledgeSessionRelease?(sessionId: string): void } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index f209a333e97..778bd3cc2fa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -135,7 +135,8 @@ describe('Claude root-exit stop', () => { now: () => NOW + 30 * 60_000, publishStatus }, - 'session-1' + 'session-1', + { cause: 'evict' } ) ).resolves.toBeUndefined() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts new file mode 100644 index 00000000000..83d80437de3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -0,0 +1,340 @@ +// A turn the host cuts short by closing its provider is the user's cancellation only when the user +// closed this chat. A quit, an idle eviction or a teardown aimed elsewhere leaves it news: the user +// needs to learn it did not finish. The adapter settles its own open turn with the cause the host +// hands its close, and the host's fallback settles any turn no adapter did, through one mapping. + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { describeNativeChatTurnStatus } from '../../../shared/native-chat-turn-status' +import { selectStructuredAgentSettledTurns } from '../../../shared/structured-agent-session-turn-timing' +import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' +import { + adapter, + attach, + hostTestState, + replaceHostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD +} from './structured-agent-session-host-test-data' +import { + childEndCauseOfEndedEvent, + turnVerdictForChildEnd +} from './structured-agent-session-stale-turn-verdict' + +const CUT_TURN = { provider: 'codex' as const, threadId: THREAD, turnId: 'cut-turn', ordinal: 1 } + +let host: StructuredAgentSessionHost +/** What the provider writes on the open turn as it exits: settled through the mapping with the + * cause its close was handed, as both adapters do, or its own verdict; null writes nothing. */ +let providerEnd: + | 'mapped' + | Pick + | null +/** The provider already saw its own exit when the close arrived. */ +let exitObservedFirst: boolean +let closeCalls = 0 + +beforeEach(() => { + const state = hostTestState() + providerEnd = 'mapped' + exitObservedFirst = false + closeCalls = 0 + host = new StructuredAgentSessionHost({ + store: state.store, + adapter: { + ...adapter(), + closeSession: async (_sessionId, cause) => { + closeCalls += 1 + const events = state.acquire.mock.calls.at(-1)?.[0].events + if (providerEnd === null) { + return true + } + const end = + providerEnd === 'mapped' + ? turnVerdictForChildEnd( + childEndCauseOfEndedEvent({ + type: 'ended', + cause: exitObservedFirst ? 'unexpected-exit' : 'requested-close', + ...(cause ? { stopCause: cause } : {}) + }), + 1_500 + ) + : providerEnd + events?.appendItem( + CUT_TURN, + { + kind: 'turn', + turnId: 'cut-turn', + startedAt: 1_000, + requestedAt: 1_000, + ...end + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return true + } + }, + journalDatabase: openTestJournalHostDatabase(state.root), + recoveryCapsule: new AgentSessionRecoveryCapsule(state.root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => HOST_TEST_NOW + }) + replaceHostTestState({ store: state.store, host }) +}) + +/** A running turn, anchored to its user row, with a status list watching the session. */ +async function runningTurn(): Promise { + await attach() + const events = hostTestState().acquire.mock.calls[0]?.[0].events + if (!events) { + throw new Error('missing provider event sink') + } + events.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'cut-turn', ordinal: 0 }, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + events.appendItem( + CUT_TURN, + { + kind: 'turn', + turnId: 'cut-turn', + state: 'running', + startedAt: 1_000, + requestedAt: 1_000 + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await host.flushStreamedEvents(SESSION) + const statuses: AgentSessionStatusEvent[] = [] + host.subscribeStatus({ id: 'list', emit: (event) => statuses.push(event) }) + return statuses +} + +/** What the settle wrote, read back from the journal the next reader opens. */ +async function settledTurn() { + await host.restoreReadableSessions([SESSION]) + const { items } = await host.journalSnapshot(SESSION) + const turn = items.map((item) => readAgentJournalTurn(item.body)).find(Boolean) + const [settled] = [...selectStructuredAgentSettledTurns(items).values()] + return { turn, settled } +} + +function lastSummary(statuses: AgentSessionStatusEvent[]) { + const last = statuses.at(-1) + return last?.type === 'status' ? last.session : null +} + +describe('a turn cut short by closing its provider', () => { + it("records the user's close of this chat as their cancellation", async () => { + const statuses = await runningTurn() + + await host.close(SESSION, 'user-close') + + expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'cancellation' }) + const { turn, settled } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect( + settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) + ).toMatchObject({ key: 'interruptedAfter' }) + }) + + it('leaves a close the user did not aim at this chat as news', async () => { + const statuses = await runningTurn() + + // What an idle eviction, a worktree teardown or an orchestration stop issues. + await host.close(SESSION, 'evict') + + expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'interruption' }) + const { turn, settled } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted' }) + expect(turn).not.toHaveProperty('outcome') + expect( + settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) + ).toMatchObject({ key: 'failedAfter' }) + }) + + it("records the user's close on a turn no adapter settled, through the host's fallback", async () => { + providerEnd = null + const statuses = await runningTurn() + + await host.close(SESSION, 'user-close') + + expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'cancellation' }) + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) + + it("records the user's close on a turn whose start landed only as the provider stopped", async () => { + // A send echo still in flight when the close arrives: the journal has no turn yet. + await attach() + await host.flushStreamedEvents(SESSION) + + await host.close(SESSION, 'user-close') + + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) + + it('leaves a turn cut off before the close as news', async () => { + providerEnd = null + await attach() + const events = hostTestState().acquire.mock.calls[0]?.[0].events + // An earlier death the user already saw as Interrupted, then closed. + events?.appendItem( + CUT_TURN, + { + kind: 'turn', + turnId: 'cut-turn', + state: 'interrupted', + startedAt: 1_000, + requestedAt: 1_000, + completedAt: 1_200 + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await host.flushStreamedEvents(SESSION) + + await host.close(SESSION, 'user-close') + + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted', completedAt: 1_200 }) + expect(turn).not.toHaveProperty('outcome') + }) + + it('keeps a turn the provider finished during the stop as finished', async () => { + providerEnd = { state: 'completed', outcome: 'success', completedAt: 1_500 } + await runningTurn() + + await host.close(SESSION, 'user-close') + + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'completed', outcome: 'success' }) + }) + + it('keeps a verdict the provider gave on its way out', async () => { + // How Codex records a turn it reports failed. + providerEnd = { state: 'interrupted', outcome: 'failure', completedAt: 1_500 } + await runningTurn() + + await host.close(SESSION, 'user-close') + + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'failure' }) + }) + + it('leaves a quit as news', async () => { + const statuses = await runningTurn() + + await host.flushAllStreamedEvents({ trigger: 'quit' }) + + expect(statuses.findLast((event) => event.type === 'status')).toMatchObject({ + session: { status: 'idle', turnOutcome: 'interruption' } + }) + const { settled } = await settledTurn() + expect( + settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) + ).toMatchObject({ key: 'failedAfter' }) + }) + + it("keeps the user's cancellation when a close aborts after the provider settled, then retries", async () => { + await runningTurn() + const sink = host['runtimeState'].eventSinkFor(SESSION) + const drained = sink.drained.bind(sink) + let failed = false + vi.spyOn(sink, 'drained').mockImplementation(async () => { + if (closeCalls > 0 && !failed) { + failed = true + return { ok: false, error: new Error('drain failed') } + } + return drained() + }) + // The adapter settled the turn, then the close aborted at the drain after it. + await expect(host.close(SESSION, 'user-close')).rejects.toThrow() + expect(closeCalls).toBe(1) + await host.close(SESSION, 'user-close') + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) + + it.each([ + ['user-close', { outcome: 'cancellation' }], + ['evict', { outcome: undefined }] + ] as const)( + "keeps a %s's cause when the idle sweep finishes a wind-down it could not", + async (cause, verdict) => { + providerEnd = null + await runningTurn() + const sink = host['runtimeState'].eventSinkFor(SESSION) + const drained = sink.drained.bind(sink) + let failed = false + vi.spyOn(sink, 'drained').mockImplementation(async () => { + if (closeCalls > 0 && !failed) { + failed = true + return { ok: false, error: new Error('drain failed') } + } + return drained() + }) + // The provider is proven gone, then the close aborts before the host settles its turn. + await expect(host.close(SESSION, cause)).rejects.toThrow() + + await host.collaboratorsForTests().lifetime.idleSweep.tick() + + expect(closeCalls).toBe(1) + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted' }) + expect(turn?.outcome).toBe(verdict.outcome) + } + ) + + it("leaves a quit's cut on a turn no adapter settled as news", async () => { + providerEnd = null + await runningTurn() + + await host.flushAllStreamedEvents({ trigger: 'quit' }) + + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted' }) + expect(turn).not.toHaveProperty('outcome') + }) + + it('leaves a crash the provider saw before the user closed the chat as news', async () => { + exitObservedFirst = true + await runningTurn() + // The provider reports its own exit, which it saw first, as it closes: no verdict. + await host.close(SESSION, 'user-close') + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted' }) + expect(turn).not.toHaveProperty('outcome') + }) + + it.each(['user-close', 'evict'] as const)( + 'closes the conversation of a chat a %s ends, as any close does', + async (cause) => { + await runningTurn() + + await host.close(SESSION, cause) + + expect(host.hasSession(SESSION)).toBe(false) + } + ) +}) + +it('requires every stop to name its cause', () => { + type StopArgs = Parameters + // @ts-expect-error a stop that names no cause must not compile, or it would default to one + const omitted: StopArgs = [host['lifetimeContext'](), SESSION] + expect(omitted).toHaveLength(2) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts index 993ee3fd258..016819a9607 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts @@ -131,7 +131,7 @@ describe('the completion feed around /compact (B6)', () => { expect(latestStructuredAgentSessionRequest(items, submissions)).toMatchObject({ kind: 'turn', id: 'turn-1', - running: false, + turnState: 'completed', outcome: 'success' }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index 977b0ff55cf..5fd28ff3927 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -137,8 +137,8 @@ describe('closing the handle', () => { it('keeps the row when a chat with an open tab is evicted, and forgets it once the tab closes', async () => { await foundRestTestChat(rig) - await rig.host.close(SESSION) - expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) + await rig.host.close(SESSION, 'evict') + expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict') // The stop says not-running; the row belongs to the tab, so nothing forgets it. expect(rig.sink.forget).not.toHaveBeenCalled() expect(rig.sink.publish.mock.calls.at(-1)?.[0]).toMatchObject({ sessionId: SESSION }) @@ -261,7 +261,7 @@ describe('a start that never finishes (P2-15)', () => { rig.clock.now += IDLE_MS + 1 await sweepOnce(rig.host) - expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) + expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'host-stop') await vi.waitFor(() => expect(readerSaw(reader.events).submissions).toContainEqual( expect.objectContaining({ dispatchState: 'rejected', reason: stopReason }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 4ecfea0381b..1d06a61f88c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -15,8 +15,10 @@ import { abandonQueuedStructuredAgentSessionMessages, closeStructuredAgentSessionConversationUnderSerialize, stopStructuredAgentSessionAgentUnderSerialize, + type StructuredAgentSessionCloseCause, type StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' +import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' @@ -46,9 +48,8 @@ export function createStructuredAgentSessionConversationLifetime(host: { session.journal.whenImported().catch((error: unknown) => { throw readRefusals.refusal(sessionId, error) }) - // The sweep's stop puts an idle agent to rest: nothing is queued, so no loop reads its cause. - const stopAgent = (sessionId: string) => - stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId) + const stopAgent = (sessionId: string, cause: StructuredAgentSessionStopCause) => + stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) const closeConversation = (sessionId: string): Promise => closeStructuredAgentSessionConversationUnderSerialize( @@ -75,7 +76,8 @@ export function createStructuredAgentSessionConversationLifetime(host: { return record !== null && deps().hasOpenDispatch?.(record) === true }, providerHoldsDispatch: (sessionId) => deps().adapter.holdsDispatch?.(sessionId) === true, - stopAgent, + // The host puts an idle agent to rest: a turn it cuts short is news, not the user's Stop. + stopAgent: (sessionId) => stopAgent(sessionId, 'evict'), // A host stop: the delivery loop waiting on this child writes the one error row and rejects // what is queued with it, both worded from the hostStopped fact. stopStartingAgent: (sessionId) => @@ -146,7 +148,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { }, /** Ends a chat's resources, not the chat: its record and journal stay on disk, and what is * still queued will not be sent. */ - close: (sessionId: string): Promise => + close: (sessionId: string, cause: StructuredAgentSessionCloseCause): Promise => serialize(sessionId, async () => { readRefusals.forget(sessionId) const session = sessions.get(sessionId) @@ -154,9 +156,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { // Abandoned before the stop, so no start delivers it. await abandonQueuedStructuredAgentSessionMessages(deps(), sessionId, session.journal) } - await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { - cause: 'evict' - }) + await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) await closeConversation(sessionId) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index c7f8f09631a..9d396b58ffa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -10,10 +10,14 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import type { + AgentSessionStatusSummary, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { describeNativeChatTurnStatus } from '../../../shared/native-chat-turn-status' import { completedStructuredAgentTurnSeconds, selectStructuredAgentTurnTimings @@ -291,6 +295,36 @@ describe('a turn a read reached before the reconcile proved its owner dead', () unsubscribe() }) + it('reports the revision to the status feed as an interruption, which the chat folds as failed', async () => { + const published: AgentSessionStatusSummary[] = [] + openHost({ + probeOwner: async () => ({ outcome: 'pid-absent' }), + statusSink: { publish: (summary) => published.push(summary), forget: () => {} } + }) + await host.history({ sessionId: SESSION, direction: 'tail' }) + const outcomes = () => + published + .filter((summary) => summary.sessionId === SESSION && summary.turnOutcome) + .map((summary) => summary.turnOutcome) + expect(outcomes().at(-1)).toBe('unconfirmed') + + await host.reconcileRestartLeases() + await drainSession() + + // The sidebar's red Failed, then the folded "Failed after 27s". + await vi.waitFor(() => expect(outcomes().at(-1)).toBe('interruption')) + const [timing] = selectStructuredAgentTurnTimings( + (await host.journalSnapshot(SESSION)).items + ).values() + expect( + describeNativeChatTurnStatus({ + elapsedSeconds: 0, + workedSeconds: completedStructuredAgentTurnSeconds(timing), + verdict: timing?.verdict + }) + ).toEqual({ key: 'failedAfter', duration: '27s' }) + }) + it('revises nothing twice, whoever re-runs the settle', async () => { openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) await host.history({ sessionId: SESSION, direction: 'tail' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 373e2ae3c36..433505bbd69 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -10,6 +10,7 @@ // would have written them. Stop and the conversation's close are the only other writers of a // queued message: a child's exit only ends the child, and this loop reads why. +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentSessionFailureFact, @@ -53,6 +54,11 @@ export type StructuredAgentSessionDeliveryLoopDeps = { ) => Promise /** The fence the conversation's own writes carry; see `structuredAgentSessionConversationFence`. */ conversationFence: (sessionId: string) => number + /** Rejects queued messages as a completed close of the chat does; false when that failed. */ + abandonQueued: ( + sessionId: string, + which: (submission: AgentJournalSubmission) => boolean + ) => Promise /** Who the chat's failure sentences name. */ failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext onError: (sessionId: string, error: unknown) => void @@ -149,6 +155,10 @@ export class StructuredAgentSessionDeliveryLoop { // A handle closes only with nothing queued, so one an earlier handle wrote is a leftover. (submission) => session.journal.wroteBeforeOpen(submission.acceptedSequence) ) + if (!(await this.closeWhatTheUserClosed(sessionId, session))) { + // Never start an agent for a message the user closed; the next wake re-derives and retries. + return this.stop(sessionId) + } const oldest = oldestQueuedSubmission(session) // A running command takes no input while its child carries it; its end is a commit, which // wakes the loop again. With no child it is a gone generation's, which the start below settles. @@ -193,8 +203,8 @@ export class StructuredAgentSessionDeliveryLoop { // The child waited on is gone, replaced by another, or settled its start without proving it. const ended = awaitedChild ? undefined : session.lastEndedChild const endedFailure = ended ? structuredAgentSessionEndedChildFailure(ended) : undefined - // A user's Stop is not a failure: the next step starts, or waits on, a child for what is - // queued. + // A user's Stop or close is not a failure: the next step starts, or waits on, a child for + // what is queued, after closing what a close of the chat closed. if (endedFailure === null) { return 'continue' } @@ -243,6 +253,27 @@ export class StructuredAgentSessionDeliveryLoop { return this.stop(sessionId) } + /** A close of this chat that stopped its child and then did not complete still closed what was + * queued before it, so no child starts for those. Ordered, not latched: a later send goes on. + * False when those could not be closed. */ + private async closeWhatTheUserClosed( + sessionId: string, + session: StructuredAgentSessionHostSession + ): Promise { + const ended = session.lastEndedChild + if (session.child || ended?.cause !== 'user-close') { + return true + } + const { epoch } = session.journal.cursor() + return this.deps.abandonQueued( + sessionId, + (submission) => + ended.endedAt.epoch === epoch && + submission.acceptedSequence !== undefined && + submission.acceptedSequence <= ended.endedAt.sequence + ) + } + /** Inside the serialized step that found nothing to do, so an accept after it wakes anew. */ private stop(sessionId: string): 'stop' { this.running.delete(sessionId) @@ -281,6 +312,8 @@ function providerEndFailure( // Every end cause, so a new one does not compile until it says whether it fails what is queued. const ENDED_CHILD_FAILURE = { 'user-stop': () => null, + // The user closing this chat closes what was queued before it; see `closeWhatTheUserClosed`. + 'user-close': () => null, // The host stopping the child is Orca's cause, never the provider's: a start that never finished. 'host-stop': () => ({ failure: agentSessionFailureFact('hostStopped') }), exit: providerEndFailure, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts index 8fc810de2a4..38349f95115 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts @@ -76,7 +76,7 @@ describe('structured agent session eviction', () => { await evictStructuredAgentSession(ctx) - expect(disposeSession).toHaveBeenCalledWith('session-1') + expect(disposeSession).toHaveBeenCalledWith('session-1', undefined) expect(closeSession).not.toHaveBeenCalled() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts index 226781117bd..fc45a2c7715 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts @@ -19,7 +19,8 @@ import { stopAgentSessionProviderRoot, - type StructuredAgentSessionAdapter + type StructuredAgentSessionAdapter, + type StructuredAgentSessionStopCause } from './structured-agent-session-adapter' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { StructuredAgentSessionStopVerdict } from './structured-agent-session-host-types' @@ -27,6 +28,8 @@ import { withTimeout } from '../../../shared/promise-timeout-fallback' export type StructuredAgentSessionEvictionContext = { sessionId: string + /** Why the host stops the child; the adapter settles the turn it cuts with it. */ + stopCause?: StructuredAgentSessionStopCause hasProviderChild?: boolean eventSink: DeferredStructuredAgentSessionEventSink adapter: StructuredAgentSessionAdapter @@ -86,7 +89,9 @@ export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSe // An adapter with no close has nothing to stop; anything else must PROVE the exit. const stop = context.adapter.disposeSession ?? context.adapter.closeSession const rootGone = stop - ? await stopAgentSessionProviderRoot(() => stop.call(context.adapter, context.sessionId)) + ? await stopAgentSessionProviderRoot(() => + stop.call(context.adapter, context.sessionId, context.stopCause) + ) : true if (!rootGone) { throw new Error('provider child exit was not proven') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index ed2e48fd562..cdb7b473ae2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -3,6 +3,7 @@ // with a message queued has a delivery loop — and the open is where a loop for leftovers wakes. import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { abandonQueuedStructuredAgentSessionMessages } from './structured-agent-session-host-lifetime' import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { @@ -65,6 +66,12 @@ export function createStructuredAgentSessionConversationDelivery(input: { ensureProviderChild: input.ensureProviderChild, conversationFence: (sessionId) => structuredAgentSessionConversationFence(deps.store, sessionId), + abandonQueued: async (sessionId, which) => { + const session = sessions.get(sessionId) + return session + ? abandonQueuedStructuredAgentSessionMessages(deps, sessionId, session.journal, which) + : true + }, failureTextContext: (sessionId) => structuredAgentSessionFailureWordsContext( deps.store.getRecord(sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index f2140c98fbc..719540da5da 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -9,6 +9,7 @@ // reentrant, so every public entry point takes it once and calls these. import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { @@ -19,7 +20,6 @@ import { import { withStructuredAgentSessionEvictionDeadline } from './structured-agent-session-eviction-deadline' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { - StructuredAgentSessionChildEndCause, StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession, StructuredAgentSessionProviderChildIdentity @@ -30,6 +30,8 @@ import { } from './structured-agent-session-provider-child' import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' +import { turnVerdictForChildEnd } from './structured-agent-session-stale-turn-verdict' +import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' export type StructuredAgentSessionLifetimeContext = { deps: StructuredAgentSessionHostDeps @@ -51,18 +53,27 @@ type ConversationCloseDeps = Pick { - await journal + journal: StructuredAgentSessionHostSession['journal'], + which?: (submission: AgentJournalSubmission) => boolean +): Promise { + return journal .rejectQueuedSubmissions( structuredAgentSessionConversationFence(deps.store, sessionId), - agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) + agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }), + which + ) + .then( + () => true, + (error: unknown) => { + deps.onEventSinkError?.({ sessionId, error }) + return false + } ) - .catch((error: unknown) => deps.onEventSinkError?.({ sessionId, error })) } /** The wind-down this host owes for the session's child. A live child always owes one, whatever a @@ -85,20 +96,22 @@ function owedProviderChildWindDown( export async function stopStructuredAgentSessionAgentUnderSerialize( context: StructuredAgentSessionLifetimeContext, sessionId: string, - ending: { - cause: Extract - reason?: string - } = { cause: 'user-stop' } + // Required: an omitted cause must not default to the user's cancellation. + ending: { cause: StructuredAgentSessionStopCause; reason?: string } ): Promise { const session = context.sessions.get(sessionId) if (!session) { return } + // A retry finishes the stop that ended the child, so the turn that stop cut keeps its cause. + const cause = session.child + ? ending.cause + : (session.owesProviderChildWindDown?.cause ?? ending.cause) // The obligation OUTLIVES the child. `child` is ended the instant the adapter proves the exit, // so a step that aborts after that point would otherwise leave the retry reading "no child // here" and skipping the settlement and the lease release it still owes. const owed = owedProviderChildWindDown(session) - session.owesProviderChildWindDown = owed + session.owesProviderChildWindDown = owed ? { ...owed, cause } : undefined const stopping = session.child let settlementError: unknown const eviction: StructuredAgentSessionEvictionContext = { @@ -108,6 +121,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( owesProviderChildWindDown: owed !== undefined, eventSink: context.runtimeState.eventSinkFor(sessionId), adapter: context.deps.adapter, + // The adapter settles its own open turn with this, so who asked travels with the stop. + stopCause: cause, ...(context.restartWitness ? { beforeProviderChildStop: () => context.restartWitness?.beforeStop(sessionId) } : {}), @@ -117,7 +132,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( endProviderChild(session, { generation: stopping.generation, fence: stopping.fence, - cause: ending.cause, + cause, reason: ending.reason ?? null, duringStartup: stopping.phase === 'starting', ...verdict @@ -136,7 +151,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( fence, settlementId: `expected-close:${sessionId}:${fence}:${owed?.generation ?? 'unknown'}`, pendingSubmissionReason: 'provider_closed_before_acknowledgement', - verdict: { state: 'interrupted', completedAt: context.now() }, + // Only a turn no adapter settled: one with no close, or whose settle threw. + verdict: turnVerdictForChildEnd(cause, context.now()), showUnexpectedExitOutcome: false, onError: (id, error) => { settlementError = error @@ -170,6 +186,12 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( ) } +/** A close's cause: the user closing this chat, or the host evicting it (quit, idle, teardown). */ +export type StructuredAgentSessionCloseCause = Extract< + StructuredAgentSessionStopCause, + 'user-close' | 'evict' +> + /** Whether the conversation's handle is only a cache now: no child, no wind-down owed, and nothing * queued or waiting on the provider. */ export function structuredAgentSessionConversationClosable( @@ -228,7 +250,7 @@ export async function evictOwnedStructuredAgentSessions( ownedSessionIds.map(async (sessionId) => { try { await context.serialize(sessionId, () => - stopStructuredAgentSessionAgentUnderSerialize(context, sessionId) + stopStructuredAgentSessionAgentUnderSerialize(context, sessionId, { cause: 'evict' }) ) retainOnFailure.delete(sessionId) } catch (error) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index 4ce0b3885ad..e6700777227 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -174,7 +174,9 @@ export async function flushStructuredAgentSessionHost( retainSessionIds, acknowledgeSessionRelease: (sessionId) => context.deps.adapter.acknowledgeSessionRelease?.(sessionId), - abandonQueued: (sessionId, session) => - abandonQueuedStructuredAgentSessionMessages(context.deps, sessionId, session.journal) + // Quit's is best effort: a failure is reported, and the next open rejects the leftover. + abandonQueued: async (sessionId, session) => { + await abandonQueuedStructuredAgentSessionMessages(context.deps, sessionId, session.journal) + } }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts index 4d064cbdfaa..2971ce4f79c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts @@ -80,7 +80,7 @@ describe('abandoning a structured agent-session host', () => { expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) // The conversation stays and its provider child does not, so the next send makes the delivery // loop start one — the shape the refusal-oracle spec ends on. - await host.close(SESSION) + await host.close(SESSION, 'evict') gate = new Promise((resolve) => { openGate = resolve }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index f69bc3fb06a..c2ec1833288 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -10,7 +10,9 @@ import type { JournalHostDatabase } from '../agent-session-journal/journal-host- import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter, - StructuredAgentSessionProviderChildPhase + StructuredAgentSessionChildEndCause, + StructuredAgentSessionProviderChildPhase, + StructuredAgentSessionStopCause } from './structured-agent-session-adapter' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' @@ -35,6 +37,11 @@ export type StructuredAgentSessionProviderChildIdentity = { readonly fence: number } +/** A wind-down still owed, with the cause of the stop that owes it: a retry finishes that stop. */ +export type StructuredAgentSessionOwedWindDown = StructuredAgentSessionProviderChildIdentity & { + readonly cause: StructuredAgentSessionStopCause +} + /** The provider process behind a conversation. Written only in * `structured-agent-session-provider-child`. */ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProviderChildIdentity & { @@ -50,12 +57,7 @@ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProvider * `stopAgentSessionProviderRoot`; an observed exit's root is gone by definition. */ export type StructuredAgentSessionStopVerdict = { rootGone: boolean } -export type StructuredAgentSessionChildEndCause = - | 'user-stop' - | 'host-stop' - | 'exit' - | 'attach-failed' - | 'evict' +export type { StructuredAgentSessionChildEndCause } /** How the conversation's last child ended. In memory only: the delivery loop reads it to tell a * Stop from a failure. */ @@ -87,7 +89,7 @@ export type StructuredAgentSessionHostSession = { /** The wind-down this host still owes for a child it started: settling that generation's work * and handing the lease back. Outlives `child`, which ends the moment the adapter proves the * exit — an eviction that aborts after that point must still finish it on the next close. */ - owesProviderChildWindDown?: StructuredAgentSessionProviderChildIdentity + owesProviderChildWindDown?: StructuredAgentSessionOwedWindDown lastEndedChild?: StructuredAgentSessionEndedChild } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index ba3b7581734..62603707ded 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -184,14 +184,15 @@ export class StructuredAgentSessionHost { handleAdapterEvent = (event: Parameters[0]) => this.eventRecovery.handle(event) - private lifetimeContext(): StructuredAgentSessionLifetimeContext { + // Inferred, so the attach context's spread keeps `publishStatus` required. + private lifetimeContext() { return { deps: this.deps, runtimeState: this.runtimeState, sessions: this.sessions, now: () => this.now(), publishStatus: this.clientDelivery.publishStatus - } + } satisfies StructuredAgentSessionLifetimeContext } /** The host's half of attaching, named so it cannot grow dependencies unnoticed. */ @@ -202,12 +203,13 @@ export class StructuredAgentSessionHost { tasks: this.tasks, reconcileLeases: (sessionId) => this.reconcileLeases(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), - publishStatus: this.clientDelivery.publishStatus, openConversation: this.conversationDelivery.open } } - /** Releases a session's resources without ending the conversation; see the lifetime's close. */ - close = (sessionId: string): Promise => this.lifetime.close(sessionId) + /** Releases a session's resources without ending the conversation; see the lifetime's close. + * `user-close` makes a turn it cuts short the user's cancellation; an `evict` leaves it news. */ + close: StructuredAgentSessionConversationLifetime['close'] = (sessionId, cause) => + this.lifetime.close(sessionId, cause) supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent) @@ -278,7 +280,7 @@ export class StructuredAgentSessionHost { ensureAgent: (sessionId) => ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), - stopAgent: this.lifetime.stopAgent, + stopAgent: (sessionId) => this.lifetime.stopAgent(sessionId, 'user-stop'), wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), now: () => this.now() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index 1345adc574f..533904d5fa0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -54,7 +54,7 @@ describe('the idle sweep', () => { await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) await vi.waitFor(() => expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released')) await vi.waitFor(() => expect(rig.adapter.acknowledgeSessionRelease).toHaveBeenCalledOnce()) expect(rig.adapter.acknowledgeSessionRelease).toHaveBeenCalledWith(SESSION) @@ -122,7 +122,7 @@ describe('the idle sweep', () => { expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.adapter.backgroundTaskState.mockReturnValue(undefined) rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) }) // A finished child reads done before the lead's wake-up turn writes its first row; stopping the @@ -143,7 +143,7 @@ describe('the idle sweep', () => { await sweepTicks() expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) }) // Owed work is read every tick, not once a window: work that ends just before a window would @@ -165,7 +165,7 @@ describe('the idle sweep', () => { await sweepTicks() expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.clock.now += IDLE_MS - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) }) it('stops an agent whose roster holds only children that went idle or finished', async () => { @@ -179,7 +179,7 @@ describe('the idle sweep', () => { }) rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) }) it('never stops an agent while its lead turn runs, however quiet (P2-10)', async () => { @@ -202,7 +202,7 @@ describe('the idle sweep', () => { await rig.host.subscribe({ id: 'on-screen', sessionId: SESSION, emit: reader.emit }) rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) expect(reader.events.some((event) => event.type === 'end')).toBe(false) }) @@ -221,7 +221,7 @@ describe('the idle sweep', () => { await sweepTicks() expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.clock.now += IDLE_MS - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) }) it('never stops a worker whose orchestration dispatch is open, and stops it once it settles (P2-19 i)', async () => { @@ -237,7 +237,7 @@ describe('the idle sweep', () => { expect(hasOpenDispatch).toHaveBeenCalledWith(expect.objectContaining({ sessionId: SESSION })) open = false rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) }) // A Claude retrying a rate-limited request has taken the send but echoes nothing, so no turn row @@ -254,7 +254,7 @@ describe('the idle sweep', () => { await sweepTicks() expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) }) it('keeps a child an unanswered prompt waits on (P2-22 i)', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 510384f3e48..44615a27ee8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -129,7 +129,7 @@ beforeEach(async () => { afterEach(async () => { await host.flushAllStreamedEvents() - await host.close(SESSION) + await host.close(SESSION, 'evict') await rm(root, { recursive: true, force: true }) }) @@ -202,7 +202,7 @@ describe('settling a send the provider proves it received after the ack window', return true }) - await host.close(SESSION) + await host.close(SESSION, 'evict') await expect(settlement).resolves.toBeUndefined() await host.revealSession(SESSION) expect(await submissions()).toMatchObject([{ dispatchState: 'accepted' }]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index 1ec8f2784a2..aa7b8a17295 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -212,9 +212,9 @@ describe('structured session options and close', () => { it('stops the provider child and forgets the session when the chat closes', async () => { expect(host.hasSession(SESSION)).toBe(true) - await host.close(SESSION) + await host.close(SESSION, 'evict') - expect(closeNativeSession).toHaveBeenCalledWith(SESSION) + expect(closeNativeSession).toHaveBeenCalledWith(SESSION, 'evict') expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', ownerProcess: null, @@ -222,15 +222,15 @@ describe('structured session options and close', () => { }) expect(host.hasSession(SESSION)).toBe(false) - await expect(host.close(SESSION)).resolves.toBeUndefined() + await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() expect(closeNativeSession).toHaveBeenCalledOnce() }) it('is a no-op for a session it does not hold', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') closeNativeSession.mockClear() - await expect(host.close(SESSION)).resolves.toBeUndefined() + await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() expect(closeNativeSession).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 75061435a1f..230709e5d21 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -28,6 +28,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-a import { ensureStructuredAgentSessionAgent } from './structured-agent-session-agent-start' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { HOST_TEST_LOCATION, HOST_TEST_NOW as NOW, @@ -41,6 +42,9 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } +const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { + surface: 'rejection' +}) let root: string let store: AgentSessionRecordStore @@ -119,7 +123,7 @@ beforeEach(async () => { store = await openTestAgentSessionRecordStore(root) startHost() expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - await host.close(SESSION) + await host.close(SESSION, 'evict') }) afterEach(async () => { @@ -642,7 +646,7 @@ describe('a quit with a message still queued', () => { starting.resolve() await quit - expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict') expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) expect(dispatch).not.toHaveBeenCalled() expect(await afterRelaunch(id)).toMatchObject({ @@ -715,6 +719,98 @@ describe('how a stopped child ends the start its loop was waiting on', () => { expect(await statusRows()).toEqual([]) }) + /** The close's stop alone: a close that aborts after it leaves the conversation indexed. */ + function closeStopOnly() { + return host['serialize'](SESSION, () => + stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { + cause: 'user-close' + }) + ) + } + + /** A message queued behind a starting child when the close's stop cut it. `beforeStart` runs + * after the stop and before the loop looks again. */ + async function closedWhileStarting(beforeStart: () => void = () => undefined) { + const start = deferred() + adapterExtras = { + awaitStarted: vi.fn(() => start.promise), + closeSession: vi.fn(async () => true) + } + await restartHost() + acquire.mockImplementationOnce(spawnStartingChild) + const first = await accept('first') + await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(1)) + await closeStopOnly() + const starts = acquire.mock.calls.length + beforeStart() + start.resolve() + await settleLoop() + return { first, starts } + } + + it('closes what was queued when the user closed the chat, and starts no child for it', async () => { + const { first, starts } = await closedWhileStarting() + + expect(await submission(first)).toMatchObject({ + dispatchState: 'rejected', + ...CHAT_CLOSED + }) + expect(acquire).toHaveBeenCalledTimes(starts) + expect(dispatch).not.toHaveBeenCalled() + expect(await statusRows()).toEqual([]) + }) + + it('starts no child when closing what was queued fails, and closes it on the next wake', async () => { + const { first, starts } = await closedWhileStarting(() => { + const journal = conversation()!.journal + const reject = journal.rejectQueuedSubmissions.bind(journal) + vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation(async (...args) => { + if (args[1].rejection.kind === 'chatClosed') { + vi.mocked(journal.rejectQueuedSubmissions).mockImplementation(reject) + throw new Error('disk full') + } + return reject(...args) + }) + }) + + expect((await submission(first))?.dispatchState).toBe('pending') + expect(acquire).toHaveBeenCalledTimes(starts) + expect(dispatch).not.toHaveBeenCalled() + + const second = await accept('second') + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) + expect(await submission(first)).toMatchObject({ + dispatchState: 'rejected', + ...CHAT_CLOSED + }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('goes on with a message sent in a later epoch, whose sequence restarts at or below the close', async () => { + await closedWhileStarting() + const session = conversation()! + await session.journal.rollEpoch( + 'corruption', + structuredAgentSessionConversationFence(store, SESSION) + ) + + const second = await accept('second') + + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) + expect(session.lastEndedChild).toMatchObject({ cause: 'user-close' }) + expect((await submission(second))!.acceptedSequence).toBeLessThanOrEqual( + session.lastEndedChild!.endedAt.sequence + ) + }) + + it('goes on with a message sent after the close, never failing it', async () => { + const second = await stoppedWhileStarting(closeStopOnly) + + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) + expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-close' }) + expect(await statusRows()).toEqual([]) + }) + it("fails the start after a host stop, as a start Orca stopped rather than the provider's (R2)", async () => { const reason = 'the start watchdog fired' const second = await stoppedWhileStarting(() => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index ff4b5e9c4ee..f5e0e14b09f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -140,7 +140,7 @@ describe('a publish-first Claude create whose init is slow', () => { await host.attach(CALLER, { ...params, options: { model: 'opus' } }) await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) - await host.close(SESSION) + await host.close(SESSION, 'evict') const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 // Starting the chat again resumes the session under a new fence. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index 9aed0d4acb8..d63af3ba7d2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -245,7 +245,7 @@ export async function createQueuedMessageTestRig() { /** A host-process restart, as the queue sees it: the conversation closes, and * opens afresh under a new instance id while its rows survive. */ async function restartHostProcess(): Promise { - await host.close(SESSION) + await host.close(SESSION, 'evict') rotateStructuredAgentSessionHostInstanceForTests() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index aa9d31cbf10..e43897826a1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -270,7 +270,7 @@ describe('held drafts', () => { // The queue is paused, not the card: it carries no hold of its own. expect(await drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) - await host.close(SESSION) + await host.close(SESSION, 'evict') expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.handoff(draftId)).toBeUndefined() @@ -418,7 +418,7 @@ describe('Stop and Delete', () => { await settleAccepted(working, 'a') // Evict the handle and reopen (the history read opens the conversation at // rest): the pause is derived from what the journal holds, so nothing drains. - await host.close(SESSION) + await host.close(SESSION, 'evict') expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.handoff(draftId)).toBeUndefined() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts index 2c5332db1bb..2dd2f567b87 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts @@ -70,7 +70,7 @@ async function twoCardsBehindWork() { describe("a newer Orca's journal", () => { it('shows the cards, and refuses a queued send, Send-now and Delete with the update words', async () => { const { first, second, cards } = await twoCardsBehindWork() - await reopenOnNewerOrcaDatabase(() => rig.host.close(HOST_TEST_SESSION)) + await reopenOnNewerOrcaDatabase(() => rig.host.close(HOST_TEST_SESSION, 'evict')) expect(await readOnlyQueue()).toEqual({ cards, pause: null }) // The waiting cards would queue this send behind them; the journal takes no new draft. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 1349a6a178e..63a12313bf6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -342,7 +342,7 @@ describe("a restart's pause", () => { await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) // Reopened, that turn is "before this open", yet the pause it ended stays ended: // the lift adopted the rows into this process. - await rig.host.close(HOST_TEST_SESSION) + await rig.host.close(HOST_TEST_SESSION, 'evict') expect(await rig.queuePause()).toBeNull() expect(await rig.drafts()).toContainEqual({ messageId: second, state: 'waiting' }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index f7d26c154f1..538fb0b9dbc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -1,8 +1,9 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A turn that was running when its host went away ends when recovery settles it. That settlement is // the edge the user needs to see — their work stopped — so the session reads as newly done then, -// and nothing along the way may call it a success. Every hop is the real one: durable journal, -// recovery settlement, status feed, the host's status row, and the turn-completion feed. +// with what the host observed of the end as its verdict, and nothing along the way may call it a +// success. Every hop is the real one: durable journal, recovery settlement, status feed, the host's +// status row, and the turn-completion feed. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -12,6 +13,12 @@ import type { AgentSessionStatusSummary, AgentSessionTurnCompletionEvent } from '../../../shared/agent-session-wire' +import { + agentTurnEndedOnPurpose, + agentVerdictDisplayMark +} from '../../../shared/agent-main-agent-verdict' +import { formatNativeChatTurnStatusLabel } from '../../../shared/native-chat-turn-status' +import { selectStructuredAgentSettledTurns } from '../../../shared/structured-agent-session-turn-timing' import { AgentHookServer, _internals } from '../../agent-hooks/server' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -19,7 +26,11 @@ import { settleStaleStructuredAgentSessionState, settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' -import type { StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' +import { + childEndCauseOfEndedEvent, + turnVerdictForChildEnd, + type StructuredAgentSessionTurnVerdict +} from './structured-agent-session-stale-turn-verdict' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' import { StructuredAgentSessionTurnCompletionFeed } from './structured-agent-session-turn-completion-feed' @@ -131,11 +142,22 @@ function settleDeadGeneration( describe('a turn recovery settled after its host went away', () => { it.each([ - ['an unverifiable end', { state: 'unverifiable' } as const], - ['an exit observed before the restart', { state: 'interrupted', completedAt: EXIT_OBSERVED }] - ] satisfies [string, StructuredAgentSessionTurnVerdict][])( - 'is done as of the recovery, never as a success: %s', - async (_label, verdict) => { + ['an unverifiable end', { state: 'unverifiable' } as const, 'unconfirmed', 'unconfirmed'], + [ + 'an exit observed before the restart', + { state: 'interrupted', completedAt: EXIT_OBSERVED }, + 'interruption', + // A turn the user did not stop is a fault, marked as a failure is. + 'failed' + ] + ] satisfies [ + string, + StructuredAgentSessionTurnVerdict, + 'unconfirmed' | 'interruption', + 'unconfirmed' | 'failed' + ][])( + 'is done as of the recovery with the end the host observed, never a success: %s', + async (_label, verdict, outcome, mark) => { const session = await sessionWithRunningTurn() session.recoverAt(RECOVERED) expect(await settleDeadGeneration(session.journal, verdict)).toBe(true) @@ -143,22 +165,75 @@ describe('a turn recovery settled after its host went away', () => { expect(session.summaries.at(-1)).toMatchObject({ status: 'idle', - statusStartedAt: RECOVERED + statusStartedAt: RECOVERED, + turnOutcome: outcome }) - expect(session.summaries.at(-1)).not.toHaveProperty('turnOutcome') const [row] = session.server.getStatusSnapshot() // A done row dated at the recovery is a completion the user has not read yet. expect(row).toMatchObject({ state: 'done', stateStartedAt: RECOVERED, - mainAgent: { state: 'done', stateStartedAt: RECOVERED } + mainAgent: { state: 'done', outcome, stateStartedAt: RECOVERED } }) - expect(row?.mainAgent).not.toHaveProperty('outcome') + // Nobody stopped it: the flag older readers take as a user's stop stays down. + expect(row?.interrupted ?? false).toBe(false) + // The sidebar and tab read the published row, with no user action in between. + expect(row && agentVerdictDisplayMark(row)).toBe(mark) + expect(row && agentTurnEndedOnPurpose(row)).toBe(false) // The dot and the OS notification come only from a completion event, and none is sent. expect(session.completionEvents).toEqual([]) } ) + // The chat's turn bar and the tab's mark read one verdict: a turn nobody stopped failed, and + // must never show the done tick of a finished turn. + it.each([ + [ + 'a restart', + (journal: AgentSessionJournal) => + settleDeadGeneration(journal, { state: 'interrupted', completedAt: EXIT_OBSERVED }) + ], + [ + 'quitting Orca', + // A quit evicts the child, and its adapter settles the open turn through the one mapping. + (journal: AgentSessionJournal) => + journal.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 9 }, + { + kind: 'turn', + turnId: 'turn-1', + startedAt: TURN_STARTED, + ...turnVerdictForChildEnd( + childEndCauseOfEndedEvent({ + type: 'ended', + cause: 'requested-close', + stopCause: 'evict' + }), + EXIT_OBSERVED + ) + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + ] + ] as const)( + 'reads Failed after N, marked failed, for a turn cut off by %s', + async (_label, cut) => { + const session = await sessionWithRunningTurn() + session.recoverAt(RECOVERED) + await cut(session.journal) + session.publish() + + const [row] = session.server.getStatusSnapshot() + expect(row && agentVerdictDisplayMark(row)).toBe('failed') + const [settled] = [ + ...selectStructuredAgentSettledTurns(session.journal.snapshot().items).values() + ] + expect(settled && formatNativeChatTurnStatusLabel({ elapsedSeconds: 0, ...settled })).toBe( + 'Failed after 1s' + ) + } + ) + it('is dated the same way when a new provider child finds the turn still running', async () => { const session = await sessionWithRunningTurn() session.recoverAt(RECOVERED) @@ -173,12 +248,17 @@ describe('a turn recovery settled after its host went away', () => { expect(session.summaries.at(-1)).toMatchObject({ status: 'idle', - statusStartedAt: RECOVERED + statusStartedAt: RECOVERED, + // No evidence of the old owner's death: the end cannot be proven. + turnOutcome: 'unconfirmed' }) - expect(session.server.getStatusSnapshot()[0]).toMatchObject({ + const [row] = session.server.getStatusSnapshot() + expect(row).toMatchObject({ state: 'done', - stateStartedAt: RECOVERED + stateStartedAt: RECOVERED, + mainAgent: { state: 'done', outcome: 'unconfirmed' } }) + expect(row && agentVerdictDisplayMark(row)).toBe('unconfirmed') expect(session.completionEvents).toEqual([]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 09ba97ee8e3..83082bf923a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -346,7 +346,7 @@ describe('agentSessionRefusalOperationState host oracle', () => { } const unreadable = await createHarness() - await unreadable.host.close(SESSION) + await unreadable.host.close(SESSION, 'evict') unreadable.host.deps.adapter.historyFilePath = async () => { throw new Error('transcript unreadable') } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index fbe81be21ac..86873faf6b0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -145,7 +145,7 @@ async function errorStatuses(): Promise { /** The child timed out or exited: its lease is handed back and the host holds no session. */ async function loseOwner(): Promise { - await host.close(SESSION) + await host.close(SESSION, 'evict') expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', ownerProcess: null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index 97c951b4517..d08f536daef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -17,11 +17,16 @@ import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' +import type { + StructuredAgentSessionChildEndCause, + StructuredAgentSessionEndedEvent +} from './structured-agent-session-adapter' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' export type StructuredAgentSessionTurnVerdict = - | { state: 'interrupted'; completedAt: number } + /** `cancellation` only for a stop the user aimed at this chat: every other cut is news. */ + | { state: 'interrupted'; completedAt: number; outcome?: 'cancellation' } | { state: 'unverifiable' } export const UNVERIFIABLE_TURN_VERDICT: StructuredAgentSessionTurnVerdict = { @@ -56,6 +61,45 @@ export function turnVerdictFromDeathEvidence( } } +/** + * The one mapping from why a provider child ended to what the turn it cut reads as. Each adapter + * settles its own open turn through it on `ended`, and the host's fallback settles through it any + * turn no adapter did. Only a stop the user aimed at this chat is their cancellation. + */ +export function turnVerdictForChildEnd( + cause: StructuredAgentSessionChildEndCause, + completedAt: number +): Extract { + return stopIsTheUsers(cause) + ? { state: 'interrupted', completedAt, outcome: 'cancellation' } + : { state: 'interrupted', completedAt } +} + +/** Whether the user asked for this end. Only then is a cut turn their cancellation. */ +export function stopIsTheUsers(cause: StructuredAgentSessionChildEndCause): boolean { + switch (cause) { + case 'user-stop': + case 'user-close': + return true + case 'host-stop': + case 'evict': + case 'exit': + case 'attach-failed': + return false + } +} + +/** Why the child an `ended` event reports ended: who asked for a close, else an exit it had. A + * requested close with no cause named is the host's own. */ +export function childEndCauseOfEndedEvent( + event: { type: 'ended' } & Partial> +): StructuredAgentSessionChildEndCause { + if (event.cause === 'unexpected-exit') { + return 'exit' + } + return event.stopCause ?? 'host-stop' +} + /** Revises every still-running lifecycle item in place, keeping its identity and start. */ export function runningTurnLifecycleRevisions( items: readonly AgentJournalRenderItem[], @@ -125,5 +169,10 @@ function settledLifecycle( } // A renewal can predate the turn, which started with its owner alive; it never ends before that. const began = Math.max(lifecycle.requestedAt ?? 0, lifecycle.startedAt ?? 0) - return { ...kept, state: verdict.state, completedAt: Math.max(verdict.completedAt, began) } + return { + ...kept, + state: verdict.state, + completedAt: Math.max(verdict.completedAt, began), + ...(verdict.outcome ? { outcome: verdict.outcome } : {}) + } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts new file mode 100644 index 00000000000..8b3078df8df --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts @@ -0,0 +1,15 @@ +/** Why a provider child ended. In memory only: never journaled, persisted or sent. */ +export type StructuredAgentSessionChildEndCause = + | 'user-stop' + /** The user closed this chat: its tab, its launch, or a `/clear` that replaces it. */ + | 'user-close' + | 'host-stop' + | 'exit' + | 'attach-failed' + | 'evict' + +/** Why the host asked a child to stop. The adapter carries it onto the `ended` it settles with. */ +export type StructuredAgentSessionStopCause = Extract< + StructuredAgentSessionChildEndCause, + 'user-stop' | 'user-close' | 'host-stop' | 'evict' +> diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index a06c70bf30a..871c05a6e33 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -132,7 +132,7 @@ function emitTurnLifecycle(state: 'running' | 'completed', ordinal: number): voi /** The sweep stops the child first and closes the conversation last. */ function waitForEviction(): Promise { return vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict') expect(host.hasSession(SESSION)).toBe(false) }) } @@ -245,9 +245,9 @@ describe('a chat that closes', () => { it('stops the provider child it started', async () => { await attach() - await host.close(SESSION) + await host.close(SESSION, 'evict') - expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict') expect(host.hasSession(SESSION)).toBe(false) expect(hostErrors).toEqual([]) // The record and its journal stay; only the process and the claim on it go. @@ -264,7 +264,7 @@ describe('a chat that closes', () => { it('answers a read from the pane that outlived it without starting a child', async () => { await attach() - await host.close(SESSION) + await host.close(SESSION, 'evict') expect(host.hasSession(SESSION)).toBe(false) expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) @@ -297,7 +297,7 @@ describe('a chat that closes', () => { await vi.waitFor(() => expect(dispatch).toHaveBeenCalled()) const settlement = host.waitForSendSettlement(SESSION, result.value.clientMessageId) - await host.close(SESSION) + await host.close(SESSION, 'evict') // Eviction's settlement is a journal write, so the wait sees it rather than timing out. await expect(settlement).resolves.toMatchObject({ @@ -321,14 +321,14 @@ describe('a chat that closes', () => { // The child is stopped and the lease released before the handle closes; the entry is dropped // before that close, so a lost result leaves no closing handle for a reader to find. - await expect(host.close(SESSION)).rejects.toThrow('journal close result lost') + await expect(host.close(SESSION, 'evict')).rejects.toThrow('journal close result lost') expect(host.hasSession(SESSION)).toBe(false) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', ownerProcess: null }) - await expect(host.close(SESSION)).resolves.toBeUndefined() + await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() expect(closeSession).toHaveBeenCalledOnce() }) @@ -349,12 +349,12 @@ describe('a chat that closes', () => { }) const settled = captureSettledSubmissions() - await expect(host.close(SESSION)).rejects.toMatchObject({ step: 'drain-published' }) + await expect(host.close(SESSION, 'evict')).rejects.toMatchObject({ step: 'drain-published' }) // The child is proven gone, but the wind-down it owes is not done: nothing settled, no release. expect(session!.child).toBeNull() expect(store.getRecord(SESSION)?.lease.claimStatus).not.toBe('released') - await expect(host.close(SESSION)).resolves.toBeUndefined() + await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() expect(closeSession).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', @@ -462,7 +462,7 @@ describe('startup', () => { describe('a session closed and started again', () => { it('publishes provider events to the reattached chat', async () => { await attach() - await host.close(SESSION) + await host.close(SESSION, 'evict') expect(host.hasSession(SESSION)).toBe(false) await startAgent() @@ -820,7 +820,7 @@ describe('a quit over an eviction that never got its retry', () => { const settled = captureSettledSubmissions() failNextDrain() - await expect(host.close(SESSION)).rejects.toMatchObject({ step: 'drain-published' }) + await expect(host.close(SESSION, 'evict')).rejects.toMatchObject({ step: 'drain-published' }) expect(host['sessions'].get(SESSION)?.child).toBeNull() expect(store.getRecord(SESSION)?.lease.claimStatus).not.toBe('released') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts index ca9f64c0573..a90a65c1c83 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts @@ -49,7 +49,7 @@ type RequestMark = Pick type SessionBaseline = CompletionFeedCursor & { settled: RequestMark | null } function settledMark(request: StructuredAgentSessionLatestRequest | null): RequestMark | null { - return request && !request.running ? { kind: request.kind, id: request.id } : null + return request && request.turnState !== 'running' ? { kind: request.kind, id: request.id } : null } export class StructuredAgentSessionTurnCompletionFeed { @@ -115,7 +115,7 @@ export class StructuredAgentSessionTurnCompletionFeed { return } baseline.sequence = cursor.sequence - if (request?.running) { + if (request?.turnState === 'running') { // A running turn clears the mark, so this detector fires on each running → settled // transition rather than on an id it happens not to have seen. baseline.settled = null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 3ae6da5fa49..2981815c1f1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -353,10 +353,10 @@ describe('already-wedged profiles become usable on load', () => { // What the sidebar reads: every status this restart published says the chat is not working. expect(published.filter((summary) => summary.sessionId === SESSION)).not.toEqual([]) expect(published.map((summary) => summary.status)).not.toContain('working') - // A crash is not something the user did: no outcome is claimed, so no reader files it as a - // cancellation the user already knows about. + // A crash is not something the user did: a proven one reads as an interruption and an + // unprovable one as unconfirmed, so no reader files it as a cancellation the user knows about. expect(published.map((summary) => summary.turnOutcome)).toEqual( - published.map(() => undefined) + published.map(() => (verdict.state === 'interrupted' ? 'interruption' : 'unconfirmed')) ) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index 28eae2dff68..283f7c4f934 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -370,7 +370,7 @@ it('refuses the command at handover when the provider opened a turn meanwhile (B it('leaves a command whose start failed not sent, beside one start-failure row (B3)', async () => { await attach() - await state.host.close(SESSION) + await state.host.close(SESSION, 'evict') state.acquire.mockRejectedValue(new Error('not signed in')) const params = compactParams() diff --git a/src/main/runtime/claude-structured-resumed-start-failure.test.ts b/src/main/runtime/claude-structured-resumed-start-failure.test.ts index 7a0c66e1b85..9d274cd68a8 100644 --- a/src/main/runtime/claude-structured-resumed-start-failure.test.ts +++ b/src/main/runtime/claude-structured-resumed-start-failure.test.ts @@ -40,7 +40,7 @@ describe('a reopened Claude chat whose CLI dies before initialize', () => { ok: true }) await waitForStructuredAgentSessionRecovery() - await host.close(SESSION) + await host.close(SESSION, 'evict') // The user reopens it and sends; this time the CLI never answers, then dies, and its tree is // unprovable. Opening starts nothing: the send does. diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 82db5d6c542..3de3fecc16f 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -504,7 +504,7 @@ describe('a structured Claude session over agentSession.*', () => { } const host = getStructuredAgentSessionHost() // The lease follows the root, so the host lets go. - await host?.close(SESSION) + await host?.close(SESSION, 'evict') expect(host?.hasSession(SESSION)).toBe(false) // The user comes back and sends: that send is what starts Claude again. diff --git a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts index 3f7047c6487..6f4248a4673 100644 --- a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts @@ -20,6 +20,7 @@ import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' import type { RuntimeCommandSurfaceHost } from './orca-runtime-core' import { SESSION_TAB_NOT_FOUND_ERROR } from '../../shared/session-tab-close' import { rendererPublicationThrottle } from '../window/renderer-publication-throttle' +import { structuredAgentSessionTabCloseCause } from './structured-agent-session-tab-close-cause' export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseUnattributedMobileSessionTabClose { async closeMobileSessionTab( @@ -300,7 +301,10 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU } } } - await this.closeStructuredAgentSessionTab(tab) + await this.closeStructuredAgentSessionTab( + tab, + structuredAgentSessionTabCloseCause(options.reason) + ) } else { if (!this.notifier?.closeSessionTab) { throw new Error('runtime_unavailable') diff --git a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts index b0a584182e0..3b3510916ba 100644 --- a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts @@ -13,10 +13,14 @@ import type { BrowserSessionTabSelectionOptions } from './browser-tab-create-pub import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' import { applyBrowserSessionTabSelection } from './browser-session-tab-selection-snapshot' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type { StructuredAgentSessionCloseCause } from '../native-chat/agent-session-wire/structured-agent-session-host-lifetime' import { retireStructuredAgentSessionTabFrom } from './structured-agent-session-tab-retirement' export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWithCloseMobileSessionTab { - protected async closeStructuredAgentSessionTab(tab: RuntimeMobileSessionAgentTab): Promise { + protected async closeStructuredAgentSessionTab( + tab: RuntimeMobileSessionAgentTab, + cause: StructuredAgentSessionCloseCause + ): Promise { const host = getStructuredAgentSessionHost() if (host) { if (typeof host.setSessionTabVisibility === 'function') { @@ -30,7 +34,7 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi // Retire durable visibility and the runtime snapshot before stopping the provider. this.retireStructuredAgentSessionTabFromSnapshot(tab.sessionId) if (typeof host?.close === 'function') { - await host.close(tab.sessionId) + await host.close(tab.sessionId, cause) } } diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 929ee82b6c8..92c39dda283 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -308,7 +308,8 @@ describe('structured session cold restoration', () => { }) expect(closeSessionTab).toHaveBeenCalledWith('agent-session:restored-session', 'workspace-1') - expect(closeStructuredSession).toHaveBeenCalledWith('restored-session') + // The user closed this chat, so a turn the close cuts short is their cancellation. + expect(closeStructuredSession).toHaveBeenCalledWith('restored-session', 'user-close') expect(setSessionTabVisibility).toHaveBeenCalledWith('restored-session', false) expect(setSessionTabVisibility.mock.invocationCallOrder[0]).toBeLessThan( closeStructuredSession.mock.invocationCallOrder[0]! diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts index f5684f1ba1b..a5a79b6be15 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -188,7 +188,7 @@ export async function discardStructuredWorkerSession( } try { await host.setSessionTabVisibility?.(sessionId, false) - await host.close(sessionId) + await host.close(sessionId, 'evict') } catch (error) { console.warn( '[orchestration] failed to discard a half-started structured worker', diff --git a/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts index 554c0912ff0..a1f05657e32 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts @@ -52,7 +52,7 @@ describe('admission revoked while a session is still open', () => { }) expect(response).toMatchObject({ ok: true, result: { ok: true } }) - expect(hostCalls.close).toHaveBeenCalledWith(SESSION) + expect(hostCalls.close).toHaveBeenCalledWith(SESSION, 'user-close') // The durable tab has to be retired too, or the chat comes back on the next sync. expect(hostCalls.setSessionTabVisibility).toHaveBeenCalledWith(SESSION, false) }) @@ -80,7 +80,7 @@ describe('admission revoked while a session is still open', () => { ) expect(response).toMatchObject({ ok: true }) - expect(hostCalls.close).toHaveBeenCalledWith(SESSION) + expect(hostCalls.close).toHaveBeenCalledWith(SESSION, 'user-close') } ) @@ -93,7 +93,7 @@ describe('admission revoked while a session is still open', () => { ) expect(response).toMatchObject({ ok: true }) - expect(hostCalls.close).toHaveBeenCalledWith(SESSION) + expect(hostCalls.close).toHaveBeenCalledWith(SESSION, 'user-close') }) it.each(ADMISSION_METHODS)( diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index 90700a5a817..64c7ec1c8b1 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -103,7 +103,7 @@ beforeEach(async () => { afterEach(async () => { setStructuredAgentSessionHost(null) await host?.flushAllStreamedEvents() - await host?.close(SESSION) + await host?.close(SESSION, 'evict') await rm(root, { recursive: true, force: true }) vi.restoreAllMocks() vi.unstubAllEnvs() diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index 000192cc183..45c01c2c63a 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -123,7 +123,7 @@ afterEach(async () => { describe('the hold surface, for clients that still call it', () => { it('answers a hold without starting an agent or registering a cleanup', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') expect(host.hasSession(SESSION)).toBe(false) const registered = vi.spyOn(runtime, 'registerOwnedSubscriptionCleanup') const acquiresBefore = acquire.mock.calls.length @@ -163,7 +163,7 @@ describe('the hold surface, for clients that still call it', () => { }) it('answers a hold even when no agent could be started', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') acquire.mockRejectedValue(new Error('provider unavailable')) const acquiresBefore = acquire.mock.calls.length @@ -176,7 +176,7 @@ describe('the hold surface, for clients that still call it', () => { describe('a stream', () => { it('reads a closed conversation without starting its agent', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') expect(host.hasSession(SESSION)).toBe(false) const acquiresBefore = acquire.mock.calls.length const frames: unknown[] = [] diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index d9d430f08e3..cc0677b134a 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -133,7 +133,7 @@ describe('capability gating', () => { const response = await call('agentSession.close', { sessionId: SESSION }, STRUCTURED_CLIENT) expect(response).toMatchObject({ ok: true, result: { ok: true } }) - expect(hostCalls.close).toHaveBeenCalledWith(SESSION) + expect(hostCalls.close).toHaveBeenCalledWith(SESSION, 'user-close') expect(hostCalls.setSessionTabVisibility).toHaveBeenCalledWith(SESSION, false) expect(hostCalls.setSessionTabVisibility.mock.invocationCallOrder[0]).toBeLessThan( hostCalls.close.mock.invocationCallOrder[0]! diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 463772d3337..b97da008a40 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -125,7 +125,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ if (replacement) { ctx.runtime.replaceStructuredAgentSessionTab(replacement) } - await host.close(params.envelope.sessionId) + await host.close(params.envelope.sessionId, 'user-close') } return result } @@ -221,7 +221,8 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ if (typeof host.setSessionTabVisibility === 'function') { await host.setSessionTabVisibility(params.sessionId, false) } - await host.close(params.sessionId) + // Clients call this only when the user closes this chat's tab or cancels its launch. + await host.close(params.sessionId, 'user-close') return { ok: true as const } } }), diff --git a/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts b/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts index aebb420fa8b..d6f41acdeee 100644 --- a/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts +++ b/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts @@ -113,7 +113,7 @@ describe('worker-stop on a structured worker this runtime cannot reach', () => { state: 'stop_unknown' } ) - expect(close).toHaveBeenCalledWith(SESSION) + expect(close).toHaveBeenCalledWith(SESSION, 'evict') expect(db.getWorkerDispatch(dispatchId)?.state).toBe('stop_unknown') }) diff --git a/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts b/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts index b48dccf52a3..cee7adf6425 100644 --- a/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts +++ b/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts @@ -323,7 +323,7 @@ describe('structured worker discard retires the chat tab', () => { }) ).rejects.toThrow(/was refused/) - expect(close).toHaveBeenCalledWith(createdSessionId) + expect(close).toHaveBeenCalledWith(createdSessionId, 'evict') expect(await structuredTabIds(runtime)).toEqual([]) }) }) diff --git a/src/main/runtime/structured-agent-session-close.ts b/src/main/runtime/structured-agent-session-close.ts index 668b7f21890..3ee8f265c4d 100644 --- a/src/main/runtime/structured-agent-session-close.ts +++ b/src/main/runtime/structured-agent-session-close.ts @@ -75,7 +75,8 @@ export async function closeStructuredAgentSessionChild( try { await host.setSessionTabVisibility?.(sessionId, false) closeAttempted = true - await host.close(sessionId) + // A worktree teardown or an orchestration stop: not the user closing this chat. + await host.close(sessionId, 'evict') } catch (error) { // Only `closeAttempted` proves the hide landed: the store transaction restores its own state on // failure, so a `setSessionTabVisibility` that threw hid nothing and has nothing to undo. diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 990c33850ef..85761bf04d9 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -271,7 +271,7 @@ describe('a Stop in that window that the turn never opens for', () => { it('lets a chat closed behind it close within its bound and one eviction', async () => { const { stopping } = await waitingStop() - const closing = host.close(SESSION) + const closing = host.close(SESSION, 'evict') expect( await settledWithin(closing, CODEX_STOP_TURN_OPEN_WAIT_MS + CHILD_EVICTION_TIMEOUT_MS) @@ -305,7 +305,7 @@ describe('a cold send with no Stop behind it', () => { it('never delays closing the chat', async () => { await answeredColdSend() - expect(await settledWithin(host.close(SESSION), PROMPTLY_MS)).not.toBe('held') + expect(await settledWithin(host.close(SESSION, 'evict'), PROMPTLY_MS)).not.toBe('held') expect(childCloses).toBe(1) }) diff --git a/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts b/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts index 91bf91738b5..9bce63de54d 100644 --- a/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts @@ -50,7 +50,7 @@ describe('a Claude child proving its start', () => { expect(claude.child(STALLED).calls).toEqual(['get_settings']) let closed = false - void host.close(STALLED).then(() => { + void host.close(STALLED, 'evict').then(() => { closed = true }) await vi.waitFor(() => expect(closed).toBe(true)) diff --git a/src/main/runtime/structured-agent-session-tab-close-cause.test.ts b/src/main/runtime/structured-agent-session-tab-close-cause.test.ts new file mode 100644 index 00000000000..f90a8674ff8 --- /dev/null +++ b/src/main/runtime/structured-agent-session-tab-close-cause.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { structuredAgentSessionTabCloseCause } from './structured-agent-session-tab-close-cause' + +describe('structuredAgentSessionTabCloseCause', () => { + it.each([ + { label: "an older client's reasonless close", reason: undefined, cause: 'user-close' }, + { label: 'a user close', reason: 'user', cause: 'user-close' }, + { label: 'a cleanup echo', reason: 'cleanup', cause: 'evict' }, + { label: 'a pty-exit echo', reason: 'pty-exit', cause: 'evict' } + ] as const)('closes the chat with $cause for $label', ({ reason, cause }) => { + expect(structuredAgentSessionTabCloseCause(reason)).toBe(cause) + }) +}) diff --git a/src/main/runtime/structured-agent-session-tab-close-cause.ts b/src/main/runtime/structured-agent-session-tab-close-cause.ts new file mode 100644 index 00000000000..143b33831eb --- /dev/null +++ b/src/main/runtime/structured-agent-session-tab-close-cause.ts @@ -0,0 +1,17 @@ +import type { RuntimeSessionTabCloseReason } from '../../shared/runtime-types' +import type { StructuredAgentSessionCloseCause } from '../native-chat/agent-session-wire/structured-agent-session-host-lifetime' + +/** Who a chat's `session.tabs.close` speaks for. A reasonless close is an older client's user + * close; a lifecycle echo is not the user's. */ +export function structuredAgentSessionTabCloseCause( + reason: RuntimeSessionTabCloseReason | undefined +): StructuredAgentSessionCloseCause { + switch (reason) { + case undefined: + case 'user': + return 'user-close' + case 'pty-exit': + case 'cleanup': + return 'evict' + } +} diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index 1490f9868e3..4ffbec11118 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -463,7 +463,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = it('leaves a pointer the person stopped while its agent was starting stopped', async () => { await openChat(COORDINATOR) const { runId, taskId } = await coordinatorRunAndTask() - await host.close(COORDINATOR) + await host.close(COORDINATOR, 'evict') providerFaults.startDelayMs = 400 const before = providerFaults.starts await finishWorker(taskId) @@ -528,7 +528,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = ): Promise<{ runId: string; starts: number }> { await openChat(COORDINATOR) const { runId, taskId } = await coordinatorRunAndTask() - await host.close(COORDINATOR) + await host.close(COORDINATOR, 'evict') providerFaults.refuseStart = refusal const before = providerFaults.starts await finishWorker(taskId) @@ -672,7 +672,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = await openChat(COORDINATOR) const { taskId } = await coordinatorRunAndTask() // What the idle sweep leaves of a chat nobody is looking at: agent stopped, no map entry. - await host.close(COORDINATOR) + await host.close(COORDINATOR, 'evict') expect(host.hasSession(COORDINATOR)).toBe(false) const before = codex.connections.length diff --git a/src/main/runtime/structured-claude-pending-rewind.test.ts b/src/main/runtime/structured-claude-pending-rewind.test.ts index 137b7ff6961..c521ef8b6d9 100644 --- a/src/main/runtime/structured-claude-pending-rewind.test.ts +++ b/src/main/runtime/structured-claude-pending-rewind.test.ts @@ -100,7 +100,7 @@ async function seedPendingRewind(phase: 'prepared' | 'provider-succeeded') { } async function reattach() { - await host.close(HOST_TEST_SESSION) + await host.close(HOST_TEST_SESSION, 'evict') expect(await host.attach(caller, attachParams(fence()))).toMatchObject({ ok: true }) } diff --git a/src/main/runtime/structured-worker-at-rest.test.ts b/src/main/runtime/structured-worker-at-rest.test.ts index adfb181524d..669a655b2bc 100644 --- a/src/main/runtime/structured-worker-at-rest.test.ts +++ b/src/main/runtime/structured-worker-at-rest.test.ts @@ -337,7 +337,7 @@ describe('a task dispatched into a worker whose own dispatch settled', () => { await vi.waitFor(() => expect(observeStructuredWorker({ sessionId: REST_TEST_SESSION }).status).toBe('exited') ) - expect(rig.adapter.closeSession).toHaveBeenCalledWith(REST_TEST_SESSION) + expect(rig.adapter.closeSession).toHaveBeenCalledWith(REST_TEST_SESSION, 'evict') } finally { hostRef.current = null await rig.dispose() diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index a767a61ccf4..97488e50fd6 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -96,7 +96,7 @@ describe('AgentStateDot', () => { expect(markup).not.toContain('data-agent-spinner') }) - it.each(['blocked', 'interrupted'] satisfies AgentDotState[])( + it.each(['blocked', 'failed'] satisfies AgentDotState[])( 'renders %s as a red attention dot', (state) => { const classNames = renderDotClassNames(state) @@ -106,6 +106,14 @@ describe('AgentStateDot', () => { } ) + it("renders a user's Stop as a muted dot, neither the fault red nor the idle grey", () => { + const classNames = renderDotClassNames('interrupted') + + expect(classNames).toContain('bg-muted-foreground') + expect(classNames).not.toContain('bg-red-500') + expect(classNames).not.toContain('bg-neutral-500/40') + }) + const ALL_STATES = [ 'working', 'monitoring', @@ -116,6 +124,7 @@ describe('AgentStateDot', () => { 'done', 'idle', 'unverifiable', + 'unconfirmed', 'permission' ] satisfies AgentDotState[] diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index af8ac4efd84..44fb3dc7c10 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -35,6 +35,9 @@ export type AgentDotState = // held there, and never rendered as 'done' or 'working' — it asserts nothing about // the agent, only about what Orca last heard. | 'unverifiable' + // Why: the turn ended and Orca cannot prove how. An outcome like 'failed', drawn with the + // 'unverifiable' glyph because it too reports missing evidence, never a finish. + | 'unconfirmed' // Why: the sidebar's title-based status flow (StatusIndicator/WorktreeCard) // collapses blocked + waiting into a single "needs attention" state. Keep // this as a distinct member so that flow can render without inventing a new @@ -63,6 +66,8 @@ export function agentStateLabel(state: AgentDotState): string { return 'Idle' case 'unverifiable': return 'No recent update' + case 'unconfirmed': + return 'Couldn’t confirm' case 'permission': return 'Needs attention' } @@ -123,7 +128,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({