From 486a4aa7d806a9d42b739f61b1c568dd374298d2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:39:39 -0700 Subject: [PATCH 01/16] fix(native-chat): a Stop binds only the turn it actually stopped A Stop pressed before any turn showed used to claim, at end-write time, whatever turn the stopped send later opened, even when the Stop stopped nothing. A turn that then died on its own read as "Interrupted" (your cancellation) instead of "Failed". Now a person's Stop that named no turn binds, in memory only, every turn that ends while the Stop settles, and afterwards only the turn its interrupt took. The settle ends a still-running stopped turn once. A relaunch finds nothing in memory, so an unsettled turnless Stop binds no turn. A Codex Stop whose answered turn does not open within its wait, or whose send's answer was lost, now answers refused, so the host ends the child and the turn can never run. --- src/main/claude/claude-result-journaling.ts | 4 +- ...codex-structured-conversation-stop.test.ts | 2 +- .../codex-structured-prompt-ownership.test.ts | 2 +- .../codex-structured-prompt-ownership.ts | 44 +++++-- .../codex-structured-session-cancel.test.ts | 6 +- .../codex-structured-turn-open-wait.test.ts | 41 +++++- .../codex/codex-structured-turn-open-wait.ts | 46 ++++--- .../codex/codex-structured-turn-steer.test.ts | 3 +- .../journal-stop-marks.ts | 25 +++- .../journal-stop-turn-end.test.ts | 119 +++++++++++++++++ .../journal-stop-turn-end.ts | 114 ++++++---------- .../queued-message-pause.ts | 6 +- .../structured-agent-session-adapter-stop.ts | 4 +- ...ructured-agent-session-event-sink-queue.ts | 4 +- .../structured-agent-session-event-sink.ts | 5 +- ...d-agent-session-stop-event-binding.test.ts | 94 ++++++++++--- ...d-agent-session-stop-event-restart.test.ts | 40 +++++- ...-agent-session-stop-settle-binding.test.ts | 121 +++++++++++++++++ .../structured-agent-session-turns-cancel.ts | 123 ++++++++++++++---- ...-session-codex-turn-end-settlement.test.ts | 49 ++++++- 20 files changed, 681 insertions(+), 171 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts index a4226067b98e..1473886b9eac 100644 --- a/src/main/claude/claude-result-journaling.ts +++ b/src/main/claude/claude-result-journaling.ts @@ -59,9 +59,7 @@ export function journalClaudeResult( // 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 + const leftToStop = turnId !== null && sink.journalStopDecidesTurn?.(turnId, observedAt) === true if (settlesTurn) { prompts.retryPendingCancellations() turn.suppressReopenOnFailure(message.is_error === true) diff --git a/src/main/codex/codex-structured-conversation-stop.test.ts b/src/main/codex/codex-structured-conversation-stop.test.ts index 62ecaf8f2ea1..4650603256bd 100644 --- a/src/main/codex/codex-structured-conversation-stop.test.ts +++ b/src/main/codex/codex-structured-conversation-stop.test.ts @@ -29,7 +29,7 @@ describe('a Codex Stop that names no turn', () => { const rig = await codexTurnLifecycleRig() await openedTurn(rig) - await expect(stop(rig, () => null)).resolves.toEqual({ cancelled: true }) + await expect(stop(rig, () => null)).resolves.toEqual({ cancelled: true, turnId: 'turn-1' }) expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) }) diff --git a/src/main/codex/codex-structured-prompt-ownership.test.ts b/src/main/codex/codex-structured-prompt-ownership.test.ts index ef1f8cc426f8..a0b2dc4146ed 100644 --- a/src/main/codex/codex-structured-prompt-ownership.test.ts +++ b/src/main/codex/codex-structured-prompt-ownership.test.ts @@ -509,7 +509,7 @@ describe('Codex live prompt ownership', () => { await expect( adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: true }) + ).resolves.toEqual({ cancelled: true, turnId: 'turn-1' }) expect(completionThreads(events)).toEqual([]) }) diff --git a/src/main/codex/codex-structured-prompt-ownership.ts b/src/main/codex/codex-structured-prompt-ownership.ts index effab9662bf5..efce60e6fad9 100644 --- a/src/main/codex/codex-structured-prompt-ownership.ts +++ b/src/main/codex/codex-structured-prompt-ownership.ts @@ -12,7 +12,7 @@ import { } from './codex-structured-prompt-replies' import { requireLiveCodexSession, type CodexSession } from './codex-structured-session-state' import { interruptCodexTurn } from './codex-structured-turn-cancellation' -import { codexRunningOrOpeningTurn } from './codex-structured-turn-open-wait' +import { codexStopTarget } from './codex-structured-turn-open-wait' type CancelInput = Parameters[0] type AnswerInput = Parameters[0] @@ -22,10 +22,25 @@ function providerTurnId(session: CodexSession, turnId: string): string | undefin return session.translator ? session.translator.commandProviderTurnId(turnId) : turnId } +/** A send whose answer this child lost: Codex may still open a turn for it that no Stop can name. + * A pending one Codex answered is not: its turn is the one the Stop waits for. */ +function sendInDoubt(dispatchStatus: CancelInput['dispatchStatus']): boolean { + return dispatchStatus?.state === 'unknown' && !dispatchStatus.recovered +} + +/** The Stop's own answer, naming the journal turn it stopped when it took. */ +function stoppedTurn( + outcome: AgentSessionCancelOutcome, + turnId: string +): AgentSessionCancelOutcome { + return outcome.cancelled ? { ...outcome, turnId } : outcome +} + /** * A Stop that names no turn: interrupt the turn the journal shows, or else the one Codex reported * started and not yet ended, which the journal can trail by a publish, or else the one Codex - * answered a send into, once it opens. + * answered a send into, once it opens. A turn that may still open, unanswered or not open in time, + * is one this Stop cannot reach: it answers refused, and the host ends the child. */ async function cancelCodexConversation( input: Parameters[0], @@ -34,20 +49,28 @@ async function cancelCodexConversation( const { request, sessions, requestTimeoutMs } = input const liveTurnId = request.resolveLiveTurnId?.() ?? null // A turn the journal shows that Codex has not started yet (a compaction's) has nothing to stop. - const turnId = - liveTurnId === null - ? await codexRunningOrOpeningTurn(session) - : providerTurnId(session, liveTurnId) + const target = liveTurnId === null ? await codexStopTarget(session) : { turnId: liveTurnId } // The wait for the turn to open can outlive the session it began on. if ( - !turnId || sessions.get(request.sessionId) !== session || session.ended || session.fence !== request.fence ) { return { cancelled: false } } - return interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }) + if (target === null || 'opening' in target) { + return target !== null || sendInDoubt(request.dispatchStatus) + ? { cancelled: false, refusal: {} } + : { cancelled: false } + } + const turnId = liveTurnId === null ? target.turnId : providerTurnId(session, liveTurnId) + if (!turnId) { + return { cancelled: false } + } + return stoppedTurn( + await interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }), + target.turnId + ) } export async function cancelCodexStructuredTurn(input: { @@ -67,7 +90,10 @@ export async function cancelCodexStructuredTurn(input: { return { cancelled: false } } if (!prompt) { - return interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }) + return stoppedTurn( + await interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }), + requestedTurnId + ) } if (session.fence !== request.fence) { return { cancelled: false } diff --git a/src/main/codex/codex-structured-session-cancel.test.ts b/src/main/codex/codex-structured-session-cancel.test.ts index cd30bfa2e6d1..939d5faf0cdb 100644 --- a/src/main/codex/codex-structured-session-cancel.test.ts +++ b/src/main/codex/codex-structured-session-cancel.test.ts @@ -143,7 +143,7 @@ describe('CodexStructuredSessionAdapter.cancelTurn', () => { await expect( adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: true }) + ).resolves.toEqual({ cancelled: true, turnId: 'turn-1' }) expect(codex.connections[0].calls.at(-1)).toEqual({ method: 'turn/interrupt', params: { threadId: THREAD_ID, turnId: 'turn-1' } @@ -288,7 +288,7 @@ describe('a Codex Stop is the interrupt alone', () => { params: expect.objectContaining({ turn: { id: 'turn-1', status: 'interrupted' } }) }) ) - await expect(stopped).resolves.toEqual({ cancelled: true }) + await expect(stopped).resolves.toEqual({ cancelled: true, turnId: 'turn-1' }) }) it('reads and kills no processes, from the send through the Stop', async () => { @@ -299,7 +299,7 @@ describe('a Codex Stop is the interrupt alone', () => { for (const work of Object.values(processWork)) { expect(work).not.toHaveBeenCalled() } - await expect(stopped).resolves.toEqual({ cancelled: true }) + await expect(stopped).resolves.toEqual({ cancelled: true, turnId: 'turn-1' }) for (const work of Object.values(processWork)) { expect(work).not.toHaveBeenCalled() } diff --git a/src/main/codex/codex-structured-turn-open-wait.test.ts b/src/main/codex/codex-structured-turn-open-wait.test.ts index c86a39fb59ef..44f1a5269289 100644 --- a/src/main/codex/codex-structured-turn-open-wait.test.ts +++ b/src/main/codex/codex-structured-turn-open-wait.test.ts @@ -57,7 +57,7 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi rig.turns.start() - expect(await settledWithin(stopping)).toEqual({ cancelled: true }) + expect(await settledWithin(stopping)).toEqual({ cancelled: true, turnId: 'turn-1' }) expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) expect(rig.turns.turnId).toBeNull() }) @@ -71,7 +71,7 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi release() await sending - expect(await settledWithin(stop(rig))).toEqual({ cancelled: true }) + expect(await settledWithin(stop(rig))).toEqual({ cancelled: true, turnId: 'turn-1' }) }) it('does not wait for a send Codex steered into the running turn', async () => { @@ -80,7 +80,7 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi rig.turns.start() expect(await settledWithin(rig.send('client-2'))).toEqual(ADMITTED) - expect(await settledWithin(stop(rig))).toEqual({ cancelled: true }) + expect(await settledWithin(stop(rig))).toEqual({ cancelled: true, turnId: 'turn-1' }) expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) }) @@ -115,7 +115,7 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi expect(await settledWithin(stopping)).toBe('held') rig.turns.start() - expect(await settledWithin(stopping)).toEqual({ cancelled: true }) + expect(await settledWithin(stopping)).toEqual({ cancelled: true, turnId: 'turn-1' }) }) it('stops nothing when the child exits', async () => { @@ -128,7 +128,8 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi expect(rig.interrupts()).toEqual([]) }) - it('stops nothing once its bound runs out', async () => { + // The turn may still open and run: the host ends the child (`performCancel`). + it('answers refused once its bound runs out', async () => { const rig = await codexTurnLifecycleRig() await answeredColdSend(rig) vi.useFakeTimers() @@ -141,7 +142,35 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi expect(outcome).toBe('held') await vi.advanceTimersByTimeAsync(1) - expect(outcome).toEqual(REFUSED) + expect(outcome).toEqual({ cancelled: false, refusal: {} }) expect(rig.interrupts()).toEqual([]) }) }) + +describe('a no-turn Stop with no turn Codex answered', () => { + it('answers refused when the answer to the latest send was lost: Codex may still open its turn', async () => { + const rig = await codexTurnLifecycleRig() + + const outcome = await rig.adapter.cancelTurn({ + sessionId: 'session-1', + fence: 7, + dispatchStatus: { state: 'unknown', recovered: false } + }) + + expect(outcome).toEqual({ cancelled: false, refusal: {} }) + expect(rig.interrupts()).toEqual([]) + }) + + it('stops nothing when nothing was sent, or the lost answer outlived its child', async () => { + const rig = await codexTurnLifecycleRig() + + expect(await stop(rig)).toEqual(REFUSED) + expect( + await rig.adapter.cancelTurn({ + sessionId: 'session-1', + fence: 7, + dispatchStatus: { state: 'unknown', recovered: true } + }) + ).toEqual(REFUSED) + }) +}) diff --git a/src/main/codex/codex-structured-turn-open-wait.ts b/src/main/codex/codex-structured-turn-open-wait.ts index 9f30b292ff0c..a6e1d50a46ed 100644 --- a/src/main/codex/codex-structured-turn-open-wait.ts +++ b/src/main/codex/codex-structured-turn-open-wait.ts @@ -14,8 +14,8 @@ import { export const CODEX_TURN_OPEN_WAIT_MS = 5_000 export type CodexTurnOpenWaits = { - /** Resolves once `turnId` opens or can no longer, and after `withinMs` at the latest. */ - wait: (turnId: string, withinMs: number) => Promise + /** Resolves once `turnId` opens or can no longer (true), or after `withinMs` (false). */ + wait: (turnId: string, withinMs: number) => Promise /** Ends the waits a notification on the session's own thread answers. */ observe: (threadId: string, method: string, params: unknown) => void /** Ends every wait: the child that would open their turns is gone. */ @@ -23,23 +23,23 @@ export type CodexTurnOpenWaits = { } export function createCodexTurnOpenWaits(): CodexTurnOpenWaits { - const waits = new Map<() => void, string>() + const waits = new Map<(answered: boolean) => void, string>() const release = (turnId?: string): void => { for (const [endWait, waitedTurnId] of waits) { if (turnId === undefined || waitedTurnId === turnId) { - endWait() + endWait(true) } } } return { wait: (turnId, withinMs) => - new Promise((resolve) => { - const endWait = (): void => { + new Promise((resolve) => { + const endWait = (answered: boolean): void => { clearTimeout(bound) waits.delete(endWait) - resolve() + resolve(answered) } - const bound = setTimeout(endWait, withinMs) + const bound = setTimeout(() => endWait(false), withinMs) // A Stop's wait must never be what keeps the process alive at quit. bound.unref?.() waits.set(endWait, turnId) @@ -61,21 +61,25 @@ export function createCodexTurnOpenWaits(): CodexTurnOpenWaits { } } +/** What a Stop or a send finds to act on: a turn running, or one Codex answered a send into that + * has not opened in time and may still open; null when neither. */ +export type CodexStopTarget = { turnId: string } | { opening: string } | null + /** * The turn a Stop or a send names: the latest Codex reported started and not ended, or else the - * one Codex answered a send into, once it opens. Null when none is running and that one ends, the - * thread stops running or the child exits first, or the wait runs out; each such turn is waited - * for once. + * one Codex answered a send into, once it opens. `opening` when the wait runs out first. Null when + * none is running and that one ends, the thread stops running or the child exits first; each such + * turn is waited for once. */ -export async function codexRunningOrOpeningTurn(session: { +export async function codexStopTarget(session: { threadId: string activeTurnIds?: ReadonlySet dispatchEchoes: Pick turnOpenWaits: Pick -}): Promise { +}): Promise { const running = [...(session.activeTurnIds ?? [])].at(-1) if (running) { - return running + return { turnId: running } } const answered = session.dispatchEchoes.answeredUnopenedTurn( session.threadId, @@ -85,12 +89,20 @@ export async function codexRunningOrOpeningTurn(session: { return null } // Codex refuses an interrupt, and before 0.148 a steer, until it opens the turn. - await session.turnOpenWaits.wait(answered, CODEX_TURN_OPEN_WAIT_MS) + const settled = await session.turnOpenWaits.wait(answered, CODEX_TURN_OPEN_WAIT_MS) if (session.activeTurnIds?.has(answered)) { - return answered + return { turnId: answered } } // Before 0.148 a turn that fails before it starts reports no end; one that opens later is still // found running. session.dispatchEchoes.leftUnopened(session.threadId, answered) - return null + return settled ? null : { opening: answered } +} + +/** `codexStopTarget`'s running or opened turn, for a send. */ +export async function codexRunningOrOpeningTurn( + session: Parameters[0] +): Promise { + const target = await codexStopTarget(session) + return target !== null && 'turnId' in target ? target.turnId : null } diff --git a/src/main/codex/codex-structured-turn-steer.test.ts b/src/main/codex/codex-structured-turn-steer.test.ts index c1b982709b30..20d009ed0cc7 100644 --- a/src/main/codex/codex-structured-turn-steer.test.ts +++ b/src/main/codex/codex-structured-turn-steer.test.ts @@ -223,7 +223,8 @@ describe('a Codex send made after Codex answered an earlier one, before it opene expect(await rig.sending).toEqual({ state: 'admitted' }) expect(rig.methods()).toEqual(['thread/start', 'turn/start', 'turn/steer']) expect(await rig.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).toEqual({ - cancelled: true + cancelled: true, + turnId: 'turn-1' }) expect(rig.settlements.map(({ clientMessageId }) => clientMessageId).sort()).toEqual([ 'client-1', 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 index e79958a952c7..baba17c0058d 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts @@ -1,12 +1,15 @@ // 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. +// the turn-end rule reads (`journal-stop-turn-end.ts`), whether a person's still decides, and what +// a person's Stop that named no turn binds while and after it settles. import type { JournalReducerState } from './journal-reducer' import { + beginJournalStopSettle, latestAcceptedSendUnopened, personStopDecidesTurn, type JournalLatestStop } from './journal-stop-turn-end' +import type { JournalStopSettle } from './queued-message-pause' export class JournalStopMarks { constructor(private readonly deps: { state: () => JournalReducerState }) {} @@ -21,7 +24,23 @@ export class JournalStopMarks { } /** `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) + personStopDecides(turnId: string | null, endedAt?: number): boolean { + return personStopDecidesTurn(this.deps.state(), turnId, endedAt) + } + + /** `beginJournalStopSettle`: a turn that ends from here until `settled` is the Stop's. */ + beginSettle(): JournalStopSettle | null { + return beginJournalStopSettle(this.deps.state()) + } + + /** Closes a settle `beginSettle` opened, binding `turnId` when the Stop stopped one. */ + settled(settle: JournalStopSettle | null, turnId?: string): void { + if (!settle) { + return + } + settle.settling = false + if (turnId !== undefined) { + settle.turnId = turnId + } } } diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts new file mode 100644 index 000000000000..8c7bc0466cfe --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts @@ -0,0 +1,119 @@ +// The turn-end rule as a Stop that named no turn binds it: every turn that ends while the Stop +// settles, then only the turn it stopped. A cancellation already written stays one. + +import { describe, expect, it } from 'vitest' +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { createJournalReducerState, type JournalReducerState } from './journal-reducer' +import type { JournalStopEvent } from './journal-row-schema' +import { + beginJournalStopSettle, + personStopDecidesTurn, + turnEndAfterStop +} from './journal-stop-turn-end' +import { JournalStopMarks } from './journal-stop-marks' + +const STOPPED_AT = 1_000 + +function stateWith(event?: JournalStopEvent): JournalReducerState { + const state = createJournalReducerState('session-1', 'epoch-1') + state.queuePauseMarks.latestStop = event ? { sequence: 5, event } : null + return state +} + +type TurnBody = Extract + +function ended(turnId: string): TurnBody { + return { kind: 'turn', turnId, state: 'interrupted', completedAt: STOPPED_AT + 10 } +} + +function withTurn(state: JournalReducerState, body: AgentJournalItemBody): void { + state.items.set('turn-item', { + itemId: 'turn-item', + revision: 1, + body, + sequence: 3, + observedAt: STOPPED_AT + }) +} + +const turnless: JournalStopEvent = { reason: 'user-stop', at: STOPPED_AT } + +describe("a person's Stop that named no turn", () => { + it('makes any turn that ends while it settles theirs', () => { + const state = stateWith(turnless) + const marks = new JournalStopMarks({ state: () => state }) + marks.beginSettle() + + expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).toMatchObject({ + outcome: 'cancellation' + }) + expect(personStopDecidesTurn(state, null)).toBe(true) + }) + + it('once settled, binds only the turn it stopped', () => { + const state = stateWith(turnless) + const marks = new JournalStopMarks({ state: () => state }) + marks.settled(marks.beginSettle(), 'turn-a') + + expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).toMatchObject({ + outcome: 'cancellation' + }) + expect(turnEndAfterStop(state, 'other-item', ended('turn-b'))).not.toHaveProperty('outcome') + expect(personStopDecidesTurn(state, null)).toBe(false) + expect(personStopDecidesTurn(state, 'turn-a')).toBe(true) + }) + + it('binds nothing once it settled having stopped nothing', () => { + const state = stateWith(turnless) + const marks = new JournalStopMarks({ state: () => state }) + marks.settled(marks.beginSettle()) + + expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).not.toHaveProperty('outcome') + }) + + it('binds nothing before it settles: a relaunch folds the event alone', () => { + const state = stateWith(turnless) + + expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).not.toHaveProperty('outcome') + expect(personStopDecidesTurn(state, null)).toBe(false) + }) + + it('keeps the turn an earlier press bound when pressed again', () => { + const state = stateWith(turnless) + const marks = new JournalStopMarks({ state: () => state }) + marks.settled(marks.beginSettle(), 'turn-a') + marks.settled(marks.beginSettle()) + + expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).toMatchObject({ + outcome: 'cancellation' + }) + }) +}) + +describe('a Stop with nothing in memory to bind', () => { + it.each([ + ['one that named its turn', { reason: 'user-stop', turnId: 'turn-a', at: STOPPED_AT }], + ['a host stop', { reason: 'host-stop', at: STOPPED_AT }] + ] satisfies [string, JournalStopEvent][])('opens no settle: %s', (_label, event) => { + expect(beginJournalStopSettle(stateWith(event))).toBeNull() + }) +}) + +describe('an end already written as a cancellation', () => { + it('stays one when a later end with no verdict is written over it', () => { + const state = stateWith() + withTurn(state, { ...ended('turn-a'), outcome: 'cancellation' }) + + expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).toMatchObject({ + outcome: 'cancellation' + }) + }) + + it("gives way to the provider's own verdict", () => { + const state = stateWith() + withTurn(state, { ...ended('turn-a'), outcome: 'cancellation' }) + const failed: TurnBody = { ...ended('turn-a'), outcome: 'failure' } + + expect(turnEndAfterStop(state, 'turn-item', failed)).toEqual(failed) + }) +}) 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 index 4c28a0422a6d..b0f1f8665e47 100644 --- 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 @@ -1,11 +1,11 @@ -// What a Stop decides about the turn it named: the one rule every turn-end write passes through. +// What a Stop decides about the turn it stopped: 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. +// A turn a person's Stop or close of this chat named, or, when it named none, one that ended while +// that Stop settled or that its settle bound, 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: the adapter's +// end, the host's fallback and a relaunch's settle all write through it, and every client folds +// the row it wrote. What an unnamed Stop binds is held in memory, so after a relaunch it binds none. import { agentJournalSubmissionKey, @@ -16,15 +16,14 @@ import type { 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' +import type { JournalQueuePauseMarks, JournalStopSettle } 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 { +export function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { switch (reason) { case 'user-stop': case 'user-close': @@ -40,7 +39,8 @@ type TurnEndState = Pick< 'items' | 'queuePauseMarks' | 'submissions' | 'aliases' > -/** The send whose journal item `userItemId` (a turn's opener, by its own or its provider key) is. */ +/** The send whose journal item `userItemId` (a turn's opener, by its own or its provider key) is. + * Only says whether a send's turn has opened; it never decides whose a turn's end is. */ function openingSubmission( state: TurnEndState, userItemId: string | undefined @@ -83,72 +83,36 @@ export function latestAcceptedSendUnopened(state: TurnEndState): boolean { 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 { + * none and the turn ends while it settles, or its settle bound that turn. */ +function stopIsTurnCancellation(stop: JournalLatestStop, turnId: string): 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) + return stop.settle?.settling === true || stop.settle?.turnId === turnId } -/** 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. */ +/** THE rule: whether the latest Stop makes turn `turnId`, 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) && + stopIsTurnCancellation(stop, turnId) && (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 + * Whether a person's Stop decides the end of turn `turnId` (null: the work 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. @@ -156,29 +120,36 @@ function unansweredSendsAreStopTargets(state: TurnEndState, stop: JournalLatestS 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 + endedAt?: number ): 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) + return stop.event.turnId === undefined && stop.settle?.settling === true } - 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) + return stopEndsTurnAsCancellation(state, turnId, endedAt) +} + +/** Opens the settle of the latest Stop, a person's that named no turn: until it closes, every turn + * that ends is that Stop's. Null when there is none, or it named its turn. */ +export function beginJournalStopSettle( + state: Pick +): JournalStopSettle | null { + const stop = state.queuePauseMarks.latestStop + if (stop === null || stop.event.turnId !== undefined || !stopIsAPersons(stop.event.reason)) { + return null + } + // A repeat press keeps the turn an earlier one bound. + stop.settle = { settling: true, ...(stop.settle?.turnId ? { turnId: stop.settle.turnId } : {}) } + return stop.settle } /** * 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. + * earlier than the latest Stop event, a person's that named it or bound it, while it was still open + * (running, or unproven). A provider's own verdict always stands. */ export function turnEndAfterStop( state: TurnEndState, @@ -189,12 +160,13 @@ export function turnEndAfterStop( return body } const previous = readAgentJournalTurn(state.items.get(itemId)?.body) - // An end already written stands: the Stop came after it. + // An end already written stands: the Stop came after it. A cancellation written stays one. if (previous && previous.state !== 'running' && previous.state !== 'unverifiable') { - return body + return previous.state === 'interrupted' && previous.outcome === 'cancellation' + ? { ...body, outcome: 'cancellation' } + : body } - const userItemId = body.userItemId ?? previous?.userItemId - return stopEndsTurnAsCancellation(state, body.turnId, userItemId, body.completedAt) + return stopEndsTurnAsCancellation(state, body.turnId, body.completedAt) ? { ...body, outcome: 'cancellation' } : body } diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index 9039760ba43c..63dc6823df28 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -15,9 +15,13 @@ import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' +/** What a person's Stop that named no turn binds, held in memory and never read from a row, so a + * reopen binds nothing: while it settles, every turn that ends; once settled, the turn it stopped. */ +export type JournalStopSettle = { settling: boolean; turnId?: string } + /** The latest Stop event, whatever its reason, and the latest Resume row, folded by the reducer. */ export type JournalQueuePauseMarks = { - latestStop: { sequence: number; event: JournalStopEvent } | null + latestStop: { sequence: number; event: JournalStopEvent; settle?: JournalStopSettle } | null /** 0 when none. */ resumedSequence: number } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts index 4709234ad5c6..95ae77a43c29 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts @@ -10,10 +10,12 @@ import type { ProviderDiagnostic } from '../../../shared/agent-session-failure' /** `refusal`: the provider answered the Stop and declined it, in its own words when it gave any. * `turnNotRunning`: its refusal is the kind it gives for a turn not running there, so the Stop keeps - * the child; absent, it could not interrupt that turn, which may run on. */ + * the child; absent, it could not interrupt that turn, which may run on. `turnId`: the journal + * turn a Stop that took interrupted, which the Stop then binds. */ export type AgentSessionCancelOutcome = { cancelled: boolean refusal?: { detail?: ProviderDiagnostic; turnNotRunning?: true } + turnId?: string } /** Where a card's Cancel goes: a dismissal (`dismissPrompt`), or the chat's Stop. */ 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 60ca5d535c53..5ce3de7d0e5f 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,8 +68,8 @@ 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 + journalStopDecidesTurn = (turnId: string, endedAt: number): boolean => + this.target?.journal.stopMarks.personStopDecides(turnId, endedAt) ?? false bindReadingControl(control: StructuredAgentSessionReadingControl): () => void { this.readingControl = control 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 47f9e03d0cc4..70d1f3f85d3f 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 @@ -137,9 +137,8 @@ export type StructuredAgentSessionEventSink = { /** 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 + * of its own, a person's cancellation (`personStopDecidesTurn`); false until bound. */ + journalStopDecidesTurn?(turnId: string, endedAt: number): boolean appendLifecycleBatch?( settlementId: string, mutations: readonly JournalLifecycleMutationInput[], 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 index 2654b20ac730..019d31c63597 100644 --- 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 @@ -1,7 +1,7 @@ -// 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. +// Which turn a person's Stop that named no turn binds: one that ends while the Stop settles, and +// the turn its interrupt took. A Stop that stopped nothing binds no turn that opens after it, nor +// does a Stop of a start that never landed; a card it held, which Resume releases, and anything +// sent after it end as their own. 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' @@ -63,6 +63,18 @@ async function laterTurn() { .find((turn) => turn?.turnId === 'turn-later') } +/** Every end row written for the later turn, in order: a relabel would show as two. */ +function laterTurnEndRows() { + 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) => { + const turn = row.kind === 'item' ? readAgentJournalTurn(row.body) : undefined + return turn?.turnId === 'turn-later' && turn.state !== 'running' ? [turn] : [] + }) +} + /** 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( @@ -185,19 +197,22 @@ describe('a Stop of a start that never landed binds no later turn', () => { }) }) -describe('a Stop pressed before its send opened a turn binds only that turn', () => { +describe('a Stop pressed before its send opened a turn binds only the turn it stopped', () => { /** The send the Stop stopped is handed over and unopened; a card waits behind it. */ - async function stopBeforeTheTurnShowed(): Promise<{ stopped: string; held: string }> { + async function stopBeforeTheTurnShowed( + answer: Awaited> = { cancelled: true } + ): Promise<{ stopped: string; held: string }> { rig = await createQueuedMessageTestRig() const stopped = await rig.workingSend() const held = await queuedDraft('queued behind the turn') + rig.cancelTurn.mockResolvedValueOnce(answer) 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() + it('binds the turn its interrupt took, whose end lands after the Stop answered', async () => { + const { stopped } = await stopBeforeTheTurnShowed({ cancelled: true, turnId: 'turn-later' }) await rig.settleAccepted(stopped, 'stopped') await turnOpenedBy(stopped, 'interrupted') @@ -205,6 +220,34 @@ describe('a Stop pressed before its send opened a turn binds only that turn', () expect(await laterTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) }) + // The interrupt reaches the turn as it opens; its end is written before the Stop answers. + it('reads a turn that ends while the Stop settles as interrupted from its first end row', async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + rig.cancelTurn.mockImplementationOnce(async () => { + await turnOpenedBy(stopped) + await turnOpenedBy(stopped, 'interrupted') + return { cancelled: true, turnId: 'turn-later' } + }) + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + expect(laterTurnEndRows()).toEqual([ + expect.objectContaining({ state: 'interrupted', outcome: 'cancellation' }) + ]) + expect(journal().activeTurnId()).toBeNull() + }) + + it('binds no turn that opens after a Stop that stopped nothing', async () => { + const { stopped } = await stopBeforeTheTurnShowed({ cancelled: false }) + await rig.settleAccepted(stopped, 'stopped') + + await turnOpenedBy(stopped) + await turnOpenedBy(stopped, 'interrupted') + + await expectNews() + }) + 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') @@ -229,25 +272,42 @@ describe('a Stop pressed before its send opened a turn binds only that turn', () }) }) +describe("a Stop's settle that ends the turn its interrupt took", () => { + it("ends a turn still running once the stream drains, as the Stop's, once", async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + await rig.settleAccepted(stopped, 'stopped') + await turnOpenedBy(stopped) + rig.cancelTurn.mockResolvedValueOnce({ cancelled: true, turnId: 'turn-later' }) + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + expect(await laterTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(journal().activeTurnId()).toBeNull() + }) +}) + 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 () => { + it('defers to that Stop while it settles, and not after', async () => { rig = await createQueuedMessageTestRig() await rig.workingSend() + let whileSettling: boolean | undefined + rig.cancelTurn.mockImplementationOnce(async () => { + whileSettling = journal().stopMarks.personStopDecides(null) + return { cancelled: true } + }) + expect(await rig.stop()).toMatchObject({ ok: true }) - expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') - expect(await evictedAt()).toEqual(['user-stop']) + expect(whileSettling).toBe(true) + expect(journal().stopMarks.personStopDecides(null)).toBe(false) }) - it("writes the host's event when a send after the Stop is unanswered beside the stopped one", async () => { + it("writes the host's event for work still unanswered once that Stop settled", 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(journal().stopMarks.personStopDecides(null)).toBe(false) expect(await evictedAt()).toEqual(['user-stop', 'evict']) }) 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 index 4ae9f59a015a..55d840315ed9 100644 --- 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 @@ -1,7 +1,7 @@ -// 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. +// A Stop that took effect still decides the turn it named 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, one a Stop never named, and one a +// turnless Stop was still settling when Orca died, still read as the news they are. import { afterEach, describe, expect, it } from 'vitest' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' @@ -272,10 +272,11 @@ describe('a restart between a Stop and its turn end', () => { 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. + // A Stop pressed before any turn showed stopped the turn its interrupt took, 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() + rig.cancelTurn.mockResolvedValueOnce({ cancelled: true, turnId: TURN }) expect(await rig.stop()).toMatchObject({ ok: true }) expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined() await rig.settleAccepted(stopped, 'stopped') @@ -322,6 +323,35 @@ describe('a restart between a Stop and its turn end', () => { expect(settled(NEXT_TURN).turn).not.toHaveProperty('outcome') }) + // What a turnless Stop binds is held in memory: a relaunch finds it unsettled, and it binds none. + it('reads the turn a turnless Stop was settling when Orca died as no Stop of its', async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + // Orca dies while the interrupt is out: the Stop never settles. + rig.cancelTurn.mockImplementationOnce(async () => { + await journal().appendItem( + CODEX_TURN, + { + kind: 'turn', + turnId: TURN, + state: 'running', + startedAt: Date.now(), + userItemId: agentJournalSubmissionKey(stopped) + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return new Promise(() => undefined) + }) + void rig.stop() + await expect.poll(() => journal().activeTurnId()).toBe(TURN) + expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined() + + await restartAndSettle('exit-observed') + + expect(settled(TURN).turn).toMatchObject({ state: 'interrupted' }) + expect(settled(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) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts new file mode 100644 index 000000000000..3950d95916b6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts @@ -0,0 +1,121 @@ +// A Stop pressed before its send's turn showed binds the turn that send opens only by stopping it: +// a Codex turn that did not open in time ends with the child the Stop ends, and a Claude turn that +// opens before the Stop's child end is proven ends as the Stop's. Nothing reads as running after. + +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 { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +afterEach(() => rig.dispose()) + +const OPENED: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-opened', + 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 +} + +/** The turn `clientMessageId` opens, running, as the provider writes it. */ +async function turnOpens(clientMessageId: string): Promise { + await journal().appendItem( + OPENED, + { + kind: 'turn', + turnId: 'turn-opened', + state: 'running', + startedAt: Date.now(), + userItemId: agentJournalSubmissionKey(clientMessageId) + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +function openedTurn() { + return journal() + .snapshot() + .items.map((item) => readAgentJournalTurn(item.body)) + .find((turn) => turn?.turnId === 'turn-opened') +} + +function nothingRuns(): boolean { + return !isStructuredAgentSessionMainAgentWorking( + journal().activeTurnId(), + journal().submissions() + ) +} + +/** Drains the Stop's next step on the session's lane: a wind-down it handed the child's end to. */ +async function windDown(): Promise { + await rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) +} + +describe('a Codex Stop whose answered turn did not open in time', () => { + // Codex admitted the send, answered into a turn it has not opened; the Stop's wait ran out. + async function stopOfAnsweredSend(): Promise { + rig = await createQueuedMessageTestRig() + const sent = await rig.workingSend() + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: {} }) + return sent + } + + it('ends the child, so the turn can never run', async () => { + await stopOfAnsweredSend() + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + expect(rig.closeSession).toHaveBeenCalled() + expect(openedTurn()).toBeUndefined() + expect(nothingRuns()).toBe(true) + }) + + it('reads a turn that opened before the child end as interrupted by the Stop', async () => { + const sent = await stopOfAnsweredSend() + rig.closeSession.mockImplementationOnce(async () => { + await turnOpens(sent) + return true + }) + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + expect(openedTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(nothingRuns()).toBe(true) + }) +}) + +describe('a Claude Stop pressed before its send echoed', () => { + it('reads the turn that opens before its child end is proven as interrupted by the Stop', async () => { + rig = await createQueuedMessageTestRig({ stopEndsSession: true }) + const sent = await rig.workingSend() + rig.closeSession.mockImplementationOnce(async () => { + await turnOpens(sent) + return true + }) + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await windDown() + + expect(rig.closeSession).toHaveBeenCalled() + expect(openedTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(nothingRuns()).toBe(true) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index 48500afa12f3..d96a6c085bbd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -3,6 +3,7 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { AgentJournalStatusItem } from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionCancelResult } from '../../../shared/agent-session-wire' import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation' import type { AgentSessionCancelOutcome } from './structured-agent-session-adapter' @@ -22,6 +23,7 @@ import { structuredAgentSessionStoppedTurnId } from './structured-agent-session-turn-stop-notes' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { runningTurnLifecycleRevisions } from './structured-agent-session-stale-turn-verdict' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' /** Claude's echo accepts a send one sink write before its turn row lands, so read after the drain. @@ -74,8 +76,13 @@ function stopRefusedNote( /** What a Stop that ends the provider's session leaves its next serialized step: whether the * provider took the interrupt, so its wind-down is worth waiting on, and when the interrupt went - * out. No turn id: that step runs right behind the Stop, so no later turn can slip in between. */ -export type StructuredAgentSessionStopWindDown = { waitsForProvider: boolean; stoppedAt: number } + * out. No turn id: that step runs right behind the Stop, so no later turn can slip in between. + * `settled` closes the Stop's settle once the child's end is proven: until then, what ends is its. */ +export type StructuredAgentSessionStopWindDown = { + waitsForProvider: boolean + stoppedAt: number + settled?: () => void +} /** * A session-ending Stop's second step, queued behind its first in the same tick so nothing sent @@ -96,34 +103,45 @@ export async function endStoppedStructuredAgentSession( await stopChild() } catch (error) { onError(error) + } finally { + windDown.settled?.() + } +} + +/** A turn the Stop's interrupt took that still reads running once the stream drains: the Stop ends + * it, once, while it still binds it. Bookkeeping: a failure is reported, never the Stop's. */ +async function endStoppedTurnAtSettle(ctx: AgentSessionTurnContext, turnId: string): Promise { + try { + await ctx.flushStreamedEvents() + const running = ctx.journal + .snapshot() + .items.filter((item) => readAgentJournalTurn(item.body)?.turnId === turnId) + const mutations = runningTurnLifecycleRevisions(running, { + state: 'interrupted', + completedAt: ctx.now() + }) + if (mutations.length > 0) { + await ctx.journal.appendLifecycleBatch({ + settlementId: `stop-settled:${turnId}`, + mutations, + fence: ctx.fence + }) + } + } catch (error) { + ctx.logger.warn("ending a stopped turn at its Stop's settle failed", { + scope: 'stop-settle', + sessionId: ctx.sessionId, + error + }) } } +/** What the Stop's settle binds: the turn it stopped, and whether its wind-down closes it. */ +type StopSettleBinding = { turnId?: string; closedByWindDown?: true } + export async function performCancel( ctx: AgentSessionTurnContext, - input: { - clientOperationId: string - /** Absent: whatever the conversation has in flight; present: only while that turn is current. */ - turnId?: string - scope?: 'background-tasks' - taskId?: string - prompt?: { itemId: string; expectedRevision: number } - /** Ends the provider child, for a running command the provider did not take the Stop on, or a - * turn whose interrupt failed. */ - stopChild?: () => Promise - /** A child end after a failed interrupt that threw. */ - onStopChildError?: (error: unknown) => void - /** After that throw: whether the host let go of the child, its exit proven before a later - * cleanup step failed. */ - childReleased?: () => boolean - /** Hands the child's end to the Stop's next serialized step, for a provider whose Stop ends - * its session. */ - endSession?: (windDown: StructuredAgentSessionStopWindDown) => void - /** The host already withdrew queued messages for this Stop. */ - withdrewQueued?: boolean - /** The session's child records: a background Stop reaches the tasks they offer a stop. */ - childWork?: () => readonly AgentChildWorkView[] | undefined - } + input: PerformCancelInput ): Promise> { if (input.prompt) { const validated = validatePendingPrompt(ctx, input.prompt) @@ -131,6 +149,49 @@ export async function performCancel( return answerCancelOfSettledPrompt(ctx, { ...input, prompt: input.prompt }, validated) } } + // A person's Stop that named no turn binds what ends while it settles (`beginJournalStopSettle`). + const settle = input.scope ? null : ctx.journal.stopMarks.beginSettle() + const binding: StopSettleBinding = {} + const close = (): void => ctx.journal.stopMarks.settled(settle, binding.turnId) + try { + return await cancelAndNote(ctx, input, binding, close) + } finally { + if (!binding.closedByWindDown) { + close() + } + } +} + +type PerformCancelInput = { + clientOperationId: string + /** Absent: whatever the conversation has in flight; present: only while that turn is current. */ + turnId?: string + scope?: 'background-tasks' + taskId?: string + prompt?: { itemId: string; expectedRevision: number } + /** Ends the provider child, for a running command the provider did not take the Stop on, or a + * turn whose interrupt failed. */ + stopChild?: () => Promise + /** A child end after a failed interrupt that threw. */ + onStopChildError?: (error: unknown) => void + /** After that throw: whether the host let go of the child, its exit proven before a later + * cleanup step failed. */ + childReleased?: () => boolean + /** Hands the child's end to the Stop's next serialized step, for a provider whose Stop ends + * its session. */ + endSession?: (windDown: StructuredAgentSessionStopWindDown) => void + /** The host already withdrew queued messages for this Stop. */ + withdrewQueued?: boolean + /** The session's child records: a background Stop reaches the tasks they offer a stop. */ + childWork?: () => readonly AgentChildWorkView[] | undefined +} + +async function cancelAndNote( + ctx: AgentSessionTurnContext, + input: PerformCancelInput, + binding: StopSettleBinding, + closeSettle: () => void +): Promise> { let cancelled = false let note: AgentJournalStatusItem | null = { kind: 'status', @@ -164,6 +225,8 @@ export async function performCancel( // The provider could not interrupt the turn, or its cancel threw: the turn may run on. let interruptFailed = false let refusal: AgentSessionCancelOutcome['refusal'] + // The turn the provider says this Stop's interrupt took. + let stoppedTurn: string | undefined try { const dispatchStatus = latestJournalDispatchObservation(ctx.journal, ctx.fence) const outcome: AgentSessionCancelOutcome = stoppedBefore @@ -191,6 +254,7 @@ export async function performCancel( taken = outcome.cancelled cancelled = outcome.cancelled refusal = outcome.refusal + stoppedTurn = outcome.turnId interruptFailed = refusal !== undefined && refusal.turnNotRunning !== true if (!cancelled && input.withdrewQueued && !(await isMainAgentWorkingOnceFlushed(ctx))) { // A Stop that withdrew what was queued and left nothing working ended what it was sent for, @@ -219,7 +283,8 @@ export async function performCancel( if (endsSession && (!namesTurnNotLive || taken !== false)) { // An interrupt the provider took is worth waiting on, turn row or not: a Stop before the echo // has none, and the echo still opens the turn the Stop interrupted. - input.endSession?.({ waitsForProvider: taken === true, stoppedAt }) + binding.closedByWindDown = true + input.endSession?.({ waitsForProvider: taken === true, stoppedAt, settled: closeSettle }) cancelled = true // The child's end confirms the Stop, so a refused or unconfirmed interrupt says nothing more. if (note !== null) { @@ -260,6 +325,12 @@ export async function performCancel( if (cancelled && input.prompt) { await ctx.flushStreamedEvents() } + if (cancelled) { + binding.turnId = stoppedTurn ?? stoppedTurnId ?? undefined + } + if (taken === true && stoppedTurn !== undefined && !binding.closedByWindDown && !input.scope) { + await endStoppedTurnAtSettle(ctx, stoppedTurn) + } const value = { ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), cancelled } if (input.scope || note === null) { return { ok: true, value } diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 36ab233a9159..103eebed8858 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -22,6 +22,7 @@ import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-sessio import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, @@ -506,7 +507,8 @@ describe('a Stop in that window that the turn never opens for', () => { expect(await settledWithin(stopping, 0)).not.toBe('held') expect(childCloses).toBe(1) expect(interrupts).toBe(0) - expect(await statusRows()).toContain('Codex had no turn running to stop.') + // Its wait ran out with the turn still able to open, so the Stop ended the child. + expect(await statusRows()).toContain('Cancellation requested.') }) it('lets the app quit behind it within the eviction budget, and still close the child', async () => { @@ -519,6 +521,51 @@ describe('a Stop in that window that the turn never opens for', () => { }) }) +describe("a Stop pressed while Codex's turn/start is in flight", () => { + function turnRow( + items: Awaited>['items'] + ) { + return items + .map((item) => readAgentJournalTurn(item.body)) + .find((turn) => turn?.turnId === 'turn-1') + } + + it("interrupts the turn that opens, which reads as the Stop's, and nothing reads as running", async () => { + const release = turns.holdNextAnswer() + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + // Queued behind the send's handover on the session's lane, so it runs after Codex answers. + const stopping = stop() + release() + await vi.waitFor(() => expect(openWaits.turnIds).toContain('turn-1')) + turns.start() + await stopping + + expect(interrupts).toBe(1) + const after = await settled() + expect(turnRow((await host.journalSnapshot(SESSION)).items)).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) + expect(after.owesWork).toBe(false) + }) + + it('ends the child when the turn has not opened by the end of its wait', async () => { + const release = turns.holdNextAnswer() + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + const stopping = stop() + release() + + expect(await settledWithin(stopping, CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') + + expect(interrupts).toBe(0) + expect(childCloses).toBe(1) + expect((await settled()).owesWork).toBe(false) + expect(turnRow((await host.journalSnapshot(SESSION)).items)).toBeUndefined() + }) +}) + describe('a cold send with no Stop behind it', () => { /** Well inside one eviction step's budget, and far inside the bound a Stop waits. */ const PROMPTLY_MS = 1_000 From 4d5e13e1c19a9d797879bfc164f397975f522a2c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:33:47 -0700 Subject: [PATCH 02/16] fix(native-chat): keep the person's queue pause and close binding after a Stop settles A host stop or eviction with no turn running now defers to a person's Stop while its queue pause still holds with nothing sent since, read from rows, so a held card is not handed off on reopen after a Stop that did nothing or whose kill failed. A person's close that named no turn opens a settle around its child's end, so a turn that end cuts reads as theirs. A press opens its settle only when the latest Stop event is its own or the one in force it repeats: a late Stop, a card's interrupt or a lost event row reopens no earlier Stop. A Codex Stop that cannot reach a turn still able to open says the Stop is unconfirmed rather than that no turn ran, and a second Stop still reaches a turn an earlier wait left unopened. Also drops the unused openedBy plumbing and the unreachable "a written cancellation stays one" rule, and pins a relaunch after a named Stop. --- src/main/claude/claude-message-journaling.ts | 5 +- src/main/claude/claude-open-turn.ts | 5 -- .../claude-structured-journal-translation.ts | 9 +-- src/main/claude/claude-turn-lifecycle-item.ts | 2 - src/main/claude/claude-turn-opening.ts | 5 +- .../codex/codex-structured-dispatch-echo.ts | 31 +++++--- .../codex-structured-prompt-ownership.ts | 2 +- .../codex-structured-turn-open-wait.test.ts | 9 ++- .../codex/codex-structured-turn-open-wait.ts | 18 +++-- .../journal-stop-turn-end.test.ts | 31 +------- .../journal-stop-turn-end.ts | 8 +- .../structured-agent-session-adapter-stop.ts | 7 +- .../structured-agent-session-chat-stop.ts | 11 ++- .../structured-agent-session-host-lifetime.ts | 5 +- ...tructured-agent-session-host-stop-event.ts | 34 +++++++-- .../structured-agent-session-queued-stop.ts | 46 +++++++---- ...d-agent-session-stop-event-binding.test.ts | 76 ++++++++++++++++++- ...d-agent-session-stop-event-restart.test.ts | 20 +++++ ...-agent-session-stop-settle-binding.test.ts | 37 ++++++++- .../structured-agent-session-turns-cancel.ts | 18 +++-- 20 files changed, 261 insertions(+), 118 deletions(-) diff --git a/src/main/claude/claude-message-journaling.ts b/src/main/claude/claude-message-journaling.ts index 0ade48d58125..9d75db1fca9f 100644 --- a/src/main/claude/claude-message-journaling.ts +++ b/src/main/claude/claude-message-journaling.ts @@ -63,9 +63,7 @@ export function journalClaudeMessage( startsTurn: boolean, observedAt: number, /** Host clock on the submission that produced this send, when known. */ - requestedAt?: number, - /** The submission this send echo acknowledged. */ - openedBy?: string + requestedAt?: number ): boolean { const envelope = readClaudeMessageEnvelope(message) if (!envelope) { @@ -178,7 +176,6 @@ 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 f6edf49d56b2..f946e4ee9cfb 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -60,11 +60,6 @@ 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 } diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index f47b71b51180..c036b7d67405 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -175,10 +175,8 @@ export function createClaudeJournalTranslator( message: Record, startsTurn: boolean, observedAt: number, - requestedAt?: number, - openedBy?: string - ): boolean => - journalClaudeMessage(messageContext, message, startsTurn, observedAt, requestedAt, openedBy) + requestedAt?: number + ): boolean => journalClaudeMessage(messageContext, message, startsTurn, observedAt, requestedAt) return { handle: (event) => { @@ -246,8 +244,7 @@ export function createClaudeJournalTranslator( event.message, event.startsTurn === true, event.observedAt ?? Date.now(), - event.requestedAt, - event.clientMessageId + event.requestedAt ) ) { providerFallback.append( diff --git a/src/main/claude/claude-turn-lifecycle-item.ts b/src/main/claude/claude-turn-lifecycle-item.ts index 1d0db5fa21f4..e816be7f2b29 100644 --- a/src/main/claude/claude-turn-lifecycle-item.ts +++ b/src/main/claude/claude-turn-lifecycle-item.ts @@ -19,8 +19,6 @@ 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 } diff --git a/src/main/claude/claude-turn-opening.ts b/src/main/claude/claude-turn-opening.ts index b5e13a39c71c..1406928b37ed 100644 --- a/src/main/claude/claude-turn-opening.ts +++ b/src/main/claude/claude-turn-opening.ts @@ -30,8 +30,6 @@ 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. */ @@ -48,8 +46,7 @@ export function claudeTurnOpenedBySendEcho( turnId: envelope.uuid, startedAt: input.observedAt, ...(input.requestedAt === undefined ? {} : { requestedAt: input.requestedAt }), - userItemId: input.userItemId, - ...(input.openedBy === undefined ? {} : { openedBy: input.openedBy }) + userItemId: input.userItemId } : null } diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts index 775cac5bca4f..1eee3bc9a0a7 100644 --- a/src/main/codex/codex-structured-dispatch-echo.ts +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -44,6 +44,9 @@ export type CodexDispatchEchoes = { answeredUnopenedTurn: (threadId: string, openTurnIds: ReadonlySet) => string | null /** Codex did not open this answered turn within a wait, so no later wait is spent on it. */ leftUnopened: (threadId: string, turnId: string) => void + /** The latest armed send's answered turn a wait left unopened, neither open nor ended: Codex may + * still open it, though no wait is spent on it again. */ + answeredTurnLeftUnopened: (threadId: string, openTurnIds: ReadonlySet) => string | null /** * Records a turn's end and returns the sends bound to it that it settles: all of them unless it * completed, which echoes its pending input first, so one it never echoed waits for recovery. An @@ -68,6 +71,19 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { let nextSequence = 0 const turnKey = (threadId: string, turnId: string): string => JSON.stringify([threadId, turnId]) const settles = (end: CodexTurnEnd): boolean => end.status !== 'completed' + const answeredTurn = ( + threadId: string, + openTurnIds: ReadonlySet, + admits: (key: string) => boolean + ): string | null => { + const answered = [...armed.values()].flatMap(({ turn }) => { + const key = turn?.threadId === threadId ? turnKey(threadId, turn.turnId) : null + return turn && key && !openTurnIds.has(turn.turnId) && !endedTurns.has(key) && admits(key) + ? [turn.turnId] + : [] + }) + return answered.at(-1) ?? null + } return { arm(clientMessageId, requestedAt) { const existing = armed.get(clientMessageId) @@ -97,17 +113,10 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { } return end }, - answeredUnopenedTurn: (threadId, openTurnIds) => { - const answered = [...armed.values()].flatMap(({ turn }) => - turn?.threadId === threadId && - !openTurnIds.has(turn.turnId) && - !endedTurns.has(turnKey(threadId, turn.turnId)) && - !unopenedTurns.has(turnKey(threadId, turn.turnId)) - ? [turn.turnId] - : [] - ) - return answered.at(-1) ?? null - }, + answeredUnopenedTurn: (threadId, openTurnIds) => + answeredTurn(threadId, openTurnIds, (key) => !unopenedTurns.has(key)), + answeredTurnLeftUnopened: (threadId, openTurnIds) => + answeredTurn(threadId, openTurnIds, (key) => unopenedTurns.has(key)), leftUnopened: (threadId, turnId) => { unopenedTurns.add(turnKey(threadId, turnId)) for (const oldest of unopenedTurns) { diff --git a/src/main/codex/codex-structured-prompt-ownership.ts b/src/main/codex/codex-structured-prompt-ownership.ts index efce60e6fad9..813da8f01b6a 100644 --- a/src/main/codex/codex-structured-prompt-ownership.ts +++ b/src/main/codex/codex-structured-prompt-ownership.ts @@ -60,7 +60,7 @@ async function cancelCodexConversation( } if (target === null || 'opening' in target) { return target !== null || sendInDoubt(request.dispatchStatus) - ? { cancelled: false, refusal: {} } + ? { cancelled: false, refusal: { turnMayOpen: true } } : { cancelled: false } } const turnId = liveTurnId === null ? target.turnId : providerTurnId(session, liveTurnId) diff --git a/src/main/codex/codex-structured-turn-open-wait.test.ts b/src/main/codex/codex-structured-turn-open-wait.test.ts index 44f1a5269289..7fa3af292eee 100644 --- a/src/main/codex/codex-structured-turn-open-wait.test.ts +++ b/src/main/codex/codex-structured-turn-open-wait.test.ts @@ -142,8 +142,13 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi expect(outcome).toBe('held') await vi.advanceTimersByTimeAsync(1) - expect(outcome).toEqual({ cancelled: false, refusal: {} }) + expect(outcome).toEqual({ cancelled: false, refusal: { turnMayOpen: true } }) expect(rig.interrupts()).toEqual([]) + // A second Stop spends no wait on that turn, and still finds it able to open. + expect(await settledWithin(stop(rig), 0)).toEqual({ + cancelled: false, + refusal: { turnMayOpen: true } + }) }) }) @@ -157,7 +162,7 @@ describe('a no-turn Stop with no turn Codex answered', () => { dispatchStatus: { state: 'unknown', recovered: false } }) - expect(outcome).toEqual({ cancelled: false, refusal: {} }) + expect(outcome).toEqual({ cancelled: false, refusal: { turnMayOpen: true } }) expect(rig.interrupts()).toEqual([]) }) diff --git a/src/main/codex/codex-structured-turn-open-wait.ts b/src/main/codex/codex-structured-turn-open-wait.ts index a6e1d50a46ed..b5a3e183f4c6 100644 --- a/src/main/codex/codex-structured-turn-open-wait.ts +++ b/src/main/codex/codex-structured-turn-open-wait.ts @@ -67,14 +67,17 @@ export type CodexStopTarget = { turnId: string } | { opening: string } | null /** * The turn a Stop or a send names: the latest Codex reported started and not ended, or else the - * one Codex answered a send into, once it opens. `opening` when the wait runs out first. Null when - * none is running and that one ends, the thread stops running or the child exits first; each such - * turn is waited for once. + * one Codex answered a send into, once it opens. `opening` when the wait runs out first, or an + * earlier wait already gave up on it with its send still owed. Null when none is running and that + * one ends, the thread stops running or the child exits first; each such turn is waited for once. */ export async function codexStopTarget(session: { threadId: string activeTurnIds?: ReadonlySet - dispatchEchoes: Pick + dispatchEchoes: Pick< + CodexDispatchEchoes, + 'answeredUnopenedTurn' | 'leftUnopened' | 'answeredTurnLeftUnopened' + > turnOpenWaits: Pick }): Promise { const running = [...(session.activeTurnIds ?? [])].at(-1) @@ -86,7 +89,12 @@ export async function codexStopTarget(session: { session.activeTurnIds ?? new Set() ) if (!answered) { - return null + // An earlier wait gave up on it, yet its send is still owed: it may still open. + const left = session.dispatchEchoes.answeredTurnLeftUnopened( + session.threadId, + session.activeTurnIds ?? new Set() + ) + return left ? { opening: left } : null } // Codex refuses an interrupt, and before 0.148 a steer, until it opens the turn. const settled = await session.turnOpenWaits.wait(answered, CODEX_TURN_OPEN_WAIT_MS) diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts index 8c7bc0466cfe..59ab1bbb990f 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts @@ -1,5 +1,5 @@ // The turn-end rule as a Stop that named no turn binds it: every turn that ends while the Stop -// settles, then only the turn it stopped. A cancellation already written stays one. +// settles, then only the turn it stopped. import { describe, expect, it } from 'vitest' import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' @@ -26,16 +26,6 @@ function ended(turnId: string): TurnBody { return { kind: 'turn', turnId, state: 'interrupted', completedAt: STOPPED_AT + 10 } } -function withTurn(state: JournalReducerState, body: AgentJournalItemBody): void { - state.items.set('turn-item', { - itemId: 'turn-item', - revision: 1, - body, - sequence: 3, - observedAt: STOPPED_AT - }) -} - const turnless: JournalStopEvent = { reason: 'user-stop', at: STOPPED_AT } describe("a person's Stop that named no turn", () => { @@ -98,22 +88,3 @@ describe('a Stop with nothing in memory to bind', () => { expect(beginJournalStopSettle(stateWith(event))).toBeNull() }) }) - -describe('an end already written as a cancellation', () => { - it('stays one when a later end with no verdict is written over it', () => { - const state = stateWith() - withTurn(state, { ...ended('turn-a'), outcome: 'cancellation' }) - - expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).toMatchObject({ - outcome: 'cancellation' - }) - }) - - it("gives way to the provider's own verdict", () => { - const state = stateWith() - withTurn(state, { ...ended('turn-a'), outcome: 'cancellation' }) - const failed: TurnBody = { ...ended('turn-a'), outcome: 'failure' } - - expect(turnEndAfterStop(state, 'turn-item', failed)).toEqual(failed) - }) -}) 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 index b0f1f8665e47..6b71477f74e4 100644 --- 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 @@ -23,7 +23,7 @@ import type { JournalQueuePauseMarks, JournalStopSettle } from './queued-message export type JournalLatestStop = NonNullable /** Only a person's own Stop, or their close of this chat, makes a cut turn their cancellation. */ -export function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { +function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { switch (reason) { case 'user-stop': case 'user-close': @@ -160,11 +160,9 @@ export function turnEndAfterStop( return body } const previous = readAgentJournalTurn(state.items.get(itemId)?.body) - // An end already written stands: the Stop came after it. A cancellation written stays one. + // An end already written stands: the Stop came after it. if (previous && previous.state !== 'running' && previous.state !== 'unverifiable') { - return previous.state === 'interrupted' && previous.outcome === 'cancellation' - ? { ...body, outcome: 'cancellation' } - : body + return body } return stopEndsTurnAsCancellation(state, body.turnId, body.completedAt) ? { ...body, outcome: 'cancellation' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts index 95ae77a43c29..a6b12b4f4fd1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts @@ -10,11 +10,12 @@ import type { ProviderDiagnostic } from '../../../shared/agent-session-failure' /** `refusal`: the provider answered the Stop and declined it, in its own words when it gave any. * `turnNotRunning`: its refusal is the kind it gives for a turn not running there, so the Stop keeps - * the child; absent, it could not interrupt that turn, which may run on. `turnId`: the journal - * turn a Stop that took interrupted, which the Stop then binds. */ + * the child; absent, it could not interrupt that turn, which may run on. `turnMayOpen`: no turn + * was there to interrupt, but one a send is owed may still open. `turnId`: the journal turn a Stop + * that took interrupted, which the Stop then binds. */ export type AgentSessionCancelOutcome = { cancelled: boolean - refusal?: { detail?: ProviderDiagnostic; turnNotRunning?: true } + refusal?: { detail?: ProviderDiagnostic; turnNotRunning?: true; turnMayOpen?: true } turnId?: string } 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 77a6fa3434e0..a0ef74c6b418 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 @@ -90,9 +90,11 @@ export function mutateWithChatStop( 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() - } + const reach = + withdrawn.length > 0 ? 'unrecorded' : await stopReachesUnrecordedWork(ctx, turnId) + // Its settle binds the latest Stop only when that Stop is this press's own, or the one in + // force this press repeats; a late Stop or a lost event row binds nothing. + const ownsLatestStop = reach === 'unrecorded' ? await tookEffect() : reach === 'repeat' return performCancel( { ...ctx, failureTextContext: structuredAgentSessionFailureWordsContext(record) }, { @@ -110,7 +112,8 @@ export function mutateWithChatStop( endSession: (owed) => { windDown = owed }, - withdrewQueued: withdrawn.length > 0 + withdrewQueued: withdrawn.length > 0, + ...(ownsLatestStop ? { opensSettle: true as const } : {}) } ) } 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 60c20cb768d9..2c312ef17e84 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 @@ -140,7 +140,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( // 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() + : Promise.resolve(null) // 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. @@ -233,6 +233,9 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( session.owesProviderChildWindDown = { ...owed, failedAt: session.journal.cursor() } } throw error + } finally { + // A person's close binds what its child's end cut; done, proven or not, it binds no more. + void recorded.then((settle) => session.journal.stopMarks.settled(settle)) } } 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 index 757a76a90590..70cfd07bb7a6 100644 --- 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 @@ -7,6 +7,8 @@ import type { StructuredAgentSessionStopCause } from './structured-agent-session 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' +import type { JournalStopSettle } from '../agent-session-journal/queued-message-pause' +import { sentSinceStop } from './structured-agent-session-queued-stop' /** 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 @@ -63,23 +65,37 @@ export async function stopEndsWork( 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' || !defersToPersonsStop(session)) +} + +/** A host stop of work a person's Stop is already ending must not supersede that Stop's reason: + * the turn it decides, or, with no turn running, the work under the queue pause it still holds, + * with nothing sent since, which a host's event would lift. */ +function defersToPersonsStop(session: StructuredAgentSessionHostSession): boolean { + const { journal } = session + const live = journal.activeTurnId() + if (live !== null) { + return journal.stopMarks.personStopDecides(live) + } + const inForce = journal.queuedMessages.userStopInForce() return ( - working && - (ending.cause === 'user-close' || !journal.stopMarks.personStopDecides(journal.activeTurnId())) + (inForce !== null && !sentSinceStop(journal, inForce)) || + journal.stopMarks.personStopDecides(null) ) } /** Writes this stop's event (`JournalStopEvent`). Issued before the kill and never awaited by it: - * bookkeeping, reported on failure. */ + * bookkeeping, reported on failure. A person's close that named no turn opens its settle once its + * event lands, as a person's Stop does, so a turn the child's end cuts is theirs; the caller + * closes it once that end is done. */ export function recordStopEvent( context: StructuredAgentSessionLifetimeContext, sessionId: string, session: StructuredAgentSessionHostSession, ending: StructuredAgentSessionStopEnding -): Promise { +): Promise { if ('recorded' in ending) { - return Promise.resolve() + return Promise.resolve(null) } const turnId = session.journal.activeTurnId() return session.journal @@ -88,12 +104,14 @@ export function recordStopEvent( structuredAgentSessionConversationFence(context.deps.store, sessionId) ) .then( - () => undefined, - (error: unknown) => + () => (ending.cause === 'user-close' ? session.journal.stopMarks.beginSettle() : null), + (error: unknown) => { context.deps.logger.warn("a host stop's Stop event row skipped", { scope: 'stop-event', sessionId, error }) + return null + } ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts index 316793751c55..dea812e50641 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts @@ -39,48 +39,63 @@ export async function runRecordedStop( ctx: AgentSessionTurnContext, /** `turnId` absent: the turn running when the Stop takes effect, if any. */ event: Omit, - stop: (tookEffect: () => Promise) => Promise> + /** `tookEffect` resolves whether the Stop's event landed. */ + stop: (tookEffect: () => Promise) => Promise> ): Promise> { - const skipped = (error: unknown): void => report(ctx, 'event row', error) + const skipped = (error: unknown): false => { + report(ctx, 'event row', error) + return false + } return stop(() => { try { const turnId = structuredAgentSessionStoppedTurnId(ctx.journal, event.turnId) ?? undefined return ctx.journal .appendStopEvent({ ...event, ...(turnId ? { turnId } : {}) }, ctx.fence) - .then(() => undefined, skipped) + .then(() => true, skipped) } catch (error) { // A throw before the append is queued is reported too: the Stop still interrupts. - skipped(error) - return Promise.resolve() + return Promise.resolve(skipped(error)) } }) } /** - * Whether a Stop reaching a running agent stops anything no Stop event records yet. Not when it - * names a turn already over (a late Stop from a phone), nor when it repeats the Stop still in force - * with nothing sent since, on the same turn or one that opened after a Stop pressed before any - * turn showed: a card queued between the presses then sends normally, as after one Stop. + * What a Stop reaching a running agent stops: work no Stop event records yet (`unrecorded`), or + * only what the Stop still in force already records, which it repeats with nothing sent since, on + * the same turn or one that opened after a Stop pressed before any turn showed (`repeat`): a card + * queued between the presses then sends normally, as after one Stop. `late`: it names a turn + * already over, as a late Stop from a phone can. */ export async function stopReachesUnrecordedWork( ctx: Pick, namedTurnId: string | undefined -): Promise { +): Promise<'unrecorded' | 'repeat' | 'late'> { const live = ctx.journal.activeTurnId() // No turn published yet while the agent works: the named one may still be opening. if ( structuredAgentSessionStopNamesTurnNotLive(namedTurnId, live) && (live !== null || !(await isMainAgentWorkingOnceFlushed(ctx))) ) { - return false + return 'late' } const inForce = ctx.journal.queuedMessages.userStopInForce() if (inForce === null) { - return true + return 'unrecorded' } - // Sent after that Stop and not refused, even if its fate is unknown: this interrupt may send it - // back to waiting, so this Stop must hold it. A send with no sequence is an older host's. - const sentSince = ctx.journal + // This interrupt may send a later send back to waiting, so this Stop must hold it. + return sentSinceStop(ctx.journal, inForce) || + structuredAgentSessionStopNamesTurnNotLive(inForce.event.turnId, live) + ? 'unrecorded' + : 'repeat' +} + +/** Whether anything was sent after the Stop in force and not refused, even if its fate is + * unknown. A send with no sequence is an older host's. */ +export function sentSinceStop( + journal: Pick, + inForce: { sequence: number } +): boolean { + return journal .submissions() .some( (entry) => @@ -88,7 +103,6 @@ export async function stopReachesUnrecordedWork( entry.acceptedSequence !== undefined && entry.acceptedSequence > inForce.sequence ) - return sentSince || structuredAgentSessionStopNamesTurnNotLive(inForce.event.turnId, live) } function report(ctx: AgentSessionTurnContext, step: string, error: unknown): void { 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 index 019d31c63597..169c925f9bc8 100644 --- 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 @@ -12,10 +12,11 @@ import { 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 { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' import { createQueuedMessageTestRig, eventually, + QUEUED_RIG_CALLER, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' @@ -272,6 +273,38 @@ describe('a Stop pressed before its send opened a turn binds only the turn it st }) }) +describe('a press that writes no Stop event of its own', () => { + // A turnless Stop long since settled, then a later turn the person's own send opened. + async function laterTurnAfterAnEarlierStop(): Promise { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + const sent = await rig.workingSend() + await rig.settleAccepted(sent, 'sent') + await turnOpenedBy(sent) + return sent + } + + it('reopens no earlier Stop: a late Stop naming a turn already over binds no turn', async () => { + const sent = await laterTurnAfterAnEarlierStop() + const earlier = journal().stopMarks.latest() + rig.cancelTurn.mockImplementationOnce(async () => { + await turnOpenedBy(sent, 'interrupted') + return { cancelled: false } + }) + + const late = await rig.host.cancel(QUEUED_RIG_CALLER, { + envelope: rig.envelope({ turnId: 'turn-gone' }, 'agentSession.cancel', hostTestOperationId()), + turnId: 'turn-gone' + }) + + expect(late).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()).toBe(earlier) + await expectNews() + }) +}) + describe("a Stop's settle that ends the turn its interrupt took", () => { it("ends a turn still running once the stream drains, as the Stop's, once", async () => { rig = await createQueuedMessageTestRig() @@ -303,11 +336,48 @@ describe('a host stop with no turn running after a Stop that named none', () => expect(journal().stopMarks.personStopDecides(null)).toBe(false) }) - it("writes the host's event for work still unanswered once that Stop settled", async () => { + it.each([ + ['stopped nothing', { cancelled: false }], + ['took', { cancelled: true }] + ] as const)( + 'writes nothing while the Stop, which %s, still pauses the queue, and the card stays held', + async (_, answer) => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + const card = await queuedDraft('queued behind the send') + rig.cancelTurn.mockResolvedValueOnce(answer) + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + + expect(await evictedAt()).toEqual(['user-stop']) + expect(await rig.queuePause()).toMatchObject({ reason: 'stopped' }) + expect(await rig.handoff(card)).toBeUndefined() + } + ) + + // Codex could not reach a turn still able to open, and its process could not be ended. + it('writes nothing after a Stop whose kill failed, and the card stays held', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + const card = await queuedDraft('queued behind the send') + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnMayOpen: true } }) + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + + expect(await evictedAt()).toEqual(['user-stop']) + expect(await rig.queuePause()).toMatchObject({ reason: 'stopped' }) + expect(await rig.handoff(card)).toBeUndefined() + }) + + 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 }) - expect(journal().stopMarks.personStopDecides(null)).toBe(false) + 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']) }) 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 index 55d840315ed9..f427ee420177 100644 --- 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 @@ -138,6 +138,26 @@ describe('a restart between a Stop and its turn end', () => { expect(errorRows).toEqual([]) }) + // A Stop naming its turn, as the phone sends one: its event names the turn, which is what a + // relaunch reads, so it still counts though Orca died with its interrupt out. + it.each([ + ['Claude', CLAUDE_TURN], + ['Codex', CODEX_TURN] + ])('reads Interrupted after N for a %s Stop that named its turn', async (_label, identity) => { + await runningTurn(identity) + rig.cancelTurn.mockImplementationOnce(() => new Promise(() => undefined)) + void rig.host.cancel(QUEUED_RIG_CALLER, { + envelope: rig.envelope({ turnId: TURN }, 'agentSession.cancel', hostTestOperationId()), + turnId: TURN + }) + await expect.poll(() => journal().stopMarks.latest()?.event.turnId).toBe(TURN) + + await restartAndSettle() + + expect(settled().turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(settled().label).toMatch(/^Interrupted after /) + }) + 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. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts index 3950d95916b6..2247471072b0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts @@ -74,7 +74,7 @@ describe('a Codex Stop whose answered turn did not open in time', () => { async function stopOfAnsweredSend(): Promise { rig = await createQueuedMessageTestRig() const sent = await rig.workingSend() - rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: {} }) + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnMayOpen: true } }) return sent } @@ -102,6 +102,22 @@ describe('a Codex Stop whose answered turn did not open in time', () => { }) }) +describe('a Codex Stop that could not reach a turn still able to open, whose kill failed', () => { + it('says the Stop is unconfirmed, never that no turn was running', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnMayOpen: true } }) + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + + const rows = journal() + .snapshot() + .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.failure?.kind] : [])) + expect(rows).toEqual(['cancelUnconfirmed']) + }) +}) + describe('a Claude Stop pressed before its send echoed', () => { it('reads the turn that opens before its child end is proven as interrupted by the Stop', async () => { rig = await createQueuedMessageTestRig({ stopEndsSession: true }) @@ -119,3 +135,22 @@ describe('a Claude Stop pressed before its send echoed', () => { expect(nothingRuns()).toBe(true) }) }) + +describe("a person's close pressed before its send's turn showed", () => { + it('reads the turn its child end cut as interrupted by the person', async () => { + rig = await createQueuedMessageTestRig() + const sent = await rig.workingSend() + rig.closeSession.mockImplementationOnce(async () => { + await turnOpens(sent) + return true + }) + + await rig.host.close(HOST_TEST_SESSION, 'user-close') + + const { items } = await rig.host.journalSnapshot(HOST_TEST_SESSION) + const turn = items + .map((item) => readAgentJournalTurn(item.body)) + .find((entry) => entry?.turnId === 'turn-opened') + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index d96a6c085bbd..256edb420f21 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -59,25 +59,27 @@ async function stillRunsStoppedTurn( return stoppedTurnId === null || ctx.journal.activeTurnId() === stoppedTurnId } -/** The row for a Stop the provider declined, in its words when it gave any. */ +/** The row for a Stop the provider declined, in its words when it gave any. One that could not + * reach a turn still able to open says the Stop is unconfirmed, never that nothing ran. */ function stopRefusedNote( ctx: Pick, refusal: AgentSessionCancelOutcome['refusal'] ): AgentJournalStatusItem { const detail = refusal?.detail + const fact = refusal?.turnMayOpen + ? agentSessionFailureFact('cancelUnconfirmed') + : agentSessionFailureFact('stopRefused', detail ? { detail } : {}) return { kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('stopRefused', detail ? { detail } : {}), { - ...ctx.failureTextContext, - surface: 'row' - }) + ...agentSessionFailureWords(fact, { ...ctx.failureTextContext, surface: 'row' }) } } /** What a Stop that ends the provider's session leaves its next serialized step: whether the * provider took the interrupt, so its wind-down is worth waiting on, and when the interrupt went * out. No turn id: that step runs right behind the Stop, so no later turn can slip in between. - * `settled` closes the Stop's settle once the child's end is proven: until then, what ends is its. */ + * `settled` closes the Stop's settle once the wind-down finishes, whether or not the child's exit + * was proven: until then, what ends is the Stop's. */ export type StructuredAgentSessionStopWindDown = { waitsForProvider: boolean stoppedAt: number @@ -150,7 +152,7 @@ export async function performCancel( } } // A person's Stop that named no turn binds what ends while it settles (`beginJournalStopSettle`). - const settle = input.scope ? null : ctx.journal.stopMarks.beginSettle() + const settle = input.opensSettle ? ctx.journal.stopMarks.beginSettle() : null const binding: StopSettleBinding = {} const close = (): void => ctx.journal.stopMarks.settled(settle, binding.turnId) try { @@ -184,6 +186,8 @@ type PerformCancelInput = { withdrewQueued?: boolean /** The session's child records: a background Stop reaches the tasks they offer a stop. */ childWork?: () => readonly AgentChildWorkView[] | undefined + /** The latest Stop event is this press's own, or the in-force one it repeats: its settle binds. */ + opensSettle?: true } async function cancelAndNote( From 9f6140439efa1922a071dad104b73860acddeea0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:50:05 -0700 Subject: [PATCH 03/16] fix(native-chat): a Codex Stop agrees on both presses when a turn is still owed, and pin the close's settle A Codex Stop that waited for a turn Codex answered a send into now answers "may still open" whenever that turn neither opened nor ended and its send is still owed, however the wait ended (it ran out, or the thread went idle). Before, a first press after an idle thread said no turn was running and kept Codex, while an identical second press ended it. Adds a test that a person's close the conversation outlives (as /clear does) closes its settle, so a later turn that ends on its own reads as a failure. --- .../codex-structured-turn-open-wait.test.ts | 7 ++- .../codex/codex-structured-turn-open-wait.ts | 46 +++++++++++-------- ...-agent-session-stop-settle-binding.test.ts | 41 +++++++++++++++++ 3 files changed, 72 insertions(+), 22 deletions(-) diff --git a/src/main/codex/codex-structured-turn-open-wait.test.ts b/src/main/codex/codex-structured-turn-open-wait.test.ts index 7fa3af292eee..9a2e20eb1e80 100644 --- a/src/main/codex/codex-structured-turn-open-wait.test.ts +++ b/src/main/codex/codex-structured-turn-open-wait.test.ts @@ -15,6 +15,7 @@ type Rig = Awaited> const ADMITTED = { state: 'admitted' } const REFUSED = { cancelled: false } +const MAY_OPEN = { cancelled: false, refusal: { turnMayOpen: true } } const stop = (rig: Rig) => rig.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) @@ -94,15 +95,17 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi expect(rig.interrupts()).toEqual([]) }) + // The turn neither opened nor ended, and its send is still owed: no press calls that nothing. it.each(['idle', 'systemError'])( - 'stops nothing when Codex reports the thread %s', + 'answers refused, on the first press and on a second alike, when Codex reports the thread %s', async (type) => { const rig = await codexTurnLifecycleRig() const { stopping } = await waitingStop(rig) rig.notify('thread/status/changed', { threadId: CODEX_TEST_THREAD_ID, status: { type } }) - expect(await settledWithin(stopping)).toEqual(REFUSED) + expect(await settledWithin(stopping)).toEqual(MAY_OPEN) + expect(await settledWithin(stop(rig), 0)).toEqual(MAY_OPEN) expect(rig.interrupts()).toEqual([]) } ) diff --git a/src/main/codex/codex-structured-turn-open-wait.ts b/src/main/codex/codex-structured-turn-open-wait.ts index b5a3e183f4c6..3f43e9c787d4 100644 --- a/src/main/codex/codex-structured-turn-open-wait.ts +++ b/src/main/codex/codex-structured-turn-open-wait.ts @@ -14,8 +14,8 @@ import { export const CODEX_TURN_OPEN_WAIT_MS = 5_000 export type CodexTurnOpenWaits = { - /** Resolves once `turnId` opens or can no longer (true), or after `withinMs` (false). */ - wait: (turnId: string, withinMs: number) => Promise + /** Resolves once `turnId` opens or can no longer, and after `withinMs` at the latest. */ + wait: (turnId: string, withinMs: number) => Promise /** Ends the waits a notification on the session's own thread answers. */ observe: (threadId: string, method: string, params: unknown) => void /** Ends every wait: the child that would open their turns is gone. */ @@ -23,23 +23,23 @@ export type CodexTurnOpenWaits = { } export function createCodexTurnOpenWaits(): CodexTurnOpenWaits { - const waits = new Map<(answered: boolean) => void, string>() + const waits = new Map<() => void, string>() const release = (turnId?: string): void => { for (const [endWait, waitedTurnId] of waits) { if (turnId === undefined || waitedTurnId === turnId) { - endWait(true) + endWait() } } } return { wait: (turnId, withinMs) => - new Promise((resolve) => { - const endWait = (answered: boolean): void => { + new Promise((resolve) => { + const endWait = (): void => { clearTimeout(bound) waits.delete(endWait) - resolve(answered) + resolve() } - const bound = setTimeout(() => endWait(false), withinMs) + const bound = setTimeout(endWait, withinMs) // A Stop's wait must never be what keeps the process alive at quit. bound.unref?.() waits.set(endWait, turnId) @@ -62,14 +62,15 @@ export function createCodexTurnOpenWaits(): CodexTurnOpenWaits { } /** What a Stop or a send finds to act on: a turn running, or one Codex answered a send into that - * has not opened in time and may still open; null when neither. */ + * has not opened and has not ended, so may still open while its send is owed; null when neither. */ export type CodexStopTarget = { turnId: string } | { opening: string } | null /** * The turn a Stop or a send names: the latest Codex reported started and not ended, or else the - * one Codex answered a send into, once it opens. `opening` when the wait runs out first, or an - * earlier wait already gave up on it with its send still owed. Null when none is running and that - * one ends, the thread stops running or the child exits first; each such turn is waited for once. + * one Codex answered a send into, once it opens. `opening` when it has not opened by the end of + * the wait, however the wait ended (it ran out, or the thread stopped running), while it has not + * ended and its send is still owed: nothing to stop holds only when nothing is owed. Null when + * none is running and none is owed. Each such turn is waited for once. */ export async function codexStopTarget(session: { threadId: string @@ -89,22 +90,27 @@ export async function codexStopTarget(session: { session.activeTurnIds ?? new Set() ) if (!answered) { - // An earlier wait gave up on it, yet its send is still owed: it may still open. - const left = session.dispatchEchoes.answeredTurnLeftUnopened( - session.threadId, - session.activeTurnIds ?? new Set() - ) - return left ? { opening: left } : null + // An earlier wait left it unopened, yet its send is still owed: it may still open. + return leftUnopenedTarget(session) } // Codex refuses an interrupt, and before 0.148 a steer, until it opens the turn. - const settled = await session.turnOpenWaits.wait(answered, CODEX_TURN_OPEN_WAIT_MS) + await session.turnOpenWaits.wait(answered, CODEX_TURN_OPEN_WAIT_MS) if (session.activeTurnIds?.has(answered)) { return { turnId: answered } } // Before 0.148 a turn that fails before it starts reports no end; one that opens later is still // found running. session.dispatchEchoes.leftUnopened(session.threadId, answered) - return settled ? null : { opening: answered } + return leftUnopenedTarget(session) +} + +/** The answered turn a wait left unopened, while it has not ended and its send is still owed. */ +function leftUnopenedTarget(session: Parameters[0]): CodexStopTarget { + const left = session.dispatchEchoes.answeredTurnLeftUnopened( + session.threadId, + session.activeTurnIds ?? new Set() + ) + return left ? { opening: left } : null } /** `codexStopTarget`'s running or opened turn, for a send. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts index 2247471072b0..14e62854bbcc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts @@ -20,6 +20,13 @@ let rig: QueuedMessageTestRig afterEach(() => rig.dispose()) +const LATER: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-later', + ordinal: 1000 +} + const OPENED: AgentJournalItemIdentity = { provider: 'codex', threadId: 'thread-1', @@ -153,4 +160,38 @@ describe("a person's close pressed before its send's turn showed", () => { .find((entry) => entry?.turnId === 'turn-opened') expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) }) + + // A /clear stops the agent as the person's close and keeps the conversation, so the close's + // settle must have closed by the time a later turn ends. + it('binds no turn that ends on its own after a close the conversation outlived', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + await rig.host['lifetime'].stopAgent(HOST_TEST_SESSION, { cause: 'user-close' }) + expect(journal().stopMarks.latest()?.event).toMatchObject({ reason: 'user-close' }) + expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + + await journal().appendItem( + LATER, + { kind: 'turn', turnId: 'turn-later', state: 'running', startedAt: Date.now() }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal().appendItem( + LATER, + { + kind: 'turn', + turnId: 'turn-later', + state: 'interrupted', + startedAt: Date.now(), + completedAt: Date.now() + 5 + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + + const later = journal() + .snapshot() + .items.map((item) => readAgentJournalTurn(item.body)) + .find((turn) => turn?.turnId === 'turn-later') + expect(later).toMatchObject({ state: 'interrupted' }) + expect(later).not.toHaveProperty('outcome') + }) }) From 06261d00dbbc38099b0d5aa5e65540fd49c5805e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:02:09 -0700 Subject: [PATCH 04/16] test(native-chat): a Stop whose event row failed binds no turn to an earlier Stop With one ordered journal writer the Stop's event is in the fold when its write returns, so the press reads whether it owns the latest Stop from the fold instead of awaiting the write. Pins the case the read must refuse. --- ...ed-agent-session-stop-event-binding.test.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) 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 index 169c925f9bc8..d5c52b25766b 100644 --- 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 @@ -3,7 +3,7 @@ // does a Stop of a start that never landed; a card it held, which Resume releases, and anything // sent after it end as their own. Turn rows name the send that opened them, as Codex writes them. -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, @@ -303,6 +303,22 @@ describe('a press that writes no Stop event of its own', () => { expect(journal().stopMarks.latest()).toBe(earlier) await expectNews() }) + + // The event is in the fold by its write's return, so one that failed leaves the earlier Stop latest. + it('reopens no earlier Stop: a press whose event row failed binds no turn', async () => { + const sent = await laterTurnAfterAnEarlierStop() + const earlier = journal().stopMarks.latest() + vi.spyOn(journal(), 'appendStopEvent').mockRejectedValueOnce(new Error('disk full')) + rig.cancelTurn.mockImplementationOnce(async () => { + await turnOpenedBy(sent, 'interrupted') + return { cancelled: false } + }) + + expect(await rig.stop()).toMatchObject({ ok: true }) + + expect(journal().stopMarks.latest()).toBe(earlier) + await expectNews() + }) }) describe("a Stop's settle that ends the turn its interrupt took", () => { From 60572cca43df73345772ede96b98be2852f8f9cf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:03:29 -0700 Subject: [PATCH 05/16] test(native-chat): name the settle, not a stream drain, in the Stop's own-end test --- .../structured-agent-session-stop-event-binding.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index d5c52b25766b..a91ed7c8cbd4 100644 --- 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 @@ -322,7 +322,7 @@ describe('a press that writes no Stop event of its own', () => { }) describe("a Stop's settle that ends the turn its interrupt took", () => { - it("ends a turn still running once the stream drains, as the Stop's, once", async () => { + it("ends a turn still running at the Stop's settle, as the Stop's, once", async () => { rig = await createQueuedMessageTestRig() const stopped = await rig.workingSend() await rig.settleAccepted(stopped, 'stopped') From 0855e218b6dec069384a38e27c4f1658a5ff3313 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:27:32 -0700 Subject: [PATCH 06/16] test(native-chat): a Codex Stop answered before Codex ends the turn reads interrupted throughout Codex answers an interrupt it took before it sends turn/completed (interrupted): on TurnAborted the app-server answers pending interrupts, then ends the turn, on one channel. The test fake did the reverse. It now answers first and ends the turn on a later read, and the tests that read the turn's end right after a Stop wait for it. New end-to-end test through the shipped host, journal and Codex adapter: with the real order, every end row of the stopped turn reads interrupted by the Stop (named, unnamed, and a Stop pressed while turn/start was in flight). Breaking the settle window turns the in-flight case red: the Stop's own end row then has no verdict, which reads as failed until Codex's end lands. --- .../codex-structured-turn-open-wait.test.ts | 3 +- .../codex/codex-structured-turn-steer.test.ts | 11 +- src/main/codex/codex-turn-lifecycle-fake.ts | 20 +- ...gent-session-codex-interrupt-order.test.ts | 255 ++++++++++++++++++ ...-session-codex-turn-end-settlement.test.ts | 8 +- 5 files changed, 283 insertions(+), 14 deletions(-) create mode 100644 src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts diff --git a/src/main/codex/codex-structured-turn-open-wait.test.ts b/src/main/codex/codex-structured-turn-open-wait.test.ts index 9a2e20eb1e80..f1c2194d6da4 100644 --- a/src/main/codex/codex-structured-turn-open-wait.test.ts +++ b/src/main/codex/codex-structured-turn-open-wait.test.ts @@ -60,7 +60,8 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi expect(await settledWithin(stopping)).toEqual({ cancelled: true, turnId: 'turn-1' }) expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) - expect(rig.turns.turnId).toBeNull() + // Codex answers the interrupt, then ends the turn. + await vi.waitFor(() => expect(rig.turns.turnId).toBeNull()) }) it('does not wait when Codex opened the turn before its answer was read', async () => { diff --git a/src/main/codex/codex-structured-turn-steer.test.ts b/src/main/codex/codex-structured-turn-steer.test.ts index 20d009ed0cc7..15f95aa29275 100644 --- a/src/main/codex/codex-structured-turn-steer.test.ts +++ b/src/main/codex/codex-structured-turn-steer.test.ts @@ -226,10 +226,13 @@ describe('a Codex send made after Codex answered an earlier one, before it opene cancelled: true, turnId: 'turn-1' }) - expect(rig.settlements.map(({ clientMessageId }) => clientMessageId).sort()).toEqual([ - 'client-1', - 'client-2' - ]) + // Codex answers the interrupt, then ends the turn, which settles both. + await vi.waitFor(() => + expect(rig.settlements.map(({ clientMessageId }) => clientMessageId).sort()).toEqual([ + 'client-1', + 'client-2' + ]) + ) }) it('starts its own turn when that turn ends without opening', async () => { diff --git a/src/main/codex/codex-turn-lifecycle-fake.ts b/src/main/codex/codex-turn-lifecycle-fake.ts index a958faca1eaa..3905c7f7f158 100644 --- a/src/main/codex/codex-turn-lifecycle-fake.ts +++ b/src/main/codex/codex-turn-lifecycle-fake.ts @@ -2,9 +2,14 @@ // it, for tests. From app-server `turn_processor.rs`: `turn/start` picks the turn before it // answers, a send while a turn is open is steered into it under the same id with no // second `turn/started`, `turn/steer` refuses with -32600 unless its `expectedTurnId` is -// the running turn, and `turn_interrupt_inner` refuses with -32600 until the -// turn has started. The answer can be held, so a test can deliver it after the -// turn's own frames, as the wire allows. `legacyStartAnswers` is a Codex before 0.148, +// the running turn. This fake refuses `turn/interrupt` with -32600 until the turn has +// started; with no turn active, `turn_interrupt_inner` refuses only an idle thread or a turn +// that already ended. +// An interrupt Codex takes is answered before its turn ends: on `TurnAborted`, +// `bespoke_event_handling.rs` answers pending interrupts, then sends `turn/completed` +// (interrupted) on the same channel; here that end comes on a later read. The `turn/start` +// answer can be held, so a test can deliver it after the turn's own frames, as the wire +// allows. `legacyStartAnswers` is a Codex before 0.148, // whose `turn/start` answers a steered send with its submission id, a turn that never opens, // and whose `turn/steer` finds no turn to steer until the picked one has started. @@ -97,8 +102,13 @@ export function codexTurnLifecycleFake( `expected active turn id ${String(turnId)} but found ${active}` ) } - // Codex answers the interrupt once the turn has aborted. - finish(active, 'interrupted') + const aborted = active + // Answered, then the turn's end on the next read; a turn that ends meanwhile is not ended twice. + setTimeout(() => { + if (active === aborted) { + finish(aborted, 'interrupted') + } + }, 0) return {} } }, diff --git a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts new file mode 100644 index 000000000000..bbdcf3b6a97c --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts @@ -0,0 +1,255 @@ +// Codex answers an interrupt it took before it ends the turn: on `TurnAborted` the app-server +// answers pending interrupts, then sends `turn/completed` (interrupted) on the same channel. The +// Stop's settle, which runs between the two, must label the turn the Stop's: no end row for it +// ever reads as a failure. Driven through the shipped host, journal and Codex adapter; only the +// Codex child is fake, and the test delivers the turn's end after the Stop has answered. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' +import type * as CodexTurnOpenWait from '../codex/codex-structured-turn-open-wait' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { AgentJournalTurnItem } from '../../shared/agent-session-journal-types' +import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { + liveTestJournalRows, + openTestJournalHostDatabase +} from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { parseJournalRow } from '../native-chat/agent-session-journal/journal-row-schema' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +// The turns a Stop is waiting on to open, so a test knows the wait began. +const openWaits = vi.hoisted(() => { + const turnIds: string[] = [] + return { turnIds } +}) +vi.mock('../codex/codex-structured-turn-open-wait', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createCodexTurnOpenWaits: () => { + const waits = actual.createCodexTurnOpenWaits() + return { + ...waits, + wait: (turnId: string, withinMs: number) => { + openWaits.turnIds.push(turnId) + return waits.wait(turnId, withinMs) + } + } + } + } +}) + +const CALLER = { callerKey: 'codex-interrupt-order-test' } +const MODEL = { + model: 'gpt-test', + displayName: 'GPT Test', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: true +} + +let root: string +let host: StructuredAgentSessionHost +let fence: number +let handlers: CodexAppServerConnectionHandlers | undefined +let answers: number +let interrupts: number +let turns: ReturnType +let operations = 0 + +/** The durable ledger stamps its own clock and refuses an id far from it. */ +const operationId = (): string => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}` + +function envelope(method: string, fields: Record) { + return { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + return sent.value.clientMessageId +} + +async function stop(turnId?: string): Promise { + const stopped = await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', turnId === undefined ? {} : { turnId }), + ...(turnId === undefined ? {} : { turnId }) + }) + if (!stopped.ok) { + throw new Error(JSON.stringify(stopped.refusal)) + } +} + +/** Every end row written for turn-1, as a row or a lifecycle batch's mutation, in order. */ +function turnEndRows(): AgentJournalTurnItem[] { + return liveTestJournalRows(openTestJournalHostDatabase(root).db, SESSION).flatMap((stored) => { + const parsed = parseJournalRow(stored.rowJson) + if (!parsed.ok) { + return [] + } + const { row } = parsed + const bodies = row.kind === 'lifecycle-batch' ? row.mutations : [row] + return bodies.flatMap((entry) => + entry.kind === 'item' && + entry.body.kind === 'turn' && + entry.body.turnId === 'turn-1' && + entry.body.state !== 'running' + ? [entry.body] + : [] + ) + }) +} + +/** The Stop has answered with Codex's end still to come; then Codex ends the turn. */ +async function expectInterruptedThroughout(): Promise { + expect(interrupts).toBe(1) + expect(turnEndRows()).toEqual([ + expect.objectContaining({ state: 'interrupted', outcome: 'cancellation' }) + ]) + + turns.end('interrupted') + + await vi.waitFor(async () => expect(await owesWork()).toBe(false)) + const ends = turnEndRows() + expect(ends.length).toBeGreaterThan(0) + for (const end of ends) { + expect(end).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + } +} + +async function owesWork(): Promise { + const snapshot = await host.journalSnapshot(SESSION) + return owesStructuredAgentSessionWork(snapshot.items, snapshot.submissions, fence) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-codex-interrupt-order-')) + openWaits.turnIds.length = 0 + answers = 0 + interrupts = 0 + turns = codexTurnLifecycleFake( + THREAD, + () => (method, params) => handlers?.onNotification?.(method, params) + ) + const openConnection: typeof openCodexAppServerConnection = async ( + _launch, + connectionHandlers = {} + ) => { + handlers = connectionHandlers + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async (method) => { + if (method === 'thread/start' || method === 'thread/resume') { + return { thread: { id: THREAD } } + } + if (method === 'model/list') { + return { data: [MODEL], nextCursor: null } + } + if (method === 'turn/start') { + answers += 1 + return turns.routes['turn/start']() + } + if (method === 'turn/interrupt') { + // Taken: answered now; the test sends the turn's end once the Stop has answered. + interrupts += 1 + return {} + } + return {} + }, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + } + return connection + } + host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveCodexCommand: () => 'codex', + resolveEnvironment: async () => ({ PATH: process.env.PATH }), + openCodexConnection: openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + attachParams.envelope.clientOperationId = operationId() + const attached = await host.attach(CALLER, attachParams) + if (!attached.ok) { + throw new Error(JSON.stringify(attached.refusal)) + } + fence = attached.value.fence +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) +}) + +describe("a Codex Stop answered before Codex's turn/completed (interrupted)", () => { + it.each([ + ['names the running turn', 'turn-1'], + ['names no turn', undefined] + ] as const)( + 'reads the turn as interrupted by the Stop, never failed, when it %s', + async (_, named) => { + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + + await stop(named) + + await expectInterruptedThroughout() + } + ) + + // No turn showed when the person pressed Stop: the turn that opens is bound by the Stop's settle. + it('reads the turn that opens behind an in-flight turn/start as interrupted, never failed', async () => { + const release = turns.holdNextAnswer() + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + const stopping = stop() + release() + await vi.waitFor(() => expect(openWaits.turnIds).toContain('turn-1')) + turns.start() + await stopping + + await expectInterruptedThroughout() + }) +}) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 103eebed8858..24ab2a163df7 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -1,7 +1,7 @@ // A Codex send the turn it went into never took: the Stop that interrupts that turn // withdraws it, and nothing reads as working after. A send made while that turn runs, a // queued card's Send-now included, goes in as `turn/steer` naming it. A Stop or a send made -// before Codex opens that turn waits for it to open, since Codex refuses an interrupt until then. +// before Codex opens that turn waits for it to open: Orca interrupts only a turn that has opened. // Driven through the shipped host, journal and Codex adapter; only the Codex child is fake, // keeping Codex 0.157's turn bookkeeping. @@ -463,7 +463,7 @@ describe('a Stop sent after Codex answered a cold send, before it opened the tur await stopping expect(interrupts).toBe(1) - expect(turns.turnId).toBeNull() + await vi.waitFor(() => expect(turns.turnId).toBeNull()) await vi.waitFor(async () => expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') ) @@ -542,12 +542,12 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { await stopping expect(interrupts).toBe(1) - const after = await settled() expect(turnRow((await host.journalSnapshot(SESSION)).items)).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) - expect(after.owesWork).toBe(false) + // Codex's end, after its answer, settles the send. + await vi.waitFor(async () => expect((await settled()).owesWork).toBe(false)) }) it('ends the child when the turn has not opened by the end of its wait', async () => { From bd95932b2c03594023fee9739adf310c816d68df Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:45:40 -0700 Subject: [PATCH 07/16] fix(native-chat): a Codex Stop interrupts a turn Codex answered but has not opened at once A Stop that named no turn, made after Codex answered a send but before the turn opened, used to wait up to 5 s for the turn to open before interrupting, and ended the Codex process when it didn't. The stated reason, that Codex refuses an interrupt until it opens the turn, holds only part of the time: with no turn active, Codex takes an interrupt once its thread runs (turn_interrupt_inner), and refuses it with -32600 "no active turn to interrupt" before that or once the turn has ended. The Stop now sends the interrupt at once. Only on that refusal, while the turn has neither opened nor ended, does it wait for the turn to open (bounded at 5 s) and send it once more. A turn that ended meanwhile was nothing to stop. One that never opens, or that an earlier wait already gave up on, fails the Stop, and the host ends the child as before. Sends still wait for the turn to open before steering into it. The test fake models Codex taking an interrupt once the thread runs (run()). --- ...codex-structured-conversation-stop.test.ts | 3 +- .../codex-structured-prompt-ownership.ts | 36 +++--- .../codex-structured-turn-open-wait.test.ts | 62 +++++++--- .../codex/codex-structured-turn-open-wait.ts | 113 +++++++++++++----- src/main/codex/codex-turn-lifecycle-fake.ts | 31 +++-- ...gent-session-codex-interrupt-order.test.ts | 47 +++----- ...-session-codex-turn-end-settlement.test.ts | 24 ++-- 7 files changed, 199 insertions(+), 117 deletions(-) diff --git a/src/main/codex/codex-structured-conversation-stop.test.ts b/src/main/codex/codex-structured-conversation-stop.test.ts index 4650603256bd..1e548dd0909f 100644 --- a/src/main/codex/codex-structured-conversation-stop.test.ts +++ b/src/main/codex/codex-structured-conversation-stop.test.ts @@ -1,5 +1,6 @@ // A Codex Stop that names no turn. The fake keeps Codex 0.157's turn bookkeeping: it answers -// `turn/start` before it opens the turn, and refuses an interrupt until then. +// `turn/start` before it opens the turn, and refuses an interrupt with no turn active until the +// thread runs. import { describe, expect, it, vi } from 'vitest' import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' diff --git a/src/main/codex/codex-structured-prompt-ownership.ts b/src/main/codex/codex-structured-prompt-ownership.ts index 813da8f01b6a..fdef6e750f48 100644 --- a/src/main/codex/codex-structured-prompt-ownership.ts +++ b/src/main/codex/codex-structured-prompt-ownership.ts @@ -12,7 +12,7 @@ import { } from './codex-structured-prompt-replies' import { requireLiveCodexSession, type CodexSession } from './codex-structured-session-state' import { interruptCodexTurn } from './codex-structured-turn-cancellation' -import { codexStopTarget } from './codex-structured-turn-open-wait' +import { codexStopTarget, interruptOpeningCodexTurn } from './codex-structured-turn-open-wait' type CancelInput = Parameters[0] type AnswerInput = Parameters[0] @@ -23,7 +23,7 @@ function providerTurnId(session: CodexSession, turnId: string): string | undefin } /** A send whose answer this child lost: Codex may still open a turn for it that no Stop can name. - * A pending one Codex answered is not: its turn is the one the Stop waits for. */ + * A pending one Codex answered is not: its turn is the one the Stop interrupts. */ function sendInDoubt(dispatchStatus: CancelInput['dispatchStatus']): boolean { return dispatchStatus?.state === 'unknown' && !dispatchStatus.recovered } @@ -39,8 +39,8 @@ function stoppedTurn( /** * A Stop that names no turn: interrupt the turn the journal shows, or else the one Codex reported * started and not yet ended, which the journal can trail by a publish, or else the one Codex - * answered a send into, once it opens. A turn that may still open, unanswered or not open in time, - * is one this Stop cannot reach: it answers refused, and the host ends the child. + * answered a send into, at once (`interruptOpeningCodexTurn`). A turn that may still open but + * cannot be reached, or a send whose answer was lost, answers refused, and the host ends the child. */ async function cancelCodexConversation( input: Parameters[0], @@ -49,27 +49,31 @@ async function cancelCodexConversation( const { request, sessions, requestTimeoutMs } = input const liveTurnId = request.resolveLiveTurnId?.() ?? null // A turn the journal shows that Codex has not started yet (a compaction's) has nothing to stop. - const target = liveTurnId === null ? await codexStopTarget(session) : { turnId: liveTurnId } - // The wait for the turn to open can outlive the session it began on. - if ( - sessions.get(request.sessionId) !== session || - session.ended || - session.fence !== request.fence - ) { + const target = liveTurnId === null ? codexStopTarget(session) : { turnId: liveTurnId } + // Read again after any wait for the turn to open, which can outlive the session it began on. + const current = (): boolean => + sessions.get(request.sessionId) === session && !session.ended && session.fence === request.fence + if (!current()) { return { cancelled: false } } - if (target === null || 'opening' in target) { - return target !== null || sendInDoubt(request.dispatchStatus) + if (target === null) { + return sendInDoubt(request.dispatchStatus) ? { cancelled: false, refusal: { turnMayOpen: true } } : { cancelled: false } } - const turnId = liveTurnId === null ? target.turnId : providerTurnId(session, liveTurnId) + const journalTurnId = 'opening' in target ? target.opening : target.turnId + const turnId = liveTurnId === null ? journalTurnId : providerTurnId(session, liveTurnId) if (!turnId) { return { cancelled: false } } + const interrupt = () => + interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }) + if (!('opening' in target)) { + return stoppedTurn(await interrupt(), journalTurnId) + } return stoppedTurn( - await interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }), - target.turnId + await interruptOpeningCodexTurn(session, turnId, interrupt, current), + journalTurnId ) } diff --git a/src/main/codex/codex-structured-turn-open-wait.test.ts b/src/main/codex/codex-structured-turn-open-wait.test.ts index f1c2194d6da4..5b79f4386b92 100644 --- a/src/main/codex/codex-structured-turn-open-wait.test.ts +++ b/src/main/codex/codex-structured-turn-open-wait.test.ts @@ -1,7 +1,8 @@ // A Codex Stop that names no turn, sent after Codex answered a cold send and before it opened that -// turn, waits for the turn to open, or provably not to, and never the send itself. The fake keeps -// Codex 0.157's turn bookkeeping: it answers before it opens the turn, and refuses an interrupt -// until then. +// turn, interrupts that turn at once. Refused as finding no turn while the turn has yet to run, it +// waits for the turn to open, or provably not to, and interrupts once more; it never waits for the +// send itself. The fake keeps Codex 0.157's turn bookkeeping: it answers before it opens the turn, +// and refuses an interrupt with no turn active until the thread runs. import { afterEach, describe, expect, it, vi } from 'vitest' import { @@ -27,12 +28,14 @@ async function answeredColdSend(rig: Rig): Promise { expect(await settledWithin(sending)).toEqual(ADMITTED) } -/** A Stop sent in the window, which Codex would refuse if it reached Codex now. */ +const interruptedTurns = (rig: Rig) => rig.interrupts().map((call) => call.params?.turnId) + +/** A Stop sent in the window before Codex's thread runs the turn: Codex refused its interrupt. */ async function waitingStop(rig: Rig) { await answeredColdSend(rig) const stopping = stop(rig) expect(await settledWithin(stopping)).toBe('held') - expect(rig.interrupts()).toEqual([]) + expect(interruptedTurns(rig)).toEqual(['turn-1']) // Wrapped: an async function returning the promise itself would wait for it. return { stopping } } @@ -52,14 +55,24 @@ describe('a Codex send answered before its turn opens', () => { }) describe("a no-turn Stop in the window between Codex's answer and its turn opening", () => { - it('waits for the turn to open, then stops it', async () => { + it("is taken at once once Codex's thread runs the turn", async () => { + const rig = await codexTurnLifecycleRig() + await answeredColdSend(rig) + rig.turns.run() + + expect(await settledWithin(stop(rig))).toEqual({ cancelled: true, turnId: 'turn-1' }) + expect(interruptedTurns(rig)).toEqual(['turn-1']) + await vi.waitFor(() => expect(rig.turns.turnId).toBeNull()) + }) + + it('refused before the thread runs, waits for the turn to open and interrupts once more', async () => { const rig = await codexTurnLifecycleRig() const { stopping } = await waitingStop(rig) rig.turns.start() expect(await settledWithin(stopping)).toEqual({ cancelled: true, turnId: 'turn-1' }) - expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) + expect(interruptedTurns(rig)).toEqual(['turn-1', 'turn-1']) // Codex answers the interrupt, then ends the turn. await vi.waitFor(() => expect(rig.turns.turnId).toBeNull()) }) @@ -93,7 +106,21 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi rig.turns.end('interrupted') expect(await settledWithin(stopping)).toEqual(REFUSED) - expect(rig.interrupts()).toEqual([]) + expect(interruptedTurns(rig)).toEqual(['turn-1']) + }) + + // Codex ended the turn as the interrupt reached it: it refuses the turn as its last ended one. + it('stops nothing, and waits for nothing, when Codex already ended the turn', async () => { + const rig = await codexTurnLifecycleRig() + await answeredColdSend(rig) + const interrupt = rig.codex.routes['turn/interrupt']! + rig.codex.routes['turn/interrupt'] = (params) => { + rig.turns.end('completed') + return interrupt(params) + } + + expect(await settledWithin(stop(rig))).toEqual(REFUSED) + expect(interruptedTurns(rig)).toEqual(['turn-1']) }) // The turn neither opened nor ended, and its send is still owed: no press calls that nothing. @@ -106,8 +133,9 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi rig.notify('thread/status/changed', { threadId: CODEX_TEST_THREAD_ID, status: { type } }) expect(await settledWithin(stopping)).toEqual(MAY_OPEN) - expect(await settledWithin(stop(rig), 0)).toEqual(MAY_OPEN) - expect(rig.interrupts()).toEqual([]) + // The second press's interrupt is refused too, and spends no wait on that turn. + expect(await settledWithin(stop(rig))).toEqual(MAY_OPEN) + expect(interruptedTurns(rig)).toEqual(['turn-1', 'turn-1']) } ) @@ -129,7 +157,7 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi rig.codex.connections[0]!.handlers.onExit?.(new Error('codex app-server exited')) expect(await settledWithin(stopping)).toEqual(REFUSED) - expect(rig.interrupts()).toEqual([]) + expect(interruptedTurns(rig)).toEqual(['turn-1']) }) // The turn may still open and run: the host ends the child (`performCancel`). @@ -146,13 +174,15 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi expect(outcome).toBe('held') await vi.advanceTimersByTimeAsync(1) - expect(outcome).toEqual({ cancelled: false, refusal: { turnMayOpen: true } }) - expect(rig.interrupts()).toEqual([]) + expect(outcome).toEqual(MAY_OPEN) + expect(interruptedTurns(rig)).toEqual(['turn-1']) // A second Stop spends no wait on that turn, and still finds it able to open. - expect(await settledWithin(stop(rig), 0)).toEqual({ - cancelled: false, - refusal: { turnMayOpen: true } + let second: unknown = 'held' + void stop(rig).then((value) => { + second = value }) + await vi.advanceTimersByTimeAsync(0) + expect(second).toEqual(MAY_OPEN) }) }) diff --git a/src/main/codex/codex-structured-turn-open-wait.ts b/src/main/codex/codex-structured-turn-open-wait.ts index 3f43e9c787d4..c29fa61eb020 100644 --- a/src/main/codex/codex-structured-turn-open-wait.ts +++ b/src/main/codex/codex-structured-turn-open-wait.ts @@ -1,6 +1,7 @@ // A Stop's or a send's wait for the turn Codex answered a send into to open, or provably not // to: it ended, the thread stopped running, or the child is gone. Held in memory only. +import type { AgentSessionCancelOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' import { codexThreadStoppedRunning, @@ -61,18 +62,12 @@ export function createCodexTurnOpenWaits(): CodexTurnOpenWaits { } } -/** What a Stop or a send finds to act on: a turn running, or one Codex answered a send into that - * has not opened and has not ended, so may still open while its send is owed; null when neither. */ +/** What a send finds to steer, or a Stop to interrupt: a turn running, or one Codex answered a send + * into that has not opened and has not ended, so may still open while its send is owed; null when + * neither. */ export type CodexStopTarget = { turnId: string } | { opening: string } | null -/** - * The turn a Stop or a send names: the latest Codex reported started and not ended, or else the - * one Codex answered a send into, once it opens. `opening` when it has not opened by the end of - * the wait, however the wait ended (it ran out, or the thread stopped running), while it has not - * ended and its send is still owed: nothing to stop holds only when nothing is owed. Null when - * none is running and none is owed. Each such turn is waited for once. - */ -export async function codexStopTarget(session: { +type CodexOpeningTurnSession = { threadId: string activeTurnIds?: ReadonlySet dispatchEchoes: Pick< @@ -80,43 +75,95 @@ export async function codexStopTarget(session: { 'answeredUnopenedTurn' | 'leftUnopened' | 'answeredTurnLeftUnopened' > turnOpenWaits: Pick -}): Promise { +} + +/** The latest turn Codex reported started and not ended, or else the one it answered a send into + * that has neither opened nor ended, whether or not a wait already gave up on it. */ +export function codexStopTarget(session: CodexOpeningTurnSession): CodexStopTarget { const running = [...(session.activeTurnIds ?? [])].at(-1) if (running) { return { turnId: running } } + const open = session.activeTurnIds ?? new Set() + const opening = + session.dispatchEchoes.answeredUnopenedTurn(session.threadId, open) ?? + session.dispatchEchoes.answeredTurnLeftUnopened(session.threadId, open) + return opening ? { opening } : null +} + +/** Codex's -32600 for an interrupt that finds no turn: before the turn has started running, or + * once it has ended. */ +const CODEX_NO_ACTIVE_TURN_TO_INTERRUPT = 'no active turn to interrupt' + +/** + * A Stop of a turn Codex answered a send into that has not opened. Codex takes the interrupt once + * its thread runs, so it goes out at once. Refused as finding no turn while that turn has yet to + * start, the Stop waits for it to open, bounded, and sends it once more. A turn that ended + * meanwhile was nothing to stop. One that never opened, or that an earlier wait already gave up on, + * fails the Stop (`turnMayOpen`): it may still open and run, so the host ends the child. + */ +export async function interruptOpeningCodexTurn( + session: CodexOpeningTurnSession, + turnId: string, + interrupt: () => Promise, + /** Whether the session the Stop began on still runs it, read after the wait. */ + stillCurrent: () => boolean +): Promise { + const open = (): ReadonlySet => session.activeTurnIds ?? new Set() + const waitedBefore = + session.dispatchEchoes.answeredTurnLeftUnopened(session.threadId, open()) === turnId + const first = await interrupt() + if (first.cancelled || first.refusal?.detail?.text !== CODEX_NO_ACTIVE_TURN_TO_INTERRUPT) { + return first + } + const opened = (): boolean => open().has(turnId) + const stillOpening = (): boolean => { + const target = codexStopTarget(session) + return target !== null && 'opening' in target && target.opening === turnId + } + if (!opened() && stillOpening() && !waitedBefore) { + await session.turnOpenWaits.wait(turnId, CODEX_TURN_OPEN_WAIT_MS) + if (!stillCurrent()) { + return { cancelled: false } + } + } + if (opened()) { + return interrupt() + } + if (!stillOpening()) { + // It ended: the Stop found no turn running. + return { cancelled: false } + } + session.dispatchEchoes.leftUnopened(session.threadId, turnId) + return { cancelled: false, refusal: { turnMayOpen: true } } +} + +/** + * The turn a send steers into: the running one, or else the one Codex answered a send into, once + * it opens. Before 0.148 Codex refuses a steer until it opens the turn, so the send waits for that, + * once per turn: a turn a wait left unopened is not waited for again. Null when no turn is running + * by then. + */ +export async function codexRunningOrOpeningTurn( + session: CodexOpeningTurnSession +): Promise { + const running = [...(session.activeTurnIds ?? [])].at(-1) + if (running) { + return running + } const answered = session.dispatchEchoes.answeredUnopenedTurn( session.threadId, session.activeTurnIds ?? new Set() ) if (!answered) { - // An earlier wait left it unopened, yet its send is still owed: it may still open. - return leftUnopenedTarget(session) + return null } - // Codex refuses an interrupt, and before 0.148 a steer, until it opens the turn. await session.turnOpenWaits.wait(answered, CODEX_TURN_OPEN_WAIT_MS) if (session.activeTurnIds?.has(answered)) { - return { turnId: answered } + return answered } // Before 0.148 a turn that fails before it starts reports no end; one that opens later is still // found running. session.dispatchEchoes.leftUnopened(session.threadId, answered) - return leftUnopenedTarget(session) -} - -/** The answered turn a wait left unopened, while it has not ended and its send is still owed. */ -function leftUnopenedTarget(session: Parameters[0]): CodexStopTarget { - const left = session.dispatchEchoes.answeredTurnLeftUnopened( - session.threadId, - session.activeTurnIds ?? new Set() - ) - return left ? { opening: left } : null -} - -/** `codexStopTarget`'s running or opened turn, for a send. */ -export async function codexRunningOrOpeningTurn( - session: Parameters[0] -): Promise { - const target = await codexStopTarget(session) - return target !== null && 'turnId' in target ? target.turnId : null + return null } diff --git a/src/main/codex/codex-turn-lifecycle-fake.ts b/src/main/codex/codex-turn-lifecycle-fake.ts index 3905c7f7f158..dda6060ee3a6 100644 --- a/src/main/codex/codex-turn-lifecycle-fake.ts +++ b/src/main/codex/codex-turn-lifecycle-fake.ts @@ -2,9 +2,8 @@ // it, for tests. From app-server `turn_processor.rs`: `turn/start` picks the turn before it // answers, a send while a turn is open is steered into it under the same id with no // second `turn/started`, `turn/steer` refuses with -32600 unless its `expectedTurnId` is -// the running turn. This fake refuses `turn/interrupt` with -32600 until the turn has -// started; with no turn active, `turn_interrupt_inner` refuses only an idle thread or a turn -// that already ended. +// the running turn, and `turn_interrupt_inner`, with no turn active, refuses with -32600 "no +// active turn to interrupt" until the thread runs (`run`, or `start`) and once the turn has ended. // An interrupt Codex takes is answered before its turn ends: on `TurnAborted`, // `bespoke_event_handling.rs` answers pending interrupts, then sends `turn/completed` // (interrupted) on the same channel; here that end comes on a later read. The `turn/start` @@ -27,6 +26,9 @@ export type CodexTurnLifecycleFake = { holdNextAnswer: () => () => void /** Codex emits `turn/started` for the turn it picked last. */ start: () => void + /** The thread runs the picked turn, whose `turn/started` has yet to be read: an interrupt of it + * is taken, and that `turn/started` comes ahead of the answer. */ + run: () => void /** Codex ends the picked or running turn on its own. */ end: (status: 'completed' | 'interrupted' | 'failed', errorMessage?: string) => void /** Codex fails the picked turn before starting it, which reports an `error` and no turn end. */ @@ -56,14 +58,24 @@ export function codexTurnLifecycleFake( let active: string | null = null let lastTurn: string | null = null let held: Promise | null = null + let pickedRuns = false const finish = (turnId: string, status: string, errorMessage?: string): void => { picked = null active = null + pickedRuns = false notify()('turn/completed', { threadId, turn: { id: turnId, status, ...(errorMessage ? { error: { message: errorMessage } } : {}) } }) } + const open = (): void => { + if (!picked) { + throw new Error('no picked turn to start') + } + active = picked + pickedRuns = false + notify()('turn/started', { threadId, turn: { id: active, status: 'inProgress' } }) + } return { routes: { 'turn/start': () => { @@ -93,6 +105,9 @@ export function codexTurnLifecycleFake( }, 'turn/interrupt': (params) => { const turnId = params?.turnId + if (!active && pickedRuns && picked === turnId) { + open() + } if (!active) { throw refusal('turn/interrupt', 'no active turn to interrupt') } @@ -119,12 +134,12 @@ export function codexTurnLifecycleFake( }) return () => release() }, - start: () => { - if (!picked) { - throw new Error('no picked turn to start') + start: () => open(), + run: () => { + if (!picked || active) { + throw new Error('no picked turn to run') } - active = picked - notify()('turn/started', { threadId, turn: { id: active, status: 'inProgress' } }) + pickedRuns = true }, end: (status, errorMessage) => { const turnId = active ?? picked diff --git a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts index bbdcf3b6a97c..de09e72b7e5c 100644 --- a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts @@ -14,7 +14,6 @@ import type { openCodexAppServerConnection } from '../codex/codex-app-server-connection' import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' -import type * as CodexTurnOpenWait from '../codex/codex-structured-turn-open-wait' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import type { AgentJournalTurnItem } from '../../shared/agent-session-journal-types' import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' @@ -36,28 +35,6 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' -// The turns a Stop is waiting on to open, so a test knows the wait began. -const openWaits = vi.hoisted(() => { - const turnIds: string[] = [] - return { turnIds } -}) -vi.mock('../codex/codex-structured-turn-open-wait', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - createCodexTurnOpenWaits: () => { - const waits = actual.createCodexTurnOpenWaits() - return { - ...waits, - wait: (turnId: string, withinMs: number) => { - openWaits.turnIds.push(turnId) - return waits.wait(turnId, withinMs) - } - } - } - } -}) - const CALLER = { callerKey: 'codex-interrupt-order-test' } const MODEL = { model: 'gpt-test', @@ -75,6 +52,7 @@ let handlers: CodexAppServerConnectionHandlers | undefined let answers: number let interrupts: number let turns: ReturnType +let opened: Set let operations = 0 /** The durable ledger stamps its own clock and refuses an id far from it. */ @@ -156,13 +134,15 @@ async function owesWork(): Promise { beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-codex-interrupt-order-')) - openWaits.turnIds.length = 0 answers = 0 interrupts = 0 - turns = codexTurnLifecycleFake( - THREAD, - () => (method, params) => handlers?.onNotification?.(method, params) - ) + opened = new Set() + turns = codexTurnLifecycleFake(THREAD, () => (method, params) => { + if (method === 'turn/started') { + opened.add(turns.turnId ?? '') + } + handlers?.onNotification?.(method, params) + }) const openConnection: typeof openCodexAppServerConnection = async ( _launch, connectionHandlers = {} @@ -183,8 +163,12 @@ beforeEach(async () => { return turns.routes['turn/start']() } if (method === 'turn/interrupt') { - // Taken: answered now; the test sends the turn's end once the Stop has answered. + // Taken: answered now; the test sends the turn's end once the Stop has answered. A turn + // Codex runs but whose turn/started was not yet read gets that frame ahead of the answer. interrupts += 1 + if (turns.turnId !== null && !opened.has(turns.turnId)) { + turns.start() + } return {} } return {} @@ -239,15 +223,14 @@ describe("a Codex Stop answered before Codex's turn/completed (interrupted)", () } ) - // No turn showed when the person pressed Stop: the turn that opens is bound by the Stop's settle. + // No turn showed when the person pressed Stop, which interrupts the answered turn at once: the + // turn that opens is bound by the Stop's settle. it('reads the turn that opens behind an in-flight turn/start as interrupted, never failed', async () => { const release = turns.holdNextAnswer() await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) const stopping = stop() release() - await vi.waitFor(() => expect(openWaits.turnIds).toContain('turn-1')) - turns.start() await stopping await expectInterruptedThroughout() diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 24ab2a163df7..7502ab93b77c 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -1,7 +1,8 @@ // A Codex send the turn it went into never took: the Stop that interrupts that turn // withdraws it, and nothing reads as working after. A send made while that turn runs, a -// queued card's Send-now included, goes in as `turn/steer` naming it. A Stop or a send made -// before Codex opens that turn waits for it to open: Orca interrupts only a turn that has opened. +// queued card's Send-now included, goes in as `turn/steer` naming it. A Stop made before Codex +// opens that turn interrupts it at once; refused before Codex's thread runs it, the Stop waits +// for it to open and interrupts once more. A send made then waits for it to open. // Driven through the shipped host, journal and Codex adapter; only the Codex child is fake, // keeping Codex 0.157's turn bookkeeping. @@ -451,18 +452,17 @@ describe('a second send made after Codex answered the first, before it opened th }) describe('a Stop sent after Codex answered a cold send, before it opened the turn', () => { - it('waits for the turn to open, then stops it and withdraws the send', async () => { + it('refused before the thread runs it, waits for the turn to open, then stops it and withdraws the send', async () => { const sent = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) const stopping = stop() - // Without the wait, it would reach Codex now, which would refuse it, and be done. expect(await settledWithin(stopping, 1_000)).toBe('held') - expect(interrupts).toBe(0) + expect(interrupts).toBe(1) turns.start() await stopping - expect(interrupts).toBe(1) + expect(interrupts).toBe(2) await vi.waitFor(() => expect(turns.turnId).toBeNull()) await vi.waitFor(async () => expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') @@ -492,7 +492,8 @@ describe('a Stop in that window that the turn never opens for', () => { turns.end('interrupted') await stopping - expect(interrupts).toBe(0) + expect(interrupts).toBe(1) + expect(childCloses).toBe(0) expect(await statusRows()).toContain('Codex had no turn running to stop.') }) @@ -506,7 +507,7 @@ describe('a Stop in that window that the turn never opens for', () => { ).not.toBe('held') expect(await settledWithin(stopping, 0)).not.toBe('held') expect(childCloses).toBe(1) - expect(interrupts).toBe(0) + expect(interrupts).toBe(1) // Its wait ran out with the turn still able to open, so the Stop ended the child. expect(await statusRows()).toContain('Cancellation requested.') }) @@ -541,7 +542,8 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { turns.start() await stopping - expect(interrupts).toBe(1) + // Refused before the thread ran the turn, then taken once it opened. + expect(interrupts).toBe(2) expect(turnRow((await host.journalSnapshot(SESSION)).items)).toMatchObject({ state: 'interrupted', outcome: 'cancellation' @@ -550,7 +552,7 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { await vi.waitFor(async () => expect((await settled()).owesWork).toBe(false)) }) - it('ends the child when the turn has not opened by the end of its wait', async () => { + it('ends the child when its interrupt was refused and the turn has not opened by the end of its wait', async () => { const release = turns.holdNextAnswer() await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) @@ -559,7 +561,7 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { expect(await settledWithin(stopping, CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') - expect(interrupts).toBe(0) + expect(interrupts).toBe(1) expect(childCloses).toBe(1) expect((await settled()).owesWork).toBe(false) expect(turnRow((await host.journalSnapshot(SESSION)).items)).toBeUndefined() From 980f0e79d70f29ff6212b1ba39e87f1fc7b7b0b7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:12:47 -0700 Subject: [PATCH 08/16] fix(native-chat): every Codex Stop waits for an answered turn to start, as the first did A Stop whose interrupt Codex refused as finding no active turn skipped the wait when an earlier wait, a Stop's or a send's, had already given up on that turn. Every press now waits its own bound and retries once if the turn starts, so a turn that opens during a later press is still stopped. Both presses still reach the same verdict when it never starts. --- .../codex-structured-turn-open-wait.test.ts | 51 ++++++++++++++----- .../codex/codex-structured-turn-open-wait.ts | 15 +++--- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/src/main/codex/codex-structured-turn-open-wait.test.ts b/src/main/codex/codex-structured-turn-open-wait.test.ts index 5b79f4386b92..d669c088d080 100644 --- a/src/main/codex/codex-structured-turn-open-wait.test.ts +++ b/src/main/codex/codex-structured-turn-open-wait.test.ts @@ -30,6 +30,15 @@ async function answeredColdSend(rig: Rig): Promise { const interruptedTurns = (rig: Rig) => rig.interrupts().map((call) => call.params?.turnId) +/** A Stop under fake timers, read without awaiting it. */ +function pressed(rig: Rig): () => unknown { + let outcome: unknown = 'held' + void stop(rig).then((value) => { + outcome = value + }) + return () => outcome +} + /** A Stop sent in the window before Codex's thread runs the turn: Codex refused its interrupt. */ async function waitingStop(rig: Rig) { await answeredColdSend(rig) @@ -133,8 +142,13 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi rig.notify('thread/status/changed', { threadId: CODEX_TEST_THREAD_ID, status: { type } }) expect(await settledWithin(stopping)).toEqual(MAY_OPEN) - // The second press's interrupt is refused too, and spends no wait on that turn. - expect(await settledWithin(stop(rig))).toEqual(MAY_OPEN) + // The second press's interrupt is refused too; it waits its own bound, then agrees. + vi.useFakeTimers() + const second = pressed(rig) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 1) + expect(second()).toBe('held') + await vi.advanceTimersByTimeAsync(1) + expect(second()).toEqual(MAY_OPEN) expect(interruptedTurns(rig)).toEqual(['turn-1', 'turn-1']) } ) @@ -165,24 +179,33 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi const rig = await codexTurnLifecycleRig() await answeredColdSend(rig) vi.useFakeTimers() - let outcome: unknown = 'held' - void stop(rig).then((value) => { - outcome = value - }) + const first = pressed(rig) await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 1) - expect(outcome).toBe('held') + expect(first()).toBe('held') await vi.advanceTimersByTimeAsync(1) - expect(outcome).toEqual(MAY_OPEN) + expect(first()).toEqual(MAY_OPEN) expect(interruptedTurns(rig)).toEqual(['turn-1']) - // A second Stop spends no wait on that turn, and still finds it able to open. - let second: unknown = 'held' - void stop(rig).then((value) => { - second = value - }) + }) + + // A press after an earlier wait gave up waits again, so a turn that opens then is still stopped. + it('waits again on a second press, and stops the turn if it opens then', async () => { + const rig = await codexTurnLifecycleRig() + await answeredColdSend(rig) + vi.useFakeTimers() + const first = pressed(rig) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS) + expect(first()).toEqual(MAY_OPEN) + + const second = pressed(rig) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 1) + expect(second()).toBe('held') + rig.turns.start() await vi.advanceTimersByTimeAsync(0) - expect(second).toEqual(MAY_OPEN) + + expect(second()).toEqual({ cancelled: true, turnId: 'turn-1' }) + expect(interruptedTurns(rig)).toEqual(['turn-1', 'turn-1', 'turn-1']) }) }) diff --git a/src/main/codex/codex-structured-turn-open-wait.ts b/src/main/codex/codex-structured-turn-open-wait.ts index c29fa61eb020..9dd071656f35 100644 --- a/src/main/codex/codex-structured-turn-open-wait.ts +++ b/src/main/codex/codex-structured-turn-open-wait.ts @@ -96,11 +96,11 @@ export function codexStopTarget(session: CodexOpeningTurnSession): CodexStopTarg const CODEX_NO_ACTIVE_TURN_TO_INTERRUPT = 'no active turn to interrupt' /** - * A Stop of a turn Codex answered a send into that has not opened. Codex takes the interrupt once - * its thread runs, so it goes out at once. Refused as finding no turn while that turn has yet to - * start, the Stop waits for it to open, bounded, and sends it once more. A turn that ended - * meanwhile was nothing to stop. One that never opened, or that an earlier wait already gave up on, - * fails the Stop (`turnMayOpen`): it may still open and run, so the host ends the child. + * A Stop of a turn Codex answered a send into that has not opened. Codex takes an interrupt as + * soon as the turn starts, which can be before its `turn/started` is read, so it goes out at once. + * Refused as finding no turn while that turn has yet to start, the Stop waits for it to open, + * bounded, and sends it once more. A turn that ended meanwhile was nothing to stop. One that never + * opens fails the Stop (`turnMayOpen`): it may still open and run, so the host ends the child. */ export async function interruptOpeningCodexTurn( session: CodexOpeningTurnSession, @@ -110,8 +110,6 @@ export async function interruptOpeningCodexTurn( stillCurrent: () => boolean ): Promise { const open = (): ReadonlySet => session.activeTurnIds ?? new Set() - const waitedBefore = - session.dispatchEchoes.answeredTurnLeftUnopened(session.threadId, open()) === turnId const first = await interrupt() if (first.cancelled || first.refusal?.detail?.text !== CODEX_NO_ACTIVE_TURN_TO_INTERRUPT) { return first @@ -121,7 +119,7 @@ export async function interruptOpeningCodexTurn( const target = codexStopTarget(session) return target !== null && 'opening' in target && target.opening === turnId } - if (!opened() && stillOpening() && !waitedBefore) { + if (!opened() && stillOpening()) { await session.turnOpenWaits.wait(turnId, CODEX_TURN_OPEN_WAIT_MS) if (!stillCurrent()) { return { cancelled: false } @@ -134,7 +132,6 @@ export async function interruptOpeningCodexTurn( // It ended: the Stop found no turn running. return { cancelled: false } } - session.dispatchEchoes.leftUnopened(session.threadId, turnId) return { cancelled: false, refusal: { turnMayOpen: true } } } From a2ba0c564f59d3a1aca5cee07399e5741c409320 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:29:05 -0700 Subject: [PATCH 09/16] fix(native-chat): a Codex Stop that ends the child before any turn opened withdraws its send A Stop on a Codex turn that was answered but never opened ends the Codex process. That end settled the send as in doubt (unknown, recovered), and the client's outbox holds every later send behind a send in doubt until the person presses Retry, which re-sends the very message they stopped. The chat looked stuck. Codex records a prompt only once its turn has started, so a send whose turn never opened never ran. When the Stop's refusal says so (turnMayOpen), the child end now settles the unanswered sends as withdrawn, the verdict Codex's own interrupted-turn end already gives an unechoed send. The flag rides on the owed wind-down, so a retry after a failed child end withdraws them too. Claude's child end still leaves its unanswered send in doubt. --- .../structured-agent-session-chat-stop.ts | 3 +- ...gent-session-dead-generation-settlement.ts | 8 +- .../structured-agent-session-host-lifetime.ts | 22 +++++- ...tructured-agent-session-host-stop-event.ts | 7 +- .../structured-agent-session-host-types.ts | 3 + .../structured-agent-session-turns-cancel.ts | 4 +- ...-session-codex-turn-end-settlement.test.ts | 51 ++++++++++++ ...gent-session-outbox-withdrawn-send.test.ts | 78 +++++++++++++++++++ 8 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 src/shared/structured-agent-session-outbox-withdrawn-send.test.ts 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 b55ebbda2d79..023a9eee0572 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 @@ -60,7 +60,8 @@ export function mutateWithChatStop( let eventAfterEnd: Promise | undefined const named = turnId !== undefined ? { turnId } : {} // Its own step wrote the Stop's event first. - const stopChild = () => context.stopAgent(sessionId, { recorded: 'user-stop' }) + const stopChild = (options: { noTurnOpened?: true } = {}) => + context.stopAgent(sessionId, { recorded: 'user-stop', ...options }) // 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 => 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 39ff1c7ba514..3f90c968bda0 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 @@ -17,6 +17,7 @@ import { cancelledJournalPromptBody } from '../agent-session-journal/journal-pro import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { agentSessionFailureWords, + type AgentJournalDispatchRejection, type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' @@ -116,6 +117,8 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { settlementId: string verdict: StructuredAgentSessionTurnVerdict pendingSubmissionReason: string + /** Settles the unacknowledged sends as this rejection instead of in doubt. */ + withdrawPending?: AgentJournalDispatchRejection showUnexpectedExitOutcome?: boolean /** Why the provider stopped, as the adapter told it; the row's sentence is this fact's. */ exitFailure?: SubmissionRejectionFact @@ -138,8 +141,9 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { const startupFailure = input.exitedDuringStartup ? structuredAgentSessionStartFailure({ exit: input.exitFailure }, input.failureTextContext) : null - await (startupFailure - ? input.journal.rejectPendingSubmissions(input.fence, startupFailure) + const rejection = startupFailure ?? input.withdrawPending + await (rejection + ? input.journal.rejectPendingSubmissions(input.fence, rejection) : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) const items = input.journal.snapshot().items const mutations: JournalLifecycleMutationInput[] = [] 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 2c312ef17e84..453967498241 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 @@ -106,7 +106,8 @@ function owedProviderChildWindDown( function owedStop( session: StructuredAgentSessionHostSession, cause: StructuredAgentSessionStopCause, - retry: boolean + retry: boolean, + noTurnOpened: boolean ): StructuredAgentSessionOwedWindDown | undefined { const owed = owedProviderChildWindDown(session) if (!owed) { @@ -118,7 +119,8 @@ function owedStop( generation: owed.generation, fence: owed.fence, cause, - requestedAt: continues ? asked.requestedAt : session.journal.cursor() + requestedAt: continues ? asked.requestedAt : session.journal.cursor(), + ...(noTurnOpened ? { noTurnOpened: true as const } : {}) } } @@ -147,7 +149,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( 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) + const noTurnOpened = 'recorded' in ending && ending.noTurnOpened === true + const owed = owedStop(session, cause, ending.retry === true, noTurnOpened) session.owesProviderChildWindDown = owed const stopping = session.child const eviction: StructuredAgentSessionEvictionContext = { @@ -190,6 +193,13 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( fence, settlementId: `expected-close:${sessionId}:${fence}:${owed?.generation ?? 'unknown'}`, pendingSubmissionReason: 'provider_closed_before_acknowledgement', + ...(noTurnOpened + ? { + withdrawPending: agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' + }) + } + : {}), // 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() }, @@ -261,7 +271,11 @@ export async function finishOwedStructuredAgentSessionWindDownUnderSerialize( context, sessionId, owed.cause === 'user-stop' - ? { recorded: 'user-stop', retry: true } + ? { + recorded: 'user-stop', + retry: true, + ...(owed.noTurnOpened ? { noTurnOpened: true } : {}) + } : { cause: owed.cause, retry: true } ) } 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 index 70cfd07bb7a6..3a5daade6638 100644 --- 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 @@ -14,7 +14,12 @@ import { sentSinceStop } from './structured-agent-session-queued-stop' * 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' } + | { + recorded: 'user-stop' + /** The provider said no turn of this child opened: the sends it leaves unanswered never ran, + * so the Stop withdraws them. */ + noTurnOpened?: true + } | { cause: Exclude reason?: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 6552f8a26016..1bc532361e8f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -45,6 +45,9 @@ export type StructuredAgentSessionOwedWindDown = StructuredAgentSessionProviderC readonly requestedAt: AgentJournalCursor /** Where it stood once the newest pass failed: a message accepted by then waited through a retry. */ readonly failedAt?: AgentJournalCursor + /** A person's Stop the provider answered with no turn of this child open: a retry withdraws + * the unanswered sends as that Stop would have. */ + readonly noTurnOpened?: true } /** The provider process behind a conversation. Written only in diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index 187b934400df..5a5c3fdd8201 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -168,7 +168,7 @@ type PerformCancelInput = { prompt?: { itemId: string; expectedRevision: number } /** Ends the provider child, for a running command the provider did not take the Stop on, or a * turn whose interrupt failed. */ - stopChild?: () => Promise + stopChild?: (options?: { noTurnOpened?: true }) => Promise /** A child end after a failed interrupt that threw. */ onStopChildError?: (error: unknown) => void /** After that throw: whether the host let go of the child, its exit proven before a later @@ -306,7 +306,7 @@ async function cancelAndNote( // The interrupt failed and the turn runs on: only the child's end stops it. let ended: boolean try { - await input.stopChild() + await input.stopChild(refusal?.turnMayOpen ? { noTurnOpened: true } : {}) ended = true } catch (error) { input.onStopChildError?.(error) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 7502ab93b77c..5b4f819ace43 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -78,6 +78,9 @@ let answers: number let steers: number let interrupts: number let childCloses: number +let connections: number +/** Closes that fail before one goes through, as a kill that times out. */ +let failingCloses: number let turns: ReturnType let operations = 0 @@ -176,6 +179,8 @@ beforeEach(async () => { steers = 0 interrupts = 0 childCloses = 0 + connections = 0 + failingCloses = 0 turns = codexTurnLifecycleFake( THREAD, () => (method, params) => handlers?.onNotification?.(method, params) @@ -185,6 +190,7 @@ beforeEach(async () => { connectionHandlers = {} ) => { handlers = connectionHandlers + connections += 1 const connection: CodexAppServerConnection = { pid: 4321, closed: false, @@ -213,6 +219,10 @@ beforeEach(async () => { respond: () => {}, respondWithError: () => {}, close: async () => { + if (failingCloses > 0) { + failingCloses -= 1 + throw new Error('the kill timed out') + } childCloses += 1 return true } @@ -566,6 +576,47 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { expect((await settled()).owesWork).toBe(false) expect(turnRow((await host.journalSnapshot(SESSION)).items)).toBeUndefined() }) + + // Codex records a prompt only once its turn starts, so a send whose turn never opened never ran: + // the Stop takes it back, and no send in doubt holds the client's queue. + async function stoppedBeforeItsTurnOpened(): Promise { + const release = turns.holdNextAnswer() + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + const stopping = stop() + release() + expect(await settledWithin(stopping, CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') + return sent + } + + async function nextSendStartsANewChild(): Promise { + turns = codexTurnLifecycleFake( + THREAD, + () => (method, params) => handlers?.onNotification?.(method, params) + ) + await send('try again') + await vi.waitFor(() => expect(answers).toBe(2)) + expect(connections).toBe(2) + } + + it('withdraws the send whose turn never opened, and the next send starts a new child', async () => { + const sent = await stoppedBeforeItsTurnOpened() + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + await nextSendStartsANewChild() + }) + + it('withdraws it too when the child end fails and a later retry lands it', async () => { + failingCloses = 1 + const sent = await stoppedBeforeItsTurnOpened() + expect(childCloses).toBe(0) + + await nextSendStartsANewChild() + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + }) }) describe('a cold send with no Stop behind it', () => { diff --git a/src/shared/structured-agent-session-outbox-withdrawn-send.test.ts b/src/shared/structured-agent-session-outbox-withdrawn-send.test.ts new file mode 100644 index 000000000000..ff127c8baabf --- /dev/null +++ b/src/shared/structured-agent-session-outbox-withdrawn-send.test.ts @@ -0,0 +1,78 @@ +// A send the host withdrew (a Stop took it back before it ran) leaves the outbox with no notice, so +// the next queued send goes out. One the host left in doubt holds the queue until its Retry. + +import { describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from './agent-session-failure' +import { agentSessionFailureWords } from './agent-session-failure-words' +import type { AgentJournalSubmission } from './agent-session-journal-types' +import { + createStructuredAgentSessionOutboxEntry, + reconcileStructuredAgentSessionOutbox, + type StructuredAgentSessionOutboxEntry +} from './structured-agent-session-outbox' +import { admitStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox-admission' + +function entry( + clientMessageId: string, + patch: Partial = {} +): StructuredAgentSessionOutboxEntry { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId, + sessionId: 'session-1', + text: clientMessageId, + attachments: [], + queuedAt: 1 + }), + ...patch + } +} + +function submission(patch: Partial): AgentJournalSubmission { + return { + clientMessageId: 'stopped', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 2, + ...patch + } +} + +const OUTBOX = [entry('stopped', { state: 'dispatching', lastAttemptAt: 1 }), entry('next')] + +describe('the send a Stop ended before its turn opened', () => { + it('leaves the outbox when withdrawn, and the next send goes out', () => { + const withdrawn = submission({ + dispatchState: 'rejected', + recovered: true, + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + }) + + const reconciled = reconcileStructuredAgentSessionOutbox(OUTBOX, [withdrawn]) + + expect(reconciled.map((each) => each.clientMessageId)).toEqual(['next']) + expect(admitStructuredAgentSessionOutboxEntry(reconciled)).toMatchObject({ + state: 'dispatch', + entry: { clientMessageId: 'next' } + }) + }) + + it('holds the queue when left in doubt', () => { + const inDoubt = submission({ + dispatchState: 'unknown', + recovered: true, + reason: 'provider_closed_before_acknowledgement' + }) + + const reconciled = reconcileStructuredAgentSessionOutbox(OUTBOX, [inDoubt]) + + expect(admitStructuredAgentSessionOutboxEntry(reconciled)).toMatchObject({ + state: 'blocked', + entry: { clientMessageId: 'stopped' } + }) + }) +}) From a6b8185302bcd588d04e76758666c4a0426beb90 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:40:59 -0700 Subject: [PATCH 10/16] fix(native-chat): derive the withdrawal of a Codex send whose turn never opened Replaces the flag the Stop carried to the child's end, and its copy on the owed wind-down, with a reading of the journal at the settlement that lands. A Codex child's unanswered send is withdrawn when a person's Stop is in force since it was sent and no turn row ran, or was written, after it; any other end (a turn that opened, a host's close, a crash, Claude) still leaves it in doubt. A retried wind-down reads the same rows, so it withdraws the same sends. --- .../agent-session-journal/journal-store.ts | 4 ++ .../structured-agent-session-chat-stop.ts | 3 +- ...gent-session-dead-generation-settlement.ts | 24 +++---- .../structured-agent-session-host-lifetime.ts | 22 ++---- ...tructured-agent-session-host-stop-event.ts | 7 +- .../structured-agent-session-host-types.ts | 3 - .../structured-agent-session-turns-cancel.ts | 4 +- ...-agent-session-unopened-send-withdrawal.ts | 72 +++++++++++++++++++ ...-session-codex-turn-end-settlement.test.ts | 45 ++++++++++-- 9 files changed, 135 insertions(+), 49 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts 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 5a4880155dec..67a2d9d04026 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -155,6 +155,10 @@ export class AgentSessionJournal { return this.state.epoch } + get agent(): AgentSessionJournalIdentity['agent'] { + return this.identity.agent + } + /** Whether a row at this sequence was on disk when this handle opened, so an earlier handle * wrote it. Sequences restart with each epoch, so a row of a later epoch never was. */ wroteBeforeOpen(sequence: number | undefined): boolean { 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 023a9eee0572..b55ebbda2d79 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 @@ -60,8 +60,7 @@ export function mutateWithChatStop( let eventAfterEnd: Promise | undefined const named = turnId !== undefined ? { turnId } : {} // Its own step wrote the Stop's event first. - const stopChild = (options: { noTurnOpened?: true } = {}) => - context.stopAgent(sessionId, { recorded: 'user-stop', ...options }) + 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 => 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 3f90c968bda0..116e198d00ba 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 @@ -17,7 +17,6 @@ import { cancelledJournalPromptBody } from '../agent-session-journal/journal-pro import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { agentSessionFailureWords, - type AgentJournalDispatchRejection, type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' @@ -38,23 +37,20 @@ import { exitedRootTurnScope, runningRootTurnScope } from './structured-agent-session-exit-turn-scope' +import { + withdrawCodexSendsNoTurnOpenedFor, + type UnopenedSendJournal +} from './structured-agent-session-unopened-send-withdrawal' /** Bounds the exit reason the lease keeps as log evidence; a provider diagnostic is held to the * same cap. */ export const MAX_UNEXPECTED_EXIT_REASON_CHARS = MAX_PROVIDER_DIAGNOSTIC_CHARS -type DeadGenerationSubmission = Pick< - ReturnType[number], - 'clientMessageId' | 'dispatchState' | 'recovered' | 'handoverRecorded' | 'handedOverAt' -> - -export type DeadGenerationJournal = { +export type DeadGenerationJournal = UnopenedSendJournal & { appendLifecycleBatch: AgentSessionJournal['appendLifecycleBatch'] markPendingSubmissionsUnknown: AgentSessionJournal['markPendingSubmissionsUnknown'] rejectPendingSubmissions: AgentSessionJournal['rejectPendingSubmissions'] - snapshot: () => Pick, 'items'> pendingSubmissions?: AgentSessionJournal['pendingSubmissions'] - submissions?: () => DeadGenerationSubmission[] } export type StructuredAgentSessionUnfinishedWork = { @@ -117,8 +113,6 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { settlementId: string verdict: StructuredAgentSessionTurnVerdict pendingSubmissionReason: string - /** Settles the unacknowledged sends as this rejection instead of in doubt. */ - withdrawPending?: AgentJournalDispatchRejection showUnexpectedExitOutcome?: boolean /** Why the provider stopped, as the adapter told it; the row's sentence is this fact's. */ exitFailure?: SubmissionRejectionFact @@ -141,9 +135,11 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { const startupFailure = input.exitedDuringStartup ? structuredAgentSessionStartFailure({ exit: input.exitFailure }, input.failureTextContext) : null - const rejection = startupFailure ?? input.withdrawPending - await (rejection - ? input.journal.rejectPendingSubmissions(input.fence, rejection) + if (!startupFailure) { + await withdrawCodexSendsNoTurnOpenedFor(input.journal, input.fence) + } + await (startupFailure + ? input.journal.rejectPendingSubmissions(input.fence, startupFailure) : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) const items = input.journal.snapshot().items const mutations: JournalLifecycleMutationInput[] = [] 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 453967498241..2c312ef17e84 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 @@ -106,8 +106,7 @@ function owedProviderChildWindDown( function owedStop( session: StructuredAgentSessionHostSession, cause: StructuredAgentSessionStopCause, - retry: boolean, - noTurnOpened: boolean + retry: boolean ): StructuredAgentSessionOwedWindDown | undefined { const owed = owedProviderChildWindDown(session) if (!owed) { @@ -119,8 +118,7 @@ function owedStop( generation: owed.generation, fence: owed.fence, cause, - requestedAt: continues ? asked.requestedAt : session.journal.cursor(), - ...(noTurnOpened ? { noTurnOpened: true as const } : {}) + requestedAt: continues ? asked.requestedAt : session.journal.cursor() } } @@ -149,8 +147,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( 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 noTurnOpened = 'recorded' in ending && ending.noTurnOpened === true - const owed = owedStop(session, cause, ending.retry === true, noTurnOpened) + const owed = owedStop(session, cause, ending.retry === true) session.owesProviderChildWindDown = owed const stopping = session.child const eviction: StructuredAgentSessionEvictionContext = { @@ -193,13 +190,6 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( fence, settlementId: `expected-close:${sessionId}:${fence}:${owed?.generation ?? 'unknown'}`, pendingSubmissionReason: 'provider_closed_before_acknowledgement', - ...(noTurnOpened - ? { - withdrawPending: agentSessionFailureWords(agentSessionFailureFact('cancelled'), { - surface: 'rejection' - }) - } - : {}), // 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() }, @@ -271,11 +261,7 @@ export async function finishOwedStructuredAgentSessionWindDownUnderSerialize( context, sessionId, owed.cause === 'user-stop' - ? { - recorded: 'user-stop', - retry: true, - ...(owed.noTurnOpened ? { noTurnOpened: true } : {}) - } + ? { recorded: 'user-stop', retry: true } : { cause: owed.cause, retry: true } ) } 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 index 3a5daade6638..70cfd07bb7a6 100644 --- 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 @@ -14,12 +14,7 @@ import { sentSinceStop } from './structured-agent-session-queued-stop' * 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' - /** The provider said no turn of this child opened: the sends it leaves unanswered never ran, - * so the Stop withdraws them. */ - noTurnOpened?: true - } + | { recorded: 'user-stop' } | { cause: Exclude reason?: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 1bc532361e8f..6552f8a26016 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -45,9 +45,6 @@ export type StructuredAgentSessionOwedWindDown = StructuredAgentSessionProviderC readonly requestedAt: AgentJournalCursor /** Where it stood once the newest pass failed: a message accepted by then waited through a retry. */ readonly failedAt?: AgentJournalCursor - /** A person's Stop the provider answered with no turn of this child open: a retry withdraws - * the unanswered sends as that Stop would have. */ - readonly noTurnOpened?: true } /** The provider process behind a conversation. Written only in diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index 5a5c3fdd8201..187b934400df 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -168,7 +168,7 @@ type PerformCancelInput = { prompt?: { itemId: string; expectedRevision: number } /** Ends the provider child, for a running command the provider did not take the Stop on, or a * turn whose interrupt failed. */ - stopChild?: (options?: { noTurnOpened?: true }) => Promise + stopChild?: () => Promise /** A child end after a failed interrupt that threw. */ onStopChildError?: (error: unknown) => void /** After that throw: whether the host let go of the child, its exit proven before a later @@ -306,7 +306,7 @@ async function cancelAndNote( // The interrupt failed and the turn runs on: only the child's end stops it. let ended: boolean try { - await input.stopChild(refusal?.turnMayOpen ? { noTurnOpened: true } : {}) + await input.stopChild() ended = true } catch (error) { input.onStopChildError?.(error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts new file mode 100644 index 000000000000..7e1a6a629ce3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts @@ -0,0 +1,72 @@ +// Which unanswered sends a dying Codex child takes with it as never sent, read from the journal at +// the settlement that lands, so a retried wind-down reads the same rows. + +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentType } from '../../../shared/agent-status-types' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' + +/** What the derivation reads; absent members (a narrow double) withdraw nothing. */ +export type UnopenedSendJournal = { + agent?: AgentType + queuedMessages?: Pick + snapshot: () => Pick, 'items'> + submissions?: () => Pick< + AgentJournalSubmission, + | 'clientMessageId' + | 'dispatchState' + | 'recovered' + | 'handoverRecorded' + | 'handedOverAt' + | 'acceptedSequence' + >[] + resolveDispatch?: AgentSessionJournal['resolveDispatch'] +} + +/** + * Withdraws the sends a Codex child left unanswered when a person's Stop, in force since they were + * sent, ends it and no turn opened for them: no turn row runs, or was written, after the send. + * Codex records a prompt only once its turn starts (core tasks/regular.rs:50, + * session/turn.rs:886-902), so they never ran. Any other end leaves them in doubt. + */ +export async function withdrawCodexSendsNoTurnOpenedFor( + journal: UnopenedSendJournal, + fence: number +): Promise { + const stop = journal.agent === 'codex' ? journal.queuedMessages?.userStopInForce() : null + if (!stop || !journal.submissions || !journal.resolveDispatch) { + return + } + const turns = journal.snapshot().items.flatMap((item) => { + const turn = readAgentJournalTurn(item.body) + return turn ? [{ running: turn.state === 'running', sequence: item.sequence }] : [] + }) + const opened = (acceptedSequence: number): boolean => + turns.some((turn) => turn.running || turn.sequence > acceptedSequence) + const unopened = journal + .submissions() + .filter( + (entry) => + !isQueuedAgentJournalSubmission(entry) && + (entry.dispatchState === 'pending' || + (entry.dispatchState === 'unknown' && entry.recovered !== true)) && + entry.acceptedSequence !== undefined && + entry.acceptedSequence < stop.sequence && + !opened(entry.acceptedSequence) + ) + const withdrawn = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' + }) + for (const entry of unopened) { + await journal.resolveDispatch({ + clientMessageId: entry.clientMessageId, + state: 'rejected', + ...withdrawn, + fence, + recovered: true + }) + } +} diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 5b4f819ace43..66c25017b296 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -10,10 +10,11 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - CodexAppServerConnection, - CodexAppServerConnectionHandlers, - openCodexAppServerConnection +import { + CodexAppServerRequestError, + type CodexAppServerConnection, + type CodexAppServerConnectionHandlers, + type openCodexAppServerConnection } from '../codex/codex-app-server-connection' import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' import { settledWithin } from '../codex/codex-structured-dispatch-test-support' @@ -81,6 +82,8 @@ let childCloses: number let connections: number /** Closes that fail before one goes through, as a kill that times out. */ let failingCloses: number +/** Codex fails every interrupt it is sent, as one it could not submit (-32603). */ +let interruptsFail: boolean let turns: ReturnType let operations = 0 @@ -181,6 +184,7 @@ beforeEach(async () => { childCloses = 0 connections = 0 failingCloses = 0 + interruptsFail = false turns = codexTurnLifecycleFake( THREAD, () => (method, params) => handlers?.onNotification?.(method, params) @@ -209,6 +213,15 @@ beforeEach(async () => { steers += 1 return turns.routes['turn/steer'](params) } + if (method === 'turn/interrupt' && interruptsFail) { + interrupts += 1 + throw new CodexAppServerRequestError( + 'turn/interrupt', + -32603, + 'codex app-server turn/interrupt failed: could not submit', + 'could not submit' + ) + } if (method === 'turn/interrupt') { interrupts += 1 return turns.routes['turn/interrupt'](params) @@ -617,6 +630,19 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { expect(childCloses).toBe(1) expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') }) + + // A turn that opened may hold the send: the child's end leaves it in doubt, as before. + it('leaves a send in doubt when its turn opened before the child end', async () => { + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + interruptsFail = true + + await stop() + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, sent)).toBe('unknown') + }) }) describe('a cold send with no Stop behind it', () => { @@ -636,6 +662,17 @@ describe('a cold send with no Stop behind it', () => { expect(childCloses).toBe(1) }) + // Only a person's Stop takes a send back: a host's close leaves it in doubt, as before. + it('leaves its send in doubt when the host closes the chat', async () => { + await answeredColdSend() + const sent = (await settled()).submissions[0]?.clientMessageId ?? '' + + await host.close(SESSION, 'evict') + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, sent)).toBe('unknown') + }) + it('never delays quitting the app', async () => { await answeredColdSend() From 70601f5cb325b4e7bdbfc36fe54befb4af3254ab Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:19:22 -0700 Subject: [PATCH 11/16] fix(native-chat): keep a send in doubt when a turn was open for it The derived withdrawal read a turn as open for a send only if it still ran or was written after the send. A send steered into a running Codex turn whose interrupt failed met neither once the adapter's end settled that turn ahead of the host's settle, so it read withdrawn, though Codex drains a steer into the running turn and may hold it. A turn that ended after the send was handed over was open for it too: such a send stays in doubt, as before. Pins that case, and that a send made after the Stop, to a child that then dies before its turn opens, stays in doubt. --- ...-agent-session-unopened-send-withdrawal.ts | 30 +++++++++++----- ...-session-codex-turn-end-settlement.test.ts | 35 +++++++++++++++++-- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts index 7e1a6a629ce3..fb435cc5266c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts @@ -14,7 +14,7 @@ export type UnopenedSendJournal = { agent?: AgentType queuedMessages?: Pick snapshot: () => Pick, 'items'> - submissions?: () => Pick< + submissions?: () => (Pick< AgentJournalSubmission, | 'clientMessageId' | 'dispatchState' @@ -22,15 +22,17 @@ export type UnopenedSendJournal = { | 'handoverRecorded' | 'handedOverAt' | 'acceptedSequence' - >[] + > & + Partial>)[] resolveDispatch?: AgentSessionJournal['resolveDispatch'] } /** * Withdraws the sends a Codex child left unanswered when a person's Stop, in force since they were - * sent, ends it and no turn opened for them: no turn row runs, or was written, after the send. - * Codex records a prompt only once its turn starts (core tasks/regular.rs:50, - * session/turn.rs:886-902), so they never ran. Any other end leaves them in doubt. + * sent, ends it and no turn was open for them: none runs, was written after the send, or ended + * after it was handed over. Codex records a prompt only once its turn starts (core + * tasks/regular.rs:50, session/turn.rs:886-902), so they never ran. A send steered into an open + * turn may have been recorded there, so it, and any other end, stays in doubt. */ export async function withdrawCodexSendsNoTurnOpenedFor( journal: UnopenedSendJournal, @@ -42,10 +44,20 @@ export async function withdrawCodexSendsNoTurnOpenedFor( } const turns = journal.snapshot().items.flatMap((item) => { const turn = readAgentJournalTurn(item.body) - return turn ? [{ running: turn.state === 'running', sequence: item.sequence }] : [] + return turn + ? [ + { + running: turn.state === 'running', + sequence: item.sequence, + completedAt: turn.completedAt ?? 0 + } + ] + : [] }) - const opened = (acceptedSequence: number): boolean => - turns.some((turn) => turn.running || turn.sequence > acceptedSequence) + const opened = (acceptedSequence: number, sentAt: number): boolean => + turns.some( + (turn) => turn.running || turn.sequence > acceptedSequence || turn.completedAt >= sentAt + ) const unopened = journal .submissions() .filter( @@ -55,7 +67,7 @@ export async function withdrawCodexSendsNoTurnOpenedFor( (entry.dispatchState === 'unknown' && entry.recovered !== true)) && entry.acceptedSequence !== undefined && entry.acceptedSequence < stop.sequence && - !opened(entry.acceptedSequence) + !opened(entry.acceptedSequence, entry.handedOverAt ?? entry.submittedAt ?? 0) ) const withdrawn = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 66c25017b296..b07da933e106 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -602,14 +602,15 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { return sent } - async function nextSendStartsANewChild(): Promise { + async function nextSendStartsANewChild(): Promise { turns = codexTurnLifecycleFake( THREAD, () => (method, params) => handlers?.onNotification?.(method, params) ) - await send('try again') + const next = await send('try again') await vi.waitFor(() => expect(answers).toBe(2)) expect(connections).toBe(2) + return next } it('withdraws the send whose turn never opened, and the next send starts a new child', async () => { @@ -643,6 +644,36 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { expect(childCloses).toBe(1) expect(verdictOf((await settled()).submissions, sent)).toBe('unknown') }) + + // Codex drains a steer into the running turn, so it may hold one it never echoed. + it('leaves a send steered into the open turn in doubt when the child end follows', async () => { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(opening) + const steered = await send('and check the tests') + await vi.waitFor(() => expect(steers).toBe(1)) + interruptsFail = true + + await stop('turn-1') + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, steered)).toBe('unknown') + }) + + // Sent after the Stop, to a child that then dies before its turn opens: the Stop, still in force, + // was never this send's. + it('leaves a send made after the Stop in doubt when its child dies', async () => { + await stoppedBeforeItsTurnOpened() + const next = await nextSendStartsANewChild() + + handlers?.onExit?.(new Error('codex app-server exited')) + + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, next)).not.toBe('pending') + ) + expect(verdictOf((await settled()).submissions, next)).toBe('unknown') + }) }) describe('a cold send with no Stop behind it', () => { From 8e3240ea389654c83bf9a3aaad2038d33cb0b878 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:49:48 -0700 Subject: [PATCH 12/16] fix(native-chat): withdraw a Codex send by whether it started its own turn, not by timing Whether a turn was open for a send was read from end times: a turn that ended after the send's handover counted. A send made while a Stop ended the turn is handed over once that turn reads ended, yet Codex's own end for it can arrive later, so such a send whose own turn never opened read in doubt again, and the chat's queue held behind it. The handover already records where the send went: its message joins the turn running then (a steer) or belongs to no turn (it starts its own). Only a send that started its own turn, with none opened since, is withdrawn; one that joined a running turn, or has no recorded place, stays in doubt. The Codex test fake takes an answered interrupt as Codex does, dropping the turn before its end arrives. --- src/main/codex/codex-turn-lifecycle-fake.ts | 36 ++++++++++++---- ...-agent-session-unopened-send-withdrawal.ts | 41 +++++++++---------- ...-session-codex-turn-end-settlement.test.ts | 29 +++++++++++++ 3 files changed, 77 insertions(+), 29 deletions(-) diff --git a/src/main/codex/codex-turn-lifecycle-fake.ts b/src/main/codex/codex-turn-lifecycle-fake.ts index dda6060ee3a6..e0747f38b0b0 100644 --- a/src/main/codex/codex-turn-lifecycle-fake.ts +++ b/src/main/codex/codex-turn-lifecycle-fake.ts @@ -29,7 +29,10 @@ export type CodexTurnLifecycleFake = { /** The thread runs the picked turn, whose `turn/started` has yet to be read: an interrupt of it * is taken, and that `turn/started` comes ahead of the answer. */ run: () => void - /** Codex ends the picked or running turn on its own. */ + /** Codex takes an interrupt of the running turn: it drops it as its active turn before it + * answers, and that turn's `turn/completed` comes later (`end`). */ + takeInterrupt: () => string + /** Codex ends the picked or running turn on its own, or the one whose interrupt it took. */ end: (status: 'completed' | 'interrupted' | 'failed', errorMessage?: string) => void /** Codex fails the picked turn before starting it, which reports an `error` and no turn end. */ failUnopened: (message: string) => void @@ -59,15 +62,32 @@ export function codexTurnLifecycleFake( let lastTurn: string | null = null let held: Promise | null = null let pickedRuns = false + // The turn whose interrupt Codex took and whose `turn/completed` is still to come. + let aborting: string | null = null const finish = (turnId: string, status: string, errorMessage?: string): void => { - picked = null - active = null - pickedRuns = false + // A taken interrupt's turn is no longer active: a turn picked since stays picked. + if (aborting === turnId) { + aborting = null + } else { + picked = null + active = null + pickedRuns = false + } notify()('turn/completed', { threadId, turn: { id: turnId, status, ...(errorMessage ? { error: { message: errorMessage } } : {}) } }) } + + const takeInterrupt = (): string => { + if (!active) { + throw new Error('no running turn to interrupt') + } + aborting = active + active = null + picked = null + return aborting + } const open = (): void => { if (!picked) { throw new Error('no picked turn to start') @@ -117,10 +137,10 @@ export function codexTurnLifecycleFake( `expected active turn id ${String(turnId)} but found ${active}` ) } - const aborted = active + const aborted = takeInterrupt() // Answered, then the turn's end on the next read; a turn that ends meanwhile is not ended twice. setTimeout(() => { - if (active === aborted) { + if (aborting === aborted) { finish(aborted, 'interrupted') } }, 0) @@ -135,6 +155,7 @@ export function codexTurnLifecycleFake( return () => release() }, start: () => open(), + takeInterrupt, run: () => { if (!picked || active) { throw new Error('no picked turn to run') @@ -142,7 +163,8 @@ export function codexTurnLifecycleFake( pickedRuns = true }, end: (status, errorMessage) => { - const turnId = active ?? picked + // A taken interrupt's turn/completed comes ahead of anything a later turn sends. + const turnId = aborting ?? active ?? picked if (!turnId) { throw new Error('no turn to end') } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts index fb435cc5266c..25fcbbfebc47 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts @@ -4,6 +4,7 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import type { AgentType } from '../../../shared/agent-status-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' @@ -14,7 +15,7 @@ export type UnopenedSendJournal = { agent?: AgentType queuedMessages?: Pick snapshot: () => Pick, 'items'> - submissions?: () => (Pick< + submissions?: () => Pick< AgentJournalSubmission, | 'clientMessageId' | 'dispatchState' @@ -22,17 +23,17 @@ export type UnopenedSendJournal = { | 'handoverRecorded' | 'handedOverAt' | 'acceptedSequence' - > & - Partial>)[] + >[] resolveDispatch?: AgentSessionJournal['resolveDispatch'] } /** * Withdraws the sends a Codex child left unanswered when a person's Stop, in force since they were - * sent, ends it and no turn was open for them: none runs, was written after the send, or ended - * after it was handed over. Codex records a prompt only once its turn starts (core - * tasks/regular.rs:50, session/turn.rs:886-902), so they never ran. A send steered into an open - * turn may have been recorded there, so it, and any other end, stays in doubt. + * sent, ends it: each one that started its own turn (handed over with no turn running, so its + * message belongs to no turn) when no turn has opened since. Codex records a prompt only once its + * turn starts (core tasks/regular.rs:50, session/turn.rs:886-902), so they never ran. A send that + * joined a running turn may be in it, so it, a send with no recorded place, and any other end stay + * in doubt. */ export async function withdrawCodexSendsNoTurnOpenedFor( journal: UnopenedSendJournal, @@ -42,22 +43,17 @@ export async function withdrawCodexSendsNoTurnOpenedFor( if (!stop || !journal.submissions || !journal.resolveDispatch) { return } - const turns = journal.snapshot().items.flatMap((item) => { + const { items } = journal.snapshot() + const turns = items.flatMap((item) => { const turn = readAgentJournalTurn(item.body) - return turn - ? [ - { - running: turn.state === 'running', - sequence: item.sequence, - completedAt: turn.completedAt ?? 0 - } - ] - : [] + return turn ? [{ running: turn.state === 'running', sequence: item.sequence }] : [] }) - const opened = (acceptedSequence: number, sentAt: number): boolean => - turns.some( - (turn) => turn.running || turn.sequence > acceptedSequence || turn.completedAt >= sentAt - ) + // Its message's place, recorded at handover: the turn it joined, or the conversation. + const startedOwnTurn = (clientMessageId: string): boolean => + items.find((item) => item.itemId === agentJournalSubmissionKey(clientMessageId))?.turnScope + ?.kind === 'thread' + const opened = (acceptedSequence: number): boolean => + turns.some((turn) => turn.running || turn.sequence > acceptedSequence) const unopened = journal .submissions() .filter( @@ -67,7 +63,8 @@ export async function withdrawCodexSendsNoTurnOpenedFor( (entry.dispatchState === 'unknown' && entry.recovered !== true)) && entry.acceptedSequence !== undefined && entry.acceptedSequence < stop.sequence && - !opened(entry.acceptedSequence, entry.handedOverAt ?? entry.submittedAt ?? 0) + startedOwnTurn(entry.clientMessageId) && + !opened(entry.acceptedSequence) ) const withdrawn = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index b07da933e106..500d795ed0cc 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -84,6 +84,8 @@ let connections: number let failingCloses: number /** Codex fails every interrupt it is sent, as one it could not submit (-32603). */ let interruptsFail: boolean +/** Codex takes the interrupt and answers it; the test sends that turn's end later. */ +let interruptEndHeld: boolean let turns: ReturnType let operations = 0 @@ -185,6 +187,7 @@ beforeEach(async () => { connections = 0 failingCloses = 0 interruptsFail = false + interruptEndHeld = false turns = codexTurnLifecycleFake( THREAD, () => (method, params) => handlers?.onNotification?.(method, params) @@ -222,6 +225,11 @@ beforeEach(async () => { 'could not submit' ) } + if (method === 'turn/interrupt' && interruptEndHeld) { + interrupts += 1 + turns.takeInterrupt() + return {} + } if (method === 'turn/interrupt') { interrupts += 1 return turns.routes['turn/interrupt'](params) @@ -645,6 +653,27 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { expect(verdictOf((await settled()).submissions, sent)).toBe('unknown') }) + // Handed over once the stopped turn read ended, it started its own turn, however late that + // turn's own end then arrives; a second Stop finds its turn never opened. + it('withdraws a send made after a Stop that started its own turn, which never opened', async () => { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(opening) + interruptEndHeld = true + await stop() + interruptEndHeld = false + const next = await send('run this after the stop') + await vi.waitFor(() => expect(answers).toBe(2)) + // The stopped turn's own end arrives after the next send was handed over. + turns.end('interrupted') + + expect(await settledWithin(stop(), CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, next)).toBe('withdrawn') + }) + // Codex drains a steer into the running turn, so it may hold one it never echoed. it('leaves a send steered into the open turn in doubt when the child end follows', async () => { const opening = await send('look around') From 5b6005712806125c51b4aaf7d3b6cb02e04ce495 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:20:41 -0700 Subject: [PATCH 13/16] fix(native-chat): leave no Stop row when the Stop took back a send that never ran A Stop on a Codex send whose turn never opened ends the child, and the child's end takes the send back into the composer. The Stop still wrote "Cancellation requested." at the conversation level, so with the send gone it sat under the previous finished turn and read as if that turn had been stopped. A Stop that found no turn running and whose child end took back every send it found now writes no row; a Stop of a running turn, or one that leaves a send in doubt, still does. --- ...ed-agent-session-conversation-stop.test.ts | 13 +- ...t-session-stop-note-withdrawn-send.test.ts | 118 ++++++++++++++++++ .../structured-agent-session-turns-cancel.ts | 32 ++++- ...-session-codex-turn-end-settlement.test.ts | 47 +++++-- 4 files changed, 192 insertions(+), 18 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts 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 aab219c779d6..6f03cc1345cc 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 @@ -240,7 +240,9 @@ describe('a Stop that names no turn', () => { expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) - expect(await statusRows()).toEqual(['Cancellation requested.']) + // Its turn never opened, so the child's end took the send back: no row stays to report on it. + expect((await submission(id))?.dispatchState).toBe('rejected') + expect(await statusRows()).toEqual([]) }) it('says the agent did not stop, in its words, when it refused because its turn is not running', async () => { @@ -271,7 +273,9 @@ describe('a Stop that names no turn', () => { expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) - expect(await statusRows()).toEqual(['Cancellation requested.']) + // Its turn never opened, so the child's end took the send back: no row stays to report on it. + expect((await submission(id))?.dispatchState).toBe('rejected') + expect(await statusRows()).toEqual([]) }) it('says the agent did not stop, in its words, when it could not interrupt and the child end is unproven', async () => { @@ -295,7 +299,7 @@ describe('a Stop that names no turn', () => { expect(await statusRows()).toEqual(["Codex didn't stop: failed to interrupt turn."]) }) - it('reads as requested when the child exit was proven and only a later cleanup step failed', async () => { + it('counts as stopped when the child exit was proven and only a later cleanup step failed', async () => { const { id, result } = send('hello') await result await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) @@ -312,7 +316,8 @@ describe('a Stop that names no turn', () => { fields: expect.objectContaining({ scope: 'stop-child', sessionId: SESSION }) }) ) - expect(await statusRows()).toEqual(['Cancellation requested.']) + expect((await submission(id))?.dispatchState).toBe('rejected') + expect(await statusRows()).toEqual([]) }) it('says the Stop is unconfirmed, not that nothing ran, when neither the interrupt nor the child end is proven', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts new file mode 100644 index 000000000000..33563091259b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts @@ -0,0 +1,118 @@ +// A Stop that ends the child after its interrupt failed: its row reports on the run it stopped, so +// one whose child end took back the send it found, with no turn running, leaves none. Its message +// is back in the composer, and a row would sit under the turn before as if that turn were stopped. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +let root: string | null = null +const journals = createTrackedJournalOpener() + +afterEach(async () => { + await journals.closeAll() + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } +}) + +async function stopEndingTheChild(options: { turnRunning: boolean; takesSendBack: boolean }) { + root = await mkdtemp(join(tmpdir(), 'orca-stop-note-withdrawn-')) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + if (options.turnRunning) { + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'turn', turnId: 'turn-1', state: 'running' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: 'send-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'look around' }] }, + fence: 1 + }) + const ctx: AgentSessionTurnContext = { + logger: createStructuredAgentSessionLogger(), + sessionId: 'session-1', + journal, + fence: 1, + adapter: { + acquire: vi.fn(), + dispatch: vi.fn(), + closeSession: vi.fn(), + cancelTurn: vi.fn(async () => ({ + cancelled: false, + refusal: { detail: { text: 'failed to interrupt turn', audience: 'person' as const } } + })), + answerPrompt: vi.fn(), + setOption: vi.fn() + }, + persistOptions: async () => undefined, + resolvedBy: 'client-1', + publish: vi.fn(), + now: () => 1 + } + const result = await performCancel(ctx, { + clientOperationId: 'stop-1', + stopChild: async () => { + if (options.takesSendBack) { + await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' + }), + fence: 1, + recovered: true + }) + } + } + }) + const rows = journal + .snapshot() + .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) + return { cancelled: result.ok && result.value.cancelled, rows } +} + +describe('a Stop that ends the child after its interrupt failed', () => { + it('leaves no row when the child end took back the send, with no turn running', async () => { + expect(await stopEndingTheChild({ turnRunning: false, takesSendBack: true })).toEqual({ + cancelled: true, + rows: [] + }) + }) + + it('says cancellation was requested when the send stays in doubt', async () => { + expect(await stopEndingTheChild({ turnRunning: false, takesSendBack: false })).toEqual({ + cancelled: true, + rows: ['Cancellation requested.'] + }) + }) + + it('says cancellation was requested on the turn it stopped, whatever became of the send', async () => { + expect(await stopEndingTheChild({ turnRunning: true, takesSendBack: true })).toEqual({ + cancelled: true, + rows: ['Cancellation requested.'] + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index 187b934400df..dfd8f4d9aa6f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -2,7 +2,11 @@ import { agentChildWorkStopTargets } from '../../../shared/agent-child-work-stop import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import type { AgentJournalStatusItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalStatusItem, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionCancelResult } from '../../../shared/agent-session-wire' import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation' @@ -54,6 +58,20 @@ function stillRunsStoppedTurn( return stoppedTurnId === null || ctx.journal.activeTurnId() === stoppedTurnId } +/** Whether the child's end took back every send the Stop found in flight, none of them having run + * (`withdrawCodexSendsNoTurnOpenedFor`). */ +function tookBackEverySend( + ctx: Pick, + inFlight: readonly AgentJournalSubmission[] +): boolean { + const rejected = new Set( + ctx.journal + .submissions() + .flatMap((entry) => (entry.dispatchState === 'rejected' ? [entry.clientMessageId] : [])) + ) + return inFlight.length > 0 && inFlight.every((entry) => rejected.has(entry.clientMessageId)) +} + /** The row for a Stop the provider declined, in its words when it gave any. One that could not * reach a turn still able to open says the Stop is unconfirmed, never that nothing ran. */ function stopRefusedNote( @@ -220,6 +238,10 @@ async function cancelAndNote( const endsSession = input.endSession !== undefined && ctx.adapter.stopEndsSession?.(ctx.sessionId) === true const stoppedAt = Date.now() + // Read before the cancel: the sends a child end may take back. + const sentBeforeStop = ctx.journal + .submissions() + .filter((entry) => !isQueuedAgentJournalSubmission(entry) && entry.dispatchState === 'pending') // The provider's own answer; unset when its cancel threw, leaving the effect unknown. let taken: boolean | undefined // The provider could not interrupt the turn, or its cancel threw: the turn may run on. @@ -315,7 +337,13 @@ async function cancelAndNote( } if (ended) { cancelled = true - note = { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } + // A child end that took back every send it found, with no turn running, ended a run that + // never started: its message is back in the composer, and a row would sit under the turn + // before as if that turn were stopped. + note = + stoppedTurnId === null && tookBackEverySend(ctx, sentBeforeStop) + ? null + : { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } } else if (taken !== undefined) { // The turn was just read running, so a named Stop says it was refused rather than nothing. note = stopRefusedNote(ctx, refusal) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 500d795ed0cc..b3048bdb7b20 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -170,6 +170,12 @@ async function settled(): Promise<{ } } +async function statusRows(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) +} + function verdictOf(submissions: readonly AgentJournalSubmission[], clientMessageId: string) { const submission = submissions.find((entry) => entry.clientMessageId === clientMessageId) return submission?.dispatchState === 'rejected' @@ -503,18 +509,12 @@ describe('a Stop sent after Codex answered a cold send, before it opened the tur }) describe('a Stop in that window that the turn never opens for', () => { - async function statusRows(): Promise { - return (await host.journalSnapshot(SESSION)).items.flatMap((item) => - item.body.kind === 'status' ? [item.body.text] : [] - ) - } - - async function waitingStop(): Promise<{ stopping: Promise }> { - await send('look around') + async function waitingStop(): Promise<{ stopping: Promise; sent: string }> { + const sent = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) const stopping = stop() expect(await settledWithin(stopping, 200)).toBe('held') - return { stopping } + return { stopping, sent } } it('says Codex had no turn running when that turn ends first', async () => { @@ -529,7 +529,7 @@ describe('a Stop in that window that the turn never opens for', () => { }) it('lets a chat closed behind it close within its bound and one eviction', async () => { - const { stopping } = await waitingStop() + const { stopping, sent } = await waitingStop() const closing = host.close(SESSION, 'evict') @@ -539,8 +539,9 @@ describe('a Stop in that window that the turn never opens for', () => { expect(await settledWithin(stopping, 0)).not.toBe('held') expect(childCloses).toBe(1) expect(interrupts).toBe(1) - // Its wait ran out with the turn still able to open, so the Stop ended the child. - expect(await statusRows()).toContain('Cancellation requested.') + // Its wait ran out with the turn still able to open, so the Stop ended the child and took the + // send back. + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') }) it('lets the app quit behind it within the eviction budget, and still close the child', async () => { @@ -629,6 +630,27 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { await nextSendStartsANewChild() }) + // Nothing it stopped stays in the transcript, so a Stop row would read as stopping the turn before. + it('leaves no Stop row under the turn before once it takes the send back', async () => { + const warmUp = await send('warm up') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(warmUp) + turns.end('completed') + const release = turns.holdNextAnswer() + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(2)) + const stopping = stop() + release() + expect(await settledWithin(stopping, CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') + + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + expect(await statusRows()).toEqual([]) + expect(turnRow((await host.journalSnapshot(SESSION)).items)).toMatchObject({ + state: 'completed' + }) + }) + it('withdraws it too when the child end fails and a later retry lands it', async () => { failingCloses = 1 const sent = await stoppedBeforeItsTurnOpened() @@ -651,6 +673,7 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { expect(childCloses).toBe(1) expect(verdictOf((await settled()).submissions, sent)).toBe('unknown') + expect(await statusRows()).toEqual(['Cancellation requested.']) }) // Handed over once the stopped turn read ended, it started its own turn, however late that From b47e69656097f63e5e3ed987c9c76ecd474bacfd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:51:30 -0700 Subject: [PATCH 14/16] fix(native-chat): count a send whose answer was lost when a Stop takes it back The no-row rule counted only pending sends, but the child's end also takes back a send this process left in doubt when Codex's turn/start answer was lost. That case still wrote "Cancellation requested." under the previous turn. Both now read one predicate, so they cannot drift apart. --- .../structured-agent-session-turns-cancel.ts | 6 ++--- ...-agent-session-unopened-send-withdrawal.ts | 19 +++++++++++--- ...-session-codex-turn-end-settlement.test.ts | 26 +++++++++++++++++++ 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index dfd8f4d9aa6f..6337d65f5537 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -6,7 +6,6 @@ import type { AgentJournalStatusItem, AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionCancelResult } from '../../../shared/agent-session-wire' import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation' @@ -28,6 +27,7 @@ import { } from './structured-agent-session-turn-stop-notes' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import { runningTurnLifecycleRevisions } from './structured-agent-session-stale-turn-verdict' +import { sendStopCanTakeBack } from './structured-agent-session-unopened-send-withdrawal' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' /** Whether the fold reads working. Every write has landed by its call's return, and the open paid @@ -239,9 +239,7 @@ async function cancelAndNote( input.endSession !== undefined && ctx.adapter.stopEndsSession?.(ctx.sessionId) === true const stoppedAt = Date.now() // Read before the cancel: the sends a child end may take back. - const sentBeforeStop = ctx.journal - .submissions() - .filter((entry) => !isQueuedAgentJournalSubmission(entry) && entry.dispatchState === 'pending') + const sentBeforeStop = ctx.journal.submissions().filter(sendStopCanTakeBack) // The provider's own answer; unset when its cancel threw, leaving the effect unknown. let taken: boolean | undefined // The provider could not interrupt the turn, or its cancel threw: the turn may run on. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts index 25fcbbfebc47..64a5296c0086 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts @@ -27,6 +27,21 @@ export type UnopenedSendJournal = { resolveDispatch?: AgentSessionJournal['resolveDispatch'] } +/** A send a dying child can take back: one still being handed over, or one this process left in + * doubt (its answer was lost). Never a queued card's, nor a send an earlier process left behind. */ +export function sendStopCanTakeBack( + entry: Pick< + AgentJournalSubmission, + 'dispatchState' | 'recovered' | 'handoverRecorded' | 'handedOverAt' + > +): boolean { + return ( + !isQueuedAgentJournalSubmission(entry) && + (entry.dispatchState === 'pending' || + (entry.dispatchState === 'unknown' && entry.recovered !== true)) + ) +} + /** * Withdraws the sends a Codex child left unanswered when a person's Stop, in force since they were * sent, ends it: each one that started its own turn (handed over with no turn running, so its @@ -58,9 +73,7 @@ export async function withdrawCodexSendsNoTurnOpenedFor( .submissions() .filter( (entry) => - !isQueuedAgentJournalSubmission(entry) && - (entry.dispatchState === 'pending' || - (entry.dispatchState === 'unknown' && entry.recovered !== true)) && + sendStopCanTakeBack(entry) && entry.acceptedSequence !== undefined && entry.acceptedSequence < stop.sequence && startedOwnTurn(entry.clientMessageId) && diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index b3048bdb7b20..cbd0339b6c14 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -651,6 +651,32 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { }) }) + // Its turn/start answer lost, the send is in doubt rather than pending; the child's end takes it + // back all the same, so it writes no row either. + it('leaves no Stop row under the turn before when it takes back a send whose answer was lost', async () => { + const warmUp = await send('warm up') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(warmUp) + turns.end('completed') + const answer = turns.routes['turn/start'] + turns.routes['turn/start'] = () => { + turns.routes['turn/start'] = answer + throw new Error('codex app-server request timed out: turn/start') + } + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(2)) + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, sent)).toBe('unknown') + ) + + expect(await settledWithin(stop(), CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + expect(await statusRows()).toEqual([]) + }) + it('withdraws it too when the child end fails and a later retry lands it', async () => { failingCloses = 1 const sent = await stoppedBeforeItsTurnOpened() From 22d2271710c0930540ad394f5cb8c415cee51c6e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:53:41 -0700 Subject: [PATCH 15/16] test(native-chat): pin which sends a Stop's child end can take back A send an earlier process left in doubt is never withdrawn and never holds the row back, and a queued card's send is never counted. --- ...t-session-stop-note-withdrawn-send.test.ts | 32 +++++- ...t-session-unopened-send-withdrawal.test.ts | 107 ++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts index 33563091259b..a3c8853ef67a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts @@ -35,7 +35,11 @@ afterEach(async () => { } }) -async function stopEndingTheChild(options: { turnRunning: boolean; takesSendBack: boolean }) { +async function stopEndingTheChild(options: { + turnRunning: boolean + takesSendBack: boolean + earlierProcessSendInDoubt?: boolean +}) { root = await mkdtemp(join(tmpdir(), 'orca-stop-note-withdrawn-')) const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) if (options.turnRunning) { @@ -45,6 +49,21 @@ async function stopEndingTheChild(options: { turnRunning: boolean; takesSendBack { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } + if (options.earlierProcessSendInDoubt) { + await journal.appendSubmission({ + clientMessageId: 'send-0', + payloadFingerprint: 'send-0', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'before the crash' }] }, + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'send-0', + state: 'unknown', + reason: 'host_restarted', + fence: 1, + recovered: true + }) + } await journal.appendSubmission({ clientMessageId: 'send-1', payloadFingerprint: 'send-1', @@ -109,6 +128,17 @@ describe('a Stop that ends the child after its interrupt failed', () => { }) }) + // That send was never this child's to take back, so it does not keep the row. + it("leaves no row when the only other send in doubt was an earlier process's", async () => { + expect( + await stopEndingTheChild({ + turnRunning: false, + takesSendBack: true, + earlierProcessSendInDoubt: true + }) + ).toEqual({ cancelled: true, rows: [] }) + }) + it('says cancellation was requested on the turn it stopped, whatever became of the send', async () => { expect(await stopEndingTheChild({ turnRunning: true, takesSendBack: true })).toEqual({ cancelled: true, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.test.ts new file mode 100644 index 000000000000..d4a78117a1e0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.test.ts @@ -0,0 +1,107 @@ +// Which sends a Stop's child end takes back: the same rule decides the withdrawal and whether the +// Stop writes a row, so a send it must leave alone is left alone by both. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + sendStopCanTakeBack, + withdrawCodexSendsNoTurnOpenedFor, + type UnopenedSendJournal +} from './structured-agent-session-unopened-send-withdrawal' + +type Send = Pick< + AgentJournalSubmission, + | 'clientMessageId' + | 'dispatchState' + | 'recovered' + | 'handoverRecorded' + | 'handedOverAt' + | 'acceptedSequence' +> + +/** A send that started its own turn before a person's Stop at sequence 10, no turn having opened. */ +function sendBeforeStop(overrides: Partial = {}): Send { + return { + clientMessageId: 'send-1', + dispatchState: 'pending', + handoverRecorded: true, + handedOverAt: 5, + acceptedSequence: 4, + ...overrides + } +} + +function journalWith(send: Send) { + const resolveDispatch = vi.fn(async () => ({ epoch: 'epoch-1', sequence: 11 })) + const journal: UnopenedSendJournal = { + agent: 'codex', + queuedMessages: { + userStopInForce: () => ({ sequence: 10, event: { reason: 'user-stop', at: 1 } }) + }, + snapshot: () => ({ + items: [ + { + itemId: agentJournalSubmissionKey(send.clientMessageId), + revision: 0, + sequence: 5, + observedAt: 5, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'look around' }] }, + turnScope: { kind: 'thread' } + } + ] + }), + submissions: () => [send], + resolveDispatch + } + return { journal, resolveDispatch } +} + +describe('a send a Stop can take back', () => { + it('is one still being handed over, or one this process lost the answer to', () => { + expect(sendStopCanTakeBack(sendBeforeStop())).toBe(true) + expect(sendStopCanTakeBack(sendBeforeStop({ dispatchState: 'unknown' }))).toBe(true) + }) + + // An earlier process's send: its child may have started it before the crash. + it('is never one an earlier process left in doubt', () => { + expect(sendStopCanTakeBack(sendBeforeStop({ dispatchState: 'unknown', recovered: true }))).toBe( + false + ) + }) + + // The card holds its text and returns paused; nothing was handed to the agent. + it("is never a queued card's send that was not handed over", () => { + expect(sendStopCanTakeBack(sendBeforeStop({ handedOverAt: undefined }))).toBe(false) + }) +}) + +describe("a Codex child's end under a person's Stop", () => { + it('withdraws a send whose answer this process lost', async () => { + const { journal, resolveDispatch } = journalWith(sendBeforeStop({ dispatchState: 'unknown' })) + + await withdrawCodexSendsNoTurnOpenedFor(journal, 1) + + expect(resolveDispatch).toHaveBeenCalledWith( + expect.objectContaining({ clientMessageId: 'send-1', state: 'rejected' }) + ) + }) + + it('leaves a send an earlier process left in doubt as it is', async () => { + const { journal, resolveDispatch } = journalWith( + sendBeforeStop({ dispatchState: 'unknown', recovered: true }) + ) + + await withdrawCodexSendsNoTurnOpenedFor(journal, 1) + + expect(resolveDispatch).not.toHaveBeenCalled() + }) + + it("leaves a queued card's send as it is", async () => { + const { journal, resolveDispatch } = journalWith(sendBeforeStop({ handedOverAt: undefined })) + + await withdrawCodexSendsNoTurnOpenedFor(journal, 1) + + expect(resolveDispatch).not.toHaveBeenCalled() + }) +}) From d8422dd79adca6400a726c7c8bcbe47e0b55d854 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:14:22 -0700 Subject: [PATCH 16/16] test(native-chat): read the outbox reconcile from where main moved it --- .../structured-agent-session-outbox-withdrawn-send.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/shared/structured-agent-session-outbox-withdrawn-send.test.ts b/src/shared/structured-agent-session-outbox-withdrawn-send.test.ts index ff127c8baabf..214ed97872aa 100644 --- a/src/shared/structured-agent-session-outbox-withdrawn-send.test.ts +++ b/src/shared/structured-agent-session-outbox-withdrawn-send.test.ts @@ -7,9 +7,9 @@ import { agentSessionFailureWords } from './agent-session-failure-words' import type { AgentJournalSubmission } from './agent-session-journal-types' import { createStructuredAgentSessionOutboxEntry, - reconcileStructuredAgentSessionOutbox, type StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' +import { reconcileStructuredAgentSessionOutbox } from './structured-agent-session-outbox-reconcile' import { admitStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox-admission' function entry( @@ -52,7 +52,7 @@ describe('the send a Stop ended before its turn opened', () => { ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) }) - const reconciled = reconcileStructuredAgentSessionOutbox(OUTBOX, [withdrawn]) + const reconciled = reconcileStructuredAgentSessionOutbox(OUTBOX, [withdrawn], []) expect(reconciled.map((each) => each.clientMessageId)).toEqual(['next']) expect(admitStructuredAgentSessionOutboxEntry(reconciled)).toMatchObject({ @@ -68,7 +68,7 @@ describe('the send a Stop ended before its turn opened', () => { reason: 'provider_closed_before_acknowledgement' }) - const reconciled = reconcileStructuredAgentSessionOutbox(OUTBOX, [inDoubt]) + const reconciled = reconcileStructuredAgentSessionOutbox(OUTBOX, [inDoubt], []) expect(admitStructuredAgentSessionOutboxEntry(reconciled)).toMatchObject({ state: 'blocked',