mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
fix(agent-status): a turn a crash cut off reads Interrupted, an unproven end Couldn't confirm (#23467)
* refactor(native-chat): remove the unused terminal handoff
No client ever called agentSession.requestHandoff or mounted the handoff
chrome. Delete the handoff coordinator, the terminal-owner runtime, the
proof write path and the unmounted UI. Keep agentSession.handoffStatus,
which released desktop clients read for worktree activation, and let
records an older build left mid handoff reconcile through the ordinary
restart and recovery paths.
* fix(native-chat): never let the pre-stop snapshot hold a chat's stop
Eviction now drains delivered events before quit's resume-offer snapshot. An
unbounded wait there sits ahead of the provider stop, so a sink whose journal
write stalls kept the child running until the step deadline aborted the
eviction. The offer is advisory: bound the drain and stop the child regardless.
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor(native-chat): drop helpers only the terminal handoff called
`claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and
`queryWindowsProcessRowsFresh` lost their last caller with the handoff. The
fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`,
the teardown path that still depends on that contract.
Co-Authored-By: Claude <noreply@anthropic.com>
* docs(native-chat): stop citing the removed handoff in lifecycle comments
Six comments still named the handoff coordinator, a handoff suspend, or a
terminal-owned session as live participants in the flows they describe.
Co-Authored-By: Claude <noreply@anthropic.com>
* test(native-chat): type the stalled snapshot drain without a cast
Co-Authored-By: Claude <noreply@anthropic.com>
* test(native-chat): pin that a start dead before proving owes no settlement
The removed restart handoff test pinned this branch; nothing else did.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(native-chat): keep the owner-status read behind an in-flight attach
The handoff removal dropped the per-session queue from `handoffStatus`, so a
read landing mid-start reported the reservation (no owner) instead of the
settled chat owner, and shipped desktop clients blocked worktree activation on
it. The read is queued again, as it was before the removal.
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor(terminal): remove the agent-session PTY write gate
The gate only refused a write when a PTY had been bound to a chat session, and the
only code that ever bound one was the terminal handoff this branch removes. With it
gone, every admit/readmit returned "admitted" unconditionally, so the checks on the
renderer write path, the runtime controller backstop, terminal.send, agent prompts,
preview input and orchestration pointers, the refusal fields on terminal.send and
worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane
orchestration routing could no longer run. Ordinary writes take the same path in
the same order as before.
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor(native-chat): drop the transcript helpers only the handoff called
appendLegacyTranscriptMessages fed the terminal transcript catch-up and
proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost
their last caller with the handoff. Their tests now go through the live entry
points instead: the roster bounds through the legacy import, the pinned-read and
growth tests through the ancestry replay the history window uses, and the marker
rules through the string proof in their own file rather than the session-file
resolver's.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(native-chat): stop calling a starting chat "mid-handoff"
A send refused because the chat's owner is not settled showed "The session is
mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that
reach it are a chat that is still starting, or one whose previous agent process
has not yet been confirmed stopped. The message now says which of the two it is.
The refusal code is unchanged.
Co-Authored-By: Claude <noreply@anthropic.com>
* test(native-chat): type the stand-in roster decoder without a cast
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor(codex): name the pinned rollout lookup for what it does
With the terminal handoff gone, the module named codex-tui-rollout-proof holds
only the pinned rollout lookup that structured Codex launches use to resume a
thread, so the name described code that no longer exists. Rename the module and
its options type. Also drop a mobile allowlist assertion that pinned the
removed agentSession.requestHandoff method, which no longer exists to allow.
* refactor(native-chat): type the owner-status reply as the host sends it
The handoffStatus reply type still listed the terminal handoff's fields and
states (terminal placement, host label, proof retry, queued and waiting phases,
the to-terminal direction). No host writes them any more and the only client
reader parses the reply as unknown, so they described nothing. The reply on the
wire is unchanged.
* refactor(native-chat): normalize terminal-handoff lease values once at decode
Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the
handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types
still admitted them, so readers across the host kept branches for values no
path produces and the compiler could not point at them.
The store now validates the on-disk shape, which still accepts those values so
an older record is not quarantined, and maps them once while parsing:
- `preparing` and `old-owner-stopped` become `recovering`
- a `tui` lease becomes `native`; when it records a process it also becomes
`conflicted`, the claim every build probes but never stops. A plain native
owner would be stopped by restart recovery, here and in older builds.
Revisions are taken over the normalized state on both sides of every compare,
and the mapped record reaches disk with the store's first transaction, the
same way the tab-id backfill does.
The in-memory types narrow to what this build writes, and the branches that
existed only for the removed values go. Structured-worker identity keeps its
verdict for a former terminal owner by refusing a conflicted claim rather
than a non-native kind.
* refactor(native-chat): stop threading the owner kind through a reservation
A reservation only ever names a native owner now, so the request no longer
carries a kind and the reserved lease records `native` directly. The attach
params keep `runtimeKind`: agentSession.ensure and create accept it, and the
operation fingerprint stored in the ledger covers it.
* test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else
Hiding a tab also committed the visibility index, so the no-op transaction
wrote the file even when its open-time revision was wrong. Committing the index
first leaves the pending rewrite as the only reason to write.
* fix(native-chat): name a chat write by its target, not the owner generation
A write carried the fence of the last frame the pane read, and the host refused it
unless that fence was still current. An idle release and the restart after it each
move the fence, and the release publishes nothing, so a send after a release was
refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a
cold start was refused as stale.
Every write already names what it acts on: a send its conversation, a cancel its
turn, a prompt answer its item revision, a rewind its epoch; an option is
last-writer-wins. So admission stops comparing the client's fence, and the rebase
that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it.
The writer-lease check stays, and so does the attach's compare-and-swap.
Frames now stamp the fence read when each frame is sent instead of a copy each
subscriber kept, which went stale on the same release.
* fix(native-chat): every journal append reaches the chats that are open
A journal write and its delivery to open readers were two calls, and some
writers made only the first. A failed start whose lease could not be handed
back, a provider revision with no frame behind it, and eviction's settlement
were all journaled without reaching an open chat.
A journal handle now reports every durable change, and the host's session map
binds that report to the session's readers when the handle is set. Writers no
longer publish what they append; the per-writer publish calls are deleted.
* test(native-chat): an epoch replacement reaches the open chat
* test(native-chat): each row reaches an open chat once, and a live handle enters only through the map
* test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite
The seeded record had no surface tab id, so the next open backfilled one and
that rewrite alone made the no-op transaction write. The test passed with the
legacy-lease rewrite signal removed.
* test(worktree-activation): restore the OMP surfaced-agent resume test
The handoff removal deleted it alongside the terminal-owner tests, but it
covers the surfaced-PTY block that still guards resume, including an agent
whose ownership is unknown.
* perf(native-chat): a publish behind a delivered commit reads nothing
Each commit now delivers itself, so the publish a provider frame still sends
afterwards found every reader caught up but still read rows and rebuilt the
timeline for each one. A caught-up reader now skips the read.
* test(native-chat): state why the teardown test's fake journal is safe to cast
* docs(native-chat): say mutation admission checks only the writer lease
* docs(native-chat): drop the send rebase from comments that still described it
* fix(native-chat): a message is accepted, then delivered
A send to a chat with no running agent restarted the agent inside the send
call, before the message was recorded, so the client waited for the whole
start and a failed restart refused the message. Claude held prompts sent
during startup, and those could settle as "unconfirmed".
A send is now accepted inside the session's serialized queue: one ledger row
and one submission row marked handoverRecorded, published, answered pending.
A per-session delivery loop exists while a message is queued. It starts the
agent through the same serialized attach a hold uses, waits outside the queue
for a Claude child to prove its start, and hands the oldest queued message
over as its own serialized step, writing dispatch{pending} before the adapter
call. A start it needed and did not get writes one error-tone row and rejects
every queued message with the same words; a start Stop cancelled writes none.
Settlement follows from the rows. A queued message is provably unwritten, so a
close, an eviction or an exit rejects it. A handed-over message stays in doubt.
A queued row at or below the sequence a handle found when it opened was left
by an earlier process and is rejected at open, with no latch. Stop withdraws
queued messages with no writer lease and no fence. An attach failure keeps the
conversation open, and the attach adopts its journal. Owed work counts the
loop and queued rows.
A compaction or rewind found prepared when a conversation opens was started
under a child this process no longer has, so the open settles it rather than
leaving it to refuse every send until a view attaches. The open cursor is
scoped to its epoch, because sequences restart when an epoch is replaced.
Deleted: restart-before-admission, recordFailedRestart, the fence rebase,
Claude's startup gate, the attach's forget on failure and its own crash
boundary. Clients without agent-session.accepted-send.v1 get their reply held
until the handover; the desktop and paired desktop lists advertise it.
* fix(native-chat): settle queued messages only for the child that ended
A child that proved its start and then exited before its message was handed
over left the message queued: the exit settlement returned early when nothing
else was in flight. Delivery then started another child for it, and a child
that died the same way started another, without end and without a row.
A retried settlement for an earlier generation, run by the attach that
delivery started, did the opposite: with that generation's turn unfinished it
rejected the message queued for the child being attached.
The settlement now takes the rejection for queued messages from its caller.
The unexpected exit and the eviction pass one, and it applies even with no
other work in flight; the retry for an earlier generation passes none.
* fix(native-chat): an adoption that fails to import keeps the conversation open
The attach now writes into the conversation's own open journal, but a failed
transcript import still closed it as if it were the attach's provisional one.
The conversation stayed indexed with a closed journal, so every later send
answered "could not be recorded" and every attach failed again until the app
restarted. The import now closes only a journal the attach opened for itself.
* perf(native-chat): the recovering open reads the journal once
Every conversation open now goes through the recovering open, including the
read restore of every chat at startup, which used to replay its journal once.
The recovering open replayed it twice: once to probe it and again inside the
open. The probe is now handed to the open as its load.
* fix(native-chat): an attach that fails after indexing its child leaves no child behind
A failed attach now keeps the conversation open, but a failure after
`onAttached` indexed the child (the rewind or compaction recovery, or the
attach's own success record) left that entry claiming a child the failure
path had already released. The next send found the phantom, skipped the start,
and wrote at a fence the journal had moved past, so the message stayed queued
for good. The entry now drops the released child and its event sink, and
follows the record's fence, as a failure before indexing already did.
* fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer
The error strip for a message the host accepted and then did not deliver matched the entry before
the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not
send your message" with nothing to retry. It now reads the reconciled entry.
A rejection the journal records before the send's own pending answer lands is final as well:
that answer no longer puts the entry back to dispatching with no Retry.
* fix(orchestration): a structured worker whose agent outlasts the preamble wait is left unknown, not torn down
The preamble waits for its submission to be delivered while the worker's agent starts. When that
wait ran out it threw operation_unknown, and the failed-start teardown then closed the session,
which rejected the very preamble the host was about to deliver. It now reports a turn start
nobody observed yet: the worker is start-unknown with its session kept, the host delivers the
preamble when the agent starts, and the worker's report settles the dispatch as for any
unobserved start. The receipt no longer suggests reading a screen a structured worker lacks.
* fix(native-chat): a message rejected while its chat was closed reads as not sent
A remount reads an entry it left dispatching as unconfirmed. When the journal had rejected it
meanwhile, as a failed start or a quit now does, the reconcile left it unconfirmed: it blocked
every later message behind a Retry and no reason, and the delivery probe, seeing the journal
already answered, never ran. The reconcile now settles it as rejected like a dispatching one.
* test(orchestration): name why the readiness settlement fakes are cast
* fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent
* docs(native-chat): drop the fence from the admission the send effects run behind
* docs(native-chat): give the fence move on release the reason that still holds
* docs(native-chat): stop citing a write fence check in launch and mailbox comments
Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease.
* refactor(native-chat): the provider child is its own record
A conversation now outlives any number of provider children, so the child is one record on the
conversation's entry instead of five loose fields beside its journal. It is written in one place:
indexed only once an attach has fully succeeded, and ended through one function that an exit, a
failed re-attach, a Stop and an eviction all share, matched on the child's generation and fence.
- A failed attach writes no child, so there is nothing to unwind: the field unwind and the fence
patch after it are gone.
- Conversation writes read the record's fence, the way mutation admission already does; a child's
own writes use its fence. The four stored-fence patches, and the settlement retry's overwrite of
the conversation's fence, are gone.
- The owed wind-down is its own tombstone, carrying the child it is owed for, and is no longer
dropped when an attach replaced the whole entry.
- Stop on a child still proving its start stops only the child: its lease goes back and the chat
is told it is idle, but the journal, the holders and the readers stay. Close is that stop plus
the conversation's close.
- The settlement retry uses the conversation's own journal, opened through the host's one open.
* fix(native-chat): the delivery loop alone settles a message its start or child failed
A queued message was settled by whichever path happened to end the child first: the loop, the
unexpected exit, eviction's work settlement, the open's leftover rule, and the startup branch that
rejected every pending row. That gave two failure rows with different tones for one start, a loop
that could hand over to a different child than the one it waited on, and a Claude start that died
while starting reading unlike every other failed start.
- The loop remembers the child it waited on. At handover, if that child is gone or replaced, it
reads how it ended: a Stop continues; anything else writes one failure row and rejects every
queued message with the same words, then stops. A child still starting whose start the adapter
says did not land fails the same way. The exit, eviction and the settlement retry only settle
the handed-over and legacy rows of the child that ended.
- One failure row, always an error, keyed by the start. A start a view began that dies with
nothing queued writes the same row through the same builder, so a second report revises it.
- The open no longer rejects leftovers; the loop's first step does, and the open wakes it.
- `awaitStarted` answers why a start did not land, so the row says it even when the loop sees the
failure before the exit is processed.
- Quit closes every conversation the way closing a chat does: what is still queued is rejected as
closed, with or without a child, and a start the loop already has in flight is waited for so the
child it produces is stopped rather than left behind.
* refactor(native-chat): a stopped child ends on the one reading of its stop
The eviction step reads a stop's result through `stopAgentSessionProviderRoot` and hands that
verdict to the child's ending, so the host never forms a second view of whether the root is gone.
Every ending carries it: a stop's comes from that reading, an exit's root is gone by definition,
and a failed re-attach passes what its release saw. The end-of-child record can therefore also
carry a stop whose root was not seen to go, which nothing ends on yet.
* feat(native-chat): the host says it accepts a send before any agent has it
The host now lists agent-session.accepted-send.v1 among its own runtime capabilities, the same
string capable clients already send. A client can then tell a host that answers a send at
acceptance, and admits a Stop with no writer before a turn starts, from an older one that still
restarts the agent inside the send. Additive: an older client ignores a capability it does not
know.
* refactor(native-chat): an attach never opens a journal of its own
The attach adopts the conversation's open journal, which outlives it, so it no longer opens one
for a direct caller either. That leaves nothing for a failed adopted import to close, and the flag
that told the two cases apart is gone. Tests that attach without a host open the conversation the
way a host does.
* fix(native-chat): a moved fence resends nothing on a host that accepts first
The outbox treated any fence change as a new owner: it dropped the answer of a send in flight,
queued that send to go out again under the same id, and unblocked a refused head. On an older
host that is how a send the restart refused, unrecorded, gets another try. On a host that records
every send before it starts an agent, a fence moves because that start ran, so the same rule
resent into every failed start. With a fence stamped on every frame, that became a loop.
The outbox now reacts to a fence change only when the host has not advertised that it accepts a
send before any agent has it. On such a host, only a Retry or a new send goes out, and a failed
start reaches the client as a rejected message it keeps with its Retry. Against an older host, or
before one has answered, the outbox behaves as it did. Desktop and paired web share this hook.
* refactor(native-chat): a child's end says whether the user or the host stopped it
The end-of-child record's cause now tells a user's Stop from the host stopping the child for a
cause of its own: `user-stop` and `host-stop` replace `stop`. The delivery loop goes on after a
user's Stop, as before, and fails the start it was waiting on after a host stop, with the one
error row and every queued message rejected, in the stop's reason when it gave one. The reason
stays description only. Stop passes `user-stop`; nothing passes `host-stop` yet.
* fix(native-chat): a chat whose only work is a queued message is not offered for resume
A message accepted while the agent was starting counts as working in the chat, and quit rejects it
as never sent. The teardown snapshot read the same working rule, so a relaunch offered to resume a
chat whose agent never had the message. The snapshot now reads only what was handed over.
* test(native-chat): type the queued-message fixtures in the resume-offer tests
* fix(native-chat): a start that dies while a message waits on it is that message's failed start
Opening a chat's tab starts an agent for the view, and a send accepted meanwhile waits on it. When
that start died, its exit wrote the start's error row and left the message queued, so the delivery
loop started a second agent into the same failure and wrote a second row. A child's end now records
where the conversation's journal stood, and the loop settles a message accepted before a failed
start ended with that start: one row, under its key, and no second start. A message sent after the
failure still gets a fresh start.
* fix(native-chat): a request that failed reads as failed
A structured chat whose only message the agent's start refused read as a
green finish, and a cancelled structured turn did too: the host published a
verdict only for turn records, and structured rows carried no `interrupted`.
The host projection now reads the session's latest request: its turn's
outcome, or `failure` for a send the agent or its start refused. A send
that was withdrawn, or left undelivered by a restart or a close, fails
nobody and makes nothing listable. The ingest publishes `interrupted` as the
hook lanes do, and every reader decodes the verdict through one accessor, so
a failure reads Failed on the dot, the rollups, history and `worktree ps`,
behaves like a cancellation in every clean-finish policy, and notifies as
"failed".
* docs(native-chat): say what an attach's open conversation and unconfirmed ids are now
* test(native-chat): a verdict change republishes the mobile status projection
* refactor(native-chat): the store's retention trigger keeps its flag compare
A verdict change always moves the completion clock the same check already
reads, so a second verdict compare there caught nothing new.
* test(native-chat): a user message the provider journaled keeps its session listed
* test(native-chat): pin what a failed start settles, and what a resume offer names
A view's child that dies while a sent message waits settles that message only when it died starting
and no child has taken its place: a proven child's crash, or a second start since, gets the message
delivered. The resume offer names the handed-over message, never a newer one still queued.
* test(native-chat): the failed-start pins fail on what the message became, not on a timeout
* fix(native-chat): a late provider-session update keeps a failed recovery record failed
A provider-session heartbeat that rewrites a completed recovery record kept
its interrupted flag but dropped the outcome it was copied with, so a live
failed checkpoint read as a clean finish until the next status write.
* test(orchestration): the preamble's host stub is typed, not cast
The preamble send now takes only what it reads of the host, the send, the settlement wait and the
record's fence, so its test builds that host with real types instead of `as never`.
* test(native-chat): the terminal-bell check asserts the renamed verdict field
The bell notification test still checked for agentInterrupted, which no
longer exists, so it could not catch a verdict leaking into a bell dispatch.
* fix(native-chat): a failed turn ranks like a completion for attention
Attention readers (completion time, Smart Sort, sticky retention, Cmd+J
Recent) now demote only a turn the user stopped. A failure is news the
user has not seen, so it keeps its completion time, ranks in the Done
class, stays retained after its pane goes away, and a retained failure
reads failed in the worktree rollup instead of done. Clean-finish
policy (hibernation, pane ownership, the value moment) still treats a
failure like a stop.
The retention trigger compares verdicts again: success -> failure no
longer moves the completion clock.
* fix(native-chat): a failed main agent reads failed while its subagents still work
The verdict is now read from the main agent's own state, not the folded
row: a main agent that is done and failed has a verdict even while its
subagents keep the row working. Without mainAgent (history, worktree ps,
older hosts) the old combined-done rule stands.
Display marks the verdict through agentVerdictDisplayMark: a failure
outranks every combined state on the agent's dot, label, tab badge,
dashboard and activity rows; a stop marks only a done row, so a
successful or stopped main agent with live subagents still reads
working. Subagent rows keep their own state. The worktree card, terminal
tab and Cmd+J rollups share one pane fold and rank a pending question,
then failed, then working, monitoring, interrupted and done.
worktree ps publishes the main agent's outcome on a working row, and the
mobile mirror reads it. The store's change check, the paired-client
mirror's equality and its epoch now see a verdict change on a working
row, which otherwise moves no state or clock and left the worktree card
reading working. Clean-finish policy is unchanged: a working row is never
hibernated and has no completion time.
* docs(native-chat): the worktree ps outcome comment no longer claims old hosts send it
The field is new: an old host sends no outcome at all, so a reader falls
back to interrupted. The removed clause said old hosts send it on done
rows, which never shipped.
* docs(native-chat): the status-store listing rule names provider-journaled user messages
* fix(native-chat): a refused send notifies failed through the completion feed
The host's completion feed followed only the newest turn, so a send the
agent or its start refused, which creates no turn, read Failed on its row
but sent no notification. The feed now follows the session's latest
request, read from the projection the status feed already makes for the
commit: a turn keeps its id, a refused send is named by its journal item
key. It announces only while the session is idle, as the row reports a
verdict, so queued sends refused one commit at a time notify once, and a
withdrawn send falls back to a request already announced.
* fix(native-chat): every copy of a row carries the main agent's own status
History entries, sleep records and `worktree ps` rows carried a flattened
top-level `outcome`, copied under different gates and without the main agent's
clock. They now carry `mainAgent` (state, outcome, stateStartedAt), the type
the live row already persists and sends, and every copy site takes it with
`interrupted` through one function, `agentVerdictFields`.
- The accessor reads `mainAgent` then the legacy flag; the mobile mirror
matches it line for line.
- Sleep records admit `mainAgent` with `normalizeMainAgentStatusField`, so a
malformed value drops the field, never the record.
- Mobile dates a main agent that failed under live subagents by its own clock,
as desktop does, and its row equality compares `mainAgent`.
- The activity feed reads a history entry's own `mainAgent` instead of
rebuilding one; the sync key and history equality compare it.
* test(native-chat): pin the worktree ps verdict across host and phone versions
Pairs the real v1.4.212 host and phone row reader with this build: an old phone
reads a new host's rows by `interrupted`, a new phone reads an old host's rows
(no `mainAgent`) the same way, and a new phone reads a failure under live
subagents as Failed, dated by `mainAgent.stateStartedAt`. The release checkout
now carries the phone's self-contained row reader, and the lane runs when the
`worktree ps` row producers change.
* test(mobile): name the parity table's row for its role
* fix(native-chat): a request that settles while the user is asked something notifies once
The completion edge waited for an idle session, and a pending prompt (including a
subagent's approval) is not idle. Structured chat has no other attention producer,
so a main turn that finished while a subagent waited on the user sent nothing
until the prompt was answered.
The edge now waits only on owed work (a running turn or an unanswered send), which
the projection reports even beneath a pending prompt. A request that settles with
a prompt pending announces once; the renderer words it "needs input" from the
host status mirror's `attention`, and answering the prompt keeps the same request
identity, so it does not announce again. The wire shape is unchanged.
* fix(native-chat): the completion says when the user is being asked
A request that settles while a prompt waits on the user was worded "needs input"
from the renderer's status-feed mirror. Remote clients receive the status and
completion streams over separate sockets, so they can arrive in either order and
the wording could be wrong both ways.
The host already knows at emit time, so the completion now carries an optional
`awaitingUser: true` in that case and omits it otherwise. The renderer words the
notification from that field alone and no longer reads the status mirror. Old
clients ignore the field and word by outcome; old hosts never send it.
* fix(worktree-status): a departed agent's failure yields to live work on the worktree card
A retained failed agent has no expiry, so ranking it with a live failure pinned the card to Failed over other panes' live work. It now ranks below working, monitoring and permission, and above every finished outcome.
* docs(agent-status): a departed agent's failure ranks below live work on the worktree card
* fix(native-chat): a view never restarts a chat whose last start failed
A Claude chat whose CLI exits during startup left one red row per start, and
every time a view bound to it (the chat opening right after its create died,
or the user switching back to it) the hold started the CLI again, so the same
launch-failure row repeated. Only a send retries a failed start now, the same
rule provider-exit recovery already applied; the rule lives in one predicate
the hold, exit recovery and the delivery loop share.
* test(native-chat): start the child the loop waits on with an attach, not a second view
A view no longer starts a child whose last start failed, so the R2 case that
waits on a child started since the failure now gets that child from a client
attach, the one non-send starter left.
* fix(native-chat): settle a gone generation's turn wherever a conversation opens
A send that opens a chat this process had not read yet (after a crash, from a
phone or the CLI) went through the delivery open, which never settled what the
dead generation left running; only the read restore and a successful acquire
did. When the send's start then failed, the turn stayed running for every
reader. The settlement now runs in the one journal open, at the crash boundary,
for every opener except an acquisition, which settles from the evidence it read
before its reserve; the read restore's separate step is gone.
* test(native-chat): prove the next child's start settles the turn an earlier child left
The R1 case lost its only settlement assertion when the latch it checked was
deleted. It now seeds the running turn the earlier child left and asserts it
ends at the exit's receipt, with the exit's row, before the message is handed
to the new child.
* test(native-chat): count a failed start's rows by row, not by text
Comparing the set of texts passed when two different rows carried the same
words, which is the duplicate the test exists to catch.
* test(cross-version): load the phone row readers without mobile's toolchain
Vite transforms a file against its nearest tsconfig, and mobile/tsconfig.json
extends expo/tsconfig.base.json, which the root-only cross-version lane never
installs. The worktree ps verdict suite imported the current phone row reader
from mobile/ directly, so CI failed with TSConfckParseError before any test ran.
The harness now imports a copy of the working-tree reader placed under the
checkout cache, where the root tsconfig applies, as it already does for the
release checkout's copy. Both readers are still the real files.
* test(cross-version): keep the checkout path-guard message and justify the copy import's cast
* test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget
* test(native-chat): pin the open's and the send's start and row counts, however the view binds
Opening a fresh chat whose starts fail makes one start and one row, with two
views bound before or after the create's child died; one send makes one more
of each.
* fix(agent-status): a turn a crash cut off reads Interrupted, an unproven end Couldn't confirm
When the provider gave no verdict, the structured status projection now derives
one from the newest turn's lifecycle: interrupted -> interruption, unverifiable ->
unconfirmed. Nothing new is journaled, the completion feed stays provider-only, and
the legacy interrupted flag stays a user stop only. Every verdict reader handles
both arms explicitly.
* fix(native-chat): settle a gone generation's turn at every open but an acquisition's
The journal open skipped the settlement whenever the lease read reserved or
live, to leave an acquisition's own open to the acquisition. But a lease a
crashed process left in recovery also reads live, until the next acquire
resolves it. A send that opened such a chat, from a phone or the CLI after a
crash on a host that could not prove the old owner gone, skipped the
settlement; when its start then failed, the dead turn stayed running for every
reader. The acquisition now says it is the opener, and every other open
settles, whatever the lease still claims.
* fix(native-chat): a folded turn a crash cut off reads Interrupted after N
The settled-turn timing now carries the turn's verdict, derived by the same
agentTurnVerdict the status row uses. A turn that ended interrupted with no
provider verdict heads its fold 'Interrupted after N'; a user's stop keeps
'Worked for N'.
* test(native-chat): hold the create's start open until the views bind
The "view binds while the create is still starting" case gave the create a
300 ms head start and asserted the views bound before it died. On a loaded
runner the holds took longer, the create's exit landed first, and the case
failed its own precondition. The create's initialize now waits on a gate the
test releases once the views are bound.
* fix(native-chat): the user's close of a chat records the turn it cuts short as their cancellation
The expected-close settle writes outcome cancellation when the user aimed the
stop at this chat: a Stop while the agent starts, the chat's tab closed (the
agentSession.close RPC, or session.tabs.close with a user reason), or /clear.
A quit, an idle eviction, a worktree teardown or an orchestration stop leaves the
turn with no verdict, so it still reads Interrupted.
* test(native-chat): a Claude turn a newer send superseded reads Interrupted
The supersede fires for any send Orca dispatched, the user's or another agent's,
and nothing at that site records the sender, so the turn keeps no verdict and
folds as Interrupted after N.
* fix(native-chat): the user's close records cancellation on the turn the provider settled on its way out
The Codex and Claude adapters settle their open turn as interrupted, with no
verdict, while the host stops them. The expected-close settle then found no
running turn, so a user's close of a mid-turn chat read Interrupted. The stop
now reads the running turns before it reaches the provider and records the
user's cancellation on each one it cut short, unless the provider gave a
verdict of its own. The close-verdict test's adapter now settles its turn on
close the way the real adapters do.
* test(native-chat): update the close and settled-turn expectations for the host-observed verdict
agentSession.close now passes the user's word to the host, and a settled
interrupted turn with no provider verdict carries `interruption`. Also merge a
duplicate import the code-quality gate rejects.
* refactor(native-chat): drop the composer's second error formatter
After the merge with main, every chat write in the composer path reports its
failure as a typed outcome worded by the refusal-notice table, so the send's
catch sees only a local throw. The {code, message} formatter this branch added
for it has no payload left to format, and its claim to be the one way a chat
words a failure is no longer true. The composer send is main's again.
* test(native-chat): pin the reason on a message rejected while its chat was closed
The reopen test checked only that the message reads as not sent; it now also
checks the Retry row carries the host's reason.
* fix(native-chat): the user's close cancels a turn whose start landed as the provider stopped
The close read which turns were running before the stop. A turn whose start was
still in flight (a send echo not yet journaled) was absent from that read, so the
provider's verdict-less settle on the way out left it Interrupted. The close now
reads which turns were already over instead, and records the user's cancellation
on every other turn the stop left running or interrupted with no verdict. A turn
cut off earlier, or finished during the stop, keeps its end.
* fix(native-chat): a send the provider never received after a restart has no verdict
Restart reconciliation rejects a crash-stranded send that is absent from a
trustworthy provider history with reason 'not_delivered'. Nobody failed that
send, but the verdict allowlist did not name it, so after a crash the chat
read Failed, was listed, and could notify "failed". Give the reason a shared
constant (persisted value unchanged), add it to the no-verdict set, and treat
it as an internal marker so the Retry row no longer shows the raw string.
* refactor(native-chat): the adapter settles the turn a stop cuts with the stop's typed cause
The host hands its stop's cause to the adapter's close. Each adapter settles its own
open turn on 'ended' through one mapping, turnVerdictForChildEnd, and the host's
dead-generation fallback uses the same mapping for any turn no adapter settled. A
user's close or stop of this chat is their cancellation; a quit, eviction, teardown
or an exit the adapter saw first is news.
Deletes the snapshot-and-diff reconstruction (endedTurnItemIds,
userStoppedTurnRevisions, settleUserStoppedTurns) and the requestedByUser flag.
host.close now takes a required cause.
* test(native-chat): a user's close drops the chat's status row like an eviction
* test(native-chat): expect the eviction cause on the host closes of idle release, worker stop and worker discard
The stop's typed cause now travels into host.close and the adapter's close, so these
three non-user closes assert the 'evict' they pass.
* refactor(native-chat): every stop names its cause, so none defaults to the user's cancellation
stopStructuredAgentSessionAgentUnderSerialize defaulted its ending to 'user-stop', which now
settles the cut turn as the user's cancellation. Every caller already passes a cause; the
parameter is now required, and a type-level test fails to compile if the default returns.
* test(native-chat): pin who a chat's session.tabs.close speaks for, older clients' reasonless close included
The mapping lived inline in a type-unchecked file, and only the explicit user reason had a test: an older client's reasonless close, or a lifecycle echo read as the user's, stayed green. It is now one exhaustive, type-checked function with a case per reason.
* style(mobile): draw the unconfirmed dot in the theme's status amber, not an inline hex
* docs(agent-status): the main agent's outcome also carries the host-observed end, interruption or unconfirmed
* fix(native-chat): a chat the user closed while its agent started is not a failed start
A still-starting child the user's close cut counted as a failed start, since only 'user-stop' was excluded: a start-failure row, and queued messages rejected as a provider failure. Whether an ending fails its start is now one exhaustive switch, shared by the failed-start read and the delivery loop's handover: a user's stop or close never does; an exit, a failed attach and the host's own stops still do.
* fix(native-chat): a chat the user closed closes its queued messages, and starts no agent for them
After 876b6989f1 a user's close of a still-starting chat went on like a Stop, so when the close did not complete the delivery loop started a new agent for the message queued behind it. A child's end now has three dispositions, not a failed-start boolean: a user's Stop lets the queue go on, the user's close closes what was queued before it, and any other end fails it. The close is the one a completed close does (the provider-closed rejection, no verdict), applied at the top of each delivery step and ordered against the close so a later send still goes on.
* fix(activity): a crash-cut turn draws the interrupted glyph; only a user's Stop keeps the done check
The Activity page drew every Interrupted row with the done check, which #2569 chose for a user's Stop. With a crash now reading Interrupted, that put a green check on a turn nobody asked to stop. The row's glyph is now an exhaustive switch over the verdict: a cancellation keeps the done check, and an interruption draws the existing interrupted dot. An unconfirmed end already drew its own glyph.
* fix(activity): the Interrupted group header draws the done check only when every row is a user's Stop
A user's Stop and a crash share the Interrupted group, and its header took its newest row's glyph, so a Stop newer than a crash put a green check over the crash. The header is now folded over the group's rows: the done check only when every row draws it, the interrupted dot otherwise.
* fix(native-chat): a failed close of what the user closed starts no agent for it
Rejecting the messages a user's close left queued swallowed a journal write failure, so the delivery step went on to start an agent for a message in a chat the user closed. The rejection now reports whether it landed, and a step whose rejection failed stops instead; the next wake re-derives and retries it. Also pins that the ordering against the close holds only within its epoch, since a later epoch's sequences restart.
* test(native-chat): the idle sweep's stop is an eviction, so its close carries that cause
Main's idle sweep now stops an idle agent through the conversation lifetime, which this branch gives the 'evict' cause; its expectations name it.
* fix(native-chat): a retried stop keeps the cause of the stop it finishes
A user's Stop or close whose wind-down failed after the child was proven gone was finished by the idle sweep as an eviction, so the turn it cut read Interrupted. The owed wind-down now carries its stop's cause, and a retry with no child settles with it.
* test(native-chat): the idle sweep's close of a retrying Claude chat carries the eviction cause
Main's new test expected the adapter close with the session id alone; every stop now names its cause, and the idle sweep's is 'evict'.
* fix(status): a user's Stop marks done on the tab and sidebar; red Interrupted is only a turn cut short by something else
The tab, the worktree card and the sidebar rows drew a Stop with the same red dot as a crash. The
verdict mark now maps a cancellation to done, still saying "Interrupted by user" in the row text,
and the mobile mirror follows. The Activity page keeps grouping a Stop under Interrupted with the
done check, as before.
* test(cross-version): a new phone reads a user's Stop as done; an old phone still draws it interrupted
* fix(native-chat): a user's Stop inside a live Claude chat reads as their cancellation
Stopping a running Claude turn interrupts it and keeps the session, so the turn's end comes from
the CLI's result frame. Claude CLIs before 2.1.91 send that frame with no terminal_reason, and later
ones may still omit it, so the user's own Stop was recorded as a failure with an error row.
Orca now records the stop on the open turn when it sends the interrupt. An error result for that
turn reads as the user's cancellation whatever reason the CLI gives. The stop belongs to that one
turn, so it cannot reach the next, and it is withdrawn when the CLI refuses the interrupt.
* docs(agent-status): a user's stop marks done; name the tab close cause by its type
The reference still said a stop marks a row interrupted and ranks between live work and an
unconfirmed end. A cancellation now marks done, and only a turn cut short by something else ranks
as interrupted. The runtime's tab close restated the close cause's union; it now uses the type.
* test(native-chat): a proven crash reads as an interruption on the status feed and in the chat
A crash the relaunch proves now settles its turn interrupted, and the status feed works the verdict
out from that record, so the restart test expects interruption for a proven crash and unconfirmed
for one it cannot prove, never a cancellation. A chat read before the proof lands reports
unconfirmed, then interruption and a folded "Interrupted after 27s" once the proof revises it.
* fix(status): a user's Stop reads Interrupted, and a turn anything else cut short reads Failed
The verdict mark now maps a cancellation, the user's own Stop, to interrupted, and an interruption,
a turn cut short by a crash or a killed agent, to failed, the same as a failure, which outranks live
subagent work. An unconfirmed end is unchanged. This applies to every agent, in a terminal or a chat,
on the tab, the sidebar rows and worktree card, the dashboard row, Cmd+J and the phone. A Stop is
not news, so the rollups rank it below an unconfirmed end, and notifications word an interruption
"failed". Recording is unchanged.
* fix(activity): group a user's Stop under Interrupted and a crash with failures
A user's Stop draws the interrupted glyph and sits alone in Interrupted, and a turn anything else cut
short sits in Failed, titled "Agent failed". Every row in a status group now draws the group's own
glyph, so the header is the group's status and the rule that folded a Stop's done check into the
header is gone. Interrupted ranks below an unconfirmed end, as in the sidebar.
* fix(status): draw a user's Stop in the muted tone, not the fault red
The interrupted dot, which now means only a user's Stop, draws in the muted foreground token on the
agent rows, the sidebar card and the phone. Red stays for a failure or a turn cut short by anything
else, and green for a finish.
* fix(native-chat): fold a stopped turn as "Interrupted after N" and a failed or crash-cut one as "Failed after N"
The settled turn header now follows the verdict mark: a user's Stop reads "Interrupted after N", and
a failure or a turn anything else cut short reads "Failed after N", under the new key
components.native-chat.status.failedAfter in all six catalogs and the boot catalog. Desktop and
phone share the one description, so they agree.
* docs(agent-status): describe the Interrupted and Failed marks
The reference and the phone's turn bar still described a user's stop as done and a crash as
interrupted. A fault now reads failed, a user's stop reads interrupted in the muted tone, and the
rollups rank an unconfirmed end above a stop.
* test(status): a crash the relaunch recovers marks failed
The recovery test still expected a recovered interruption to mark interrupted; it now marks failed,
as a failure does. Formatting only elsewhere.
* fix(native-chat): record a turn a newer request replaced as superseded, and show it Interrupted
A Claude turn that a newer send replaced before its result arrived was recorded as interrupted with
no verdict, which reads as a turn cut short by something else, now "Failed". It is now recorded with
its own outcome, `superseded`, where the replacement is detected. That outcome names no sender, so a
dispatch from another agent is never recorded as the user's Stop, and it sets no legacy flag.
Every reader handles it in an exhaustive switch: it draws the muted Interrupted mark with the plain
text "Interrupted", folds as "Interrupted after N", and attention demotes it with a Stop, through
the renamed agentTurnEndedOnRequest. Older builds read an arm they do not know as no verdict, which
is what this turn carried before, so their rows keep reading done; the cross-version suites pin an
older desktop's journal and status readers and an older phone.
* refactor(status): name the attention predicate for a turn ended on purpose
agentTurnEndedOnRequest becomes agentTurnEndedOnPurpose: a user's Stop or a newer request's
replacement, never a fault. The Claude turn-end comment no longer says a replaced turn carries no
verdict.
* test(native-chat): a turn cut off by a restart or by quitting Orca reads Failed after N
On main a restart-cut turn shows the done tick. Pin the chat's turn bar and
the tab's mark for both cuts, through the recovery settlement and the quit's
child-end mapping, and pin the quit's turn bar through the host's own quit.
* style(native-chat): format the superseded turn-bar expectations
* fix(claude): a Stop that names no turn is the user's stop of the open turn
The chat's Stop button names no turn. Claude's conversation Stop recorded the
user's stop only for a named turn, so an older CLI's error result after that
Stop read Failed. It now records it on the open turn through the same intent,
dropped when Claude refuses the interrupt and never carried to the next turn.
* fix(native-chat): keep the attach context's publishStatus required
The lifetime context type makes publishStatus optional, so the attach context
that spreads it no longer satisfied its own type once its duplicate
publishStatus went. The host's lifetime context is now inferred, and checked
with satisfies, so the spread carries the member it always sets.
* fix(activity): rank a user's Stop below live work in the status grouping
The Activity page's status grouping put the Interrupted group (a user's Stop, or a
turn a newer request replaced) above Working and Monitoring, so a Stop still sorted
like news there while the sidebar, worktree card and Cmd+J rank it below live work.
It now follows live work and stays above Done; Failed and Couldn't confirm keep
their places above live work.
* docs(agent-status): say which turn outcomes the journal records and which are derived
The journal now records superseded as well as the provider's verdict and a stop;
interruption and unconfirmed are derived on read. The resume row no longer claims
interrupted renders red.
* test(native-chat): the idle sweep's held-send rest closes with the evict cause, like its siblings
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -112,7 +112,10 @@ its start refused; a send that was withdrawn, or left undelivered by a
|
||||
restart or a close, fails nobody and makes nothing listable.
|
||||
`summary.turnOutcome` is the latest request's verdict: its turn's outcome, or
|
||||
`failure` for a send the agent or its start refused (a send that joined a running
|
||||
turn is answered by that turn). The row also publishes `interrupted` from
|
||||
turn is answered by that turn). A turn the provider gave no outcome reads as its
|
||||
host-observed end through `agentTurnVerdict`: `interruption` for an `interrupted`
|
||||
lifecycle, `unconfirmed` for an `unverifiable` one. It is derived on each read,
|
||||
never journaled. The row also publishes `interrupted` from
|
||||
`mainAgent.outcome`, exactly as the hook lanes do. When the host revokes live ownership the row is re-set
|
||||
without the flag; when the host closes or evicts the session the row is
|
||||
dropped. Both already exist as feed events (`revokeLive` and the roster
|
||||
@@ -202,7 +205,7 @@ Claude, Codex and Grok hook rows and structured-session rows publish the combine
|
||||
rows and terminal-title-only rows carry none, and readers fall back to `state`:
|
||||
|
||||
```ts
|
||||
mainAgent?: { state: AgentStatusState; outcome?: AgentJournalTurnOutcome; stateStartedAt: number }
|
||||
mainAgent?: { state: AgentStatusState; outcome?: AgentTurnOutcome; stateStartedAt: number }
|
||||
```
|
||||
|
||||
`state` still answers "what should the user see" and folds live child work in,
|
||||
@@ -215,8 +218,12 @@ works (including a child's permission wait) refuses OSC, which carries no child
|
||||
identity; the children's own lifecycle hooks settle it. `outcome` is the recorded verdict on
|
||||
the main agent's most recent finished turn, present only while `mainAgent.state` is
|
||||
`done`. It is reported by the provider, or is a `cancellation` Orca inferred
|
||||
from the user's own interrupt keystroke (the journal's turn outcome, by
|
||||
contrast, is never inferred). A plain end of turn carries none, because absent
|
||||
from the user's own interrupt keystroke, or a `superseded` the host recorded when
|
||||
a newer request replaced a structured Claude turn before it ended (it names no
|
||||
sender, and sets no legacy flag), or, on a structured row whose turn the
|
||||
provider gave no verdict, is what the host observed of its end: `interruption`
|
||||
(a proven death nobody asked for) or `unconfirmed` (an end it cannot prove,
|
||||
never success). The journal's turn outcome, by contrast, stores only recorded verdicts: the provider's, a `cancellation`, or the host's `superseded`; `interruption` and `unconfirmed` are derived from the turn's lifecycle state and never stored. A plain end of turn carries none, because absent
|
||||
means unknown and a provider that omits its interrupt flag must not turn a
|
||||
cancel into a success.
|
||||
In the Claude hook lane the cancellation comes primarily from Orca's own
|
||||
@@ -235,21 +242,31 @@ the verdict through `agentVerdictFields`, which carries `interrupted` and the
|
||||
whole `mainAgent` (state, outcome and its own clock) together, so a copy agrees
|
||||
with the row and can date a failure by `mainAgent.stateStartedAt`.
|
||||
|
||||
Display reads the verdict through `agentVerdictDisplayMark`: a failure marks the
|
||||
Display reads the verdict through `agentVerdictDisplayMark`. A fault marks the
|
||||
agent failed whatever the combined state, because it is news the user must see
|
||||
even while subagents run; a stop marks it interrupted only on a `done` row, so
|
||||
a stopped or finished main agent with live child work still reads working.
|
||||
even while subagents run: a `failure`, and an `interruption`, a turn cut short
|
||||
by anything other than the user or a newer request. A user's stop (`cancellation`)
|
||||
marks it interrupted, drawn in the muted tone with the row text "Interrupted by user";
|
||||
a turn a newer request replaced (`superseded`) marks it interrupted in the same muted
|
||||
tone with the row text "Interrupted"; and `unconfirmed` marks it unconfirmed, all only
|
||||
on a `done` row, so a stopped
|
||||
or finished main agent with live child work still reads working. The folded
|
||||
turn header follows the same mark: "Failed after N", "Interrupted after N", or
|
||||
"Worked for N".
|
||||
Each subagent keeps its own row and state. Container rollups (worktree card,
|
||||
terminal tab, Cmd+J) rank a pending question first, then a failure, then live
|
||||
work, then a stop, then done. On the worktree card, a failure retained after its
|
||||
agent's pane went away has no expiry, so it ranks below live work and above a
|
||||
stop. Lifecycle waiters keep reading the combined `state`.
|
||||
work, then an unconfirmed end, then a user's stop, then done. On the worktree
|
||||
card, a failure retained after its agent's pane went away has no expiry, so it
|
||||
ranks below live work and above an unconfirmed end. Lifecycle waiters keep
|
||||
reading the combined `state`.
|
||||
|
||||
Policy splits the verdict two ways. Clean-finish policy (hibernation, pane
|
||||
ownership, the star-nag value moment) treats a failure like a cancellation
|
||||
(`agentTurnEndedUncleanly`). Attention (completion time, Smart Sort, sticky
|
||||
retention, Cmd+J Recent) demotes only a turn the user stopped
|
||||
(`agentTurnStoppedByUser`); a failure ranks like a completion.
|
||||
ownership, the star-nag value moment) treats a failure, an interruption and an
|
||||
unconfirmed end like a cancellation (`agentTurnEndedUncleanly`). Attention
|
||||
(completion time, Smart Sort, sticky retention, Cmd+J Recent) demotes only a
|
||||
turn ended on purpose, the user's stop or a newer request that replaced it
|
||||
(`agentTurnEndedOnPurpose`); a failure, an interruption or
|
||||
an unconfirmed end ranks like a completion.
|
||||
|
||||
Admission is one function, `normalizeAgentStatusPayload`, on the relay wire,
|
||||
IPC and disk. A malformed `mainAgent` drops the field and keeps the row. Old hosts
|
||||
|
||||
@@ -2,18 +2,21 @@ import { useEffect, useRef } from 'react'
|
||||
import { Activity } from 'lucide-react-native'
|
||||
import { Animated, Easing, StyleSheet, View } from 'react-native'
|
||||
import type { AgentDotState } from '../worktree/agent-row-display'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
|
||||
// Per-agent state indicator, 1:1 with desktop AgentStateDot
|
||||
// (src/renderer/src/components/AgentStateDot.tsx): yellow spinner for 'working',
|
||||
// emerald for 'done', red for blocked/waiting/interrupted/failed (attention), neutral
|
||||
// for idle. Distinct from the worktree-level AgentSpinner, which collapses the
|
||||
// agent vocabulary into the 5-state rollup the sidebar dot uses.
|
||||
// emerald for 'done', red for blocked/waiting/failed (attention), muted for a user's Stop
|
||||
// ('interrupted'), amber for 'unconfirmed' (desktop's missing-evidence tone), neutral for idle. Distinct from the
|
||||
// worktree-level AgentSpinner, which collapses the agent vocabulary into the 5-state
|
||||
// rollup the sidebar dot uses.
|
||||
const DOT_COLORS: Record<Exclude<AgentDotState, 'working' | 'monitoring'>, string> = {
|
||||
done: '#10b981',
|
||||
blocked: '#ef4444',
|
||||
waiting: '#ef4444',
|
||||
interrupted: '#ef4444',
|
||||
interrupted: colors.textMuted,
|
||||
failed: '#ef4444',
|
||||
unconfirmed: colors.statusAmber,
|
||||
idle: 'rgba(115,115,115,0.4)'
|
||||
}
|
||||
const WORKING_COLOR = '#eab308'
|
||||
|
||||
@@ -3,12 +3,14 @@ import { act, create } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AgentSpinner } from './AgentSpinner'
|
||||
import { AgentStateDot } from './AgentStateDot'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
|
||||
const DESKTOP_WORKING_COLOR = '#eab308'
|
||||
|
||||
type MonitoringTestRenderer = {
|
||||
readonly root: {
|
||||
findByType(type: string): { props: Record<string, unknown> }
|
||||
findAllByType(type: string): { props: Record<string, unknown> }[]
|
||||
}
|
||||
unmount(): void
|
||||
}
|
||||
@@ -84,4 +86,17 @@ describe('mobile monitoring indicators', () => {
|
||||
expect(animationTiming).toHaveBeenCalledOnce()
|
||||
expect(animationLoop).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
// A user's Stop is not news: muted, never the fault red a failure draws.
|
||||
['interrupted', colors.textMuted],
|
||||
['failed', '#ef4444']
|
||||
] as const)('draws %s with its own dot colour', async (state, color) => {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(AgentStateDot, { state }))
|
||||
})
|
||||
|
||||
const dot = renderer?.root.findAllByType('View').find((view) => Array.isArray(view.props.style))
|
||||
expect(dot?.props.style).toContainEqual({ backgroundColor: color })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -123,6 +123,7 @@ function MobileNativeChatMessageImpl({
|
||||
<MobileNativeChatTurnStatus
|
||||
startedAt={turnStatus.startedAt}
|
||||
workedSeconds={turnStatus.workedSeconds}
|
||||
verdict={turnStatus.verdict}
|
||||
expanded={turnExpanded ?? false}
|
||||
onToggleExpanded={turnKey && onToggleTurn ? () => onToggleTurn(turnKey) : undefined}
|
||||
/>
|
||||
|
||||
@@ -47,6 +47,7 @@ describe('MobileNativeChatTurnStatus', () => {
|
||||
function render(props: {
|
||||
startedAt: number | null
|
||||
workedSeconds?: number | null
|
||||
verdict?: 'interruption' | 'cancellation'
|
||||
expanded?: boolean
|
||||
onToggleExpanded?: () => void
|
||||
}): ReactTestRenderer {
|
||||
@@ -77,6 +78,24 @@ describe('MobileNativeChatTurnStatus', () => {
|
||||
expect(onToggleExpanded).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('heads a turn a crash cut off as failed, and a turn the user stopped as interrupted', () => {
|
||||
const crashed = render({
|
||||
startedAt: Date.now(),
|
||||
workedSeconds: 12,
|
||||
verdict: 'interruption',
|
||||
onToggleExpanded: vi.fn()
|
||||
})
|
||||
expect(labels(crashed.root)).toEqual(['Failed after 12s'])
|
||||
act(() => crashed.unmount())
|
||||
const stopped = render({
|
||||
startedAt: Date.now(),
|
||||
workedSeconds: 12,
|
||||
verdict: 'cancellation',
|
||||
onToggleExpanded: vi.fn()
|
||||
})
|
||||
expect(labels(stopped.root)).toEqual(['Interrupted after 12s'])
|
||||
})
|
||||
|
||||
it('stays a plain row when the settled turn has nothing to disclose', () => {
|
||||
const tree = render({ startedAt: Date.now(), workedSeconds: 5 })
|
||||
expect(tree.root.findAllByType('Pressable' as never)).toHaveLength(0)
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
NATIVE_CHAT_TURN_STATUS_COPY,
|
||||
nativeChatElapsedSeconds
|
||||
} from '../../../src/shared/native-chat-turn-status'
|
||||
import type { AgentTurnOutcome } from '../../../src/shared/agent-turn-outcome'
|
||||
import { colors, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
/** Seconds tick only while a turn is actually counting, so a settled transcript
|
||||
@@ -27,22 +28,26 @@ function useElapsedSeconds(startedAt: number | null, counting: boolean): number
|
||||
}
|
||||
|
||||
/** The turn bar under the user's message: "Working for 12s" while the turn runs,
|
||||
* settling in place to a tappable "Worked for 3m 4s" that discloses the turn's
|
||||
* tool activity. Desktop parity: `NativeChatWorkingStatus`. */
|
||||
* settling in place to a tappable "Worked for 3m 4s" ("Interrupted after" for a Stop,
|
||||
* "Failed after" for a fault) that discloses the turn's tool activity. Desktop parity:
|
||||
* `NativeChatWorkingStatus`. */
|
||||
export function MobileNativeChatTurnStatus({
|
||||
startedAt,
|
||||
workedSeconds,
|
||||
verdict,
|
||||
expanded = false,
|
||||
onToggleExpanded
|
||||
}: {
|
||||
startedAt: number | null
|
||||
workedSeconds?: number | null
|
||||
/** How a settled turn ended; it picks the settled label. */
|
||||
verdict?: AgentTurnOutcome
|
||||
expanded?: boolean
|
||||
onToggleExpanded?: () => void
|
||||
}): React.JSX.Element {
|
||||
const settled = workedSeconds != null
|
||||
const elapsedSeconds = useElapsedSeconds(startedAt, !settled)
|
||||
const label = formatNativeChatTurnStatusLabel({ workedSeconds, elapsedSeconds })
|
||||
const label = formatNativeChatTurnStatusLabel({ workedSeconds, elapsedSeconds, verdict })
|
||||
|
||||
if (settled && onToggleExpanded) {
|
||||
return (
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('useMobileStructuredAgentTurnTiming', () => {
|
||||
expect(timing?.workingStartedAt).toBe(CLIENT_NOW - 2_500)
|
||||
// The row's provider key resolves through the submission alias, not journal order.
|
||||
expect([...timing!.settledTurns]).toEqual([
|
||||
['orca:first', { startedAt: HOST_START, workedSeconds: 61 }],
|
||||
['orca:first', { startedAt: HOST_START, workedSeconds: 61, verdict: 'interruption' }],
|
||||
['u2', null]
|
||||
])
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
agentMainAgentVerdict,
|
||||
agentVerdictDisplayMark
|
||||
} from '../../../src/shared/agent-main-agent-verdict'
|
||||
import { AGENT_JOURNAL_TURN_OUTCOMES } from '../../../src/shared/agent-turn-outcome'
|
||||
import { AGENT_TURN_OUTCOMES } from '../../../src/shared/agent-turn-outcome'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
agentDisplayLabel,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
formatTimeAgo
|
||||
} from './agent-row-display'
|
||||
|
||||
type Outcome = (typeof AGENT_JOURNAL_TURN_OUTCOMES)[number]
|
||||
type Outcome = (typeof AGENT_TURN_OUTCOMES)[number]
|
||||
const mainAgentDone = (outcome: Outcome, stateStartedAt = 0) => ({
|
||||
mainAgent: { state: 'done' as const, outcome, stateStartedAt }
|
||||
})
|
||||
@@ -50,19 +50,48 @@ describe('agentDotState', () => {
|
||||
expect(agentDotState(row({ state: 'unknown-state' as never }), 0)).toBe('idle')
|
||||
})
|
||||
|
||||
it('reports the verdict of a done row: failed, interrupted, or an old host legacy flag', () => {
|
||||
it("reports the verdict of a done row: failed, or a user's Stop (also an old host's flag) as interrupted", () => {
|
||||
expect(agentDotState(row({ state: 'done', interrupted: true }), 0)).toBe('interrupted')
|
||||
expect(agentDotState(row({ state: 'done', ...mainAgentDone('failure') }), 0)).toBe('failed')
|
||||
expect(
|
||||
agentDotState(row({ state: 'done', ...mainAgentDone('cancellation'), interrupted: true }), 0)
|
||||
).toBe('interrupted')
|
||||
expect(agentDotState(row({ state: 'done', ...mainAgentDone('success') }), 0)).toBe('done')
|
||||
// A turn a newer request replaced reads as a Stop does.
|
||||
expect(agentDotState(row({ state: 'done', ...mainAgentDone('superseded') }), 0)).toBe(
|
||||
'interrupted'
|
||||
)
|
||||
})
|
||||
|
||||
it('reads a crash-cut turn as failed and an unproven end as unconfirmed', () => {
|
||||
expect(agentDotState(row({ state: 'done', ...mainAgentDone('interruption') }), 0)).toBe(
|
||||
'failed'
|
||||
)
|
||||
expect(agentDisplayLabel(row({ state: 'done', ...mainAgentDone('interruption') }), 0)).toBe(
|
||||
'Failed'
|
||||
)
|
||||
expect(agentDotState(row({ state: 'done', ...mainAgentDone('unconfirmed') }), 0)).toBe(
|
||||
'unconfirmed'
|
||||
)
|
||||
expect(agentDisplayLabel(row({ state: 'done', ...mainAgentDone('unconfirmed') }), 0)).toBe(
|
||||
'Couldn’t confirm'
|
||||
)
|
||||
})
|
||||
|
||||
// Rows arrive unparsed, so an arm a newer host adds must read as the done it always did.
|
||||
it('reads a done row carrying an outcome it cannot name as done', () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an arm from a newer host, which the unparsed wire row can carry.
|
||||
const future = mainAgentDone('from-a-newer-host' as Outcome)
|
||||
expect(agentDotState(row({ state: 'done', ...future }), 0)).toBe('done')
|
||||
})
|
||||
|
||||
it('shows a main agent that failed while its subagents still run as failed', () => {
|
||||
expect(agentDotState(row({ state: 'working', ...mainAgentDone('failure') }), 0)).toBe('failed')
|
||||
expect(agentDotState(row({ state: 'waiting', ...mainAgentDone('failure') }), 0)).toBe('failed')
|
||||
// Only a failure outranks live work; a success or a stop with live subagents reads working.
|
||||
expect(agentDotState(row({ state: 'working', ...mainAgentDone('interruption') }), 0)).toBe(
|
||||
'failed'
|
||||
)
|
||||
// Only a fault outranks live work; a success or a stop with live subagents reads working.
|
||||
expect(agentDotState(row({ state: 'working', ...mainAgentDone('success') }), 0)).toBe('working')
|
||||
expect(
|
||||
agentDotState(
|
||||
@@ -78,7 +107,7 @@ describe('agentDotState', () => {
|
||||
const mainAgents = [
|
||||
undefined,
|
||||
...states.flatMap((state) =>
|
||||
[undefined, ...AGENT_JOURNAL_TURN_OUTCOMES].map((outcome) => ({
|
||||
[undefined, ...AGENT_TURN_OUTCOMES].map((outcome) => ({
|
||||
state,
|
||||
...(outcome ? { outcome } : {}),
|
||||
stateStartedAt: 0
|
||||
@@ -112,9 +141,9 @@ describe('agentDotState', () => {
|
||||
).toBe('working')
|
||||
// 'done' never decays, and neither does its verdict.
|
||||
expect(agentDotState(row({ state: 'done', updatedAt: 0 }), stale)).toBe('done')
|
||||
expect(agentDotState(row({ state: 'done', updatedAt: 0, interrupted: true }), stale)).toBe(
|
||||
'interrupted'
|
||||
)
|
||||
expect(
|
||||
agentDotState(row({ state: 'done', updatedAt: 0, ...mainAgentDone('interruption') }), stale)
|
||||
).toBe('failed')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
|
||||
import type { AgentJournalTurnOutcome } from '../../../src/shared/agent-turn-outcome'
|
||||
import type { AgentTurnOutcome } from '../../../src/shared/agent-turn-outcome'
|
||||
|
||||
// Mirrors the desktop AGENT_STATUS_STALE_AFTER_MS (src/shared/agent-status-types.ts:
|
||||
// 30 min). Defined locally rather than imported because a runtime-value import
|
||||
@@ -19,26 +19,55 @@ export type AgentDotState =
|
||||
| 'idle'
|
||||
| 'interrupted'
|
||||
| 'failed'
|
||||
| 'unconfirmed'
|
||||
|
||||
type AgentRowVerdictSource = Pick<RuntimeWorktreeAgentRow, 'state' | 'interrupted' | 'mainAgent'>
|
||||
|
||||
// Mirrors AGENT_TURN_OUTCOMES (src/shared/agent-turn-outcome.ts), which mobile cannot import as a
|
||||
// value (see AGENT_STATUS_STALE_AFTER_MS above). The rows arrive unparsed, so a newer host's arm
|
||||
// has to read as no verdict here.
|
||||
const AGENT_TURN_OUTCOMES: readonly AgentTurnOutcome[] = [
|
||||
'success',
|
||||
'failure',
|
||||
'cancellation',
|
||||
'superseded',
|
||||
'interruption',
|
||||
'unconfirmed'
|
||||
]
|
||||
|
||||
// Mirrors desktop agentMainAgentVerdict and agentVerdictDisplayMark
|
||||
// (src/shared/agent-main-agent-verdict.ts); a parity test runs both over one table. `mainAgent` is
|
||||
// the main agent's own status, sent also while subagents hold the row working; an old host sends none.
|
||||
export function agentRowVerdict(row: AgentRowVerdictSource): AgentJournalTurnOutcome | null {
|
||||
export function agentRowVerdict(row: AgentRowVerdictSource): AgentTurnOutcome | null {
|
||||
if (row.mainAgent && row.mainAgent.state !== 'done') {
|
||||
return null
|
||||
}
|
||||
return row.mainAgent?.outcome ?? (row.state === 'done' && row.interrupted ? 'cancellation' : null)
|
||||
const outcome = row.mainAgent?.outcome
|
||||
if (outcome !== undefined) {
|
||||
return AGENT_TURN_OUTCOMES.find((known) => known === outcome) ?? null
|
||||
}
|
||||
return row.state === 'done' && row.interrupted ? 'cancellation' : null
|
||||
}
|
||||
|
||||
// A failure outranks every state; a stop marks only a row that is itself done.
|
||||
export function agentRowVerdictMark(row: AgentRowVerdictSource): 'failed' | 'interrupted' | null {
|
||||
const verdict = agentRowVerdict(row)
|
||||
if (verdict === 'failure') {
|
||||
return 'failed'
|
||||
// A fault (a failure, or a turn cut short by something other than the user) reads failed and
|
||||
// outranks every state; a user's Stop, a turn a newer request replaced, and an unproven end mark
|
||||
// only a row that is itself done.
|
||||
export function agentRowVerdictMark(
|
||||
row: AgentRowVerdictSource
|
||||
): 'failed' | 'interrupted' | 'unconfirmed' | null {
|
||||
switch (agentRowVerdict(row)) {
|
||||
case 'failure':
|
||||
case 'interruption':
|
||||
return 'failed'
|
||||
case 'cancellation':
|
||||
case 'superseded':
|
||||
return row.state === 'done' ? 'interrupted' : null
|
||||
case 'unconfirmed':
|
||||
return row.state === 'done' ? 'unconfirmed' : null
|
||||
case 'success':
|
||||
case null:
|
||||
return null
|
||||
}
|
||||
return verdict === 'cancellation' && row.state === 'done' ? 'interrupted' : null
|
||||
}
|
||||
|
||||
export function agentDotState(
|
||||
@@ -85,6 +114,8 @@ export function agentStateLabel(state: AgentDotState): string {
|
||||
return 'Interrupted'
|
||||
case 'failed':
|
||||
return 'Failed'
|
||||
case 'unconfirmed':
|
||||
return 'Couldn’t confirm'
|
||||
case 'done':
|
||||
return 'Done'
|
||||
case 'idle':
|
||||
|
||||
@@ -123,7 +123,7 @@ describe('a Claude retrying a refused request', () => {
|
||||
// Once the frames stop, the same clock does let the sweep close it.
|
||||
clock += STRUCTURED_AGENT_SESSION_IDLE_MS
|
||||
await vi.waitFor(() => {
|
||||
expect(closeSession).toHaveBeenCalledWith(SESSION)
|
||||
expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict')
|
||||
expect(host.hasSession(SESSION)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// Closing a Claude child settles its open turn in the adapter, with the cause the host handed the
|
||||
// close. Only a stop the user aimed at this chat reads as their cancellation; an exit the adapter
|
||||
// saw before the close settles as that exit.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalTurnLifecycle
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import { readAgentJournalTurn } from '../../shared/agent-session-turn-record'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state'
|
||||
import {
|
||||
PROVIDER_SESSION_ID,
|
||||
adapterFor,
|
||||
fakeClaude,
|
||||
identityFor,
|
||||
tick
|
||||
} from './claude-structured-session-test-support'
|
||||
|
||||
function turnSink() {
|
||||
const turns: AgentJournalTurnLifecycle[] = []
|
||||
const sink: StructuredAgentSessionEventSink = {
|
||||
appendItem: (_identity, body: AgentJournalItemBody) => {
|
||||
const turn = readAgentJournalTurn(body)
|
||||
if (turn) {
|
||||
turns.push(turn)
|
||||
}
|
||||
},
|
||||
appendTombstone: () => {},
|
||||
publish: () => {}
|
||||
}
|
||||
return { sink, turns }
|
||||
}
|
||||
|
||||
/** A live Claude child inside a turn the provider opened on its own (a background-task wake). */
|
||||
async function childInsideTurn() {
|
||||
const claude = fakeClaude()
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const adapter = adapterFor(claude, {}, events)
|
||||
const { sink, turns } = turnSink()
|
||||
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink })
|
||||
const connection = claude.connections[0]!
|
||||
connection.handlers.onMessage?.({
|
||||
session_id: PROVIDER_SESSION_ID,
|
||||
type: 'assistant',
|
||||
uuid: 'assistant-1',
|
||||
parent_tool_use_id: null,
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'working' }] }
|
||||
})
|
||||
await tick()
|
||||
expect(turns.at(-1)).toMatchObject({ state: 'running' })
|
||||
return { adapter, connection, events, turns }
|
||||
}
|
||||
|
||||
describe('a Claude close settles the open turn with the host-named cause', () => {
|
||||
it("records the user's close of this chat as their cancellation", async () => {
|
||||
const { adapter, events, turns } = await childInsideTurn()
|
||||
|
||||
await expect(adapter.closeSession('session-1', 'user-close')).resolves.toBe(true)
|
||||
|
||||
expect(turns.at(-1)).toMatchObject({ state: 'interrupted', outcome: 'cancellation' })
|
||||
expect(events.find((event) => event.type === 'ended')).toMatchObject({
|
||||
stopCause: 'user-close'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves an eviction as news', async () => {
|
||||
const { adapter, turns } = await childInsideTurn()
|
||||
|
||||
await adapter.closeSession('session-1', 'evict')
|
||||
|
||||
expect(turns.at(-1)).toMatchObject({ state: 'interrupted' })
|
||||
expect(turns.at(-1)).not.toHaveProperty('outcome')
|
||||
})
|
||||
|
||||
it('leaves a crash it saw before the user closed the chat as news', async () => {
|
||||
const { adapter, connection, events, turns } = await childInsideTurn()
|
||||
// The child dies on its own first; the user's close arrives while that exit is still settling.
|
||||
connection.handlers.onExit?.(new Error('provider exited'))
|
||||
|
||||
await adapter.closeSession('session-1', 'user-close')
|
||||
await tick()
|
||||
|
||||
const settled = turns.filter((turn) => turn.state !== 'running')
|
||||
expect(settled).toEqual([expect.objectContaining({ state: 'interrupted' })])
|
||||
expect(settled[0]).not.toHaveProperty('outcome')
|
||||
expect(events.filter((event) => event.type === 'ended')).toEqual([
|
||||
expect.objectContaining({ cause: 'unexpected-exit' })
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { AgentSessionContextReport } from '../../shared/agent-session-context-usage'
|
||||
import type { StructuredAgentSessionSinkAdmission } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type { ClaudeChildToolQueries } from './claude-child-tool-queries'
|
||||
import type { ClaudeContextReportPart, ClaudeContextReportTarget } from './claude-context-facts'
|
||||
import type { ClaudeJournalPrompts } from './claude-structured-journal-prompts'
|
||||
@@ -14,6 +15,11 @@ export type ClaudeJournalTranslator = {
|
||||
/** The open turn's provider id — the same id its journal row carries, and the one
|
||||
* a client's Stop names. Sole owner: no reader keeps a copy to disagree with. */
|
||||
readonly currentTurnId: string | null
|
||||
/** Orca is stopping this turn; its error end reads as the user's cancellation when they asked.
|
||||
* False when the turn is no longer open. */
|
||||
recordTurnStop: (turnId: string, cause: StructuredAgentSessionStopCause) => boolean
|
||||
/** The provider refused the stop. */
|
||||
withdrawTurnStop: (turnId: string) => void
|
||||
/** The open turn's id while it is a conversation command's. */
|
||||
readonly commandTurnId: string | null
|
||||
/** Makes the host's command turn the open one until the command's result ends it. */
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type AgentJournalTurnScope
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import {
|
||||
claudeCurrentTurnIdentity,
|
||||
claudeTurnLifecycleItem,
|
||||
@@ -32,6 +33,9 @@ export type ClaudeOpenTurnDeps = {
|
||||
|
||||
export class ClaudeOpenTurn {
|
||||
private current: ClaudeCurrentTurn | null = null
|
||||
/** The stop Orca sent, held against the turn it was sent to: it reads only while that turn is open. */
|
||||
private sentStop: { turn: ClaudeCurrentTurn; cause: StructuredAgentSessionStopCause } | null =
|
||||
null
|
||||
/** Provider output may not reopen a turn after the session ended or a turn
|
||||
* failed: nothing would ever close the turn it opened, and the row would read
|
||||
* working for the life of the session. Only an accepted send lifts it. */
|
||||
@@ -86,6 +90,26 @@ export class ClaudeOpenTurn {
|
||||
return this.current !== null
|
||||
}
|
||||
|
||||
get stop(): StructuredAgentSessionStopCause | null {
|
||||
return this.current && this.sentStop?.turn === this.current ? this.sentStop.cause : null
|
||||
}
|
||||
|
||||
/** Orca is stopping `turnId`. False when that turn is no longer the open one. */
|
||||
recordStop(turnId: string, cause: StructuredAgentSessionStopCause): boolean {
|
||||
if (this.current?.turnId !== turnId) {
|
||||
return false
|
||||
}
|
||||
this.sentStop = { turn: this.current, cause }
|
||||
return true
|
||||
}
|
||||
|
||||
/** The provider refused the stop, so the turn goes on as if none was sent. */
|
||||
withdrawStop(turnId: string): void {
|
||||
if (this.current?.turnId === turnId) {
|
||||
this.sentStop = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a turn is open inside a provider request cycle that has already
|
||||
* done work — the state in which the CLI folds an arriving send into it. A
|
||||
* cycle's first send is its opener, never a fold. */
|
||||
@@ -105,12 +129,17 @@ export class ClaudeOpenTurn {
|
||||
|
||||
/** Open a turn, ending whichever one was still open. A new turn starting is the
|
||||
* only end the previous one gets when its result never arrives; settling it
|
||||
* later would sweep THIS turn. */
|
||||
* later would sweep THIS turn. The replaced turn is recorded superseded: a newer
|
||||
* request ended it, whoever sent that request. */
|
||||
open(turn: ClaudeCurrentTurn, observedAt: number): void {
|
||||
this.deps.onOpen?.()
|
||||
if (this.current) {
|
||||
this.deps.settleChildren(this.groupKey)
|
||||
this.publish(this.current, { state: 'interrupted', completedAt: observedAt })
|
||||
this.publish(this.current, {
|
||||
state: 'interrupted',
|
||||
completedAt: observedAt,
|
||||
outcome: 'superseded'
|
||||
})
|
||||
}
|
||||
this.current = turn
|
||||
this.publish(turn)
|
||||
@@ -123,7 +152,11 @@ export class ClaudeOpenTurn {
|
||||
this.deps.onOpen?.()
|
||||
if (this.current) {
|
||||
this.deps.settleChildren(this.groupKey)
|
||||
this.publish(this.current, { state: 'interrupted', completedAt: turn.startedAt })
|
||||
this.publish(this.current, {
|
||||
state: 'interrupted',
|
||||
completedAt: turn.startedAt,
|
||||
outcome: 'superseded'
|
||||
})
|
||||
}
|
||||
this.current = turn
|
||||
this.deps.sink.setActivity?.(null)
|
||||
|
||||
@@ -56,13 +56,15 @@ export function journalClaudeResult(
|
||||
}
|
||||
// Read before the settle below closes it: the result reports that turn's end.
|
||||
const endedTurnScope = turn.turnScope
|
||||
// The stop Orca sent that turn: after the user's own, an error end is their cancellation.
|
||||
const stop = settlesTurn ? turn.stop : null
|
||||
if (settlesTurn) {
|
||||
prompts.retryPendingCancellations()
|
||||
turn.suppressReopenOnFailure(message.is_error === true)
|
||||
// The turn is over however it ended, so a foreground child still
|
||||
// reported as working will never be settled by an event.
|
||||
subagents.settleTurn(turn.groupKey)
|
||||
context.settle(message, commandEnd ?? claudeTurnEndForResult(message, observedAt))
|
||||
context.settle(message, commandEnd ?? claudeTurnEndForResult(message, observedAt, stop))
|
||||
// The turn is over. A block still awaiting its final keeps the text the
|
||||
// flush above journaled, but its live state goes: an interrupted turn
|
||||
// would otherwise retain that text for the life of the session.
|
||||
@@ -70,7 +72,7 @@ export function journalClaudeResult(
|
||||
streamedText.settle()
|
||||
}
|
||||
const kind = claudeProviderFrameKind(message)
|
||||
const failure = claudeResultFailure(message)
|
||||
const failure = claudeResultFailure(message, stop)
|
||||
if (failure || !isSettledClaudeResultKind(kind)) {
|
||||
providerFallback.append(
|
||||
kind,
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
// the user's or the provider's.
|
||||
|
||||
import type { AgentJournalTurnOutcome } from '../../shared/agent-session-journal-types'
|
||||
import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import { stopIsTheUsers } from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict'
|
||||
import { claudeText } from './claude-structured-item-translation'
|
||||
|
||||
/** The SDK reports the user's stop as an error result, so `is_error` alone cannot
|
||||
@@ -13,11 +15,18 @@ import { claudeText } from './claude-structured-item-translation'
|
||||
const CLAUDE_ABORTED_TERMINAL_REASONS = new Set(['aborted_streaming', 'aborted_tools'])
|
||||
|
||||
/** A success-subtype result still carries `is_error` for an API error, so the flag
|
||||
* is what decides, never the subtype. */
|
||||
export function claudeResultOutcome(message: Record<string, unknown>): AgentJournalTurnOutcome {
|
||||
* is what decides, never the subtype. `stop` is the stop Orca sent for this turn: an error
|
||||
* end after the user's own is their cancellation, since older CLIs name no reason. */
|
||||
export function claudeResultOutcome(
|
||||
message: Record<string, unknown>,
|
||||
stop: StructuredAgentSessionStopCause | null = null
|
||||
): AgentJournalTurnOutcome {
|
||||
if (message.is_error !== true) {
|
||||
return 'success'
|
||||
}
|
||||
if (stop !== null && stopIsTheUsers(stop)) {
|
||||
return 'cancellation'
|
||||
}
|
||||
const reason = claudeText(message.terminal_reason)
|
||||
return reason !== null && CLAUDE_ABORTED_TERMINAL_REASONS.has(reason) ? 'cancellation' : 'failure'
|
||||
}
|
||||
|
||||
@@ -249,6 +249,8 @@ describe('answerClaudePrompt', () => {
|
||||
resolve: resolvePrompt
|
||||
},
|
||||
currentTurnId: null,
|
||||
recordTurnStop: () => true,
|
||||
withdrawTurnStop: () => {},
|
||||
commandTurnId: null,
|
||||
beginCommand: vi.fn(),
|
||||
forgetCommand: vi.fn(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ClaudeControlRequestError } from './claude-stream-json-connection'
|
||||
import { settleCancelledClaudeDispatchWaiters } from './claude-structured-dispatch'
|
||||
import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution'
|
||||
import type { ClaudeSession } from './claude-structured-session-state'
|
||||
import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
|
||||
const INTERRUPT_CANCEL_QUEUED_CAPABILITY = 'interrupt_cancel_queued_v1'
|
||||
|
||||
@@ -25,7 +26,8 @@ export async function cancelClaudeTurn(
|
||||
session: ClaudeSession,
|
||||
timeoutMs: number | undefined,
|
||||
isCurrent: ClaudeTurnCancellationGuard = () => true,
|
||||
onDispatchSettledLate?: ClaudeLateDispatchSettlement
|
||||
onDispatchSettledLate?: ClaudeLateDispatchSettlement,
|
||||
stopped?: { turnId: string; cause: StructuredAgentSessionStopCause }
|
||||
): Promise<{ cancelled: boolean }> {
|
||||
// The SDK interrupt is session-scoped. Re-check the caller's turn/fence
|
||||
// immediately before issuing it so a delayed request cannot stop a later turn.
|
||||
@@ -33,6 +35,10 @@ export async function cancelClaudeTurn(
|
||||
return { cancelled: false }
|
||||
}
|
||||
const cancelQueued = supportsClaudeQueuedInterruptCancellation(session)
|
||||
// Recorded before the interrupt goes out, so the result it provokes finds it.
|
||||
if (stopped) {
|
||||
session.translator?.recordTurnStop(stopped.turnId, stopped.cause)
|
||||
}
|
||||
try {
|
||||
const receipt = await session.connection.interrupt({
|
||||
...(cancelQueued ? { cancelQueued: true } : {}),
|
||||
@@ -52,6 +58,11 @@ export async function cancelClaudeTurn(
|
||||
return { cancelled: true }
|
||||
} catch (error) {
|
||||
if (error instanceof ClaudeControlRequestError) {
|
||||
// The CLI refused, so the turn runs on and its own end means what it says. Any other error
|
||||
// leaves the interrupt's effect unknown, and the stop the user asked for stands.
|
||||
if (stopped) {
|
||||
session.translator?.withdrawTurnStop(stopped.turnId)
|
||||
}
|
||||
return { cancelled: false }
|
||||
}
|
||||
throw error
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// A user's Stop inside a live Claude chat interrupts the turn and keeps the session. The turn's end
|
||||
// then comes from the CLI's result frame, which CLIs before 2.1.91 send with no terminal_reason.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types'
|
||||
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
|
||||
import { readAgentJournalTurn } from '../../shared/agent-session-turn-record'
|
||||
import { ClaudeControlRequestError } from './claude-stream-json-connection'
|
||||
import {
|
||||
PROVIDER_SESSION_ID,
|
||||
USER_MESSAGE,
|
||||
adapterFor,
|
||||
fakeClaude,
|
||||
identityFor,
|
||||
type FakeConnection
|
||||
} from './claude-structured-session-test-support'
|
||||
|
||||
const CUT_SHORT = {
|
||||
type: 'result',
|
||||
subtype: 'error_during_execution',
|
||||
is_error: true,
|
||||
session_id: PROVIDER_SESSION_ID,
|
||||
parent_tool_use_id: null
|
||||
}
|
||||
|
||||
async function runningChat(claude: ReturnType<typeof fakeClaude>): Promise<{
|
||||
adapter: ReturnType<typeof adapterFor>
|
||||
bodies: Map<string, AgentJournalItemBody>
|
||||
connection: FakeConnection
|
||||
turnId: string
|
||||
}> {
|
||||
const bodies = new Map<string, AgentJournalItemBody>()
|
||||
const adapter = adapterFor(claude)
|
||||
await adapter.acquire({
|
||||
identity: identityFor(),
|
||||
fence: 7,
|
||||
spawnToken: 'spawn-9',
|
||||
events: {
|
||||
appendItem: (identity, body) => bodies.set(agentJournalItemKey(identity), body),
|
||||
appendTombstone: (identity) => bodies.delete(agentJournalItemKey(identity)),
|
||||
publish: vi.fn()
|
||||
}
|
||||
})
|
||||
await adapter.dispatch({
|
||||
sessionId: 'session-1',
|
||||
clientMessageId: 'client-a',
|
||||
body: USER_MESSAGE,
|
||||
fence: 7
|
||||
})
|
||||
const connection = claude.connections[0]!
|
||||
// Claude adopts the client uuid for the echo that opens the turn.
|
||||
connection.handlers.onMessage?.({ ...connection.sent.at(-1)! })
|
||||
const turnId = [...bodies.values()]
|
||||
.map((body) => readAgentJournalTurn(body))
|
||||
.find((turn) => turn?.state === 'running')?.turnId
|
||||
if (!turnId) {
|
||||
throw new Error('expected a running turn')
|
||||
}
|
||||
return { adapter, bodies, connection, turnId }
|
||||
}
|
||||
|
||||
function settled(bodies: Map<string, AgentJournalItemBody>, turnId: string) {
|
||||
return [...bodies.values()]
|
||||
.map((body) => readAgentJournalTurn(body))
|
||||
.find((turn) => turn?.turnId === turnId && turn.state !== 'running')
|
||||
}
|
||||
|
||||
function providerRows(bodies: Map<string, AgentJournalItemBody>): string[] {
|
||||
return [...bodies.keys()].filter((key) => key.includes('provider-frame'))
|
||||
}
|
||||
|
||||
describe("a user's Stop inside a live Claude chat", () => {
|
||||
it('records the turn the interrupt cut as their cancellation when the CLI names no reason', async () => {
|
||||
const claude = fakeClaude({
|
||||
routes: {
|
||||
// The CLI aborts the turn, then acknowledges the interrupt.
|
||||
interrupt: () => {
|
||||
claude.connections[0]!.handlers.onMessage?.(CUT_SHORT)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
const { adapter, bodies, turnId } = await runningChat(claude)
|
||||
|
||||
await expect(adapter.cancelTurn({ sessionId: 'session-1', turnId, fence: 7 })).resolves.toEqual(
|
||||
{ cancelled: true }
|
||||
)
|
||||
|
||||
expect(settled(bodies, turnId)).toMatchObject({
|
||||
state: 'interrupted',
|
||||
outcome: 'cancellation'
|
||||
})
|
||||
expect(providerRows(bodies)).toEqual([])
|
||||
})
|
||||
|
||||
// The chat's Stop button names no turn: it stops whatever the conversation has open.
|
||||
it('records the open turn a Stop naming no turn cut as their cancellation', async () => {
|
||||
const claude = fakeClaude({
|
||||
routes: {
|
||||
interrupt: () => {
|
||||
claude.connections[0]!.handlers.onMessage?.(CUT_SHORT)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
const { adapter, bodies, turnId } = await runningChat(claude)
|
||||
|
||||
await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({
|
||||
cancelled: true
|
||||
})
|
||||
|
||||
expect(settled(bodies, turnId)).toMatchObject({
|
||||
state: 'interrupted',
|
||||
outcome: 'cancellation'
|
||||
})
|
||||
expect(providerRows(bodies)).toEqual([])
|
||||
})
|
||||
|
||||
it('reads the same result with no Stop as a failure', async () => {
|
||||
const { bodies, connection, turnId } = await runningChat(fakeClaude())
|
||||
|
||||
connection.handlers.onMessage?.(CUT_SHORT)
|
||||
|
||||
expect(settled(bodies, turnId)).toMatchObject({ state: 'completed', outcome: 'failure' })
|
||||
expect(providerRows(bodies)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps a Stop naming no turn off the turn after it', async () => {
|
||||
const claude = fakeClaude({
|
||||
routes: {
|
||||
interrupt: () => {
|
||||
claude.connections[0]!.handlers.onMessage?.(CUT_SHORT)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
const { adapter, bodies, connection } = await runningChat(claude)
|
||||
await adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })
|
||||
|
||||
await adapter.dispatch({
|
||||
sessionId: 'session-1',
|
||||
clientMessageId: 'client-b',
|
||||
body: USER_MESSAGE,
|
||||
fence: 7
|
||||
})
|
||||
connection.handlers.onMessage?.({ ...connection.sent.at(-1)! })
|
||||
const nextTurnId = [...bodies.values()]
|
||||
.map((body) => readAgentJournalTurn(body))
|
||||
.find((turn) => turn?.state === 'running')?.turnId
|
||||
if (!nextTurnId) {
|
||||
throw new Error('expected the next turn running')
|
||||
}
|
||||
connection.handlers.onMessage?.(CUT_SHORT)
|
||||
|
||||
expect(settled(bodies, nextTurnId)).toMatchObject({ state: 'completed', outcome: 'failure' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['naming the turn', true],
|
||||
['naming no turn', false]
|
||||
] as const)(
|
||||
'keeps a failure the turn reaches after the CLI refused the interrupt, %s',
|
||||
async (_label, named) => {
|
||||
const claude = fakeClaude({
|
||||
routes: {
|
||||
interrupt: () => {
|
||||
throw new ClaudeControlRequestError('interrupt', 'not running')
|
||||
}
|
||||
}
|
||||
})
|
||||
const { adapter, bodies, connection, turnId } = await runningChat(claude)
|
||||
|
||||
await expect(
|
||||
adapter.cancelTurn({ sessionId: 'session-1', ...(named ? { turnId } : {}), fence: 7 })
|
||||
).resolves.toEqual({ cancelled: false })
|
||||
connection.handlers.onMessage?.(CUT_SHORT)
|
||||
|
||||
expect(settled(bodies, turnId)).toMatchObject({ state: 'completed', outcome: 'failure' })
|
||||
expect(providerRows(bodies)).toHaveLength(1)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -256,6 +256,7 @@ describe('Claude structured turn timing', () => {
|
||||
{
|
||||
turnId: 'user-1',
|
||||
state: 'interrupted',
|
||||
outcome: 'superseded',
|
||||
startedAt: 1_000,
|
||||
completedAt: 3_000,
|
||||
userItemId: USER_1_KEY
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
childEndCauseOfEndedEvent,
|
||||
turnVerdictForChildEnd
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict'
|
||||
import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import type { ClaudeJournalTranslator } from './claude-journal-translator-contract'
|
||||
@@ -185,8 +189,11 @@ export function createClaudeJournalTranslator(
|
||||
streamedText.flush()
|
||||
subagents.settleSession()
|
||||
backgroundTasks.settleSession()
|
||||
// The host saw the child end, so the turn's end is observed, not lost.
|
||||
turn.settle({ state: 'interrupted', completedAt: event.observedAt ?? Date.now() })
|
||||
// The host saw the child end, so the turn's end is observed, not lost; its verdict is only
|
||||
// what the host's own cause says, a user's stop of this chat or else news.
|
||||
turn.settle(
|
||||
turnVerdictForChildEnd(childEndCauseOfEndedEvent(event), event.observedAt ?? Date.now())
|
||||
)
|
||||
// A frame that arrives after the child is gone must not open a turn no
|
||||
// event can close.
|
||||
turn.suppressReopen()
|
||||
@@ -275,6 +282,8 @@ export function createClaudeJournalTranslator(
|
||||
get currentTurnId() {
|
||||
return turn.id
|
||||
},
|
||||
recordTurnStop: (turnId, cause) => turn.recordStop(turnId, cause),
|
||||
withdrawTurnStop: (turnId) => turn.withdrawStop(turnId),
|
||||
get commandTurnId() {
|
||||
return turn.command ? turn.id : null
|
||||
},
|
||||
|
||||
@@ -75,7 +75,16 @@ function cancelClaudeConversation(
|
||||
session.fence === request.fence &&
|
||||
session.acquisitionGeneration === acquisitionGeneration &&
|
||||
(claudeLiveTurnId(session, request) !== null || session.dispatchWaiters.length > 0)
|
||||
return cancelClaudeTurn(session, timeoutMs, isCurrent, onDispatchSettledLate)
|
||||
// A turn is cancelled only at a client's request, so the stop is the user's: on the named turn,
|
||||
// else on whatever turn is open.
|
||||
const stoppedTurnId = request.turnId ?? session.translator?.currentTurnId ?? null
|
||||
return cancelClaudeTurn(
|
||||
session,
|
||||
timeoutMs,
|
||||
isCurrent,
|
||||
onDispatchSettledLate,
|
||||
stoppedTurnId === null ? undefined : { turnId: stoppedTurnId, cause: 'user-stop' }
|
||||
)
|
||||
}
|
||||
|
||||
export async function cancelClaudeStructuredTurn(input: {
|
||||
@@ -166,6 +175,7 @@ export async function cancelClaudeStructuredTurn(input: {
|
||||
: compactionOwnsTurn() || (ownsRequestedTurn() && dispatchAdmissionAllowsCancellation()))
|
||||
let interruptConfirmed = false
|
||||
try {
|
||||
// A turn is cancelled only at a client's request, so the stop is the user's.
|
||||
const result = await cancelClaudeTurn(
|
||||
session,
|
||||
timeoutMs,
|
||||
@@ -177,7 +187,8 @@ export async function cancelClaudeStructuredTurn(input: {
|
||||
}
|
||||
return current
|
||||
},
|
||||
input.onDispatchSettledLate
|
||||
input.onDispatchSettledLate,
|
||||
{ turnId: requestedTurnId, cause: 'user-stop' }
|
||||
)
|
||||
if (result.cancelled && claim && cancellationObserved) {
|
||||
interruptConfirmed = true
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type ClaudeMessageEnvelope
|
||||
} from './claude-structured-item-translation'
|
||||
import { claudeResultOutcome } from './claude-result-outcome'
|
||||
import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type { ClaudeRowStamp } from './claude-provisional-row-corrections'
|
||||
import {
|
||||
CLAUDE_API_RETRY_FRAME_KIND,
|
||||
@@ -51,11 +52,12 @@ export function isSettledClaudeResultKind(kind: string): boolean {
|
||||
* would only be noise.
|
||||
*/
|
||||
export function claudeResultFailure(
|
||||
message: Record<string, unknown>
|
||||
message: Record<string, unknown>,
|
||||
stop: StructuredAgentSessionStopCause | null = null
|
||||
): { text: string | null } | null {
|
||||
// A cancellation is not a fault and earns no error row; the outcome classifier
|
||||
// owns that distinction so this reader cannot drift from the turn's verdict.
|
||||
if (claudeResultOutcome(message) !== 'failure') {
|
||||
if (claudeResultOutcome(message, stop) !== 'failure') {
|
||||
return null
|
||||
}
|
||||
const result = claudeText(message.result)?.trim()
|
||||
|
||||
@@ -3,7 +3,8 @@ import { dispatchClaudeCommand } from './claude-structured-command-dispatch'
|
||||
import type {
|
||||
AgentSessionAcquisition,
|
||||
StructuredAgentSessionAcquireInput,
|
||||
StructuredAgentSessionAdapter
|
||||
StructuredAgentSessionAdapter,
|
||||
StructuredAgentSessionStopCause
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import { stopClaudeBackgroundTasks } from './claude-structured-control-actions'
|
||||
import { dispatchClaudeTurn } from './claude-structured-dispatch'
|
||||
@@ -282,20 +283,30 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
|
||||
...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {})
|
||||
})
|
||||
|
||||
closeSession = (sessionId: string): Promise<boolean> =>
|
||||
closeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise<boolean> =>
|
||||
// After the close, not before: releasing an exit still settling settles it on the way.
|
||||
this.closeSessionProcess(sessionId).finally(() => this.settledExitErrors.delete(sessionId))
|
||||
this.closeSessionProcess(sessionId, cause).finally(() =>
|
||||
this.settledExitErrors.delete(sessionId)
|
||||
)
|
||||
|
||||
private closeSessionProcess(sessionId: string): Promise<boolean> {
|
||||
private closeSessionProcess(
|
||||
sessionId: string,
|
||||
cause: StructuredAgentSessionStopCause | undefined
|
||||
): Promise<boolean> {
|
||||
// An exit seen first settles as that exit, whoever asked for the close after it.
|
||||
if (this.exits.has(sessionId)) {
|
||||
return this.releaseAcquisition({ sessionId })
|
||||
}
|
||||
return this.afterClose(sessionId, () => this.closeProviderSession(sessionId))
|
||||
return this.afterClose(sessionId, () => this.closeProviderSession(sessionId, cause))
|
||||
}
|
||||
|
||||
private closeProviderSession = (sessionId: string): Promise<boolean> =>
|
||||
private closeProviderSession = (
|
||||
sessionId: string,
|
||||
stopCause?: StructuredAgentSessionStopCause
|
||||
): Promise<boolean> =>
|
||||
closeClaudeSession({
|
||||
sessionId,
|
||||
...(stopCause ? { stopCause } : {}),
|
||||
sessions: this.sessions,
|
||||
acquisitions: this.acquisitions,
|
||||
...(this.deps.persistHandle ? { persistHandle: this.deps.persistHandle } : {}),
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ClaudeStructuredSessionEvent
|
||||
} from './claude-structured-session-state'
|
||||
import { cancelClaudeAcquisitionAttempt } from './claude-structured-session-state'
|
||||
import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import {
|
||||
AgentSessionAcquisitionExitUnprovenError,
|
||||
AgentSessionAcquisitionRootExitObservedError,
|
||||
@@ -77,6 +78,8 @@ export function settleClaudeExitedSession(session: ClaudeSession): void {
|
||||
type CloseClaudePublishedSessionInput = {
|
||||
sessions: Map<string, ClaudeSession>
|
||||
sessionId: string
|
||||
/** Who asked for the close; the translator settles the open turn with it. */
|
||||
stopCause?: StructuredAgentSessionStopCause
|
||||
persistHandle?: (handle: {
|
||||
sessionId: string
|
||||
providerSessionId: string
|
||||
@@ -135,6 +138,7 @@ async function finalizeClaudePublishedSession(
|
||||
type: 'ended',
|
||||
sessionId: input.sessionId,
|
||||
reason: 'claude session closed',
|
||||
...(input.stopCause ? { stopCause: input.stopCause } : {}),
|
||||
observedAt: Date.now()
|
||||
} as const
|
||||
let callbackError: unknown
|
||||
@@ -242,6 +246,7 @@ export function closeClaudePublishedSessionForDeps(
|
||||
|
||||
export async function closeClaudeSession(input: {
|
||||
sessionId: string
|
||||
stopCause?: StructuredAgentSessionStopCause
|
||||
sessions: Map<string, ClaudeSession>
|
||||
acquisitions: ClaudeAcquisitionRegistry
|
||||
persistHandle?: (handle: {
|
||||
|
||||
@@ -5,7 +5,10 @@ import type {
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import type { StructuredAgentSessionStartedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type {
|
||||
StructuredAgentSessionStartedEvent,
|
||||
StructuredAgentSessionStopCause
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type {
|
||||
ClaudeStreamJsonConnection,
|
||||
openClaudeStreamJsonConnection
|
||||
@@ -73,6 +76,8 @@ export type ClaudeStructuredSessionEvent =
|
||||
failure?: SubmissionRejectionFact
|
||||
/** Present for first-hand child exits so the host can fence recovery. */
|
||||
cause?: 'unexpected-exit' | 'requested-close'
|
||||
/** Who asked for a close; the translator settles the open turn with it. */
|
||||
stopCause?: StructuredAgentSessionStopCause
|
||||
fence?: number
|
||||
acquisitionGeneration?: string
|
||||
/** Host clock when the end was observed. */
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
|
||||
import { agentJournalTurnBody } from '../../shared/agent-session-turn-record'
|
||||
import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type { StructuredAgentSessionAppendOptions } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { claudeResultOutcome } from './claude-result-outcome'
|
||||
import type { ClaudeCommandTurn } from './claude-command-turn'
|
||||
@@ -31,9 +32,9 @@ export function claudeCurrentTurnIdentity(turn: ClaudeCurrentTurn): AgentJournal
|
||||
export type ClaudeTurnEnd = {
|
||||
state: 'completed' | 'interrupted'
|
||||
completedAt: number
|
||||
/** Only an end the PROVIDER reported carries one. An end the host inferred —
|
||||
* the child going away, a new turn superseding this one — leaves it absent,
|
||||
* which reads as unknown rather than claiming the turn worked. */
|
||||
/** An end the provider reported carries its verdict, and a turn a newer one
|
||||
* replaced carries `superseded`. The child going away leaves it absent, which
|
||||
* reads as unknown rather than claiming the turn worked. */
|
||||
outcome?: AgentJournalTurnOutcome
|
||||
/** The SDK's own measured turn duration; only a result frame carries one. */
|
||||
durationMs?: number
|
||||
@@ -44,9 +45,10 @@ export type ClaudeTurnEnd = {
|
||||
* is still a turn the host watched finish, and only `outcome` says it failed. */
|
||||
export function claudeTurnEndForResult(
|
||||
message: Record<string, unknown>,
|
||||
completedAt: number
|
||||
completedAt: number,
|
||||
stop: StructuredAgentSessionStopCause | null = null
|
||||
): ClaudeTurnEnd {
|
||||
const outcome = claudeResultOutcome(message)
|
||||
const outcome = claudeResultOutcome(message, stop)
|
||||
const durationMs = message.duration_ms
|
||||
return {
|
||||
state: outcome === 'cancellation' ? 'interrupted' : 'completed',
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity
|
||||
AgentJournalItemIdentity,
|
||||
AgentJournalRenderItem
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
|
||||
import {
|
||||
readAgentJournalTurn,
|
||||
readAgentJournalTurnOutcome
|
||||
} from '../../shared/agent-session-turn-record'
|
||||
import { describeNativeChatTurnStatus } from '../../shared/native-chat-turn-status'
|
||||
import { selectStructuredAgentSettledTurns } from '../../shared/structured-agent-session-turn-timing'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { claudeResultOutcome } from './claude-result-outcome'
|
||||
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
|
||||
@@ -162,23 +166,12 @@ describe('claudeTurnLifecycleItem', () => {
|
||||
})
|
||||
|
||||
describe('a turn end the host inferred', () => {
|
||||
it.each([
|
||||
[
|
||||
'the child ending',
|
||||
(translator: ReturnType<typeof createClaudeJournalTranslator>) =>
|
||||
translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' })
|
||||
],
|
||||
[
|
||||
'a new turn superseding it',
|
||||
(translator: ReturnType<typeof createClaudeJournalTranslator>) =>
|
||||
translator.handle(userTurn('user-2'))
|
||||
]
|
||||
])('records no outcome for a turn ended by %s', (_label, end) => {
|
||||
it('records no outcome for a turn ended by the child ending', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(userTurn('user-1'))
|
||||
end(translator)
|
||||
translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' })
|
||||
|
||||
// No result frame arrived, so the provider never said what became of the
|
||||
// turn. The host observed the END — the arm stays `interrupted` — but the
|
||||
@@ -194,4 +187,173 @@ describe('a turn end the host inferred', () => {
|
||||
expect(settled?.body).not.toHaveProperty('outcome')
|
||||
expect(readAgentJournalTurnOutcome(readAgentJournalTurn(settled?.body))).toBeNull()
|
||||
})
|
||||
|
||||
// The supersede fires for any send Orca dispatched, and nothing here says whether the user or
|
||||
// another agent sent it, so it is recorded as replaced, never as the user's stop or a fault.
|
||||
it('records a turn a newer send superseded as superseded, folded as interrupted', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
let observedAt = 1_000
|
||||
translator.handle({ ...userTurn('user-1'), observedAt })
|
||||
observedAt = 13_000
|
||||
translator.handle({ ...userTurn('user-2'), observedAt })
|
||||
|
||||
// The journal as a reader holds it: each turn row at its newest revision, after the user row
|
||||
// it names (the host journals that row, not this translator).
|
||||
const rows = new Map<string, AgentJournalRenderItem>()
|
||||
state.items.forEach((item, index) => {
|
||||
const itemId = agentJournalItemKey(item.identity)
|
||||
rows.set(itemId, { itemId, revision: index, sequence: index, observedAt, body: item.body })
|
||||
})
|
||||
const items = [...rows.values()].flatMap((row): AgentJournalRenderItem[] => {
|
||||
const userItemId = readAgentJournalTurn(row.body)?.userItemId
|
||||
return userItemId
|
||||
? [
|
||||
{
|
||||
...row,
|
||||
itemId: userItemId,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }
|
||||
},
|
||||
row
|
||||
]
|
||||
: [row]
|
||||
})
|
||||
const superseded = items.find((item) => readAgentJournalTurn(item.body)?.turnId === 'user-1')
|
||||
expect(readAgentJournalTurn(superseded?.body)).toMatchObject({
|
||||
state: 'interrupted',
|
||||
outcome: 'superseded'
|
||||
})
|
||||
const settled = selectStructuredAgentSettledTurns(items).get(
|
||||
readAgentJournalTurn(superseded?.body)?.userItemId ?? ''
|
||||
)
|
||||
expect(settled).toMatchObject({ verdict: 'superseded', workedSeconds: 12 })
|
||||
expect(
|
||||
settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled })
|
||||
).toMatchObject({ key: 'interruptedAfter', duration: '12s' })
|
||||
})
|
||||
})
|
||||
|
||||
describe("a user's Stop inside a live turn", () => {
|
||||
// Claude CLIs before 2.1.91 send no terminal_reason, and later ones may omit it.
|
||||
const cutShort = { type: 'result', subtype: 'error_during_execution', is_error: true }
|
||||
|
||||
function settledTurn(items: ReturnType<typeof sinkState>['items'], turnId: string) {
|
||||
return items
|
||||
.map((item) => readAgentJournalTurn(item.body))
|
||||
.findLast((turn) => turn?.turnId === turnId && turn.state !== 'running')
|
||||
}
|
||||
|
||||
function providerRows(items: ReturnType<typeof sinkState>['items']): number {
|
||||
return items.filter(
|
||||
(item) =>
|
||||
item.identity.provider === 'orca' &&
|
||||
item.identity.clientMessageId.startsWith('provider-frame:')
|
||||
).length
|
||||
}
|
||||
|
||||
it('reads an error result with no terminal reason as the cancellation it asked for', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
translator.handle(userTurn('user-1'))
|
||||
|
||||
expect(translator.recordTurnStop('user-1', 'user-stop')).toBe(true)
|
||||
translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort })
|
||||
|
||||
expect(settledTurn(state.items, 'user-1')).toMatchObject({
|
||||
state: 'interrupted',
|
||||
outcome: 'cancellation'
|
||||
})
|
||||
expect(providerRows(state.items)).toBe(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['no terminal reason', cutShort],
|
||||
[
|
||||
'a failure terminal reason',
|
||||
{ ...cutShort, terminal_reason: 'api_error', result: 'API Error' }
|
||||
]
|
||||
])('keeps a result with %s and no Stop a failure', (_label, frame) => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
translator.handle(userTurn('user-1'))
|
||||
|
||||
translator.handle({ type: 'message', sessionId: 'orca-session', message: frame })
|
||||
|
||||
expect(settledTurn(state.items, 'user-1')).toMatchObject({
|
||||
state: 'completed',
|
||||
outcome: 'failure'
|
||||
})
|
||||
expect(providerRows(state.items)).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps a turn that finished during the Stop a success', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
translator.handle(userTurn('user-1'))
|
||||
|
||||
translator.recordTurnStop('user-1', 'user-stop')
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
message: { type: 'result', subtype: 'success', is_error: false }
|
||||
})
|
||||
|
||||
expect(settledTurn(state.items, 'user-1')).toMatchObject({ outcome: 'success' })
|
||||
})
|
||||
|
||||
it('does not carry a Stop onto the next turn', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
translator.handle(userTurn('user-1'))
|
||||
translator.recordTurnStop('user-1', 'user-stop')
|
||||
// Superseded before its result: the Stop was for user-1 only.
|
||||
translator.handle(userTurn('user-2'))
|
||||
translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort })
|
||||
// A late Stop names a turn that already ended.
|
||||
expect(translator.recordTurnStop('user-2', 'user-stop')).toBe(false)
|
||||
translator.handle(userTurn('user-3'))
|
||||
translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort })
|
||||
|
||||
expect(settledTurn(state.items, 'user-2')).toMatchObject({ outcome: 'failure' })
|
||||
expect(settledTurn(state.items, 'user-3')).toMatchObject({ outcome: 'failure' })
|
||||
})
|
||||
|
||||
it('ends the Stop with its turn, so a result after the turn settled reads on its own', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
translator.handle(userTurn('user-1'))
|
||||
translator.recordTurnStop('user-1', 'user-stop')
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
message: { type: 'system', subtype: 'session_state_changed', state: 'idle' }
|
||||
})
|
||||
|
||||
translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort })
|
||||
|
||||
expect(providerRows(state.items)).toBe(1)
|
||||
})
|
||||
|
||||
it('forgets a Stop the CLI refused', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
translator.handle(userTurn('user-1'))
|
||||
|
||||
translator.recordTurnStop('user-1', 'user-stop')
|
||||
translator.withdrawTurnStop('user-1')
|
||||
translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort })
|
||||
|
||||
expect(settledTurn(state.items, 'user-1')).toMatchObject({ outcome: 'failure' })
|
||||
})
|
||||
|
||||
it('does not read a host stop as the user asking', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
translator.handle(userTurn('user-1'))
|
||||
|
||||
translator.recordTurnStop('user-1', 'host-stop')
|
||||
translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort })
|
||||
|
||||
expect(settledTurn(state.items, 'user-1')).toMatchObject({ outcome: 'failure' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,6 +95,8 @@ function sessionHoldingTurn(turnId: string | null): ReturnType<typeof sessionFor
|
||||
openTurnInLiveProviderCycle: false,
|
||||
journalPrompts: { cancel: vi.fn(), resolve: vi.fn() },
|
||||
currentTurnId: turnId,
|
||||
recordTurnStop: () => true,
|
||||
withdrawTurnStop: () => {},
|
||||
commandTurnId: null,
|
||||
beginCommand: vi.fn(),
|
||||
forgetCommand: vi.fn(),
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Closing a Codex child settles its open turn in the adapter, with the cause the host handed the
|
||||
// close. Only a stop the user aimed at this chat reads as their cancellation.
|
||||
|
||||
import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types'
|
||||
import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
|
||||
import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo'
|
||||
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
|
||||
import { CodexPromptRegistry } from './codex-structured-prompt-replies'
|
||||
import {
|
||||
closeCodexPublishedSession,
|
||||
handleCodexSessionExit
|
||||
} from './codex-structured-session-close'
|
||||
import { CodexAcquisitionRegistry, type CodexSession } from './codex-structured-session-state'
|
||||
import { CodexStructuredSessionTeardown } from './codex-structured-session-teardown'
|
||||
|
||||
/** A live Codex child whose primary thread is inside `turn-1`, and what its ended batch wrote. */
|
||||
function sessionWithRunningTurn() {
|
||||
const turnBodies: AgentJournalItemBody[] = []
|
||||
const sink: StructuredAgentSessionEventSink = {
|
||||
appendItem: () => {},
|
||||
appendTombstone: () => {},
|
||||
publish: () => {},
|
||||
tryAppendLifecycleBatch: (_id, mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.kind === 'item' && mutation.body.kind === 'turn') {
|
||||
turnBodies.push(mutation.body)
|
||||
}
|
||||
}
|
||||
return { accepted: true }
|
||||
}
|
||||
}
|
||||
const translator = createCodexJournalTranslator({
|
||||
sink,
|
||||
sessionId: 'session-1',
|
||||
primaryThreadId: () => 'thread-1',
|
||||
now: () => 2_000
|
||||
})
|
||||
translator.handle({
|
||||
type: 'notification',
|
||||
sessionId: 'session-1',
|
||||
threadId: 'thread-1',
|
||||
method: 'turn/started',
|
||||
params: { turn: { id: 'turn-1' } },
|
||||
observedAt: 1_000
|
||||
})
|
||||
const session: CodexSession = {
|
||||
connection: {
|
||||
pid: 4321,
|
||||
closed: false,
|
||||
request: async () => ({}),
|
||||
notify: () => {},
|
||||
respond: () => {},
|
||||
respondWithError: () => {},
|
||||
close: async () => true
|
||||
},
|
||||
backgroundTasks: new CodexBackgroundTaskTracker('thread-1'),
|
||||
ended: false,
|
||||
requestedClose: false,
|
||||
fence: 7,
|
||||
acquisitionGeneration: 'generation-1',
|
||||
threadId: 'thread-1',
|
||||
historyPath: null,
|
||||
prompts: new CodexPromptRegistry(),
|
||||
options: new Map(),
|
||||
reportedOptions: {},
|
||||
fastModeTierByModel: new Map(),
|
||||
dispatchEchoes: createCodexDispatchEchoes(),
|
||||
turnOpenWaits: createCodexTurnOpenWaits(),
|
||||
translator
|
||||
}
|
||||
return { sessions: new Map([['session-1', session]]), session, turnBodies }
|
||||
}
|
||||
|
||||
describe('a Codex close settles the open turn with the host-named cause', () => {
|
||||
it.each(['user-close', 'user-stop'] satisfies StructuredAgentSessionStopCause[])(
|
||||
"records the user's %s as their cancellation",
|
||||
async (stopCause) => {
|
||||
const { sessions, turnBodies } = sessionWithRunningTurn()
|
||||
const onEvent = vi.fn()
|
||||
|
||||
await expect(
|
||||
closeCodexPublishedSession(sessions, 'session-1', onEvent, { stopCause })
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(turnBodies).toEqual([
|
||||
expect.objectContaining({ turnId: 'turn-1', state: 'interrupted', outcome: 'cancellation' })
|
||||
])
|
||||
expect(onEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'ended', cause: 'requested-close', stopCause })
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([['evict'], ['host-stop'], [undefined]] as const)(
|
||||
'leaves a close for %s as news',
|
||||
async (stopCause) => {
|
||||
const { sessions, turnBodies } = sessionWithRunningTurn()
|
||||
|
||||
await closeCodexPublishedSession(
|
||||
sessions,
|
||||
'session-1',
|
||||
undefined,
|
||||
stopCause ? { stopCause } : {}
|
||||
)
|
||||
|
||||
expect(turnBodies).toEqual([
|
||||
expect.objectContaining({ turnId: 'turn-1', state: 'interrupted' })
|
||||
])
|
||||
expect(turnBodies[0]).not.toHaveProperty('outcome')
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves a crash it saw before the user closed the chat as news', async () => {
|
||||
const { sessions, session, turnBodies } = sessionWithRunningTurn()
|
||||
// The child died on its own first; the user's close then finds it already ended.
|
||||
handleCodexSessionExit({
|
||||
sessions,
|
||||
sessionId: 'session-1',
|
||||
connection: session.connection,
|
||||
error: new Error('app-server exited')
|
||||
})
|
||||
|
||||
await closeCodexPublishedSession(sessions, 'session-1', undefined, { stopCause: 'user-close' })
|
||||
|
||||
expect(turnBodies).toEqual([
|
||||
expect.objectContaining({ turnId: 'turn-1', state: 'interrupted' })
|
||||
])
|
||||
expect(turnBodies[0]).not.toHaveProperty('outcome')
|
||||
})
|
||||
|
||||
it('never carries a user cause onto a close forced after a sink failure', async () => {
|
||||
const { sessions, turnBodies } = sessionWithRunningTurn()
|
||||
|
||||
await closeCodexPublishedSession(sessions, 'session-1', undefined, {
|
||||
requestedClose: false,
|
||||
stopCause: 'user-close'
|
||||
})
|
||||
|
||||
expect(turnBodies[0]).not.toHaveProperty('outcome')
|
||||
})
|
||||
|
||||
it("carries the host's cause from the adapter's close to the ended batch", async () => {
|
||||
const { sessions, turnBodies } = sessionWithRunningTurn()
|
||||
const teardown = new CodexStructuredSessionTeardown({
|
||||
sessions,
|
||||
acquisitions: new CodexAcquisitionRegistry(),
|
||||
forgetNotificationRetries: () => {}
|
||||
})
|
||||
|
||||
await expect(teardown.close('session-1', 'user-close')).resolves.toBe(true)
|
||||
|
||||
expect(turnBodies[0]).toMatchObject({ state: 'interrupted', outcome: 'cancellation' })
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
childEndCauseOfEndedEvent,
|
||||
turnVerdictForChildEnd
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict'
|
||||
import { createCodexProviderActivityReader } from '../native-chat/agent-session-wire/provider-frame-activity'
|
||||
import { CODEX_TOKEN_USAGE_METHOD } from './codex-subagent-activity'
|
||||
import {
|
||||
@@ -157,14 +161,18 @@ export function createCodexJournalTranslator(
|
||||
currentTurnIds: activeTurns.byThread,
|
||||
primaryThreadId: deps.primaryThreadId?.() ?? null,
|
||||
ordinals: items.ordinals,
|
||||
// The host saw the child go, not what Codex made of the turn, so the row
|
||||
// carries no outcome: the end is observed, the verdict is unknown.
|
||||
// The host saw the child go, not what Codex made of the turn: the verdict is only what
|
||||
// the host's own cause says, a user's stop of this chat or else news.
|
||||
settledTurnLifecycle: (threadId, turnId) =>
|
||||
turnBoundaries.ownsRecord(threadId, turnId)
|
||||
? turnBoundaries.settled(threadId, turnId, {
|
||||
state: 'interrupted',
|
||||
completedAt: event.observedAt ?? deps.now?.() ?? Date.now()
|
||||
})
|
||||
? turnBoundaries.settled(
|
||||
threadId,
|
||||
turnId,
|
||||
turnVerdictForChildEnd(
|
||||
childEndCauseOfEndedEvent(event),
|
||||
event.observedAt ?? deps.now?.() ?? Date.now()
|
||||
)
|
||||
)
|
||||
: null,
|
||||
attributionFor
|
||||
})
|
||||
|
||||
@@ -9,7 +9,8 @@ import type {
|
||||
AgentSessionDispatchOutcome,
|
||||
StructuredAgentSessionAcquireInput,
|
||||
StructuredAgentSessionAdapter,
|
||||
StructuredAgentSessionSetOptionInput
|
||||
StructuredAgentSessionSetOptionInput,
|
||||
StructuredAgentSessionStopCause
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation'
|
||||
import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start'
|
||||
@@ -285,9 +286,11 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
|
||||
identity: AgentSessionJournalIdentity
|
||||
}): Promise<string | null> => this.sessions.get(input.identity.sessionId)?.historyPath ?? null
|
||||
|
||||
closeSession = (sessionId: string): Promise<boolean> => this.teardown.close(sessionId)
|
||||
closeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise<boolean> =>
|
||||
this.teardown.close(sessionId, cause)
|
||||
forceCloseSession = (sessionId: string): Promise<boolean> => this.teardown.forceClose(sessionId)
|
||||
disposeSession = (sessionId: string): Promise<boolean> => this.teardown.close(sessionId)
|
||||
disposeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise<boolean> =>
|
||||
this.teardown.close(sessionId, cause)
|
||||
closeAll = (): Promise<void> => this.teardown.closeAll()
|
||||
releaseAcquisition = (input: { sessionId: string }): Promise<boolean> =>
|
||||
this.teardown.close(input.sessionId)
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
type CodexStructuredSessionAdapterDeps,
|
||||
type CodexStructuredSessionEvent
|
||||
} from './codex-structured-session-state'
|
||||
import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type {
|
||||
StructuredAgentSessionEndedEvent,
|
||||
StructuredAgentSessionStopCause
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
|
||||
export function handleCodexSessionExit(input: {
|
||||
sessions: Map<string, CodexSession>
|
||||
@@ -40,6 +43,9 @@ export function handleCodexSessionExit(input: {
|
||||
? agentSessionFailureFact('hostFault')
|
||||
: agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(input.error) }),
|
||||
cause: session.requestedClose ? 'requested-close' : 'unexpected-exit',
|
||||
...(session.requestedClose && session.closeStopCause
|
||||
? { stopCause: session.closeStopCause }
|
||||
: {}),
|
||||
fence: session.fence,
|
||||
acquisitionGeneration: session.acquisitionGeneration,
|
||||
observedAt: session.exitObservedAt
|
||||
@@ -74,6 +80,8 @@ export async function closeCodexPublishedSession(
|
||||
options?: {
|
||||
allowFailedSettlement?: boolean
|
||||
requestedClose?: boolean
|
||||
/** Who asked for a requested close; the translator settles the open turn with it. */
|
||||
stopCause?: StructuredAgentSessionStopCause
|
||||
expectedFence?: number
|
||||
expectedAcquisitionGeneration?: string
|
||||
unexpectedReason?: Error
|
||||
@@ -93,6 +101,7 @@ export async function closeCodexPublishedSession(
|
||||
// Sink-failure recovery force-closes the child but must preserve the
|
||||
// observed-exit cause so host lease settlement runs as an unexpected death.
|
||||
session.requestedClose = options?.requestedClose ?? true
|
||||
session.closeStopCause = options?.stopCause
|
||||
// Keep the session indexed until the child exit is observed. A timeout or
|
||||
// failed kill must leave the live connection available for a safe retry.
|
||||
const exited = await session.connection.close()
|
||||
@@ -124,7 +133,8 @@ export async function closeCodexSession(
|
||||
sessionId: string,
|
||||
sessions: Map<string, CodexSession>,
|
||||
acquisitions: CodexAcquisitionRegistry,
|
||||
onEvent?: (event: CodexStructuredSessionEvent) => void
|
||||
onEvent?: (event: CodexStructuredSessionEvent) => void,
|
||||
stopCause?: StructuredAgentSessionStopCause
|
||||
): Promise<boolean> {
|
||||
const attempt = acquisitions.get(sessionId)
|
||||
if (!(await cancelCodexAcquisitionAttempt(attempt))) {
|
||||
@@ -133,7 +143,7 @@ export async function closeCodexSession(
|
||||
if (attempt) {
|
||||
acquisitions.deleteIfCurrent(sessionId, attempt)
|
||||
}
|
||||
return closeCodexPublishedSession(sessions, sessionId, onEvent)
|
||||
return closeCodexPublishedSession(sessions, sessionId, onEvent, stopCause ? { stopCause } : {})
|
||||
}
|
||||
|
||||
export async function closeAllCodexSessions(
|
||||
|
||||
@@ -19,7 +19,10 @@ import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session
|
||||
import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence'
|
||||
import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
|
||||
import type { CodexJournalTranslator } from './codex-structured-journal-translation'
|
||||
import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type {
|
||||
StructuredAgentSessionEndedEvent,
|
||||
StructuredAgentSessionStopCause
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy'
|
||||
import type {
|
||||
AgentModelCatalogSessionAccess,
|
||||
@@ -111,6 +114,8 @@ export type CodexSession = {
|
||||
/** First observed child exit survives rejected settlement admission. */
|
||||
exitObservedAt?: number
|
||||
requestedClose: boolean
|
||||
/** Who asked for the requested close in flight, carried onto its `ended`. */
|
||||
closeStopCause?: StructuredAgentSessionStopCause
|
||||
fence: number
|
||||
acquisitionGeneration: string
|
||||
threadId: string
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// proven stopped — a refused close leaves the session indexed for a retry.
|
||||
|
||||
import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire'
|
||||
import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import {
|
||||
closeAllCodexSessions,
|
||||
closeCodexPublishedSession,
|
||||
@@ -30,12 +31,13 @@ export type CodexStructuredSessionTeardownDeps = {
|
||||
export class CodexStructuredSessionTeardown {
|
||||
constructor(private readonly deps: CodexStructuredSessionTeardownDeps) {}
|
||||
|
||||
close = async (sessionId: string): Promise<boolean> => {
|
||||
close = async (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise<boolean> => {
|
||||
const closed = await closeCodexSession(
|
||||
sessionId,
|
||||
this.deps.sessions,
|
||||
this.deps.acquisitions,
|
||||
this.deps.onEvent
|
||||
this.deps.onEvent,
|
||||
cause
|
||||
)
|
||||
return this.settled(sessionId, closed)
|
||||
}
|
||||
|
||||
@@ -76,12 +76,24 @@ function formatAgentNotificationStatusText(args: NotificationDispatchRequest): s
|
||||
if (args.agentState === 'working') {
|
||||
return translateMain('notifications.agentStatus.working', 'working')
|
||||
}
|
||||
if (args.agentState === 'done' && args.agentTurnOutcome === 'failure') {
|
||||
return translateMain('notifications.agentStatus.failed', 'failed')
|
||||
if (args.agentState !== 'done') {
|
||||
return translateMain('notifications.agentStatus.finished', 'finished')
|
||||
}
|
||||
switch (args.agentTurnOutcome) {
|
||||
// A turn cut short by anything but the user is a fault, as a failure is.
|
||||
case 'failure':
|
||||
case 'interruption':
|
||||
return translateMain('notifications.agentStatus.failed', 'failed')
|
||||
// Why: a Stop the user asked for, a turn a newer request replaced, or an end Orca cannot
|
||||
// prove, still never reads finished.
|
||||
case 'cancellation':
|
||||
case 'superseded':
|
||||
case 'unconfirmed':
|
||||
return translateMain('notifications.agentStatus.stopped', 'stopped')
|
||||
case 'success':
|
||||
case undefined:
|
||||
return translateMain('notifications.agentStatus.finished', 'finished')
|
||||
}
|
||||
return args.agentState === 'done' && args.agentTurnOutcome === 'cancellation'
|
||||
? translateMain('notifications.agentStatus.stopped', 'stopped')
|
||||
: translateMain('notifications.agentStatus.finished', 'finished')
|
||||
}
|
||||
|
||||
function formatNotificationWorktreeContext(args: NotificationDispatchRequest): string {
|
||||
|
||||
@@ -319,6 +319,10 @@ describe('registerNotificationHandlers', () => {
|
||||
it.each([
|
||||
{ agentTurnOutcome: 'cancellation', word: 'stopped' },
|
||||
{ agentTurnOutcome: 'failure', word: 'failed' },
|
||||
// A turn cut short by anything but the user is a fault, worded as one.
|
||||
{ agentTurnOutcome: 'interruption', word: 'failed' },
|
||||
{ agentTurnOutcome: 'unconfirmed', word: 'stopped' },
|
||||
{ agentTurnOutcome: 'superseded', word: 'stopped' },
|
||||
{ agentTurnOutcome: 'success', word: 'finished' },
|
||||
{ agentTurnOutcome: undefined, word: 'finished' }
|
||||
] as const)('words a $agentTurnOutcome finish as $word', async ({ agentTurnOutcome, word }) => {
|
||||
|
||||
+21
-21
@@ -231,7 +231,7 @@ function deferred<T>() {
|
||||
async function writeAsEarlierProcess(
|
||||
write: (journal: AgentSessionJournal, fence: number) => Promise<void>
|
||||
): Promise<void> {
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
const record = store.getRecord(SESSION)!
|
||||
const params = attachParamsForRecord(record, {
|
||||
clientOperationId: 'earlier',
|
||||
@@ -261,7 +261,7 @@ function earlierSubmission(id: string, text: string, handoverRecorded?: true) {
|
||||
|
||||
describe('a send is answered at acceptance', () => {
|
||||
it('answers before the child starts, then an open chat sees the handover and the reply (W2)', async () => {
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
const starting = deferred<void>()
|
||||
acquire.mockImplementationOnce(async (input) => {
|
||||
await starting.promise
|
||||
@@ -289,7 +289,7 @@ describe('a send is answered at acceptance', () => {
|
||||
})
|
||||
|
||||
it('accepts a second send while the first one starts the child, before handing either over (W6)', async () => {
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
const starting = deferred<void>()
|
||||
acquire.mockImplementationOnce(async (input) => {
|
||||
await starting.promise
|
||||
@@ -320,7 +320,7 @@ describe('a send is answered at acceptance', () => {
|
||||
|
||||
describe('a start the chat needed and did not get', () => {
|
||||
it('writes one error row and rejects every queued message with it; the next send starts (W3)', async () => {
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT'))
|
||||
const first = await accept('first')
|
||||
const second = await accept('second')
|
||||
@@ -350,7 +350,7 @@ describe('a start the chat needed and did not get', () => {
|
||||
})
|
||||
|
||||
it('draws the messages it failed above the error row, since they were accepted first', async () => {
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT'))
|
||||
const first = await accept('first')
|
||||
const second = await accept('second')
|
||||
@@ -369,7 +369,7 @@ describe('a start the chat needed and did not get', () => {
|
||||
})
|
||||
|
||||
it('notifies failed once for the queued messages one start failure refused', async () => {
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT'))
|
||||
const completions: AgentSessionTurnCompletionEvent[] = []
|
||||
host.subscribeTurnCompletions({ id: 'dot-1', emit: (event) => completions.push(event) })
|
||||
@@ -434,7 +434,7 @@ describe('a start the chat needed and did not get', () => {
|
||||
}
|
||||
]
|
||||
])('writes one row a live chat sees for a %s refusal (W14)', async (_source, arrange, row) => {
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
arrange()
|
||||
await host.flushAllStreamedEvents()
|
||||
await startHost()
|
||||
@@ -455,7 +455,7 @@ describe('a start the chat needed and did not get', () => {
|
||||
})
|
||||
|
||||
it('names the start failure on queued messages when the attach fails after acquiring (W4′a)', async () => {
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
const id = await accept('hello')
|
||||
// The attach's own success record is the post-acquisition step that fails.
|
||||
const record = vi.spyOn(store, 'recordOperationOutcome')
|
||||
@@ -487,7 +487,7 @@ describe('a start the chat needed and did not get', () => {
|
||||
|
||||
describe('an attach that fails after indexing its child', () => {
|
||||
it('leaves no child behind, so the next send starts one and is delivered', async () => {
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
const owned: boolean[] = []
|
||||
host.subscribeStatus({
|
||||
id: 'list-1',
|
||||
@@ -578,7 +578,7 @@ describe('a child that exits before its message is handed over', () => {
|
||||
})
|
||||
})
|
||||
adapterExtras = { awaitStarted }
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
await startHost()
|
||||
|
||||
const id = await accept('hello')
|
||||
@@ -602,7 +602,7 @@ describe('a start whose failure the delivery loop settles before the exit is pub
|
||||
...(await spawnChild(input)),
|
||||
providerChildPhase: 'starting' as const
|
||||
}))
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
await startHost()
|
||||
|
||||
const first = await accept('first')
|
||||
@@ -659,7 +659,7 @@ describe('Stop withdraws what is queued', () => {
|
||||
})
|
||||
|
||||
it('withdraws a message whose start holds the queue: nothing is handed over (W17b)', async () => {
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
const starting = deferred<void>()
|
||||
acquire.mockImplementationOnce(async (input) => {
|
||||
await starting.promise
|
||||
@@ -685,7 +685,7 @@ describe('Stop withdraws what is queued', () => {
|
||||
return true
|
||||
})
|
||||
adapterExtras = { awaitStarted, closeSession }
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
await startHost()
|
||||
acquire.mockImplementationOnce(async (input) => ({
|
||||
...(await spawnChild(input)),
|
||||
@@ -713,12 +713,12 @@ describe('an eviction between acceptance and handover', () => {
|
||||
it('rejects the message as not sent, never leaves it in doubt (W24)', async () => {
|
||||
const started = deferred<void>()
|
||||
adapterExtras = { awaitStarted: () => started.promise }
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
await startHost()
|
||||
const id = await accept('hello')
|
||||
await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2))
|
||||
// Between the delivery loop's start step and its handover step.
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
started.resolve()
|
||||
|
||||
expect(await reopened(id)).toMatchObject({
|
||||
@@ -732,7 +732,7 @@ describe('an eviction between acceptance and handover', () => {
|
||||
const second = deferred<void>()
|
||||
const awaitStarted = vi.fn(async (): Promise<void> => undefined)
|
||||
adapterExtras = { awaitStarted }
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
await startHost()
|
||||
dispatch.mockResolvedValueOnce({ state: 'admitted' })
|
||||
const handed = await accept('handed over')
|
||||
@@ -741,7 +741,7 @@ describe('an eviction between acceptance and handover', () => {
|
||||
const queued = await accept('still queued')
|
||||
await eventually(async () => expect(awaitStarted).toHaveBeenCalledTimes(2))
|
||||
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
second.resolve()
|
||||
|
||||
expect(await reopened(queued)).toMatchObject({
|
||||
@@ -756,7 +756,7 @@ describe('an eviction between acceptance and handover', () => {
|
||||
idleMs = 0
|
||||
const started = deferred<void>()
|
||||
adapterExtras = { awaitStarted: () => started.promise }
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
await startHost()
|
||||
const id = await accept('hello')
|
||||
await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2))
|
||||
@@ -775,7 +775,7 @@ describe('an eviction between acceptance and handover', () => {
|
||||
// leaves every queued message rejected as closed, never blamed on the provider.
|
||||
describe('a close that stops the child and then fails', () => {
|
||||
const END_CHILD = {
|
||||
evict: () => host.close(SESSION)
|
||||
evict: () => host.close(SESSION, 'evict')
|
||||
} satisfies Partial<Record<StructuredAgentSessionChildEndCause, () => Promise<void>>>
|
||||
|
||||
it.each([
|
||||
@@ -792,7 +792,7 @@ describe('a close that stops the child and then fails', () => {
|
||||
throw new Error('release acknowledgement failed')
|
||||
})
|
||||
}
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
await startHost()
|
||||
acquire.mockImplementationOnce(async (input) => ({
|
||||
...(await spawnChild(input)),
|
||||
@@ -816,7 +816,7 @@ describe('a close that stops the child and then fails', () => {
|
||||
|
||||
describe('a compaction or rewind an earlier child left prepared', () => {
|
||||
async function leftPrepared(prepare: (fence: number) => Promise<unknown>): Promise<void> {
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
await prepare(store.getRecord(SESSION)!.lease.runtimeFence)
|
||||
// A new process: nothing is open and no view attaches.
|
||||
await host.flushAllStreamedEvents()
|
||||
|
||||
+2
-1
@@ -161,7 +161,8 @@ describe('StructuredAgentSessionAdapterRouter optional lifecycle methods', () =>
|
||||
const stopSession = router[method]
|
||||
|
||||
await expect(stopSession('session-1')).resolves.toBe(true)
|
||||
expect(closeSession).toHaveBeenCalledWith('session-1')
|
||||
// A host with no cause to name passes none; the adapter settles its turn as news.
|
||||
expect(closeSession).toHaveBeenCalledWith('session-1', undefined)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -4,7 +4,10 @@ import type {
|
||||
AgentSessionAccountHome,
|
||||
AgentSessionExecutionLocation
|
||||
} from '../../../shared/agent-session-record'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
import type {
|
||||
StructuredAgentSessionAdapter,
|
||||
StructuredAgentSessionStopCause
|
||||
} from './structured-agent-session-adapter'
|
||||
|
||||
type RoutedAgent = 'claude' | 'codex'
|
||||
type SessionRoute = { adapter: StructuredAgentSessionAdapter; state: 'live' | 'stopped' }
|
||||
@@ -148,20 +151,21 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
|
||||
accountHome: AgentSessionAccountHome
|
||||
}) => this.requireAgent(input.identity).providerHistoryWindow?.(input) ?? Promise.resolve(null)
|
||||
|
||||
closeSession = (sessionId: string): Promise<boolean> =>
|
||||
this.stopSession(sessionId, (adapter) => adapter.closeSession)
|
||||
closeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise<boolean> =>
|
||||
this.stopSession(sessionId, (adapter) => adapter.closeSession, cause)
|
||||
|
||||
forceCloseSession = (sessionId: string): Promise<boolean> =>
|
||||
this.stopSession(sessionId, (adapter) => adapter.forceCloseSession ?? adapter.closeSession)
|
||||
|
||||
disposeSession = (sessionId: string): Promise<boolean> =>
|
||||
this.stopSession(sessionId, (adapter) => adapter.disposeSession ?? adapter.closeSession)
|
||||
disposeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise<boolean> =>
|
||||
this.stopSession(sessionId, (adapter) => adapter.disposeSession ?? adapter.closeSession, cause)
|
||||
|
||||
private async stopSession(
|
||||
sessionId: string,
|
||||
selectStop: (
|
||||
adapter: StructuredAgentSessionAdapter
|
||||
) => NonNullable<StructuredAgentSessionAdapter['closeSession']> | undefined
|
||||
) => NonNullable<StructuredAgentSessionAdapter['closeSession']> | undefined,
|
||||
cause?: StructuredAgentSessionStopCause
|
||||
): Promise<boolean> {
|
||||
const route = this.routes.get(sessionId)
|
||||
if (!route) {
|
||||
@@ -174,7 +178,7 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
|
||||
return true
|
||||
}
|
||||
const stop = selectStop(route.adapter)
|
||||
const stopped = await stop?.call(route.adapter, sessionId)
|
||||
const stopped = await stop?.call(route.adapter, sessionId, cause)
|
||||
if (stopped === true) {
|
||||
route.state = 'stopped'
|
||||
return true
|
||||
|
||||
@@ -38,6 +38,11 @@ import type {
|
||||
ProviderDiagnostic,
|
||||
SubmissionRejectionFact
|
||||
} from '../../../shared/agent-session-failure'
|
||||
import type { StructuredAgentSessionStopCause } from './structured-agent-session-stop-cause'
|
||||
export type {
|
||||
StructuredAgentSessionChildEndCause,
|
||||
StructuredAgentSessionStopCause
|
||||
} from './structured-agent-session-stop-cause'
|
||||
import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words'
|
||||
import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer'
|
||||
import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler'
|
||||
@@ -186,6 +191,8 @@ export type StructuredAgentSessionEndedEvent = {
|
||||
* Orca fault. Absent reads as a provider exit with nothing to add. */
|
||||
failure?: SubmissionRejectionFact
|
||||
cause: 'unexpected-exit' | 'requested-close'
|
||||
/** With `requested-close`: who asked for it. Absent when the host named no cause. */
|
||||
stopCause?: StructuredAgentSessionStopCause
|
||||
fence: number
|
||||
acquisitionGeneration: string
|
||||
/** Host receipt of the child exit: the end time of a turn it interrupted. */
|
||||
@@ -375,11 +382,11 @@ export type StructuredAgentSessionAdapter = {
|
||||
/** Gracefully stops the structured owner after its event stream is drained. */
|
||||
/** Returns true only after the provider child exit is proven. A root-exit or processless verdict
|
||||
* is thrown only once the session is finalized; read it through `stopAgentSessionProviderRoot`. */
|
||||
closeSession?(sessionId: string): Promise<boolean>
|
||||
closeSession?(sessionId: string, cause?: StructuredAgentSessionStopCause): Promise<boolean>
|
||||
/** Stops a provider after a sink failure; the resulting exit is recovered as unexpected. */
|
||||
forceCloseSession?(sessionId: string): Promise<boolean>
|
||||
/** Stops a provider child for teardown without requiring a future-resume cursor. */
|
||||
disposeSession?(sessionId: string): Promise<boolean>
|
||||
disposeSession?(sessionId: string, cause?: StructuredAgentSessionStopCause): Promise<boolean>
|
||||
/** Host acknowledgement that the proven-dead child, lease and journal owner are released. */
|
||||
acknowledgeSessionRelease?(sessionId: string): void
|
||||
}
|
||||
|
||||
+2
-1
@@ -135,7 +135,8 @@ describe('Claude root-exit stop', () => {
|
||||
now: () => NOW + 30 * 60_000,
|
||||
publishStatus
|
||||
},
|
||||
'session-1'
|
||||
'session-1',
|
||||
{ cause: 'evict' }
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
// A turn the host cuts short by closing its provider is the user's cancellation only when the user
|
||||
// closed this chat. A quit, an idle eviction or a teardown aimed elsewhere leaves it news: the user
|
||||
// needs to learn it did not finish. The adapter settles its own open turn with the cause the host
|
||||
// hands its close, and the host's fallback settles any turn no adapter did, through one mapping.
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
AGENT_JOURNAL_THREAD_SCOPE,
|
||||
type AgentJournalTurnLifecycle
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire'
|
||||
import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record'
|
||||
import { describeNativeChatTurnStatus } from '../../../shared/native-chat-turn-status'
|
||||
import { selectStructuredAgentSettledTurns } from '../../../shared/structured-agent-session-turn-timing'
|
||||
import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule'
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { StructuredAgentSessionHost } from './structured-agent-session-host'
|
||||
import type { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime'
|
||||
import {
|
||||
adapter,
|
||||
attach,
|
||||
hostTestState,
|
||||
replaceHostTestState
|
||||
} from './structured-agent-session-host-test-harness'
|
||||
import {
|
||||
HOST_TEST_NOW,
|
||||
HOST_TEST_SESSION as SESSION,
|
||||
HOST_TEST_THREAD as THREAD
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import {
|
||||
childEndCauseOfEndedEvent,
|
||||
turnVerdictForChildEnd
|
||||
} from './structured-agent-session-stale-turn-verdict'
|
||||
|
||||
const CUT_TURN = { provider: 'codex' as const, threadId: THREAD, turnId: 'cut-turn', ordinal: 1 }
|
||||
|
||||
let host: StructuredAgentSessionHost
|
||||
/** What the provider writes on the open turn as it exits: settled through the mapping with the
|
||||
* cause its close was handed, as both adapters do, or its own verdict; null writes nothing. */
|
||||
let providerEnd:
|
||||
| 'mapped'
|
||||
| Pick<AgentJournalTurnLifecycle, 'state' | 'outcome' | 'completedAt'>
|
||||
| null
|
||||
/** The provider already saw its own exit when the close arrived. */
|
||||
let exitObservedFirst: boolean
|
||||
let closeCalls = 0
|
||||
|
||||
beforeEach(() => {
|
||||
const state = hostTestState()
|
||||
providerEnd = 'mapped'
|
||||
exitObservedFirst = false
|
||||
closeCalls = 0
|
||||
host = new StructuredAgentSessionHost({
|
||||
store: state.store,
|
||||
adapter: {
|
||||
...adapter(),
|
||||
closeSession: async (_sessionId, cause) => {
|
||||
closeCalls += 1
|
||||
const events = state.acquire.mock.calls.at(-1)?.[0].events
|
||||
if (providerEnd === null) {
|
||||
return true
|
||||
}
|
||||
const end =
|
||||
providerEnd === 'mapped'
|
||||
? turnVerdictForChildEnd(
|
||||
childEndCauseOfEndedEvent({
|
||||
type: 'ended',
|
||||
cause: exitObservedFirst ? 'unexpected-exit' : 'requested-close',
|
||||
...(cause ? { stopCause: cause } : {})
|
||||
}),
|
||||
1_500
|
||||
)
|
||||
: providerEnd
|
||||
events?.appendItem(
|
||||
CUT_TURN,
|
||||
{
|
||||
kind: 'turn',
|
||||
turnId: 'cut-turn',
|
||||
startedAt: 1_000,
|
||||
requestedAt: 1_000,
|
||||
...end
|
||||
},
|
||||
{ turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
return true
|
||||
}
|
||||
},
|
||||
journalDatabase: openTestJournalHostDatabase(state.root),
|
||||
recoveryCapsule: new AgentSessionRecoveryCapsule(state.root),
|
||||
claimKeyId: 'key-1',
|
||||
mintSpawnToken: () => 'spawn-a',
|
||||
now: () => HOST_TEST_NOW
|
||||
})
|
||||
replaceHostTestState({ store: state.store, host })
|
||||
})
|
||||
|
||||
/** A running turn, anchored to its user row, with a status list watching the session. */
|
||||
async function runningTurn(): Promise<AgentSessionStatusEvent[]> {
|
||||
await attach()
|
||||
const events = hostTestState().acquire.mock.calls[0]?.[0].events
|
||||
if (!events) {
|
||||
throw new Error('missing provider event sink')
|
||||
}
|
||||
events.appendItem(
|
||||
{ provider: 'codex', threadId: THREAD, turnId: 'cut-turn', ordinal: 0 },
|
||||
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] },
|
||||
{ turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
events.appendItem(
|
||||
CUT_TURN,
|
||||
{
|
||||
kind: 'turn',
|
||||
turnId: 'cut-turn',
|
||||
state: 'running',
|
||||
startedAt: 1_000,
|
||||
requestedAt: 1_000
|
||||
},
|
||||
{ turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
await host.flushStreamedEvents(SESSION)
|
||||
const statuses: AgentSessionStatusEvent[] = []
|
||||
host.subscribeStatus({ id: 'list', emit: (event) => statuses.push(event) })
|
||||
return statuses
|
||||
}
|
||||
|
||||
/** What the settle wrote, read back from the journal the next reader opens. */
|
||||
async function settledTurn() {
|
||||
await host.restoreReadableSessions([SESSION])
|
||||
const { items } = await host.journalSnapshot(SESSION)
|
||||
const turn = items.map((item) => readAgentJournalTurn(item.body)).find(Boolean)
|
||||
const [settled] = [...selectStructuredAgentSettledTurns(items).values()]
|
||||
return { turn, settled }
|
||||
}
|
||||
|
||||
function lastSummary(statuses: AgentSessionStatusEvent[]) {
|
||||
const last = statuses.at(-1)
|
||||
return last?.type === 'status' ? last.session : null
|
||||
}
|
||||
|
||||
describe('a turn cut short by closing its provider', () => {
|
||||
it("records the user's close of this chat as their cancellation", async () => {
|
||||
const statuses = await runningTurn()
|
||||
|
||||
await host.close(SESSION, 'user-close')
|
||||
|
||||
expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'cancellation' })
|
||||
const { turn, settled } = await settledTurn()
|
||||
expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' })
|
||||
expect(
|
||||
settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled })
|
||||
).toMatchObject({ key: 'interruptedAfter' })
|
||||
})
|
||||
|
||||
it('leaves a close the user did not aim at this chat as news', async () => {
|
||||
const statuses = await runningTurn()
|
||||
|
||||
// What an idle eviction, a worktree teardown or an orchestration stop issues.
|
||||
await host.close(SESSION, 'evict')
|
||||
|
||||
expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'interruption' })
|
||||
const { turn, settled } = await settledTurn()
|
||||
expect(turn).toMatchObject({ state: 'interrupted' })
|
||||
expect(turn).not.toHaveProperty('outcome')
|
||||
expect(
|
||||
settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled })
|
||||
).toMatchObject({ key: 'failedAfter' })
|
||||
})
|
||||
|
||||
it("records the user's close on a turn no adapter settled, through the host's fallback", async () => {
|
||||
providerEnd = null
|
||||
const statuses = await runningTurn()
|
||||
|
||||
await host.close(SESSION, 'user-close')
|
||||
|
||||
expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'cancellation' })
|
||||
const { turn } = await settledTurn()
|
||||
expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' })
|
||||
})
|
||||
|
||||
it("records the user's close on a turn whose start landed only as the provider stopped", async () => {
|
||||
// A send echo still in flight when the close arrives: the journal has no turn yet.
|
||||
await attach()
|
||||
await host.flushStreamedEvents(SESSION)
|
||||
|
||||
await host.close(SESSION, 'user-close')
|
||||
|
||||
const { turn } = await settledTurn()
|
||||
expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' })
|
||||
})
|
||||
|
||||
it('leaves a turn cut off before the close as news', async () => {
|
||||
providerEnd = null
|
||||
await attach()
|
||||
const events = hostTestState().acquire.mock.calls[0]?.[0].events
|
||||
// An earlier death the user already saw as Interrupted, then closed.
|
||||
events?.appendItem(
|
||||
CUT_TURN,
|
||||
{
|
||||
kind: 'turn',
|
||||
turnId: 'cut-turn',
|
||||
state: 'interrupted',
|
||||
startedAt: 1_000,
|
||||
requestedAt: 1_000,
|
||||
completedAt: 1_200
|
||||
},
|
||||
{ turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
await host.flushStreamedEvents(SESSION)
|
||||
|
||||
await host.close(SESSION, 'user-close')
|
||||
|
||||
const { turn } = await settledTurn()
|
||||
expect(turn).toMatchObject({ state: 'interrupted', completedAt: 1_200 })
|
||||
expect(turn).not.toHaveProperty('outcome')
|
||||
})
|
||||
|
||||
it('keeps a turn the provider finished during the stop as finished', async () => {
|
||||
providerEnd = { state: 'completed', outcome: 'success', completedAt: 1_500 }
|
||||
await runningTurn()
|
||||
|
||||
await host.close(SESSION, 'user-close')
|
||||
|
||||
const { turn } = await settledTurn()
|
||||
expect(turn).toMatchObject({ state: 'completed', outcome: 'success' })
|
||||
})
|
||||
|
||||
it('keeps a verdict the provider gave on its way out', async () => {
|
||||
// How Codex records a turn it reports failed.
|
||||
providerEnd = { state: 'interrupted', outcome: 'failure', completedAt: 1_500 }
|
||||
await runningTurn()
|
||||
|
||||
await host.close(SESSION, 'user-close')
|
||||
|
||||
const { turn } = await settledTurn()
|
||||
expect(turn).toMatchObject({ state: 'interrupted', outcome: 'failure' })
|
||||
})
|
||||
|
||||
it('leaves a quit as news', async () => {
|
||||
const statuses = await runningTurn()
|
||||
|
||||
await host.flushAllStreamedEvents({ trigger: 'quit' })
|
||||
|
||||
expect(statuses.findLast((event) => event.type === 'status')).toMatchObject({
|
||||
session: { status: 'idle', turnOutcome: 'interruption' }
|
||||
})
|
||||
const { settled } = await settledTurn()
|
||||
expect(
|
||||
settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled })
|
||||
).toMatchObject({ key: 'failedAfter' })
|
||||
})
|
||||
|
||||
it("keeps the user's cancellation when a close aborts after the provider settled, then retries", async () => {
|
||||
await runningTurn()
|
||||
const sink = host['runtimeState'].eventSinkFor(SESSION)
|
||||
const drained = sink.drained.bind(sink)
|
||||
let failed = false
|
||||
vi.spyOn(sink, 'drained').mockImplementation(async () => {
|
||||
if (closeCalls > 0 && !failed) {
|
||||
failed = true
|
||||
return { ok: false, error: new Error('drain failed') }
|
||||
}
|
||||
return drained()
|
||||
})
|
||||
// The adapter settled the turn, then the close aborted at the drain after it.
|
||||
await expect(host.close(SESSION, 'user-close')).rejects.toThrow()
|
||||
expect(closeCalls).toBe(1)
|
||||
await host.close(SESSION, 'user-close')
|
||||
const { turn } = await settledTurn()
|
||||
expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['user-close', { outcome: 'cancellation' }],
|
||||
['evict', { outcome: undefined }]
|
||||
] as const)(
|
||||
"keeps a %s's cause when the idle sweep finishes a wind-down it could not",
|
||||
async (cause, verdict) => {
|
||||
providerEnd = null
|
||||
await runningTurn()
|
||||
const sink = host['runtimeState'].eventSinkFor(SESSION)
|
||||
const drained = sink.drained.bind(sink)
|
||||
let failed = false
|
||||
vi.spyOn(sink, 'drained').mockImplementation(async () => {
|
||||
if (closeCalls > 0 && !failed) {
|
||||
failed = true
|
||||
return { ok: false, error: new Error('drain failed') }
|
||||
}
|
||||
return drained()
|
||||
})
|
||||
// The provider is proven gone, then the close aborts before the host settles its turn.
|
||||
await expect(host.close(SESSION, cause)).rejects.toThrow()
|
||||
|
||||
await host.collaboratorsForTests().lifetime.idleSweep.tick()
|
||||
|
||||
expect(closeCalls).toBe(1)
|
||||
const { turn } = await settledTurn()
|
||||
expect(turn).toMatchObject({ state: 'interrupted' })
|
||||
expect(turn?.outcome).toBe(verdict.outcome)
|
||||
}
|
||||
)
|
||||
|
||||
it("leaves a quit's cut on a turn no adapter settled as news", async () => {
|
||||
providerEnd = null
|
||||
await runningTurn()
|
||||
|
||||
await host.flushAllStreamedEvents({ trigger: 'quit' })
|
||||
|
||||
const { turn } = await settledTurn()
|
||||
expect(turn).toMatchObject({ state: 'interrupted' })
|
||||
expect(turn).not.toHaveProperty('outcome')
|
||||
})
|
||||
|
||||
it('leaves a crash the provider saw before the user closed the chat as news', async () => {
|
||||
exitObservedFirst = true
|
||||
await runningTurn()
|
||||
// The provider reports its own exit, which it saw first, as it closes: no verdict.
|
||||
await host.close(SESSION, 'user-close')
|
||||
const { turn } = await settledTurn()
|
||||
expect(turn).toMatchObject({ state: 'interrupted' })
|
||||
expect(turn).not.toHaveProperty('outcome')
|
||||
})
|
||||
|
||||
it.each(['user-close', 'evict'] as const)(
|
||||
'closes the conversation of a chat a %s ends, as any close does',
|
||||
async (cause) => {
|
||||
await runningTurn()
|
||||
|
||||
await host.close(SESSION, cause)
|
||||
|
||||
expect(host.hasSession(SESSION)).toBe(false)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('requires every stop to name its cause', () => {
|
||||
type StopArgs = Parameters<typeof stopStructuredAgentSessionAgentUnderSerialize>
|
||||
// @ts-expect-error a stop that names no cause must not compile, or it would default to one
|
||||
const omitted: StopArgs = [host['lifetimeContext'](), SESSION]
|
||||
expect(omitted).toHaveLength(2)
|
||||
})
|
||||
+1
-1
@@ -131,7 +131,7 @@ describe('the completion feed around /compact (B6)', () => {
|
||||
expect(latestStructuredAgentSessionRequest(items, submissions)).toMatchObject({
|
||||
kind: 'turn',
|
||||
id: 'turn-1',
|
||||
running: false,
|
||||
turnState: 'completed',
|
||||
outcome: 'success'
|
||||
})
|
||||
}
|
||||
|
||||
+3
-3
@@ -137,8 +137,8 @@ describe('closing the handle', () => {
|
||||
it('keeps the row when a chat with an open tab is evicted, and forgets it once the tab closes', async () => {
|
||||
await foundRestTestChat(rig)
|
||||
|
||||
await rig.host.close(SESSION)
|
||||
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)
|
||||
await rig.host.close(SESSION, 'evict')
|
||||
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')
|
||||
// The stop says not-running; the row belongs to the tab, so nothing forgets it.
|
||||
expect(rig.sink.forget).not.toHaveBeenCalled()
|
||||
expect(rig.sink.publish.mock.calls.at(-1)?.[0]).toMatchObject({ sessionId: SESSION })
|
||||
@@ -261,7 +261,7 @@ describe('a start that never finishes (P2-15)', () => {
|
||||
rig.clock.now += IDLE_MS + 1
|
||||
|
||||
await sweepOnce(rig.host)
|
||||
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)
|
||||
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'host-stop')
|
||||
await vi.waitFor(() =>
|
||||
expect(readerSaw(reader.events).submissions).toContainEqual(
|
||||
expect.objectContaining({ dispatchState: 'rejected', reason: stopReason })
|
||||
|
||||
+8
-8
@@ -15,8 +15,10 @@ import {
|
||||
abandonQueuedStructuredAgentSessionMessages,
|
||||
closeStructuredAgentSessionConversationUnderSerialize,
|
||||
stopStructuredAgentSessionAgentUnderSerialize,
|
||||
type StructuredAgentSessionCloseCause,
|
||||
type StructuredAgentSessionLifetimeContext
|
||||
} from './structured-agent-session-host-lifetime'
|
||||
import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter'
|
||||
import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types'
|
||||
import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep'
|
||||
import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission'
|
||||
@@ -46,9 +48,8 @@ export function createStructuredAgentSessionConversationLifetime(host: {
|
||||
session.journal.whenImported().catch((error: unknown) => {
|
||||
throw readRefusals.refusal(sessionId, error)
|
||||
})
|
||||
// The sweep's stop puts an idle agent to rest: nothing is queued, so no loop reads its cause.
|
||||
const stopAgent = (sessionId: string) =>
|
||||
stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId)
|
||||
const stopAgent = (sessionId: string, cause: StructuredAgentSessionStopCause) =>
|
||||
stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause })
|
||||
|
||||
const closeConversation = (sessionId: string): Promise<boolean> =>
|
||||
closeStructuredAgentSessionConversationUnderSerialize(
|
||||
@@ -75,7 +76,8 @@ export function createStructuredAgentSessionConversationLifetime(host: {
|
||||
return record !== null && deps().hasOpenDispatch?.(record) === true
|
||||
},
|
||||
providerHoldsDispatch: (sessionId) => deps().adapter.holdsDispatch?.(sessionId) === true,
|
||||
stopAgent,
|
||||
// The host puts an idle agent to rest: a turn it cuts short is news, not the user's Stop.
|
||||
stopAgent: (sessionId) => stopAgent(sessionId, 'evict'),
|
||||
// A host stop: the delivery loop waiting on this child writes the one error row and rejects
|
||||
// what is queued with it, both worded from the hostStopped fact.
|
||||
stopStartingAgent: (sessionId) =>
|
||||
@@ -146,7 +148,7 @@ export function createStructuredAgentSessionConversationLifetime(host: {
|
||||
},
|
||||
/** Ends a chat's resources, not the chat: its record and journal stay on disk, and what is
|
||||
* still queued will not be sent. */
|
||||
close: (sessionId: string): Promise<void> =>
|
||||
close: (sessionId: string, cause: StructuredAgentSessionCloseCause): Promise<void> =>
|
||||
serialize(sessionId, async () => {
|
||||
readRefusals.forget(sessionId)
|
||||
const session = sessions.get(sessionId)
|
||||
@@ -154,9 +156,7 @@ export function createStructuredAgentSessionConversationLifetime(host: {
|
||||
// Abandoned before the stop, so no start delivers it.
|
||||
await abandonQueuedStructuredAgentSessionMessages(deps(), sessionId, session.journal)
|
||||
}
|
||||
await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, {
|
||||
cause: 'evict'
|
||||
})
|
||||
await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause })
|
||||
await closeConversation(sessionId)
|
||||
})
|
||||
}
|
||||
|
||||
+35
-1
@@ -10,10 +10,14 @@ import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire'
|
||||
import type {
|
||||
AgentSessionStatusSummary,
|
||||
AgentSessionSubscribeEvent
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
|
||||
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record'
|
||||
import { describeNativeChatTurnStatus } from '../../../shared/native-chat-turn-status'
|
||||
import {
|
||||
completedStructuredAgentTurnSeconds,
|
||||
selectStructuredAgentTurnTimings
|
||||
@@ -291,6 +295,36 @@ describe('a turn a read reached before the reconcile proved its owner dead', ()
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('reports the revision to the status feed as an interruption, which the chat folds as failed', async () => {
|
||||
const published: AgentSessionStatusSummary[] = []
|
||||
openHost({
|
||||
probeOwner: async () => ({ outcome: 'pid-absent' }),
|
||||
statusSink: { publish: (summary) => published.push(summary), forget: () => {} }
|
||||
})
|
||||
await host.history({ sessionId: SESSION, direction: 'tail' })
|
||||
const outcomes = () =>
|
||||
published
|
||||
.filter((summary) => summary.sessionId === SESSION && summary.turnOutcome)
|
||||
.map((summary) => summary.turnOutcome)
|
||||
expect(outcomes().at(-1)).toBe('unconfirmed')
|
||||
|
||||
await host.reconcileRestartLeases()
|
||||
await drainSession()
|
||||
|
||||
// The sidebar's red Failed, then the folded "Failed after 27s".
|
||||
await vi.waitFor(() => expect(outcomes().at(-1)).toBe('interruption'))
|
||||
const [timing] = selectStructuredAgentTurnTimings(
|
||||
(await host.journalSnapshot(SESSION)).items
|
||||
).values()
|
||||
expect(
|
||||
describeNativeChatTurnStatus({
|
||||
elapsedSeconds: 0,
|
||||
workedSeconds: completedStructuredAgentTurnSeconds(timing),
|
||||
verdict: timing?.verdict
|
||||
})
|
||||
).toEqual({ key: 'failedAfter', duration: '27s' })
|
||||
})
|
||||
|
||||
it('revises nothing twice, whoever re-runs the settle', async () => {
|
||||
openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) })
|
||||
await host.history({ sessionId: SESSION, direction: 'tail' })
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// would have written them. Stop and the conversation's close are the only other writers of a
|
||||
// queued message: a child's exit only ends the child, and this loop reads why.
|
||||
|
||||
import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import {
|
||||
agentSessionFailureFact,
|
||||
@@ -53,6 +54,11 @@ export type StructuredAgentSessionDeliveryLoopDeps = {
|
||||
) => Promise<StructuredAgentSessionResumeOutcome>
|
||||
/** The fence the conversation's own writes carry; see `structuredAgentSessionConversationFence`. */
|
||||
conversationFence: (sessionId: string) => number
|
||||
/** Rejects queued messages as a completed close of the chat does; false when that failed. */
|
||||
abandonQueued: (
|
||||
sessionId: string,
|
||||
which: (submission: AgentJournalSubmission) => boolean
|
||||
) => Promise<boolean>
|
||||
/** Who the chat's failure sentences name. */
|
||||
failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext
|
||||
onError: (sessionId: string, error: unknown) => void
|
||||
@@ -149,6 +155,10 @@ export class StructuredAgentSessionDeliveryLoop {
|
||||
// A handle closes only with nothing queued, so one an earlier handle wrote is a leftover.
|
||||
(submission) => session.journal.wroteBeforeOpen(submission.acceptedSequence)
|
||||
)
|
||||
if (!(await this.closeWhatTheUserClosed(sessionId, session))) {
|
||||
// Never start an agent for a message the user closed; the next wake re-derives and retries.
|
||||
return this.stop(sessionId)
|
||||
}
|
||||
const oldest = oldestQueuedSubmission(session)
|
||||
// A running command takes no input while its child carries it; its end is a commit, which
|
||||
// wakes the loop again. With no child it is a gone generation's, which the start below settles.
|
||||
@@ -193,8 +203,8 @@ export class StructuredAgentSessionDeliveryLoop {
|
||||
// The child waited on is gone, replaced by another, or settled its start without proving it.
|
||||
const ended = awaitedChild ? undefined : session.lastEndedChild
|
||||
const endedFailure = ended ? structuredAgentSessionEndedChildFailure(ended) : undefined
|
||||
// A user's Stop is not a failure: the next step starts, or waits on, a child for what is
|
||||
// queued.
|
||||
// A user's Stop or close is not a failure: the next step starts, or waits on, a child for
|
||||
// what is queued, after closing what a close of the chat closed.
|
||||
if (endedFailure === null) {
|
||||
return 'continue'
|
||||
}
|
||||
@@ -243,6 +253,27 @@ export class StructuredAgentSessionDeliveryLoop {
|
||||
return this.stop(sessionId)
|
||||
}
|
||||
|
||||
/** A close of this chat that stopped its child and then did not complete still closed what was
|
||||
* queued before it, so no child starts for those. Ordered, not latched: a later send goes on.
|
||||
* False when those could not be closed. */
|
||||
private async closeWhatTheUserClosed(
|
||||
sessionId: string,
|
||||
session: StructuredAgentSessionHostSession
|
||||
): Promise<boolean> {
|
||||
const ended = session.lastEndedChild
|
||||
if (session.child || ended?.cause !== 'user-close') {
|
||||
return true
|
||||
}
|
||||
const { epoch } = session.journal.cursor()
|
||||
return this.deps.abandonQueued(
|
||||
sessionId,
|
||||
(submission) =>
|
||||
ended.endedAt.epoch === epoch &&
|
||||
submission.acceptedSequence !== undefined &&
|
||||
submission.acceptedSequence <= ended.endedAt.sequence
|
||||
)
|
||||
}
|
||||
|
||||
/** Inside the serialized step that found nothing to do, so an accept after it wakes anew. */
|
||||
private stop(sessionId: string): 'stop' {
|
||||
this.running.delete(sessionId)
|
||||
@@ -281,6 +312,8 @@ function providerEndFailure(
|
||||
// Every end cause, so a new one does not compile until it says whether it fails what is queued.
|
||||
const ENDED_CHILD_FAILURE = {
|
||||
'user-stop': () => null,
|
||||
// The user closing this chat closes what was queued before it; see `closeWhatTheUserClosed`.
|
||||
'user-close': () => null,
|
||||
// The host stopping the child is Orca's cause, never the provider's: a start that never finished.
|
||||
'host-stop': () => ({ failure: agentSessionFailureFact('hostStopped') }),
|
||||
exit: providerEndFailure,
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('structured agent session eviction', () => {
|
||||
|
||||
await evictStructuredAgentSession(ctx)
|
||||
|
||||
expect(disposeSession).toHaveBeenCalledWith('session-1')
|
||||
expect(disposeSession).toHaveBeenCalledWith('session-1', undefined)
|
||||
expect(closeSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
|
||||
import {
|
||||
stopAgentSessionProviderRoot,
|
||||
type StructuredAgentSessionAdapter
|
||||
type StructuredAgentSessionAdapter,
|
||||
type StructuredAgentSessionStopCause
|
||||
} from './structured-agent-session-adapter'
|
||||
import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
|
||||
import type { StructuredAgentSessionStopVerdict } from './structured-agent-session-host-types'
|
||||
@@ -27,6 +28,8 @@ import { withTimeout } from '../../../shared/promise-timeout-fallback'
|
||||
|
||||
export type StructuredAgentSessionEvictionContext = {
|
||||
sessionId: string
|
||||
/** Why the host stops the child; the adapter settles the turn it cuts with it. */
|
||||
stopCause?: StructuredAgentSessionStopCause
|
||||
hasProviderChild?: boolean
|
||||
eventSink: DeferredStructuredAgentSessionEventSink
|
||||
adapter: StructuredAgentSessionAdapter
|
||||
@@ -86,7 +89,9 @@ export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSe
|
||||
// An adapter with no close has nothing to stop; anything else must PROVE the exit.
|
||||
const stop = context.adapter.disposeSession ?? context.adapter.closeSession
|
||||
const rootGone = stop
|
||||
? await stopAgentSessionProviderRoot(() => stop.call(context.adapter, context.sessionId))
|
||||
? await stopAgentSessionProviderRoot(() =>
|
||||
stop.call(context.adapter, context.sessionId, context.stopCause)
|
||||
)
|
||||
: true
|
||||
if (!rootGone) {
|
||||
throw new Error('provider child exit was not proven')
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// with a message queued has a delivery loop — and the open is where a loop for leftovers wakes.
|
||||
|
||||
import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission'
|
||||
import { abandonQueuedStructuredAgentSessionMessages } from './structured-agent-session-host-lifetime'
|
||||
import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import {
|
||||
@@ -65,6 +66,12 @@ export function createStructuredAgentSessionConversationDelivery(input: {
|
||||
ensureProviderChild: input.ensureProviderChild,
|
||||
conversationFence: (sessionId) =>
|
||||
structuredAgentSessionConversationFence(deps.store, sessionId),
|
||||
abandonQueued: async (sessionId, which) => {
|
||||
const session = sessions.get(sessionId)
|
||||
return session
|
||||
? abandonQueuedStructuredAgentSessionMessages(deps, sessionId, session.journal, which)
|
||||
: true
|
||||
},
|
||||
failureTextContext: (sessionId) =>
|
||||
structuredAgentSessionFailureWordsContext(
|
||||
deps.store.getRecord(sessionId),
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
// reentrant, so every public entry point takes it once and calls these.
|
||||
|
||||
import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission'
|
||||
import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types'
|
||||
import { agentSessionFailureFact } from '../../../shared/agent-session-failure'
|
||||
import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words'
|
||||
import {
|
||||
@@ -19,7 +20,6 @@ import {
|
||||
import { withStructuredAgentSessionEvictionDeadline } from './structured-agent-session-eviction-deadline'
|
||||
import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state'
|
||||
import type {
|
||||
StructuredAgentSessionChildEndCause,
|
||||
StructuredAgentSessionHostDeps,
|
||||
StructuredAgentSessionHostSession,
|
||||
StructuredAgentSessionProviderChildIdentity
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
} from './structured-agent-session-provider-child'
|
||||
import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release'
|
||||
import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement'
|
||||
import { turnVerdictForChildEnd } from './structured-agent-session-stale-turn-verdict'
|
||||
import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter'
|
||||
|
||||
export type StructuredAgentSessionLifetimeContext = {
|
||||
deps: StructuredAgentSessionHostDeps
|
||||
@@ -51,18 +53,27 @@ type ConversationCloseDeps = Pick<StructuredAgentSessionHostDeps, 'onEventSinkEr
|
||||
|
||||
/** A conversation's handle closes with nothing queued: what is still queued when the chat closes,
|
||||
* or the app quits, will not be handed over. Best effort: the next open's delivery loop rejects a
|
||||
* leftover itself. */
|
||||
* leftover itself. `which` narrows it to the messages a close that did not complete closed.
|
||||
* Resolves false when the rejection failed; the failure is reported, never thrown. */
|
||||
export async function abandonQueuedStructuredAgentSessionMessages(
|
||||
deps: ConversationCloseDeps,
|
||||
sessionId: string,
|
||||
journal: StructuredAgentSessionHostSession['journal']
|
||||
): Promise<void> {
|
||||
await journal
|
||||
journal: StructuredAgentSessionHostSession['journal'],
|
||||
which?: (submission: AgentJournalSubmission) => boolean
|
||||
): Promise<boolean> {
|
||||
return journal
|
||||
.rejectQueuedSubmissions(
|
||||
structuredAgentSessionConversationFence(deps.store, sessionId),
|
||||
agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' })
|
||||
agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }),
|
||||
which
|
||||
)
|
||||
.then(
|
||||
() => true,
|
||||
(error: unknown) => {
|
||||
deps.onEventSinkError?.({ sessionId, error })
|
||||
return false
|
||||
}
|
||||
)
|
||||
.catch((error: unknown) => deps.onEventSinkError?.({ sessionId, error }))
|
||||
}
|
||||
|
||||
/** The wind-down this host owes for the session's child. A live child always owes one, whatever a
|
||||
@@ -85,20 +96,22 @@ function owedProviderChildWindDown(
|
||||
export async function stopStructuredAgentSessionAgentUnderSerialize(
|
||||
context: StructuredAgentSessionLifetimeContext,
|
||||
sessionId: string,
|
||||
ending: {
|
||||
cause: Extract<StructuredAgentSessionChildEndCause, 'user-stop' | 'host-stop' | 'evict'>
|
||||
reason?: string
|
||||
} = { cause: 'user-stop' }
|
||||
// Required: an omitted cause must not default to the user's cancellation.
|
||||
ending: { cause: StructuredAgentSessionStopCause; reason?: string }
|
||||
): Promise<void> {
|
||||
const session = context.sessions.get(sessionId)
|
||||
if (!session) {
|
||||
return
|
||||
}
|
||||
// A retry finishes the stop that ended the child, so the turn that stop cut keeps its cause.
|
||||
const cause = session.child
|
||||
? ending.cause
|
||||
: (session.owesProviderChildWindDown?.cause ?? ending.cause)
|
||||
// The obligation OUTLIVES the child. `child` is ended the instant the adapter proves the exit,
|
||||
// so a step that aborts after that point would otherwise leave the retry reading "no child
|
||||
// here" and skipping the settlement and the lease release it still owes.
|
||||
const owed = owedProviderChildWindDown(session)
|
||||
session.owesProviderChildWindDown = owed
|
||||
session.owesProviderChildWindDown = owed ? { ...owed, cause } : undefined
|
||||
const stopping = session.child
|
||||
let settlementError: unknown
|
||||
const eviction: StructuredAgentSessionEvictionContext = {
|
||||
@@ -108,6 +121,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize(
|
||||
owesProviderChildWindDown: owed !== undefined,
|
||||
eventSink: context.runtimeState.eventSinkFor(sessionId),
|
||||
adapter: context.deps.adapter,
|
||||
// The adapter settles its own open turn with this, so who asked travels with the stop.
|
||||
stopCause: cause,
|
||||
...(context.restartWitness
|
||||
? { beforeProviderChildStop: () => context.restartWitness?.beforeStop(sessionId) }
|
||||
: {}),
|
||||
@@ -117,7 +132,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize(
|
||||
endProviderChild(session, {
|
||||
generation: stopping.generation,
|
||||
fence: stopping.fence,
|
||||
cause: ending.cause,
|
||||
cause,
|
||||
reason: ending.reason ?? null,
|
||||
duringStartup: stopping.phase === 'starting',
|
||||
...verdict
|
||||
@@ -136,7 +151,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize(
|
||||
fence,
|
||||
settlementId: `expected-close:${sessionId}:${fence}:${owed?.generation ?? 'unknown'}`,
|
||||
pendingSubmissionReason: 'provider_closed_before_acknowledgement',
|
||||
verdict: { state: 'interrupted', completedAt: context.now() },
|
||||
// Only a turn no adapter settled: one with no close, or whose settle threw.
|
||||
verdict: turnVerdictForChildEnd(cause, context.now()),
|
||||
showUnexpectedExitOutcome: false,
|
||||
onError: (id, error) => {
|
||||
settlementError = error
|
||||
@@ -170,6 +186,12 @@ export async function stopStructuredAgentSessionAgentUnderSerialize(
|
||||
)
|
||||
}
|
||||
|
||||
/** A close's cause: the user closing this chat, or the host evicting it (quit, idle, teardown). */
|
||||
export type StructuredAgentSessionCloseCause = Extract<
|
||||
StructuredAgentSessionStopCause,
|
||||
'user-close' | 'evict'
|
||||
>
|
||||
|
||||
/** Whether the conversation's handle is only a cache now: no child, no wind-down owed, and nothing
|
||||
* queued or waiting on the provider. */
|
||||
export function structuredAgentSessionConversationClosable(
|
||||
@@ -228,7 +250,7 @@ export async function evictOwnedStructuredAgentSessions(
|
||||
ownedSessionIds.map(async (sessionId) => {
|
||||
try {
|
||||
await context.serialize(sessionId, () =>
|
||||
stopStructuredAgentSessionAgentUnderSerialize(context, sessionId)
|
||||
stopStructuredAgentSessionAgentUnderSerialize(context, sessionId, { cause: 'evict' })
|
||||
)
|
||||
retainOnFailure.delete(sessionId)
|
||||
} catch (error) {
|
||||
|
||||
@@ -174,7 +174,9 @@ export async function flushStructuredAgentSessionHost(
|
||||
retainSessionIds,
|
||||
acknowledgeSessionRelease: (sessionId) =>
|
||||
context.deps.adapter.acknowledgeSessionRelease?.(sessionId),
|
||||
abandonQueued: (sessionId, session) =>
|
||||
abandonQueuedStructuredAgentSessionMessages(context.deps, sessionId, session.journal)
|
||||
// Quit's is best effort: a failure is reported, and the next open rejects the leftover.
|
||||
abandonQueued: async (sessionId, session) => {
|
||||
await abandonQueuedStructuredAgentSessionMessages(context.deps, sessionId, session.journal)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ describe('abandoning a structured agent-session host', () => {
|
||||
expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true })
|
||||
// The conversation stays and its provider child does not, so the next send makes the delivery
|
||||
// loop start one — the shape the refusal-oracle spec ends on.
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
gate = new Promise<void>((resolve) => {
|
||||
openGate = resolve
|
||||
})
|
||||
|
||||
@@ -10,7 +10,9 @@ import type { JournalHostDatabase } from '../agent-session-journal/journal-host-
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import type {
|
||||
StructuredAgentSessionAdapter,
|
||||
StructuredAgentSessionProviderChildPhase
|
||||
StructuredAgentSessionChildEndCause,
|
||||
StructuredAgentSessionProviderChildPhase,
|
||||
StructuredAgentSessionStopCause
|
||||
} from './structured-agent-session-adapter'
|
||||
import type { AgentSessionAttachParams } from './structured-agent-session-attach'
|
||||
import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed'
|
||||
@@ -35,6 +37,11 @@ export type StructuredAgentSessionProviderChildIdentity = {
|
||||
readonly fence: number
|
||||
}
|
||||
|
||||
/** A wind-down still owed, with the cause of the stop that owes it: a retry finishes that stop. */
|
||||
export type StructuredAgentSessionOwedWindDown = StructuredAgentSessionProviderChildIdentity & {
|
||||
readonly cause: StructuredAgentSessionStopCause
|
||||
}
|
||||
|
||||
/** The provider process behind a conversation. Written only in
|
||||
* `structured-agent-session-provider-child`. */
|
||||
export type StructuredAgentSessionProviderChild = StructuredAgentSessionProviderChildIdentity & {
|
||||
@@ -50,12 +57,7 @@ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProvider
|
||||
* `stopAgentSessionProviderRoot`; an observed exit's root is gone by definition. */
|
||||
export type StructuredAgentSessionStopVerdict = { rootGone: boolean }
|
||||
|
||||
export type StructuredAgentSessionChildEndCause =
|
||||
| 'user-stop'
|
||||
| 'host-stop'
|
||||
| 'exit'
|
||||
| 'attach-failed'
|
||||
| 'evict'
|
||||
export type { StructuredAgentSessionChildEndCause }
|
||||
|
||||
/** How the conversation's last child ended. In memory only: the delivery loop reads it to tell a
|
||||
* Stop from a failure. */
|
||||
@@ -87,7 +89,7 @@ export type StructuredAgentSessionHostSession = {
|
||||
/** The wind-down this host still owes for a child it started: settling that generation's work
|
||||
* and handing the lease back. Outlives `child`, which ends the moment the adapter proves the
|
||||
* exit — an eviction that aborts after that point must still finish it on the next close. */
|
||||
owesProviderChildWindDown?: StructuredAgentSessionProviderChildIdentity
|
||||
owesProviderChildWindDown?: StructuredAgentSessionOwedWindDown
|
||||
lastEndedChild?: StructuredAgentSessionEndedChild
|
||||
}
|
||||
|
||||
|
||||
@@ -184,14 +184,15 @@ export class StructuredAgentSessionHost {
|
||||
handleAdapterEvent = (event: Parameters<StructuredAgentSessionEventRecovery['handle']>[0]) =>
|
||||
this.eventRecovery.handle(event)
|
||||
|
||||
private lifetimeContext(): StructuredAgentSessionLifetimeContext {
|
||||
// Inferred, so the attach context's spread keeps `publishStatus` required.
|
||||
private lifetimeContext() {
|
||||
return {
|
||||
deps: this.deps,
|
||||
runtimeState: this.runtimeState,
|
||||
sessions: this.sessions,
|
||||
now: () => this.now(),
|
||||
publishStatus: this.clientDelivery.publishStatus
|
||||
}
|
||||
} satisfies StructuredAgentSessionLifetimeContext
|
||||
}
|
||||
|
||||
/** The host's half of attaching, named so it cannot grow dependencies unnoticed. */
|
||||
@@ -202,12 +203,13 @@ export class StructuredAgentSessionHost {
|
||||
tasks: this.tasks,
|
||||
reconcileLeases: (sessionId) => this.reconcileLeases(sessionId),
|
||||
serialize: (sessionId, task) => this.serialize(sessionId, task),
|
||||
publishStatus: this.clientDelivery.publishStatus,
|
||||
openConversation: this.conversationDelivery.open
|
||||
}
|
||||
}
|
||||
/** Releases a session's resources without ending the conversation; see the lifetime's close. */
|
||||
close = (sessionId: string): Promise<void> => this.lifetime.close(sessionId)
|
||||
/** Releases a session's resources without ending the conversation; see the lifetime's close.
|
||||
* `user-close` makes a turn it cuts short the user's cancellation; an `evict` leaves it news. */
|
||||
close: StructuredAgentSessionConversationLifetime['close'] = (sessionId, cause) =>
|
||||
this.lifetime.close(sessionId, cause)
|
||||
|
||||
supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean =>
|
||||
providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent)
|
||||
@@ -278,7 +280,7 @@ export class StructuredAgentSessionHost {
|
||||
ensureAgent: (sessionId) =>
|
||||
ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId),
|
||||
wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId),
|
||||
stopAgent: this.lifetime.stopAgent,
|
||||
stopAgent: (sessionId) => this.lifetime.stopAgent(sessionId, 'user-stop'),
|
||||
wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId),
|
||||
now: () => this.now()
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ describe('the idle sweep', () => {
|
||||
await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit })
|
||||
rig.clock.now += IDLE_MS + 1
|
||||
|
||||
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
|
||||
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict'))
|
||||
await vi.waitFor(() => expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released'))
|
||||
await vi.waitFor(() => expect(rig.adapter.acknowledgeSessionRelease).toHaveBeenCalledOnce())
|
||||
expect(rig.adapter.acknowledgeSessionRelease).toHaveBeenCalledWith(SESSION)
|
||||
@@ -122,7 +122,7 @@ describe('the idle sweep', () => {
|
||||
expect(rig.adapter.closeSession).not.toHaveBeenCalled()
|
||||
rig.adapter.backgroundTaskState.mockReturnValue(undefined)
|
||||
rig.clock.now += IDLE_MS + 1
|
||||
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
|
||||
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict'))
|
||||
})
|
||||
|
||||
// A finished child reads done before the lead's wake-up turn writes its first row; stopping the
|
||||
@@ -143,7 +143,7 @@ describe('the idle sweep', () => {
|
||||
await sweepTicks()
|
||||
expect(rig.adapter.closeSession).not.toHaveBeenCalled()
|
||||
rig.clock.now += IDLE_MS + 1
|
||||
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
|
||||
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict'))
|
||||
})
|
||||
|
||||
// Owed work is read every tick, not once a window: work that ends just before a window would
|
||||
@@ -165,7 +165,7 @@ describe('the idle sweep', () => {
|
||||
await sweepTicks()
|
||||
expect(rig.adapter.closeSession).not.toHaveBeenCalled()
|
||||
rig.clock.now += IDLE_MS
|
||||
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
|
||||
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict'))
|
||||
})
|
||||
|
||||
it('stops an agent whose roster holds only children that went idle or finished', async () => {
|
||||
@@ -179,7 +179,7 @@ describe('the idle sweep', () => {
|
||||
})
|
||||
rig.clock.now += IDLE_MS + 1
|
||||
|
||||
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
|
||||
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict'))
|
||||
})
|
||||
|
||||
it('never stops an agent while its lead turn runs, however quiet (P2-10)', async () => {
|
||||
@@ -202,7 +202,7 @@ describe('the idle sweep', () => {
|
||||
await rig.host.subscribe({ id: 'on-screen', sessionId: SESSION, emit: reader.emit })
|
||||
rig.clock.now += IDLE_MS + 1
|
||||
|
||||
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
|
||||
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict'))
|
||||
expect(reader.events.some((event) => event.type === 'end')).toBe(false)
|
||||
})
|
||||
|
||||
@@ -221,7 +221,7 @@ describe('the idle sweep', () => {
|
||||
await sweepTicks()
|
||||
expect(rig.adapter.closeSession).not.toHaveBeenCalled()
|
||||
rig.clock.now += IDLE_MS
|
||||
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
|
||||
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict'))
|
||||
})
|
||||
|
||||
it('never stops a worker whose orchestration dispatch is open, and stops it once it settles (P2-19 i)', async () => {
|
||||
@@ -237,7 +237,7 @@ describe('the idle sweep', () => {
|
||||
expect(hasOpenDispatch).toHaveBeenCalledWith(expect.objectContaining({ sessionId: SESSION }))
|
||||
open = false
|
||||
rig.clock.now += IDLE_MS + 1
|
||||
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
|
||||
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict'))
|
||||
})
|
||||
|
||||
// A Claude retrying a rate-limited request has taken the send but echoes nothing, so no turn row
|
||||
@@ -254,7 +254,7 @@ describe('the idle sweep', () => {
|
||||
await sweepTicks()
|
||||
expect(rig.adapter.closeSession).not.toHaveBeenCalled()
|
||||
rig.clock.now += IDLE_MS + 1
|
||||
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
|
||||
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict'))
|
||||
})
|
||||
|
||||
it('keeps a child an unanswered prompt waits on (P2-22 i)', async () => {
|
||||
|
||||
+2
-2
@@ -129,7 +129,7 @@ beforeEach(async () => {
|
||||
|
||||
afterEach(async () => {
|
||||
await host.flushAllStreamedEvents()
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
@@ -202,7 +202,7 @@ describe('settling a send the provider proves it received after the ack window',
|
||||
return true
|
||||
})
|
||||
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
await expect(settlement).resolves.toBeUndefined()
|
||||
await host.revealSession(SESSION)
|
||||
expect(await submissions()).toMatchObject([{ dispatchState: 'accepted' }])
|
||||
|
||||
+5
-5
@@ -212,9 +212,9 @@ describe('structured session options and close', () => {
|
||||
it('stops the provider child and forgets the session when the chat closes', async () => {
|
||||
expect(host.hasSession(SESSION)).toBe(true)
|
||||
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
|
||||
expect(closeNativeSession).toHaveBeenCalledWith(SESSION)
|
||||
expect(closeNativeSession).toHaveBeenCalledWith(SESSION, 'evict')
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
claimStatus: 'released',
|
||||
ownerProcess: null,
|
||||
@@ -222,15 +222,15 @@ describe('structured session options and close', () => {
|
||||
})
|
||||
expect(host.hasSession(SESSION)).toBe(false)
|
||||
|
||||
await expect(host.close(SESSION)).resolves.toBeUndefined()
|
||||
await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined()
|
||||
expect(closeNativeSession).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('is a no-op for a session it does not hold', async () => {
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
closeNativeSession.mockClear()
|
||||
|
||||
await expect(host.close(SESSION)).resolves.toBeUndefined()
|
||||
await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined()
|
||||
expect(closeNativeSession).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
+98
-2
@@ -28,6 +28,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-a
|
||||
import { ensureStructuredAgentSessionAgent } from './structured-agent-session-agent-start'
|
||||
import { StructuredAgentSessionHost } from './structured-agent-session-host'
|
||||
import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime'
|
||||
import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child'
|
||||
import {
|
||||
HOST_TEST_LOCATION,
|
||||
HOST_TEST_NOW as NOW,
|
||||
@@ -41,6 +42,9 @@ import {
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), {
|
||||
surface: 'rejection'
|
||||
})
|
||||
|
||||
let root: string
|
||||
let store: AgentSessionRecordStore
|
||||
@@ -119,7 +123,7 @@ beforeEach(async () => {
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
startHost()
|
||||
expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true })
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -642,7 +646,7 @@ describe('a quit with a message still queued', () => {
|
||||
starting.resolve()
|
||||
await quit
|
||||
|
||||
expect(closeSession).toHaveBeenCalledWith(SESSION)
|
||||
expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict')
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' })
|
||||
expect(dispatch).not.toHaveBeenCalled()
|
||||
expect(await afterRelaunch(id)).toMatchObject({
|
||||
@@ -715,6 +719,98 @@ describe('how a stopped child ends the start its loop was waiting on', () => {
|
||||
expect(await statusRows()).toEqual([])
|
||||
})
|
||||
|
||||
/** The close's stop alone: a close that aborts after it leaves the conversation indexed. */
|
||||
function closeStopOnly() {
|
||||
return host['serialize'](SESSION, () =>
|
||||
stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, {
|
||||
cause: 'user-close'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** A message queued behind a starting child when the close's stop cut it. `beforeStart` runs
|
||||
* after the stop and before the loop looks again. */
|
||||
async function closedWhileStarting(beforeStart: () => void = () => undefined) {
|
||||
const start = deferred<void>()
|
||||
adapterExtras = {
|
||||
awaitStarted: vi.fn(() => start.promise),
|
||||
closeSession: vi.fn(async () => true)
|
||||
}
|
||||
await restartHost()
|
||||
acquire.mockImplementationOnce(spawnStartingChild)
|
||||
const first = await accept('first')
|
||||
await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(1))
|
||||
await closeStopOnly()
|
||||
const starts = acquire.mock.calls.length
|
||||
beforeStart()
|
||||
start.resolve()
|
||||
await settleLoop()
|
||||
return { first, starts }
|
||||
}
|
||||
|
||||
it('closes what was queued when the user closed the chat, and starts no child for it', async () => {
|
||||
const { first, starts } = await closedWhileStarting()
|
||||
|
||||
expect(await submission(first)).toMatchObject({
|
||||
dispatchState: 'rejected',
|
||||
...CHAT_CLOSED
|
||||
})
|
||||
expect(acquire).toHaveBeenCalledTimes(starts)
|
||||
expect(dispatch).not.toHaveBeenCalled()
|
||||
expect(await statusRows()).toEqual([])
|
||||
})
|
||||
|
||||
it('starts no child when closing what was queued fails, and closes it on the next wake', async () => {
|
||||
const { first, starts } = await closedWhileStarting(() => {
|
||||
const journal = conversation()!.journal
|
||||
const reject = journal.rejectQueuedSubmissions.bind(journal)
|
||||
vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation(async (...args) => {
|
||||
if (args[1].rejection.kind === 'chatClosed') {
|
||||
vi.mocked(journal.rejectQueuedSubmissions).mockImplementation(reject)
|
||||
throw new Error('disk full')
|
||||
}
|
||||
return reject(...args)
|
||||
})
|
||||
})
|
||||
|
||||
expect((await submission(first))?.dispatchState).toBe('pending')
|
||||
expect(acquire).toHaveBeenCalledTimes(starts)
|
||||
expect(dispatch).not.toHaveBeenCalled()
|
||||
|
||||
const second = await accept('second')
|
||||
await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted'))
|
||||
expect(await submission(first)).toMatchObject({
|
||||
dispatchState: 'rejected',
|
||||
...CHAT_CLOSED
|
||||
})
|
||||
expect(dispatch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('goes on with a message sent in a later epoch, whose sequence restarts at or below the close', async () => {
|
||||
await closedWhileStarting()
|
||||
const session = conversation()!
|
||||
await session.journal.rollEpoch(
|
||||
'corruption',
|
||||
structuredAgentSessionConversationFence(store, SESSION)
|
||||
)
|
||||
|
||||
const second = await accept('second')
|
||||
|
||||
await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted'))
|
||||
expect(session.lastEndedChild).toMatchObject({ cause: 'user-close' })
|
||||
expect((await submission(second))!.acceptedSequence).toBeLessThanOrEqual(
|
||||
session.lastEndedChild!.endedAt.sequence
|
||||
)
|
||||
})
|
||||
|
||||
it('goes on with a message sent after the close, never failing it', async () => {
|
||||
const second = await stoppedWhileStarting(closeStopOnly)
|
||||
|
||||
await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted'))
|
||||
expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-close' })
|
||||
expect(await statusRows()).toEqual([])
|
||||
})
|
||||
|
||||
it("fails the start after a host stop, as a start Orca stopped rather than the provider's (R2)", async () => {
|
||||
const reason = 'the start watchdog fired'
|
||||
const second = await stoppedWhileStarting(() =>
|
||||
|
||||
+1
-1
@@ -140,7 +140,7 @@ describe('a publish-first Claude create whose init is slow', () => {
|
||||
await host.attach(CALLER, { ...params, options: { model: 'opus' } })
|
||||
await adapter.awaitStarted(SESSION)
|
||||
await Promise.all(lifecycle)
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0
|
||||
|
||||
// Starting the chat again resumes the session under a new fence.
|
||||
|
||||
+1
-1
@@ -245,7 +245,7 @@ export async function createQueuedMessageTestRig() {
|
||||
/** A host-process restart, as the queue sees it: the conversation closes, and
|
||||
* opens afresh under a new instance id while its rows survive. */
|
||||
async function restartHostProcess(): Promise<void> {
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
rotateStructuredAgentSessionHostInstanceForTests()
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -270,7 +270,7 @@ describe('held drafts', () => {
|
||||
// The queue is paused, not the card: it carries no hold of its own.
|
||||
expect(await drafts()).toEqual([{ messageId: draftId, state: 'waiting' }])
|
||||
expect(await rig.queuePause()).toEqual({ reason: 'restarted' })
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
expect(await rig.queuePause()).toEqual({ reason: 'restarted' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
expect(await rig.handoff(draftId)).toBeUndefined()
|
||||
@@ -418,7 +418,7 @@ describe('Stop and Delete', () => {
|
||||
await settleAccepted(working, 'a')
|
||||
// Evict the handle and reopen (the history read opens the conversation at
|
||||
// rest): the pause is derived from what the journal holds, so nothing drains.
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
expect(await rig.handoff(draftId)).toBeUndefined()
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ async function twoCardsBehindWork() {
|
||||
describe("a newer Orca's journal", () => {
|
||||
it('shows the cards, and refuses a queued send, Send-now and Delete with the update words', async () => {
|
||||
const { first, second, cards } = await twoCardsBehindWork()
|
||||
await reopenOnNewerOrcaDatabase(() => rig.host.close(HOST_TEST_SESSION))
|
||||
await reopenOnNewerOrcaDatabase(() => rig.host.close(HOST_TEST_SESSION, 'evict'))
|
||||
expect(await readOnlyQueue()).toEqual({ cards, pause: null })
|
||||
|
||||
// The waiting cards would queue this send behind them; the journal takes no new draft.
|
||||
|
||||
+1
-1
@@ -342,7 +342,7 @@ describe("a restart's pause", () => {
|
||||
await eventually(async () => expect(await rig.handoff(first)).toBeDefined())
|
||||
// Reopened, that turn is "before this open", yet the pause it ended stays ended:
|
||||
// the lift adopted the rows into this process.
|
||||
await rig.host.close(HOST_TEST_SESSION)
|
||||
await rig.host.close(HOST_TEST_SESSION, 'evict')
|
||||
expect(await rig.queuePause()).toBeNull()
|
||||
expect(await rig.drafts()).toContainEqual({ messageId: second, state: 'waiting' })
|
||||
})
|
||||
|
||||
+95
-15
@@ -1,8 +1,9 @@
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
|
||||
// A turn that was running when its host went away ends when recovery settles it. That settlement is
|
||||
// the edge the user needs to see — their work stopped — so the session reads as newly done then,
|
||||
// and nothing along the way may call it a success. Every hop is the real one: durable journal,
|
||||
// recovery settlement, status feed, the host's status row, and the turn-completion feed.
|
||||
// with what the host observed of the end as its verdict, and nothing along the way may call it a
|
||||
// success. Every hop is the real one: durable journal, recovery settlement, status feed, the host's
|
||||
// status row, and the turn-completion feed.
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -12,6 +13,12 @@ import type {
|
||||
AgentSessionStatusSummary,
|
||||
AgentSessionTurnCompletionEvent
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import {
|
||||
agentTurnEndedOnPurpose,
|
||||
agentVerdictDisplayMark
|
||||
} from '../../../shared/agent-main-agent-verdict'
|
||||
import { formatNativeChatTurnStatusLabel } from '../../../shared/native-chat-turn-status'
|
||||
import { selectStructuredAgentSettledTurns } from '../../../shared/structured-agent-session-turn-timing'
|
||||
import { AgentHookServer, _internals } from '../../agent-hooks/server'
|
||||
import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
@@ -19,7 +26,11 @@ import {
|
||||
settleStaleStructuredAgentSessionState,
|
||||
settleStructuredAgentSessionDeadGeneration
|
||||
} from './structured-agent-session-dead-generation-settlement'
|
||||
import type { StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict'
|
||||
import {
|
||||
childEndCauseOfEndedEvent,
|
||||
turnVerdictForChildEnd,
|
||||
type StructuredAgentSessionTurnVerdict
|
||||
} from './structured-agent-session-stale-turn-verdict'
|
||||
import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed'
|
||||
import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session'
|
||||
import { StructuredAgentSessionTurnCompletionFeed } from './structured-agent-session-turn-completion-feed'
|
||||
@@ -131,11 +142,22 @@ function settleDeadGeneration(
|
||||
|
||||
describe('a turn recovery settled after its host went away', () => {
|
||||
it.each([
|
||||
['an unverifiable end', { state: 'unverifiable' } as const],
|
||||
['an exit observed before the restart', { state: 'interrupted', completedAt: EXIT_OBSERVED }]
|
||||
] satisfies [string, StructuredAgentSessionTurnVerdict][])(
|
||||
'is done as of the recovery, never as a success: %s',
|
||||
async (_label, verdict) => {
|
||||
['an unverifiable end', { state: 'unverifiable' } as const, 'unconfirmed', 'unconfirmed'],
|
||||
[
|
||||
'an exit observed before the restart',
|
||||
{ state: 'interrupted', completedAt: EXIT_OBSERVED },
|
||||
'interruption',
|
||||
// A turn the user did not stop is a fault, marked as a failure is.
|
||||
'failed'
|
||||
]
|
||||
] satisfies [
|
||||
string,
|
||||
StructuredAgentSessionTurnVerdict,
|
||||
'unconfirmed' | 'interruption',
|
||||
'unconfirmed' | 'failed'
|
||||
][])(
|
||||
'is done as of the recovery with the end the host observed, never a success: %s',
|
||||
async (_label, verdict, outcome, mark) => {
|
||||
const session = await sessionWithRunningTurn()
|
||||
session.recoverAt(RECOVERED)
|
||||
expect(await settleDeadGeneration(session.journal, verdict)).toBe(true)
|
||||
@@ -143,22 +165,75 @@ describe('a turn recovery settled after its host went away', () => {
|
||||
|
||||
expect(session.summaries.at(-1)).toMatchObject({
|
||||
status: 'idle',
|
||||
statusStartedAt: RECOVERED
|
||||
statusStartedAt: RECOVERED,
|
||||
turnOutcome: outcome
|
||||
})
|
||||
expect(session.summaries.at(-1)).not.toHaveProperty('turnOutcome')
|
||||
const [row] = session.server.getStatusSnapshot()
|
||||
// A done row dated at the recovery is a completion the user has not read yet.
|
||||
expect(row).toMatchObject({
|
||||
state: 'done',
|
||||
stateStartedAt: RECOVERED,
|
||||
mainAgent: { state: 'done', stateStartedAt: RECOVERED }
|
||||
mainAgent: { state: 'done', outcome, stateStartedAt: RECOVERED }
|
||||
})
|
||||
expect(row?.mainAgent).not.toHaveProperty('outcome')
|
||||
// Nobody stopped it: the flag older readers take as a user's stop stays down.
|
||||
expect(row?.interrupted ?? false).toBe(false)
|
||||
// The sidebar and tab read the published row, with no user action in between.
|
||||
expect(row && agentVerdictDisplayMark(row)).toBe(mark)
|
||||
expect(row && agentTurnEndedOnPurpose(row)).toBe(false)
|
||||
// The dot and the OS notification come only from a completion event, and none is sent.
|
||||
expect(session.completionEvents).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
// The chat's turn bar and the tab's mark read one verdict: a turn nobody stopped failed, and
|
||||
// must never show the done tick of a finished turn.
|
||||
it.each([
|
||||
[
|
||||
'a restart',
|
||||
(journal: AgentSessionJournal) =>
|
||||
settleDeadGeneration(journal, { state: 'interrupted', completedAt: EXIT_OBSERVED })
|
||||
],
|
||||
[
|
||||
'quitting Orca',
|
||||
// A quit evicts the child, and its adapter settles the open turn through the one mapping.
|
||||
(journal: AgentSessionJournal) =>
|
||||
journal.appendItem(
|
||||
{ provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 9 },
|
||||
{
|
||||
kind: 'turn',
|
||||
turnId: 'turn-1',
|
||||
startedAt: TURN_STARTED,
|
||||
...turnVerdictForChildEnd(
|
||||
childEndCauseOfEndedEvent({
|
||||
type: 'ended',
|
||||
cause: 'requested-close',
|
||||
stopCause: 'evict'
|
||||
}),
|
||||
EXIT_OBSERVED
|
||||
)
|
||||
},
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
]
|
||||
] as const)(
|
||||
'reads Failed after N, marked failed, for a turn cut off by %s',
|
||||
async (_label, cut) => {
|
||||
const session = await sessionWithRunningTurn()
|
||||
session.recoverAt(RECOVERED)
|
||||
await cut(session.journal)
|
||||
session.publish()
|
||||
|
||||
const [row] = session.server.getStatusSnapshot()
|
||||
expect(row && agentVerdictDisplayMark(row)).toBe('failed')
|
||||
const [settled] = [
|
||||
...selectStructuredAgentSettledTurns(session.journal.snapshot().items).values()
|
||||
]
|
||||
expect(settled && formatNativeChatTurnStatusLabel({ elapsedSeconds: 0, ...settled })).toBe(
|
||||
'Failed after 1s'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('is dated the same way when a new provider child finds the turn still running', async () => {
|
||||
const session = await sessionWithRunningTurn()
|
||||
session.recoverAt(RECOVERED)
|
||||
@@ -173,12 +248,17 @@ describe('a turn recovery settled after its host went away', () => {
|
||||
|
||||
expect(session.summaries.at(-1)).toMatchObject({
|
||||
status: 'idle',
|
||||
statusStartedAt: RECOVERED
|
||||
statusStartedAt: RECOVERED,
|
||||
// No evidence of the old owner's death: the end cannot be proven.
|
||||
turnOutcome: 'unconfirmed'
|
||||
})
|
||||
expect(session.server.getStatusSnapshot()[0]).toMatchObject({
|
||||
const [row] = session.server.getStatusSnapshot()
|
||||
expect(row).toMatchObject({
|
||||
state: 'done',
|
||||
stateStartedAt: RECOVERED
|
||||
stateStartedAt: RECOVERED,
|
||||
mainAgent: { state: 'done', outcome: 'unconfirmed' }
|
||||
})
|
||||
expect(row && agentVerdictDisplayMark(row)).toBe('unconfirmed')
|
||||
expect(session.completionEvents).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
+1
-1
@@ -346,7 +346,7 @@ describe('agentSessionRefusalOperationState host oracle', () => {
|
||||
}
|
||||
|
||||
const unreadable = await createHarness()
|
||||
await unreadable.host.close(SESSION)
|
||||
await unreadable.host.close(SESSION, 'evict')
|
||||
unreadable.host.deps.adapter.historyFilePath = async () => {
|
||||
throw new Error('transcript unreadable')
|
||||
}
|
||||
|
||||
+1
-1
@@ -145,7 +145,7 @@ async function errorStatuses(): Promise<string[]> {
|
||||
|
||||
/** The child timed out or exited: its lease is handed back and the host holds no session. */
|
||||
async function loseOwner(): Promise<void> {
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
claimStatus: 'released',
|
||||
ownerProcess: null
|
||||
|
||||
+51
-2
@@ -17,11 +17,16 @@ import {
|
||||
readAgentJournalTurn
|
||||
} from '../../../shared/agent-session-turn-record'
|
||||
import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record'
|
||||
import type {
|
||||
StructuredAgentSessionChildEndCause,
|
||||
StructuredAgentSessionEndedEvent
|
||||
} from './structured-agent-session-adapter'
|
||||
import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
|
||||
export type StructuredAgentSessionTurnVerdict =
|
||||
| { state: 'interrupted'; completedAt: number }
|
||||
/** `cancellation` only for a stop the user aimed at this chat: every other cut is news. */
|
||||
| { state: 'interrupted'; completedAt: number; outcome?: 'cancellation' }
|
||||
| { state: 'unverifiable' }
|
||||
|
||||
export const UNVERIFIABLE_TURN_VERDICT: StructuredAgentSessionTurnVerdict = {
|
||||
@@ -56,6 +61,45 @@ export function turnVerdictFromDeathEvidence(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one mapping from why a provider child ended to what the turn it cut reads as. Each adapter
|
||||
* settles its own open turn through it on `ended`, and the host's fallback settles through it any
|
||||
* turn no adapter did. Only a stop the user aimed at this chat is their cancellation.
|
||||
*/
|
||||
export function turnVerdictForChildEnd(
|
||||
cause: StructuredAgentSessionChildEndCause,
|
||||
completedAt: number
|
||||
): Extract<StructuredAgentSessionTurnVerdict, { state: 'interrupted' }> {
|
||||
return stopIsTheUsers(cause)
|
||||
? { state: 'interrupted', completedAt, outcome: 'cancellation' }
|
||||
: { state: 'interrupted', completedAt }
|
||||
}
|
||||
|
||||
/** Whether the user asked for this end. Only then is a cut turn their cancellation. */
|
||||
export function stopIsTheUsers(cause: StructuredAgentSessionChildEndCause): boolean {
|
||||
switch (cause) {
|
||||
case 'user-stop':
|
||||
case 'user-close':
|
||||
return true
|
||||
case 'host-stop':
|
||||
case 'evict':
|
||||
case 'exit':
|
||||
case 'attach-failed':
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Why the child an `ended` event reports ended: who asked for a close, else an exit it had. A
|
||||
* requested close with no cause named is the host's own. */
|
||||
export function childEndCauseOfEndedEvent(
|
||||
event: { type: 'ended' } & Partial<Pick<StructuredAgentSessionEndedEvent, 'cause' | 'stopCause'>>
|
||||
): StructuredAgentSessionChildEndCause {
|
||||
if (event.cause === 'unexpected-exit') {
|
||||
return 'exit'
|
||||
}
|
||||
return event.stopCause ?? 'host-stop'
|
||||
}
|
||||
|
||||
/** Revises every still-running lifecycle item in place, keeping its identity and start. */
|
||||
export function runningTurnLifecycleRevisions(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
@@ -125,5 +169,10 @@ function settledLifecycle(
|
||||
}
|
||||
// A renewal can predate the turn, which started with its owner alive; it never ends before that.
|
||||
const began = Math.max(lifecycle.requestedAt ?? 0, lifecycle.startedAt ?? 0)
|
||||
return { ...kept, state: verdict.state, completedAt: Math.max(verdict.completedAt, began) }
|
||||
return {
|
||||
...kept,
|
||||
state: verdict.state,
|
||||
completedAt: Math.max(verdict.completedAt, began),
|
||||
...(verdict.outcome ? { outcome: verdict.outcome } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Why a provider child ended. In memory only: never journaled, persisted or sent. */
|
||||
export type StructuredAgentSessionChildEndCause =
|
||||
| 'user-stop'
|
||||
/** The user closed this chat: its tab, its launch, or a `/clear` that replaces it. */
|
||||
| 'user-close'
|
||||
| 'host-stop'
|
||||
| 'exit'
|
||||
| 'attach-failed'
|
||||
| 'evict'
|
||||
|
||||
/** Why the host asked a child to stop. The adapter carries it onto the `ended` it settles with. */
|
||||
export type StructuredAgentSessionStopCause = Extract<
|
||||
StructuredAgentSessionChildEndCause,
|
||||
'user-stop' | 'user-close' | 'host-stop' | 'evict'
|
||||
>
|
||||
+11
-11
@@ -132,7 +132,7 @@ function emitTurnLifecycle(state: 'running' | 'completed', ordinal: number): voi
|
||||
/** The sweep stops the child first and closes the conversation last. */
|
||||
function waitForEviction(): Promise<void> {
|
||||
return vi.waitFor(() => {
|
||||
expect(closeSession).toHaveBeenCalledWith(SESSION)
|
||||
expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict')
|
||||
expect(host.hasSession(SESSION)).toBe(false)
|
||||
})
|
||||
}
|
||||
@@ -245,9 +245,9 @@ describe('a chat that closes', () => {
|
||||
it('stops the provider child it started', async () => {
|
||||
await attach()
|
||||
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
|
||||
expect(closeSession).toHaveBeenCalledWith(SESSION)
|
||||
expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict')
|
||||
expect(host.hasSession(SESSION)).toBe(false)
|
||||
expect(hostErrors).toEqual([])
|
||||
// The record and its journal stay; only the process and the claim on it go.
|
||||
@@ -264,7 +264,7 @@ describe('a chat that closes', () => {
|
||||
it('answers a read from the pane that outlived it without starting a child', async () => {
|
||||
await attach()
|
||||
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
expect(host.hasSession(SESSION)).toBe(false)
|
||||
|
||||
expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true)
|
||||
@@ -297,7 +297,7 @@ describe('a chat that closes', () => {
|
||||
await vi.waitFor(() => expect(dispatch).toHaveBeenCalled())
|
||||
const settlement = host.waitForSendSettlement(SESSION, result.value.clientMessageId)
|
||||
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
|
||||
// Eviction's settlement is a journal write, so the wait sees it rather than timing out.
|
||||
await expect(settlement).resolves.toMatchObject({
|
||||
@@ -321,14 +321,14 @@ describe('a chat that closes', () => {
|
||||
|
||||
// The child is stopped and the lease released before the handle closes; the entry is dropped
|
||||
// before that close, so a lost result leaves no closing handle for a reader to find.
|
||||
await expect(host.close(SESSION)).rejects.toThrow('journal close result lost')
|
||||
await expect(host.close(SESSION, 'evict')).rejects.toThrow('journal close result lost')
|
||||
expect(host.hasSession(SESSION)).toBe(false)
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
claimStatus: 'released',
|
||||
ownerProcess: null
|
||||
})
|
||||
|
||||
await expect(host.close(SESSION)).resolves.toBeUndefined()
|
||||
await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined()
|
||||
expect(closeSession).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -349,12 +349,12 @@ describe('a chat that closes', () => {
|
||||
})
|
||||
const settled = captureSettledSubmissions()
|
||||
|
||||
await expect(host.close(SESSION)).rejects.toMatchObject({ step: 'drain-published' })
|
||||
await expect(host.close(SESSION, 'evict')).rejects.toMatchObject({ step: 'drain-published' })
|
||||
// The child is proven gone, but the wind-down it owes is not done: nothing settled, no release.
|
||||
expect(session!.child).toBeNull()
|
||||
expect(store.getRecord(SESSION)?.lease.claimStatus).not.toBe('released')
|
||||
|
||||
await expect(host.close(SESSION)).resolves.toBeUndefined()
|
||||
await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined()
|
||||
expect(closeSession).toHaveBeenCalledOnce()
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
claimStatus: 'released',
|
||||
@@ -462,7 +462,7 @@ describe('startup', () => {
|
||||
describe('a session closed and started again', () => {
|
||||
it('publishes provider events to the reattached chat', async () => {
|
||||
await attach()
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
expect(host.hasSession(SESSION)).toBe(false)
|
||||
|
||||
await startAgent()
|
||||
@@ -820,7 +820,7 @@ describe('a quit over an eviction that never got its retry', () => {
|
||||
const settled = captureSettledSubmissions()
|
||||
failNextDrain()
|
||||
|
||||
await expect(host.close(SESSION)).rejects.toMatchObject({ step: 'drain-published' })
|
||||
await expect(host.close(SESSION, 'evict')).rejects.toMatchObject({ step: 'drain-published' })
|
||||
expect(host['sessions'].get(SESSION)?.child).toBeNull()
|
||||
expect(store.getRecord(SESSION)?.lease.claimStatus).not.toBe('released')
|
||||
|
||||
|
||||
+2
-2
@@ -49,7 +49,7 @@ type RequestMark = Pick<StructuredAgentSessionLatestRequest, 'kind' | 'id'>
|
||||
type SessionBaseline = CompletionFeedCursor & { settled: RequestMark | null }
|
||||
|
||||
function settledMark(request: StructuredAgentSessionLatestRequest | null): RequestMark | null {
|
||||
return request && !request.running ? { kind: request.kind, id: request.id } : null
|
||||
return request && request.turnState !== 'running' ? { kind: request.kind, id: request.id } : null
|
||||
}
|
||||
|
||||
export class StructuredAgentSessionTurnCompletionFeed {
|
||||
@@ -115,7 +115,7 @@ export class StructuredAgentSessionTurnCompletionFeed {
|
||||
return
|
||||
}
|
||||
baseline.sequence = cursor.sequence
|
||||
if (request?.running) {
|
||||
if (request?.turnState === 'running') {
|
||||
// A running turn clears the mark, so this detector fires on each running → settled
|
||||
// transition rather than on an id it happens not to have seen.
|
||||
baseline.settled = null
|
||||
|
||||
+3
-3
@@ -353,10 +353,10 @@ describe('already-wedged profiles become usable on load', () => {
|
||||
// What the sidebar reads: every status this restart published says the chat is not working.
|
||||
expect(published.filter((summary) => summary.sessionId === SESSION)).not.toEqual([])
|
||||
expect(published.map((summary) => summary.status)).not.toContain('working')
|
||||
// A crash is not something the user did: no outcome is claimed, so no reader files it as a
|
||||
// cancellation the user already knows about.
|
||||
// A crash is not something the user did: a proven one reads as an interruption and an
|
||||
// unprovable one as unconfirmed, so no reader files it as a cancellation the user knows about.
|
||||
expect(published.map((summary) => summary.turnOutcome)).toEqual(
|
||||
published.map(() => undefined)
|
||||
published.map(() => (verdict.state === 'interrupted' ? 'interruption' : 'unconfirmed'))
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -370,7 +370,7 @@ it('refuses the command at handover when the provider opened a turn meanwhile (B
|
||||
|
||||
it('leaves a command whose start failed not sent, beside one start-failure row (B3)', async () => {
|
||||
await attach()
|
||||
await state.host.close(SESSION)
|
||||
await state.host.close(SESSION, 'evict')
|
||||
state.acquire.mockRejectedValue(new Error('not signed in'))
|
||||
const params = compactParams()
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('a reopened Claude chat whose CLI dies before initialize', () => {
|
||||
ok: true
|
||||
})
|
||||
await waitForStructuredAgentSessionRecovery()
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
|
||||
// The user reopens it and sends; this time the CLI never answers, then dies, and its tree is
|
||||
// unprovable. Opening starts nothing: the send does.
|
||||
|
||||
@@ -504,7 +504,7 @@ describe('a structured Claude session over agentSession.*', () => {
|
||||
}
|
||||
const host = getStructuredAgentSessionHost()
|
||||
// The lease follows the root, so the host lets go.
|
||||
await host?.close(SESSION)
|
||||
await host?.close(SESSION, 'evict')
|
||||
expect(host?.hasSession(SESSION)).toBe(false)
|
||||
|
||||
// The user comes back and sends: that send is what starts Claude again.
|
||||
|
||||
@@ -20,6 +20,7 @@ import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry'
|
||||
import type { RuntimeCommandSurfaceHost } from './orca-runtime-core'
|
||||
import { SESSION_TAB_NOT_FOUND_ERROR } from '../../shared/session-tab-close'
|
||||
import { rendererPublicationThrottle } from '../window/renderer-publication-throttle'
|
||||
import { structuredAgentSessionTabCloseCause } from './structured-agent-session-tab-close-cause'
|
||||
|
||||
export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseUnattributedMobileSessionTabClose {
|
||||
async closeMobileSessionTab(
|
||||
@@ -300,7 +301,10 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.closeStructuredAgentSessionTab(tab)
|
||||
await this.closeStructuredAgentSessionTab(
|
||||
tab,
|
||||
structuredAgentSessionTabCloseCause(options.reason)
|
||||
)
|
||||
} else {
|
||||
if (!this.notifier?.closeSessionTab) {
|
||||
throw new Error('runtime_unavailable')
|
||||
|
||||
@@ -13,10 +13,14 @@ import type { BrowserSessionTabSelectionOptions } from './browser-tab-create-pub
|
||||
import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry'
|
||||
import { applyBrowserSessionTabSelection } from './browser-session-tab-selection-snapshot'
|
||||
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import type { StructuredAgentSessionCloseCause } from '../native-chat/agent-session-wire/structured-agent-session-host-lifetime'
|
||||
import { retireStructuredAgentSessionTabFrom } from './structured-agent-session-tab-retirement'
|
||||
|
||||
export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWithCloseMobileSessionTab {
|
||||
protected async closeStructuredAgentSessionTab(tab: RuntimeMobileSessionAgentTab): Promise<void> {
|
||||
protected async closeStructuredAgentSessionTab(
|
||||
tab: RuntimeMobileSessionAgentTab,
|
||||
cause: StructuredAgentSessionCloseCause
|
||||
): Promise<void> {
|
||||
const host = getStructuredAgentSessionHost()
|
||||
if (host) {
|
||||
if (typeof host.setSessionTabVisibility === 'function') {
|
||||
@@ -30,7 +34,7 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi
|
||||
// Retire durable visibility and the runtime snapshot before stopping the provider.
|
||||
this.retireStructuredAgentSessionTabFromSnapshot(tab.sessionId)
|
||||
if (typeof host?.close === 'function') {
|
||||
await host.close(tab.sessionId)
|
||||
await host.close(tab.sessionId, cause)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -308,7 +308,8 @@ describe('structured session cold restoration', () => {
|
||||
})
|
||||
|
||||
expect(closeSessionTab).toHaveBeenCalledWith('agent-session:restored-session', 'workspace-1')
|
||||
expect(closeStructuredSession).toHaveBeenCalledWith('restored-session')
|
||||
// The user closed this chat, so a turn the close cuts short is their cancellation.
|
||||
expect(closeStructuredSession).toHaveBeenCalledWith('restored-session', 'user-close')
|
||||
expect(setSessionTabVisibility).toHaveBeenCalledWith('restored-session', false)
|
||||
expect(setSessionTabVisibility.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
closeStructuredSession.mock.invocationCallOrder[0]!
|
||||
|
||||
@@ -188,7 +188,7 @@ export async function discardStructuredWorkerSession(
|
||||
}
|
||||
try {
|
||||
await host.setSessionTabVisibility?.(sessionId, false)
|
||||
await host.close(sessionId)
|
||||
await host.close(sessionId, 'evict')
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[orchestration] failed to discard a half-started structured worker',
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('admission revoked while a session is still open', () => {
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
expect(hostCalls.close).toHaveBeenCalledWith(SESSION)
|
||||
expect(hostCalls.close).toHaveBeenCalledWith(SESSION, 'user-close')
|
||||
// The durable tab has to be retired too, or the chat comes back on the next sync.
|
||||
expect(hostCalls.setSessionTabVisibility).toHaveBeenCalledWith(SESSION, false)
|
||||
})
|
||||
@@ -80,7 +80,7 @@ describe('admission revoked while a session is still open', () => {
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: true })
|
||||
expect(hostCalls.close).toHaveBeenCalledWith(SESSION)
|
||||
expect(hostCalls.close).toHaveBeenCalledWith(SESSION, 'user-close')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('admission revoked while a session is still open', () => {
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: true })
|
||||
expect(hostCalls.close).toHaveBeenCalledWith(SESSION)
|
||||
expect(hostCalls.close).toHaveBeenCalledWith(SESSION, 'user-close')
|
||||
})
|
||||
|
||||
it.each(ADMISSION_METHODS)(
|
||||
|
||||
@@ -103,7 +103,7 @@ beforeEach(async () => {
|
||||
afterEach(async () => {
|
||||
setStructuredAgentSessionHost(null)
|
||||
await host?.flushAllStreamedEvents()
|
||||
await host?.close(SESSION)
|
||||
await host?.close(SESSION, 'evict')
|
||||
await rm(root, { recursive: true, force: true })
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
|
||||
@@ -123,7 +123,7 @@ afterEach(async () => {
|
||||
|
||||
describe('the hold surface, for clients that still call it', () => {
|
||||
it('answers a hold without starting an agent or registering a cleanup', async () => {
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
expect(host.hasSession(SESSION)).toBe(false)
|
||||
const registered = vi.spyOn(runtime, 'registerOwnedSubscriptionCleanup')
|
||||
const acquiresBefore = acquire.mock.calls.length
|
||||
@@ -163,7 +163,7 @@ describe('the hold surface, for clients that still call it', () => {
|
||||
})
|
||||
|
||||
it('answers a hold even when no agent could be started', async () => {
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
acquire.mockRejectedValue(new Error('provider unavailable'))
|
||||
const acquiresBefore = acquire.mock.calls.length
|
||||
|
||||
@@ -176,7 +176,7 @@ describe('the hold surface, for clients that still call it', () => {
|
||||
|
||||
describe('a stream', () => {
|
||||
it('reads a closed conversation without starting its agent', async () => {
|
||||
await host.close(SESSION)
|
||||
await host.close(SESSION, 'evict')
|
||||
expect(host.hasSession(SESSION)).toBe(false)
|
||||
const acquiresBefore = acquire.mock.calls.length
|
||||
const frames: unknown[] = []
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('capability gating', () => {
|
||||
const response = await call('agentSession.close', { sessionId: SESSION }, STRUCTURED_CLIENT)
|
||||
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
expect(hostCalls.close).toHaveBeenCalledWith(SESSION)
|
||||
expect(hostCalls.close).toHaveBeenCalledWith(SESSION, 'user-close')
|
||||
expect(hostCalls.setSessionTabVisibility).toHaveBeenCalledWith(SESSION, false)
|
||||
expect(hostCalls.setSessionTabVisibility.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hostCalls.close.mock.invocationCallOrder[0]!
|
||||
|
||||
@@ -125,7 +125,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [
|
||||
if (replacement) {
|
||||
ctx.runtime.replaceStructuredAgentSessionTab(replacement)
|
||||
}
|
||||
await host.close(params.envelope.sessionId)
|
||||
await host.close(params.envelope.sessionId, 'user-close')
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -221,7 +221,8 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [
|
||||
if (typeof host.setSessionTabVisibility === 'function') {
|
||||
await host.setSessionTabVisibility(params.sessionId, false)
|
||||
}
|
||||
await host.close(params.sessionId)
|
||||
// Clients call this only when the user closes this chat's tab or cancels its launch.
|
||||
await host.close(params.sessionId, 'user-close')
|
||||
return { ok: true as const }
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -113,7 +113,7 @@ describe('worker-stop on a structured worker this runtime cannot reach', () => {
|
||||
state: 'stop_unknown'
|
||||
}
|
||||
)
|
||||
expect(close).toHaveBeenCalledWith(SESSION)
|
||||
expect(close).toHaveBeenCalledWith(SESSION, 'evict')
|
||||
expect(db.getWorkerDispatch(dispatchId)?.state).toBe('stop_unknown')
|
||||
})
|
||||
|
||||
|
||||
@@ -323,7 +323,7 @@ describe('structured worker discard retires the chat tab', () => {
|
||||
})
|
||||
).rejects.toThrow(/was refused/)
|
||||
|
||||
expect(close).toHaveBeenCalledWith(createdSessionId)
|
||||
expect(close).toHaveBeenCalledWith(createdSessionId, 'evict')
|
||||
expect(await structuredTabIds(runtime)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,7 +75,8 @@ export async function closeStructuredAgentSessionChild(
|
||||
try {
|
||||
await host.setSessionTabVisibility?.(sessionId, false)
|
||||
closeAttempted = true
|
||||
await host.close(sessionId)
|
||||
// A worktree teardown or an orchestration stop: not the user closing this chat.
|
||||
await host.close(sessionId, 'evict')
|
||||
} catch (error) {
|
||||
// Only `closeAttempted` proves the hide landed: the store transaction restores its own state on
|
||||
// failure, so a `setSessionTabVisibility` that threw hid nothing and has nothing to undo.
|
||||
|
||||
@@ -271,7 +271,7 @@ describe('a Stop in that window that the turn never opens for', () => {
|
||||
it('lets a chat closed behind it close within its bound and one eviction', async () => {
|
||||
const { stopping } = await waitingStop()
|
||||
|
||||
const closing = host.close(SESSION)
|
||||
const closing = host.close(SESSION, 'evict')
|
||||
|
||||
expect(
|
||||
await settledWithin(closing, CODEX_STOP_TURN_OPEN_WAIT_MS + CHILD_EVICTION_TIMEOUT_MS)
|
||||
@@ -305,7 +305,7 @@ describe('a cold send with no Stop behind it', () => {
|
||||
it('never delays closing the chat', async () => {
|
||||
await answeredColdSend()
|
||||
|
||||
expect(await settledWithin(host.close(SESSION), PROMPTLY_MS)).not.toBe('held')
|
||||
expect(await settledWithin(host.close(SESSION, 'evict'), PROMPTLY_MS)).not.toBe('held')
|
||||
expect(childCloses).toBe(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('a Claude child proving its start', () => {
|
||||
expect(claude.child(STALLED).calls).toEqual(['get_settings'])
|
||||
|
||||
let closed = false
|
||||
void host.close(STALLED).then(() => {
|
||||
void host.close(STALLED, 'evict').then(() => {
|
||||
closed = true
|
||||
})
|
||||
await vi.waitFor(() => expect(closed).toBe(true))
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { structuredAgentSessionTabCloseCause } from './structured-agent-session-tab-close-cause'
|
||||
|
||||
describe('structuredAgentSessionTabCloseCause', () => {
|
||||
it.each([
|
||||
{ label: "an older client's reasonless close", reason: undefined, cause: 'user-close' },
|
||||
{ label: 'a user close', reason: 'user', cause: 'user-close' },
|
||||
{ label: 'a cleanup echo', reason: 'cleanup', cause: 'evict' },
|
||||
{ label: 'a pty-exit echo', reason: 'pty-exit', cause: 'evict' }
|
||||
] as const)('closes the chat with $cause for $label', ({ reason, cause }) => {
|
||||
expect(structuredAgentSessionTabCloseCause(reason)).toBe(cause)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { RuntimeSessionTabCloseReason } from '../../shared/runtime-types'
|
||||
import type { StructuredAgentSessionCloseCause } from '../native-chat/agent-session-wire/structured-agent-session-host-lifetime'
|
||||
|
||||
/** Who a chat's `session.tabs.close` speaks for. A reasonless close is an older client's user
|
||||
* close; a lifecycle echo is not the user's. */
|
||||
export function structuredAgentSessionTabCloseCause(
|
||||
reason: RuntimeSessionTabCloseReason | undefined
|
||||
): StructuredAgentSessionCloseCause {
|
||||
switch (reason) {
|
||||
case undefined:
|
||||
case 'user':
|
||||
return 'user-close'
|
||||
case 'pty-exit':
|
||||
case 'cleanup':
|
||||
return 'evict'
|
||||
}
|
||||
}
|
||||
@@ -463,7 +463,7 @@ describe('a worker result reaches the structured chat that coordinates it', () =
|
||||
it('leaves a pointer the person stopped while its agent was starting stopped', async () => {
|
||||
await openChat(COORDINATOR)
|
||||
const { runId, taskId } = await coordinatorRunAndTask()
|
||||
await host.close(COORDINATOR)
|
||||
await host.close(COORDINATOR, 'evict')
|
||||
providerFaults.startDelayMs = 400
|
||||
const before = providerFaults.starts
|
||||
await finishWorker(taskId)
|
||||
@@ -528,7 +528,7 @@ describe('a worker result reaches the structured chat that coordinates it', () =
|
||||
): Promise<{ runId: string; starts: number }> {
|
||||
await openChat(COORDINATOR)
|
||||
const { runId, taskId } = await coordinatorRunAndTask()
|
||||
await host.close(COORDINATOR)
|
||||
await host.close(COORDINATOR, 'evict')
|
||||
providerFaults.refuseStart = refusal
|
||||
const before = providerFaults.starts
|
||||
await finishWorker(taskId)
|
||||
@@ -672,7 +672,7 @@ describe('a worker result reaches the structured chat that coordinates it', () =
|
||||
await openChat(COORDINATOR)
|
||||
const { taskId } = await coordinatorRunAndTask()
|
||||
// What the idle sweep leaves of a chat nobody is looking at: agent stopped, no map entry.
|
||||
await host.close(COORDINATOR)
|
||||
await host.close(COORDINATOR, 'evict')
|
||||
expect(host.hasSession(COORDINATOR)).toBe(false)
|
||||
const before = codex.connections.length
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ async function seedPendingRewind(phase: 'prepared' | 'provider-succeeded') {
|
||||
}
|
||||
|
||||
async function reattach() {
|
||||
await host.close(HOST_TEST_SESSION)
|
||||
await host.close(HOST_TEST_SESSION, 'evict')
|
||||
expect(await host.attach(caller, attachParams(fence()))).toMatchObject({ ok: true })
|
||||
}
|
||||
|
||||
|
||||
@@ -337,7 +337,7 @@ describe('a task dispatched into a worker whose own dispatch settled', () => {
|
||||
await vi.waitFor(() =>
|
||||
expect(observeStructuredWorker({ sessionId: REST_TEST_SESSION }).status).toBe('exited')
|
||||
)
|
||||
expect(rig.adapter.closeSession).toHaveBeenCalledWith(REST_TEST_SESSION)
|
||||
expect(rig.adapter.closeSession).toHaveBeenCalledWith(REST_TEST_SESSION, 'evict')
|
||||
} finally {
|
||||
hostRef.current = null
|
||||
await rig.dispose()
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('AgentStateDot', () => {
|
||||
expect(markup).not.toContain('data-agent-spinner')
|
||||
})
|
||||
|
||||
it.each(['blocked', 'interrupted'] satisfies AgentDotState[])(
|
||||
it.each(['blocked', 'failed'] satisfies AgentDotState[])(
|
||||
'renders %s as a red attention dot',
|
||||
(state) => {
|
||||
const classNames = renderDotClassNames(state)
|
||||
@@ -106,6 +106,14 @@ describe('AgentStateDot', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it("renders a user's Stop as a muted dot, neither the fault red nor the idle grey", () => {
|
||||
const classNames = renderDotClassNames('interrupted')
|
||||
|
||||
expect(classNames).toContain('bg-muted-foreground')
|
||||
expect(classNames).not.toContain('bg-red-500')
|
||||
expect(classNames).not.toContain('bg-neutral-500/40')
|
||||
})
|
||||
|
||||
const ALL_STATES = [
|
||||
'working',
|
||||
'monitoring',
|
||||
@@ -116,6 +124,7 @@ describe('AgentStateDot', () => {
|
||||
'done',
|
||||
'idle',
|
||||
'unverifiable',
|
||||
'unconfirmed',
|
||||
'permission'
|
||||
] satisfies AgentDotState[]
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ export type AgentDotState =
|
||||
// held there, and never rendered as 'done' or 'working' — it asserts nothing about
|
||||
// the agent, only about what Orca last heard.
|
||||
| 'unverifiable'
|
||||
// Why: the turn ended and Orca cannot prove how. An outcome like 'failed', drawn with the
|
||||
// 'unverifiable' glyph because it too reports missing evidence, never a finish.
|
||||
| 'unconfirmed'
|
||||
// Why: the sidebar's title-based status flow (StatusIndicator/WorktreeCard)
|
||||
// collapses blocked + waiting into a single "needs attention" state. Keep
|
||||
// this as a distinct member so that flow can render without inventing a new
|
||||
@@ -63,6 +66,8 @@ export function agentStateLabel(state: AgentDotState): string {
|
||||
return 'Idle'
|
||||
case 'unverifiable':
|
||||
return 'No recent update'
|
||||
case 'unconfirmed':
|
||||
return 'Couldn’t confirm'
|
||||
case 'permission':
|
||||
return 'Needs attention'
|
||||
}
|
||||
@@ -123,7 +128,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({
|
||||
<CircleCheck className={cn('text-emerald-500', icon)} aria-hidden="true" />
|
||||
</span>
|
||||
)
|
||||
} else if (state === 'unverifiable') {
|
||||
} else if (state === 'unverifiable' || state === 'unconfirmed') {
|
||||
// Why: a dashed ring reads as "incomplete information" rather than a state claim,
|
||||
// and amber carries warning weight without borrowing 'done' green or 'working' yellow.
|
||||
indicator = (
|
||||
@@ -153,9 +158,12 @@ export const AgentStateDot = React.memo(function AgentStateDot({
|
||||
className={cn(
|
||||
'block rounded-full',
|
||||
inner,
|
||||
state === 'blocked' || state === 'interrupted' || state === 'failed'
|
||||
state === 'blocked' || state === 'failed'
|
||||
? 'bg-red-500'
|
||||
: 'bg-neutral-500/40'
|
||||
: // Why: a user's Stop is not news; muted, never the fault red or the finished green.
|
||||
state === 'interrupted'
|
||||
? 'bg-muted-foreground'
|
||||
: 'bg-neutral-500/40'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -22,8 +22,9 @@ import { resumeActivityLabel } from './native-chat-resume-activity-label'
|
||||
* hidden when empty).
|
||||
*
|
||||
* No state dot, deliberately. Every `AgentDotState` would mislead: `idle` and `unverifiable` both
|
||||
* presuppose a live pane, `interrupted` renders red like an error, `done` green, `working` a
|
||||
* spinner. A missing dot beats a dot that says these agents are running.
|
||||
* presuppose a live pane, `interrupted` claims a stop or a newer message ended the turn, `failed`
|
||||
* a fault, `done` a finish, `working` a spinner. A missing dot beats a dot that says these agents
|
||||
* are running.
|
||||
*
|
||||
* Under the name, what the chat was doing when Orca went away — mid-reply, waiting on the user,
|
||||
* subagents or monitoring — so rows the sidebar showed as working for different reasons differ.
|
||||
|
||||
@@ -49,6 +49,37 @@ describe('ThreadAgentStateIndicator', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ThreadAgentStateIndicator for a turn that ended without finishing', () => {
|
||||
it.each([
|
||||
{ outcome: 'cancellation', label: 'Interrupted' },
|
||||
// A turn anything but the user cut short is a fault, as a failure is.
|
||||
{ outcome: 'interruption', label: 'Failed' },
|
||||
{ outcome: 'unconfirmed', label: 'Couldn’t confirm' }
|
||||
] as const)('draws $outcome as $label', ({ outcome, label }) => {
|
||||
const threads = makeThreads(
|
||||
makeActivityResult({
|
||||
entries: {
|
||||
[PANE_KEY]: {
|
||||
...makeWorkingEntryWithoutHistory(),
|
||||
state: 'done',
|
||||
mainAgent: { state: 'done', outcome, stateStartedAt: 3_000 }
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
createElement(ThreadAgentStateIndicator, { thread: threads[0]! })
|
||||
)
|
||||
)
|
||||
|
||||
expect(markup).toContain(`aria-label="${label}"`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('activity thread grouping', () => {
|
||||
it('status grouping separates interrupted done from normal done and keeps Interrupted label', () => {
|
||||
const repo = makeRepo()
|
||||
@@ -95,8 +126,7 @@ describe('activity thread grouping', () => {
|
||||
expect(groups).toHaveLength(2)
|
||||
expect(groups[0].key).toBe('interrupted')
|
||||
expect(groups[0].label).toBe('Interrupted')
|
||||
// Interrupted rows keep the done glyph (#2569); the header mirrors the row.
|
||||
expect(groups[0].state).toBe('done')
|
||||
expect(groups[0].state).toBe('interrupted')
|
||||
expect(groups[1].key).toBe('done')
|
||||
expect(groups[1].label).toBe('Done')
|
||||
})
|
||||
|
||||
@@ -18,15 +18,15 @@ export type ClearCompletedActivityPlan = {
|
||||
clearedThreadCount: number
|
||||
}
|
||||
|
||||
/** A thread is clearable when it needs nothing from the user: completed, failed or interrupted,
|
||||
* with no fresh live working/monitoring/blocked/waiting state. */
|
||||
/** A thread is clearable when it needs nothing from the user: any finished outcome, with no fresh
|
||||
* live working/monitoring/blocked/waiting state. */
|
||||
export function isClearableActivityThread(thread: AgentPaneThread): boolean {
|
||||
const id = activityThreadStatusId(thread)
|
||||
// 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'
|
||||
return id === 'done' || id === 'failed' || id === 'interrupted' || id === 'unconfirmed'
|
||||
}
|
||||
|
||||
export function planClearCompletedActivity(
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import {
|
||||
buildActivityEvents,
|
||||
buildActivityThreadGroups,
|
||||
buildAgentPaneThreads
|
||||
} from './ActivityPrototypePage'
|
||||
import {
|
||||
makeRepo,
|
||||
makeTabWithIds,
|
||||
makeWorktree,
|
||||
PANE_KEY,
|
||||
PANE_KEY_2
|
||||
} from './ActivityPrototypePage-test-fixtures'
|
||||
|
||||
type Ending = 'cancellation' | 'interruption'
|
||||
|
||||
function doneEntry(paneKey: string, outcome: Ending, at: number): AgentStatusEntry {
|
||||
return {
|
||||
state: 'done',
|
||||
prompt: 'Prompt',
|
||||
terminalTitle: 'Claude',
|
||||
stateHistory: [],
|
||||
agentType: 'claude',
|
||||
paneKey,
|
||||
interrupted: outcome === 'cancellation',
|
||||
updatedAt: at,
|
||||
stateStartedAt: at,
|
||||
mainAgent: { state: 'done', outcome, stateStartedAt: at }
|
||||
}
|
||||
}
|
||||
|
||||
/** Status groups for two ended panes, `newer` the most recent. */
|
||||
function statusGroups(newer: Ending, older: Ending) {
|
||||
const repo = makeRepo()
|
||||
const worktree = makeWorktree()
|
||||
const { events, liveAgentByPaneKey } = buildActivityEvents({
|
||||
agentStatusByPaneKey: {
|
||||
[PANE_KEY]: doneEntry(PANE_KEY, newer, 3_000),
|
||||
[PANE_KEY_2]: doneEntry(PANE_KEY_2, older, 2_000)
|
||||
},
|
||||
retainedAgentsByPaneKey: {},
|
||||
tabsByWorktree: {
|
||||
[worktree.id]: [makeTabWithIds('tab-1', worktree.id), makeTabWithIds('tab-2', worktree.id)]
|
||||
},
|
||||
worktreeMap: new Map([[worktree.id, worktree]]),
|
||||
repoMap: new Map([[repo.id, repo]]),
|
||||
acknowledgedAgentsByPaneKey: {},
|
||||
now: 3_000
|
||||
})
|
||||
return buildActivityThreadGroups(buildAgentPaneThreads({ events, liveAgentByPaneKey }), 'status')
|
||||
}
|
||||
|
||||
describe('the status group headers', () => {
|
||||
it.each([
|
||||
{ newer: 'cancellation', older: 'interruption' },
|
||||
{ newer: 'interruption', older: 'cancellation' }
|
||||
] as const)(
|
||||
"never mixes a user's Stop with a crash, whichever is newest ($newer newest)",
|
||||
({ newer, older }) => {
|
||||
const groups = statusGroups(newer, older)
|
||||
|
||||
// A crash sits with failures; the Stop alone heads Interrupted, below it.
|
||||
expect(groups.map((group) => [group.key, group.state, group.label])).toEqual([
|
||||
['failed', 'failed', 'Failed'],
|
||||
['interrupted', 'interrupted', 'Interrupted']
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it("heads a group of user's Stops with the interrupted glyph", () => {
|
||||
const groups = statusGroups('cancellation', 'cancellation')
|
||||
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0]).toMatchObject({ key: 'interrupted', state: 'interrupted' })
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user