From 85067494a16d80a52c36c59261efa699bdb60665 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:23:49 -0700 Subject: [PATCH] fix(native-chat): a request that failed reads as failed (#22944) * 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(native-chat): settle a gone generation's turn at every open but an acquisition's The journal open skipped the settlement whenever the lease read reserved or live, to leave an acquisition's own open to the acquisition. But a lease a crashed process left in recovery also reads live, until the next acquire resolves it. A send that opened such a chat, from a phone or the CLI after a crash on a host that could not prove the old owner gone, skipped the settlement; when its start then failed, the dead turn stayed running for every reader. The acquisition now says it is the opener, and every other open settles, whatever the lease still claims. * test(native-chat): hold the create's start open until the views bind The "view binds while the create is still starting" case gave the create a 300 ms head start and asserted the views bound before it died. On a loaded runner the holds took longer, the create's exit landed first, and the case failed its own precondition. The create's initialize now waits on a gate the test releases once the views are bound. * refactor(native-chat): drop the composer's second error formatter After the merge with main, every chat write in the composer path reports its failure as a typed outcome worded by the refusal-notice table, so the send's catch sees only a local throw. The {code, message} formatter this branch added for it has no payload left to format, and its claim to be the one way a chat words a failure is no longer true. The composer send is main's again. * test(native-chat): pin the reason on a message rejected while its chat was closed The reopen test checked only that the message reads as not sent; it now also checks the Retry row carries the host's reason. * fix(native-chat): a send the provider never received after a restart has no verdict Restart reconciliation rejects a crash-stranded send that is absent from a trustworthy provider history with reason 'not_delivered'. Nobody failed that send, but the verdict allowlist did not name it, so after a crash the chat read Failed, was listed, and could notify "failed". Give the reason a shared constant (persisted value unchanged), add it to the no-verdict set, and treat it as an internal marker so the Retry row no longer shows the raw string. --------- Co-authored-by: Claude --- .github/workflows/pr.yml | 1 + config/scripts/pr-code-change-scope.mjs | 3 + config/scripts/pr-code-change-scope.test.mjs | 3 + docs/reference/agent-status-store.md | 37 +- mobile/src/components/AgentStateDot.tsx | 3 +- mobile/src/components/WorktreeAgentRow.tsx | 9 +- mobile/src/worktree/agent-row-display.test.ts | 87 +++- mobile/src/worktree/agent-row-display.ts | 46 +- .../worktree/worktree-list-snapshot.test.ts | 32 ++ mobile/src/worktree/worktree-list-snapshot.ts | 18 + .../server/server-ingest-structured.ts | 5 +- src/main/ipc/notification-options.ts | 7 +- .../notifications-message-formatting.test.ts | 41 +- .../journal-submission-reconciler.ts | 5 +- ...-agent-session-accept-then-deliver.test.ts | 28 +- ...gent-session-attach-reconciliation.test.ts | 14 + ...tructured-agent-session-client-delivery.ts | 9 +- ...agent-session-recovered-turn-clock.test.ts | 6 +- ...session-restart-status-publication.test.ts | 7 +- ...ructured-agent-session-status-feed.test.ts | 3 +- .../structured-agent-session-status-feed.ts | 85 ++-- ...agent-session-turn-completion-feed.test.ts | 417 +++++++++++++++++- ...ured-agent-session-turn-completion-feed.ts | 81 ++-- ...untime-worktree-agent-rows-verdict.test.ts | 213 +++++++++ .../runtime/runtime-worktree-agent-rows.ts | 1 + .../runtime/runtime-worktree-agent-source.ts | 2 + .../runtime-worktree-pty-agent-sources.ts | 4 +- .../activity/activity-clear-completed.test.ts | 39 +- .../activity/activity-clear-completed.ts | 8 +- .../activity/activity-pane-events.ts | 4 +- .../activity/activity-thread-grouping.ts | 10 +- .../activity/activity-thread-presentation.ts | 33 +- .../dashboard/DashboardAgentRow.tsx | 14 +- .../agent-finished-timestamp.test.ts | 41 ++ .../dashboard/agent-finished-timestamp.ts | 12 +- .../dashboard/useRetainedAgents.test.ts | 38 +- .../components/dashboard/useRetainedAgents.ts | 8 +- ...cturedAgentSessionAttentionBridge.test.tsx | 71 ++- .../StructuredAgentSessionStatusBridge.tsx | 5 +- .../structured-attention-dispatch.test.ts | 40 +- .../structured-attention-dispatch.ts | 17 +- .../components/sidebar/StatusIndicator.tsx | 3 +- .../sidebar/smart-attention.test.ts | 46 ++ .../src/components/sidebar/smart-attention.ts | 14 +- .../src/components/sidebar/smart-sort.test.ts | 28 +- .../sidebar/use-worktree-activity-status.ts | 10 +- .../sidebar/use-worktree-activity-statuses.ts | 6 +- .../worktree-agent-activity-summary.test.ts | 130 ++++++ .../worktree-agent-activity-summary.ts | 40 +- .../worktree-card-agent-summary.test.ts | 69 +++ .../sidebar/worktree-card-agent-summary.ts | 10 +- .../worktree-card-compact-agent-row.tsx | 7 +- .../StarNagAgentValueMomentObserver.test.tsx | 30 ++ .../StarNagAgentValueMomentObserver.tsx | 5 +- .../terminal-tab-activity-status.test.ts | 33 ++ .../tab-bar/terminal-tab-activity-status.ts | 40 +- ...y-connection-hook-idle-arbitration.test.ts | 2 +- ...-connection-task-complete-dispatch.test.ts | 2 +- .../pty-connection/pty-exit-hibernate.test.ts | 5 + .../pty-connection/pty-exit-hibernate.ts | 3 +- .../use-notification-dispatch-verdict.test.ts | 53 +++ .../use-notification-dispatch.ts | 3 +- ...tree-jump-palette-recent-inclusion.test.ts | 85 ++++ .../worktree-jump-palette-recent-inclusion.ts | 3 +- src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + .../notification-agent-status-locales.test.ts | 22 + .../src/lib/activity-thread-display.test.ts | 11 + .../src/lib/activity-thread-display.ts | 9 +- .../lib/agent-hibernation-pane-eligibility.ts | 3 +- .../src/lib/agent-hibernation-planner.test.ts | 15 + .../src/lib/agent-pane-activity-flags.ts | 38 ++ .../src/lib/recent-workspace-tab-rows.test.ts | 22 + .../src/lib/recent-workspace-tab-rows.ts | 27 +- .../src/lib/sleeping-agent-pane-ownership.ts | 5 +- .../lib/worktree-status.interrupted.test.ts | 32 ++ src/renderer/src/lib/worktree-status.ts | 17 +- ...time-graph-agent-status-projection.test.ts | 45 +- ...-runtime-graph-projection-hot-path.test.ts | 11 +- .../agent-status-projection.ts | 12 +- ...abs-sync-remote-main-agent-verdict.test.ts | 112 +++++ .../agent-status-patch.ts | 6 +- .../state-equality-core.test.ts | 27 ++ .../state-equality-core.ts | 5 +- .../store/slices/agent-status-drop-actions.ts | 3 +- .../agent-status-live-entry-state-history.ts | 3 +- .../store/slices/agent-status-live-facts.ts | 9 +- .../agent-status-provider-session-actions.ts | 5 +- .../agent-status-provider-session.test.ts | 55 +++ .../agent-status-recovery-collection.ts | 3 +- .../agent-status-recovery-equivalence.ts | 5 +- .../slices/agent-status-sleeping-records.ts | 16 +- .../agent-status-verdict-copies.test.ts | 191 ++++++++ .../agent-status-worktree-drop-actions.ts | 3 +- src/shared/agent-completion-time.ts | 9 +- src/shared/agent-main-agent-verdict.test.ts | 124 ++++++ src/shared/agent-main-agent-verdict.ts | 75 ++++ .../agent-session-refusal-notice.test.ts | 9 + src/shared/agent-session-resume.ts | 4 + src/shared/agent-session-wire.ts | 8 +- src/shared/agent-state-history.ts | 4 + src/shared/agent-status-types.ts | 2 +- src/shared/notification-settings-types.ts | 4 +- src/shared/runtime-worktree-contracts.ts | 5 + ...ctured-agent-session-dispatch-rejection.ts | 21 +- ...tured-agent-session-latest-request.test.ts | 247 +++++++++++ ...structured-agent-session-latest-request.ts | 145 ++++++ .../structured-agent-session-owed-work.ts | 16 + ...tructured-agent-session-projection.test.ts | 57 ++- .../structured-agent-session-projection.ts | 110 ++--- ...uctured-agent-session-status-started-at.ts | 15 +- ...tured-agent-session-unanswered-dispatch.ts | 21 + .../workspace-session-sleeping-agents.ts | 3 + ...s-version-worktree-ps-verdict.unit.test.ts | 197 +++++++++ .../release-checkout-tree.ts | 10 +- .../cross-version-wire/release-checkout.ts | 33 +- 120 files changed, 3602 insertions(+), 369 deletions(-) create mode 100644 src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts create mode 100644 src/renderer/src/components/terminal-pane/use-notification-dispatch-verdict.test.ts create mode 100644 src/renderer/src/components/worktree-jump-palette-recent-inclusion.test.ts create mode 100644 src/renderer/src/i18n/notification-agent-status-locales.test.ts create mode 100644 src/renderer/src/lib/agent-pane-activity-flags.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync-remote-main-agent-verdict.test.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.test.ts create mode 100644 src/renderer/src/store/slices/agent-status-verdict-copies.test.ts create mode 100644 src/shared/agent-main-agent-verdict.test.ts create mode 100644 src/shared/agent-main-agent-verdict.ts create mode 100644 src/shared/structured-agent-session-latest-request.test.ts create mode 100644 src/shared/structured-agent-session-latest-request.ts create mode 100644 src/shared/structured-agent-session-owed-work.ts create mode 100644 tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4580a6e2385..1313b2e5266 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -784,6 +784,7 @@ jobs: tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts + tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts managed_hook_node18: name: managed hooks on Node 18 diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index ceb75a66bfc..ab23c41d75a 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -159,6 +159,9 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/main/runtime/rpc/methods/session-tabs.ts', 'src/main/runtime/rpc/methods/structured-agent-session', 'src/main/runtime/rpc/methods/terminal', + 'src/main/runtime/runtime-worktree-agent-', + 'src/main/runtime/runtime-worktree-pty-agent-sources', + 'src/shared/runtime-worktree-contracts', 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer' ] diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 06ef8e3879e..094dadf542e 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -350,6 +350,9 @@ describe('per-job path classification', () => { 'src/main/runtime/rpc/methods/structured-agent-session-hold.ts', 'src/main/runtime/rpc/methods/structured-agent-session-schemas.ts', 'src/main/runtime/rpc/methods/terminal.ts', + 'src/main/runtime/runtime-worktree-agent-rows.ts', + 'src/main/runtime/runtime-worktree-pty-agent-sources.ts', + 'src/shared/runtime-worktree-contracts.ts', 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts' ]) { expectClassification([file], { diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 4dff79d6666..b7ce4fa16dd 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -105,8 +105,15 @@ ingests the summary into the hook server as a status row: | `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it | | prompt, tool, last message, model, provider session | the summary's fields | -Sessions with no persisted turn (`status === null`) produce no row, matching -what the chat shows. When the host revokes live ownership the row is re-set +Sessions with no request (`status === null`) produce no row. A request is a +turn record, an assistant message, a user message the provider journaled itself +(history, an older host), an accepted or unanswered send, or a send the agent or +its start refused; a send that was withdrawn, or left undelivered by a +restart or a close, fails nobody and makes nothing listable. +`summary.turnOutcome` is the latest request's verdict: its turn's outcome, or +`failure` for a send the agent or its start refused (a send that joined a running +turn is answered by that turn). The row also publishes `interrupted` from +`mainAgent.outcome`, exactly as the hook lanes do. When the host revokes live ownership the row is re-set without the flag; when the host closes or evicts the session the row is dropped. Both already exist as feed events (`revokeLive` and the roster filter in `liveSessionSummaries`); PR 1 turns them into store writes. @@ -218,6 +225,32 @@ sends no hook at all on a cancel and no `is_interrupt` on Stop; that flag on a turn boundary remains a secondary source for builds that send it, and `StopFailure` maps to `failure`. +Readers decode the verdict through one accessor, `agentMainAgentVerdict`, which +reads the main agent's own state, not the combined row's: `mainAgent.outcome` +while `mainAgent.state` is `done`, then the legacy `interrupted` flag as a +cancellation, which alone needs the combined `done`. So a main agent that +failed while its subagents still run has a verdict on a `working` row. Every +copy of a row (state-history entries, sleep records, `worktree ps` rows) takes +the verdict through `agentVerdictFields`, which carries `interrupted` and the +whole `mainAgent` (state, outcome and its own clock) together, so a copy agrees +with the row and can date a failure by `mainAgent.stateStartedAt`. + +Display reads the verdict through `agentVerdictDisplayMark`: a failure marks the +agent failed whatever the combined state, because it is news the user must see +even while subagents run; a stop marks it interrupted only on a `done` row, so +a stopped or finished main agent with live child work still reads working. +Each subagent keeps its own row and state. Container rollups (worktree card, +terminal tab, Cmd+J) rank a pending question first, then a failure, then live +work, then a stop, then done. On the worktree card, a failure retained after its +agent's pane went away has no expiry, so it ranks below live work and above a +stop. Lifecycle waiters keep reading the combined `state`. + +Policy splits the verdict two ways. Clean-finish policy (hibernation, pane +ownership, the star-nag value moment) treats a failure like a cancellation +(`agentTurnEndedUncleanly`). Attention (completion time, Smart Sort, sticky +retention, Cmd+J Recent) demotes only a turn the user stopped +(`agentTurnStoppedByUser`); a failure ranks like a completion. + Admission is one function, `normalizeAgentStatusPayload`, on the relay wire, IPC and disk. A malformed `mainAgent` drops the field and keeps the row. Old hosts send none and readers fall back to `state`. Hook rows persist it inside the diff --git a/mobile/src/components/AgentStateDot.tsx b/mobile/src/components/AgentStateDot.tsx index 1637296d900..80ef9d9d239 100644 --- a/mobile/src/components/AgentStateDot.tsx +++ b/mobile/src/components/AgentStateDot.tsx @@ -5,7 +5,7 @@ import type { AgentDotState } from '../worktree/agent-row-display' // 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 (attention), neutral +// 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. const DOT_COLORS: Record, string> = { @@ -13,6 +13,7 @@ const DOT_COLORS: Record, strin blocked: '#ef4444', waiting: '#ef4444', interrupted: '#ef4444', + failed: '#ef4444', idle: 'rgba(115,115,115,0.4)' } const WORKING_COLOR = '#eab308' diff --git a/mobile/src/components/WorktreeAgentRow.tsx b/mobile/src/components/WorktreeAgentRow.tsx index 8740d12eaed..cd8e10b3454 100644 --- a/mobile/src/components/WorktreeAgentRow.tsx +++ b/mobile/src/components/WorktreeAgentRow.tsx @@ -2,7 +2,12 @@ import { memo } from 'react' import { StyleSheet, Text, View } from 'react-native' import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' import { colors, spacing } from '../theme/mobile-theme' -import { agentDisplayLabel, agentDotState, formatTimeAgo } from '../worktree/agent-row-display' +import { + agentDisplayLabel, + agentDotState, + agentRowTimeAt, + formatTimeAgo +} from '../worktree/agent-row-display' import { AgentStateDot } from './AgentStateDot' import { MobileAgentIcon } from './MobileAgentIcon' @@ -22,7 +27,7 @@ type Props = { function WorktreeAgentRowComponent({ agent, depth, now, unvisited }: Props) { const dotState = agentDotState(agent, now) const label = agentDisplayLabel(agent, now) - const ts = formatTimeAgo(agent.stateStartedAt, now) + const ts = formatTimeAgo(agentRowTimeAt(agent), now) return ( diff --git a/mobile/src/worktree/agent-row-display.test.ts b/mobile/src/worktree/agent-row-display.test.ts index ee872e40f26..47898017ff8 100644 --- a/mobile/src/worktree/agent-row-display.test.ts +++ b/mobile/src/worktree/agent-row-display.test.ts @@ -1,13 +1,26 @@ import { describe, expect, it } from 'vitest' import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' +import { + agentMainAgentVerdict, + agentVerdictDisplayMark +} from '../../../src/shared/agent-main-agent-verdict' +import { AGENT_JOURNAL_TURN_OUTCOMES } from '../../../src/shared/agent-turn-outcome' import { AGENT_STATUS_STALE_AFTER_MS, agentDisplayLabel, agentDotState, agentIdentityLabel, + agentRowTimeAt, + agentRowVerdict, + agentRowVerdictMark, formatTimeAgo } from './agent-row-display' +type Outcome = (typeof AGENT_JOURNAL_TURN_OUTCOMES)[number] +const mainAgentDone = (outcome: Outcome, stateStartedAt = 0) => ({ + mainAgent: { state: 'done' as const, outcome, stateStartedAt } +}) + function row(overrides: Partial = {}): RuntimeWorktreeAgentRow { return { paneKey: 'p', @@ -37,8 +50,54 @@ describe('agentDotState', () => { expect(agentDotState(row({ state: 'unknown-state' as never }), 0)).toBe('idle') }) - it('reports interrupted regardless of state', () => { + it('reports the verdict of a done row: failed, interrupted, or an old host legacy flag', () => { 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') + }) + + 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('success') }), 0)).toBe('working') + expect( + agentDotState( + row({ state: 'working', ...mainAgentDone('cancellation'), interrupted: true }), + 0 + ) + ).toBe('working') + }) + + // The shared accessor cannot be imported by app code here, so this mirror must not drift from it. + it('agrees with the desktop verdict accessor on every row', () => { + const states = ['working', 'blocked', 'waiting', 'done'] as const + const mainAgents = [ + undefined, + ...states.flatMap((state) => + [undefined, ...AGENT_JOURNAL_TURN_OUTCOMES].map((outcome) => ({ + state, + ...(outcome ? { outcome } : {}), + stateStartedAt: 0 + })) + ) + ] + for (const state of states) { + for (const mainAgent of mainAgents) { + for (const interrupted of [false, true]) { + const agentRow = { state, interrupted, ...(mainAgent ? { mainAgent } : {}) } + expect(agentRowVerdict(agentRow), JSON.stringify(agentRow)).toBe( + agentMainAgentVerdict(agentRow) + ) + expect(agentRowVerdictMark(agentRow), JSON.stringify(agentRow)).toBe( + agentVerdictDisplayMark(agentRow) + ) + } + } + } }) it('decays a stale active state to idle, matching desktop', () => { @@ -51,14 +110,36 @@ describe('agentDotState', () => { expect( agentDotState(row({ state: 'working', updatedAt: 0 }), AGENT_STATUS_STALE_AFTER_MS) ).toBe('working') - // 'done' never decays; interrupted still wins. + // 'done' never decays, and neither does its verdict. expect(agentDotState(row({ state: 'done', updatedAt: 0 }), stale)).toBe('done') - expect(agentDotState(row({ state: 'working', updatedAt: 0, interrupted: true }), stale)).toBe( + expect(agentDotState(row({ state: 'done', updatedAt: 0, interrupted: true }), stale)).toBe( 'interrupted' ) }) }) +describe('agentRowTimeAt', () => { + it('dates a main agent that failed while its subagents run by its own failure', () => { + expect( + agentRowTimeAt( + row({ state: 'working', stateStartedAt: 100, ...mainAgentDone('failure', 900) }) + ) + ).toBe(900) + }) + + it('dates every other row by when its state began', () => { + expect( + agentRowTimeAt( + row({ state: 'working', stateStartedAt: 100, ...mainAgentDone('success', 900) }) + ) + ).toBe(100) + expect( + agentRowTimeAt(row({ state: 'done', stateStartedAt: 100, ...mainAgentDone('failure', 900) })) + ).toBe(100) + expect(agentRowTimeAt(row({ state: 'working', stateStartedAt: 100 }))).toBe(100) + }) +}) + describe('agentDisplayLabel', () => { it('prefers last message, then prompt, then state label', () => { expect(agentDisplayLabel(row({ lastAssistantMessage: 'hello there' }), 0)).toBe('hello there') diff --git a/mobile/src/worktree/agent-row-display.ts b/mobile/src/worktree/agent-row-display.ts index c678b522a15..da93aa39220 100644 --- a/mobile/src/worktree/agent-row-display.ts +++ b/mobile/src/worktree/agent-row-display.ts @@ -1,4 +1,5 @@ import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' +import type { AgentJournalTurnOutcome } 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 @@ -17,13 +18,39 @@ export type AgentDotState = | 'done' | 'idle' | 'interrupted' + | 'failed' + +type AgentRowVerdictSource = Pick + +// 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 { + if (row.mainAgent && row.mainAgent.state !== 'done') { + return null + } + return row.mainAgent?.outcome ?? (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' + } + return verdict === 'cancellation' && row.state === 'done' ? 'interrupted' : null +} export function agentDotState( - row: Pick, + row: Pick< + RuntimeWorktreeAgentRow, + 'state' | 'workingMode' | 'interrupted' | 'mainAgent' | 'updatedAt' + >, now: number ): AgentDotState { - if (row.interrupted) { - return 'interrupted' + const mark = agentRowVerdictMark(row) + if (mark) { + return mark } switch (row.state) { case 'blocked': @@ -56,6 +83,8 @@ export function agentStateLabel(state: AgentDotState): string { return 'Waiting for input' case 'interrupted': return 'Interrupted' + case 'failed': + return 'Failed' case 'done': return 'Done' case 'idle': @@ -99,6 +128,17 @@ export function agentIdentityLabel(agentType: string | null): string { return known[normalized] ?? normalized.slice(0, 2).toUpperCase() } +// When the row's state began, except that a main agent that failed while its subagents run is +// dated by its own failure. Mirrors desktop lastEnteredDoneAt (agent-finished-timestamp.ts). +export function agentRowTimeAt( + row: Pick +): number { + if (row.state !== 'done' && row.mainAgent && agentRowVerdictMark(row) === 'failed') { + return row.mainAgent.stateStartedAt + } + return row.stateStartedAt +} + // Relative time, matching desktop formatTimeAgo thresholds (just now / Xm / Xh / Xd). export function formatTimeAgo(ts: number, now: number): string { const delta = now - ts diff --git a/mobile/src/worktree/worktree-list-snapshot.test.ts b/mobile/src/worktree/worktree-list-snapshot.test.ts index c7c5756160f..25c9ecbe852 100644 --- a/mobile/src/worktree/worktree-list-snapshot.test.ts +++ b/mobile/src/worktree/worktree-list-snapshot.test.ts @@ -21,6 +21,13 @@ function agent(overrides: Partial = {}): RuntimeWorktre } } +function done( + outcome: 'success' | 'failure', + stateStartedAt = 1 +): NonNullable { + return { state: 'done', outcome, stateStartedAt } +} + function worktree(overrides: Partial = {}): Worktree { const worktreePath = join('/tmp', 'orca', 'worktrees', 'manta') return { @@ -160,6 +167,31 @@ describe('areWorktreeListsEqual', () => { expect(areWorktreeListsEqual(first, second)).toBe(false) }) + it('detects a verdict change that leaves the interrupted flag as it was', () => { + const first = [worktree({ agents: [agent({ state: 'done', mainAgent: done('success') })] })] + const second = [worktree({ agents: [agent({ state: 'done', mainAgent: done('failure') })] })] + + expect(areWorktreeListsEqual(first, second)).toBe(false) + }) + + it('detects a main agent failing while its subagents keep the row working', () => { + const first = [worktree({ agents: [agent({ state: 'working' })] })] + const second = [worktree({ agents: [agent({ state: 'working', mainAgent: done('failure') })] })] + + expect(areWorktreeListsEqual(first, second)).toBe(false) + }) + + it('detects the main agent clock moving, which dates a failure', () => { + const at = (stateStartedAt: number) => [ + worktree({ + agents: [agent({ state: 'working', mainAgent: done('failure', stateStartedAt) })] + }) + ] + + expect(areWorktreeListsEqual(at(1), at(2))).toBe(false) + expect(areWorktreeListsEqual(at(1), at(1))).toBe(true) + }) + it('detects monitoring mode changes within working', () => { const first = [worktree({ agents: [agent({ state: 'working' })] })] const second = [worktree({ agents: [agent({ state: 'working', workingMode: 'monitoring' })] })] diff --git a/mobile/src/worktree/worktree-list-snapshot.ts b/mobile/src/worktree/worktree-list-snapshot.ts index 8fa27c2a197..250c871493f 100644 --- a/mobile/src/worktree/worktree-list-snapshot.ts +++ b/mobile/src/worktree/worktree-list-snapshot.ts @@ -110,6 +110,7 @@ function areAgentRowsEqual( a.toolName !== b.toolName || a.toolInput !== b.toolInput || a.interrupted !== b.interrupted || + !areMainAgentsEqual(a.mainAgent, b.mainAgent) || a.stateStartedAt !== b.stateStartedAt || a.updatedAt !== b.updatedAt ) { @@ -118,3 +119,20 @@ function areAgentRowsEqual( } return true } + +function areMainAgentsEqual( + left: RuntimeWorktreeAgentRow['mainAgent'], + right: RuntimeWorktreeAgentRow['mainAgent'] +): boolean { + if (left === right) { + return true + } + if (!left || !right) { + return false + } + return ( + left.state === right.state && + left.outcome === right.outcome && + left.stateStartedAt === right.stateStartedAt + ) +} diff --git a/src/main/agent-hooks/server/server-ingest-structured.ts b/src/main/agent-hooks/server/server-ingest-structured.ts index 5a5e2ce5ecf..19ee70e88af 100644 --- a/src/main/agent-hooks/server/server-ingest-structured.ts +++ b/src/main/agent-hooks/server/server-ingest-structured.ts @@ -11,7 +11,8 @@ import { } from '../../../shared/structured-agent-session-projection' import { continueMainAgentStatus, - isAgentStatusHeldOpenByChildWork + isAgentStatusHeldOpenByChildWork, + mainAgentTurnInterrupted } from '../../../shared/agent-lead-status-fold' import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' import { @@ -73,6 +74,8 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng state, ...(workingMode ? { workingMode } : {}), mainAgent, + // Readers that predate `mainAgent` read a cancellation off this flag, as the hook lanes publish it. + interrupted: mainAgentTurnInterrupted(mainAgent), prompt: summary.latestPrompt, agentType: summary.agent, ...(summary.model ? { model: summary.model } : {}), diff --git a/src/main/ipc/notification-options.ts b/src/main/ipc/notification-options.ts index a19f6044a46..24d43545e13 100644 --- a/src/main/ipc/notification-options.ts +++ b/src/main/ipc/notification-options.ts @@ -76,7 +76,10 @@ function formatAgentNotificationStatusText(args: NotificationDispatchRequest): s if (args.agentState === 'working') { return translateMain('notifications.agentStatus.working', 'working') } - return args.agentState === 'done' && args.agentInterrupted + if (args.agentState === 'done' && args.agentTurnOutcome === 'failure') { + return translateMain('notifications.agentStatus.failed', 'failed') + } + return args.agentState === 'done' && args.agentTurnOutcome === 'cancellation' ? translateMain('notifications.agentStatus.stopped', 'stopped') : translateMain('notifications.agentStatus.finished', 'finished') } @@ -104,7 +107,7 @@ function hasAgentNotificationSnapshot(args: NotificationDispatchRequest): boolea args.agentToolName || args.agentToolInput || args.agentLastAssistantMessage || - args.agentInterrupted + args.agentTurnOutcome !== undefined ) } diff --git a/src/main/ipc/notifications-message-formatting.test.ts b/src/main/ipc/notifications-message-formatting.test.ts index abf41bfae3c..3d519293f3c 100644 --- a/src/main/ipc/notifications-message-formatting.test.ts +++ b/src/main/ipc/notifications-message-formatting.test.ts @@ -217,7 +217,7 @@ describe('registerNotificationHandlers', () => { worktreeLabel: 'feat/notis', agentType: 'claude', agentState: 'done', - agentInterrupted: true, + agentTurnOutcome: 'cancellation', agentLastAssistantMessage: 'Stopped by user.' } ) @@ -316,7 +316,12 @@ describe('registerNotificationHandlers', () => { ) }) - it('reports an interrupted finish as stopped', async () => { + it.each([ + { agentTurnOutcome: 'cancellation', word: 'stopped' }, + { agentTurnOutcome: 'failure', word: 'failed' }, + { agentTurnOutcome: 'success', word: 'finished' }, + { agentTurnOutcome: undefined, word: 'finished' } + ] as const)('words a $agentTurnOutcome finish as $word', async ({ agentTurnOutcome, word }) => { registerNotificationHandlers({ getSettings: () => ({ notifications: { @@ -336,14 +341,40 @@ describe('registerNotificationHandlers', () => { worktreeLabel: 'feat/notis', agentType: 'claude', agentState: 'done', - agentInterrupted: true + ...(agentTurnOutcome ? { agentTurnOutcome } : {}) } ) expect(notificationCtorMock).toHaveBeenCalledWith( expectedNativeNotificationOptions({ - title: 'feat/notis - Claude stopped', - body: 'Claude stopped.' + title: `feat/notis - Claude ${word}`, + body: `Claude ${word}.` + }) + ) + }) + + it('counts a success verdict alone as an agent snapshot', async () => { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: false, + suppressWhenFocused: true + } + }) + } as never) + + const handler = getDispatchHandler() + await handler( + {}, + { source: 'agent-task-complete', worktreeLabel: 'feat/notis', agentTurnOutcome: 'success' } + ) + + expect(notificationCtorMock).toHaveBeenCalledWith( + expectedNativeNotificationOptions({ + title: 'feat/notis - Agent finished', + body: 'Agent finished.' }) ) }) diff --git a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts index 16999de0363..4647ba1e0e3 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts @@ -18,6 +18,7 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { DISPATCH_REJECTED_NOT_DELIVERED } from '../../../shared/structured-agent-session-dispatch-rejection' export type ProviderHistoryItem = { /** The provider's own id for this item. Used to claim it at most once; the @@ -55,7 +56,7 @@ export type SubmissionReconciliation = | { clientMessageId: string; outcome: 'rejected'; reason: SubmissionRejectionReason } | { clientMessageId: string; outcome: 'unknown'; reason: SubmissionUnknownReason } -export type SubmissionRejectionReason = 'not_delivered' +export type SubmissionRejectionReason = typeof DISPATCH_REJECTED_NOT_DELIVERED export type SubmissionUnknownReason = | 'history_boundary_inconsistent' @@ -197,6 +198,6 @@ function resolveOne( return { clientMessageId: submission.clientMessageId, outcome: 'rejected', - reason: 'not_delivered' + reason: DISPATCH_REJECTED_NOT_DELIVERED } } 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 186127847a6..b9e1b49c213 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 @@ -8,7 +8,11 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentSessionSubscribeEvent, + AgentSessionTurnCompletionEvent +} from '../../../shared/agent-session-wire' import { DISPATCH_REJECTED_CANCELLED, DISPATCH_REJECTED_HOST_RESTARTED, @@ -318,6 +322,28 @@ describe('a start the chat needed and did not get', () => { expect(errorRows()).toHaveLength(1) }) + it('notifies failed once for the queued messages one start failure refused', async () => { + await host.close(SESSION) + acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) + const completions: AgentSessionTurnCompletionEvent[] = [] + host.subscribeTurnCompletions({ id: 'dot-1', emit: (event) => completions.push(event) }) + await accept('first') + const second = await accept('second') + + await eventually(() => expect(submission(second)?.dispatchState).toBe('rejected')) + await host.flushAllStreamedEvents() + expect(completions).toEqual([ + { + type: 'completion', + completion: expect.objectContaining({ + sessionId: SESSION, + turnId: agentJournalSubmissionKey(second), + outcome: 'failure' + }) + } + ]) + }) + it.each([ [ 'eligibility', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts index 4e15b23d4d3..4e6390c46d9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts @@ -8,6 +8,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import { digestPayload } from '../agent-session-journal/journal-payload-bounds' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' @@ -117,6 +118,19 @@ describe('attachJournal restart reconciliation', () => { expect(dispatch).not.toHaveBeenCalled() }) + it('gives a send the provider never received no verdict and no listing', async () => { + await crashedJournal() + const { adapter } = adapterWith(async () => window()) + + const attached = await attach(adapter) + + // Nobody failed: the crash stranded it, so the chat must not read Failed or be listed by it. + const { items, submissions } = attached.journal.snapshot() + expect( + projectStructuredAgentSessionStatusState(items, submissions, RECORD.lease.runtimeFence) + ).toMatchObject({ summary: { status: null }, latestRequest: null }) + }) + it('still reports a submission unconfirmed when the window cannot decide it', async () => { await crashedJournal() const { adapter, dispatch } = adapterWith(async () => window({ turnInFlight: true })) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index aa3f298a077..24ab5cad012 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -31,7 +31,11 @@ export class StructuredAgentSessionClientDelivery { private readonly onJournalActivity?: (sessionId: string) => void ) { this.statusFeed = createStructuredAgentSessionHostStatusFeed({ sessions, now, deps }) - this.turnCompletionFeed = new StructuredAgentSessionTurnCompletionFeed({ sessions, now }) + this.turnCompletionFeed = new StructuredAgentSessionTurnCompletionFeed({ + sessions, + now, + readStatusState: (sessionId, journal) => this.statusFeed.statusState(sessionId, journal) + }) this.sendSettlement = new StructuredAgentSessionSendSettlement((sessionId) => this.requireJournal(sessionId) ) @@ -81,7 +85,8 @@ export class StructuredAgentSessionClientDelivery { this.statusFeed.publish(sessionId, journal) this.sendSettlement.publish(sessionId, journal) // Derived here rather than per-subscriber: this edge runs whether or not anyone is - // subscribed, which is the whole reason a backgrounded chat can complete at all. + // subscribed, which is the whole reason a backgrounded chat can complete at all. After the + // status publish, so it reads the projection that publish cached. this.turnCompletionFeed.observe(sessionId, journal) this.onJournalActivity?.(sessionId) } 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 72a908a3361..a2be1610327 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 @@ -88,7 +88,11 @@ async function sessionWithRunningTurn() { } } }) - const completions = new StructuredAgentSessionTurnCompletionFeed({ sessions, now: () => clock }) + const completions = new StructuredAgentSessionTurnCompletionFeed({ + sessions, + now: () => clock, + readStatusState: (sessionId, source) => feed.statusState(sessionId, source) + }) const completionEvents: AgentSessionTurnCompletionEvent[] = [] completions.subscribe({ id: 'dot-1', emit: (event) => completionEvents.push(event) }) // Both feeds have seen the turn running, so its settlement is a transition they must judge. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts index 884fba78a4e..524d8943fac 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts @@ -95,7 +95,12 @@ async function restartWithPersistedTurn(): Promise { const host = createHost(store) expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) const body = hostTestMessage('persisted conversation') - await host.send(CALLER, { envelope: sendEnvelope(store, { body }), body }) + const sent = await host.send(CALLER, { envelope: sendEnvelope(store, { body }), body }) + if (!sent.ok) { + throw new Error('send was refused') + } + // Delivered, not just accepted: a message still queued at the restart was never a request. + await host.waitForSendSettlement(SESSION, sent.value.clientMessageId) await host.flushAllStreamedEvents() return createHost(await AgentSessionRecordStore.open({ directory, hostId: 'local' })) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index e5da2eece97..a26cd324217 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -191,7 +191,8 @@ describe('StructuredAgentSessionStatusFeed', () => { expect(events.at(-1)).toMatchObject({ session: { status: 'working' } }) record.lease.runtimeFence = 2 feed.publish(SESSION) - expect(events.at(-1)).toMatchObject({ session: { status: 'idle' } }) + // Its only send outlived the host that sent it and became no turn: nothing left to list. + expect(events.at(-1)).toMatchObject({ session: { status: null } }) }) it('publishes working from the pending submission, before the provider replays the turn', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 6e572f0366d..c301cb32233 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -22,7 +22,7 @@ import { type AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' -import { projectStructuredAgentSessionStatusSummary } from '../../../shared/structured-agent-session-projection' +import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' @@ -34,6 +34,10 @@ import { export type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-ownership' +export type StructuredAgentSessionStatusState = ReturnType< + typeof projectStructuredAgentSessionStatusState +> + export type StructuredAgentSessionStatusSubscriber = { id: string emit: (event: AgentSessionStatusEvent) => void @@ -142,7 +146,7 @@ export class StructuredAgentSessionStatusFeed { sequence: number readOnly: boolean fence: number | undefined - summary: ReturnType + state: StructuredAgentSessionStatusState } >() @@ -204,6 +208,17 @@ export class StructuredAgentSessionStatusFeed { }) } + /** The projection behind the session's row and the latest request it read, cached per commit, + * so the completion feed follows the same request without snapshotting the journal again. */ + statusState( + sessionId: string, + journal?: AgentSessionJournal + ): StructuredAgentSessionStatusState | null { + const session = this.deps.sessions.get(sessionId) + const source = journal ?? session?.journal + return source ? this.projectionFor(source, this.deps.getRecord(sessionId)) : null + } + /** Re-projects one session after its journal changed; equal projections are not re-sent. */ publish(sessionId: string, journal?: AgentSessionJournal, options?: { replay?: boolean }): void { const session = this.deps.sessions.get(sessionId) @@ -234,35 +249,8 @@ export class StructuredAgentSessionStatusFeed { session: StatusFeedSession, journal: AgentSessionJournal ): AgentSessionStatusSummary { - // An unreadable journal projects as "no turn": the chat itself shows the reset. - const cursor = journal.cursor() - const readOnly = journal.isReadOnly const record = this.deps.getRecord(sessionId) - // The conversation's fence, which a child's end moves: its unanswered sends stop counting. - const fence = record?.lease.runtimeFence - let projection = this.journalProjections.get(journal) - if ( - !projection || - projection.epoch !== cursor.epoch || - projection.sequence !== cursor.sequence || - projection.readOnly !== readOnly || - projection.fence !== fence - ) { - // A journalled submission bumps `lastSequence`, so the send-time working - // signal reaches the cache; the lease fence does not, hence the extra key. - const snapshot = readOnly ? null : journal.snapshot() - projection = { - ...cursor, - readOnly, - fence, - summary: projectStructuredAgentSessionStatusSummary( - snapshot?.items ?? [], - snapshot?.submissions ?? [], - fence - ) - } - this.journalProjections.set(journal, projection) - } + const { summary: projected } = this.projectionFor(journal, record) const providerSession = structuredAgentSessionProviderSessionMetadata(record) // The journal has no model: the record's acknowledged options are where a mid-session // switch lands, so the row follows whichever is in force. @@ -280,7 +268,7 @@ export class StructuredAgentSessionStatusFeed { ...(session.child ? { hostExecutionOwned: true as const, hostExecutionPhase: session.child.phase } : {}), - ...projection.summary, + ...projected, ...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded' ? { rewindBlockedReason: 'outcome-unknown' as const } : {}), @@ -304,6 +292,41 @@ export class StructuredAgentSessionStatusFeed { } } + private projectionFor( + journal: AgentSessionJournal, + record: AgentSessionRecord | null + ): StructuredAgentSessionStatusState { + // An unreadable journal projects as "no turn": the chat itself shows the reset. + const cursor = journal.cursor() + const readOnly = journal.isReadOnly + // The conversation's fence, which a child's end moves: its unanswered sends stop counting. + const fence = record?.lease.runtimeFence + let projection = this.journalProjections.get(journal) + if ( + !projection || + projection.epoch !== cursor.epoch || + projection.sequence !== cursor.sequence || + projection.readOnly !== readOnly || + projection.fence !== fence + ) { + // A journalled submission bumps `lastSequence`, so the send-time working + // signal reaches the cache; the lease fence does not, hence the extra key. + const snapshot = readOnly ? null : journal.snapshot() + projection = { + ...cursor, + readOnly, + fence, + state: projectStructuredAgentSessionStatusState( + snapshot?.items ?? [], + snapshot?.submissions ?? [], + fence + ) + } + this.journalProjections.set(journal, projection) + } + return projection.state + } + /** A failing sink must never cost the subscribers their status event. */ private sink( summary: AgentSessionStatusSummary, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts index c0e652724af..93d4a54cdc9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts @@ -1,6 +1,18 @@ import { describe, expect, it, vi } from 'vitest' -import type { AgentJournalTurnLifecycle } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionTurnCompletionEvent } from '../../../shared/agent-session-wire' +import { + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_HOST_RESTARTED, + DISPATCH_REJECTED_NOT_DELIVERED, + DISPATCH_REJECTED_PROVIDER_CLOSED +} from '../../../shared/structured-agent-session-dispatch-rejection' +import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import { StructuredAgentSessionTurnCompletionFeed } from './structured-agent-session-turn-completion-feed' const LOCATION = { @@ -10,6 +22,8 @@ const LOCATION = { workspaceKind: 'git-worktree' } as const +const START_FAILURE = 'Claude is not signed in.' + function turn( turnId: string, state: AgentJournalTurnLifecycle['state'], @@ -18,33 +32,102 @@ function turn( return { turnId, state, ...(outcome ? { outcome } : {}) } } +function turnItem(lifecycle: AgentJournalTurnLifecycle, sequence: number): AgentJournalRenderItem { + return { + itemId: `codex:turn:${lifecycle.turnId}`, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'turn', ...lifecycle } + } +} + +function userEntry(clientMessageId: string, sequence: number): AgentJournalRenderItem { + return { + itemId: agentJournalSubmissionKey(clientMessageId), + revision: 0, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] } + } +} + +function sent( + clientMessageId: string, + fields: Partial & Pick +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + providerItemId: null, + reason: null, + submittedAt: 10, + resolvedAt: 20, + handoverRecorded: true, + ...fields + } +} + +const pending = (clientMessageId: string, fence = 1) => + sent(clientMessageId, { dispatchState: 'pending', fence, handedOverAt: 11, resolvedAt: null }) +const refused = (clientMessageId: string, reason = START_FAILURE) => + sent(clientMessageId, { dispatchState: 'rejected', reason }) + function harness(): { feed: StructuredAgentSessionTurnCompletionFeed setTurn: (next: AgentJournalTurnLifecycle | null) => void + setJournal: ( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[], + fence?: number + ) => void setCursor: (next: { epoch: string; sequence: number }) => void observe: () => void events: AgentSessionTurnCompletionEvent[] + outcomes: () => [string, string][] + /** Whether each completion said the user is being asked something. */ + awaitingUser: () => boolean[] listen: () => () => void } { - let current: AgentJournalTurnLifecycle | null = null + let items: AgentJournalRenderItem[] = [] + let submissions: AgentJournalSubmission[] = [] + let fence: number | undefined let cursor = { epoch: 'epoch-1', sequence: 0 } - const journal = { - newestTurn: () => current, - cursor: () => cursor - } + const journal = { cursor: () => cursor } const sessions = new Map([['session-1', { journal, params: { location: LOCATION } }]]) - const feed = new StructuredAgentSessionTurnCompletionFeed({ sessions, now: () => 1_700 }) + const feed = new StructuredAgentSessionTurnCompletionFeed({ + sessions, + now: () => 1_700, + // The status feed's projection, computed as it computes it. + readStatusState: () => projectStructuredAgentSessionStatusState(items, submissions, fence) + }) const events: AgentSessionTurnCompletionEvent[] = [] return { feed, setTurn: (next) => { - current = next + items = next ? [turnItem(next, 1)] : [] + submissions = [] + }, + setJournal: (nextItems, nextSubmissions, nextFence) => { + items = nextItems + submissions = nextSubmissions + fence = nextFence + cursor = { ...cursor, sequence: cursor.sequence + 1 } }, setCursor: (next) => { cursor = next }, observe: () => feed.observe('session-1'), events, + outcomes: () => + events.flatMap((event): [string, string][] => + event.type === 'completion' ? [[event.completion.turnId, event.completion.outcome]] : [] + ), + awaitingUser: () => + events.flatMap((event) => + event.type === 'completion' ? [event.completion.awaitingUser === true] : [] + ), listen: () => feed.subscribe({ id: 'sub', emit: (event) => events.push(event) }) } } @@ -59,7 +142,8 @@ describe('StructuredAgentSessionTurnCompletionFeed', () => { h.setTurn(turn('turn-1', 'completed', 'success')) h.setCursor({ epoch: 'epoch-1', sequence: 2 }) h.observe() - expect(h.events).toEqual([ + // Strict: an idle settle omits `awaitingUser` rather than sending it undefined. + expect(h.events).toStrictEqual([ { type: 'completion', completion: { @@ -261,3 +345,318 @@ describe('StructuredAgentSessionTurnCompletionFeed', () => { expect(emit).not.toHaveBeenCalled() }) }) + +describe('a request the agent or its start refused', () => { + const M1 = agentJournalSubmissionKey('m1') + const M2 = agentJournalSubmissionKey('m2') + const M3 = agentJournalSubmissionKey('m3') + const settledTurn = turnItem(turn('t1', 'completed', 'success'), 2) + + /** A session whose first turn succeeded, as the feed saw it happen. */ + function afterSuccessfulTurn() { + const h = harness() + h.listen() + h.setJournal([userEntry('m1', 1), turnItem(turn('t1', 'running'), 2)], []) + h.observe() + h.setJournal([userEntry('m1', 1), settledTurn], [sent('m1', { dispatchState: 'accepted' })]) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + return h + } + + it('notifies failed once when the only send fails to start, named by its item key', () => { + const h = harness() + h.listen() + h.observe() + h.setJournal([userEntry('m1', 1)], [pending('m1')]) + h.observe() + h.setJournal([userEntry('m1', 1)], [refused('m1')]) + h.observe() + h.observe() + expect(h.events).toEqual([ + { + type: 'completion', + completion: { + scope: LOCATION, + sessionId: 'session-1', + turnId: M1, + outcome: 'failure', + completedAt: 1_700 + } + } + ]) + }) + + it('stays silent on a first observation of a send that had already failed', () => { + // A restart, reopen or re-attach: the failure is history, not news. + const h = harness() + h.listen() + h.setJournal([userEntry('m1', 1)], [refused('m1')]) + h.observe() + h.observe() + expect(h.events).toEqual([]) + }) + + it('re-baselines an epoch replacement that surfaces an older failure', () => { + const h = afterSuccessfulTurn() + h.setJournal([userEntry('m1', 1)], [refused('m1')]) + h.setCursor({ epoch: 'epoch-2', sequence: 1 }) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + }) + + it.each([ + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_HOST_RESTARTED, + DISPATCH_REJECTED_PROVIDER_CLOSED, + DISPATCH_REJECTED_NOT_DELIVERED + ])('never notifies a send %s, alone or after a turn', (reason) => { + const alone = harness() + alone.listen() + alone.observe() + alone.setJournal([userEntry('m1', 1)], [pending('m1')]) + alone.observe() + alone.setJournal([userEntry('m1', 1)], [refused('m1', reason)]) + alone.observe() + expect(alone.events).toEqual([]) + + // The latest request falls back to the turn already announced, which must not announce again. + const h = afterSuccessfulTurn() + const accepted = sent('m1', { dispatchState: 'accepted' }) + h.setJournal([userEntry('m1', 1), settledTurn, userEntry('m2', 3)], [accepted, pending('m2')]) + h.observe() + h.setJournal( + [userEntry('m1', 1), settledTurn, userEntry('m2', 3)], + [accepted, refused('m2', reason)] + ) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + }) + + it('never notifies a crash-stranded send that restart reconciliation finds undelivered', () => { + const h = afterSuccessfulTurn() + const items = [userEntry('m1', 1), settledTurn, userEntry('m2', 3)] + const accepted = sent('m1', { dispatchState: 'accepted' }) + h.setJournal(items, [accepted, sent('m2', { dispatchState: 'unknown', recovered: true })], 2) + h.observe() + h.setJournal( + items, + [ + accepted, + sent('m2', { + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_NOT_DELIVERED, + fence: 2, + recovered: true + }) + ], + 2 + ) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + }) + + it('notifies failed, then success, when a failed start is retried and the retry succeeds', () => { + const h = harness() + h.listen() + h.observe() + h.setJournal([userEntry('m1', 1)], [refused('m1')]) + h.observe() + h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [refused('m1'), pending('m2')]) + h.observe() + const accepted = sent('m2', { dispatchState: 'accepted' }) + h.setJournal( + [userEntry('m1', 1), userEntry('m2', 2), turnItem(turn('t2', 'running'), 3)], + [refused('m1'), accepted] + ) + h.observe() + h.setJournal( + [userEntry('m1', 1), userEntry('m2', 2), turnItem(turn('t2', 'completed', 'success'), 3)], + [refused('m1'), accepted] + ) + h.observe() + expect(h.outcomes()).toEqual([ + [M1, 'failure'], + ['t2', 'success'] + ]) + }) + + it('notifies each failed start that follows another', () => { + const h = harness() + h.listen() + h.observe() + h.setJournal([userEntry('m1', 1)], [refused('m1')]) + h.observe() + h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [refused('m1'), pending('m2')]) + h.observe() + h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [refused('m1'), refused('m2')]) + h.observe() + expect(h.outcomes()).toEqual([ + [M1, 'failure'], + [M2, 'failure'] + ]) + }) + + it.each([ + ['in one commit', [['m2', 'm3']]], + ['oldest first, across commits', [['m2'], ['m3']]], + ['newest first, across commits', [['m3'], ['m2']]] + ])('notifies once for queued sends one start failure refused %s', (_name, batches) => { + const h = afterSuccessfulTurn() + const items = [userEntry('m1', 1), settledTurn, userEntry('m2', 3), userEntry('m3', 4)] + const accepted = sent('m1', { dispatchState: 'accepted' }) + const queued = (id: string) => sent(id, { dispatchState: 'pending', resolvedAt: null }) + const answered = new Set() + const submissions = () => [ + accepted, + ...['m2', 'm3'].map((id) => (answered.has(id) ? refused(id) : queued(id))) + ] + h.setJournal(items, submissions()) + h.observe() + for (const batch of batches) { + batch.forEach((id) => answered.add(id)) + h.setJournal(items, submissions()) + h.observe() + } + expect(h.outcomes()).toEqual([ + ['t1', 'success'], + [M3, 'failure'] + ]) + }) + + it('does not wait on a send left pending at an older fence', () => { + const h = harness() + h.listen() + h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [pending('m1', 1), pending('m2', 2)], 2) + h.observe() + h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [pending('m1', 1), refused('m2')], 2) + h.observe() + expect(h.outcomes()).toEqual([[M2, 'failure']]) + }) +}) + +describe('a request that settles while the user is asked something', () => { + const M1 = agentJournalSubmissionKey('m1') + + /** An approval the user has not answered; `agentId` makes it a subagent's. */ + function approval( + itemId: string, + sequence: number, + state: 'pending' | 'resolved', + agentId?: string + ): AgentJournalRenderItem { + return { + itemId, + revision: state === 'pending' ? 1 : 2, + sequence, + observedAt: sequence, + ...(agentId ? { agentId } : {}), + body: { + kind: 'approval', + title: 'Run command?', + detail: null, + options: [{ id: 'yes', label: 'Allow' }], + resolution: { state, selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + } + } + + it('notifies once when the main turn settles while a subagent waits on an approval', () => { + const h = harness() + h.listen() + const user = userEntry('m1', 1) + const accepted = [sent('m1', { dispatchState: 'accepted' })] + h.setJournal([user, turnItem(turn('t1', 'running'), 2)], accepted) + h.observe() + h.setJournal( + [user, turnItem(turn('t1', 'running'), 2), approval('a1', 3, 'pending', 'child-1')], + accepted + ) + h.observe() + h.setJournal( + [ + user, + turnItem(turn('t1', 'completed', 'success'), 2), + approval('a1', 3, 'pending', 'child-1') + ], + accepted + ) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + expect(h.awaitingUser()).toEqual([true]) + + // Answering the prompt settles the session idle on the request already announced. + h.setJournal( + [ + user, + turnItem(turn('t1', 'completed', 'success'), 2), + approval('a1', 3, 'resolved', 'child-1') + ], + accepted + ) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + }) + + it('notifies a refused send once while a prompt is pending', () => { + const h = harness() + h.listen() + const prompt = approval('a1', 1, 'pending', 'child-1') + h.setJournal([prompt, userEntry('m1', 2)], [pending('m1')]) + h.observe() + h.setJournal([prompt, userEntry('m1', 2)], [refused('m1')]) + h.observe() + expect(h.outcomes()).toEqual([[M1, 'failure']]) + expect(h.awaitingUser()).toEqual([true]) + h.setJournal([approval('a1', 1, 'resolved', 'child-1'), userEntry('m1', 2)], [refused('m1')]) + h.observe() + expect(h.outcomes()).toEqual([[M1, 'failure']]) + }) + + it('sends nothing while the main turn asks for permission, and one event when it settles', () => { + const h = harness() + h.listen() + const user = userEntry('m1', 1) + const accepted = [sent('m1', { dispatchState: 'accepted' })] + h.setJournal([user, turnItem(turn('t1', 'running'), 2)], accepted) + h.observe() + h.setJournal([user, turnItem(turn('t1', 'running'), 2), approval('a1', 3, 'pending')], accepted) + h.observe() + expect(h.events).toEqual([]) + h.setJournal( + [user, turnItem(turn('t1', 'running'), 2), approval('a1', 3, 'resolved')], + accepted + ) + h.observe() + h.setJournal( + [user, turnItem(turn('t1', 'completed', 'success'), 2), approval('a1', 3, 'resolved')], + accepted + ) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + // Idle when it settles: the prompt was already answered. + expect(h.awaitingUser()).toEqual([false]) + }) + + it('still waits on a queued send the prompt hides, so the queue notifies once', () => { + const h = harness() + h.listen() + const prompt = approval('a1', 3, 'pending', 'child-1') + const items = [userEntry('m1', 1), turnItem(turn('t1', 'running'), 2), prompt] + const queued = sent('m2', { dispatchState: 'pending', resolvedAt: null }) + const accepted = sent('m1', { dispatchState: 'accepted' }) + h.setJournal([...items, userEntry('m2', 4)], [accepted, queued]) + h.observe() + h.setJournal( + [ + userEntry('m1', 1), + turnItem(turn('t1', 'completed', 'success'), 2), + prompt, + userEntry('m2', 4) + ], + [accepted, queued] + ) + h.observe() + expect(h.events).toEqual([]) + }) +}) 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 1281fbec49b..ca9f64c0573 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 @@ -1,4 +1,5 @@ -// The host's answer to "a turn just finished", derived once per journal commit. +// The host's answer to "a request just finished", derived once per journal commit. A request is +// the one the status row reports: a turn, or a send the agent or its start refused. // // WHY THE HOST DERIVES IT: a structured session runs on the execution host and keeps journalling // whether or not any renderer has a reader mounted. A client that derived completions itself would @@ -10,41 +11,45 @@ // needs, a completion is an edge that has already passed. Keeping a queue would create a durable // obligation with nothing to retire it. -import type { AgentJournalTurnLifecycle } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { readAgentJournalTurnOutcome } from '../../../shared/agent-session-turn-record' import type { AgentSessionTurnCompletion, AgentSessionTurnCompletionEvent } from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionLatestRequest } from '../../../shared/structured-agent-session-latest-request' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionStatusState } from './structured-agent-session-status-feed' export type StructuredAgentSessionTurnCompletionSubscriber = { id: string emit: (event: AgentSessionTurnCompletionEvent) => void } -/** Only the newest-turn reader and cursor are needed here; asking for the whole journal would overstate it. */ type CompletionFeedCursor = { epoch: string; sequence: number } -type CompletionFeedJournal = Pick - type CompletionFeedSession = { - journal: CompletionFeedJournal + journal: Pick params: { location: AgentSessionRecord['location'] } } export type StructuredAgentSessionTurnCompletionFeedDeps = { sessions: ReadonlyMap now: () => number + /** The status feed's projection for this commit, so the event follows the request its row reports. */ + readStatusState: ( + sessionId: string, + journal?: AgentSessionJournal + ) => StructuredAgentSessionStatusState | null } -/** Per-session baseline. `settledTurnId` is the last settled turn this feed has accounted for; - * absence of the whole entry — not a null field — is what makes the first observation silent. */ -type SessionBaseline = CompletionFeedCursor & { settledTurnId: string | null } +type RequestMark = Pick -function isSettled(turn: AgentJournalTurnLifecycle | null): turn is AgentJournalTurnLifecycle { - return turn !== null && turn.state !== 'running' +/** Per-session baseline. `settled` is the last settled request this feed has accounted for; + * absence of the whole entry — not a null field — is what makes the first observation silent. */ +type SessionBaseline = CompletionFeedCursor & { settled: RequestMark | null } + +function settledMark(request: StructuredAgentSessionLatestRequest | null): RequestMark | null { + return request && !request.running ? { kind: request.kind, id: request.id } : null } export class StructuredAgentSessionTurnCompletionFeed { @@ -80,29 +85,24 @@ export class StructuredAgentSessionTurnCompletionFeed { /** * One journal publication. Emits at most one completion, and only on the transition into a - * settled turn this feed has not already accounted for. + * settled request this feed has not already accounted for. * * The first observation of a session only records where it is, so restore, restart, rewind and - * a re-read of history all pass through silently. An already-settled turn republished by an - * in-place revision carries the same turn id and so cannot fire twice. + * a re-read of history all pass through silently. An already-settled request republished by an + * in-place revision carries the same identity and so cannot fire twice. */ - observe(sessionId: string, journal?: CompletionFeedJournal): void { + observe(sessionId: string, journal?: AgentSessionJournal): void { const session = this.deps.sessions.get(sessionId) - if (!session) { + const state = session ? this.deps.readStatusState(sessionId, journal) : null + if (!session || !state) { return } - const source = journal ?? session.journal - const cursor = source.cursor() - const turn = source.newestTurn() - const settled = isSettled(turn) ? turn : null + const cursor = (journal ?? session.journal).cursor() + const request = state.latestRequest const baseline = this.baselines.get(sessionId) if (!baseline) { // Baseline only. Whatever the session was already holding is history, not news. - this.baselines.set(sessionId, { - epoch: cursor.epoch, - sequence: cursor.sequence, - settledTurnId: settled?.turnId ?? null - }) + this.baselines.set(sessionId, { ...cursor, settled: settledMark(request) }) return } if (baseline.epoch !== cursor.epoch || cursor.sequence < baseline.sequence) { @@ -111,24 +111,31 @@ export class StructuredAgentSessionTurnCompletionFeed { // newest settled row as a fresh completion. baseline.epoch = cursor.epoch baseline.sequence = cursor.sequence - baseline.settledTurnId = settled?.turnId ?? null + baseline.settled = settledMark(request) return } baseline.sequence = cursor.sequence - if (!settled) { + if (request?.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.settledTurnId = null + baseline.settled = null return } - if (baseline.settledTurnId === settled.turnId) { + // Owed work waits, so sends refused one commit at a time announce once, when the last is + // answered. A pending prompt does not wait (structured chat has no other attention producer): + // the event says so itself, and answering it keeps the same identity. + // A withdrawn send leaves the older request latest. + if ( + state.owesWork || + !request || + (baseline.settled?.kind === request.kind && baseline.settled.id === request.id) + ) { return } - baseline.settledTurnId = settled.turnId + baseline.settled = settledMark(request) // ABSENT OUTCOME IS UNKNOWN: a turn the host only saw stop carries no verdict and gets no // event. Inferring success here is the one mistake that would light the dot on a failure. - const outcome = readAgentJournalTurnOutcome(settled) - if (!outcome) { + if (!request.outcome) { return } this.broadcast({ @@ -136,9 +143,11 @@ export class StructuredAgentSessionTurnCompletionFeed { completion: { scope: session.params.location, sessionId, - turnId: settled.turnId, - outcome, - completedAt: this.deps.now() + turnId: request.id, + outcome: request.outcome, + completedAt: this.deps.now(), + // Stated here, not joined from the status stream: remote clients receive the two unordered. + ...(state.summary.status === 'attention' ? { awaitingUser: true } : {}) } }) } diff --git a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts new file mode 100644 index 00000000000..75cbf2e6231 --- /dev/null +++ b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts @@ -0,0 +1,213 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../shared/agent-session-wire' +import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' +import { AgentHookServer, _internals } from '../agent-hooks/server' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' +import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' +import { indexedStatusFeedSession } from '../native-chat/agent-session-wire/structured-agent-session-status-feed-test-session' +import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' +import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) + +// A request that failed reads as failed on every surface the host feeds: the journal's verdict +// travels the real feed, the status-store ingest and `worktree ps`, never just the projection. +const SESSION = 'verdict-session' +const WORKSPACE_ID = 'workspace-1' +const SUBJECT = makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE_ID, + workspaceKind: 'git-worktree' + }, + SESSION +) +const TURN_IDENTITY = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 0 +} as const + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + _internals.resetCachesForTests() + root = await mkdtemp(join(tmpdir(), 'orca-verdict-rows-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +async function openJournal(): Promise { + return journals.open({ + identity: { + sessionId: SESSION, + workspaceId: WORKSPACE_ID, + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: join(root, SESSION) + }) +} + +/** What the host's real feed publishes for this journal. */ +function publishedSummary(journal: AgentSessionJournal): AgentSessionStatusSummary { + const session = indexedStatusFeedSession({ journal }) + const feed = new StructuredAgentSessionStatusFeed({ + sessions: new Map([[SESSION, session]]), + getRecord: () => agentSessionRecordFixture(), + now: () => 1_000 + }) + const events: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'list', emit: (event) => events.push(event) }) + const snapshot = events.find((event) => event.type === 'snapshot') + const summary = snapshot?.type === 'snapshot' ? snapshot.sessions[0] : undefined + if (!summary) { + throw new Error('the feed published no session') + } + return summary +} + +function ingest(summary: AgentSessionStatusSummary) { + const store = new AgentHookServer() + store.ingestStructuredStatus(summary, SUBJECT) + const hookSnapshots = store.getStatusSnapshot() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: attaching agent rows reads and writes only `worktreeId`, `status`, `hasHostSidebarActivity` and `agents`. + const row = { + worktreeId: WORKSPACE_ID, + status: 'inactive', + hasHostSidebarActivity: false, + agents: [] + } as unknown as RuntimeWorktreePsSummary + attachRuntimeWorktreeAgentRows({ + summaries: new Map([[WORKSPACE_ID, row]]), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `getSummary` below resolves every row by id, so the path index is never read. + pathIndex: { byPath: new Map(), byRealPath: new Map() } as never, + missingWorktreeIds: new Set(), + workingTerminalEvidenceByWorktreeId: new Map(), + rowSources: collectRuntimeWorktreeAgentSources({ + mirroredWorktreeIdByTabId: new Map(), + connectedPtyEvidence: { + tabIds: new Set(), + paneKeys: new Set(), + ptyIdByTerminalHandle: new Map() + }, + hookSnapshots + }), + orchestrationByPaneKey: null, + getSummary: (map, _p, _m, id) => map.get(id) ?? null + }) + return { status: hookSnapshots[0], ps: row.agents[0] } +} + +describe('a request that failed reads as failed through the feed, the ingest and worktree ps', () => { + it('reads a chat whose only send the agent start refused as failed, not interrupted', async () => { + const journal = await openJournal() + await journal.appendSubmission({ + clientMessageId: 'first', + payloadFingerprint: 'fp', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + fence: 1, + handoverRecorded: true + }) + await journal.rejectQueuedSubmissions(1, 'Claude is not signed in.') + + const summary = publishedSummary(journal) + expect(summary).toMatchObject({ status: 'idle', turnOutcome: 'failure', latestPrompt: 'hello' }) + const { status, ps } = ingest(summary) + expect(status).toMatchObject({ + state: 'done', + mainAgent: { state: 'done', outcome: 'failure' } + }) + expect(status?.interrupted).not.toBe(true) + expect(ps).toMatchObject({ + state: 'done', + mainAgent: { state: 'done', outcome: 'failure' }, + interrupted: false + }) + }) + + it('reads a cancelled structured turn as interrupted for readers that predate the verdict', async () => { + const journal = await openJournal() + await journal.appendItem( + TURN_IDENTITY, + { + kind: 'turn', + turnId: 'turn-1', + state: 'interrupted', + outcome: 'cancellation', + completedAt: 5 + }, + { fence: 1 } + ) + + const { status, ps } = ingest(publishedSummary(journal)) + expect(status).toMatchObject({ state: 'done', interrupted: true }) + expect(ps).toMatchObject({ + mainAgent: { state: 'done', outcome: 'cancellation' }, + interrupted: true + }) + }) + + it('publishes a main agent that failed while its subagent runs, on the row that still works', async () => { + const journal = await openJournal() + await journal.appendItem( + TURN_IDENTITY, + { kind: 'turn', turnId: 'turn-1', state: 'completed', outcome: 'failure', completedAt: 5 }, + { fence: 1 } + ) + const summary: AgentSessionStatusSummary = { + ...publishedSummary(journal), + backgroundTasks: [{ id: 'child-1', kind: 'agent', state: 'working' }] + } + + const { status, ps } = ingest(summary) + expect(status).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'failure' } + }) + // The row carries the main agent's own clock, which dates the failure apart from the working row. + expect(ps).toMatchObject({ + state: 'working', + mainAgent: { + state: 'done', + outcome: 'failure', + stateStartedAt: status?.mainAgent?.stateStartedAt + }, + interrupted: false + }) + }) + + it('lists nothing for a chat whose only send the user withdrew', async () => { + const journal = await openJournal() + await journal.appendSubmission({ + clientMessageId: 'first', + payloadFingerprint: 'fp', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + fence: 1, + handoverRecorded: true + }) + await journal.rejectQueuedSubmissions(1, 'provider_cancelled_before_start') + + expect(publishedSummary(journal)).toMatchObject({ status: null }) + expect(ingest(publishedSummary(journal)).ps).toBeUndefined() + }) +}) diff --git a/src/main/runtime/runtime-worktree-agent-rows.ts b/src/main/runtime/runtime-worktree-agent-rows.ts index 20c17f9b01a..310b0f6bc52 100644 --- a/src/main/runtime/runtime-worktree-agent-rows.ts +++ b/src/main/runtime/runtime-worktree-agent-rows.ts @@ -60,6 +60,7 @@ export function attachRuntimeWorktreeAgentRows(args: { toolName: source.toolName, toolInput: source.toolInput, interrupted: source.interrupted, + ...(source.mainAgent ? { mainAgent: source.mainAgent } : {}), stateStartedAt: source.stateStartedAt, updatedAt: source.updatedAt, ...(source.structuredHost === 'owned' ? { structuredHostOwned: true as const } : {}) diff --git a/src/main/runtime/runtime-worktree-agent-source.ts b/src/main/runtime/runtime-worktree-agent-source.ts index 984f20e0955..fd98d7914b1 100644 --- a/src/main/runtime/runtime-worktree-agent-source.ts +++ b/src/main/runtime/runtime-worktree-agent-source.ts @@ -1,5 +1,6 @@ import type { StructuredHostStatus } from '../../shared/agent-hook-listener/listener-event' import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' +import type { AgentMainAgentStatus } from '../../shared/main-agent-status' export type RuntimeWorktreeAgentSource = { paneKey: string @@ -15,6 +16,7 @@ export type RuntimeWorktreeAgentSource = { toolName: string | null toolInput: string | null interrupted: boolean + mainAgent?: AgentMainAgentStatus stateStartedAt: number updatedAt: number /** Projected by the structured session host; `owned` rows stay fresh past the staleness window. */ diff --git a/src/main/runtime/runtime-worktree-pty-agent-sources.ts b/src/main/runtime/runtime-worktree-pty-agent-sources.ts index 058d378df11..e03f458134f 100644 --- a/src/main/runtime/runtime-worktree-pty-agent-sources.ts +++ b/src/main/runtime/runtime-worktree-pty-agent-sources.ts @@ -4,6 +4,7 @@ import { type ParsedAgentStatusPayload } from '../../shared/agent-status-types' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import { agentVerdictFields } from '../../shared/agent-main-agent-verdict' import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source' @@ -47,7 +48,8 @@ export function collectRuntimeWorktreePtyAgentSources(args: { lastAssistantMessage: entry.lastAssistantMessage ?? null, toolName: entry.toolName ?? null, toolInput: entry.toolInput ?? null, - interrupted: entry.interrupted ?? false, + interrupted: false, + ...agentVerdictFields(entry), stateStartedAt: entry.stateStartedAt, // A replay advances delivery order, not the age of the evidence shown by worktree.ps. updatedAt: entry.evidenceObservedAt ?? entry.receivedAt, diff --git a/src/renderer/src/components/activity/activity-clear-completed.test.ts b/src/renderer/src/components/activity/activity-clear-completed.test.ts index e0cee5aa9d8..79191ce3252 100644 --- a/src/renderer/src/components/activity/activity-clear-completed.test.ts +++ b/src/renderer/src/components/activity/activity-clear-completed.test.ts @@ -61,6 +61,7 @@ import { isClearableActivityThread, planClearCompletedActivity } from './activity-clear-completed' +import { activityThreadStatusId } from './activity-thread-presentation' function makeThread(paneKey: string, overrides: Partial = {}): AgentPaneThread { return { @@ -81,7 +82,7 @@ function makeThread(paneKey: string, overrides: Partial = {}): } } -function doneEvent(interrupted: boolean): ActivityEvent { +function doneEvent(interrupted: boolean, outcome?: 'failure'): ActivityEvent { return { id: 'evt', state: 'done', @@ -89,7 +90,16 @@ function doneEvent(interrupted: boolean): ActivityEvent { observedAt: 5_000, worktree: makeWorktree(), repo: null, - entry: { interrupted } as ActivityEvent['entry'], + entry: { + paneKey: 'evt-pane', + state: 'done', + prompt: '', + updatedAt: 5_000, + stateStartedAt: 5_000, + stateHistory: [], + interrupted, + ...(outcome ? { mainAgent: { state: 'done', outcome, stateStartedAt: 5_000 } } : {}) + }, tab: makeTab(), agentType: 'claude', agentAlive: false, @@ -102,6 +112,7 @@ const blockedThread = makeThread('t-blocked:1', { currentAgentState: 'blocked' } const waitingThread = makeThread('t-waiting:1', { currentAgentState: 'waiting' }) const doneThread = makeThread('t-done:1', { latestEvent: doneEvent(false) }) const interruptedThread = makeThread('t-interrupted:1', { latestEvent: doneEvent(true) }) +const failedThread = makeThread('t-failed:1', { latestEvent: doneEvent(false, 'failure') }) function makeRetained(paneKey: string): RetainedAgentEntry { return { @@ -125,10 +136,34 @@ describe('isClearableActivityThread', () => { it('clears only completed and interrupted threads', () => { expect(isClearableActivityThread(doneThread)).toBe(true) expect(isClearableActivityThread(interruptedThread)).toBe(true) + expect(activityThreadStatusId(failedThread)).toBe('failed') + expect(isClearableActivityThread(failedThread)).toBe(true) expect(isClearableActivityThread(workingThread)).toBe(false) expect(isClearableActivityThread(blockedThread)).toBe(false) expect(isClearableActivityThread(waitingThread)).toBe(false) }) + + it('reads a live thread whose main agent failed as failed, but keeps it while subagents run', () => { + const heldEntry = { + ...doneEvent(false).entry, + state: 'working' as const, + mainAgent: { state: 'done' as const, outcome: 'failure' as const, stateStartedAt: 5_000 } + } + const held = makeThread('t-held:1', { + currentAgentState: 'working', + currentAgentEntry: heldEntry + }) + expect(activityThreadStatusId(held)).toBe('failed') + expect(isClearableActivityThread(held)).toBe(false) + const succeeded = makeThread('t-ok:1', { + currentAgentState: 'working', + currentAgentEntry: { + ...heldEntry, + mainAgent: { state: 'done', outcome: 'success', stateStartedAt: 5_000 } + } + }) + expect(activityThreadStatusId(succeeded)).toBe('working') + }) }) describe('clearCompletedActivity', () => { diff --git a/src/renderer/src/components/activity/activity-clear-completed.ts b/src/renderer/src/components/activity/activity-clear-completed.ts index 2bb39fdff43..e365152c9bb 100644 --- a/src/renderer/src/components/activity/activity-clear-completed.ts +++ b/src/renderer/src/components/activity/activity-clear-completed.ts @@ -18,11 +18,15 @@ export type ClearCompletedActivityPlan = { clearedThreadCount: number } -/** A thread is clearable when it needs nothing from the user: completed or interrupted, +/** A thread is clearable when it needs nothing from the user: completed, failed or interrupted, * with no fresh live working/monitoring/blocked/waiting state. */ export function isClearableActivityThread(thread: AgentPaneThread): boolean { const id = activityThreadStatusId(thread) - return id === 'done' || id === 'interrupted' + // Why: a failed main agent reads failed while its subagents still run; that thread is still live. + if (thread.currentAgentState) { + return false + } + return id === 'done' || id === 'failed' || id === 'interrupted' } export function planClearCompletedActivity( diff --git a/src/renderer/src/components/activity/activity-pane-events.ts b/src/renderer/src/components/activity/activity-pane-events.ts index 6a147beac81..0ee287af187 100644 --- a/src/renderer/src/components/activity/activity-pane-events.ts +++ b/src/renderer/src/components/activity/activity-pane-events.ts @@ -27,7 +27,9 @@ function historyEntrySnapshot( toolName: undefined, toolInput: undefined, lastAssistantMessage: undefined, - interrupted: history.interrupted + interrupted: history.interrupted, + // The live row's main agent belongs to its current state, not to this snapshot. + mainAgent: history.mainAgent } } diff --git a/src/renderer/src/components/activity/activity-thread-grouping.ts b/src/renderer/src/components/activity/activity-thread-grouping.ts index cab4fbcf8c3..9bf99cc67cf 100644 --- a/src/renderer/src/components/activity/activity-thread-grouping.ts +++ b/src/renderer/src/components/activity/activity-thread-grouping.ts @@ -21,11 +21,11 @@ const ACTIVITY_STATUS_GROUP_RANK: Record = { waiting: 0, blocked: 1, permission: 2, - interrupted: 3, - working: 4, - monitoring: 5, - unverifiable: 6, - failed: 7, + failed: 3, + interrupted: 4, + working: 5, + monitoring: 6, + unverifiable: 7, done: 8, idle: 9 } diff --git a/src/renderer/src/components/activity/activity-thread-presentation.ts b/src/renderer/src/components/activity/activity-thread-presentation.ts index 5541b3da493..e38c802f867 100644 --- a/src/renderer/src/components/activity/activity-thread-presentation.ts +++ b/src/renderer/src/components/activity/activity-thread-presentation.ts @@ -2,6 +2,10 @@ import type { AgentDotState } from '@/components/AgentStateDot' import { formatAgentTypeLabel } from '@/lib/agent-status' import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' import { showsAgentToolPreview } from '@/lib/agent-row-tool-preview' +import { + agentMainAgentVerdict, + agentVerdictDisplayMark +} from '../../../../shared/agent-main-agent-verdict' import { getActivityThreadTaskTitle, getActivityThreadWorkspaceTitle, @@ -68,7 +72,12 @@ export function agentTitle(event: ActivityEvent): string { return 'Agent working' } if (event.state === 'done') { - return event.entry.interrupted ? 'Agent interrupted' : 'Agent finished' + const verdict = agentMainAgentVerdict(event.entry) + return verdict === 'failure' + ? 'Agent failed' + : verdict === 'cancellation' + ? 'Agent interrupted' + : 'Agent finished' } return event.state === 'waiting' ? 'Agent waiting for input' : 'Agent needs input' } @@ -91,7 +100,12 @@ export function agentMeta(event: ActivityEvent): string { return `${agent} ${event.state}` } if (event.state === 'done') { - return event.entry.interrupted ? `${agent} interrupted` : `${agent} completed` + const verdict = agentMainAgentVerdict(event.entry) + return verdict === 'failure' + ? `${agent} failed` + : verdict === 'cancellation' + ? `${agent} interrupted` + : `${agent} completed` } return event.state === 'waiting' ? `${agent} waiting` : `${agent} blocked` } @@ -120,13 +134,18 @@ export function statusPreviewForEntry( export type ActivityThreadStatusId = AgentDotState /** Single classifier behind grouping, labels, and clear-completed; the only place the - * interrupted predicate is spelled. */ + * verdict predicate is spelled. */ export function activityThreadStatusId(thread: AgentPaneThread): ActivityThreadStatusId { + // Why: a failed main agent outranks the subagent work still holding its row live. + if (thread.currentAgentEntry && agentVerdictDisplayMark(thread.currentAgentEntry) === 'failed') { + return 'failed' + } const paneEntry = paneActivityEntry(thread) const state = threadCurrentState(thread) ?? 'done' - const interrupted = paneEntry ? paneEntry.interrupted : thread.latestEvent?.entry.interrupted - if (!thread.currentAgentState && state === 'done' && interrupted) { - return 'interrupted' + const verdictEntry = paneEntry ?? thread.latestEvent?.entry + const verdictDot = verdictEntry ? agentVerdictDisplayMark(verdictEntry) : null + if (!thread.currentAgentState && state === 'done' && verdictDot) { + return verdictDot } return state } @@ -149,7 +168,7 @@ function threadCurrentState( ) } -// Interrupted rows deliberately keep the done glyph (#2569). +// Interrupted rows deliberately keep the done glyph (#2569); a failure is a fault and does not. export function threadAgentState(thread: AgentPaneThread): AgentDotState { const id = activityThreadStatusId(thread) return id === 'interrupted' ? 'done' : id diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index fb80712e65f..4b38d8d1b0d 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -11,6 +11,7 @@ import { DashboardAgentRowToolStep } from './DashboardAgentRowToolStep' import { showsAgentToolPreview } from '@/lib/agent-row-tool-preview' import { agentNoUpdateLabel, formatCompactDuration } from '@/lib/agent-row-decay-state' import { agentRowDotState as asDotState } from '@/lib/agent-row-dot-state' +import { agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict' import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData' import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' import { useAgentRowConversationName } from './use-agent-row-conversation-name' @@ -29,7 +30,7 @@ function stateDotTooltipLabel( dotState: AgentDotState, now: number ): string { - if (agent.entry.interrupted === true) { + if (dotState === 'interrupted') { return 'Interrupted by user' } // Why: report the observation, not a verdict on the agent — the elapsed gap is what @@ -141,7 +142,8 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ const toolName = showsTool ? (agent.entry.toolName?.trim() ?? '') : '' const toolInput = showsTool ? (agent.entry.toolInput?.trim() ?? '') : '' const lastAssistantMessage = agent.entry.lastAssistantMessage?.trim() ?? '' - const isInterrupted = agent.entry.interrupted === true + const verdictDotState = agentVerdictDisplayMark(agent.entry) + const isInterrupted = verdictDotState === 'interrupted' const lineage = agent.lineage const isLineageChild = lineage?.depth === 1 const lineageChildCount = lineage?.childCount ?? 0 @@ -152,10 +154,10 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ lineageChildCount === 1 ? 'agent' : 'agents' }` : [formatAgentTypeLabel(agent.agentType), model].filter(Boolean).join(' · ') - // Why: interrupted is a terminal outcome, so surface it in the leading state dot. - const dotState: AgentDotState = isInterrupted - ? 'interrupted' - : asDotState(agent.state, agent.entry.workingMode) + // Why: a stop or a failure is a terminal outcome, so surface it in the leading state dot; a + // failure does so even while subagents still run. + const dotState: AgentDotState = + verdictDotState ?? asDotState(agent.state, agent.entry.workingMode) const dotTooltipLabel = stateDotTooltipLabel(agent, dotState, now) // Why: the elapsed gap is the whole content of an `unverifiable` row, so it rides the // row's own timestamp slot rather than hiding in a hover tooltip. diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts index 91b445dd78f..b5ee4ae239c 100644 --- a/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts @@ -51,6 +51,47 @@ describe('lastEnteredDoneAt shares the Smart Sort completion clock', () => { expect(lastEnteredDoneAt(row(entry))).toBe(2_000) expect(agentEntryCompletionAt(entry)).toBeNull() }) + + it('dates a failed turn as a completion, and a stopped one only for display', () => { + const verdictDone = (outcome: 'failure' | 'cancellation') => + doneEntry({ + stateStartedAt: 2_000, + mainAgent: { state: 'done', outcome, stateStartedAt: 2_000 } + }) + expect(agentEntryCompletionAt(verdictDone('failure'))).toBe(2_000) + expect(lastEnteredDoneAt(row(verdictDone('failure')))).toBe(2_000) + expect(agentEntryCompletionAt(verdictDone('cancellation'))).toBeNull() + expect(lastEnteredDoneAt(row(verdictDone('cancellation')))).toBe(2_000) + }) + + it('dates a main agent that failed while its subagents run by when it failed', () => { + const held = (outcome: 'failure' | 'cancellation') => + doneEntry({ + state: 'working', + stateStartedAt: 3_000, + mainAgent: { state: 'done', outcome, stateStartedAt: 2_500 } + }) + expect(agentEntryCompletionAt(held('failure'))).toBeNull() + expect(lastEnteredDoneAt(row(held('failure')))).toBe(2_500) + expect(lastEnteredDoneAt(row(held('cancellation')))).toBeNull() + }) + + it('reads the verdict history carries when a boundary displaced the completion', () => { + const history = { state: 'done' as const, prompt: '', startedAt: 1_500 } + const boundary = (outcome?: 'failure' | 'cancellation') => + doneEntry({ + sessionBoundary: true, + stateHistory: [ + { + ...history, + ...(outcome ? { mainAgent: { state: 'done', outcome, stateStartedAt: 1_500 } } : {}) + } + ] + }) + expect(agentEntryCompletionAt(boundary())).toBe(1_500) + expect(agentEntryCompletionAt(boundary('failure'))).toBe(1_500) + expect(agentEntryCompletionAt(boundary('cancellation'))).toBeNull() + }) }) describe('lastEnteredDoneAt subagent rows', () => { diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts index fe101862957..28d7a2572bb 100644 --- a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts @@ -1,4 +1,8 @@ import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time' +import { + agentTurnStoppedByUser, + agentVerdictDisplayMark +} from '../../../../shared/agent-main-agent-verdict' import type { DashboardAgentRow } from './useDashboardData' /** @@ -22,10 +26,14 @@ export function lastEnteredDoneAt( if (completedAt !== null) { return completedAt } - // Why: display is looser than ranking — an interrupted turn still shows when it stopped. - if (entry.state === 'done' && entry.interrupted === true && entry.sessionBoundary !== true) { + // Why: display is looser than ranking — a stopped turn still shows when it ended. + if (entry.state === 'done' && agentTurnStoppedByUser(entry) && entry.sessionBoundary !== true) { return entry.stateStartedAt } + // Why: a failed main agent reads failed while its subagents run, so it shows when it failed. + if (entry.state !== 'done' && entry.mainAgent && agentVerdictDisplayMark(entry) === 'failed') { + return entry.mainAgent.stateStartedAt + } for (let i = (entry.stateHistory?.length ?? 0) - 1; i >= 0; i--) { if (entry.stateHistory[i].state === 'done') { return entry.stateHistory[i].startedAt diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.test.ts b/src/renderer/src/components/dashboard/useRetainedAgents.test.ts index d1440f4e5ef..97640f2c2ae 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.test.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.test.ts @@ -68,7 +68,12 @@ function makeTab(overrides: Partial & { id: string }): TerminalTab } } -function makeAgentRow(args: { paneKey: string; state: AgentStatusState; interrupted?: boolean }) { +function makeAgentRow(args: { + paneKey: string + state: AgentStatusState + interrupted?: boolean + mainAgent?: AgentStatusEntry['mainAgent'] +}) { const entry: AgentStatusEntry = { state: args.state, prompt: 'Fix it', @@ -78,7 +83,8 @@ function makeAgentRow(args: { paneKey: string; state: AgentStatusState; interrup terminalTitle: 'Claude', stateHistory: [], agentType: 'claude', - interrupted: args.interrupted + interrupted: args.interrupted, + mainAgent: args.mainAgent } return { @@ -134,6 +140,34 @@ describe('collectRetainedAgentsOnDisappear', () => { expect(result.toRetain).toEqual([]) }) + it('retains a failed done row so the failure stays visible, but not a cancelled one', () => { + const retainedFor = (outcome: 'failure' | 'cancellation') => + collectRetainedAgentsOnDisappear({ + previousAgents: new Map([ + [ + 'tab-1:1', + { + row: makeAgentRow({ + paneKey: 'tab-1:1', + state: 'done', + mainAgent: { state: 'done', outcome, stateStartedAt: 100 } + }), + worktreeId: 'wt-1' + } + ] + ]), + currentAgents: new Map(), + retainedAgentsByPaneKey: {}, + retentionSuppressedPaneKeys: {}, + recentlyClosedAgentStatusTabIds: {}, + recentlyRetiredAgentStatusPaneKeys: {} + }).toRetain + + expect(retainedFor('failure')).toHaveLength(1) + expect(retainedFor('failure')[0]?.entry.mainAgent?.outcome).toBe('failure') + expect(retainedFor('cancellation')).toEqual([]) + }) + it('refreshes the retained snapshot when a reused paneKey starts a newer run', () => { // Why: a reused paneKey (same tab+pane, fresh agent start after a prior // retained run) produces a newer startedAt. Without the freshness check diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.ts b/src/renderer/src/components/dashboard/useRetainedAgents.ts index f0ab639e2ba..f26baaafc37 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.ts @@ -15,6 +15,7 @@ import { type AgentStatusEntry } from '../../../../shared/agent-status-types' import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { agentTurnStoppedByUser } from '../../../../shared/agent-main-agent-verdict' import { createWorktreeTabBucketProjection, @@ -299,13 +300,12 @@ export function collectRetainedAgentsOnDisappear(args: { if (args.recentlyClosedAgentStatusTabIds[ownerTabId]) { continue } - // Why: only keep a sticky snapshot when the agent finished cleanly - // (state === 'done' and not interrupted). Explicit teardown paths mark + // Why: only keep a sticky snapshot when the agent finished and the user did not + // stop it; a failure is kept so it stays visible. Explicit teardown paths mark // pane keys as suppression candidates, so a close/quit/crash cannot // resurrect a stale `done` row on the next sync. const lastState = prev.row.state - const wasInterrupted = prev.row.entry.interrupted === true - if (lastState !== 'done' || wasInterrupted) { + if (lastState !== 'done' || agentTurnStoppedByUser(prev.row.entry)) { continue } toRetain.push({ diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx index ba210d74994..4b31d0b0357 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx @@ -10,6 +10,7 @@ import { act, cleanup, render, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import type { AgentJournalTurnOutcome } from '../../../../shared/agent-session-journal-types' import type { + AgentSessionStatusEvent, AgentSessionTurnCompletion, AgentSessionTurnCompletionEvent } from '../../../../shared/agent-session-wire' @@ -27,7 +28,9 @@ type TestStore = { type BridgeMocks = { store: TestStore | null emitters: ((event: AgentSessionTurnCompletionEvent) => void)[] + statusEmitters: ((event: AgentSessionStatusEvent) => void)[] subscribeCompletions: Mock + subscribeStatus: Mock supportsCapability: Mock unsubscribe: Mock } @@ -35,7 +38,9 @@ type BridgeMocks = { const mocks = vi.hoisted(() => ({ store: null, emitters: [], + statusEmitters: [], subscribeCompletions: vi.fn(), + subscribeStatus: vi.fn(), supportsCapability: vi.fn(), unsubscribe: vi.fn() })) @@ -58,11 +63,16 @@ vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ })) vi.mock('@/runtime/structured-agent-session-client', () => ({ - subscribeStructuredAgentSessionTurnCompletions: mocks.subscribeCompletions + subscribeStructuredAgentSessionTurnCompletions: mocks.subscribeCompletions, + subscribeStructuredAgentSessionStatus: mocks.subscribeStatus })) import { StructuredAgentSessionAttentionBridge } from './StructuredAgentSessionAttentionBridge' import { resetStructuredAgentSessionTurnCompletionFeedsForTests } from '@/runtime/structured-agent-session-turn-completion-feed' +import { + getStructuredAgentSessionStatusFeed, + resetStructuredAgentSessionStatusFeedsForTests +} from '@/runtime/structured-agent-session-status-feed' import { makeTabGroup, makeUnifiedTab, @@ -176,7 +186,15 @@ describe('StructuredAgentSessionAttentionBridge', () => { } }) resetStructuredAgentSessionTurnCompletionFeedsForTests() + resetStructuredAgentSessionStatusFeedsForTests() mocks.emitters.length = 0 + mocks.statusEmitters.length = 0 + mocks.subscribeStatus.mockImplementation( + (_target: unknown, emit: (event: AgentSessionStatusEvent) => void) => { + mocks.statusEmitters.push(emit) + return Promise.resolve({ unsubscribe: vi.fn() }) + } + ) mocks.subscribeCompletions.mockImplementation( (_target: unknown, emit: (event: AgentSessionTurnCompletionEvent) => void) => { mocks.emitters.push(emit) @@ -215,6 +233,7 @@ describe('StructuredAgentSessionAttentionBridge', () => { cleanup() vi.unstubAllGlobals() resetStructuredAgentSessionTurnCompletionFeedsForTests() + resetStructuredAgentSessionStatusFeedsForTests() }) it('lights the unread indicators when the host reports a successful turn', async () => { @@ -235,14 +254,14 @@ describe('StructuredAgentSessionAttentionBridge', () => { worktreeId: WORKSPACE, paneKey: CHAT_SUBJECT, agentState: 'done', - agentInterrupted: false + agentTurnOutcome: 'success' }) }) // A settled turn is news whichever way it settled, exactly as the CLI lane treats one. The - // difference is wording, and it rides the notification flag that already says "stopped". + // difference is wording, which main picks from the verdict. it.each(['failure', 'cancellation'] as const)( - 'lights the indicators and says stopped for a %s the host reports', + 'lights the indicators and hands main the %s the host reports', async (outcome) => { render() await waitFor(() => expect(mocks.subscribeCompletions).toHaveBeenCalledOnce()) @@ -254,7 +273,7 @@ describe('StructuredAgentSessionAttentionBridge', () => { paneDot: 'agent-completion', tabDot: 'agent-completion' }) - expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentInterrupted: true }) + expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentTurnOutcome: outcome }) } ) @@ -299,6 +318,48 @@ describe('StructuredAgentSessionAttentionBridge', () => { } ) + // Remote clients receive the status and completion streams over separate sockets, unordered, so + // the wording must come from the completion alone. The mirror is set to disagree in each case. + function mirrorStatus(status: 'idle' | 'attention'): void { + mocks.statusEmitters[0]?.({ + type: 'status', + session: { + sessionId: SESSION, + workspaceId: 'host-side-workspace', + agent: 'claude', + status, + latestPrompt: 'Ship it', + updatedAt: 1 + } + }) + } + + it.each([ + { awaitingUser: true, mirror: 'idle', agentState: 'blocked' }, + { awaitingUser: undefined, mirror: 'attention', agentState: 'done' } + ] as const)( + 'words awaitingUser=$awaitingUser as $agentState whatever the status mirror says ($mirror)', + async ({ awaitingUser, mirror, agentState }) => { + const stopStatus = getStructuredAgentSessionStatusFeed({ kind: 'local' }).activate() + render() + await waitFor(() => expect(mocks.subscribeCompletions).toHaveBeenCalledOnce()) + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + const completion = turnCompletion() + + act(() => { + mirrorStatus(mirror) + hostStream()({ + type: 'completion', + completion: awaitingUser ? { ...completion, awaitingUser } : completion + }) + }) + + expect(indicators().paneDot).toBe('agent-completion') + expect(onlyDispatch()).toMatchObject({ agentState, agentTurnOutcome: 'success' }) + stopStatus() + } + ) + it('lights nothing for a turn whose outcome the host never stated', async () => { render() await waitFor(() => expect(mocks.subscribeCompletions).toHaveBeenCalledOnce()) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 92d3128d082..b0c546b3f9b 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -8,7 +8,8 @@ import { } from '../../../../shared/agent-status-child-work-projection' import { continueMainAgentStatus, - isAgentStatusHeldOpenByChildWork + isAgentStatusHeldOpenByChildWork, + mainAgentTurnInterrupted } from '../../../../shared/agent-lead-status-fold' import { mainAgentStatusEqual, agentSubagentsEqual } from '../../../../shared/agent-status-types' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' @@ -98,6 +99,8 @@ function projectStatus( state: agentStatus.state, ...(agentStatus.workingMode ? { workingMode: agentStatus.workingMode } : {}), mainAgent, + // Derived from `mainAgent`, so the equality below needs no second check of it. + interrupted: mainAgentTurnInterrupted(mainAgent), prompt: summary.latestPrompt, agentType: tab.agentSessionAgent, // The host projects these from the journal so the row reads like a hook-reported one: diff --git a/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts b/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts index 573eca9d455..76a49ff8e3b 100644 --- a/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts +++ b/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { AgentSessionTurnCompletion } from '../../../../shared/agent-session-wire' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import { createTestStore, @@ -230,7 +231,7 @@ describe('dispatchStructuredTurnCompletionAttention', () => { expect(indicators().paneDot).toBe('agent-completion') }) - it('words a successful turn as finished and a stopped one through the shipped interrupted flag', () => { + it('hands main the host verdict, which picks finished, failed or stopped', () => { dispatchStructuredTurnCompletionAttention(structuredTab(), completion()) // 'done' is the host's report that the turn settled, not a reading of the status row: main // words a 'working' state as "working", which would announce a finished turn as unfinished. @@ -238,16 +239,39 @@ describe('dispatchStructuredTurnCompletionAttention', () => { source: 'agent-task-complete', surface: 'agent-session', agentState: 'done', - agentInterrupted: false + agentTurnOutcome: 'success' }) + for (const [outcome, turnId] of [ + ['cancellation', 'turn-2'], + ['failure', 'turn-3'] + ] as const) { + dispatched.length = 0 + seed() + dispatchStructuredTurnCompletionAttention(structuredTab(), completion({ outcome, turnId })) + expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentTurnOutcome: outcome }) + } + }) + + it('calls back a send refused before any turn, named by its journal item key, as failed', () => { + dispatchStructuredTurnCompletionAttention( + structuredTab(), + completion({ outcome: 'failure', turnId: agentJournalSubmissionKey('m1') }) + ) + expect(indicators().paneDot).toBe('agent-completion') + expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentTurnOutcome: 'failure' }) + }) + + it('asks for input when the host settled a request while a prompt waits on the user', () => { + // e.g. a subagent's approval is unanswered. + dispatchStructuredTurnCompletionAttention(structuredTab(), completion({ awaitingUser: true })) + expect(indicators().paneDot).toBe('agent-completion') + expect(onlyDispatch()).toMatchObject({ agentState: 'blocked', agentTurnOutcome: 'success' }) + dispatched.length = 0 seed() - dispatchStructuredTurnCompletionAttention( - structuredTab(), - completion({ outcome: 'cancellation', turnId: 'turn-2' }) - ) - expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentInterrupted: true }) + dispatchStructuredTurnCompletionAttention(structuredTab(), completion()) + expect(onlyDispatch()).toMatchObject({ agentState: 'done' }) }) it('says done even while the status row still reads working, because the host settled the turn', () => { @@ -270,7 +294,7 @@ describe('dispatchStructuredTurnCompletionAttention', () => { } }) dispatchStructuredTurnCompletionAttention(structuredTab(), completion()) - expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentInterrupted: false }) + expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentTurnOutcome: 'success' }) }) it('delivers an id the acknowledgement round trip dismisses when the user reads the chat', () => { diff --git a/src/renderer/src/components/native-chat/structured-attention-dispatch.ts b/src/renderer/src/components/native-chat/structured-attention-dispatch.ts index f0afca80e13..4553d79b0a5 100644 --- a/src/renderer/src/components/native-chat/structured-attention-dispatch.ts +++ b/src/renderer/src/components/native-chat/structured-attention-dispatch.ts @@ -13,10 +13,11 @@ * adapter, and the same delivery tail — so suppression, acknowledgement, addressing, the success * sound and the blocked-permission fallback all have exactly one implementation. * - * EVERY SETTLED TURN NOTIFIES, matching the CLI lane: success says "finished", and failure and - * cancellation say "stopped" through the shipped `agentInterrupted` flag rather than a second - * vocabulary. A turn with no outcome is UNKNOWN — the host sends no event for one, and nothing - * here may turn that absence into success. + * EVERY SETTLED TURN NOTIFIES, matching the CLI lane: the outcome picks the wording — "finished", + * "failed" or "stopped" — exactly as the hook lane's verdict does. A turn with no outcome is UNKNOWN — the host sends no event for one, and nothing + * here may turn that absence into success. A request that settles while a prompt (a subagent's + * approval, say) waits on the user is worded "needs input" instead, as the hook lane words a + * blocked row. * * Unread and delivery come out of ONE `resolveAgentAttention` decision. "Do not alert me about * something I am watching" is already answered by focus, in the surface adapter's viewed gates and @@ -107,10 +108,10 @@ export function dispatchStructuredTurnCompletionAttention( ...(row?.agentType ? { agentType: row.agentType } : {}), // 'done' is what the host told us, not an inference from the row — the row's own state // can still read 'working' when the completion outruns the status re-projection, and - // main words a 'working' notification as "working". The outcome picks the wording from - // there: interrupted covers failure and cancellation alike. - agentState: 'done', - agentInterrupted: completion.outcome !== 'success', + // main words a 'working' notification as "working". The outcome picks the wording from there. + // `awaitingUser` is the row's 'blocked': the user has a prompt to answer. + agentState: completion.awaitingUser ? 'blocked' : 'done', + agentTurnOutcome: completion.outcome, ...(row?.prompt ? { agentPrompt: row.prompt } : {}), ...(row?.lastAssistantMessage ? { agentLastAssistantMessage: row.lastAssistantMessage } diff --git a/src/renderer/src/components/sidebar/StatusIndicator.tsx b/src/renderer/src/components/sidebar/StatusIndicator.tsx index fdce0b62f83..10f65234c6f 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.tsx +++ b/src/renderer/src/components/sidebar/StatusIndicator.tsx @@ -25,6 +25,7 @@ const AGENT_STATUS_TOOLTIP_STATUSES = new Set([ 'working', 'monitoring', 'permission', + 'failed', 'interrupted', 'done' ]) @@ -58,7 +59,7 @@ const StatusIndicator = React.memo(function StatusIndicator({