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')