From ccb63afc06ebc7f232c1173bf5db5b2cf420ebdd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:56:13 -0700 Subject: [PATCH] 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 (5ead1f6bccd) 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. --- .../claude-api-retry-idle-sweep.test.ts | 2 +- ....test.ts => claude-close-turn-end.test.ts} | 31 +- .../claude-journal-translator-contract.ts | 4 - src/main/claude/claude-message-journaling.ts | 5 +- src/main/claude/claude-open-turn.ts | 22 +- .../claude/claude-replay-turn-resolution.ts | 20 +- src/main/claude/claude-result-journaling.ts | 12 +- src/main/claude/claude-result-outcome.ts | 17 +- ...ude-structured-child-work-captures.test.ts | 2 +- .../claude-structured-control-actions.test.ts | 1 - .../claude-structured-control-actions.ts | 10 +- ...aude-structured-dispatch-admission.test.ts | 11 +- .../claude-structured-in-turn-stop.test.ts | 58 ++- .../claude-structured-journal-translation.ts | 22 +- .../claude-structured-prompt-ownership.ts | 15 +- .../claude-structured-provider-fallback.ts | 5 +- .../claude-structured-session-acquisition.ts | 1 + .../claude-structured-session-adapter.ts | 22 +- .../claude/claude-structured-session-close.ts | 5 - .../claude/claude-structured-session-state.ts | 9 +- src/main/claude/claude-turn-lifecycle-item.ts | 12 +- src/main/claude/claude-turn-opening.ts | 5 +- src/main/claude/claude-turn-outcome.test.ts | 63 ++- src/main/claude/claude-turn-ownership.test.ts | 1 - ...e.test.ts => codex-close-turn-end.test.ts} | 81 +--- .../codex-provider-retry-idle-sweep.test.ts | 2 +- .../codex-structured-journal-translation.ts | 20 +- .../codex/codex-structured-session-adapter.ts | 9 +- .../codex/codex-structured-session-close.ts | 16 +- .../codex/codex-structured-session-state.ts | 7 +- .../codex-structured-session-teardown.ts | 6 +- .../journal-lifecycle-batch-partition.ts | 15 +- .../journal-row-builders.ts | 14 +- .../journal-stop-marks.ts | 27 ++ .../journal-stop-turn-end.ts | 200 ++++++++ .../journal-store-collaborators.ts | 3 + .../agent-session-journal/journal-store.ts | 3 + ...tured-agent-session-adapter-router.test.ts | 2 +- ...structured-agent-session-adapter-router.ts | 18 +- .../structured-agent-session-adapter.ts | 7 +- .../structured-agent-session-chat-stop.ts | 105 +++-- ...agent-session-claude-stop-turn-end.test.ts | 247 ++++++++++ ...ctured-agent-session-close-verdict.test.ts | 47 +- ...tured-agent-session-codex-stop-row.test.ts | 9 +- ...d-agent-session-conversation-close.test.ts | 4 +- ...red-agent-session-conversation-lifetime.ts | 10 +- ...ed-agent-session-conversation-stop.test.ts | 16 +- ...gent-session-dead-generation-settlement.ts | 16 +- ...ructured-agent-session-event-sink-queue.ts | 3 + .../structured-agent-session-event-sink.ts | 5 + .../structured-agent-session-eviction.test.ts | 2 +- .../structured-agent-session-eviction.ts | 11 +- .../structured-agent-session-host-lifetime.ts | 49 +- ...tructured-agent-session-host-stop-event.ts | 99 ++++ .../structured-agent-session-host.ts | 2 +- ...tructured-agent-session-idle-sweep.test.ts | 18 +- ...ructured-agent-session-mutation-context.ts | 6 +- ...ed-agent-session-option-settlement.test.ts | 2 +- ...gent-session-provider-child-record.test.ts | 2 +- ...session-queued-message-rig.test-fixture.ts | 17 +- ...ed-agent-session-queued-pause-lift.test.ts | 5 +- ...ured-agent-session-queued-stop-row.test.ts | 16 +- ...agent-session-recovered-turn-clock.test.ts | 18 +- ...d-agent-session-stale-turn-verdict.test.ts | 1 + ...ctured-agent-session-stale-turn-verdict.ts | 90 ++-- .../structured-agent-session-stop-cause.ts | 8 +- ...d-agent-session-stop-event-binding.test.ts | 293 ++++++++++++ ...d-agent-session-stop-event-entries.test.ts | 432 ++++++++++++++++++ ...d-agent-session-stop-event-restart.test.ts | 339 ++++++++++++++ ...red-agent-session-surface-lifetime.test.ts | 25 +- ...ured-agent-session-unexpected-exit.test.ts | 1 + .../structured-conversation-command.test.ts | 19 + .../structured-conversation-command.ts | 3 +- .../runtime/structured-worker-at-rest.test.ts | 2 +- ...-session-stop-event-downgrade.unit.test.ts | 1 + ...-chat-owner-status-activation.unit.test.ts | 2 +- 76 files changed, 2179 insertions(+), 531 deletions(-) rename src/main/claude/{claude-close-stop-cause.test.ts => claude-close-turn-end.test.ts} (69%) rename src/main/codex/{codex-close-stop-cause.test.ts => codex-close-turn-end.test.ts} (59%) create mode 100644 src/main/native-chat/agent-session-journal/journal-stop-marks.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts index 664f9c135da..dcbd00776fd 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -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) }) }) diff --git a/src/main/claude/claude-close-stop-cause.test.ts b/src/main/claude/claude-close-turn-end.test.ts similarity index 69% rename from src/main/claude/claude-close-stop-cause.test.ts rename to src/main/claude/claude-close-turn-end.test.ts index ba93c53a3d8..d115afe61a2 100644 --- a/src/main/claude/claude-close-stop-cause.test.ts +++ b/src/main/claude/claude-close-turn-end.test.ts @@ -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') diff --git a/src/main/claude/claude-journal-translator-contract.ts b/src/main/claude/claude-journal-translator-contract.ts index 24884205ac2..45568c0b8d2 100644 --- a/src/main/claude/claude-journal-translator-contract.ts +++ b/src/main/claude/claude-journal-translator-contract.ts @@ -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. */ diff --git a/src/main/claude/claude-message-journaling.ts b/src/main/claude/claude-message-journaling.ts index 9d75db1fca9..0ade48d5812 100644 --- a/src/main/claude/claude-message-journaling.ts +++ b/src/main/claude/claude-message-journaling.ts @@ -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) { diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index dc5a9c8dcf7..f6edf49d56b 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -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. */ diff --git a/src/main/claude/claude-replay-turn-resolution.ts b/src/main/claude/claude-replay-turn-resolution.ts index fe2bb1523f9..c8887b4f3f1 100644 --- a/src/main/claude/claude-replay-turn-resolution.ts +++ b/src/main/claude/claude-replay-turn-resolution.ts @@ -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 } diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts index 1fe79b62606..a4226067b98 100644 --- a/src/main/claude/claude-result-journaling.ts +++ b/src/main/claude/claude-result-journaling.ts @@ -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, diff --git a/src/main/claude/claude-result-outcome.ts b/src/main/claude/claude-result-outcome.ts index cdbf5a529bf..de3cadad338 100644 --- a/src/main/claude/claude-result-outcome.ts +++ b/src/main/claude/claude-result-outcome.ts @@ -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, - 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' } diff --git a/src/main/claude/claude-structured-child-work-captures.test.ts b/src/main/claude/claude-structured-child-work-captures.test.ts index 4ab9dd61b62..cdad53f8823 100644 --- a/src/main/claude/claude-structured-child-work-captures.test.ts +++ b/src/main/claude/claude-structured-child-work-captures.test.ts @@ -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([ diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index b8629af1f3f..b0143ace8bf 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -243,7 +243,6 @@ describe('answerClaudePrompt', () => { cancel: () => ({ accepted: true }) }, currentTurnId: null, - recordTurnStop: () => true, commandTurnId: null, beginCommand: vi.fn(), forgetCommand: vi.fn(), diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index adf4d866fc3..071cff0430c 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -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 } } diff --git a/src/main/claude/claude-structured-dispatch-admission.test.ts b/src/main/claude/claude-structured-dispatch-admission.test.ts index c4578ca38d8..a7501b39d88 100644 --- a/src/main/claude/claude-structured-dispatch-admission.test.ts +++ b/src/main/claude/claude-structured-dispatch-admission.test.ts @@ -16,7 +16,7 @@ function resolveClaudeReplayWaiter(...args: Parameters { - 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' }) }) diff --git a/src/main/claude/claude-structured-in-turn-stop.test.ts b/src/main/claude/claude-structured-in-turn-stop.test.ts index 04afb55bad6..93e8e9ee6d6 100644 --- a/src/main/claude/claude-structured-in-turn-stop.test.ts +++ b/src/main/claude/claude-structured-in-turn-stop.test.ts @@ -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): Promise<{ bodies: Map connection: FakeConnection turnId: string + /** What the host's Stop writes before the interrupt. */ + stopEvent: (turnId: string) => void }> { const bodies = new Map() + 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): 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): 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, turnId: string) { @@ -71,7 +85,7 @@ function providerRows(bodies: Map): 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) } ) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 0d09485a987..f47b71b5118 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -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, 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 }, diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index 48b462ed174..9a5dc477e9b 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -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 ) } diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts index 0da42846fe5..0fa645ea648 100644 --- a/src/main/claude/claude-structured-provider-fallback.ts +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -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, - 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() diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 92f84f6c489..404787620e9 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -145,6 +145,7 @@ export async function acquireClaudeSession({ message, ...(startsTurn ? { startsTurn: true } : {}), ...(requestedAt === null || requestedAt === undefined ? {} : { requestedAt }), + ...(turnOrigin?.clientMessageId ? { clientMessageId: turnOrigin.clientMessageId } : {}), ...observedAt }) ) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 46e1d993635..746d603308f 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -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 => + closeSession = (sessionId: string): Promise => // 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 { + private closeSessionProcess(sessionId: string): Promise { // An exit seen first settles as that exit, whoever asked for the close after it. if (this.exits.has(sessionId)) { return this.releaseAcquisition({ sessionId }) } - return this.afterClose(sessionId, () => this.closeProviderSession(sessionId, cause)) + return this.afterClose(sessionId, () => this.closeProviderSession(sessionId)) } - private closeProviderSession = ( - sessionId: string, - stopCause?: StructuredAgentSessionStopCause - ): Promise => + private closeProviderSession = (sessionId: string): Promise => closeClaudeSession({ sessionId, - ...(stopCause ? { stopCause } : {}), sessions: this.sessions, acquisitions: this.acquisitions, ...(this.deps.persistHandle ? { persistHandle: this.deps.persistHandle } : {}), diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index 1e19bf5600e..68f3dbbae1f 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -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 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 acquisitions: ClaudeAcquisitionRegistry persistHandle?: (handle: { diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 2f0a262c313..d61df04ac75 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -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. */ diff --git a/src/main/claude/claude-turn-lifecycle-item.ts b/src/main/claude/claude-turn-lifecycle-item.ts index da3af882309..1d0db5fa21f 100644 --- a/src/main/claude/claude-turn-lifecycle-item.ts +++ b/src/main/claude/claude-turn-lifecycle-item.ts @@ -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, 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 } : {}) diff --git a/src/main/claude/claude-turn-opening.ts b/src/main/claude/claude-turn-opening.ts index 1406928b37e..b5e13a39c71 100644 --- a/src/main/claude/claude-turn-opening.ts +++ b/src/main/claude/claude-turn-opening.ts @@ -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 } diff --git a/src/main/claude/claude-turn-outcome.test.ts b/src/main/claude/claude-turn-outcome.test.ts index 0dfb3080710..de88da76635 100644 --- a/src/main/claude/claude-turn-outcome.test.ts +++ b/src/main/claude/claude-turn-outcome.test.ts @@ -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['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' }) diff --git a/src/main/claude/claude-turn-ownership.test.ts b/src/main/claude/claude-turn-ownership.test.ts index f150dd362b6..7830b507a8d 100644 --- a/src/main/claude/claude-turn-ownership.test.ts +++ b/src/main/claude/claude-turn-ownership.test.ts @@ -99,7 +99,6 @@ function sessionHoldingTurn(turnId: string | null): ReturnType ({ accepted: true }) }, currentTurnId: turnId, - recordTurnStop: () => true, commandTurnId: null, beginCommand: vi.fn(), forgetCommand: vi.fn(), diff --git a/src/main/codex/codex-close-stop-cause.test.ts b/src/main/codex/codex-close-turn-end.test.ts similarity index 59% rename from src/main/codex/codex-close-stop-cause.test.ts rename to src/main/codex/codex-close-turn-end.test.ts index 69618f22dd8..1e3475301bf 100644 --- a/src/main/codex/codex-close-stop-cause.test.ts +++ b/src/main/codex/codex-close-turn-end.test.ts @@ -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') }) }) diff --git a/src/main/codex/codex-provider-retry-idle-sweep.test.ts b/src/main/codex/codex-provider-retry-idle-sweep.test.ts index 39291b11b19..0e3e8385685 100644 --- a/src/main/codex/codex-provider-retry-idle-sweep.test.ts +++ b/src/main/codex/codex-provider-retry-idle-sweep.test.ts @@ -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) }) }) diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index 7200e5bc76a..20fd8bf58df 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -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 }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 9d2202076fa..13a687a72cd 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -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 => this.sessions.get(input.identity.sessionId)?.historyPath ?? null - closeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => - this.teardown.close(sessionId, cause) + closeSession = (sessionId: string): Promise => this.teardown.close(sessionId) forceCloseSession = (sessionId: string): Promise => this.teardown.forceClose(sessionId) - disposeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => - this.teardown.close(sessionId, cause) + disposeSession = (sessionId: string): Promise => this.teardown.close(sessionId) closeAll = (): Promise => this.teardown.closeAll() releaseAcquisition = (input: { sessionId: string }): Promise => this.teardown.close(input.sessionId) diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 198f2114a65..fe6c0d67673 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -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 @@ -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, acquisitions: CodexAcquisitionRegistry, - onEvent?: (event: CodexStructuredSessionEvent) => void, - stopCause?: StructuredAgentSessionStopCause + onEvent?: (event: CodexStructuredSessionEvent) => void ): Promise { 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( diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 896d2b352af..a47cb5a78fe 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -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 diff --git a/src/main/codex/codex-structured-session-teardown.ts b/src/main/codex/codex-structured-session-teardown.ts index 05eb1e4b15e..80b06ab1c36 100644 --- a/src/main/codex/codex-structured-session-teardown.ts +++ b/src/main/codex/codex-structured-session-teardown.ts @@ -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 => { + close = async (sessionId: string): Promise => { const closed = await closeCodexSession( sessionId, this.deps.sessions, this.deps.acquisitions, - this.deps.onEvent, - cause + this.deps.onEvent ) return this.settled(sessionId, closed) } diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts index 11fb69863f2..2074e4d77c1 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts @@ -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 ) diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 46d41ecb7cf..0865df3a41f 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -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 = (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) diff --git a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts new file mode 100644 index 00000000000..e79958a952c --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts @@ -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) + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts new file mode 100644 index 00000000000..4c28a0422a6 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts @@ -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 + +/** 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 +} diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 647b64e0f00..b938efa3c5c 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -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 @@ -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: () => diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 08113e3bc7d..3de15cc3df6 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -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 /** 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 } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts index 7de5dc5033b..e22dd1bd289 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts @@ -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') } ) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 8e43cfb914f..b5d431c859a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -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 => - this.stopSession(sessionId, (adapter) => adapter.closeSession, cause) + closeSession = (sessionId: string): Promise => + this.stopSession(sessionId, (adapter) => adapter.closeSession) forceCloseSession = (sessionId: string): Promise => this.stopSession(sessionId, (adapter) => adapter.forceCloseSession ?? adapter.closeSession) - disposeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => - this.stopSession(sessionId, (adapter) => adapter.disposeSession ?? adapter.closeSession, cause) + disposeSession = (sessionId: string): Promise => + this.stopSession(sessionId, (adapter) => adapter.disposeSession ?? adapter.closeSession) private async stopSession( sessionId: string, selectStop: ( adapter: StructuredAgentSessionAdapter - ) => NonNullable | undefined, - cause?: StructuredAgentSessionStopCause + ) => NonNullable | undefined ): Promise { 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 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 91ac8a7598c..318bada1fe5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -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 + closeSession?(sessionId: string): Promise /** Stops a provider after a sink failure; the resulting exit is recovered as unexpected. */ forceCloseSession?(sessionId: string): Promise /** Stops a provider child for teardown without requiring a future-resume cursor. */ - disposeSession?(sessionId: string, cause?: StructuredAgentSessionStopCause): Promise + disposeSession?(sessionId: string): Promise /** Host acknowledgement that the proven-dead child, lease and journal owner are released. */ acknowledgeSessionRelease?(sessionId: string): void } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index 6f808baa629..77a6fa3434e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -52,58 +52,69 @@ export function mutateWithChatStop( // 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 => - 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( 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', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts new file mode 100644 index 00000000000..9e10a753f07 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts @@ -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 + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-stop-turn-end-')) + resetHostTestOperationIds() + claude = fakeClaude({ replayUuid: null }) + const lifecycle: Promise[] = [] + 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(assertion: () => T | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +function envelope( + method: 'agentSession.send' | 'agentSession.cancel', + fields: Record +) { + 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 { + 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): 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 { + 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') +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index 4b51f9cfe77..33ef8080c03 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -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 @@ -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 - // @ts-expect-error a stop that names no cause must not compile, or it would default to one - const omitted: StopArgs = [host['lifetimeContext'](), SESSION] - expect(omitted).toHaveLength(2) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index e02d605fa73..ef254fdc765 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -183,9 +183,10 @@ async function followUpUnopened(): Promise { 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." ]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index 5b88983d872..c084d2b2e07 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -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 }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index c79d53dc23f..19af6d048d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -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. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index 8c1ae5f3128..155837a01e1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -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.']) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 8ec0768cc52..39ff1c7ba51 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -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', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index 508893d67f4..60ca5d535c5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -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) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 18003e72369..47f9e03d0cc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -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) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts index 236dc7bc54b..d12a6509c74 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts @@ -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() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts index 7dde43d165d..dcc31c451d2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts @@ -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') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index fa90e015169..60c20cb768d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -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 { 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) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts new file mode 100644 index 00000000000..757a76a9059 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts @@ -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 + 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 { + 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 { + 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 + }) + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index e11e1dbd04e..eb0b3564527 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -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() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index fba7abcfdeb..7823f65b535 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -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 () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index f02a982024b..6a66836fc8a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -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 /** 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 + /** 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 /** 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. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index a544e9e1761..8a9d93c35e7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -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, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index e387d2a5e99..6d42155fa38 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -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({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index ead8d440004..54cfbb47012 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -39,7 +39,14 @@ export type QueuedMessageTestRig = Awaited 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 { await host.close(SESSION, 'evict') rotateStructuredAgentSessionHostInstanceForTests() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 0760a55c264..d5aa5e497d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -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. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index aa2c93ba15e..d1db51426c5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -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) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index a277d9a3d4c..e8dfe9f7f5e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -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 } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts index 71c5df2692d..8a9deaad48c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts @@ -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 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index d08f536daef..d7cd404e468 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -17,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 { - 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, + 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> -): 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, + 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 + journal: Pick ): 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) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts index 58a36546a04..36002750cd5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts @@ -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' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts new file mode 100644 index 00000000000..2654b20ac73 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -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 { + 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 { + rig = await createQueuedMessageTestRig({ starting: true, restartable: true }) + let release: () => void = () => undefined + rig.awaitStarted.mockImplementation( + () => new Promise((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 { + 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 { + 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 { + 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() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts new file mode 100644 index 00000000000..dd0acf82b37 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -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 { + 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 { + 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((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) => { + 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' }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts new file mode 100644 index 00000000000..4ae9f59a015 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -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 { + 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 { + 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) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 6ef96b7f98c..1f8fc80c863 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -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 { 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 { 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() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index b2ddb882207..4f4e414657d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -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 () => []) } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index 8bbec6f5588..fc771000741 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -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, { diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 1df9b08c78f..7518220a3b3 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -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, diff --git a/src/main/runtime/structured-worker-at-rest.test.ts b/src/main/runtime/structured-worker-at-rest.test.ts index 669a655b2bc..adfb181524d 100644 --- a/src/main/runtime/structured-worker-at-rest.test.ts +++ b/src/main/runtime/structured-worker-at-rest.test.ts @@ -337,7 +337,7 @@ describe('a task dispatched into a worker whose own dispatch settled', () => { await vi.waitFor(() => expect(observeStructuredWorker({ sessionId: REST_TEST_SESSION }).status).toBe('exited') ) - expect(rig.adapter.closeSession).toHaveBeenCalledWith(REST_TEST_SESSION, 'evict') + expect(rig.adapter.closeSession).toHaveBeenCalledWith(REST_TEST_SESSION) } finally { hostRef.current = null await rig.dispose() diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index 0a9b5ea5f35..20b64c7f6db 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -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') diff --git a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts index a5827937358..4968af98a93 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -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')