fix(native-chat): a Stop still reads as yours after Orca restarts, because the turn's end reads the Stop's event (#24311)

* test(native-chat): a Stop over a card sent now into the running turn keeps it paused

Red on main: Codex's turn end withdraws the steered hand-off and the queue
sends the card again as a new host turn, with no pause recorded.

* fix(native-chat): a Stop's queue pause holds a card whose hand-off is still unanswered

A card sent now into the running turn was still pending when Stop judged the
pause, so nothing was recorded; the interrupt then withdrew the hand-off, the
card went back to waiting unpaused, and the queue sent it again as a new turn.
The pause now counts a hand-off that may still return to waiting, judged with
the appended row applied, so a withdrawal lands under the pause and an
acceptance retires it in that same write. Codex and Claude both hit it.

* test(native-chat): the Claude re-send case fails on its diff, inside the test's budget

* test(native-chat): a pause held by an unanswered hand-off ends on every path that ends it

The provider's answer, the provider dying, the chat closing, a restart, a
withdrawal still owed at open, and a /clear (refused until the hand-off ends,
then carrying every waiting card paused 'cleared'); each ends with the queue
sending again.

* fix(native-chat): narrow the pause's settled hand-off, and assert the queued receipt's card

* refactor(native-chat): derive the queue's pause from Stop and Resume journal rows

Stop now appends one journal row where it takes effect, before the interrupt,
whatever the queue holds; Resume appends its own. The pause is a pure function
of the fold: the latest Stop with no later Resume and no later accepted turn a
person asked for. A /clear's carried cards name their source, which is the
replacement's 'cleared' pause. Host-origin turns never lift either.

One predicate decides which cards a pause holds; by default every waiting card
without a hold of its own, including one queued after the Stop. The drain's
consume re-judges it inside its own transaction.

The rows are tombstones of an id no item takes, carrying the mark: a released
host reads an unknown row kind as corruption and truncates the journal there.

Deletes the stored pause (recordPause, the retire hook on every appended row,
the settle-before-record step, mayReturnToWaiting and its row overlay) and the
tests that only proved it retires. The queued_message_pauses table stays in the
schema, unread and unwritten, for downgrade safety.

* fix(native-chat): a card queued after a Stop sends normally, never ahead of held ones

A Stop's pause now holds only the cards queued before its row, plus a steer it
withdrew, which returns to its own place. Each card records the journal
position it was queued at, and the one hold rule compares that with the Stop
row. A card queued after the Stop is a new instruction: it sends as usual, but
the drain still stops at the first held card, so it never overtakes them.
/clear's pause holds the cards it carried. Holding every card again is a
one-line switch in that rule.

* fix(native-chat): the queue's own send re-checks the no-overtake rule in its transaction

The drain's pick and its consume now read one function, nextSendableQueuedCard,
so a Stop row that lands between them holds a newer card behind an older held
one exactly as the pick would. Notes why Stop and Resume ride a tombstone row.

* fix(native-chat): stop creating the unused queue pause table

The queue's pause is derived from journal rows, so nothing reads or writes
queued_message_pauses. It was still created on every open "for downgrade
safety", but an older build creates it itself when it opens the database, so
the table only sat empty in every new database. The tests now pin that no
pause table exists.

* fix(native-chat): a Stop's pause never hides the restart pause

A Stop holds only the cards queued before it. The pause derivation still
returned the Stop alone whenever it was in force, so the restart pause was
never considered: a card queued after the Stop, written by a host process
that has since exited, sent by itself after Orca restarted, with no pause
header and no Resume. A /clear pause that held nothing could hide it the
same way.

Every pause in force is now derived. A card is held if any of them holds
it, and it names the first that does. The drain's pick, the consume
transaction's re-check and the published header all read that one rule;
the header names the pause holding the first card Resume would send.

* test(native-chat): pin the Stop's no-resend, lift and held-card rules

- The Claude and Codex Stop-withdraws-a-steer tests checked "not sent
  again" at one instant, before a queue ignoring the pause re-sends. They
  now wait for the stopped turn to end and re-check after a quiet window.
- The deleted-card test read a card queued after the Stop, which sends
  whether or not a person's turn lifts it; it now reads the Stop's pause
  before and after that turn.
- Unit cases pin that a Stop holds a card with no recorded position and one
  queued before a rewind.

* refactor(native-chat): a Stop writes one Stop event with its reason, turn and caller

The Stop row that paused the queue becomes the general Stop event
{ reason, turnId?, at, caller? }, whose reason is the host's existing stop
cause. It still rides a tombstone of a host-only id (a released host deletes
the journal from the first unknown row kind), and Resume keeps its own marker
on its own id. Only a person's Stop (reason user-stop) pauses the queue.

* test(native-chat): a rewind keeps a lifted /clear pause lifted and restates the same Stop event

* test(native-chat): pin that Stop and Resume rows never reach apps or count as history

* test(native-chat): only a person's Stop event pauses the queue

* test(native-chat): pin that a Stop's event precedes the interrupt and the at-start stop

Through the real host: the event names the turn and who asked and is in the
journal when the interrupt reaches the agent; at an agent still starting it is
there before the start is ended and holds a card queued before it; an idle Stop
writes one only when it withdrew a send; and the queue's claim re-judges a
pause that landed after its pick.

* test(native-chat): a card held at a starting agent is checked before the Stop's timing

Also says precisely what the claim's in-transaction pause check defends
against: the Stop and the drain share one serialized lane.

* test(native-chat): a released build keeps and folds a journal holding Stop events

Replays this build's rows from the released build's own journal database: every
row is kept, the history after the Stop still folds, and an older client is sent
only removed ids no item uses.

* style(native-chat): format the Stop event changes

* test(native-chat): type the released build's exports through one checked helper

* fix(native-chat): the Stop/Resume row guard narrows to those tombstones only

* test(native-chat): run the Stop-event downgrade test in CI, and cover a writable downgrade

The Stop-event downgrade test ran in no CI lane: unit shards exclude the
cross-version folder, and the cross-version lane runs a fixed file list that
did not name it. It is now on that list.

Its only case replayed the rows into a release's own fresh database, because
that release cannot open the current host database. A second case opens the
journal this build wrote with a main build that shares the database: it opens
writable, keeps every row, appends, and this build then reopens it with the
person's Stop still pausing the queue.

* fix(native-chat): a Stop that stops nothing new writes no Stop event

A Stop reaching a running agent wrote a Stop event on every press. Two
presses before the first interrupt landed wrote two events, so a card
queued between them counted as before the latest Stop and was held,
though a card queued after a Stop should send normally. A Stop naming a
turn that had already ended, as a phone sends late, also wrote an event
for a turn it never stopped.

It now writes one only when it withdrew a queued send, or stops something
no event records yet: not a turn the journal no longer runs, and not the
live turn a Stop still in force already names, unless a card was handed
over into it since, which this Stop's interrupt sends back and must hold.
The interrupt and the "already finished" note are unchanged. A Stop at a
starting agent still always writes.

* test(native-chat): pin that a later host, eviction or close Stop never lifts a person's Stop

* chore(native-chat): put each Stop-row doc on its own declaration, and say only user-stop is journaled

* fix(native-chat): any later Stop event ends a person's Stop pause

A person's Stop paused the queue until their next accepted turn or Resume,
and a later Stop of another reason (the host stopping the agent, an
eviction, a close) was ignored. Now the pause is the latest Stop event's:
a later Stop of any reason ends a person's pause, and only a person's Stop
pauses. The fold keeps the latest Stop event whatever its reason.

An eviction of a resting chat writes no Stop event (a Stop that stops
nothing writes nothing), so it cannot release held cards; a test pins that
no event means no lift.

* fix(native-chat): a second Stop press is a repeat even when the first came before the turn showed

A Stop pressed before the agent's turn shows in the journal (before
Claude's echo, or before Codex opens the turn) records no turn. A second
press once the turn showed compared that missing turn with the live one,
wrote a second Stop event, and held a card queued between the presses.

A repeat is now judged by what was sent since the Stop in force: with
nothing sent after it (a refused send aside), a Stop that named no turn,
or named the live one, is repeated and writes nothing. Anything sent since
and not refused, including a send whose fate is unknown, makes the new
press write, since its interrupt may send that card back to waiting.

Tests: the two-press case across the turn showing; a steer between the
presses settled unknown; and a Stop naming a turn that ended while the next
card is sent but shows no turn yet, which writes and holds that card. The
fold test that claimed an eviction path is renamed.

* fix(native-chat): the queue's pause ignores a Stop or Resume row holding a value no build writes

A Stop or Resume row's value is read from disk with no shape check, and
the pause fold stored whatever it found. A stored `stopEvent: null` would
then throw on every pause check for that chat: the queue's pick, its
send, and every queue update to clients. No build writes such a row, so
this is hardening.

The fold now reads a Stop only when it is an object with a string reason
and a finite time, and a Resume only when it is `true`. Anything else is
ignored: it pauses nothing and ends nothing. The row is still not treated
as malformed, which could cut the history short.

* fix(native-chat): a Stop still reads as yours after Orca restarts before the turn ends

Every stop that ends work now writes the Stop's event before it ends the child: a
person's close of the chat, an eviction (worktree teardown, orchestration stop, tab
cleanup) and the idle sweep's stop of a start that never landed. A stop that ends
nothing writes nothing, and quit writes none: its resume marker records why.

The turn-end write reads the latest Stop event where every turn row is built, so the
adapter's settle, the host's fallback and the relaunch's settle all agree: a turn a
person's Stop or close named, ending with no verdict of its own after that Stop, ends
as their cancellation. A relaunch's probe-bounded end is no earlier than a Stop that
found the turn running. When the provider refuses the interrupt and the turn runs on,
a refusal row answers the Stop, so a later crash still reads Failed; pressing Stop
again after a refusal is a new Stop.

* refactor(native-chat): a stop no longer carries its cause; the turn's end reads the Stop event

The cause of a stop was threaded in memory from each entry through the host's stop
step, the adapter router and each adapter's close onto the `ended` it settled with,
and Claude kept a per-turn copy of a Stop it sent. All of that is gone: adapters
settle a turn they cut as interrupted with no verdict, the host's fallback does the
same, and the one rule where a turn row is built (`turnEndAfterStop`) reads the
journal's latest Stop event to say whether it was a person's.

- `closeSession` / `disposeSession` take no cause; `ended` has no `stopCause`.
- Claude reads an error result after a person's Stop as their cancellation from the
  journal's Stop event (through the event sink), not from a per-turn slot, and a
  refused interrupt is the host's refusal row, not `withdrawTurnStop`.
- An owed wind-down keeps no cause: its retry's fallback reads the Stop event.
- The mutation context's Stop passes no cause: its step already wrote the event, and
  the delivery loop's child-end reason is read back from it.
- A Stop pressed before its turn showed applies to the turn that opens under it,
  unless a send a person made since was accepted.

* test(native-chat): a turn a later send opened is no Stop's that named no turn

* test(native-chat): the restart test's death proof carries its detail

* refactor(native-chat): a refused Stop leaves no record; a Stop only ever ends the turn it names

The stop-refused mark is gone: its tombstone kind, its fold, the clock-keyed match that tied it to
a Stop, and the exception that let a second press after a refusal write a new Stop. A Stop that
stops nothing writes nothing. A Codex refusal names a turn that is no longer its active one, and
the Stop names that turn, so the turn running instead never reads as the person's by its id alone.

* fix(native-chat): a Stop pressed before any turn showed stops only the turn opened next

A Stop that named no turn read as the person's cancellation for every later turn that opened
after it, until a send a person made was accepted. The queue's drain, orchestration mail and a
restart continuation send as the host, so a turn they opened long after, cut by a crash, read
"Interrupted" as if the person had stopped it. The Stop now applies only to the first turn
opened after it.

* fix(native-chat): an older Claude's error end after a Stop pressed before its echo reads Interrupted

Claude CLIs before 2.1.91 end an interrupted turn with an error result that names no reason. The
translator judged whether a person's Stop explained it by its own copy of the Stop rule, which
ignored a Stop that named no turn, so a Stop pressed before Claude echoed the send read "Failed".
The translator now writes such an end as interrupted with no verdict and no error row whenever a
person's Stop may name the turn, and the journal's one rule decides as it writes the end.

* fix(native-chat): a person's Stop and /clear each name why they end the agent

The host's mutation path ended the agent with one "recorded" ending for every caller, which read
back the reason of whatever Stop event the journal held last, however old. /clear writes no Stop
event, so its end took an unrelated earlier reason. Each caller now names its own: the chat's Stop
`user-stop`, whose event its own step wrote, and /clear `user-close`, the user replacing this chat.

* fix(native-chat): a host stop judges whether it ends work after the provider's rows land

A close, eviction or host stop decided whether it ended a running turn from the journal as it
stood, while the provider's own rows (the turn its echo opened) could still be in the session's
event sink. A close landing in that gap wrote no Stop event, so the turn it cut read as news. It
now reads after the sink drains, as a person's Stop does, through the same check; a drain that
fails or takes over a second reads working.

* fix(native-chat): a Claude Stop naming a turn that just ended still marks the follow-up it cuts

A phone names the turn it last saw. When that turn had ended and a follow-up was still unechoed,
Claude's Stop interrupted the follow-up and ended the child, but the Stop's event named the ended
turn, so the follow-up's turn the child's end cut read "Failed" under "Cancellation requested.".
A Stop that ends the provider's session ends whatever is in flight, so its event now names the
live turn or none, and a Stop that names none binds the turn opened next. Codex keeps naming only
the turn the Stop names.

The Claude Stop turn-end tests move to their own file, since the session-ending Stop suite is at
its line budget.

* fix(native-chat): the idle sweep reads working by the same rule as a stop's event

The sweep judged a chat resting while a send whose reply was lost was still unanswered, but the
stop's event writer counts that send as work. So the sweep evicted it and wrote an evict event,
which ends a person's Stop pause and let the cards behind it drain on their own. The sweep's owed
work now reads the main agent working the way every session list and the event writer do.

* test(native-chat): an aborted eviction's injected drain failure lands on the eviction's own drain

A host stop now drains the session's sink once to judge whether it ends work, so the tests that
fail the eviction's drain-published step skip that first drain.

* fix(native-chat): the idle sweep's rest writes no Stop event; it evicts a send that never echoes

The previous commit made the sweep count an unanswered send as owed work, which pins a chat whose
admitted send Codex never echoes forever, and the sweep exists to retire exactly that. That rule
returns. The sweep stops only an agent it judged resting, so its eviction now writes no Stop
event, whatever send it retires: a person's Stop pause holds through it.

* fix(native-chat): stopping a start that carries no send writes no Stop event

A host stop, eviction or close of a starting child wrote a Stop event whatever the start carried.
A start with a send already reads working, so the clause only mattered for a start with none,
which ends no turn and no send: its event only lifted a person's Stop pause and bumped the idle
clock, which is why the idle sweep had been changed to close the conversation in the same pass.
The clause goes and the sweep is #24072's again. The child's end still reads host-stop, as before.

* test(native-chat): a Stop's pause across a restart is tested with a restart that writes no event

The rig's restart closes the chat with an eviction, which now writes a Stop event when work runs
and so ends a person's Stop pause. "A Stop never hides a restart's pause" then passed with no Stop
pause left to hide anything. Those tests, and the pause-lift test whose dropped assertion returns,
restart as a process that dies with no close, which like a quit writes no Stop event, and assert
that both the Stop's and the restart's pauses are in force first.

* fix(native-chat): a host stop of a turn a person's Stop is still ending keeps that Stop's reason

An eviction or host stop that landed while a person's Stop or close was already ending the same
turn wrote a newer Stop event, and the turn's end reads only the latest, so the person's Stop of
that turn read as news. A host reason now writes nothing while a person's Stop still decides what
runs: the live turn it names or bound, or, with none, the turn a send opens next. The person's
own close still writes. The E2 tests now open and end the stopped send's own turn, as Codex does,
so the mail turn after it is not the turnless Stop's.

* fix(native-chat): an older Claude's error on a later turn keeps its error text after a Stop

The translator left an error result that names no reason to the journal's Stop rule whenever a
person's Stop named the turn or none, but the rule binds a Stop naming no turn only to the turn
opened next. So a real error on a later turn read "Failed" with its error text dropped. The
translator now asks the journal's rule itself (`personStopDecidesTurn`, the one core
`turnEndAfterStop` and a host stop's in-force check share), so the two cannot disagree.

* fix(native-chat): a Stop of a start that never landed binds no later turn, whatever sent it

A person's Stop pressed while the agent starts names no turn, and the send it stopped is
cancelled before it opens one. The Stop then bound the next turn anything opened (orchestration
mail, a restart continuation, the queue's drain, all of which send as the host), so a host
eviction of that turn wrote nothing and its crash or close read as the person's cancellation. A
Stop that named no turn now binds only a turn no send journaled after it opened: any send since,
of any origin and not refused, opens its own. The E2 test's mail send is accepted as Codex
accepts it, instead of opening the stopped send's own turn first.

* test(native-chat): a rewind's restated turnless Stop binds no turn opened after the rewind

A Codex rewind restates a person's Stop still in force after the turns it keeps, at a new
sequence, so by sequence alone it would bind the next turn opened after the rewind. A send
journaled after the restated row voids that binding (the previous commit), which this pins.

* fix(native-chat): a relaunch settles a person's stopped turn with no "stopped while in progress" row

After a restart, a turn a person's Stop ended reads "Interrupted after N" with the muted mark, but
the relaunch still added the error row saying the provider stopped mid-response, which a live Stop
never writes. The settle now skips that row when every turn it interrupts is the person's Stop's
by the journal's one rule; a crash nobody stopped keeps it.

* test(native-chat): the unexpected-exit settle's journal fake answers whether a person's Stop decides a turn

* fix(native-chat): a host stop whose sink drain fails reads the journal as it stands

A host stop drains the session's sink before judging whether it ends work, and a failed or slow
drain read as working. So an eviction of an agent at rest wrote a Stop event that ended nothing,
which lifts a person's Stop pause, and a close wrote a person's event naming no turn. The drain is
now best effort: the stop goes ahead either way and only its record is at stake, so a failed or
slow drain leaves the journal's read as it stands. A person's Stop keeps its own rule.

* fix(native-chat): a Stop that named no turn applies only to a turn a send it stopped opened

A person's Stop pressed before any turn showed names no turn. It bound the first turn opened
after it, then (5ead1f6bcc) any turn opened by no later send, so a turn the host started for
a card the Stop held, or for orchestration mail, read as the person's cancellation, and a host
eviction of it wrote no Stop event when its send had been abandoned by the close first.

The rule is now the concept itself: a Stop naming no turn applies to a turn opened by a send it
stopped, one already handed to the agent at the Stop's position. Nothing new is stored. The turn's
row names the send that opened it (Codex: the submission's key; Claude: the echo, which the journal
aliases to the submission), and a handed-over send's item sits at its handover, so the target set
is derived from the journal. A card the Stop held is handed over after it, so it is no target; a
Stop of a start whose send never opens a turn binds nothing; a rewind keeps no submissions, so a
restated Stop binds no turn opened after it. With no turn running, a host stop defers to the
person's Stop only while every unanswered send is one it stopped. Claude's translator, which asks
before its echo row lands, passes the send its echo acknowledged.

* fix(native-chat): a host stop whose sink drain runs long reads the agent working; a failed one reads the journal

A drain past its bound may still hold the turn's row, while the echo's acceptance has already
landed, so the journal as it stands read nothing running: a person's close of that turn wrote no
Stop event and the turn read as news. The two drain outcomes now differ: one that failed has
nothing more to deliver, so the journal's read holds (as before); one still running reads working.

* test(native-chat): a host stop with no turn running defers only while every unanswered send is the Stop's

The branch had no test. An eviction with only the stopped send unanswered writes nothing; one
with a send made after the Stop still unanswered writes its event.

* fix(native-chat): a slow sink drain reads working only while an accepted send's turn row is due

The previous commit read every drain past its bound as working, so a host eviction or stop of an
agent at rest during a sink backlog wrote a Stop event that ended nothing and lifted a person's
Stop pause. A slow drain now reads working only when the latest send the agent accepted has opened
no turn the journal holds, the race it was for; otherwise the journal's read holds.

* test(native-chat): the host-stop control keeps the stopped send unanswered beside the later one

With both unanswered, the host writes only because not every unanswered send is the Stop's; a rule
that deferred when any one was would pass the old control.

* fix(native-chat): a steer is no send owed a turn when a slow drain judges a host stop

A slow drain reads working when the latest accepted send has opened no turn yet. A Codex steer or
a Claude fold is accepted into the running turn and never opens one, so a chat at rest whose last
send was a steer still read working, and an eviction lifted a person's Stop pause. Sends delivered
into a running turn, whose item carries that turn's scope, are skipped.

* test(native-chat): type the Stop test envelope's fields narrowly

* test(native-chat): the retry of a close whose exit was unproven writes no second Stop event

The idle sweep finishes a stop left owed with that stop's own cause. It is the same stop, so its
event stands alone and the child's end keeps the cause, for a person's close and an eviction.
This commit is contained in:
Brennan Benson
2026-10-01 16:56:13 -07:00
committed by GitHub
parent 1553bc3b80
commit ccb63afc06
76 changed files with 2179 additions and 531 deletions
@@ -125,7 +125,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, 'evict')
expect(closeSession).toHaveBeenCalledWith(SESSION)
expect(host.hasSession(SESSION)).toBe(false)
})
})
@@ -1,6 +1,6 @@
// 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.
// Closing a Claude child settles its open turn in the adapter as interrupted, with no verdict: the
// close names no cause. Whether the end was a person's is the journal's Stop event to say; an exit
// the adapter saw before the close settles as that exit.
import { describe, expect, it } from 'vitest'
import type {
@@ -53,33 +53,24 @@ async function childInsideTurn() {
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 () => {
describe('a Claude close settles the open turn with no verdict of its own', () => {
// Whose end it was is the journal's Stop event to say (`turnEndAfterStop`), never the close's.
it('ends it interrupted, with no outcome', 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')
await expect(adapter.closeSession('session-1')).resolves.toBe(true)
expect(turns.at(-1)).toMatchObject({ state: 'interrupted' })
expect(turns.at(-1)).not.toHaveProperty('outcome')
expect(events.find((event) => event.type === 'ended')).not.toHaveProperty('stopCause')
})
it('leaves a crash it saw before the user closed the chat as news', async () => {
it('settles a crash it saw before the close once, as that exit', 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.
// The child dies on its own first; the close arrives while that exit is still settling.
connection.handlers.onExit?.(new Error('provider exited'))
await adapter.closeSession('session-1', 'user-close')
await adapter.closeSession('session-1')
await tick()
const settled = turns.filter((turn) => turn.state !== 'running')
@@ -2,7 +2,6 @@
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'
@@ -15,9 +14,6 @@ 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 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. */
+4 -1
View File
@@ -63,7 +63,9 @@ export function journalClaudeMessage(
startsTurn: boolean,
observedAt: number,
/** Host clock on the submission that produced this send, when known. */
requestedAt?: number
requestedAt?: number,
/** The submission this send echo acknowledged. */
openedBy?: string
): boolean {
const envelope = readClaudeMessageEnvelope(message)
if (!envelope) {
@@ -176,6 +178,7 @@ export function journalClaudeMessage(
startsTurn,
observedAt,
...(requestedAt === undefined ? {} : { requestedAt }),
...(openedBy === undefined ? {} : { openedBy }),
userItemId: agentJournalItemKey(identity)
})
if (sendEchoTurn) {
+5 -17
View File
@@ -12,7 +12,6 @@ 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,
@@ -33,9 +32,6 @@ 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. */
@@ -64,6 +60,11 @@ export class ClaudeOpenTurn {
}
/** The open turn's row, where a fact about the running turn lands. */
/** The submission that opened the open turn, when known (`ClaudeCurrentTurn.openedBy`). */
get openedBy(): string | null {
return this.current?.openedBy ?? null
}
get identity(): AgentJournalItemIdentity | null {
return this.current ? claudeCurrentTurnIdentity(this.current) : null
}
@@ -90,19 +91,6 @@ 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
}
/** 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. */
@@ -27,7 +27,11 @@ import { claudeDispatchContentKey } from './claude-structured-dispatch-content'
/** Settles a provider-proven late outcome; replay rows independently reconcile acceptance. */
export type ClaudeLateDispatchSettlement = (input: ClaudeLateDispatchOutcome) => void
export type ClaudeReplayTurnOrigin = { requestedAt: number | null }
export type ClaudeReplayTurnOrigin = {
requestedAt: number | null
/** The submission this replay acknowledged, which opens the turn; null for a provider-control turn. */
clientMessageId: string | null
}
export function resolveClaudeReplayTurn(
session: ClaudeSession,
@@ -89,7 +93,9 @@ export function resolveClaudeReplayTurn(
if (exact) {
const foldReceipt = isUserReplay && claudeReplayIsFoldReceipt(session, exact, uuid)
settleWaiter(session, exact, uuid, onSettledLate)
return isUserReplay && !foldReceipt ? { requestedAt: exact.requestedAt } : null
return isUserReplay && !foldReceipt
? { requestedAt: exact.requestedAt, clientMessageId: exact.clientMessageId }
: null
}
const retired = session.retiredDispatchWaiters.find(
(candidate) => candidate.sentUuid === userMessageUuid
@@ -111,7 +117,9 @@ export function resolveClaudeReplayTurn(
if (exact) {
const foldReceipt = isUserReplay && claudeReplayIsFoldReceipt(session, exact, uuid)
settleWaiter(session, exact, uuid, onSettledLate)
return isUserReplay && !foldReceipt ? { requestedAt: exact.requestedAt } : null
return isUserReplay && !foldReceipt
? { requestedAt: exact.requestedAt, clientMessageId: exact.clientMessageId }
: null
}
const retired = session.retiredDispatchWaiters.find((candidate) => candidate.sentUuid === uuid)
if (retired) {
@@ -133,7 +141,7 @@ export function resolveClaudeReplayTurn(
if (compatible.length === 1) {
const [candidate] = compatible
settleWaiter(session, candidate!, uuid, onSettledLate)
return { requestedAt: candidate!.requestedAt }
return { requestedAt: candidate!.requestedAt, clientMessageId: candidate!.clientMessageId }
}
} else if (!session.replayContentFallbackBlocked && session.dispatchWaiters.length === 0) {
const lateCompatible = session.retiredDispatchWaiters.filter(
@@ -164,7 +172,9 @@ export function resolveClaudeReplayTurn(
const waiter = uuid ? session.dispatchWaiters.shift() : undefined
if (waiter && uuid) {
settleWaiter(session, waiter, uuid, onSettledLate)
return isUserReplay ? { requestedAt: waiter.requestedAt } : null
return isUserReplay
? { requestedAt: waiter.requestedAt, clientMessageId: waiter.clientMessageId }
: null
}
return null
}
+8 -4
View File
@@ -56,15 +56,19 @@ 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
// Read before the settle below closes the turn: an error end the journal's Stop rule makes a
// person's cancellation is theirs to decide as the end is written (`turnEndAfterStop`).
const turnId = settlesTurn ? turn.id : null
const leftToStop =
turnId !== null &&
sink.journalStopDecidesTurn?.(turnId, observedAt, turn.openedBy ?? undefined) === true
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, stop))
context.settle(message, commandEnd ?? claudeTurnEndForResult(message, observedAt, leftToStop))
// 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.
@@ -72,7 +76,7 @@ export function journalClaudeResult(
streamedText.settle()
}
const kind = claudeProviderFrameKind(message)
const failure = claudeResultFailure(message, stop)
const failure = claudeResultFailure(message, leftToStop)
if (failure || !isSettledClaudeResultKind(kind)) {
providerFallback.append(
kind,
+8 -9
View File
@@ -6,8 +6,6 @@
// 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
@@ -15,18 +13,19 @@ 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. `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. */
* is what decides, never the subtype. `leftToStop`: the journal's Stop rule makes this turn's end
* a person's cancellation (`personStopDecidesTurn`), so an error end with no abort reason gives no
* verdict (undefined): older CLIs name no reason, and that rule writes it with the end. */
export function claudeResultOutcome(
message: Record<string, unknown>,
stop: StructuredAgentSessionStopCause | null = null
): AgentJournalTurnOutcome {
leftToStop = false
): AgentJournalTurnOutcome | undefined {
if (message.is_error !== true) {
return 'success'
}
if (stop !== null && stopIsTheUsers(stop)) {
const reason = claudeText(message.terminal_reason)
if (reason !== null && CLAUDE_ABORTED_TERMINAL_REASONS.has(reason)) {
return 'cancellation'
}
const reason = claudeText(message.terminal_reason)
return reason !== null && CLAUDE_ABORTED_TERMINAL_REASONS.has(reason) ? 'cancellation' : 'failure'
return leftToStop ? undefined : 'failure'
}
@@ -112,7 +112,7 @@ describe('Claude child work from captured frame orders', () => {
const connection = run.claude.connections[0]!
connection.exitVerdict = { root: 'exited', tree: 'live' }
connection.close = async () => false
await expect(run.adapter.closeSession('session-1', 'user-stop')).rejects.toMatchObject({
await expect(run.adapter.closeSession('session-1')).rejects.toMatchObject({
name: 'AgentSessionAcquisitionRootExitObservedError'
})
expect(run.records().map(({ membership, outcome }) => ({ membership, outcome }))).toEqual([
@@ -243,7 +243,6 @@ describe('answerClaudePrompt', () => {
cancel: () => ({ accepted: true })
},
currentTurnId: null,
recordTurnStop: () => true,
commandTurnId: null,
beginCommand: vi.fn(),
forgetCommand: vi.fn(),
@@ -5,7 +5,6 @@ import { ClaudeControlRequestTimeoutError } from './claude-agent-sdk-control-req
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'
@@ -27,8 +26,7 @@ export async function cancelClaudeTurn(
session: ClaudeSession,
timeoutMs: number | undefined,
isCurrent: ClaudeTurnCancellationGuard = () => true,
onDispatchSettledLate?: ClaudeLateDispatchSettlement,
stopped?: { turnId: string; cause: StructuredAgentSessionStopCause }
onDispatchSettledLate?: ClaudeLateDispatchSettlement
): 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.
@@ -36,10 +34,6 @@ 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 } : {}),
@@ -59,7 +53,7 @@ export async function cancelClaudeTurn(
return { cancelled: true }
} catch (error) {
// The CLI refused. Any other error leaves the interrupt's effect unknown. Either way the Stop
// ends the child next, so the stop recorded on the turn stands.
// ends the child next, so its Stop event stands.
if (error instanceof ClaudeControlRequestError) {
return { cancelled: false }
}
@@ -16,7 +16,7 @@ function resolveClaudeReplayWaiter(...args: Parameters<typeof resolveClaudeRepla
}
describe('Claude structured dispatch admission', () => {
it('opens queued exact replays with the origin owned by each send', async () => {
it('opens queued exact replays with the origin and the send owned by each send', async () => {
const session = sessionFor()
await dispatchClaudeTurn(session, {
clientMessageId: 'client-a',
@@ -25,7 +25,8 @@ describe('Claude structured dispatch admission', () => {
})
const aUuid = session.dispatchWaiters[0]!.sentUuid
expect(resolveClaudeReplayTurn(session, userReplayFrame(aUuid, 'a'))).toEqual({
requestedAt: 100
requestedAt: 100,
clientMessageId: 'client-a'
})
await dispatchClaudeTurn(session, {
@@ -41,10 +42,12 @@ describe('Claude structured dispatch admission', () => {
const [b, c] = session.dispatchWaiters
expect(resolveClaudeReplayTurn(session, userReplayFrame(b!.sentUuid, 'b'))).toEqual({
requestedAt: 200
requestedAt: 200,
clientMessageId: 'client-b'
})
expect(resolveClaudeReplayTurn(session, userReplayFrame(c!.sentUuid, 'c'))).toEqual({
requestedAt: 300
requestedAt: 300,
clientMessageId: 'client-c'
})
})
@@ -1,11 +1,15 @@
// A user's Stop inside a live Claude chat interrupts the turn before the host ends its child. The
// turn's end then comes from the CLI's result frame, which CLIs before 2.1.91 send with no
// terminal_reason.
// terminal_reason. The translator then writes an interrupted end with no verdict and no error row,
// and the host's Stop event, written before the interrupt, decides whose end it was as the journal
// writes it (`turnEndAfterStop`).
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 { personStopDecidesTurn } from '../native-chat/agent-session-journal/journal-stop-turn-end'
import { createJournalReducerState } from '../native-chat/agent-session-journal/journal-reducer'
import { ClaudeControlRequestError } from './claude-stream-json-connection'
import {
PROVIDER_SESSION_ID,
@@ -29,8 +33,11 @@ async function runningChat(claude: ReturnType<typeof fakeClaude>): Promise<{
bodies: Map<string, AgentJournalItemBody>
connection: FakeConnection
turnId: string
/** What the host's Stop writes before the interrupt. */
stopEvent: (turnId: string) => void
}> {
const bodies = new Map<string, AgentJournalItemBody>()
const journal = createJournalReducerState('session-1', 'epoch-1')
const adapter = adapterFor(claude)
await adapter.acquire({
identity: identityFor(),
@@ -39,7 +46,8 @@ async function runningChat(claude: ReturnType<typeof fakeClaude>): Promise<{
events: {
appendItem: (identity, body) => bodies.set(agentJournalItemKey(identity), body),
appendTombstone: (identity) => bodies.delete(agentJournalItemKey(identity)),
publish: vi.fn()
publish: vi.fn(),
journalStopDecidesTurn: (turnId, endedAt) => personStopDecidesTurn(journal, turnId, endedAt)
}
})
await adapter.dispatch({
@@ -57,7 +65,13 @@ async function runningChat(claude: ReturnType<typeof fakeClaude>): Promise<{
if (!turnId) {
throw new Error('expected a running turn')
}
return { adapter, bodies, connection, turnId }
const stopEvent = (stoppedTurnId: string) => {
journal.queuePauseMarks.latestStop = {
sequence: 9,
event: { reason: 'user-stop', turnId: stoppedTurnId, at: 1 }
}
}
return { adapter, bodies, connection, turnId, stopEvent }
}
function settled(bodies: Map<string, AgentJournalItemBody>, turnId: string) {
@@ -71,7 +85,7 @@ function providerRows(bodies: Map<string, AgentJournalItemBody>): string[] {
}
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 () => {
it('leaves the turn the interrupt cut to the Stop when the CLI names no reason', async () => {
const claude = fakeClaude({
routes: {
// The CLI aborts the turn, then acknowledges the interrupt.
@@ -81,21 +95,20 @@ describe("a user's Stop inside a live Claude chat", () => {
}
}
})
const { adapter, bodies, turnId } = await runningChat(claude)
const { adapter, bodies, turnId, stopEvent } = await runningChat(claude)
stopEvent(turnId)
await expect(adapter.cancelTurn({ sessionId: 'session-1', turnId, fence: 7 })).resolves.toEqual(
{ cancelled: true }
)
expect(settled(bodies, turnId)).toMatchObject({
state: 'interrupted',
outcome: 'cancellation'
})
expect(settled(bodies, turnId)).toMatchObject({ state: 'interrupted' })
expect(settled(bodies, turnId)).not.toHaveProperty('outcome')
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 () => {
it('leaves the open turn a Stop naming no turn cut to that Stop', async () => {
const claude = fakeClaude({
routes: {
interrupt: () => {
@@ -104,16 +117,16 @@ describe("a user's Stop inside a live Claude chat", () => {
}
}
})
const { adapter, bodies, turnId } = await runningChat(claude)
const { adapter, bodies, turnId, stopEvent } = await runningChat(claude)
// The host names the open turn on the Stop's event.
stopEvent(turnId)
await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({
cancelled: true
})
expect(settled(bodies, turnId)).toMatchObject({
state: 'interrupted',
outcome: 'cancellation'
})
expect(settled(bodies, turnId)).toMatchObject({ state: 'interrupted' })
expect(settled(bodies, turnId)).not.toHaveProperty('outcome')
expect(providerRows(bodies)).toEqual([])
})
@@ -128,6 +141,8 @@ describe("a user's Stop inside a live Claude chat", () => {
it('keeps a Stop naming no turn off the turn after it', async () => {
const claude = fakeClaude({
// Each turn is its own uuid, as Claude's are: the Stop names the first.
replayUuids: ['user-uuid-0', 'user-uuid-1'],
routes: {
interrupt: () => {
claude.connections[0]!.handlers.onMessage?.(CUT_SHORT)
@@ -135,7 +150,8 @@ describe("a user's Stop inside a live Claude chat", () => {
}
}
})
const { adapter, bodies, connection } = await runningChat(claude)
const { adapter, bodies, connection, turnId, stopEvent } = await runningChat(claude)
stopEvent(turnId)
await adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })
await adapter.dispatch({
@@ -156,12 +172,12 @@ describe("a user's Stop inside a live Claude chat", () => {
expect(settled(bodies, nextTurnId)).toMatchObject({ state: 'completed', outcome: 'failure' })
})
// The Stop ends the child next, so the turn it was asked for reads Interrupted however it ends.
// The Stop ends the child next, so the turn it was asked for is the Stop's however it ends.
it.each([
['naming the turn', true],
['naming no turn', false]
] as const)(
'reads a turn the CLI refused to interrupt as the user stopping it, %s',
'leaves a turn the CLI refused to interrupt to the Stop, %s',
async (_label, named) => {
const claude = fakeClaude({
routes: {
@@ -170,14 +186,16 @@ describe("a user's Stop inside a live Claude chat", () => {
}
}
})
const { adapter, bodies, connection, turnId } = await runningChat(claude)
const { adapter, bodies, connection, turnId, stopEvent } = await runningChat(claude)
stopEvent(turnId)
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({ outcome: 'cancellation' })
expect(settled(bodies, turnId)).toMatchObject({ state: 'interrupted' })
expect(settled(bodies, turnId)).not.toHaveProperty('outcome')
expect(providerRows(bodies)).toHaveLength(0)
}
)
@@ -1,7 +1,3 @@
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'
@@ -179,8 +175,10 @@ export function createClaudeJournalTranslator(
message: Record<string, unknown>,
startsTurn: boolean,
observedAt: number,
requestedAt?: number
): boolean => journalClaudeMessage(messageContext, message, startsTurn, observedAt, requestedAt)
requestedAt?: number,
openedBy?: string
): boolean =>
journalClaudeMessage(messageContext, message, startsTurn, observedAt, requestedAt, openedBy)
return {
handle: (event) => {
@@ -189,11 +187,9 @@ export function createClaudeJournalTranslator(
streamedText.flush()
subagents.settleSession()
backgroundTasks.settleSession()
// 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())
)
// The host saw the child end, so the turn's end is observed, not lost. Whether it was a
// person's Stop is the journal's Stop event to say (`turnEndAfterStop`), else it is news.
turn.settle({ state: 'interrupted', completedAt: event.observedAt ?? Date.now() })
// A frame that arrives after the child is gone must not open a turn no
// event can close.
turn.suppressReopen()
@@ -250,7 +246,8 @@ export function createClaudeJournalTranslator(
event.message,
event.startsTurn === true,
event.observedAt ?? Date.now(),
event.requestedAt
event.requestedAt,
event.clientMessageId
)
) {
providerFallback.append(
@@ -282,7 +279,6 @@ export function createClaudeJournalTranslator(
get currentTurnId() {
return turn.id
},
recordTurnStop: (turnId, cause) => turn.recordStop(turnId, cause),
get commandTurnId() {
return turn.command ? turn.id : null
},
@@ -48,16 +48,7 @@ function cancelClaudeConversation(
session.fence === request.fence &&
session.acquisitionGeneration === acquisitionGeneration &&
(claudeLiveTurnId(session, request) !== null || session.dispatchWaiters.length > 0)
// 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' }
)
return cancelClaudeTurn(session, timeoutMs, isCurrent, onDispatchSettledLate)
}
/** A Stop's interrupt. A card's own Cancel never comes here: `claudePromptCancelRoute` routes it. */
@@ -117,7 +108,6 @@ export async function cancelClaudeStructuredTurn(input: {
)
const compactionOwnsTurn = (): boolean =>
session.translator !== null && session.translator.commandTurnId === requestedTurnId
// A turn is cancelled only at a client's request, so the stop is the user's.
return cancelClaudeTurn(
session,
timeoutMs,
@@ -133,8 +123,7 @@ export async function cancelClaudeStructuredTurn(input: {
}
return current
},
input.onDispatchSettledLate,
{ turnId: requestedTurnId, cause: 'user-stop' }
input.onDispatchSettledLate
)
}
@@ -16,7 +16,6 @@ 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,
@@ -53,11 +52,11 @@ export function isSettledClaudeResultKind(kind: string): boolean {
*/
export function claudeResultFailure(
message: Record<string, unknown>,
stop: StructuredAgentSessionStopCause | null = null
leftToStop = false
): { 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, stop) !== 'failure') {
if (claudeResultOutcome(message, leftToStop) !== 'failure') {
return null
}
const result = claudeText(message.result)?.trim()
@@ -145,6 +145,7 @@ export async function acquireClaudeSession({
message,
...(startsTurn ? { startsTurn: true } : {}),
...(requestedAt === null || requestedAt === undefined ? {} : { requestedAt }),
...(turnOrigin?.clientMessageId ? { clientMessageId: turnOrigin.clientMessageId } : {}),
...observedAt
})
)
@@ -3,8 +3,7 @@ import { dispatchClaudeCommand } from './claude-structured-command-dispatch'
import type {
AgentSessionAcquisition,
StructuredAgentSessionAcquireInput,
StructuredAgentSessionAdapter,
StructuredAgentSessionStopCause
StructuredAgentSessionAdapter
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { stopClaudeBackgroundTasks } from './claude-structured-control-actions'
import { dispatchClaudeTurn } from './claude-structured-dispatch'
@@ -287,30 +286,21 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {})
})
closeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise<boolean> =>
closeSession = (sessionId: string): Promise<boolean> =>
// After the close, not before: releasing an exit still settling settles it on the way.
this.closeSessionProcess(sessionId, cause).finally(() =>
this.settledExitErrors.delete(sessionId)
)
this.closeSessionProcess(sessionId).finally(() => this.settledExitErrors.delete(sessionId))
private closeSessionProcess(
sessionId: string,
cause: StructuredAgentSessionStopCause | undefined
): Promise<boolean> {
private closeSessionProcess(sessionId: string): 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, cause))
return this.afterClose(sessionId, () => this.closeProviderSession(sessionId))
}
private closeProviderSession = (
sessionId: string,
stopCause?: StructuredAgentSessionStopCause
): Promise<boolean> =>
private closeProviderSession = (sessionId: string): Promise<boolean> =>
closeClaudeSession({
sessionId,
...(stopCause ? { stopCause } : {}),
sessions: this.sessions,
acquisitions: this.acquisitions,
...(this.deps.persistHandle ? { persistHandle: this.deps.persistHandle } : {}),
@@ -6,7 +6,6 @@ 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,
@@ -79,8 +78,6 @@ 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
@@ -140,7 +137,6 @@ 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
@@ -244,7 +240,6 @@ export function closeClaudePublishedSessionForDeps(
export async function closeClaudeSession(input: {
sessionId: string
stopCause?: StructuredAgentSessionStopCause
sessions: Map<string, ClaudeSession>
acquisitions: ClaudeAcquisitionRegistry
persistHandle?: (handle: {
@@ -5,10 +5,7 @@ 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,
StructuredAgentSessionStopCause
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { StructuredAgentSessionStartedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type {
ClaudeStreamJsonConnection,
openClaudeStreamJsonConnection
@@ -50,6 +47,8 @@ export type ClaudeStructuredSessionEvent =
/** Submission instant of the dispatch this replay acknowledged; the origin
* of the turn it opens. Absent when the host cannot name a send. */
requestedAt?: number
/** The submission this replay acknowledged, which opens the turn. */
clientMessageId?: string
/** Host clock at receipt; stamped on turn boundaries only. */
observedAt?: number
}
@@ -74,8 +73,6 @@ 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,7 +5,6 @@ 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'
@@ -20,6 +19,8 @@ export type ClaudeCurrentTurn = {
/** Provider key of the user echo, or the lifecycle row itself when provider
* output opened a turn with no user row to receive its timing. */
userItemId: string
/** The submission whose send opened the turn, while its echo has yet to land in the journal. */
openedBy?: string
/** Present when the turn is the host's record of a conversation command. */
command?: ClaudeCommandTurn
}
@@ -46,14 +47,15 @@ export type ClaudeTurnEnd = {
export function claudeTurnEndForResult(
message: Record<string, unknown>,
completedAt: number,
stop: StructuredAgentSessionStopCause | null = null
leftToStop = false
): ClaudeTurnEnd {
const outcome = claudeResultOutcome(message, stop)
const outcome = claudeResultOutcome(message, leftToStop)
const durationMs = message.duration_ms
return {
state: outcome === 'cancellation' ? 'interrupted' : 'completed',
// No verdict: an interrupted end, which a person's Stop of it makes their cancellation.
state: outcome === undefined || outcome === 'cancellation' ? 'interrupted' : 'completed',
completedAt,
outcome,
...(outcome !== undefined ? { outcome } : {}),
...(typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0
? { durationMs }
: {})
+4 -1
View File
@@ -30,6 +30,8 @@ export type ClaudeSendEchoTurnInput = {
requestedAt?: number
/** Provider key of the user row this turn is anchored to. */
userItemId: string
/** The submission this echo acknowledged. */
openedBy?: string
}
/** The turn a replayed send echo opens, or null when this frame is not one. */
@@ -46,7 +48,8 @@ export function claudeTurnOpenedBySendEcho(
turnId: envelope.uuid,
startedAt: input.observedAt,
...(input.requestedAt === undefined ? {} : { requestedAt: input.requestedAt }),
userItemId: input.userItemId
userItemId: input.userItemId,
...(input.openedBy === undefined ? {} : { openedBy: input.openedBy })
}
: null
}
+42 -21
View File
@@ -12,6 +12,12 @@ import {
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 type { JournalStopEvent } from '../native-chat/agent-session-journal/journal-row-schema'
import {
personStopDecidesTurn,
type JournalLatestStop
} from '../native-chat/agent-session-journal/journal-stop-turn-end'
import { createJournalReducerState } from '../native-chat/agent-session-journal/journal-reducer'
import { claudeResultOutcome } from './claude-result-outcome'
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
import { claudeResultFailure } from './claude-structured-provider-fallback'
@@ -233,10 +239,32 @@ describe('a turn end the host inferred', () => {
})
})
describe("a user's Stop inside a live turn", () => {
describe("a person'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 }
/** A sink whose journal's latest Stop event is `stop`, answering by the journal's own rule. */
function sinkWithStop(stop: JournalLatestStop | null) {
const state = sinkState()
const journal = createJournalReducerState('orca-session', 'epoch-1')
journal.queuePauseMarks.latestStop = stop
return {
...state,
sink: {
...state.sink,
journalStopDecidesTurn: (turnId: string, endedAt: number) =>
personStopDecidesTurn(journal, turnId, endedAt)
}
}
}
function stopOf(
turnId: string,
reason: JournalStopEvent['reason'] = 'user-stop'
): JournalLatestStop {
return { sequence: 9, event: { reason, turnId, at: 1 } }
}
function settledTurn(items: ReturnType<typeof sinkState>['items'], turnId: string) {
return items
.map((item) => readAgentJournalTurn(item.body))
@@ -251,18 +279,17 @@ describe("a user's Stop inside a live turn", () => {
).length
}
it('reads an error result with no terminal reason as the cancellation it asked for', () => {
const state = sinkState()
// Whose end it was is the journal's Stop rule to say as it writes the end (`turnEndAfterStop`).
it("leaves an error result with no terminal reason after a person's Stop of the turn to that Stop", () => {
const state = sinkWithStop(stopOf('user-1'))
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'
})
const turn = settledTurn(state.items, 'user-1')
expect(turn).toMatchObject({ state: 'interrupted' })
expect(turn).not.toHaveProperty('outcome')
expect(providerRows(state.items)).toBe(0)
})
@@ -273,7 +300,7 @@ describe("a user's Stop inside a live turn", () => {
{ ...cutShort, terminal_reason: 'api_error', result: 'API Error' }
]
])('keeps a result with %s and no Stop a failure', (_label, frame) => {
const state = sinkState()
const state = sinkWithStop(null)
const translator = createClaudeJournalTranslator({ sink: state.sink })
translator.handle(userTurn('user-1'))
@@ -287,11 +314,10 @@ describe("a user's Stop inside a live turn", () => {
})
it('keeps a turn that finished during the Stop a success', () => {
const state = sinkState()
const state = sinkWithStop(stopOf('user-1'))
const translator = createClaudeJournalTranslator({ sink: state.sink })
translator.handle(userTurn('user-1'))
translator.recordTurnStop('user-1', 'user-stop')
translator.handle({
type: 'message',
sessionId: 'orca-session',
@@ -302,15 +328,12 @@ describe("a user's Stop inside a live turn", () => {
})
it('does not carry a Stop onto the next turn', () => {
const state = sinkState()
// The Stop named user-1, which a newer send superseded before its result.
const state = sinkWithStop(stopOf('user-1'))
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 })
@@ -319,10 +342,9 @@ describe("a user's Stop inside a live turn", () => {
})
it('ends the Stop with its turn, so a result after the turn settled reads on its own', () => {
const state = sinkState()
const state = sinkWithStop(stopOf('user-1'))
const translator = createClaudeJournalTranslator({ sink: state.sink })
translator.handle(userTurn('user-1'))
translator.recordTurnStop('user-1', 'user-stop')
translator.handle({
type: 'message',
sessionId: 'orca-session',
@@ -334,12 +356,11 @@ describe("a user's Stop inside a live turn", () => {
expect(providerRows(state.items)).toBe(1)
})
it('does not read a host stop as the user asking', () => {
const state = sinkState()
it.each(['host-stop', 'evict'] as const)('does not read a %s as the person asking', (reason) => {
const state = sinkWithStop(stopOf('user-1', reason))
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' })
@@ -99,7 +99,6 @@ function sessionHoldingTurn(turnId: string | null): ReturnType<typeof sessionFor
cancel: () => ({ accepted: true })
},
currentTurnId: turnId,
recordTurnStop: () => true,
commandTurnId: null,
beginCommand: vi.fn(),
forgetCommand: vi.fn(),
@@ -1,10 +1,9 @@
// 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.
// Closing a Codex child settles its open turn in the adapter as interrupted, with no verdict: the
// close names no cause. Whether the end was a person's is the journal's Stop event to say.
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'
@@ -75,48 +74,26 @@ function sessionWithRunningTurn() {
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()
describe('a Codex close settles the open turn with no verdict of its own', () => {
// Whose end it was is the journal's Stop event to say (`turnEndAfterStop`), never the close's.
it('ends it interrupted at the exit, with no outcome', async () => {
const { sessions, turnBodies } = sessionWithRunningTurn()
const onEvent = vi.fn()
await expect(
closeCodexPublishedSession(sessions, 'session-1', onEvent, { stopCause })
).resolves.toBe(true)
await expect(closeCodexPublishedSession(sessions, 'session-1', onEvent)).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 })
)
}
)
expect(turnBodies).toEqual([
expect.objectContaining({ turnId: 'turn-1', state: 'interrupted' })
])
expect(turnBodies[0]).not.toHaveProperty('outcome')
expect(onEvent).toHaveBeenCalledWith(
expect.objectContaining({ type: 'ended', cause: 'requested-close' })
)
})
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 () => {
it('settles a crash it saw before the close once, as that exit', async () => {
const { sessions, session, turnBodies } = sessionWithRunningTurn()
// The child died on its own first; the user's close then finds it already ended.
// The child died on its own first; the close then finds it already ended.
handleCodexSessionExit({
sessions,
sessionId: 'session-1',
@@ -124,7 +101,7 @@ describe('a Codex close settles the open turn with the host-named cause', () =>
error: new Error('app-server exited')
})
await closeCodexPublishedSession(sessions, 'session-1', undefined, { stopCause: 'user-close' })
await closeCodexPublishedSession(sessions, 'session-1')
expect(turnBodies).toEqual([
expect.objectContaining({ turnId: 'turn-1', state: 'interrupted' })
@@ -132,18 +109,7 @@ describe('a Codex close settles the open turn with the host-named cause', () =>
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 () => {
it("settles the same through the adapter's close", async () => {
const { sessions, turnBodies } = sessionWithRunningTurn()
const teardown = new CodexStructuredSessionTeardown({
sessions,
@@ -151,8 +117,11 @@ describe('a Codex close settles the open turn with the host-named cause', () =>
forgetNotificationRetries: () => {}
})
await expect(teardown.close('session-1', 'user-close')).resolves.toBe(true)
await expect(teardown.close('session-1')).resolves.toBe(true)
expect(turnBodies[0]).toMatchObject({ state: 'interrupted', outcome: 'cancellation' })
expect(turnBodies).toEqual([
expect.objectContaining({ turnId: 'turn-1', state: 'interrupted' })
])
expect(turnBodies[0]).not.toHaveProperty('outcome')
})
})
@@ -134,7 +134,7 @@ describe('a Codex reconnecting a dropped stream', () => {
// 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, 'evict')
expect(closeSession).toHaveBeenCalledWith(SESSION)
expect(host.hasSession(SESSION)).toBe(false)
})
})
@@ -1,7 +1,3 @@
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 {
@@ -162,18 +158,14 @@ 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: the verdict is only what
// the host's own cause says, a user's stop of this chat or else news.
// The host saw the child go, not what Codex made of the turn: whether it was a person's
// Stop is the journal's Stop event to say (`turnEndAfterStop`), else it is news.
settledTurnLifecycle: (threadId, turnId) =>
turnBoundaries.ownsRecord(threadId, turnId)
? turnBoundaries.settled(
threadId,
turnId,
turnVerdictForChildEnd(
childEndCauseOfEndedEvent(event),
event.observedAt ?? deps.now?.() ?? Date.now()
)
)
? turnBoundaries.settled(threadId, turnId, {
state: 'interrupted',
completedAt: event.observedAt ?? deps.now?.() ?? Date.now()
})
: null,
attributionFor
})
@@ -10,8 +10,7 @@ import type {
AgentSessionDispatchOutcome,
StructuredAgentSessionAcquireInput,
StructuredAgentSessionAdapter,
StructuredAgentSessionSetOptionInput,
StructuredAgentSessionStopCause
StructuredAgentSessionSetOptionInput
} 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'
@@ -289,11 +288,9 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
identity: AgentSessionJournalIdentity
}): Promise<string | null> => this.sessions.get(input.identity.sessionId)?.historyPath ?? null
closeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise<boolean> =>
this.teardown.close(sessionId, cause)
closeSession = (sessionId: string): Promise<boolean> => this.teardown.close(sessionId)
forceCloseSession = (sessionId: string): Promise<boolean> => this.teardown.forceClose(sessionId)
disposeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise<boolean> =>
this.teardown.close(sessionId, cause)
disposeSession = (sessionId: string): Promise<boolean> => this.teardown.close(sessionId)
closeAll = (): Promise<void> => this.teardown.closeAll()
releaseAcquisition = (input: { sessionId: string }): Promise<boolean> =>
this.teardown.close(input.sessionId)
@@ -7,10 +7,7 @@ import {
type CodexSession,
type CodexStructuredSessionEvent
} from './codex-structured-session-state'
import type {
StructuredAgentSessionEndedEvent,
StructuredAgentSessionStopCause
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
export function handleCodexSessionExit(input: {
sessions: Map<string, CodexSession>
@@ -41,9 +38,6 @@ 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
@@ -77,8 +71,6 @@ 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
@@ -98,7 +90,6 @@ 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()
@@ -130,8 +121,7 @@ export async function closeCodexSession(
sessionId: string,
sessions: Map<string, CodexSession>,
acquisitions: CodexAcquisitionRegistry,
onEvent?: (event: CodexStructuredSessionEvent) => void,
stopCause?: StructuredAgentSessionStopCause
onEvent?: (event: CodexStructuredSessionEvent) => void
): Promise<boolean> {
const attempt = acquisitions.get(sessionId)
if (!(await cancelCodexAcquisitionAttempt(attempt))) {
@@ -140,7 +130,7 @@ export async function closeCodexSession(
if (attempt) {
acquisitions.deleteIfCurrent(sessionId, attempt)
}
return closeCodexPublishedSession(sessions, sessionId, onEvent, stopCause ? { stopCause } : {})
return closeCodexPublishedSession(sessions, sessionId, onEvent)
}
export async function closeAllCodexSessions(
@@ -18,10 +18,7 @@ import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo'
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,
StructuredAgentSessionStopCause
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy'
import type {
AgentModelCatalogSessionAccess,
@@ -109,8 +106,6 @@ 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
@@ -4,7 +4,6 @@
// session owns are cleared exactly once, and only when the child was actually
// proven stopped — a refused close leaves the session indexed for a retry.
import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import {
closeAllCodexSessions,
closeCodexPublishedSession,
@@ -26,13 +25,12 @@ export type CodexStructuredSessionTeardownDeps = {
export class CodexStructuredSessionTeardown {
constructor(private readonly deps: CodexStructuredSessionTeardownDeps) {}
close = async (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise<boolean> => {
close = async (sessionId: string): Promise<boolean> => {
const closed = await closeCodexSession(
sessionId,
this.deps.sessions,
this.deps.acquisitions,
this.deps.onEvent,
cause
this.deps.onEvent
)
return this.settled(sessionId, closed)
}
@@ -71,9 +71,22 @@ function serializedLifecycleBatchFits(
return Buffer.byteLength(JSON.stringify(row), 'utf8') + 1 <= MAX_JOURNAL_LIFECYCLE_BATCH_BYTES
}
/** Sized as a Stop may write it (`turnEndAfterStop`), so the chunk built from it still fits. */
function sizedAsStopped(mutation: JournalLifecycleMutationInput): JournalLifecycleMutationInput {
if (
mutation.kind !== 'item' ||
mutation.body.kind !== 'turn' ||
mutation.body.state !== 'interrupted' ||
mutation.body.outcome !== undefined
) {
return mutation
}
return { ...mutation, body: { ...mutation.body, outcome: 'cancellation' } }
}
function toLifecycleMutationRow(mutation: JournalLifecycleMutationInput): JournalLifecycleMutation {
return journalLifecycleMutationRow(
mutation,
sizedAsStopped(mutation),
agentJournalItemKey(mutation.identity),
Number.MAX_SAFE_INTEGER
)
@@ -28,6 +28,7 @@ import {
} from './journal-row-schema'
import { boundInlineText, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds'
import { assertSubmissionIdUnused } from './journal-write-guards'
import { turnEndAfterStop } from './journal-stop-turn-end'
import type { ResolveDispatchInput } from './journal-store-contracts'
type RowBuilder<T> = (seq: number, ts: number) => T
@@ -200,7 +201,13 @@ export function journalLifecycleBatchRowBuilder(
current.tombstones.get(resolved) ?? 0
)) + 1
revisions.set(resolved, revision)
return journalLifecycleMutationRow(mutation, itemId, revision)
return journalLifecycleMutationRow(
mutation.kind === 'item'
? { ...mutation, body: turnEndAfterStop(current, resolved, mutation.body) }
: mutation,
itemId,
revision
)
})
const row: JournalLifecycleBatchRow = {
kind: 'lifecycle-batch',
@@ -252,12 +259,13 @@ export function buildJournalItemRow(input: {
input.state.items.get(resolved)?.revision ?? 0,
input.state.tombstones.get(resolved) ?? 0
) + 1
const body = turnEndAfterStop(input.state, resolved, input.body)
return {
kind: 'item',
itemId,
revision,
body: input.body,
...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts, [input.body]),
body,
...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts, [body]),
...(input.recovered ? { recovered: input.recovered } : {}),
turnScope: input.turnScope,
...agentJournalLinkageFields(input.linkage)
@@ -0,0 +1,27 @@
// What the journal answers about its Stops beyond the queue's pause: the latest Stop event, which
// the turn-end rule reads (`journal-stop-turn-end.ts`), and whether a person's still decides.
import type { JournalReducerState } from './journal-reducer'
import {
latestAcceptedSendUnopened,
personStopDecidesTurn,
type JournalLatestStop
} from './journal-stop-turn-end'
export class JournalStopMarks {
constructor(private readonly deps: { state: () => JournalReducerState }) {}
latest(): JournalLatestStop | null {
return this.deps.state().queuePauseMarks.latestStop
}
/** `latestAcceptedSendUnopened`: the latest accepted send's turn row may still be on its way. */
latestAcceptedSendUnopened(): boolean {
return latestAcceptedSendUnopened(this.deps.state())
}
/** `personStopDecidesTurn`: a person's Stop decides how turn `turnId` ends. */
personStopDecides(turnId: string | null, endedAt?: number, openedBy?: string): boolean {
return personStopDecidesTurn(this.deps.state(), turnId, endedAt, openedBy)
}
}
@@ -0,0 +1,200 @@
// What a Stop decides about the turn it named: the one rule every turn-end write passes through.
//
// A turn a person's Stop or close of this chat named, or, when it named none, a turn opened by a
// send it stopped, ending with no verdict of its own after that Stop's event, ends as their
// cancellation. A host stop, an eviction and no Stop at all leave the end as written. It runs
// where each row is built, inside the journal's serialized write, so it reads every Stop folded
// before the end: the adapter's settle, the host's fallback and a relaunch's settle all write
// through it, and every client folds the row it wrote.
import {
agentJournalSubmissionKey,
parseAgentJournalItemKey
} from '../../../shared/agent-session-journal-item-key'
import type {
AgentJournalItemBody,
AgentJournalSubmission
} from '../../../shared/agent-session-journal-types'
import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record'
import { isUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-unanswered-dispatch'
import type { JournalReducerState } from './journal-reducer'
import type { JournalStopEvent } from './journal-row-schema'
import type { JournalQueuePauseMarks } from './queued-message-pause'
export type JournalLatestStop = NonNullable<JournalQueuePauseMarks['latestStop']>
/** Only a person's own Stop, or their close of this chat, makes a cut turn their cancellation. */
function stopIsAPersons(reason: JournalStopEvent['reason']): boolean {
switch (reason) {
case 'user-stop':
case 'user-close':
return true
case 'host-stop':
case 'evict':
return false
}
}
type TurnEndState = Pick<
JournalReducerState,
'items' | 'queuePauseMarks' | 'submissions' | 'aliases'
>
/** The send whose journal item `userItemId` (a turn's opener, by its own or its provider key) is. */
function openingSubmission(
state: TurnEndState,
userItemId: string | undefined
): AgentJournalSubmission | undefined {
if (userItemId === undefined) {
return undefined
}
const identity = parseAgentJournalItemKey(state.aliases.get(userItemId) ?? userItemId)
return identity?.provider === 'orca' && 'clientMessageId' in identity
? state.submissions.get(identity.clientMessageId)
: undefined
}
/** Whether the latest send the agent accepted to open a turn has opened none the journal holds: its
* turn's row may still be on its way. A send delivered into a running turn (a steer, a fold)
* opens none, and its item carries that turn's scope (`placeHandedOverMessage`). */
export function latestAcceptedSendUnopened(state: TurnEndState): boolean {
let latest: { submission: AgentJournalSubmission; sequence: number } | undefined
for (const submission of state.submissions.values()) {
const item = state.items.get(agentJournalSubmissionKey(submission.clientMessageId))
if (
submission.dispatchState === 'accepted' &&
item !== undefined &&
item.turnScope?.kind !== 'turn' &&
item.sequence >= (latest?.sequence ?? -1)
) {
latest = { submission, sequence: item.sequence }
}
}
if (!latest) {
return false
}
for (const item of state.items.values()) {
if (
openingSubmission(state, readAgentJournalTurn(item.body)?.userItemId) === latest.submission
) {
return false
}
}
return true
}
/** A send a Stop that named no turn stopped: one already handed to the agent at the Stop's
* position, whose turn had not opened. A card the Stop held, or anything sent after it, is not. */
function isStopTarget(
state: TurnEndState,
stop: JournalLatestStop,
submission: AgentJournalSubmission
): boolean {
if (
submission.dispatchState === 'rejected' ||
(submission.handoverRecorded === true && submission.handedOverAt === undefined)
) {
return false
}
// A handed-over send's item sits at its handover (`placeHandedOverMessage`).
const handedOver = state.items.get(agentJournalSubmissionKey(submission.clientMessageId))
return handedOver !== undefined && handedOver.sequence < stop.sequence
}
/** Whether `stop`, a person's, makes the end of turn `turnId` theirs: it named that turn, or named
* none and stopped the send that opened it (`userItemId`). */
function stopIsTurnCancellation(
state: TurnEndState,
stop: JournalLatestStop,
turnId: string,
userItemId: string | undefined
): boolean {
if (!stopIsAPersons(stop.event.reason)) {
return false
}
if (stop.event.turnId !== undefined) {
return stop.event.turnId === turnId
}
const opener = openingSubmission(state, userItemId)
return opener !== undefined && isStopTarget(state, stop, opener)
}
/** THE rule: whether the latest Stop makes turn `turnId`, opened by `userItemId` and ending at
* `endedAt` with no verdict of its own, a person's cancellation. An exit the provider saw before
* the Stop was news, whenever its end is written. */
function stopEndsTurnAsCancellation(
state: TurnEndState,
turnId: string,
userItemId: string | undefined,
endedAt: number | undefined
): boolean {
const stop = state.queuePauseMarks.latestStop
return (
stop !== null &&
stopIsTurnCancellation(state, stop, turnId, userItemId) &&
(endedAt === undefined || endedAt >= stop.event.at)
)
}
/** With no turn running, the work in flight is the person's Stop's: every send still unanswered is
* one it stopped. */
function unansweredSendsAreStopTargets(state: TurnEndState, stop: JournalLatestStop): boolean {
const unanswered = [...state.submissions.values()].filter((submission) =>
isUnansweredStructuredAgentSessionDispatch(submission)
)
return (
unanswered.length > 0 && unanswered.every((submission) => isStopTarget(state, stop, submission))
)
}
/**
* Whether a person's Stop decides the end of turn `turnId` (null: the sends in flight with no turn
* running), by `turnEndAfterStop`'s rule: ending at `endedAt` it is their cancellation, and still
* running it is theirs to end. For a writer that must choose before the end is written: a host
* stop must not supersede it, and a Claude error result naming no reason leaves its verdict to it.
*/
export function personStopDecidesTurn(
state: TurnEndState,
turnId: string | null,
endedAt?: number,
/** The submission that opened the turn, for one whose rows have yet to land. */
openedBy?: string
): boolean {
const stop = state.queuePauseMarks.latestStop
if (stop === null || !stopIsAPersons(stop.event.reason)) {
return false
}
if (turnId === null) {
return stop.event.turnId === undefined && unansweredSendsAreStopTargets(state, stop)
}
const turn = [...state.items.values()]
.map((item) => readAgentJournalTurn(item.body))
.find((candidate) => candidate?.turnId === turnId)
const userItemId =
turn?.userItemId ?? (openedBy === undefined ? undefined : agentJournalSubmissionKey(openedBy))
return stopEndsTurnAsCancellation(state, turnId, userItemId, endedAt)
}
/**
* The body to write for item `itemId`: unchanged unless it ends, with no verdict of its own and no
* earlier than the latest Stop event, a person's, which named it, or stopped the send that opened
* it, while it was still open (running, or unproven). A provider's own verdict always stands.
*/
export function turnEndAfterStop(
state: TurnEndState,
itemId: string,
body: AgentJournalItemBody
): AgentJournalItemBody {
if (body.kind !== 'turn' || body.state !== 'interrupted' || body.outcome !== undefined) {
return body
}
const previous = readAgentJournalTurn(state.items.get(itemId)?.body)
// An end already written stands: the Stop came after it.
if (previous && previous.state !== 'running' && previous.state !== 'unverifiable') {
return body
}
const userItemId = body.userItemId ?? previous?.userItemId
return stopEndsTurnAsCancellation(state, body.turnId, userItemId, body.completedAt)
? { ...body, outcome: 'cancellation' }
: body
}
@@ -13,6 +13,7 @@ import { JournalItemAppender } from './journal-item-appender'
import { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender'
import type { JournalLoad } from './journal-open'
import { JournalQueuedMessages } from './journal-queued-messages'
import { JournalStopMarks } from './journal-stop-marks'
import { journalQueuePauseRestatement } from './queued-message-pause'
import type { JournalReducerState } from './journal-reducer'
import { JournalRowWriter } from './journal-row-writer'
@@ -56,6 +57,7 @@ export type JournalStoreCollaborators = {
itemAppender: JournalItemAppender
lifecycleBatchAppender: JournalLifecycleBatchAppender
queuedMessages: JournalQueuedMessages
stopMarks: JournalStopMarks
/** Restores the store's state from disk. Owned here because it needs the same
* collaborators the constructor just built. */
restore: () => Promise<void>
@@ -92,6 +94,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal
return {
epochController,
queuedMessages,
stopMarks: new JournalStopMarks({ state: host.state }),
// Behind the stored fact: settles drafts whose consumed submission the loaded journal shows
// refused (a downgrade wrote no hook), then prunes. Bookkeeping, never failing the open.
restore: () =>
@@ -70,6 +70,7 @@ import { createJournalStoreCollaborators } from './journal-store-collaborators'
import { journalStoreLoadedFields } from './journal-store-open'
import type { JournalItemAppender } from './journal-item-appender'
import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender'
import type { JournalStopMarks } from './journal-stop-marks'
export { AgentSessionJournalError } from './journal-write-guards'
@@ -93,6 +94,7 @@ export class AgentSessionJournal {
private readonly restore: () => Promise<void>
/** Draft rows queued while the agent works; never reducer input or owed work. */
readonly queuedMessages: JournalQueuedMessages
readonly stopMarks: JournalStopMarks
constructor(options: AgentSessionJournalOptions) {
this.identity = options.identity
@@ -141,6 +143,7 @@ export class AgentSessionJournal {
this.itemAppender = collaborators.itemAppender
this.lifecycleBatchAppender = collaborators.lifecycleBatchAppender
this.queuedMessages = collaborators.queuedMessages
this.stopMarks = collaborators.stopMarks
this.restore = collaborators.restore
}
@@ -165,7 +165,7 @@ describe('StructuredAgentSessionAdapterRouter optional lifecycle methods', () =>
await expect(stopSession('session-1')).resolves.toBe(true)
// A host with no cause to name passes none; the adapter settles its turn as news.
expect(closeSession).toHaveBeenCalledWith('session-1', undefined)
expect(closeSession).toHaveBeenCalledWith('session-1')
}
)
})
@@ -5,10 +5,7 @@ import type {
AgentSessionAccountHome,
AgentSessionExecutionLocation
} from '../../../shared/agent-session-record'
import type {
StructuredAgentSessionAdapter,
StructuredAgentSessionStopCause
} from './structured-agent-session-adapter'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
type RoutedAgent = 'claude' | 'codex'
type SessionRoute = { adapter: StructuredAgentSessionAdapter; state: 'live' | 'stopped' }
@@ -174,21 +171,20 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
accountHome: AgentSessionAccountHome
}) => this.requireAgent(input.identity).providerHistoryWindow?.(input) ?? Promise.resolve(null)
closeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise<boolean> =>
this.stopSession(sessionId, (adapter) => adapter.closeSession, cause)
closeSession = (sessionId: string): Promise<boolean> =>
this.stopSession(sessionId, (adapter) => adapter.closeSession)
forceCloseSession = (sessionId: string): Promise<boolean> =>
this.stopSession(sessionId, (adapter) => adapter.forceCloseSession ?? adapter.closeSession)
disposeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise<boolean> =>
this.stopSession(sessionId, (adapter) => adapter.disposeSession ?? adapter.closeSession, cause)
disposeSession = (sessionId: string): Promise<boolean> =>
this.stopSession(sessionId, (adapter) => adapter.disposeSession ?? adapter.closeSession)
private async stopSession(
sessionId: string,
selectStop: (
adapter: StructuredAgentSessionAdapter
) => NonNullable<StructuredAgentSessionAdapter['closeSession']> | undefined,
cause?: StructuredAgentSessionStopCause
) => NonNullable<StructuredAgentSessionAdapter['closeSession']> | undefined
): Promise<boolean> {
const route = this.routes.get(sessionId)
if (!route) {
@@ -201,7 +197,7 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
return true
}
const stop = selectStop(route.adapter)
const stopped = await stop?.call(route.adapter, sessionId, cause)
const stopped = await stop?.call(route.adapter, sessionId)
if (stopped === true) {
route.state = 'stopped'
return true
@@ -33,7 +33,6 @@ import type {
} from '../../../shared/agent-session-wire'
import type { AgentSessionRefusalReason } from '../../../shared/agent-session-wire-refusals'
import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure'
import type { StructuredAgentSessionStopCause } from './structured-agent-session-stop-cause'
import type {
AgentSessionCancelOutcome,
StructuredAgentSessionAdapterStop
@@ -191,8 +190,6 @@ 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. */
@@ -381,11 +378,11 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop &
/** 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, cause?: StructuredAgentSessionStopCause): Promise<boolean>
closeSession?(sessionId: string): 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, cause?: StructuredAgentSessionStopCause): Promise<boolean>
disposeSession?(sessionId: string): Promise<boolean>
/** Host acknowledgement that the proven-dead child, lease and journal owner are released. */
acknowledgeSessionRelease?(sessionId: string): void
}
@@ -52,58 +52,69 @@ export function mutateWithChatStop<TValue>(
// Set by the Stop's step only when its provider's session ends; a replay leaves it unset.
let windDown: StructuredAgentSessionStopWindDown | undefined
const named = turnId !== undefined ? { turnId } : {}
const stopEvent = { reason: 'user-stop' as const, caller: caller.callerKey, ...named }
// Its own step wrote the Stop's event first.
const stopChild = () => context.stopAgent(sessionId, { recorded: 'user-stop' })
// The same for every client: once the Stop takes effect its event is written, and the queue's
// pause follows from it. The cards stay published; no text rides the answer.
const stop = (ctx: AgentSessionTurnContext): Promise<ChatStopOutcome> =>
runRecordedStop(ctx, stopEvent, async (tookEffect) => {
// Stop withdraws every queued SUBMISSION first, whatever the start or the child is doing.
const withdrawn = await ctx.journal.rejectQueuedSubmissions(
ctx.fence,
agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' })
)
const child = context.sessions.get(ctx.sessionId)?.child
if (child?.phase === 'starting') {
// A start that may never land is the one thing here Stop has to end; the chat stays.
await tookEffect()
await context.stopAgent(ctx.sessionId)
return { ok: true, value: { ...named, cancelled: true } }
}
// A Stop naming no turn ends nothing more unless the session reads working, by the rule
// every session list and the chat's own Stop read it.
const inFlight = turnId !== undefined || (await isMainAgentWorkingOnceFlushed(ctx))
const record = context.deps.store.getRecord(ctx.sessionId)
if (!child || !inFlight) {
if (withdrawn.length > 0) {
runRecordedStop(
ctx,
{
reason: 'user-stop',
caller: caller.callerKey,
// A Stop that ends the provider's session ends whatever is in flight, so its event names
// the live turn, or none (the turn opened next), never a named turn that already ended.
...(ctx.adapter.stopEndsSession?.(ctx.sessionId) === true ? {} : named)
},
async (tookEffect) => {
// Stop withdraws every queued SUBMISSION first, whatever the start or the child is doing.
const withdrawn = await ctx.journal.rejectQueuedSubmissions(
ctx.fence,
agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' })
)
const child = context.sessions.get(ctx.sessionId)?.child
if (child?.phase === 'starting') {
// A start that may never land is the one thing here Stop has to end; the chat stays.
await tookEffect()
await stopChild()
return { ok: true, value: { ...named, cancelled: true } }
}
// A Stop naming no turn ends nothing more unless the session reads working, by the rule
// every session list and the chat's own Stop read it.
const inFlight = turnId !== undefined || (await isMainAgentWorkingOnceFlushed(ctx))
const record = context.deps.store.getRecord(ctx.sessionId)
if (!child || !inFlight) {
if (withdrawn.length > 0) {
await tookEffect()
}
return { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } }
}
// Awaited until journal appends are synchronous; then issued here, and a `finally` awaits it.
if (withdrawn.length > 0 || (await stopReachesUnrecordedWork(ctx, turnId))) {
await tookEffect()
}
return { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } }
return performCancel(
{ ...ctx, failureTextContext: structuredAgentSessionFailureWordsContext(record) },
{
clientOperationId: envelope.clientOperationId,
...named,
stopChild,
onStopChildError: (error) =>
context.deps.logger.warn('ending the agent process on Stop failed', {
scope: 'stop-child',
sessionId,
error
}),
// The host drops its child only once the exit is proven, and nothing else runs meanwhile.
childReleased: () => context.sessions.get(sessionId)?.child !== child,
endSession: (owed) => {
windDown = owed
},
withdrewQueued: withdrawn.length > 0
}
)
}
// Awaited until journal appends are synchronous; then issued here, and a `finally` awaits it.
if (withdrawn.length > 0 || (await stopReachesUnrecordedWork(ctx, turnId))) {
await tookEffect()
}
return performCancel(
{ ...ctx, failureTextContext: structuredAgentSessionFailureWordsContext(record) },
{
clientOperationId: envelope.clientOperationId,
...named,
stopChild: () => context.stopAgent(sessionId),
onStopChildError: (error) =>
context.deps.logger.warn('ending the agent process on Stop failed', {
scope: 'stop-child',
sessionId,
error
}),
// The host drops its child only once the exit is proven, and nothing else runs meanwhile.
childReleased: () => context.sessions.get(sessionId)?.child !== child,
endSession: (owed) => {
windDown = owed
},
withdrewQueued: withdrawn.length > 0
}
)
})
)
const result = mutateStructuredAgentSession(
context,
caller,
@@ -121,7 +132,7 @@ export function mutateWithChatStop<TValue>(
await endStoppedStructuredAgentSession(
{ sessionId, adapter: context.deps.adapter },
windDown,
() => context.stopAgent(sessionId),
stopChild,
(error) =>
context.deps.logger.warn("ending a stopped chat's provider session failed", {
scope: 'chat-stop',
@@ -0,0 +1,247 @@
// Which turn a Claude Stop's event makes the person's cancellation, on the shipping adapter: the
// Stop ends the child, so whatever its event binds is what the child's end cut. Older CLIs end an
// interrupted turn with an error result that names no reason, and the journal's Stop rule decides
// it as it writes the end.
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record'
import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn'
import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter'
import {
fakeClaude,
PROVIDER_SESSION_ID,
type FakeConnection
} from '../../claude/claude-structured-session-test-support'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness'
import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter'
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
import { StructuredAgentSessionHost } from './structured-agent-session-host'
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
import {
HOST_TEST_NOW as NOW,
HOST_TEST_SESSION as SESSION,
hostTestAttachParams,
hostTestMessage,
hostTestOperationId,
resetHostTestOperationIds
} from './structured-agent-session-host-test-data'
const CALLER = { callerKey: 'client-1' }
// As Claude Code 2.1.280 advertises them on a turn's system/init frame.
const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1']
let root: string
let host: StructuredAgentSessionHost
let adapter: ClaudeStructuredSessionAdapter
let store: AgentSessionRecordStore
let claude: ReturnType<typeof fakeClaude>
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-claude-stop-turn-end-'))
resetHostTestOperationIds()
claude = fakeClaude({ replayUuid: null })
const lifecycle: Promise<void>[] = []
adapter = new ClaudeStructuredSessionAdapter({
resolveLaunch: async () => ({
pathToClaudeCodeExecutable: 'claude',
options: {},
cwd: root,
claudeConfigDir: join(root, 'claude-home'),
providerSessionId: PROVIDER_SESSION_ID,
resumeLeafUuid: null,
resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0,
continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0
}),
onEvent: (event) => {
const mapped = structuredClaudeLifecycleEvent(event)
if (mapped) {
lifecycle.push(host.handleAdapterEvent(mapped))
}
},
onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement),
openConnection: claude.openConnection,
readProcessStartTime: async () => 1_700_000_000_000,
now: () => NOW
})
store = await openTestAgentSessionRecordStore(root)
host = new StructuredAgentSessionHost({
store,
adapter: Object.assign(adapter, { supportsCreate: () => true }),
journalDatabase: openTestJournalHostDatabase(root),
logger: createStructuredAgentSessionLogger(),
claimKeyId: 'key-1',
mintSpawnToken: () => 'spawn-a',
now: () => NOW
})
const params = hostTestAttachParams(null, {
provider: 'claude',
agent: 'claude',
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') },
providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null }
})
expect(await host.attach(CALLER, params)).toMatchObject({ ok: true })
await adapter.awaitStarted(SESSION)
await Promise.all(lifecycle)
})
afterEach(async () => {
await adapter.closeAll()
await host.flushAllStreamedEvents()
await rm(root, { recursive: true, force: true })
})
function eventually<T>(assertion: () => T | Promise<T>): Promise<T> {
return vi.waitFor(assertion, { timeout: 10_000 })
}
function envelope(
method: 'agentSession.send' | 'agentSession.cancel',
fields: Record<string, unknown>
) {
return {
sessionId: SESSION,
clientOperationId: hostTestOperationId(),
expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method,
sessionId: SESSION,
fields: { ...fields }
})
}
}
/** A send Claude takes but has not echoed yet. */
async function sendUnechoed(connection: FakeConnection, text: string): Promise<void> {
const body = hostTestMessage(text)
expect(
await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body })
).toMatchObject({ ok: true })
await eventually(() =>
expect(connection.sent.some((message) => JSON.stringify(message).includes(text))).toBe(true)
)
}
function frame(connection: FakeConnection, message: Record<string, unknown>): void {
connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message })
}
/** Claude echoes its latest send, which opens that send's turn. */
function echoLatest(connection: FakeConnection): void {
const written = connection.sent.findLast((message) => message.type === 'user')!
frame(connection, { ...written, uuid: written.uuid })
}
function stop(turnId?: string) {
const fields = turnId === undefined ? {} : { turnId }
return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields })
}
/** Resolves once everything queued on the session's lane so far has run: a Stop's second step. */
function laneDrained(): Promise<void> {
return host['tasks'].serialize(SESSION, async () => {})
}
async function lastTurn() {
await host.flushStreamedEvents(SESSION)
const { items } = await host.journalSnapshot(SESSION)
return readAgentJournalTurn(items.findLast((item) => item.body.kind === 'turn')?.body)
}
it("reads an older CLI's error end after a Stop pressed before the echo as interrupted, not failed", async () => {
const connection = claude.connections[0]!
claude.routes.interrupt = () => {
setTimeout(() => {
echoLatest(connection)
frame(connection, { type: 'result', subtype: 'error_during_execution', is_error: true })
frame(connection, { type: 'system', subtype: 'session_state_changed', state: 'idle' })
}, 5)
return { still_queued: [], cancelled: [] }
}
await sendUnechoed(connection, 'Write a long reply.')
await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } })
await laneDrained()
expect(await lastTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' })
})
// A phone names the turn it last saw. The Stop ends the child, which ends whatever is in flight,
// so its event names no ended turn: it binds the turn the follow-up's echo opens.
it('reads a follow-up the child end cut as interrupted when the Stop named the turn before it', async () => {
const connection = claude.connections[0]!
await sendUnechoed(connection, 'Write a long reply.')
frame(connection, { type: 'system', subtype: 'init', uuid: 'init-1', capabilities: CAPABILITIES })
echoLatest(connection)
const ended = await eventually(async () => {
const turnId = activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items)
expect(turnId).not.toBeNull()
return turnId!
})
frame(connection, { type: 'result', subtype: 'success', is_error: false, uuid: 'ended-result' })
await eventually(async () =>
expect(activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items)).toBe(
null
)
)
await sendUnechoed(connection, 'Follow up.')
// Claude takes the interrupt; the follow-up's echo opens its turn, which outlives the grace.
claude.routes.interrupt = () => {
setTimeout(() => echoLatest(connection), 5)
return { still_queued: [], cancelled: [] }
}
await expect(stop(ended)).resolves.toMatchObject({ ok: true, value: { cancelled: true } })
await laneDrained()
expect(connection.closed).toBe(true)
const cut = await lastTurn()
expect(cut?.turnId).not.toBe(ended)
expect(cut).toMatchObject({ state: 'interrupted', outcome: 'cancellation' })
}, 15_000)
// The Stop bound only the turn it stopped: a later turn's error end is the provider's own.
it("keeps an older CLI's error end on a later turn a failure, with its error text, after a turnless Stop", async () => {
const stopped = claude.connections[0]!
claude.routes.interrupt = () => {
setTimeout(() => {
echoLatest(stopped)
frame(stopped, { type: 'result', subtype: 'error_during_execution', is_error: true })
frame(stopped, { type: 'system', subtype: 'session_state_changed', state: 'idle' })
}, 5)
return { still_queued: [], cancelled: [] }
}
await sendUnechoed(stopped, 'Write a long reply.')
await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } })
await laneDrained()
expect(await lastTurn()).toMatchObject({ outcome: 'cancellation' })
// The next send starts a new child on the same conversation.
const body = hostTestMessage('Carry on.')
expect(
await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body })
).toMatchObject({ ok: true })
const resumed = await eventually(() => {
const started = claude.connections.at(-1)!
expect(started).not.toBe(stopped)
expect(started.sent.some((message) => JSON.stringify(message).includes('Carry on.'))).toBe(true)
return started
})
echoLatest(resumed)
frame(resumed, {
type: 'result',
subtype: 'error_during_execution',
is_error: true,
result: 'API Error: overloaded'
})
await eventually(async () =>
expect(await lastTurn()).toMatchObject({ state: 'completed', outcome: 'failure' })
)
const { items } = await host.journalSnapshot(SESSION)
expect(JSON.stringify(items.map((item) => item.body))).toContain('API Error: overloaded')
})
@@ -1,7 +1,8 @@
// 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.
// needs to learn it did not finish. The adapter settles its own open turn interrupted, the host's
// fallback settles any turn no adapter did, and both ends read the close's Stop event where the
// row is built: no cause travels with the close.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
@@ -15,7 +16,6 @@ import { selectStructuredAgentSettledTurns } from '../../../shared/structured-ag
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,
@@ -27,17 +27,13 @@ import {
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'
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
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. */
/** What the provider writes on the open turn as it exits: interrupted at the exit it saw, as both
* adapters do, or its own verdict; null writes nothing. */
let providerEnd:
| 'mapped'
| Pick<AgentJournalTurnLifecycle, 'state' | 'outcome' | 'completedAt'>
@@ -56,22 +52,16 @@ beforeEach(() => {
store: state.store,
adapter: {
...adapter(),
closeSession: async (_sessionId, cause) => {
closeSession: async () => {
closeCalls += 1
const events = state.acquire.mock.calls.at(-1)?.[0].events
if (providerEnd === null) {
return true
}
// An exit it saw first ended before the close's Stop; a close it made ends after it.
const end =
providerEnd === 'mapped'
? turnVerdictForChildEnd(
childEndCauseOfEndedEvent({
type: 'ended',
cause: exitObservedFirst ? 'unexpected-exit' : 'requested-close',
...(cause ? { stopCause: cause } : {})
}),
1_500
)
? { state: 'interrupted' as const, completedAt: exitObservedFirst ? 1_500 : Date.now() }
: providerEnd
events?.appendItem(
CUT_TURN,
@@ -180,9 +170,21 @@ describe('a turn cut short by closing its provider', () => {
})
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.
// The provider opened the turn, but its rows are still in the event sink when the close
// arrives: the journal has no turn yet.
await attach()
await host.flushStreamedEvents(SESSION)
const events = hostTestState().acquire.mock.calls[0]?.[0].events
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.close(SESSION, 'user-close')
@@ -333,10 +335,3 @@ describe('a turn cut short by closing its provider', () => {
}
)
})
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)
})
@@ -183,9 +183,10 @@ async function followUpUnopened(): Promise<void> {
await host.flushStreamedEvents(SESSION)
}
/** Whether the Stop ended the child: the host's stop, which proves the exit, with the user's cause. */
/** Whether the Stop ended the child: the host's stop, which proves the exit. Nothing else here
* stops it before the test's teardown. */
function childEndedByStop(): boolean {
return disposeSession.mock.calls.some(([, cause]) => cause === 'user-stop')
return disposeSession.mock.calls.length > 0
}
describe('a Codex Stop that Codex answered', () => {
@@ -228,7 +229,7 @@ describe('a Codex Stop whose interrupt failed', () => {
await host.flushStreamedEvents(SESSION)
expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } })
expect(disposeSession).toHaveBeenCalledExactlyOnceWith(SESSION, 'user-stop')
expect(disposeSession).toHaveBeenCalledExactlyOnceWith(SESSION)
expect(codex.connections.at(-1)?.closed).toBe(true)
const rows = await journalRows()
expect(rows.turns).toEqual(['interrupted'])
@@ -248,7 +249,7 @@ describe('a Codex Stop whose interrupt failed', () => {
await host.flushStreamedEvents(SESSION)
expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } })
expect(disposeSession).toHaveBeenCalledExactlyOnceWith(SESSION, 'user-stop')
expect(disposeSession).toHaveBeenCalledExactlyOnceWith(SESSION)
expect((await journalRows()).statuses).toEqual([
"Codex didn't stop: failed to interrupt turn: channel closed."
])
@@ -138,7 +138,7 @@ describe('closing the handle', () => {
await foundRestTestChat(rig)
await rig.host.close(SESSION, 'evict')
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)
// 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, 'host-stop')
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)
await vi.waitFor(() =>
expect(readerSaw(reader.events).submissions).toContainEqual(
expect.objectContaining({ dispatchState: 'rejected', reason: stopReason })
@@ -18,9 +18,9 @@ import {
finishOwedStructuredAgentSessionWindDownUnderSerialize,
stopStructuredAgentSessionAgentUnderSerialize,
type StructuredAgentSessionCloseCause,
type StructuredAgentSessionLifetimeContext
type StructuredAgentSessionLifetimeContext,
type StructuredAgentSessionStopEnding
} 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'
@@ -55,8 +55,8 @@ export function createStructuredAgentSessionConversationLifetime(host: {
session.journal.whenImported().catch((error: unknown) => {
throw readRefusals.refusal(sessionId, error)
})
const stopAgent = (sessionId: string, cause: StructuredAgentSessionStopCause) =>
stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause })
const stopAgent = (sessionId: string, ending: StructuredAgentSessionStopEnding) =>
stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, ending)
const finishOwedWindDown = (sessionId: string) =>
finishOwedStructuredAgentSessionWindDownUnderSerialize(host.context(), sessionId)
@@ -86,7 +86,7 @@ export function createStructuredAgentSessionConversationLifetime(host: {
},
providerHoldsDispatch: (sessionId) => deps().adapter.holdsDispatch?.(sessionId) === true,
// 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'),
stopAgent: (sessionId) => stopAgent(sessionId, { cause: 'evict', resting: true }),
finishOwedWindDown,
// 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.
@@ -239,7 +239,7 @@ describe('a Stop that names no turn', () => {
expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } })
expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION, 'user-stop')
expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION)
expect(await statusRows()).toEqual(['Cancellation requested.'])
})
@@ -270,7 +270,7 @@ describe('a Stop that names no turn', () => {
expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } })
expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION, 'user-stop')
expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION)
expect(await statusRows()).toEqual(['Cancellation requested.'])
})
@@ -286,7 +286,7 @@ describe('a Stop that names no turn', () => {
expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } })
expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION, 'user-stop')
expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION)
expect(log.entries).toContainEqual(
expect.objectContaining({
fields: expect.objectContaining({ scope: 'stop-child', sessionId: SESSION })
@@ -306,7 +306,7 @@ describe('a Stop that names no turn', () => {
expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } })
expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION, 'user-stop')
expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION)
expect(log.entries).toContainEqual(
expect.objectContaining({
fields: expect.objectContaining({ scope: 'stop-child', sessionId: SESSION })
@@ -487,7 +487,7 @@ describe('a Stop on a provider whose Stop ends its session', () => {
expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } })
await laneDrained()
expect(closeSession).toHaveBeenCalledWith(SESSION, 'user-stop')
expect(closeSession).toHaveBeenCalledWith(SESSION)
expect(await statusRows()).toEqual(['Cancellation requested.'])
})
@@ -506,7 +506,7 @@ describe('a Stop on a provider whose Stop ends its session', () => {
expect(await stop('turn-1')).toMatchObject({ ok: true, value: { cancelled: true } })
await laneDrained()
expect(closeSession).toHaveBeenCalledWith(SESSION, 'user-stop')
expect(closeSession).toHaveBeenCalledWith(SESSION)
expect(await statusRows()).toEqual(['Cancellation requested.'])
})
@@ -541,7 +541,7 @@ describe('a Stop on a provider whose Stop ends its session', () => {
expect(await stop('turn-1')).toMatchObject({ ok: true, value: { cancelled: true } })
await laneDrained()
expect(closeSession).toHaveBeenCalledWith(SESSION, 'user-stop')
expect(closeSession).toHaveBeenCalledWith(SESSION)
expect(await statusRows()).toEqual(['Cancellation requested.'])
})
@@ -554,7 +554,7 @@ describe('a Stop on a provider whose Stop ends its session', () => {
expect(await stop('turn-1')).toMatchObject({ ok: true, value: { cancelled: true } })
await laneDrained()
expect(closeSession).toHaveBeenCalledWith(SESSION, 'user-stop')
expect(closeSession).toHaveBeenCalledWith(SESSION)
expect(await statusRows()).toEqual(['Cancellation requested.'])
})
})
@@ -26,8 +26,10 @@ import {
} from './structured-agent-session-start-failure-row'
import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record'
import {
endedByPersonsStop,
provenUnverifiableTurnRevisions,
runningTurnLifecycleRevisions,
stopFoundTurnLiveAt,
turnVerdictFromDeathEvidence,
type StructuredAgentSessionTurnVerdict
} from './structured-agent-session-stale-turn-verdict'
@@ -220,7 +222,11 @@ export async function settleStaleStructuredAgentSessionState(input: {
const items = journal.snapshot().items
// Each turn is judged by the evidence only if it names that turn's owner.
const verdictFor = (item: AgentJournalRenderItem) =>
turnVerdictFromDeathEvidence(input.deathEvidence, journal.itemFence(item.itemId))
turnVerdictFromDeathEvidence(
input.deathEvidence,
journal.itemFence(item.itemId),
stopFoundTurnLiveAt(journal, item)
)
// Per attempt: a retry re-partitions only what is left, and a reused chunk id would skip it.
const generation = input.acquisitionGeneration ?? `seq-${journal.cursor().sequence}`
const settlementId = `stale-session:${input.sessionId}:${input.fence}:${generation}`
@@ -238,15 +244,17 @@ export async function settleStaleStructuredAgentSessionState(input: {
}
}
const proven = provenUnverifiableTurnRevisions(items, input.deathEvidence, journal)
mutations.push(
const turnEnds = [
...items.flatMap((item) => runningTurnLifecycleRevisions([item], verdictFor(item))),
...proven
)
]
mutations.push(...turnEnds)
const evidence = input.deathEvidence
if (
evidence &&
(proven.length > 0 ||
items.some((item) => isInProgressItem(item) && verdictFor(item).state === 'interrupted'))
items.some((item) => isInProgressItem(item) && verdictFor(item).state === 'interrupted')) &&
!endedByPersonsStop(journal, turnEnds)
) {
mutations.unshift({
kind: 'item',
@@ -68,6 +68,9 @@ export class StructuredAgentSessionSinkQueue {
journalLinkage = (): StructuredAgentSessionLinkageJournal | null => this.target?.journal ?? null
journalStopDecidesTurn = (turnId: string, endedAt: number, openedBy?: string): boolean =>
this.target?.journal.stopMarks.personStopDecides(turnId, endedAt, openedBy) ?? false
bindReadingControl(control: StructuredAgentSessionReadingControl): () => void {
this.readingControl = control
if (this.backpressured) {
@@ -136,6 +136,10 @@ export type StructuredAgentSessionEventSink = {
journalEpoch?(): string | null
/** The bound journal's producer linkage; null until bound. */
journalLinkage?(): StructuredAgentSessionLinkageJournal | null
/** Whether the bound journal's Stop rule makes turn `turnId`, ending at `endedAt` with no verdict
* of its own, a person's cancellation (`personStopDecidesTurn`); false until bound. `openedBy`:
* the submission that opened it, for a turn whose rows have yet to land. */
journalStopDecidesTurn?(turnId: string, endedAt: number, openedBy?: string): boolean
appendLifecycleBatch?(
settlementId: string,
mutations: readonly JournalLifecycleMutationInput[],
@@ -288,6 +292,7 @@ export function createDeferredStructuredAgentSessionEventSink(deps: {
...resolvedAppend,
journalEpoch: queue.journalEpoch,
journalLinkage: queue.journalLinkage,
journalStopDecidesTurn: queue.journalStopDecidesTurn,
appendLifecycleBatch: (settlementId, mutations, options = {}) => {
const admission = appendLifecycleBatch(settlementId, mutations, options)
if (!admission.accepted) {
@@ -81,7 +81,7 @@ describe('structured agent session eviction', () => {
await evictStructuredAgentSession(ctx)
expect(disposeSession).toHaveBeenCalledWith('session-1', undefined)
expect(disposeSession).toHaveBeenCalledWith('session-1')
expect(closeSession).not.toHaveBeenCalled()
})
@@ -17,10 +17,7 @@
// reach it; forgetting it anyway stranded the process forever and reported success. Leaving the
// session in place is what makes the next close a real retry instead of a no-op.
import type {
StructuredAgentSessionAdapter,
StructuredAgentSessionStopCause
} from './structured-agent-session-adapter'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof'
import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
import type { StructuredAgentSessionStopVerdict } from './structured-agent-session-host-types'
@@ -29,8 +26,6 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo
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
@@ -94,9 +89,7 @@ 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, context.stopCause)
)
? await stopAgentSessionProviderRoot(() => stop.call(context.adapter, context.sessionId))
: true
if (!rootGone) {
throw new Error('provider child exit was not proven')
@@ -33,8 +33,13 @@ 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 { StructuredAgentSessionStopEnding } from './structured-agent-session-host-stop-event'
import {
recordStopEvent,
stopEndsWork,
type StructuredAgentSessionStopEnding
} from './structured-agent-session-host-stop-event'
export type StructuredAgentSessionLifetimeContext = {
deps: StructuredAgentSessionHostDeps
@@ -126,21 +131,22 @@ function owedStop(
export async function stopStructuredAgentSessionAgentUnderSerialize(
context: StructuredAgentSessionLifetimeContext,
sessionId: string,
// Required: an omitted cause must not default to the user's cancellation. `retry` is set only by
// the retry of a stop already owed.
ending: { cause: StructuredAgentSessionStopCause; reason?: string; retry?: true }
ending: StructuredAgentSessionStopEnding
): 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)
// Judged before the kill: a stop that ends nothing writes nothing.
const recorded = (await stopEndsWork(context, sessionId, session, ending))
? recordStopEvent(context, sessionId, session, ending)
: Promise.resolve()
// 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 asked = 'recorded' in ending ? ending.recorded : ending.cause
// A retry finishes the stop that ended the child, so the child's end keeps that stop's cause.
const cause = session.child ? asked : (session.owesProviderChildWindDown?.cause ?? asked)
const owed = owedStop(session, cause, ending.retry === true)
session.owesProviderChildWindDown = owed
const stopping = session.child
@@ -152,8 +158,6 @@ export async function stopStructuredAgentSessionAgentUnderSerialize(
eventSink: context.runtimeState.eventSinkFor(sessionId),
adapter: context.deps.adapter,
logger: context.deps.logger,
// 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) }
: {}),
@@ -164,7 +168,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize(
generation: stopping.generation,
fence: stopping.fence,
cause,
reason: ending.reason ?? null,
reason: ('reason' in ending ? ending.reason : undefined) ?? null,
duringStartup: stopping.phase === 'starting',
// A later retry that proves the exit still ends the child at the Stop it finishes.
...(owed ? { endedAt: owed.requestedAt } : {}),
@@ -176,6 +180,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize(
acknowledgeRelease: () => context.deps.adapter.acknowledgeSessionRelease?.(sessionId),
discardSink: () => context.runtimeState.discardEventSink(sessionId),
settleWork: async () => {
// Folded before the fallback's end is built, so the end reads it (`turnEndAfterStop`).
await recorded
const fence =
owed?.fence ?? structuredAgentSessionConversationFence(context.deps.store, sessionId)
const settled = await settleStructuredAgentSessionDeadGeneration({
@@ -184,8 +190,9 @@ export async function stopStructuredAgentSessionAgentUnderSerialize(
fence,
settlementId: `expected-close:${sessionId}:${fence}:${owed?.generation ?? 'unknown'}`,
pendingSubmissionReason: 'provider_closed_before_acknowledgement',
// Only a turn no adapter settled: one with no close, or whose settle threw.
verdict: turnVerdictForChildEnd(cause, context.now()),
// Only a turn no adapter settled: one with no close, or whose settle threw. Whether it was
// a person's Stop is its event's to say (`turnEndAfterStop`).
verdict: { state: 'interrupted', completedAt: context.now() },
showUnexpectedExitOutcome: false
})
if (!settled.ok) {
@@ -247,10 +254,13 @@ export async function finishOwedStructuredAgentSessionWindDownUnderSerialize(
return true
}
try {
await stopStructuredAgentSessionAgentUnderSerialize(context, sessionId, {
cause: owed.cause,
retry: true
})
await stopStructuredAgentSessionAgentUnderSerialize(
context,
sessionId,
owed.cause === 'user-stop'
? { recorded: 'user-stop', retry: true }
: { cause: owed.cause, retry: true }
)
} catch (error) {
context.deps.logger.warn('retrying an unfinished agent stop failed', {
scope: 'owed-stop-retry',
@@ -325,7 +335,10 @@ export async function evictOwnedStructuredAgentSessions(
ownedSessionIds.map(async (sessionId) => {
try {
await context.serialize(sessionId, () =>
stopStructuredAgentSessionAgentUnderSerialize(context, sessionId, { cause: 'evict' })
stopStructuredAgentSessionAgentUnderSerialize(context, sessionId, {
cause: 'evict',
quit: true
})
)
retainOnFailure.delete(sessionId)
} catch (error) {
@@ -0,0 +1,99 @@
// The Stop event a host stop writes (`JournalStopEvent`): whether it ends work its event must
// record, and the write itself, issued before the kill.
import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working'
import { withTimeout } from '../../../shared/promise-timeout-fallback'
import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter'
import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime'
import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types'
import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child'
/** How a stop ends the child, and why (`lastEndedChild`). A person's Stop wrote its event in its
* own step (`recorded` names its reason); any other stop names the reason its event records, with
* the host's text for it. Quit writes none: its resume marker's trigger records why. */
export type StructuredAgentSessionStopEnding = (
| { recorded: 'user-stop' }
| {
cause: Exclude<StructuredAgentSessionStopCause, 'user-stop'>
reason?: string
quit?: true
/** The idle sweep judged the agent resting (`owesWork`): a send it retires unanswered is
* no work its event records. */
resting?: true
}
) & {
/** The retry of a stop already owed, set only by that retry: its event, if any, is written. */
retry?: true
}
/** How long a host stop waits for the session's sink before it judges whether the stop ends work. */
const STOP_EVENT_DRAIN_TIMEOUT_MS = 1_000
/**
* Whether this stop ends work its event must record: a running turn or an unanswered send, a start's
* own included, read once the sink drained what the provider already said. A start that carries
* no send ends nothing. A person's Stop wrote its own event, and quit, the idle sweep's rest and a
* retry of a stop already owed write none.
*/
export async function stopEndsWork(
context: StructuredAgentSessionLifetimeContext,
sessionId: string,
session: StructuredAgentSessionHostSession,
ending: StructuredAgentSessionStopEnding
): Promise<boolean> {
const { child, journal } = session
if ('recorded' in ending || ending.quit || ending.resting || ending.retry || !child) {
return false
}
// A failed drain has nothing more to deliver, so the journal's read as it stands holds. One
// still running past its bound may hold the turn row of a send already accepted: that reads
// working.
const drain = await withTimeout(
context.runtimeState.flushEventSink(sessionId).then(
() => 'drained' as const,
() => 'failed' as const
),
STOP_EVENT_DRAIN_TIMEOUT_MS,
'slow' as const
)
const working =
(drain === 'slow' && journal.stopMarks.latestAcceptedSendUnopened()) ||
isStructuredAgentSessionMainAgentWorking(
journal.activeTurnId(),
journal.submissions(),
child.fence
)
// A host stop of work a person's Stop is already ending must not supersede that Stop's reason.
return (
working &&
(ending.cause === 'user-close' || !journal.stopMarks.personStopDecides(journal.activeTurnId()))
)
}
/** Writes this stop's event (`JournalStopEvent`). Issued before the kill and never awaited by it:
* bookkeeping, reported on failure. */
export function recordStopEvent(
context: StructuredAgentSessionLifetimeContext,
sessionId: string,
session: StructuredAgentSessionHostSession,
ending: StructuredAgentSessionStopEnding
): Promise<void> {
if ('recorded' in ending) {
return Promise.resolve()
}
const turnId = session.journal.activeTurnId()
return session.journal
.appendStopEvent(
{ reason: ending.cause, ...(turnId !== null ? { turnId } : {}) },
structuredAgentSessionConversationFence(context.deps.store, sessionId)
)
.then(
() => undefined,
(error: unknown) =>
context.deps.logger.warn("a host stop's Stop event row skipped", {
scope: 'stop-event',
sessionId,
error
})
)
}
@@ -288,7 +288,7 @@ export class StructuredAgentSessionHost {
sessionId
),
wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId),
stopAgent: (sessionId) => this.lifetime.stopAgent(sessionId, 'user-stop'),
stopAgent: (sessionId, ending) => this.lifetime.stopAgent(sessionId, ending),
wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId),
now: () => this.now()
}
@@ -74,7 +74,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, 'evict'))
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
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)
@@ -130,7 +130,7 @@ describe('the idle sweep', () => {
expect(rig.adapter.closeSession).not.toHaveBeenCalled()
rig.sink.readChildWork.mockReturnValue([])
rig.clock.now += IDLE_MS + 1
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict'))
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
})
// A finished child reads done before the lead's wake-up turn writes its first row; stopping the
@@ -145,7 +145,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, 'evict'))
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
})
// Owed work is read every tick, not once a window: work that ends just before a window would
@@ -163,7 +163,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, 'evict'))
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
})
it('stops an agent whose child records hold only children that went idle or finished', async () => {
@@ -174,7 +174,7 @@ describe('the idle sweep', () => {
])
rig.clock.now += IDLE_MS + 1
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict'))
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
})
it('never stops an agent while its lead turn runs, however quiet (P2-10)', async () => {
@@ -197,7 +197,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, 'evict'))
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
expect(reader.events.some((event) => event.type === 'end')).toBe(false)
})
@@ -216,7 +216,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, 'evict'))
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
})
it('never stops a worker whose orchestration dispatch is open, and stops it once it settles (P2-19 i)', async () => {
@@ -232,7 +232,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, 'evict'))
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
})
// A Claude retrying a rate-limited request has taken the send but echoes nothing, so no turn row
@@ -249,7 +249,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, 'evict'))
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
})
it('keeps a child an unanswered prompt waits on (P2-22 i)', async () => {
@@ -12,6 +12,7 @@ import {
type AgentSessionMutationSessionPreparation
} from './structured-agent-session-mutation-admission'
import type { MutationPlan } from './structured-agent-session-mutation-plans'
import type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-lifetime'
import type {
StructuredAgentSessionCaller,
StructuredAgentSessionHostDeps,
@@ -37,8 +38,9 @@ export type StructuredAgentSessionMutationContext = {
finishOwedStop: (sessionId: string) => Promise<AgentSessionMutationSessionPreparation>
/** A message was accepted: the session's delivery loop hands it over. */
wakeDelivery: (sessionId: string) => void
/** Stops the session's provider child, keeping its conversation; inside the caller's serialize. */
stopAgent: (sessionId: string) => Promise<void>
/** Stops the session's provider child, keeping its conversation; inside the caller's serialize.
* Each caller names why (`ending`). */
stopAgent: (sessionId: string, ending: StructuredAgentSessionStopEnding) => Promise<void>
/** Only for gate inputs living in the RECORD store, which can settle with no
* journal commit (a conversation command). Draft-table changes need no call:
* the draft store notifies through the journal's own commit listener. */
@@ -216,7 +216,7 @@ describe('structured session options and close', () => {
await host.close(SESSION, 'evict')
expect(closeNativeSession).toHaveBeenCalledWith(SESSION, 'evict')
expect(closeNativeSession).toHaveBeenCalledWith(SESSION)
expect(store.getRecord(SESSION)?.lease).toMatchObject({
claimStatus: 'released',
ownerProcess: null,
@@ -648,7 +648,7 @@ describe('a quit with a message still queued', () => {
starting.resolve()
await quit
expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict')
expect(closeSession).toHaveBeenCalledWith(SESSION)
expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' })
expect(dispatch).not.toHaveBeenCalled()
expect(await afterRelaunch(id)).toMatchObject({
@@ -39,7 +39,14 @@ export type QueuedMessageTestRig = Awaited<ReturnType<typeof createQueuedMessage
/** `restartable`: a child started for a chat whose chain already names a thread resumes it, so a
* chat whose child closed or died can start another. `starting`: every child stays starting. */
export async function createQueuedMessageTestRig(
options: { restartable?: true; starting?: true } = {}
options: {
restartable?: true
starting?: true
/** Lets a test sweep idle chats on its own `tick`. */
idleSweep?: { idleMs: number; intervalMs: number }
/** The provider's Stop ends its child, as Claude's does. */
stopEndsSession?: true
} = {}
) {
const root = await mkdtemp(join(tmpdir(), 'orca-queued-messages-'))
resetHostTestOperationIds()
@@ -98,13 +105,15 @@ export async function createQueuedMessageTestRig(
releaseAcquisition: vi.fn(async () => true),
compact,
cancelTurn,
...(options.stopEndsSession ? { stopEndsSession: () => true } : {}),
answerPrompt: vi.fn(async () => undefined),
setOption: vi.fn(async () => undefined)
},
journalDatabase: openTestJournalHostDatabase(root),
claimKeyId: 'key-1',
mintSpawnToken: () => 'spawn-1',
now: () => NOW
now: () => NOW,
...(options.idleSweep ? { idleSweep: options.idleSweep } : {})
})
let host = makeHost()
expect(await host.attach(QUEUED_RIG_CALLER, hostTestAttachParams(null))).toMatchObject({
@@ -261,7 +270,9 @@ 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. */
* opens afresh under a new instance id while its rows survive. The close is an eviction, whose
* Stop event ends a person's Stop pause if work runs; a quit writes none, so a test of that
* pause across a restart uses `crashRestartHostProcess`. */
async function restartHostProcess(): Promise<void> {
await host.close(SESSION, 'evict')
rotateStructuredAgentSessionHostInstanceForTests()
@@ -92,8 +92,9 @@ describe("a Stop's queue pause", () => {
it('survives a restart, and a send made after the Stop still ends it when its turn starts there', async () => {
const draftId = await stoppedDraft()
const inFlight = await handedOverUserSend('sent before the restart')
// Derived from the journal, not remembered: a restart forgets nothing it needs.
await rig.restartHostProcess()
// Derived from the journal, not remembered: a restart forgets nothing it needs. The process
// dies with no close, as a quit writes no Stop event to end the pause either.
rig.crashRestartHostProcess()
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
await rig.settleAccepted(inFlight, 'after-restart')
// The Stop's pause is over; the restart's own lasts until a turn asked for since it.
@@ -192,8 +192,15 @@ describe("a Stop never hides a restart's pause", () => {
await rig.settleAccepted(working, 'stopped')
const mail = await mailTurn()
const typed = await queuedDraft('typed during the mail turn')
await rig.restartHostProcess()
// The process dies with no close: a quit writes no Stop event either, so the Stop's pause stays.
rig.crashRestartHostProcess()
await rig.settleAccepted(mail, 'mail')
// The new host opens the conversation for its first reader.
await rig.queuePause()
expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual([
'stopped',
'restarted'
])
await expectHeld('restarted', typed)
})
@@ -207,8 +214,13 @@ describe("a Stop never hides a restart's pause", () => {
expect((await rig.handoff(correction))?.handedOverAt).toBeDefined()
)
const typed = await queuedDraft('typed during that send')
await rig.restartHostProcess()
rig.crashRestartHostProcess()
await rig.settleAccepted(await rig.handoffId(correction), 'correction')
await rig.queuePause()
expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual([
'stopped',
'restarted'
])
await expectHeld('restarted', typed)
})
})
@@ -26,11 +26,7 @@ import {
settleStaleStructuredAgentSessionState,
settleStructuredAgentSessionDeadGeneration
} from './structured-agent-session-dead-generation-settlement'
import {
childEndCauseOfEndedEvent,
turnVerdictForChildEnd,
type StructuredAgentSessionTurnVerdict
} from './structured-agent-session-stale-turn-verdict'
import 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'
@@ -197,7 +193,7 @@ describe('a turn recovery settled after its host went away', () => {
],
[
'quitting Orca',
// A quit evicts the child, and its adapter settles the open turn through the one mapping.
// A quit evicts the child, writing no Stop event, and its adapter settles the open turn.
(journal: AgentSessionJournal) =>
journal.appendItem(
{ provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 9 },
@@ -205,14 +201,8 @@ describe('a turn recovery settled after its host went away', () => {
kind: 'turn',
turnId: 'turn-1',
startedAt: TURN_STARTED,
...turnVerdictForChildEnd(
childEndCauseOfEndedEvent({
type: 'ended',
cause: 'requested-close',
stopCause: 'evict'
}),
EXIT_OBSERVED
)
state: 'interrupted',
completedAt: EXIT_OBSERVED
},
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
@@ -276,6 +276,7 @@ describe('stale session state on a cold acquire', () => {
const journal = {
snapshot: () => ({ items }),
itemFence: () => 1,
stopMarks: { latest: () => null },
cursor: () => ({ epoch: 'epoch-1', sequence: 8 }),
appendLifecycleBatch
} as unknown as AgentSessionJournal
@@ -17,17 +17,12 @@ 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 =
/** `cancellation` only for a stop the user aimed at this chat: every other cut is news. */
| { state: 'interrupted'; completedAt: number; outcome?: 'cancellation' }
| { state: 'unverifiable' }
/** Whose end it was is the Stop event's to say, where the row is built (`turnEndAfterStop`). */
{ state: 'interrupted'; completedAt: number } | { state: 'unverifiable' }
export const UNVERIFIABLE_TURN_VERDICT: StructuredAgentSessionTurnVerdict = {
state: 'unverifiable'
@@ -36,7 +31,9 @@ export const UNVERIFIABLE_TURN_VERDICT: StructuredAgentSessionTurnVerdict = {
export function turnVerdictFromDeathEvidence(
evidence: AgentSessionDeathEvidence | null | undefined,
/** Fence of the owner that wrote the turn. */
turnFence: number | undefined
turnFence: number | undefined,
/** When a Stop event found the turn running (`stopFoundTurnLiveAt`): a later proof of life. */
liveAt?: number
): StructuredAgentSessionTurnVerdict {
if (!evidence) {
return UNVERIFIABLE_TURN_VERDICT
@@ -54,50 +51,36 @@ export function turnVerdictFromDeathEvidence(
return { state: 'interrupted', completedAt: evidence.observedAt }
}
// A probe finds a dead child long after it died; its last renewal bounds the end, so the turn never
// counts the time Orca was down. Timeline rows don't: a send can land there after the death.
return {
state: 'interrupted',
completedAt: Math.min(evidence.lastProvenAliveAt ?? evidence.observedAt, evidence.observedAt)
}
// counts the time Orca was down. Timeline rows don't: a send can land there after the death. A
// Stop that found the turn running is a later renewal, so the end reads after that Stop.
const lastAlive = Math.max(evidence.lastProvenAliveAt ?? evidence.observedAt, liveAt ?? 0)
return { state: 'interrupted', completedAt: Math.min(lastAlive, evidence.observedAt) }
}
/**
* 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 }
/** When the latest Stop event found `item`'s turn running: E1 writes one only for a live turn. */
export function stopFoundTurnLiveAt(
journal: Pick<AgentSessionJournal, 'stopMarks'>,
item: AgentJournalRenderItem
): number | undefined {
const stop = journal.stopMarks.latest()
const turnId = readAgentJournalTurn(item.body)?.turnId
return stop && turnId !== undefined && stop.event.turnId === turnId ? stop.event.at : undefined
}
/** 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'
/** Every turn this settle interrupts is a person's Stop's to end (`turnEndAfterStop`), so it reads
* as theirs, muted, with no row saying the provider stopped: as a live Stop writes none. */
export function endedByPersonsStop(
journal: Pick<AgentSessionJournal, 'stopMarks'>,
turnEnds: readonly JournalLifecycleMutationInput[]
): boolean {
const interrupted = turnEnds.flatMap((mutation) => {
const turn = mutation.kind === 'item' ? readAgentJournalTurn(mutation.body) : undefined
return turn?.state === 'interrupted' ? [turn] : []
})
return (
interrupted.length > 0 &&
interrupted.every((turn) => journal.stopMarks.personStopDecides(turn.turnId, turn.completedAt))
)
}
/** Revises every still-running lifecycle item in place, keeping its identity and start. */
@@ -119,7 +102,7 @@ export function runningTurnLifecycleRevisions(
export function provenUnverifiableTurnRevisions(
items: readonly AgentJournalRenderItem[],
evidence: AgentSessionDeathEvidence | null | undefined,
journal: Pick<AgentSessionJournal, 'itemFence'>
journal: Pick<AgentSessionJournal, 'itemFence' | 'stopMarks'>
): JournalLifecycleMutationInput[] {
const ownerFence = evidence?.ownerFence
if (ownerFence === undefined) {
@@ -128,7 +111,11 @@ export function provenUnverifiableTurnRevisions(
return items.flatMap((item) => {
const turn = readAgentJournalTurn(item.body)
return turn?.state === 'unverifiable' && journal.itemFence(item.itemId) === ownerFence
? turnLifecycleRevision(item, turn, turnVerdictFromDeathEvidence(evidence, ownerFence))
? turnLifecycleRevision(
item,
turn,
turnVerdictFromDeathEvidence(evidence, ownerFence, stopFoundTurnLiveAt(journal, item))
)
: []
})
}
@@ -172,7 +159,6 @@ function settledLifecycle(
return {
...kept,
state: verdict.state,
completedAt: Math.max(verdict.completedAt, began),
...(verdict.outcome ? { outcome: verdict.outcome } : {})
completedAt: Math.max(verdict.completedAt, began)
}
}
@@ -1,5 +1,5 @@
/** Why a provider child ended. In memory only, except `user-stop`, the one arm a Stop event
* journals so far. */
/** Why a provider child ended, for the delivery loop (`lastEndedChild`). A stop's arms are also
* its Stop event's reason (`JournalStopEvent`); the others are in memory only. */
export type StructuredAgentSessionChildEndCause =
| 'user-stop'
/** The user closed this chat: its tab, its launch, or a `/clear` that replaces it. */
@@ -9,8 +9,8 @@ export type StructuredAgentSessionChildEndCause =
| 'attach-failed'
| 'evict'
/** Why the host asked a child to stop. The adapter carries it onto the `ended` it settles with,
* and a Stop event persists it (`JournalStopEvent.reason`), so never rename an arm. */
/** Why a child was asked to stop. A Stop event persists it (`JournalStopEvent.reason`), which
* is what a turn's end reads, so never rename an arm. */
export type StructuredAgentSessionStopCause = Extract<
StructuredAgentSessionChildEndCause,
'user-stop' | 'user-close' | 'host-stop' | 'evict'
@@ -0,0 +1,293 @@
// Which turn a person's Stop that named no turn binds: only a turn a send it stopped opens. A Stop
// of a start that never landed stopped a send that opens no turn; a card it held, which Resume
// releases, and anything sent after it open their own; a rewind keeps the binding. Turn rows name
// the send that opened them, as Codex writes them.
import { afterEach, describe, expect, it } from 'vitest'
import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key'
import {
AGENT_JOURNAL_THREAD_SCOPE,
type AgentJournalItemIdentity
} from '../../../shared/agent-session-journal-types'
import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record'
import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema'
import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement'
import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data'
import {
createQueuedMessageTestRig,
eventually,
type QueuedMessageTestRig
} from './structured-agent-session-queued-message-rig.test-fixture'
let rig: QueuedMessageTestRig
afterEach(() => rig.dispose())
const LATER_TURN: AgentJournalItemIdentity = {
provider: 'codex',
threadId: 'thread-1',
turnId: 'turn-later',
ordinal: 999
}
function journal() {
const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal
if (!open) {
throw new Error('expected the conversation open')
}
return open
}
function stopEvents(): JournalStopEvent[] {
const since = journal().readSince({ epoch: journal().epoch, sequence: 0 })
if (!since.ok) {
throw new Error(`expected rows, got reset ${since.reset}`)
}
return since.rows.flatMap((row) =>
row.kind === 'tombstone' && row.stopEvent ? [row.stopEvent] : []
)
}
function childPhase() {
return rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase
}
function fence(): number {
return rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1
}
async function laterTurn() {
const { items } = await rig.host.journalSnapshot(HOST_TEST_SESSION)
return items
.map((item) => readAgentJournalTurn(item.body))
.find((turn) => turn?.turnId === 'turn-later')
}
/** The turn send `clientMessageId` opens, running, named by its row as Codex writes it. */
async function turnOpenedBy(clientMessageId: string, state: 'running' | 'interrupted' = 'running') {
await journal().appendItem(
LATER_TURN,
{
kind: 'turn',
turnId: 'turn-later',
startedAt: Date.now(),
userItemId: agentJournalSubmissionKey(clientMessageId),
...(state === 'running' ? { state } : { state, completedAt: Date.now() + 5 })
},
{ fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
}
async function queuedDraft(text: string): Promise<string> {
const queued = await rig.send(text, 'queue-if-active').result
if (!queued.ok || !('queued' in queued.value)) {
throw new Error(`expected a queued receipt: ${JSON.stringify(queued)}`)
}
return queued.value.queued.messageId
}
/** A person's Stop of a start that never landed, whose send opens no turn. `held`: a card queued
* behind the start, which the Stop holds. */
async function stopOfStart(options: { held?: true } = {}): Promise<string | undefined> {
rig = await createQueuedMessageTestRig({ starting: true, restartable: true })
let release: () => void = () => undefined
rig.awaitStarted.mockImplementation(
() => new Promise<undefined>((resolve) => (release = () => resolve(undefined)))
)
rig.send('work on this')
await eventually(() => expect(childPhase()).toBe('starting'))
const held = options.held ? await queuedDraft('queued behind the start') : undefined
expect(await rig.stop()).toMatchObject({ ok: true })
release()
expect(stopEvents()).toEqual([expect.objectContaining({ reason: 'user-stop' })])
expect(stopEvents()[0]).not.toHaveProperty('turnId')
await eventually(() => expect(childPhase()).toBeUndefined())
rig.awaitStarted.mockImplementation(async () => undefined)
return held
}
/** Orchestration mail after the Stop starts a new child and its turn runs. */
async function mailTurn(): Promise<void> {
const mail = rig.send('mail for the worker', undefined, { internal: true })
await mail.result
await eventually(() => expect(rig.dispatch).toHaveBeenCalled())
await turnOpenedBy(mail.id)
}
/** The host evicts the chat; the Stop events as its provider close finds them. */
async function evictedAt(): Promise<string[]> {
let atClose: JournalStopEvent[] = []
rig.closeSession.mockImplementationOnce(async () => {
atClose = stopEvents()
return true
})
await rig.host.close(HOST_TEST_SESSION, 'evict')
return atClose.map((event) => event.reason)
}
async function expectNews(): Promise<void> {
const turn = await laterTurn()
expect(turn).toMatchObject({ state: 'interrupted' })
expect(turn).not.toHaveProperty('outcome')
}
describe('a Stop of a start that never landed binds no later turn', () => {
it("writes the host's event when it evicts a mail turn, which reads as news", async () => {
await stopOfStart()
await mailTurn()
expect(await evictedAt()).toEqual(['user-stop', 'evict'])
await expectNews()
})
it('reads a mail turn the child end cut, with no verdict of its own, as news', async () => {
await stopOfStart()
const mail = rig.send('mail for the worker', undefined, { internal: true })
await mail.result
await turnOpenedBy(mail.id)
await turnOpenedBy(mail.id, 'interrupted')
await expectNews()
})
it('settles a crash of a mail turn on relaunch as news', async () => {
await stopOfStart()
await mailTurn()
const owner = fence()
rig.crashRestartHostProcess()
await rig.host.journalSnapshot(HOST_TEST_SESSION)
await settleStaleStructuredAgentSessionState({
journal: journal(),
sessionId: HOST_TEST_SESSION,
fence: owner + 1,
acquisitionGeneration: 'generation-2',
deathEvidence: {
kind: 'exit-observed',
detail: 'the relaunch proved the old child gone',
observedAt: Date.now() + 60_000,
ownerFence: owner
}
})
await expectNews()
})
it("writes the host's event when it evicts the turn of a card the Stop held, which Resume sent", async () => {
const held = (await stopOfStart({ held: true }))!
expect(await rig.resume()).toMatchObject({ ok: true })
await eventually(async () => expect(await rig.handoff(held)).toBeDefined())
await turnOpenedBy(await rig.handoffId(held))
expect(await evictedAt()).toEqual(['user-stop', 'evict'])
await expectNews()
})
})
describe('a Stop pressed before its send opened a turn binds only that turn', () => {
/** The send the Stop stopped is handed over and unopened; a card waits behind it. */
async function stopBeforeTheTurnShowed(): Promise<{ stopped: string; held: string }> {
rig = await createQueuedMessageTestRig()
const stopped = await rig.workingSend()
const held = await queuedDraft('queued behind the turn')
expect(await rig.stop()).toMatchObject({ ok: true })
expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId')
return { stopped, held }
}
it("binds the stopped send's own turn", async () => {
const { stopped } = await stopBeforeTheTurnShowed()
await rig.settleAccepted(stopped, 'stopped')
await turnOpenedBy(stopped, 'interrupted')
expect(await laterTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' })
})
it("writes the host's event when it evicts the turn of the card Resume sent", async () => {
const { stopped, held } = await stopBeforeTheTurnShowed()
await rig.settleAccepted(stopped, 'stopped')
expect(await rig.resume()).toMatchObject({ ok: true })
await eventually(async () => expect(await rig.handoff(held)).toBeDefined())
await turnOpenedBy(await rig.handoffId(held))
expect(await evictedAt()).toEqual(['user-stop', 'evict'])
await expectNews()
})
it('reads a mail turn the child end cut as news', async () => {
const { stopped } = await stopBeforeTheTurnShowed()
await rig.settleAccepted(stopped, 'stopped')
const mail = rig.send('mail for the lead', undefined, { internal: true })
await mail.result
await turnOpenedBy(mail.id)
await turnOpenedBy(mail.id, 'interrupted')
await expectNews()
})
})
describe('a host stop with no turn running after a Stop that named none', () => {
it('writes nothing while every unanswered send is one the Stop stopped', async () => {
rig = await createQueuedMessageTestRig()
await rig.workingSend()
expect(await rig.stop()).toMatchObject({ ok: true })
expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId')
expect(await evictedAt()).toEqual(['user-stop'])
})
it("writes the host's event when a send after the Stop is unanswered beside the stopped one", async () => {
rig = await createQueuedMessageTestRig()
await rig.workingSend()
expect(await rig.stop()).toMatchObject({ ok: true })
const mail = rig.send('mail for the lead', undefined, { internal: true })
await mail.result
await eventually(async () =>
expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined()
)
expect(await evictedAt()).toEqual(['user-stop', 'evict'])
})
})
describe('a rewind that restates a turnless Stop', () => {
// The rewind writes the Stop still in force after the turns it keeps, at a new position; the
// mail after the rewind is still its own.
it('binds no turn opened after the rewind', async () => {
rig = await createQueuedMessageTestRig()
const stopped = await rig.workingSend()
expect(await rig.stop()).toMatchObject({ ok: true })
expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId')
await rig.settleAccepted(stopped, 'stopped')
const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
const stoppedTurn = { ...LATER_TURN, turnId: 'turn-stopped', ordinal: 998 }
const ended = {
kind: 'turn' as const,
turnId: 'turn-stopped',
state: 'interrupted' as const,
userItemId: agentJournalSubmissionKey(stopped)
}
await journal().appendItem(
stoppedTurn,
{ ...ended, state: 'running', startedAt: Date.now() },
scope
)
await journal().appendItem(stoppedTurn, { ...ended, completedAt: Date.now() + 1 }, scope)
await journal().replaceEpochItems('handle_forked', 1, [
{
identity: stoppedTurn,
body: { ...ended, completedAt: Date.now() + 1, outcome: 'cancellation' }
}
])
const mail = rig.send('mail after the rewind', undefined, { internal: true })
await mail.result
await turnOpenedBy(mail.id)
await turnOpenedBy(mail.id, 'interrupted')
await expectNews()
})
})
@@ -0,0 +1,432 @@
// Every way a host stop or close begins writes the Stop's event with its reason, before it ends the
// child, and only when it ends work: a running turn or a send. A stop that ends nothing writes
// nothing, quit writes nothing (its resume marker records why), and any later Stop event ends a
// person's Stop pause.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key'
import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record'
import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema'
import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data'
import {
createQueuedMessageTestRig,
eventually,
type QueuedMessageTestRig
} from './structured-agent-session-queued-message-rig.test-fixture'
let rig: QueuedMessageTestRig
afterEach(() => rig.dispose())
/** Swept only when a test ticks it. */
const MANUAL_IDLE_SWEEP = { idleMs: 0, intervalMs: 60 * 60 * 1000 }
function journal() {
const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal
if (!open) {
throw new Error('expected the conversation open')
}
return open
}
/** Every Stop event in the live epoch, oldest first. */
function stopEvents(): JournalStopEvent[] {
const since = journal().readSince({ epoch: journal().epoch, sequence: 0 })
if (!since.ok) {
throw new Error(`expected rows, got reset ${since.reset}`)
}
return since.rows.flatMap((row) =>
row.kind === 'tombstone' && row.stopEvent ? [row.stopEvent] : []
)
}
/** The Stop events as the provider's close finds them, or null when no close ran. */
function stopEventsAtClose(): { events: JournalStopEvent[] | null } {
const seen: { events: JournalStopEvent[] | null } = { events: null }
rig.closeSession.mockImplementationOnce(async () => {
seen.events = stopEvents()
return true
})
return seen
}
async function runningTurn(turnId = 'turn-1'): Promise<string> {
const working = await rig.workingSend()
await journal().appendItem(
{ provider: 'codex', threadId: 'thread-1', turnId, ordinal: 999 },
{ kind: 'turn', turnId, state: 'running', startedAt: 1 },
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
return working
}
async function queuedDraft(text: string): Promise<string> {
const queued = await rig.send(text, 'queue-if-active').result
if (!queued.ok || !('queued' in queued.value)) {
throw new Error(`expected a queued receipt: ${JSON.stringify(queued)}`)
}
return queued.value.queued.messageId
}
function idleSweep() {
return rig.host.collaboratorsForTests().lifetime.idleSweep
}
/** Holds every start until the returned release. */
function holdStart(): () => void {
let release: () => void = () => undefined
rig.awaitStarted.mockImplementation(
() => new Promise<undefined>((resolve) => (release = () => resolve(undefined)))
)
return () => release()
}
describe('every Stop entry writes its event, with its reason, before it ends the child', () => {
it.each([
// A person closing this chat: its tab, its launch, or a /clear that replaces it.
['user-close' as const],
// A worktree teardown, an orchestration stop, a discarded half-started worker, a tab cleanup.
['evict' as const]
])('a %s close of a running turn', async (cause) => {
rig = await createQueuedMessageTestRig()
await runningTurn()
const atClose = stopEventsAtClose()
await rig.host.close(HOST_TEST_SESSION, cause)
expect(atClose.events).toEqual([{ reason: cause, turnId: 'turn-1', at: expect.any(Number) }])
})
it("a person's Stop of a running turn", async () => {
rig = await createQueuedMessageTestRig()
await runningTurn()
let atInterrupt: JournalStopEvent[] = []
rig.cancelTurn.mockImplementationOnce(async () => {
atInterrupt = stopEvents()
return { cancelled: true }
})
await rig.stop()
expect(atInterrupt).toEqual([
expect.objectContaining({ reason: 'user-stop', turnId: 'turn-1', at: expect.any(Number) })
])
})
it('the idle sweep stopping a start, with its send, that never landed', async () => {
rig = await createQueuedMessageTestRig({
starting: true,
restartable: true,
idleSweep: MANUAL_IDLE_SWEEP
})
holdStart()
rig.send('work on this')
await eventually(async () =>
expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe(
'starting'
)
)
const atClose = stopEventsAtClose()
await idleSweep().tick()
expect(atClose.events).toEqual([{ reason: 'host-stop', at: expect.any(Number) }])
})
it('writes nothing when it ends nothing: a close of a chat at rest', async () => {
rig = await createQueuedMessageTestRig()
const atClose = stopEventsAtClose()
await rig.host.close(HOST_TEST_SESSION, 'user-close')
expect(atClose.events).toEqual([])
})
// The person's reason survives to the turn's end.
it("writes nothing when the host evicts a turn a person's Stop is still ending", async () => {
rig = await createQueuedMessageTestRig()
await runningTurn()
expect(await rig.stop()).toMatchObject({ ok: true })
const atClose = stopEventsAtClose()
await rig.host.close(HOST_TEST_SESSION, 'evict')
expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop'])
const { items } = await rig.host.journalSnapshot(HOST_TEST_SESSION)
expect(items.map((item) => readAgentJournalTurn(item.body)).find(Boolean)).toMatchObject({
state: 'interrupted',
outcome: 'cancellation'
})
})
// A drain still running after its bound may hold the turn's row: the agent reads working.
it("writes a person's close while the running turn's row waits behind a slow sink", async () => {
rig = await createQueuedMessageTestRig()
const sent = await rig.workingSend()
const open = journal()
const append = open.appendItem.bind(open)
let held = false
vi.spyOn(open, 'appendItem').mockImplementation(async (...args: Parameters<typeof append>) => {
if (!held && args[1].kind === 'turn' && args[1].state === 'running') {
held = true
await new Promise((resolve) => setTimeout(resolve, 1_500))
}
return append(...args)
})
rig.host['runtimeState'].eventSinkFor(HOST_TEST_SESSION).sink.appendItem(
{ provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 999 },
{
kind: 'turn',
turnId: 'turn-1',
state: 'running',
startedAt: Date.now(),
userItemId: agentJournalSubmissionKey(sent)
},
{ turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
// The echo's acceptance lands straight in the journal, ahead of the turn row.
await rig.settleAccepted(sent, 'turn-1')
const atClose = stopEventsAtClose()
await rig.host.close(HOST_TEST_SESSION, 'user-close')
expect(atClose.events?.map((event) => event.reason)).toEqual(['user-close'])
}, 20_000)
// A slow drain counts as working only for a send accepted with no turn row yet.
// A steer delivered into the running turn opens no turn of its own, so it is never owed one.
it.each([
['', false],
[', its last send a steer into the stopped turn', true]
])(
'writes nothing when the drain runs long as it evicts a chat at rest%s',
async (_label, steered) => {
rig = await createQueuedMessageTestRig()
const working = await rig.workingSend()
const opener = agentJournalSubmissionKey(working)
const identity = {
provider: 'codex' as const,
threadId: 'thread-1',
turnId: 'turn-1',
ordinal: 999
}
const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
await journal().appendItem(
identity,
{ kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1, userItemId: opener },
scope
)
if (steered) {
const steer = rig.send('steer the running turn')
await steer.result
await eventually(async () =>
expect((await rig.submission(steer.id))?.handedOverAt).toBeDefined()
)
await rig.settleAccepted(steer.id, 'turn-1')
expect(
journal()
.snapshot()
.items.find((item) => item.itemId === agentJournalSubmissionKey(steer.id))?.turnScope
).toMatchObject({ kind: 'turn' })
}
await queuedDraft('queued behind the turn')
expect(await rig.stop()).toMatchObject({ ok: true })
await rig.settleAccepted(working, 'turn-1')
await journal().appendItem(
identity,
{
kind: 'turn',
turnId: 'turn-1',
state: 'interrupted',
completedAt: Date.now(),
userItemId: opener
},
scope
)
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
const sink = rig.host['runtimeState'].eventSinkFor(HOST_TEST_SESSION)
const drained = sink.drained.bind(sink)
vi.spyOn(sink, 'drained')
.mockImplementationOnce(
() => new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 1_500))
)
.mockImplementation(drained)
const atClose = stopEventsAtClose()
await rig.host.close(HOST_TEST_SESSION, 'evict')
expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop'])
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
},
20_000
)
// The drain is best effort: when it fails, the journal as it stands says the agent rests.
it('writes nothing when the drain fails as it evicts a chat at rest', async () => {
rig = await createQueuedMessageTestRig()
const working = await runningTurn()
expect(await rig.stop()).toMatchObject({ ok: true })
await rig.settleAccepted(working, 'stopped')
await journal().appendItem(
{ provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 999 },
{ kind: 'turn', turnId: 'turn-1', state: 'interrupted', completedAt: Date.now() },
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
expect(journal().activeTurnId()).toBeNull()
const sink = rig.host['runtimeState'].eventSinkFor(HOST_TEST_SESSION)
const drained = sink.drained.bind(sink)
vi.spyOn(sink, 'drained')
.mockResolvedValueOnce({ ok: false, error: new Error('drain lost once') })
.mockImplementation(drained)
const atClose = stopEventsAtClose()
await rig.host.close(HOST_TEST_SESSION, 'evict')
expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop'])
})
// The idle sweep finishes a stop whose exit was unproven: the same stop, so its event stands alone.
it.each([['user-close' as const], ['evict' as const]])(
'writes one event for a close (%s) whose exit was unproven, and none for its retry',
async (cause) => {
rig = await createQueuedMessageTestRig({ idleSweep: MANUAL_IDLE_SWEEP })
await runningTurn()
rig.closeSession.mockResolvedValueOnce(false)
const session = () => rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)!
await rig.host.close(HOST_TEST_SESSION, cause).catch(() => undefined)
expect(session().child).not.toBeNull()
expect(session().owesProviderChildWindDown).toMatchObject({ cause })
expect(stopEvents()).toEqual([{ reason: cause, turnId: 'turn-1', at: expect.any(Number) }])
await idleSweep().tick()
expect(session().owesProviderChildWindDown).toBeUndefined()
expect(stopEvents().map((event) => event.reason)).toEqual([cause])
expect(session().lastEndedChild?.cause).toBe(cause)
}
)
it("writes nothing at quit, whose resume marker's trigger records why", async () => {
rig = await createQueuedMessageTestRig()
await runningTurn()
const atClose = stopEventsAtClose()
await rig.host.flushAllStreamedEvents({ trigger: 'quit' })
expect(atClose.events).toEqual([])
})
})
describe("a person's Stop pause and the Stop events after it", () => {
it('holds through an idle eviction of the chat at rest, which writes nothing', async () => {
rig = await createQueuedMessageTestRig({ idleSweep: MANUAL_IDLE_SWEEP })
const working = await rig.workingSend()
const held = await queuedDraft('queued behind the turn')
expect(await rig.stop()).toMatchObject({ ok: true })
await rig.settleAccepted(working, 'stopped')
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
const atClose = stopEventsAtClose()
await idleSweep().tick()
expect(rig.closeSession).toHaveBeenCalledTimes(1)
expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop'])
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
expect(await rig.handoff(held)).toBeUndefined()
})
// The sweep rests an agent with no turn running even while a send whose reply was lost waits,
// so that send cannot pin it forever; that rest ends no work, so it writes no event.
it('holds through an idle eviction that retires a send whose reply was lost', async () => {
rig = await createQueuedMessageTestRig({ idleSweep: MANUAL_IDLE_SWEEP })
const working = await rig.workingSend()
await queuedDraft('queued behind the turn')
expect(await rig.stop()).toMatchObject({ ok: true })
await rig.settleAccepted(working, 'stopped')
rig.dispatch.mockRejectedValueOnce(new Error('reply lost'))
const lost = rig.send('sent as the reply was lost')
await lost.result
await eventually(async () =>
expect(await rig.submission(lost.id)).toMatchObject({ dispatchState: 'unknown' })
)
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
const atClose = stopEventsAtClose()
await idleSweep().tick()
expect(rig.closeSession).toHaveBeenCalledTimes(1)
expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop'])
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
})
// A start that carries no send ends no turn and no send, so stopping it writes nothing.
it('holds through the sweep stopping a start that carries no send, which writes nothing', async () => {
rig = await createQueuedMessageTestRig({ starting: true, idleSweep: MANUAL_IDLE_SWEEP })
expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe(
'starting'
)
// A person's Stop of an earlier turn still pauses the queue.
await journal().appendStopEvent({ reason: 'user-stop', caller: 'client-1' }, 1)
expect(journal().queuedMessages.userStopInForce()).not.toBeNull()
const atClose = stopEventsAtClose()
await idleSweep().tick()
expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop'])
expect(journal().queuedMessages.userStopInForce()).not.toBeNull()
})
it('ends when a host eviction ends a running turn: that Stop event is later', async () => {
rig = await createQueuedMessageTestRig()
const working = await rig.workingSend()
await queuedDraft('queued behind the turn')
expect(await rig.stop()).toMatchObject({ ok: true })
await rig.settleAccepted(working, 'stopped')
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
// Orchestration mail starts a turn the host sent, which lifts nothing.
await rig.send('mail for the lead', undefined, { internal: true }).result
await journal().appendItem(
{ provider: 'codex', threadId: 'thread-1', turnId: 'turn-mail', ordinal: 999 },
{ kind: 'turn', turnId: 'turn-mail', state: 'running', startedAt: 1 },
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
const atClose = stopEventsAtClose()
await rig.host.close(HOST_TEST_SESSION, 'evict')
expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop', 'evict'])
expect(await rig.queuePause()).not.toEqual({ reason: 'stopped' })
})
it('ends when the host stops a start that never landed: that Stop event is later', async () => {
rig = await createQueuedMessageTestRig({
starting: true,
restartable: true,
idleSweep: MANUAL_IDLE_SWEEP
})
const working = await rig.workingSend()
const held = await queuedDraft('queued behind the turn')
expect(await rig.stop()).toMatchObject({ ok: true })
await rig.settleAccepted(working, 'stopped')
// The agent at rest goes, writing nothing; mail then starts a new child, which never lands.
await idleSweep().tick()
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
holdStart()
rig.send('mail for the lead', undefined, { internal: true })
await eventually(async () =>
expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe(
'starting'
)
)
const atClose = stopEventsAtClose()
await idleSweep().tick()
expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop', 'host-stop'])
expect(await rig.handoff(held)).toBeUndefined()
expect(await rig.queuePause()).not.toEqual({ reason: 'stopped' })
})
})
@@ -0,0 +1,339 @@
// A Stop that took effect still decides its turn after Orca restarts before the turn's end was
// written: the relaunch's settle reads the Stop's event, so the turn reads "Interrupted after N"
// with the muted mark, not "Failed". A turn nobody stopped, and one a Stop never named, still read
// as the news they are.
import { afterEach, describe, expect, it } from 'vitest'
import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record'
import {
AGENT_JOURNAL_THREAD_SCOPE,
type AgentJournalItemIdentity
} from '../../../shared/agent-session-journal-types'
import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key'
import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict'
import { agentTurnVerdict } from '../../../shared/agent-turn-outcome'
import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record'
import { formatNativeChatTurnStatusLabel } from '../../../shared/native-chat-turn-status'
import { selectStructuredAgentSettledTurns } from '../../../shared/structured-agent-session-turn-timing'
import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement'
import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data'
import {
QUEUED_RIG_CALLER,
createQueuedMessageTestRig,
type QueuedMessageTestRig
} from './structured-agent-session-queued-message-rig.test-fixture'
const TURN = 'turn-1'
/** The provider rows a turn lives on: the Claude lane's, and Codex's. */
const CLAUDE_TURN: AgentJournalItemIdentity = {
provider: 'claude',
sessionId: 'provider-session-1',
uuid: 'uuid-turn'
}
const CODEX_TURN: AgentJournalItemIdentity = {
provider: 'codex',
threadId: 'thread-1',
turnId: TURN,
ordinal: 999
}
const NEXT_TURN = 'turn-2'
const CODEX_NEXT_TURN: AgentJournalItemIdentity = {
...CODEX_TURN,
turnId: NEXT_TURN,
ordinal: 1000
}
let rig: QueuedMessageTestRig
afterEach(() => rig.dispose())
function journal() {
const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal
if (!open) {
throw new Error('expected the conversation open')
}
return open
}
/** A send the provider is working on, with its turn running since half a minute ago. */
async function runningTurn(
identity: AgentJournalItemIdentity,
options: { stopEndsSession?: true } = {}
): Promise<void> {
rig = await createQueuedMessageTestRig(options)
await rig.workingSend()
await journal().appendItem(
identity,
{ kind: 'turn', turnId: TURN, state: 'running', startedAt: Date.now() - 30_000 },
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
}
/** Orca dies with the turn's end unwritten; the relaunch reopens the chat from disk and proves
* the old child gone (its last renewal came before the Stop), then settles what it left. */
async function restartAndSettle(
proof: 'pid-absent' | 'exit-observed' | 'unproven' = 'pid-absent'
): Promise<void> {
rig.crashRestartHostProcess()
await rig.host.journalSnapshot(HOST_TEST_SESSION)
const now = Date.now()
const deathEvidence: AgentSessionDeathEvidence | null =
proof === 'unproven'
? null
: {
kind: proof,
detail: 'the relaunch proved the old child gone',
observedAt: now + 60_000,
ownerFence: 1,
lastProvenAliveAt: now - 20_000
}
await settleStaleStructuredAgentSessionState({
journal: journal(),
sessionId: HOST_TEST_SESSION,
fence: 2,
acquisitionGeneration: 'generation-2',
deathEvidence
})
}
/** What the chat's turn bar and the session's mark read, for `turnId` or else the first turn, and
* the error rows beside it. */
function settled(turnId?: string) {
const { items } = journal().snapshot()
const turn = items
.map((item) => readAgentJournalTurn(item.body))
.find((entry) => entry && (turnId === undefined || entry.turnId === turnId))
const [timing] = [...selectStructuredAgentSettledTurns(items).values()]
const verdict = turn
? agentTurnVerdict({ state: turn.state, outcome: turn.outcome ?? null })
: null
return {
turn,
label: timing ? formatNativeChatTurnStatusLabel({ elapsedSeconds: 0, ...timing }) : null,
mark: verdict
? agentVerdictDisplayMark({ state: 'done', mainAgent: { state: 'done', outcome: verdict } })
: null,
errorRows: items.flatMap((item) =>
item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : []
)
}
}
describe('a restart between a Stop and its turn end', () => {
it.each([
['a Claude Stop before its result arrives', CLAUDE_TURN],
['a Codex Stop before turn/completed', CODEX_TURN]
])('reads Interrupted after N, marked interrupted: %s', async (_label, identity) => {
await runningTurn(identity)
// The provider took the interrupt; its end never arrived.
expect(await rig.stop()).toMatchObject({ ok: true })
await restartAndSettle()
const { turn, label, mark, errorRows } = settled()
expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' })
expect(label).toMatch(/^Interrupted after /)
expect(mark).toBe('interrupted')
// A live Stop writes no row saying the provider stopped; nor does its relaunch.
expect(errorRows).toEqual([])
})
it('reads Interrupted after N, marked interrupted: a close of the chat that died midway', async () => {
await runningTurn(CODEX_TURN)
// The host dies inside the close: the provider's close never answers, and nothing settles.
rig.closeSession.mockImplementationOnce(() => Promise.reject(new Error('host died')))
await expect(rig.host.close(HOST_TEST_SESSION, 'user-close')).rejects.toThrow()
expect(journal().stopMarks.latest()?.event).toMatchObject({
reason: 'user-close',
turnId: TURN
})
await restartAndSettle()
const { turn, label, mark, errorRows } = settled()
expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' })
expect(label).toMatch(/^Interrupted after /)
expect(mark).toBe('interrupted')
// A live Stop writes no row saying the provider stopped; nor does its relaunch.
expect(errorRows).toEqual([])
})
it('reads Failed after N, marked failed, when nobody stopped it', async () => {
await runningTurn(CODEX_TURN)
await restartAndSettle()
const { turn, label, mark, errorRows } = settled()
expect(turn).toMatchObject({ state: 'interrupted' })
expect(turn).not.toHaveProperty('outcome')
expect(label).toMatch(/^Failed after /)
expect(mark).toBe('failed')
expect(errorRows).toEqual([
expect.stringContaining('stopped while this response was in progress')
])
})
it("reads Couldn't confirm when the relaunch cannot prove the child gone, Stop or not", async () => {
// The Stop says whose end it was, never that the turn ended.
await runningTurn(CODEX_TURN)
expect(await rig.stop()).toMatchObject({ ok: true })
await restartAndSettle('unproven')
const { turn, mark } = settled()
expect(turn).toMatchObject({ state: 'unverifiable' })
expect(mark).toBe('unconfirmed')
})
// Codex refuses a Stop naming a turn that is no longer its active one ("expected active turn id
// X but found Y"), as a turn not running, so the child stays. The Stop names X, so Y's end is
// never the person's, by its turn id alone.
it('reads Failed for the turn running when the provider refused a Stop naming the one before it', async () => {
await runningTurn(CODEX_TURN)
rig.cancelTurn.mockResolvedValueOnce({
cancelled: false,
refusal: {
detail: {
text: `expected active turn id ${TURN} but found ${NEXT_TURN}`,
audience: 'person'
},
turnNotRunning: true
}
})
const fields = { turnId: TURN }
expect(
await rig.host.cancel(QUEUED_RIG_CALLER, {
envelope: rig.envelope(fields, 'agentSession.cancel', hostTestOperationId()),
...fields
})
).toMatchObject({ ok: true, value: { cancelled: false } })
expect(journal().stopMarks.latest()?.event).toMatchObject({ reason: 'user-stop', turnId: TURN })
// The journal catches up: X had finished, and Y runs on until the crash.
await journal().appendItem(
CODEX_TURN,
{
kind: 'turn',
turnId: TURN,
state: 'completed',
outcome: 'success',
completedAt: Date.now()
},
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
await journal().appendItem(
CODEX_NEXT_TURN,
{ kind: 'turn', turnId: NEXT_TURN, state: 'running', startedAt: Date.now() - 10_000 },
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
// Its exit is proven after the Stop, so only the turn the Stop named decides.
await restartAndSettle('exit-observed')
const { turn, mark } = settled(NEXT_TURN)
expect(turn).toMatchObject({ state: 'interrupted' })
expect(turn).not.toHaveProperty('outcome')
expect(mark).toBe('failed')
})
// Claude's Stop ends its child whatever the interrupt answered.
it('reads Interrupted after N when the provider refused a Stop that ends its child', async () => {
await runningTurn(CLAUDE_TURN, { stopEndsSession: true })
rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: {} })
expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } })
// The Stop's next step on the session's lane ends the child.
await rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {})
expect(rig.closeSession).toHaveBeenCalled()
await restartAndSettle()
expect(settled().turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' })
})
it('reads a turn a send made after a Stop pressed before any turn showed as no Stop of its', async () => {
rig = await createQueuedMessageTestRig()
const stopped = await rig.workingSend()
// Pressed before the turn showed: the Stop names no turn.
expect(await rig.stop()).toMatchObject({ ok: true })
expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined()
await rig.settleAccepted(stopped, 'stopped')
const next = rig.send('sent after the Stop')
await next.result
await rig.settleAccepted(next.id, 'next')
await journal().appendItem(
CODEX_TURN,
{ kind: 'turn', turnId: TURN, state: 'running', startedAt: Date.now() - 30_000 },
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
// Its exit is proven after the Stop, so only whose turn it is decides.
await restartAndSettle('exit-observed')
expect(settled().turn).toMatchObject({ state: 'interrupted' })
expect(settled().turn).not.toHaveProperty('outcome')
})
// A Stop pressed before any turn showed stopped the turn its send was about to open, and no other.
it('reads a turn a host send opened after the turnless Stop ended its own turn as no Stop of its', async () => {
rig = await createQueuedMessageTestRig()
const stopped = await rig.workingSend()
expect(await rig.stop()).toMatchObject({ ok: true })
expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined()
await rig.settleAccepted(stopped, 'stopped')
const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
// Codex names the send that opened a turn on its row.
const opener = agentJournalSubmissionKey(stopped)
await journal().appendItem(
CODEX_TURN,
{ kind: 'turn', turnId: TURN, state: 'running', startedAt: Date.now(), userItemId: opener },
scope
)
await journal().appendItem(
CODEX_TURN,
{
kind: 'turn',
turnId: TURN,
state: 'interrupted',
completedAt: Date.now() + 1,
userItemId: opener
},
scope
)
expect(settled(TURN).turn).toMatchObject({ outcome: 'cancellation' })
// A send after the Stop, the queue's drain here, opens its own turn.
const drained = rig.send('drained after the Stop', undefined, { internal: true })
await drained.result
await rig.settleAccepted(drained.id, 'drained')
await journal().appendItem(
CODEX_NEXT_TURN,
{
kind: 'turn',
turnId: NEXT_TURN,
state: 'running',
startedAt: Date.now(),
userItemId: agentJournalSubmissionKey(drained.id)
},
scope
)
// Its exit is proven after the Stop, so only which turn the Stop stopped decides.
await restartAndSettle('exit-observed')
expect(settled(NEXT_TURN).turn).toMatchObject({ state: 'interrupted' })
expect(settled(NEXT_TURN).turn).not.toHaveProperty('outcome')
})
// A refusal is no record: the first Stop stays the one in force, so pressing again repeats it.
it('writes nothing for a Stop pressed again after the provider refused one', async () => {
await runningTurn(CODEX_TURN)
rig.cancelTurn.mockResolvedValueOnce({
cancelled: false,
refusal: { detail: { text: 'no active turn to interrupt', audience: 'person' } }
})
await rig.stop()
const first = journal().stopMarks.latest()
await rig.stop()
expect(journal().stopMarks.latest()).toEqual(first)
})
})
@@ -135,7 +135,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, 'evict')
expect(closeSession).toHaveBeenCalledWith(SESSION)
expect(host.hasSession(SESSION)).toBe(false)
})
}
@@ -145,12 +145,14 @@ function waitOutSeveralSweeps(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, SWEEP_MS * 20))
}
/** Fails the next eviction at `drain-published`, which leaves the session indexed for a retry. */
function failNextDrain(): void {
vi.spyOn(host['runtimeState'].eventSinkFor(SESSION), 'drained').mockResolvedValueOnce({
ok: false,
error: new Error('drain barrier lost')
})
/** Fails the next eviction at `drain-published`, which leaves the session indexed for a retry. The
* stop drains once before it, to judge whether it ends work. */
function failEvictionDrain(): void {
const sink = host['runtimeState'].eventSinkFor(SESSION)
const drained = sink.drained.bind(sink)
vi.spyOn(sink, 'drained')
.mockImplementationOnce(drained)
.mockResolvedValueOnce({ ok: false, error: new Error('drain barrier lost') })
}
/** The submissions as they stood when the session was forgotten; its journal is gone after that. */
@@ -250,7 +252,7 @@ describe('a chat that closes', () => {
await host.close(SESSION, 'evict')
expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict')
expect(closeSession).toHaveBeenCalledWith(SESSION)
expect(host.hasSession(SESSION)).toBe(false)
expect(hostErrors).toEqual([])
// The record and its journal stay; only the process and the claim on it go.
@@ -346,10 +348,7 @@ describe('a chat that closes', () => {
expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } })
const session = host['sessions'].get(SESSION)
expect(session).toBeDefined()
vi.spyOn(host['runtimeState'].eventSinkFor(SESSION), 'drained').mockResolvedValueOnce({
ok: false,
error: new Error('drain barrier lost')
})
failEvictionDrain()
const settled = captureSettledSubmissions()
await expect(host.close(SESSION, 'evict')).rejects.toMatchObject({ step: 'drain-published' })
@@ -821,7 +820,7 @@ describe('a quit over an eviction that never got its retry', () => {
await attach()
await sendPending('pending across an abandoned eviction')
const settled = captureSettledSubmissions()
failNextDrain()
failEvictionDrain()
await expect(host.close(SESSION, 'evict')).rejects.toMatchObject({ step: 'drain-published' })
expect(host['sessions'].get(SESSION)?.child).toBeNull()
@@ -99,6 +99,7 @@ describe('provider-exit settlement', () => {
items: [lifecycleItem('turn-1', 1, { state: 'running', startedAt: 1_000 })]
}),
itemFence: () => 7,
stopMarks: { latest: () => null, personStopDecides: () => false },
appendLifecycleBatch,
markPendingSubmissionsUnknown: vi.fn(async () => [])
}
@@ -350,6 +350,25 @@ describe('/clear starts nothing', () => {
expect(atCommit).toEqual({ child: null, claim: 'released' })
})
// Its own cause, never the reason of whatever Stop the journal holds last.
it("ends a running source's agent as the user closing the chat", async () => {
const session = host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)!
await session.journal.appendStopEvent(
{ reason: 'host-stop' },
store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence
)
const commit = store.commitConversationClear
let endedAs: string | undefined
vi.spyOn(store, 'commitConversationClear').mockImplementationOnce(async (clear) => {
endedAs = session.lastEndedChild?.cause
return commit(clear)
})
await clearCommits()
expect(endedAs).toBe('user-close')
})
it('founds one record per /clear through a chain of clears, starting neither', async () => {
const first = await clearCommits()
const second = await host.conversationCommand(caller, {
@@ -146,7 +146,8 @@ export function runStructuredConversationCommand(
}
// Stopped before the marker, so nothing the old agent does can land after the clear. The
// stop releases the lease, which moves its fence: the marker is written at the new one.
await context.stopAgent(sessionId)
// A /clear replaces this chat: the user closing it.
await context.stopAgent(sessionId, { cause: 'user-close' })
const fence = store.getRecord(sessionId)!.lease.runtimeFence
const completed = {
command,
@@ -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, 'evict')
expect(rig.adapter.closeSession).toHaveBeenCalledWith(REST_TEST_SESSION)
} finally {
hostRef.current = null
await rig.dispose()
@@ -69,6 +69,7 @@ test("an older build keeps every row around a Stop's event and a Resume, and fol
await append(0, 'before the Stop')
const beforeMarks = journal.cursor()
await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 1)
await journal.appendStopEvent({ reason: 'user-close', turnId: 'turn-1' }, 1)
await journal.appendQueueResume(1)
const afterMarks = journal.cursor()
await append(1, 'after the Stop')
@@ -135,7 +135,7 @@ describe('a chat at rest keeps its worktree activatable', () => {
it('after the idle sweep stopped its agent and closed the conversation', async () => {
clock += STRUCTURED_AGENT_SESSION_IDLE_MS + 1
await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false))
expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict')
expect(closeSession).toHaveBeenCalledWith(SESSION)
expect(host.handoffStatus(SESSION)).toMatchObject({ owner: 'native' })
expect(await activate()).toBe('structured')