From d4981a0e900098dafaad56d794e88502105b92b5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:11:56 -0700 Subject: [PATCH 01/58] test(native-chat): a Stop over a card sent now into the running turn keeps it paused Red on main: Codex's turn end withdraws the steered hand-off and the queue sends the card again as a new host turn, with no pause recorded. --- ...-session-codex-turn-end-settlement.test.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) 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 990c33850efe..ce23a8df5918 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 @@ -222,6 +222,80 @@ describe('a Codex send its turn ended without taking it', () => { }) }) +describe('a queued card sent now into the turn a Stop ends', () => { + async function queueCard(text: string): Promise { + const body = hostTestMessage(text) + const delivery = 'queue-if-active' as const + const queued = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body, delivery }), + body, + delivery, + userSend: true + }) + if (!queued.ok || !('queued' in queued.value)) { + throw new Error(`not queued: ${JSON.stringify(queued)}`) + } + return queued.value.queued.messageId + } + + async function handoffs(messageId: string): Promise { + return (await settled()).submissions.filter((entry) => entry.queuedMessageId === messageId) + } + + /** Each hand-off of the card, as who sent it and how it settled. */ + async function sends(messageId: string) { + return (await handoffs(messageId)).map((entry) => ({ + origin: entry.origin, + verdict: verdictOf([entry], entry.clientMessageId) + })) + } + + async function queue() { + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + if (!page.ok) { + throw new Error('history refused') + } + return { + pause: page.page.queuePause ?? null, + cards: (page.page.queuedMessages ?? []).map(({ messageId, state }) => ({ messageId, state })) + } + } + + it('comes back as a paused waiting card, and nothing sends it again', async () => { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(opening) + const cardId = await queueCard('and check the tests') + const sentNow = await host.queuedMessageSend(CALLER, { + envelope: envelope('agentSession.queuedMessageSend', { messageId: cardId }), + messageId: cardId + }) + expect(sentNow).toMatchObject({ ok: true }) + // Steered into the running turn, with no echo yet. + await vi.waitFor(() => expect(answers).toBe(2)) + const [steered] = await handoffs(cardId) + expect(steered?.dispatchState).toBe('pending') + + await stop('turn-1') + + await vi.waitFor(async () => { + const [withdrawn] = await handoffs(cardId) + expect(verdictOf([withdrawn!], withdrawn!.clientMessageId)).toBe('withdrawn') + }) + await vi.waitFor( + async () => + expect({ ...(await queue()), sends: await sends(cardId) }).toEqual({ + pause: { reason: 'stopped' }, + cards: [{ messageId: cardId, state: 'waiting' }], + sends: [{ origin: 'client', verdict: 'withdrawn' }] + }), + { timeout: 5_000 } + ) + expect(answers).toBe(2) + }) +}) + 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 () => { const sent = await send('look around') From 943bbe4dbb9e1550f4703ae21bd3ea2ab67c7ba1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:28:09 -0700 Subject: [PATCH 02/58] fix(native-chat): a Stop's queue pause holds a card whose hand-off is still unanswered A card sent now into the running turn was still pending when Stop judged the pause, so nothing was recorded; the interrupt then withdrew the hand-off, the card went back to waiting unpaused, and the queue sent it again as a new turn. The pause now counts a hand-off that may still return to waiting, judged with the appended row applied, so a withdrawal lands under the pause and an acceptance retires it in that same write. Codex and Claude both hit it. --- .../journal-queued-messages.ts | 18 ++++--- .../queued-message-pause-table.ts | 14 ++--- .../queued-message-settlement.ts | 29 +++++++--- .../queued-message-store.test.ts | 34 ++++++++++++ ...d-agent-session-claude-queued-stop.test.ts | 54 ++++++++++++++++++- ...ed-agent-session-queued-pause-lift.test.ts | 23 ++++++++ .../structured-agent-session-queued-stop.ts | 9 ++-- 7 files changed, 154 insertions(+), 27 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 96b45c9d17cb..05dde59051f3 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -13,6 +13,7 @@ import { import type { JournalHostDatabase } from './journal-host-database' import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' +import type { JournalRowTransactionHook } from './journal-row-writer' import type { JournalSubmissionConsume } from './journal-store-contracts' import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' import { @@ -37,7 +38,7 @@ import { import { draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo' import { pruneQueuedMessages, retainedSubmissionVerdict } from './queued-message-retention' import { - owedBackToWaiting, + mayReturnToWaiting, queuedMessageSettlementOwed, settleOwedQueuedMessages, settleQueuedMessagesForRow @@ -173,10 +174,10 @@ export class JournalQueuedMessages { } /** What `queuePauseHoldsBack` judges a pause by, from this journal's submissions. */ - private pauseScope() { + private pauseScope(row?: JournalRow) { return { sessionId: this.deps.sessionId, - owedToWaiting: owedBackToWaiting(this.deps.state().submissions) + mayReturnToWaiting: mayReturnToWaiting(this.deps.state().submissions, row) } } @@ -252,14 +253,15 @@ export class JournalQueuedMessages { row, now: this.deps.now() }) - this.changeRevision += retireQueuePauseIfNothingHeld(db, this.pauseScope()) + this.changeRevision += retireQueuePauseIfNothingHeld(db, this.pauseScope(row)) } /** The in-transaction consume for `appendSubmission`; a false compare-and-set * throws so the whole append — draft transition AND submission row — rolls back. */ consumeInTransaction( db: Database.Database, - input: JournalSubmissionConsume & { consumedAs: string } + input: JournalSubmissionConsume & { consumedAs: string }, + row: JournalRow ): void { const { db: own } = this.deps.database() if (own !== db) { @@ -274,7 +276,7 @@ export class JournalQueuedMessages { if (!consumed) { throw new QueuedMessageNotConsumableError(input.messageId, input.expect) } - retireQueuePauseIfNothingHeld(db, this.pauseScope()) + retireQueuePauseIfNothingHeld(db, this.pauseScope(row)) this.changeRevision++ } @@ -348,8 +350,8 @@ export function queuedMessageConsumeHook( queuedMessages: JournalQueuedMessages, consumedAs: string, consume: JournalSubmissionConsume -): (db: Database.Database) => void { - return (db) => queuedMessages.consumeInTransaction(db, { ...consume, consumedAs }) +): JournalRowTransactionHook { + return (db, row) => queuedMessages.consumeInTransaction(db, { ...consume, consumedAs }, row) } export class QueuedMessageNotConsumableError extends Error { diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause-table.ts b/src/main/native-chat/agent-session-journal/queued-message-pause-table.ts index 41387165de04..f7dba2b2fca4 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause-table.ts @@ -82,7 +82,7 @@ export function clearQueuePause( type QueueCardState = { state: string; holdReason: string | null } /** A card a pause holds back: waiting, with no hold of its own, wherever it sits. - * While one exists — or a hand-off still owed a return to waiting + * While one exists — or a hand-off that may still return to waiting * (`queuePauseHoldsBack`) — the pause is KEPT: a Stop records it, and it is * retired only once none remains, so deleting a returned card that blocks such * cards leaves them paused rather than sending them unasked. */ @@ -108,12 +108,12 @@ export function hasResumableQueuedMessage(rows: readonly QueueCardState[]): bool /** What a queue pause holds back, judged inside the caller's transaction: a waiting * card with no hold of its own (`isPausableQueuedMessage`, in SQL), or a dispatched - * one whose settlement back to waiting is still owed — its hook was skipped, so the - * row has not caught up with its rejected submission, which only the journal's - * submissions can tell (`owedToWaiting`). */ + * one its hand-off may still send back to waiting — unanswered, so the Stop that + * recorded the pause may yet withdraw it, or withdrawn with that return still owed. + * Only the journal's submissions can tell (`mayReturnToWaiting`). */ export function queuePauseHoldsBack( db: Database.Database, - input: { sessionId: string; owedToWaiting: (consumedRef: string) => boolean } + input: { sessionId: string; mayReturnToWaiting: (consumedRef: string) => boolean } ): boolean { const pausable = db .prepare( @@ -136,7 +136,7 @@ export function queuePauseHoldsBack( row !== null && 'consumed_as' in row && typeof row.consumed_as === 'string' && - input.owedToWaiting(row.consumed_as) + input.mayReturnToWaiting(row.consumed_as) ) } @@ -145,7 +145,7 @@ export function queuePauseHoldsBack( * it can never outlive them and catch a card typed long after. */ export function retireQueuePauseIfNothingHeld( db: Database.Database, - input: { sessionId: string; owedToWaiting: (consumedRef: string) => boolean } + input: { sessionId: string; mayReturnToWaiting: (consumedRef: string) => boolean } ): number { // Runs on every appended journal row: with no pause recorded there is nothing to judge. const recorded = db diff --git a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts index 75e622567809..fed516db95c7 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts @@ -8,6 +8,7 @@ import type Database from '../../sqlite/sync-database' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { consumedSubmissionWasRejected, + journalDispatchRowApplies, journalDispatchRowNewlyRejects, rejectedDraftSettlement } from './journal-dispatch-settlement' @@ -36,16 +37,30 @@ export function queuedMessageSettlementOwed( ) } -/** A dispatched draft's consumed submission settled so that the draft is owed a - * return to waiting (a withdrawal, not a refusal): what a queue pause must still - * count as a card it holds back while that settlement is owed. */ -export function owedBackToWaiting(submissions: Submissions): (consumedRef: string) => boolean { +/** A dispatched draft's hand-off can still send it back to waiting: unanswered, so a Stop may + * yet withdraw it, or withdrawn with that return still owed. What a queue pause holds back + * besides waiting cards. `row`, the journal row this transaction appends, counts as applied, + * so the write that settles the last such hand-off is the one that retires the pause. */ +export function mayReturnToWaiting( + submissions: Submissions, + row?: JournalRow +): (consumedRef: string) => boolean { return (consumedRef) => { + if (row?.kind === 'submission' && row.clientMessageId === consumedRef) { + return true + } const submission = submissions.get(consumedRef) + const current = + row?.kind === 'dispatch' && + row.clientMessageId === consumedRef && + journalDispatchRowApplies(submission) + ? { dispatchState: row.state, reason: row.reason, rejection: row.rejection } + : submission + if (current?.dispatchState === 'pending') { + return true + } return ( - submission !== undefined && - consumedSubmissionWasRejected(submission) && - rejectedDraftSettlement(submission).state === 'waiting' + consumedSubmissionWasRejected(current) && rejectedDraftSettlement(current).state === 'waiting' ) } } diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts index 0e42a22be4d9..053210c015de 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -732,6 +732,40 @@ describe("the queue's Stop fact", () => { expect(journal.queuedMessages.pause()).not.toBeNull() }) + it('holds a card sent now while its hand-off is unanswered; the write accepting it retires the pause', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) + // Send-now: the last waiting card is handed off, which a Stop may yet withdraw. + await consumeDraft(journal, 'draft-1') + expect(journal.queuedMessages.pause()).not.toBeNull() + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'accepted', + providerIdentity: { provider: 'claude', sessionId: 'native-1', uuid: 'echo-1' }, + fence: 0 + }) + expect(journal.queuedMessages.pause()).toBeNull() + await journal.close() + journal = await open() + expect(journal.queuedMessages.pause()).toBeNull() + }) + + it("records over a hand-off still unanswered, so the Stop's withdrawal lands it back under the pause", async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...STOP_WITHDRAWAL, + fence: 0 + }) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + expect(journal.queuedMessages.pause()).not.toBeNull() + }) + it('a hand-off whose return to waiting is still owed (its hook skipped) keeps the pause', async () => { const journal = await open() await queueDraft(journal, 'draft-sent') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index f78be712f7cf..27d1a7951697 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -111,7 +111,7 @@ function eventually(assertion: () => unknown): Promise { } function envelope( - method: 'agentSession.send' | 'agentSession.cancel', + method: 'agentSession.send' | 'agentSession.cancel' | 'agentSession.queuedMessageSend', fields: Record ) { return { @@ -316,3 +316,55 @@ it('withdraws a host-queued follow-up but leaves a newer turn running when the S ) expect(rows).toContain('The provider had already finished this turn.') }, 15_000) + +it('a card sent now into the running turn comes back paused when Stop withdraws it, and is not sent again', async () => { + const connection = claude.connections[0]! + const turnId = await openFirstTurn(connection) + const body = hostTestMessage('And then this.') + const delivery = 'queue-if-active' as const + const queuedSend = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body, delivery }), + body, + delivery, + userSend: true + }) + if (!queuedSend.ok || !('queued' in queuedSend.value)) { + throw new Error('expected a queued receipt') + } + const cardId = queuedSend.value.queued.messageId + const sentNow = await host.queuedMessageSend(CALLER, { + envelope: envelope('agentSession.queuedMessageSend', { messageId: cardId }), + messageId: cardId + }) + expect(sentNow).toMatchObject({ ok: true }) + // Folded into the running turn: Claude holds it until that turn ends. + await eventually(() => expect(connection.sent).toHaveLength(2)) + queued.push(String(connection.sent.at(-1)!.uuid)) + const sends = async () => + (await host.journalSnapshot(SESSION)).submissions + .filter((entry) => entry.queuedMessageId === cardId) + .map((entry) => ({ origin: entry.origin, state: entry.dispatchState, reason: entry.reason })) + await eventually(async () => expect((await sends())[0]?.state).toBe('pending')) + + expect(await stop(turnId)).toMatchObject({ ok: true, value: { cancelled: true } }) + connection.handlers.onMessage?.({ + type: 'result', + subtype: 'error_during_execution', + session_id: PROVIDER_SESSION_ID, + uuid: 'interrupted-result' + }) + + await eventually(async () => { + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + expect({ + pause: page.ok ? (page.page.queuePause ?? null) : 'history refused', + cards: page.ok ? (page.page.queuedMessages ?? []).map((card) => card.state) : [], + sends: await sends() + }).toEqual({ + pause: { reason: 'stopped' }, + cards: ['waiting'], + sends: [{ origin: 'client', state: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }] + }) + }) + expect(connection.sent).toHaveLength(2) +}, 15_000) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 1349a6a178e0..42230f826a30 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -231,6 +231,29 @@ describe('a pause is over the cards it paused', () => { await eventually(async () => expect(await rig.handoff(correction)).toBeDefined()) }) + it('a Stop over a card sent now into the turn holds it until the agent takes it; then a card typed later drains', async () => { + const working = await rig.workingSend() + const sentId = await queuedDraft('sent now into the turn') + await rig.sendNow(sentId) + await handedOver(sentId) + await rig.stop() + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + // Recorded over the unanswered hand-off, which the Stop may yet withdraw; nothing to publish. + expect(journal?.queuedMessages.pause()).toMatchObject({ reason: 'stopped' }) + expect(await rig.queuePause()).toBeNull() + await rig.settleAccepted(await rig.handoffId(sentId), 'sent-now') + expect(journal?.queuedMessages.pause()).toBeNull() + await rig.settleAccepted(working, 'stopped') + const mail = rig.send('coordinator mail', undefined, { internal: true }) + await mail.result + await eventually(async () => + expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined() + ) + const later = await queuedDraft('typed during the mail turn') + await rig.settleAccepted(mail.id, 'mail') + await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) + }) + it('deleting the last paused card ends the pause, so a card typed later is not held by it', async () => { const working = await rig.workingSend() const only = await queuedDraft('paused, then deleted') 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 16a9b4aeaf77..4d5369364b24 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 @@ -25,10 +25,11 @@ export function unsettledQueuedMessages(journal: AgentSessionJournal): QueuedMes * once it withdrew something — and only over cards it then holds back. The Stop * calls `tookEffect` there. A Stop that throws before then changed nothing and * recorded nothing, so there is nothing to undo; one that fails after it keeps - * the pause, since the interrupt may have landed. A draft whose hand-off the - * Stop withdrew is back to waiting in its own place, under this same pause. The - * drain cannot slip a draft in between: it runs on the same serialized lane as - * the Stop. + * the pause, since the interrupt may have landed. A hand-off still unanswered + * counts as held, so a draft the interrupt withdraws later — at the provider's turn + * end — is back to waiting in its own place, under this same pause; accepted + * instead, the pause retires in that write. The drain cannot slip a draft in + * between: it runs on the same serialized lane as the Stop. */ export async function runStopWithQueuePause( ctx: AgentSessionTurnContext, From 071e2c0f7e91d40092a027d65712cc104d17ddb7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:31:41 -0700 Subject: [PATCH 03/58] test(native-chat): the Claude re-send case fails on its diff, inside the test's budget --- .../structured-agent-session-claude-queued-stop.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index 27d1a7951697..bd960b179ba6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -354,7 +354,8 @@ it('a card sent now into the running turn comes back paused when Stop withdraws uuid: 'interrupted-result' }) - await eventually(async () => { + // Inside the test's budget, so a re-send fails on this diff rather than the timeout. + await vi.waitFor(async () => { const page = await host.history({ sessionId: SESSION, direction: 'tail' }) expect({ pause: page.ok ? (page.page.queuePause ?? null) : 'history refused', @@ -365,6 +366,6 @@ it('a card sent now into the running turn comes back paused when Stop withdraws cards: ['waiting'], sends: [{ origin: 'client', state: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }] }) - }) + }, 5_000) expect(connection.sent).toHaveLength(2) }, 15_000) From 7de5fca5ad8c5eaab369a1e206282c17b1771de6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:10:55 -0700 Subject: [PATCH 04/58] test(native-chat): a pause held by an unanswered hand-off ends on every path that ends it The provider's answer, the provider dying, the chat closing, a restart, a withdrawal still owed at open, and a /clear (refused until the hand-off ends, then carrying every waiting card paused 'cleared'); each ends with the queue sending again. --- ...session-queued-message-rig.test-fixture.ts | 11 +- ...t-session-queued-pause-handoff-end.test.ts | 194 ++++++++++++++++++ 2 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index 9aed0d4acb84..c645b6ea4e47 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -35,7 +35,9 @@ export function eventually(assertion: () => void | Promise): Promise export type QueuedMessageTestRig = Awaited> -export async function createQueuedMessageTestRig() { +/** `restartable`: a child started for a chat whose chain already names a thread resumes it, so a + * chat whose child closed or died can start another. */ +export async function createQueuedMessageTestRig(options: { restartable?: true } = {}) { const root = await mkdtemp(join(tmpdir(), 'orca-queued-messages-')) resetHostTestOperationIds() // Admitted: the message is written and unanswered, so the session owes work @@ -56,8 +58,11 @@ export async function createQueuedMessageTestRig() { const host = new StructuredAgentSessionHost({ store, adapter: { - acquire: async ({ fence, spawnToken, events: sink }) => { + acquire: async ({ identity, fence, spawnToken, events: sink }) => { events = sink + const resumes = + options.restartable === true && + (store.getRecord(identity.sessionId)?.providerHandleChain.length ?? 0) > 0 return { process: { hostId: 'local', @@ -69,7 +74,7 @@ export async function createQueuedMessageTestRig() { link: { linkId: `link-${fence}`, handle: { provider: 'codex' as const, threadId: THREAD }, - origin: 'created' as const, + origin: resumes ? ('resumed' as const) : ('created' as const), mintedAtFence: fence, observedAt: NOW } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts new file mode 100644 index 000000000000..e83c7c5a32e5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts @@ -0,0 +1,194 @@ +// A Stop's queue pause kept alive only by a card's unanswered hand-off ends on every path that +// ends that hand-off, so no lost answer pauses a chat's queue for good: the provider answering +// it, the chat closing, a restart, the provider dying without answering, and a /clear. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' +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' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig({ restartable: true }) +}) + +afterEach(() => rig.dispose()) + +async function queuedDraft(text: string, sessionId = HOST_TEST_SESSION): Promise { + const queued = await rig.send(text, 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + expect( + (await rig.drafts(sessionId)).some((card) => card.messageId === queued.value.queued.messageId) + ) + return queued.value.queued.messageId +} + +/** The stored Stop fact, read from the open conversation (opened by a history read if closed). */ +async function stopFact(sessionId = HOST_TEST_SESSION) { + await rig.drafts(sessionId) + const journal = rig.host.collaboratorsForTests().sessions.get(sessionId)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + return journal.queuedMessages.pause() +} + +/** A card sent now into the running turn, then Stop, with the provider answering neither: the + * pause is recorded over that unanswered hand-off alone, and is not shown. */ +async function stopOverUnansweredHandOff(): Promise<{ working: string; sentId: string }> { + const working = await rig.workingSend() + const sentId = await queuedDraft('sent now into the turn') + await rig.sendNow(sentId) + await eventually(async () => expect((await rig.handoff(sentId))?.handedOverAt).toBeDefined()) + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(await stopFact()).toMatchObject({ reason: 'stopped' }) + expect(await rig.queuePause()).toBeNull() + return { working, sentId } +} + +/** The queue sends on its own again: a card typed during a later mail turn drains after it. */ +async function expectQueueDrains(): Promise { + expect(await stopFact()).toBeNull() + expect(await rig.queuePause()).toBeNull() + const mail = rig.send('coordinator mail', undefined, { internal: true }) + await mail.result + await eventually(async () => expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined()) + const later = await queuedDraft('typed during the mail turn') + await rig.settleAccepted(mail.id, 'mail') + await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) +} + +/** The provider's answer that the Stop took the card back before it reached the agent. */ +async function withdraw(draftId: string): Promise { + await rig.host.settleLateDispatch({ + sessionId: HOST_TEST_SESSION, + clientMessageId: await rig.handoffId(draftId), + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + }) +} + +function clear() { + const fields = { command: 'clear' as const } + return rig.host.conversationCommand(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) +} + +function providerExited() { + return rig.host.handleAdapterEvent({ + type: 'ended', + sessionId: HOST_TEST_SESSION, + reason: 'provider exited', + cause: 'unexpected-exit', + fence: 1, + acquisitionGeneration: 'generation-1' + }) +} + +describe('a pause held only by an unanswered hand-off', () => { + it('the provider withdrawing it leaves the card waiting under the pause; Resume sends it', async () => { + const { working, sentId } = await stopOverUnansweredHandOff() + await withdraw(sentId) + await rig.settleAccepted(working, 'stopped') + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.drafts()).toEqual([{ messageId: sentId, state: 'waiting' }]) + expect(await rig.resume()).toMatchObject({ ok: true }) + await eventually(async () => expect((await rig.handoff(sentId))?.origin).toBe('host')) + }) + + it('ends when the provider dies without answering it', async () => { + const { sentId } = await stopOverUnansweredHandOff() + await providerExited() + // The dead generation's settlement: in doubt, never back to waiting. + await eventually(async () => expect((await rig.handoff(sentId))?.dispatchState).toBe('unknown')) + await expectQueueDrains() + }) + + it('ends when the chat closes', async () => { + const { sentId } = await stopOverUnansweredHandOff() + await rig.host.close(HOST_TEST_SESSION) + expect((await rig.handoff(sentId))?.dispatchState).toBe('unknown') + await expectQueueDrains() + }) + + it('ends when the host restarts under it', async () => { + const { sentId } = await stopOverUnansweredHandOff() + await rig.restartHostProcess() + expect((await rig.handoff(sentId))?.dispatchState).toBe('unknown') + await expectQueueDrains() + }) + + it('a withdrawal the restart finds still owed is settled at open, under the pause; Resume sends it', async () => { + const { working, sentId } = await stopOverUnansweredHandOff() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const hook = vi + .spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction') + .mockImplementationOnce(() => { + throw new Error('bookkeeping failed') + }) + try { + await withdraw(sentId) + } finally { + hook.mockRestore() + warn.mockRestore() + } + await rig.settleAccepted(working, 'stopped') + await rig.restartHostProcess() + expect(await rig.drafts()).toEqual([{ messageId: sentId, state: 'waiting' }]) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.resume()).toMatchObject({ ok: true }) + await eventually(async () => expect((await rig.handoff(sentId))?.origin).toBe('host')) + }) + + it("waits for it before a /clear, which then carries every waiting card paused 'cleared'", async () => { + const { working, sentId } = await stopOverUnansweredHandOff() + const carriedId = await queuedDraft('waiting behind the stop') + await rig.settleAccepted(working, 'stopped') + // An unanswered send blocks /clear: the hand-off ends first, by one of the paths above. + expect(await clear()).toMatchObject({ + ok: false, + refusal: { details: { reason: 'messagesUnsettled' } } + }) + await withdraw(sentId) + const cleared = await clear() + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error(`expected a replacement session: ${JSON.stringify(cleared)}`) + } + // The superseded chat keeps no pause over the cards it gave away. + expect(await stopFact()).toBeNull() + expect(await rig.drafts(replacementId)).toEqual([ + { messageId: sentId, state: 'waiting' }, + { messageId: carriedId, state: 'waiting' } + ]) + expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + const resumed = await rig.host.queuedMessagesResume(QUEUED_RIG_CALLER, { + envelope: rig.envelope( + {}, + 'agentSession.queuedMessagesResume', + hostTestOperationId(), + replacementId + ) + }) + expect(resumed).toMatchObject({ ok: true }) + await eventually(async () => + expect( + (await rig.host.journalSnapshot(replacementId)).submissions.some( + (entry) => entry.queuedMessageId === sentId + ) + ).toBe(true) + ) + }) +}) From 6ce9464a1cc6b565f292c9b8a9bd1bae709c30e3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:24:26 -0700 Subject: [PATCH 05/58] fix(native-chat): narrow the pause's settled hand-off, and assert the queued receipt's card --- .../agent-session-journal/queued-message-settlement.ts | 4 +++- ...ructured-agent-session-queued-pause-handoff-end.test.ts | 7 +++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts index fed516db95c7..3813534a7c6c 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts @@ -60,7 +60,9 @@ export function mayReturnToWaiting( return true } return ( - consumedSubmissionWasRejected(current) && rejectedDraftSettlement(current).state === 'waiting' + current !== undefined && + consumedSubmissionWasRejected(current) && + rejectedDraftSettlement(current).state === 'waiting' ) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts index e83c7c5a32e5..5e8c7dd027b4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts @@ -27,10 +27,9 @@ async function queuedDraft(text: string, sessionId = HOST_TEST_SESSION): Promise if (!queued.ok || !('queued' in queued.value)) { throw new Error('expected a queued receipt') } - expect( - (await rig.drafts(sessionId)).some((card) => card.messageId === queued.value.queued.messageId) - ) - return queued.value.queued.messageId + const { messageId } = queued.value.queued + expect((await rig.drafts(sessionId)).some((card) => card.messageId === messageId)).toBe(true) + return messageId } /** The stored Stop fact, read from the open conversation (opened by a history read if closed). */ From 2af16ab1326745fda4eae052a3dad8b5e18191ca Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:38:08 -0700 Subject: [PATCH 06/58] refactor(native-chat): derive the queue's pause from Stop and Resume journal rows Stop now appends one journal row where it takes effect, before the interrupt, whatever the queue holds; Resume appends its own. The pause is a pure function of the fold: the latest Stop with no later Resume and no later accepted turn a person asked for. A /clear's carried cards name their source, which is the replacement's 'cleared' pause. Host-origin turns never lift either. One predicate decides which cards a pause holds; by default every waiting card without a hold of its own, including one queued after the Stop. The drain's consume re-judges it inside its own transaction. The rows are tombstones of an id no item takes, carrying the mark: a released host reads an unknown row kind as corruption and truncates the journal there. Deletes the stored pause (recordPause, the retire hook on every appended row, the settle-before-record step, mayReturnToWaiting and its row overlay) and the tests that only proved it retires. The queued_message_pauses table stays in the schema, unread and unwritten, for downgrade safety. --- .../journal-epoch-controller.ts | 3 + .../journal-epoch-replacement.test.ts | 1 + .../journal-epoch-replacement.ts | 15 + .../agent-session-journal/journal-open.ts | 5 +- .../journal-queue-pause-row.ts | 37 +++ .../journal-queued-messages.ts | 126 +++----- .../agent-session-journal/journal-reducer.ts | 16 +- .../journal-row-schema.ts | 13 + .../journal-store-collaborators.ts | 4 + .../journal-store-contracts.ts | 3 + .../agent-session-journal/journal-store.ts | 8 +- .../queued-message-delivered-echo.test.ts | 8 - .../queued-message-holds.ts | 5 +- .../queued-message-pause-table.ts | 161 ---------- .../queued-message-pause.test.ts | 279 ++++++++++++++++++ .../queued-message-pause.ts | 115 ++++++++ .../queued-message-schema.ts | 6 +- .../queued-message-settlement.ts | 34 +-- .../queued-message-store.test.ts | 166 ----------- .../queued-message-table.ts | 18 +- .../agent-session-journal-batch.ts | 9 +- ...session-queued-message-rig.test-fixture.ts | 91 +++--- ...ured-agent-session-queued-messages.test.ts | 17 +- ...tructured-agent-session-queued-messages.ts | 23 +- ...ructured-agent-session-queued-mutations.ts | 14 +- ...t-session-queued-pause-handoff-end.test.ts | 193 ------------ ...ed-agent-session-queued-pause-lift.test.ts | 70 ++--- .../structured-agent-session-queued-pause.ts | 92 ++---- ...ctured-agent-session-queued-publication.ts | 15 +- ...ured-agent-session-queued-stop-row.test.ts | 240 +++++++++++++++ .../structured-agent-session-queued-stop.ts | 67 ++--- .../structured-agent-session-queued-wiring.ts | 6 +- ...ent-session-queued-withdrawn-draft.test.ts | 27 +- 33 files changed, 983 insertions(+), 904 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-queue-pause-row.ts delete mode 100644 src/main/native-chat/agent-session-journal/queued-message-pause-table.ts create mode 100644 src/main/native-chat/agent-session-journal/queued-message-pause.test.ts create mode 100644 src/main/native-chat/agent-session-journal/queued-message-pause.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts index 52a5478c381a..2c8b2766c0f2 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts @@ -20,6 +20,8 @@ export class JournalEpochController { readOnly: () => boolean setReadOnly: (readOnly: boolean) => void highestFence: () => number + /** Whether the live epoch's latest Stop still pauses the queue. */ + queueStopped: () => boolean cursor: () => AgentJournalCursor adopt: (loaded: JournalLoad) => void } @@ -67,6 +69,7 @@ export class JournalEpochController { reason, fence, items, + queueStopped: this.deps.queueStopped(), now: this.deps.now, mintEpoch: this.deps.mintEpoch, onPublished: this.deps.adopt diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts index 646254b4932d..3c893545c4e0 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts @@ -67,6 +67,7 @@ function replace(input: { reason: 'legacy_import', fence: 1, items: input.items, + queueStopped: false, now, mintEpoch: () => `epoch-${clock}`, onPublished: input.onPublished ?? (() => undefined) diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index 8d94c5b2b5b0..1097b5ab5cc7 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -16,6 +16,7 @@ import type { JournalLoad } from './journal-open' import { clearJournalRepairMarker } from './journal-repair-marker' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { buildJournalItemRow, journalRowBase } from './journal-row-builders' +import { buildJournalQueuePauseRow } from './journal-queue-pause-row' import { deleteJournalEpochRows, insertJournalRow, @@ -40,6 +41,9 @@ export function replaceJournalEpoch(input: { reason: AgentJournalEpochReason fence: number items: readonly JournalReplacementItem[] + /** The superseded epoch's latest Stop still paused the queue: the new epoch restates it, or + * the rewind would release cards the person stopped. */ + queueStopped: boolean now: () => number mintEpoch: () => string /** Called the instant the transaction commits, before any fallible follow-up. */ @@ -70,6 +74,17 @@ export function replaceJournalEpoch(input: { applyJournalRow(state, row) rows.push(row) } + if (input.queueStopped) { + const row = buildJournalQueuePauseRow({ + state, + mark: 'stopped', + seq: state.lastSequence + 1, + fence: input.fence, + ts: input.now() + }) + applyJournalRow(state, row) + rows.push(row) + } const { sessionId } = input.identity input.database.transaction((db) => { diff --git a/src/main/native-chat/agent-session-journal/journal-open.ts b/src/main/native-chat/agent-session-journal/journal-open.ts index ae753839c40d..8654ad569aa4 100644 --- a/src/main/native-chat/agent-session-journal/journal-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-open.ts @@ -19,7 +19,7 @@ import { } from './journal-row-table' import { JOURNAL_REPAIR_DISCLOSURE_ITEM_ID } from './journal-repair-disclosure' import { pendingJournalRepairSequence } from './journal-repair-marker' -import { parseJournalRow, type JournalRow } from './journal-row-schema' +import { isJournalQueuePauseRow, parseJournalRow, type JournalRow } from './journal-row-schema' /** Every epoch row is sequence 1, and no compaction moves that floor. */ const FIRST_JOURNAL_SEQUENCE = 1 @@ -120,7 +120,8 @@ export function startJournalRowFold(input: JournalRowFoldInput): { } applyJournalRow(state, row) const disclosure = row.kind === 'item' && row.itemId === JOURNAL_REPAIR_DISCLOSURE_ITEM_ID - if (!disclosure) { + // A Stop or Resume is no history, so it never reads as a rebuilt or provider-backed epoch. + if (!disclosure && !isJournalQueuePauseRow(row)) { repairHasContent ||= repairedFrom !== null && row.seq >= repairedFrom providerHasContent ||= row.seq >= FIRST_JOURNAL_SEQUENCE + 1 } diff --git a/src/main/native-chat/agent-session-journal/journal-queue-pause-row.ts b/src/main/native-chat/agent-session-journal/journal-queue-pause-row.ts new file mode 100644 index 000000000000..4be07ce6b8b0 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-queue-pause-row.ts @@ -0,0 +1,37 @@ +// The row a person's Stop or Resume appends: a tombstone of an id no item ever takes, carrying +// the mark (`journal-row-schema.ts` says why not a row kind of its own). + +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { JournalReducerState } from './journal-reducer' +import { journalRowBase } from './journal-row-builders' +import type { JournalQueuePauseMark, JournalTombstoneRow } from './journal-row-schema' + +/** The one id the queue's Stop and Resume rows name; no item ever takes it. */ +const JOURNAL_QUEUE_PAUSE_ITEM_ID = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'queue-pause' +}) + +export function buildJournalQueuePauseRow(input: { + state: JournalReducerState + mark: JournalQueuePauseMark + seq: number + fence: number + ts: number +}): JournalTombstoneRow { + return { + kind: 'tombstone', + itemId: JOURNAL_QUEUE_PAUSE_ITEM_ID, + revision: 1, + queuePause: input.mark, + ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts) + } +} + +export function journalQueuePauseRowBuilder( + state: () => JournalReducerState, + mark: JournalQueuePauseMark, + fence: number +): (seq: number, ts: number) => JournalTombstoneRow { + return (seq, ts) => buildJournalQueuePauseRow({ state: state(), mark, seq, fence, ts }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 05dde59051f3..34281b1b849e 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -16,15 +16,7 @@ import type { JournalRow } from './journal-row-schema' import type { JournalRowTransactionHook } from './journal-row-writer' import type { JournalSubmissionConsume } from './journal-store-contracts' import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' -import { - clearQueuePause, - queuePauseHoldsBack, - readQueuePause, - recordQueuePause, - retireQueuePauseIfNothingHeld, - type QueuePauseFact, - type QueuePauseReason -} from './queued-message-pause-table' +import { deriveQueuePause, queuePauseHolds, type DerivedQueuePause } from './queued-message-pause' import { consumeQueuedMessageInTransaction, getQueuedMessage, @@ -38,7 +30,6 @@ import { import { draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo' import { pruneQueuedMessages, retainedSubmissionVerdict } from './queued-message-retention' import { - mayReturnToWaiting, queuedMessageSettlementOwed, settleOwedQueuedMessages, settleQueuedMessagesForRow @@ -56,6 +47,8 @@ export type JournalQueuedMessagesDeps = { database: () => JournalHostDatabase readOnly: () => boolean state: () => JournalReducerState + /** Whether this handle found the row at `sequence` on disk when it opened. */ + wroteBeforeOpen: (sequence: number) => boolean /** The journal's own commit notification. Every standalone draft-table * transaction that changed rows fires it after COMMIT, so a draft or hold * change publishes and wakes the drain through the same path a journal row @@ -68,7 +61,6 @@ export class JournalQueuedMessages { /** Bumped on every draft-table write, so publication memos recompute only when they must. */ private changeRevision = 0 private listed: { revision: number; rows: readonly QueuedMessageRow[] } | null = null - private paused: { revision: number; fact: QueuePauseFact | null } | null = null constructor(private readonly deps: JournalQueuedMessagesDeps) {} @@ -76,12 +68,6 @@ export class JournalQueuedMessages { return this.changeRevision } - /** The submission row of the latest accepted turn a person asked for; 0 when none. What - * ends the queue's pause, read from the reducer in O(1). */ - latestPersonTurnSequence(): number { - return this.deps.state().latestPersonTurnSequence - } - /** A journal transaction rolled back: nothing read inside it may stay cached. */ invalidate(): void { this.changeRevision++ @@ -108,33 +94,26 @@ export class JournalQueuedMessages { return queuedMessagesSettledByOp(this.deps.database().db, this.deps.sessionId, settledByOp) } - /** `pausedBy`: the queue is paused in the SAME transaction as this card lands - * (a /clear's carry), so the drain never sees it unpaused and no pause fact - * exists without a card under it. */ + /** `carriedFrom`: a /clear's carry. The card is its own 'cleared' pause, so it lands paused. */ insert(input: { messageId: string body: AgentJournalMessageItem fingerprint: string hostInstance: string - pausedBy?: QueuePauseReason + carriedFrom?: string }): Promise { - const { pausedBy, ...draft } = input const { sessionId } = this.deps let inserted = false return this.transact( (db) => { - const existing = getQueuedMessage(db, sessionId, draft.messageId) + const existing = getQueuedMessage(db, sessionId, input.messageId) if (existing) { // One id, one draft: admission replays a recorded operation before it // gets here, so an existing row is the same accept landing twice. return existing } inserted = true - const row = insertQueuedMessage(db, { ...draft, sessionId, now: this.deps.now() }) - if (pausedBy) { - recordQueuePause(db, { sessionId, fact: this.pauseFact(pausedBy) }) - } - return row + return insertQueuedMessage(db, { ...input, sessionId, now: this.deps.now() }) }, () => inserted ) @@ -150,53 +129,37 @@ export class JournalQueuedMessages { ).then(() => undefined) } - /** Where the user's last Stop took effect, if it is still recorded; cached per revision. */ - pause(): QueuePauseFact | null { - if (this.paused?.revision !== this.changeRevision) { - this.paused = { - revision: this.changeRevision, - fact: readQueuePause(this.deps.database().db, this.deps.sessionId) - } - } - return this.paused.fact + /** The queue's pause, derived from the fold and the cards (`queued-message-pause.ts`). */ + pause(hostInstance: string): DerivedQueuePause | null { + return this.derivePause(this.list(), hostInstance) } - /** A Stop took effect here: the queue is paused from this position on — if, judged in - * the same transaction, it holds back a card at all. Returns whether it recorded. */ - recordPause(reason: QueuePauseReason): Promise { - const fact = this.pauseFact(reason) - return this.transact( - (db) => - queuePauseHoldsBack(db, this.pauseScope()) && - (recordQueuePause(db, { sessionId: this.deps.sessionId, fact }), true), - (recorded) => recorded - ) - } - - /** What `queuePauseHoldsBack` judges a pause by, from this journal's submissions. */ - private pauseScope(row?: JournalRow) { - return { - sessionId: this.deps.sessionId, - mayReturnToWaiting: mayReturnToWaiting(this.deps.state().submissions, row) - } + private derivePause( + cards: readonly QueuedMessageRow[], + hostInstance: string + ): DerivedQueuePause | null { + const state = this.deps.state() + return deriveQueuePause({ + marks: state.queuePauseMarks, + latestPersonTurnSequence: state.latestPersonTurnSequence, + cards, + hostInstance, + restartEnded: this.restartEnded() + }) } - private pauseFact(reason: QueuePauseReason): QueuePauseFact { - const { epoch, lastSequence: sequence } = this.deps.state() - return { reason, epoch, sequence, recordedAt: this.deps.now() } + /** A person's turn started since this handle opened, which ends a restart's pause. */ + restartEnded(): boolean { + const latest = this.deps.state().latestPersonTurnSequence + return latest > 0 && !this.deps.wroteBeforeOpen(latest) } - /** Ends the queue's pause: `stop` retires that Stop fact (never a later one), - * `adoptInto` adopts a restart's rows into this host instance. Returns whether - * anything changed. */ - liftPause(input: { stop: QueuePauseFact | null; adoptInto: string | null }): Promise { + /** Adopts waiting rows another host instance wrote into this one, ending a restart's pause. + * Returns whether anything changed. */ + adopt(hostInstance: string): Promise { const { sessionId } = this.deps return this.transact( - (db) => - (input.stop ? clearQueuePause(db, { sessionId, fact: input.stop }) : 0) + - (input.adoptInto === null - ? 0 - : adoptQueuedMessages(db, { sessionId, hostInstance: input.adoptInto })), + (db) => adoptQueuedMessages(db, { sessionId, hostInstance }), (changed) => changed > 0 ).then((changed) => changed > 0) } @@ -230,12 +193,8 @@ export class JournalQueuedMessages { ): Promise { return this.deps.serialize(async () => { assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) - const { result, retired } = this.deps.database().transaction((db) => ({ - result: run(db), - // Any draft write may take the last card a pause holds back. - retired: retireQueuePauseIfNothingHeld(db, this.pauseScope()) - })) - if (changed(result) || retired > 0) { + const result = this.deps.database().transaction(run) + if (changed(result)) { this.changeRevision++ this.deps.committed() } @@ -253,21 +212,29 @@ export class JournalQueuedMessages { row, now: this.deps.now() }) - this.changeRevision += retireQueuePauseIfNothingHeld(db, this.pauseScope(row)) } /** The in-transaction consume for `appendSubmission`; a false compare-and-set * throws so the whole append — draft transition AND submission row — rolls back. */ consumeInTransaction( db: Database.Database, - input: JournalSubmissionConsume & { consumedAs: string }, - row: JournalRow + input: JournalSubmissionConsume & { consumedAs: string } ): void { const { db: own } = this.deps.database() if (own !== db) { // Same handle only: a second connection could not join the transaction. throw new AgentSessionJournalError('journal_closed', 'consume crossed database handles') } + if (input.yieldsToPause) { + // Judged here, on the same queue as every journal append, so a Stop row that landed + // after the drain chose this card still holds it. + const cards = listQueuedMessages(db, this.deps.sessionId) + const pause = this.derivePause(cards, input.yieldsToPause.hostInstance) + const card = cards.find((entry) => entry.messageId === input.messageId) + if (pause && card && queuePauseHolds(pause, card)) { + throw new QueuedMessageNotConsumableError(input.messageId, input.expect) + } + } const consumed = consumeQueuedMessageInTransaction(db, { ...input, sessionId: this.deps.sessionId, @@ -276,7 +243,6 @@ export class JournalQueuedMessages { if (!consumed) { throw new QueuedMessageNotConsumableError(input.messageId, input.expect) } - retireQueuePauseIfNothingHeld(db, this.pauseScope(row)) this.changeRevision++ } @@ -319,11 +285,11 @@ export class JournalQueuedMessages { * Open-time reconciliation, a re-derivation behind the stored fact: owed * settlements apply exactly as the live hook would have (covers consume → * crash → downgrade → upgrade, where the old build rejected the leftover with - * no hook), then retention runs; a pause left holding back nothing retires. + * no hook), then retention runs. */ repairAndPrune(): Promise { // No draft, no work, and no write: a chat whose first-use copy is still owed stays uncopied. - if (this.deps.readOnly() || (this.list().length === 0 && this.pause() === null)) { + if (this.deps.readOnly() || this.list().length === 0) { return Promise.resolve() } const { sessionId } = this.deps @@ -351,7 +317,7 @@ export function queuedMessageConsumeHook( consumedAs: string, consume: JournalSubmissionConsume ): JournalRowTransactionHook { - return (db, row) => queuedMessages.consumeInTransaction(db, { ...consume, consumedAs }, row) + return (db) => queuedMessages.consumeInTransaction(db, { ...consume, consumedAs }) } export class QueuedMessageNotConsumableError extends Error { diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index ed12c4a6cba8..0af2eccf11dd 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -26,10 +26,15 @@ import { structuredAgentSessionPayloadFingerprint } from '../../../shared/struct import { JournalDerivedTurnScope } from './journal-derived-turn-scope' import { removeJournalItem, statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold' import { journalItemRevisionIsStale } from './journal-item-revision' -import type { JournalRow } from './journal-row-schema' +import { isJournalQueuePauseRow, type JournalRow } from './journal-row-schema' import { acceptSubmissionFromProviderItem, applyJournalSubmission } from './journal-submission-fold' import { applyJournalDispatchRow } from './journal-dispatch-reducer' import { isWriteFailureSubmission } from '../../../shared/structured-agent-session-dispatch-rejection' +import { + createJournalQueuePauseMarks, + foldJournalQueuePauseMark, + type JournalQueuePauseMarks +} from './queued-message-pause' export const MAX_JOURNAL_APPLIED_SETTLEMENT_IDS = 4_096 @@ -57,6 +62,8 @@ export type JournalReducerState = { /** The submission row of the latest turn a person asked for (`origin: 'client'`) that the * provider accepted; 0 when none. Kept as it folds so the queue's pause reads it in O(1). */ latestPersonTurnSequence: number + /** The queue's latest Stop and Resume rows, what its pause is derived from. */ + queuePauseMarks: JournalQueuePauseMarks } export function createJournalReducerState(sessionId: string, epoch: string): JournalReducerState { @@ -75,7 +82,8 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou aliases: new Map(), appliedSettlementIds: new Set(), derivedTurnScope: new JournalDerivedTurnScope(), - latestPersonTurnSequence: 0 + latestPersonTurnSequence: 0, + queuePauseMarks: createJournalQueuePauseMarks() } } @@ -101,6 +109,10 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo ) return } + if (isJournalQueuePauseRow(row)) { + foldJournalQueuePauseMark(state.queuePauseMarks, row) + return + } if (row.kind === 'tombstone') { removeJournalItem(state, resolveItemId(state, row.itemId), row.revision) return diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 1c7ea36eaefd..67c8aa8ca111 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -72,6 +72,19 @@ export type JournalTombstoneRow = JournalRowBase & { kind: 'tombstone' itemId: string revision: number + /** Present: not a removal but the queue's Stop or Resume (`queued-message-pause.ts`), on an id + * no item ever takes. A new row kind would make a released host truncate the journal from + * that row; this one it reads as removing nothing. */ + queuePause?: JournalQueuePauseMark +} + +export type JournalQueuePauseMark = 'stopped' | 'resumed' + +/** A Stop or Resume row. Any value counts, so a newer build's mark never removes an item. */ +export function isJournalQueuePauseRow( + row: JournalRow +): row is JournalTombstoneRow & { queuePause: JournalQueuePauseMark } { + return row.kind === 'tombstone' && row.queuePause !== undefined } /** The write-ahead row. Durable BEFORE the adapter dispatches anything; it diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 16d3795cc6ab..5e90baf7a873 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -13,6 +13,7 @@ import { JournalItemAppender } from './journal-item-appender' import { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' import type { JournalLoad } from './journal-open' import { JournalQueuedMessages } from './journal-queued-messages' +import { journalQueueStopHolds } from './queued-message-pause' import type { JournalReducerState } from './journal-reducer' import { JournalRowWriter } from './journal-row-writer' import { restoreJournalStore } from './journal-store-restore' @@ -70,6 +71,8 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal readOnly: host.readOnly, setReadOnly: host.setReadOnly, highestFence: () => host.state().highestFence, + queueStopped: () => + journalQueueStopHolds(host.state().queuePauseMarks, host.state().latestPersonTurnSequence), cursor: host.cursor, adopt: host.adopt }) @@ -80,6 +83,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal database: host.database, readOnly: host.readOnly, state: host.state, + wroteBeforeOpen: (sequence) => host.journal().wroteBeforeOpen(sequence), committed: host.notifyCommitted }) return { diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index 29f0db6bed63..85e6b99f9029 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -94,6 +94,9 @@ export type JournalSubmissionConsume = { /** The host process handing it off, stamped on the draft so a hand-off withdrawn back to * waiting belongs to the process that sent it, not the one that first wrote the card. */ hostInstance?: string + /** The queue's own send: refused in the consume's transaction while the queue's pause, as + * this host instance derives it, holds the card. Send-now omits it. */ + yieldsToPause?: { hostInstance: string } } export type JournalItemAppendInput = { 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 5b046ab421f2..1c26eb031f55 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -58,7 +58,8 @@ import type { ResolveDispatchInput } from './journal-store-contracts' import { queuedMessageConsumeHook, type JournalQueuedMessages } from './journal-queued-messages' -import type { AgentJournalEpochReason } from './journal-row-schema' +import { journalQueuePauseRowBuilder } from './journal-queue-pause-row' +import type { AgentJournalEpochReason, JournalQueuePauseMark } from './journal-row-schema' import type { JournalRowWriter } from './journal-row-writer' import type { JournalEpochController } from './journal-epoch-controller' import { JournalWriteQueue } from './journal-write-queue' @@ -307,6 +308,11 @@ export class AgentSessionJournal { ) } + /** A person's Stop or Resume: the rows the queue's pause is derived from. */ + appendQueuePauseMark(mark: JournalQueuePauseMark, fence: number): Promise { + return this.rowWriter.append(journalQueuePauseRowBuilder(() => this.state, mark, fence)) + } + appendLifecycleBatch(input: JournalLifecycleBatchInput): Promise { return this.lifecycleBatchAppender.append(input) } diff --git a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts index 5b21c36f3af5..4ce62c6192b2 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts @@ -139,14 +139,6 @@ describe("a waiting draft whose 'never delivered' claim an echo disproves", () = expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') }) - it('retires the pause in the per-row hook when that echo withdraws the last card it holds back', async () => { - const journal = await withdrawnDraft('did it land?') - expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) - await echo(journal, 'echo-1', 'did it land?') - expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') - expect(journal.queuedMessages.pause()).toBeNull() - }) - it('stays waiting for an echo of some other text', async () => { const journal = await withdrawnDraft('did it land?') await echo(journal, 'echo-1', 'something else') diff --git a/src/main/native-chat/agent-session-journal/queued-message-holds.ts b/src/main/native-chat/agent-session-journal/queued-message-holds.ts index 665466d60e76..00b90a832c1d 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-holds.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-holds.ts @@ -1,7 +1,6 @@ // Per-draft holds: what keeps one card from auto-sending, stored on its row. A -// Stop or a restart pauses the whole queue instead (`queued-message-pause-table.ts`, -// and the host instance each row records); a per-draft hold is only a -// conversion that failed, which an explicit Send releases. +// Stop or a restart pauses the queue instead (`queued-message-pause.ts`, derived); +// a per-draft hold is only a conversion that failed, which an explicit Send releases. import type Database from '../../sqlite/sync-database' import type { QueuedMessageHoldReason } from './queued-message-table' diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause-table.ts b/src/main/native-chat/agent-session-journal/queued-message-pause-table.ts deleted file mode 100644 index f7dba2b2fca4..000000000000 --- a/src/main/native-chat/agent-session-journal/queued-message-pause-table.ts +++ /dev/null @@ -1,161 +0,0 @@ -// The queue-level pause fact: where in the journal the user's last Stop (or a -// /clear, which starts its replacement paused) took effect. The pause itself is never stored — it is derived from this fact and -// the journal rows after it (a user-requested turn that started ends it); the -// fact only records the one event the journal's closed row kinds cannot carry. -// An explicit Resume retires it. - -import type Database from '../../sqlite/sync-database' - -export type QueuePauseReason = 'stopped' | 'cleared' - -export type QueuePauseFact = { - reason: QueuePauseReason - /** The journal position the Stop took effect at: rows after it are later. */ - epoch: string - sequence: number - recordedAt: number -} - -export function readQueuePause(db: Database.Database, sessionId: string): QueuePauseFact | null { - const row: unknown = db - .prepare( - 'SELECT reason, epoch, sequence, recorded_at FROM queued_message_pauses WHERE session_id = ?' - ) - .get(sessionId) - if ( - typeof row !== 'object' || - row === null || - !('reason' in row) || - (row.reason !== 'stopped' && row.reason !== 'cleared') || - !('epoch' in row) || - typeof row.epoch !== 'string' || - !('sequence' in row) || - typeof row.sequence !== 'number' || - !('recorded_at' in row) || - typeof row.recorded_at !== 'number' - ) { - // A reason this build cannot place reads as no pause rather than a wrong one. - return null - } - return { - reason: row.reason, - epoch: row.epoch, - sequence: row.sequence, - recordedAt: row.recorded_at - } -} - -/** The latest Stop replaces an earlier one: only the last interruption decides. */ -export function recordQueuePause( - db: Database.Database, - input: { sessionId: string; fact: QueuePauseFact } -): void { - db.prepare( - `INSERT INTO queued_message_pauses (session_id, reason, epoch, sequence, recorded_at) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT (session_id) DO UPDATE SET - reason = excluded.reason, epoch = excluded.epoch, - sequence = excluded.sequence, recorded_at = excluded.recorded_at` - ).run( - input.sessionId, - input.fact.reason, - input.fact.epoch, - input.fact.sequence, - input.fact.recordedAt - ) -} - -/** Compare-and-clear: only the fact the caller judged, so a Stop recorded since stands. */ -export function clearQueuePause( - db: Database.Database, - input: { sessionId: string; fact: Pick } -): number { - return Number( - db - .prepare( - 'DELETE FROM queued_message_pauses WHERE session_id = ? AND epoch = ? AND sequence = ?' - ) - .run(input.sessionId, input.fact.epoch, input.fact.sequence).changes ?? 0 - ) -} - -type QueueCardState = { state: string; holdReason: string | null } - -/** A card a pause holds back: waiting, with no hold of its own, wherever it sits. - * While one exists — or a hand-off that may still return to waiting - * (`queuePauseHoldsBack`) — the pause is KEPT: a Stop records it, and it is - * retired only once none remains, so deleting a returned card that blocks such - * cards leaves them paused rather than sending them unasked. */ -export function isPausableQueuedMessage(row: QueueCardState): boolean { - return row.state === 'waiting' && row.holdReason === null -} - -/** Whether Resume would send anything: a pausable card not behind a returned one, - * which blocks everything after it until the user acts, exactly as the drain - * reads it. Only then is the kept pause PUBLISHED, so its header never offers a - * Resume that sends nothing. */ -export function hasResumableQueuedMessage(rows: readonly QueueCardState[]): boolean { - for (const row of rows) { - if (row.state === 'returned') { - return false - } - if (isPausableQueuedMessage(row)) { - return true - } - } - return false -} - -/** What a queue pause holds back, judged inside the caller's transaction: a waiting - * card with no hold of its own (`isPausableQueuedMessage`, in SQL), or a dispatched - * one its hand-off may still send back to waiting — unanswered, so the Stop that - * recorded the pause may yet withdraw it, or withdrawn with that return still owed. - * Only the journal's submissions can tell (`mayReturnToWaiting`). */ -export function queuePauseHoldsBack( - db: Database.Database, - input: { sessionId: string; mayReturnToWaiting: (consumedRef: string) => boolean } -): boolean { - const pausable = db - .prepare( - `SELECT 1 FROM queued_messages - WHERE session_id = ? AND state = 'waiting' AND hold_reason IS NULL LIMIT 1` - ) - .get(input.sessionId) - if (pausable !== undefined) { - return true - } - return db - .prepare( - `SELECT consumed_as FROM queued_messages - WHERE session_id = ? AND state = 'dispatched' AND consumed_as IS NOT NULL` - ) - .all(input.sessionId) - .some( - (row) => - typeof row === 'object' && - row !== null && - 'consumed_as' in row && - typeof row.consumed_as === 'string' && - input.mayReturnToWaiting(row.consumed_as) - ) -} - -/** A pause is over the cards it paused: once it holds back none (`queuePauseHoldsBack`), - * the fact goes too, in the same transaction as the write that took the last one, so - * it can never outlive them and catch a card typed long after. */ -export function retireQueuePauseIfNothingHeld( - db: Database.Database, - input: { sessionId: string; mayReturnToWaiting: (consumedRef: string) => boolean } -): number { - // Runs on every appended journal row: with no pause recorded there is nothing to judge. - const recorded = db - .prepare('SELECT 1 FROM queued_message_pauses WHERE session_id = ?') - .get(input.sessionId) - if (recorded === undefined || queuePauseHoldsBack(db, input)) { - return 0 - } - return Number( - db.prepare('DELETE FROM queued_message_pauses WHERE session_id = ?').run(input.sessionId) - .changes ?? 0 - ) -} diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts new file mode 100644 index 000000000000..e0b5de53eb62 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -0,0 +1,279 @@ +// The queue's pause is a pure function of the journal: a Stop and a Resume are rows, a person's +// accepted turn is a row, and a /clear's carried card names its source. Nothing is stored beside +// them, so nothing has to retire. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import Database from '../../sqlite/sync-database' +import { journalDatabasePath } from './journal-host-database' +import { + createTrackedJournalOpener, + liveTestJournalRows +} from './journal-host-database-test-support' +import { QueuedMessageNotConsumableError } from './journal-queued-messages' +import { applyJournalRow, createJournalReducerState } from './journal-reducer' +import { parseJournalRow } from './journal-row-schema' +import type { AgentSessionJournal } from './journal-store' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-p', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } +} +const HOST = 'proc-1' + +let root: string +let clock = 1_000 +const journals = createTrackedJournalOpener() + +function message(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +function open(): Promise { + return journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => ++clock, + mintEpoch: () => `epoch-${clock}` + }) +} + +function queueDraft(journal: AgentSessionJournal, messageId: string, carriedFrom?: string) { + return journal.queuedMessages.insert({ + messageId, + body: message(messageId), + fingerprint: `fp-${messageId}`, + hostInstance: HOST, + ...(carriedFrom ? { carriedFrom } : {}) + }) +} + +/** A turn sent, then accepted by the provider; `origin` says who asked for it. */ +async function turn( + journal: AgentSessionJournal, + id: string, + origin: 'client' | 'host', + accept = true +): Promise { + await journal.appendSubmission({ + clientMessageId: id, + origin, + payloadFingerprint: `fp-${id}`, + body: message(id), + fence: 0, + handoverRecorded: true + }) + if (accept) { + await acceptTurn(journal, id) + } +} + +function acceptTurn(journal: AgentSessionJournal, id: string) { + return journal.resolveDispatch({ + clientMessageId: id, + state: 'accepted', + providerIdentity: { provider: 'claude', sessionId: 'native-1', uuid: `echo-${id}` }, + fence: 0 + }) +} + +function reason(journal: AgentSessionJournal): string | null { + return journal.queuedMessages.pause(HOST)?.reason ?? null +} + +function storedPauses(): number { + const db = new Database(journalDatabasePath(root), { readonly: true }) + try { + const row: unknown = db.prepare('SELECT COUNT(*) AS n FROM queued_message_pauses').get() + return typeof row === 'object' && row !== null && 'n' in row ? Number(row.n) : -1 + } finally { + db.close() + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queue-pause-')) + clock = 1_000 +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe("the queue's pause, derived from the journal", () => { + it('a Stop row pauses the queue, even with no card yet, survives reopen, and stores nothing', async () => { + let journal = await open() + await journal.appendQueuePauseMark('stopped', 0) + expect(reason(journal)).toBe('stopped') + await queueDraft(journal, 'draft-1') + await journal.close() + journal = await open() + expect(reason(journal)).toBe('stopped') + expect(storedPauses()).toBe(0) + }) + + it("only a person's turn sent after the Stop and accepted lifts it; host turns never do", async () => { + const journal = await open() + await turn(journal, 'before-stop', 'client', false) + await journal.appendQueuePauseMark('stopped', 0) + // Sent before the Stop: its acceptance now does not end a Stop that came after it. + await acceptTurn(journal, 'before-stop') + await turn(journal, 'mail', 'host') + expect(reason(journal)).toBe('stopped') + await turn(journal, 'typed', 'client', false) + expect(reason(journal)).toBe('stopped') + await acceptTurn(journal, 'typed') + expect(reason(journal)).toBeNull() + }) + + it('a later Stop is the latest, and a Resume row lifts it', async () => { + const journal = await open() + await journal.appendQueuePauseMark('stopped', 0) + await turn(journal, 'typed', 'client') + expect(reason(journal)).toBeNull() + await journal.appendQueuePauseMark('stopped', 0) + expect(reason(journal)).toBe('stopped') + await journal.appendQueuePauseMark('resumed', 0) + expect(reason(journal)).toBeNull() + await journal.appendQueuePauseMark('stopped', 0) + expect(reason(journal)).toBe('stopped') + expect(storedPauses()).toBe(0) + }) + + it("a card /clear carried in pauses the replacement 'cleared' until a Resume there", async () => { + let journal = await open() + await queueDraft(journal, 'carried', 'source-session') + expect(reason(journal)).toBe('cleared') + await journal.close() + journal = await open() + expect(reason(journal)).toBe('cleared') + await journal.appendQueuePauseMark('resumed', 0) + expect(reason(journal)).toBeNull() + expect(storedPauses()).toBe(0) + }) + + it("a person's accepted turn on the replacement lifts 'cleared'; a host turn does not", async () => { + const journal = await open() + await queueDraft(journal, 'carried', 'source-session') + await turn(journal, 'launch', 'host') + expect(reason(journal)).toBe('cleared') + await turn(journal, 'typed', 'client') + expect(reason(journal)).toBeNull() + }) + + it('rides a tombstone of an id no item takes, so an older build reads it and changes nothing', async () => { + const journal = await open() + await turn(journal, 'typed', 'client') + await journal.appendQueuePauseMark('stopped', 0) + const db = new Database(journalDatabasePath(root), { readonly: true }) + const stored = liveTestJournalRows(db, IDENTITY.sessionId) + db.close() + const parsed = parseJournalRow(stored.at(-1)?.rowJson ?? '') + expect(parsed).toMatchObject({ ok: true, row: { kind: 'tombstone', queuePause: 'stopped' } }) + if (!parsed.ok || parsed.row.kind !== 'tombstone') { + throw new Error('expected a tombstone row') + } + // An older build ignores the unknown key: the row is an ordinary removal of nothing. + const { queuePause: _ignored, ...asOlderBuildReadsIt } = parsed.row + const state = createJournalReducerState(IDENTITY.sessionId, parsed.row.epoch) + for (const row of stored.slice(0, -1)) { + const earlier = parseJournalRow(row.rowJson) + if (earlier.ok) { + applyJournalRow(state, earlier.row) + } + } + const items = [...state.items.keys()] + const submissions = [...state.submissions.keys()] + applyJournalRow(state, asOlderBuildReadsIt) + expect([...state.items.keys()]).toEqual(items) + expect([...state.submissions.keys()]).toEqual(submissions) + }) + + it("the queue's own consume is refused in its transaction while the pause holds the card; Send-now is not", async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.appendQueuePauseMark('stopped', 0) + const consume = (id: string, automatic: boolean) => + journal.appendSubmission( + { + clientMessageId: id, + origin: automatic ? 'host' : 'client', + payloadFingerprint: 'fp-draft-1', + body: message('draft-1'), + fence: 0, + handoverRecorded: true + }, + { + messageId: 'draft-1', + expect: 'waiting', + settledByOp: null, + hostInstance: HOST, + ...(automatic ? { yieldsToPause: { hostInstance: HOST } } : {}) + } + ) + await expect(consume('drain-1', true)).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) + expect(journal.submission('drain-1')).toBeUndefined() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + await consume('send-now-1', false) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + }) + + it("a rewind's epoch replacement restates a Stop still pausing, and not one a person ended", async () => { + const journal = await open() + await journal.appendQueuePauseMark('stopped', 0) + await journal.replaceEpochItems('handle_forked', 0, []) + expect(reason(journal)).toBe('stopped') + await turn(journal, 'typed', 'client') + await journal.replaceEpochItems('handle_forked', 0, []) + expect(reason(journal)).toBeNull() + }) +}) + +describe("a restart's pause", () => { + it("adopting a restart's rows moves them into this instance and clears an older build's stored 'stopped' hold; send_failed stays", async () => { + const journal = await open() + await journal.queuedMessages.insert({ + messageId: 'draft-restart', + body: message('written before the restart'), + fingerprint: 'fp-draft-restart', + hostInstance: 'proc-0' + }) + expect(reason(journal)).toBe('restarted') + await queueDraft(journal, 'draft-legacy') + await queueDraft(journal, 'draft-failed') + await journal.queuedMessages.hold({ messageIds: ['draft-failed'], reason: 'send_failed' }) + const db = new Database(journalDatabasePath(root)) + db.prepare("UPDATE queued_messages SET hold_reason = 'stopped' WHERE message_id = ?").run( + 'draft-legacy' + ) + db.close() + journal.queuedMessages.invalidate() + expect(await journal.queuedMessages.adopt(HOST)).toBe(true) + expect(reason(journal)).toBeNull() + expect( + journal.queuedMessages.list().map((row) => [row.messageId, row.hostInstance, row.holdReason]) + ).toEqual([ + ['draft-restart', HOST, null], + ['draft-legacy', HOST, null], + ['draft-failed', HOST, 'send_failed'] + ]) + }) + + it('an adoption with nothing to adopt changes nothing and fires no commit notification', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + const revision = journal.queuedMessages.revision() + expect(await journal.queuedMessages.adopt(HOST)).toBe(false) + expect(journal.queuedMessages.revision()).toBe(revision) + }) +}) 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 new file mode 100644 index 000000000000..ef0cd9950a9e --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -0,0 +1,115 @@ +// The queue's pause: a pure function of the journal fold and the cards, never a stored flag, so +// nothing has to retire it. Paused when: +// - 'stopped': the latest Stop row has no later Resume row, and no turn a person asked for was +// sent after it and accepted. A later Stop is simply the latest. +// - 'cleared': a card /clear carried into this conversation waits, and no person's turn or +// Resume has happened here since. +// - 'restarted': a waiting card was written by another host process, and no person's turn has +// started since this conversation opened. +// A person's turn is an accepted submission of origin `client`. Orchestration mail, a restart +// continuation, a launch prompt and the queue's own drain are `host` and never lift it. + +import type { JournalQueuePauseMark, JournalTombstoneRow } from './journal-row-schema' + +export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' + +/** The latest Stop and Resume rows, folded by the reducer; 0 when none. */ +export type JournalQueuePauseMarks = { + stoppedSequence: number + stoppedAt: number + resumedSequence: number +} + +export type DerivedQueuePause = { + reason: QueuePauseReason + /** Host clock the pause began at; a card created later is newer than it. */ + since: number +} + +type QueueCard = { + state: string + holdReason: string | null + createdAt: number + hostInstance: string + carriedFrom: string | null +} + +export function createJournalQueuePauseMarks(): JournalQueuePauseMarks { + return { stoppedSequence: 0, stoppedAt: 0, resumedSequence: 0 } +} + +export function foldJournalQueuePauseMark( + marks: JournalQueuePauseMarks, + row: JournalTombstoneRow & { queuePause: JournalQueuePauseMark } +): void { + if (row.queuePause === 'stopped') { + marks.stoppedSequence = row.seq + marks.stoppedAt = row.ts + } else if (row.queuePause === 'resumed') { + marks.resumedSequence = row.seq + } +} + +/** The latest Stop still pauses: nothing a person did since, and no Resume, ended it. */ +export function journalQueueStopHolds( + marks: JournalQueuePauseMarks, + latestPersonTurnSequence: number +): boolean { + return marks.stoppedSequence > Math.max(latestPersonTurnSequence, marks.resumedSequence) +} + +export function deriveQueuePause(input: { + marks: JournalQueuePauseMarks + latestPersonTurnSequence: number + cards: readonly QueueCard[] + hostInstance: string + /** A person's turn started since this conversation opened. */ + restartEnded: boolean +}): DerivedQueuePause | null { + const { marks, latestPersonTurnSequence } = input + if (journalQueueStopHolds(marks, latestPersonTurnSequence)) { + return { reason: 'stopped', since: marks.stoppedAt } + } + const waiting = input.cards.filter((card) => card.state === 'waiting') + const carried = waiting.filter((card) => card.carriedFrom !== null) + if (carried.length > 0 && latestPersonTurnSequence === 0 && marks.resumedSequence === 0) { + return { reason: 'cleared', since: Math.max(...carried.map((card) => card.createdAt)) } + } + if (!input.restartEnded && waiting.some((card) => card.hostInstance !== input.hostInstance)) { + // The process that wrote a card is gone: every card waits, whenever it was written. + return { reason: 'restarted', since: Number.POSITIVE_INFINITY } + } + return null +} + +// Open product decision: whether a pause also holds a card queued AFTER it began (for example +// typed while orchestration mail runs after a Stop). true: every waiting card waits for the person. +// false: only cards that existed when it began, or came back from it, wait; newer ones drain. +const PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT = true + +/** THE rule for which cards a pause holds: the drain, its consume, and publication all read it. */ +export function queuePauseHolds(pause: DerivedQueuePause, card: QueueCard): boolean { + return ( + card.state === 'waiting' && + card.holdReason === null && + (PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT || card.createdAt <= pause.since) + ) +} + +/** Whether Resume would send anything: a card the pause holds, not behind a returned card, which + * blocks everything after it until the user acts. Only then is the pause PUBLISHED, so its + * header never offers a Resume that sends nothing. */ +export function queuePauseHoldsResumableCard( + pause: DerivedQueuePause, + cards: readonly QueueCard[] +): boolean { + for (const card of cards) { + if (card.state === 'returned') { + return false + } + if (queuePauseHolds(pause, card)) { + return true + } + } + return false +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-schema.ts b/src/main/native-chat/agent-session-journal/queued-message-schema.ts index 31806e6b71a7..34230fe51cf0 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-schema.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-schema.ts @@ -9,7 +9,8 @@ const NULLABLE_COLUMNS: readonly (readonly [name: string, type: string])[] = [ ['returned_rejection', 'TEXT'], ['settled_at', 'INTEGER'], ['settled_by_op', 'TEXT'], - ['consumed_as', 'TEXT'] + ['consumed_as', 'TEXT'], + ['carried_from', 'TEXT'] ] /** @@ -37,6 +38,7 @@ CREATE TABLE IF NOT EXISTS queued_messages ( settled_at INTEGER, settled_by_op TEXT, consumed_as TEXT, + carried_from TEXT, PRIMARY KEY (session_id, message_id) ); `) @@ -61,6 +63,8 @@ CREATE TABLE IF NOT EXISTS queued_messages ( db.exec(` CREATE UNIQUE INDEX IF NOT EXISTS queued_messages_consumed_as ON queued_messages (session_id, consumed_as) WHERE consumed_as IS NOT NULL; +-- Unread and unwritten: the queue's pause is derived from the journal. Kept for downgrade +-- safety; dropped in a later migration. CREATE TABLE IF NOT EXISTS queued_message_pauses ( session_id TEXT PRIMARY KEY, reason TEXT NOT NULL, diff --git a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts index 3813534a7c6c..8d2e9ec0acce 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts @@ -8,9 +8,7 @@ import type Database from '../../sqlite/sync-database' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { consumedSubmissionWasRejected, - journalDispatchRowApplies, - journalDispatchRowNewlyRejects, - rejectedDraftSettlement + journalDispatchRowNewlyRejects } from './journal-dispatch-settlement' import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' @@ -37,36 +35,6 @@ export function queuedMessageSettlementOwed( ) } -/** A dispatched draft's hand-off can still send it back to waiting: unanswered, so a Stop may - * yet withdraw it, or withdrawn with that return still owed. What a queue pause holds back - * besides waiting cards. `row`, the journal row this transaction appends, counts as applied, - * so the write that settles the last such hand-off is the one that retires the pause. */ -export function mayReturnToWaiting( - submissions: Submissions, - row?: JournalRow -): (consumedRef: string) => boolean { - return (consumedRef) => { - if (row?.kind === 'submission' && row.clientMessageId === consumedRef) { - return true - } - const submission = submissions.get(consumedRef) - const current = - row?.kind === 'dispatch' && - row.clientMessageId === consumedRef && - journalDispatchRowApplies(submission) - ? { dispatchState: row.state, reason: row.reason, rejection: row.rejection } - : submission - if (current?.dispatchState === 'pending') { - return true - } - return ( - current !== undefined && - consumedSubmissionWasRejected(current) && - rejectedDraftSettlement(current).state === 'waiting' - ) - } -} - /** Applies each owed settlement, and withdraws each waiting draft an applied echo proves * delivered (`draftsDeliveredByAppliedEcho`); returns how many drafts changed. */ export function settleOwedQueuedMessages( diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts index 053210c015de..c3910e466854 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -685,172 +685,6 @@ describe('holds', () => { }) }) -describe("the queue's Stop fact", () => { - it('records where the Stop took effect, survives reopen, and the latest Stop replaces an earlier one', async () => { - let journal = await open() - await queueDraft(journal, 'draft-1') - await journal.queuedMessages.recordPause('stopped') - const first = journal.queuedMessages.pause() - expect(first).toMatchObject({ reason: 'stopped', sequence: journal.cursor().sequence }) - await journal.appendItem( - { provider: 'orca', clientMessageId: 'later' }, - { kind: 'status', text: 'later' }, - { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.queuedMessages.recordPause('stopped') - expect(journal.queuedMessages.pause()?.sequence).toBe((first?.sequence ?? 0) + 1) - await journal.close() - journal = await open() - expect(journal.queuedMessages.pause()?.sequence).toBe((first?.sequence ?? 0) + 1) - // The pause is the queue's, never a row's. - expect(journal.queuedMessages.get('draft-1')?.holdReason).toBeNull() - }) - - it("a /clear's replacement records its pause as 'cleared', read back the same way", async () => { - let journal = await open() - await queueDraft(journal, 'draft-1') - await journal.queuedMessages.recordPause('cleared') - await journal.close() - journal = await open() - expect(journal.queuedMessages.pause()).toMatchObject({ reason: 'cleared' }) - }) - - it('retires in the write that takes the last card it holds back: a hold of its own', async () => { - const journal = await open() - await queueDraft(journal, 'draft-held') - expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) - await journal.queuedMessages.hold({ messageIds: ['draft-held'], reason: 'send_failed' }) - expect(journal.queuedMessages.pause()).toBeNull() - }) - - it('records nothing over a queue with no card it holds back, judged in its own transaction', async () => { - const journal = await open() - expect(await journal.queuedMessages.recordPause('stopped')).toBe(false) - expect(journal.queuedMessages.pause()).toBeNull() - await queueDraft(journal, 'draft-1') - expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) - expect(journal.queuedMessages.pause()).not.toBeNull() - }) - - it('holds a card sent now while its hand-off is unanswered; the write accepting it retires the pause', async () => { - let journal = await open() - await queueDraft(journal, 'draft-1') - expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) - // Send-now: the last waiting card is handed off, which a Stop may yet withdraw. - await consumeDraft(journal, 'draft-1') - expect(journal.queuedMessages.pause()).not.toBeNull() - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'accepted', - providerIdentity: { provider: 'claude', sessionId: 'native-1', uuid: 'echo-1' }, - fence: 0 - }) - expect(journal.queuedMessages.pause()).toBeNull() - await journal.close() - journal = await open() - expect(journal.queuedMessages.pause()).toBeNull() - }) - - it("records over a hand-off still unanswered, so the Stop's withdrawal lands it back under the pause", async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...STOP_WITHDRAWAL, - fence: 0 - }) - expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') - expect(journal.queuedMessages.pause()).not.toBeNull() - }) - - it('a hand-off whose return to waiting is still owed (its hook skipped) keeps the pause', async () => { - const journal = await open() - await queueDraft(journal, 'draft-sent') - await queueDraft(journal, 'draft-other') - await consumeDraft(journal, 'draft-sent') - expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const hook = vi - .spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction') - .mockImplementationOnce(() => { - throw new Error('bookkeeping failed') - }) - try { - await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) - } finally { - hook.mockRestore() - warn.mockRestore() - } - expect(journal.queuedMessages.get('draft-sent')?.state).toBe('dispatched') - // The only waiting card goes; the owed one is still a card the pause holds back. - await journal.queuedMessages.withdraw({ messageIds: ['draft-other'], settledByOp: 'c\u0000op' }) - expect(journal.queuedMessages.pause()).not.toBeNull() - await journal.queuedMessages.settleOwed() - expect(journal.queuedMessages.get('draft-sent')?.state).toBe('waiting') - expect(journal.queuedMessages.pause()).not.toBeNull() - }) - - it('lifting retires only the Stop fact it judged, never one recorded since', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await journal.queuedMessages.recordPause('stopped') - const judged = journal.queuedMessages.pause() - await journal.appendItem( - { provider: 'orca', clientMessageId: 'later' }, - { kind: 'status', text: 'later' }, - { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.queuedMessages.recordPause('stopped') - expect(await journal.queuedMessages.liftPause({ stop: judged, adoptInto: null })).toBe(false) - expect(journal.queuedMessages.pause()).not.toBeNull() - expect( - await journal.queuedMessages.liftPause({ - stop: journal.queuedMessages.pause(), - adoptInto: null - }) - ).toBe(true) - expect(journal.queuedMessages.pause()).toBeNull() - }) - - it("adopting a restart's rows moves them into this instance and clears an older build's stored 'stopped' hold; send_failed stays", async () => { - const journal = await open() - await journal.queuedMessages.insert({ - messageId: 'draft-restart', - body: message('written before the restart'), - fingerprint: 'fp-draft-restart', - hostInstance: 'proc-0' - }) - await queueDraft(journal, 'draft-legacy') - await queueDraft(journal, 'draft-failed') - await journal.queuedMessages.hold({ messageIds: ['draft-failed'], reason: 'send_failed' }) - const db = new Database(journalDatabasePath(root)) - db.prepare("UPDATE queued_messages SET hold_reason = 'stopped' WHERE message_id = ?").run( - 'draft-legacy' - ) - db.close() - journal.queuedMessages.invalidate() - expect(await journal.queuedMessages.liftPause({ stop: null, adoptInto: 'proc-1' })).toBe(true) - expect( - journal.queuedMessages.list().map((row) => [row.messageId, row.hostInstance, row.holdReason]) - ).toEqual([ - ['draft-restart', 'proc-1', null], - ['draft-legacy', 'proc-1', null], - ['draft-failed', 'proc-1', 'send_failed'] - ]) - }) - - it('a lift with nothing to lift changes nothing and fires no commit notification', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - const revision = journal.queuedMessages.revision() - expect(await journal.queuedMessages.liftPause({ stop: null, adoptInto: 'proc-1' })).toBe(false) - expect(journal.queuedMessages.revision()).toBe(revision) - }) -}) - describe('the commit listener', () => { it('draft-table writes fire it exactly when rows changed, so no caller publishes by hand', async () => { const journal = await open() diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index ffc9b240987e..8b812e33481b 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -50,10 +50,13 @@ export type QueuedMessageRow = { * card, cleared when a withdrawal sends it back to waiting. Host-only; the published link is * the submission's `queuedMessageId`. */ consumedAs: string | null + /** The conversation /clear carried this card from; null for a card written here. What the + * replacement's 'cleared' pause is derived from. */ + carriedFrom: string | null } const COLUMNS = - 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as' + 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as, carried_from' export function insertQueuedMessage( db: Database.Database, @@ -63,6 +66,7 @@ export function insertQueuedMessage( body: AgentJournalMessageItem fingerprint: string hostInstance: string + carriedFrom?: string now: number } ): QueuedMessageRow { @@ -73,7 +77,7 @@ export function insertQueuedMessage( const position = Number(highest?.p ?? 0) + 1 db.prepare( `INSERT INTO queued_messages (${COLUMNS}) - VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL)` + VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL, ?)` ).run( input.sessionId, input.messageId, @@ -81,7 +85,8 @@ export function insertQueuedMessage( JSON.stringify(input.body), input.fingerprint, input.now, - input.hostInstance + input.hostInstance, + input.carriedFrom ?? null ) return { sessionId: input.sessionId, @@ -97,7 +102,8 @@ export function insertQueuedMessage( returnedRejection: null, settledAt: null, settledByOp: null, - consumedAs: null + consumedAs: null, + carriedFrom: input.carriedFrom ?? null } } @@ -276,6 +282,7 @@ function toStoredRow(row: unknown): QueuedMessageRow | null { settled_at: number | null settled_by_op: string | null consumed_as: string | null + carried_from: string | null } let body: AgentJournalMessageItem try { @@ -309,7 +316,8 @@ function toStoredRow(row: unknown): QueuedMessageRow | null { returnedRejection: storedRejection(record.returned_rejection), settledAt: record.settled_at, settledByOp: record.settled_by_op, - consumedAs: record.consumed_as + consumedAs: record.consumed_as, + carriedFrom: record.carried_from } } diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts index b70a18a83861..55782a3756f6 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts @@ -14,7 +14,10 @@ import type { } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournalBatch } from '../../../shared/agent-session-wire' import { findSequenceGap } from '../agent-session-journal/journal-cursor' -import type { JournalRow } from '../agent-session-journal/journal-row-schema' +import { + isJournalQueuePauseRow, + type JournalRow +} from '../agent-session-journal/journal-row-schema' export type JournalBatchProjection = | { ok: true; batch: AgentSessionJournalBatch } @@ -50,6 +53,10 @@ export function projectJournalBatch(input: { } continue } + if (isJournalQueuePauseRow(row)) { + // Host-only: the queue pause it feeds is published beside the list, not as an item. + continue + } if (row.kind === 'item' || row.kind === 'tombstone') { touchedItemIds.add( input.canonicalItemId?.(row.itemId) ?? aliases.get(row.itemId) ?? row.itemId diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index c645b6ea4e47..c267a0028800 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -53,47 +53,52 @@ export async function createQueuedMessageTestRig(options: { restartable?: true } state: 'accepted' as const, providerIdentity: null })) + const cancelTurn: Mock = vi.fn(async () => ({ + cancelled: true + })) let events: StructuredAgentSessionEventSink | undefined const store = await openTestAgentSessionRecordStore(root) - const host = new StructuredAgentSessionHost({ - store, - adapter: { - acquire: async ({ identity, fence, spawnToken, events: sink }) => { - events = sink - const resumes = - options.restartable === true && - (store.getRecord(identity.sessionId)?.providerHandleChain.length ?? 0) > 0 - return { - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken - }, - acquisitionGeneration: 'generation-1', - link: { - linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, - origin: resumes ? ('resumed' as const) : ('created' as const), - mintedAtFence: fence, - observedAt: NOW + const makeHost = () => + new StructuredAgentSessionHost({ + store, + adapter: { + acquire: async ({ identity, fence, spawnToken, events: sink }) => { + events = sink + const resumes = + options.restartable === true && + (store.getRecord(identity.sessionId)?.providerHandleChain.length ?? 0) > 0 + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + acquisitionGeneration: 'generation-1', + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: resumes ? ('resumed' as const) : ('created' as const), + mintedAtFence: fence, + observedAt: NOW + } } - } + }, + dispatch, + awaitStarted, + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + compact, + cancelTurn, + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) }, - dispatch, - awaitStarted, - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - compact, - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-1', - now: () => NOW - }) + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-1', + now: () => NOW + }) + let host = makeHost() expect(await host.attach(QUEUED_RIG_CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) @@ -254,6 +259,12 @@ export async function createQueuedMessageTestRig(options: { restartable?: true } rotateStructuredAgentSessionHostInstanceForTests() } + /** A host process that dies with no close: a new host opens the same state directory. */ + function crashRestartHostProcess(): void { + rotateStructuredAgentSessionHostInstanceForTests() + host = makeHost() + } + /** The queue's published pause: null when it sends on its own. */ async function queuePause(sessionId = SESSION): Promise { const page = await host.history({ sessionId, direction: 'tail' }) @@ -277,8 +288,11 @@ export async function createQueuedMessageTestRig(options: { restartable?: true } return { root, store, - host, + get host() { + return host + }, dispatch, + cancelTurn, awaitStarted, compact, finishCompact, @@ -295,6 +309,7 @@ export async function createQueuedMessageTestRig(options: { restartable?: true } settleAccepted, settleRejected, restartHostProcess, + crashRestartHostProcess, queuePause, resume, dispose diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index aa9d31cbf105..d3847af8b728 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -14,7 +14,10 @@ import { import { ConversationCommandParams } from '../../../shared/rpc-contract/structured-agent-session-params' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' -import { rotateStructuredAgentSessionHostInstanceForTests } from './structured-agent-session-queued-pause' +import { + rotateStructuredAgentSessionHostInstanceForTests, + structuredQueuePause +} from './structured-agent-session-queued-pause' import { createQueuedMessageTestRig, eventually, @@ -512,19 +515,23 @@ describe('Stop and Delete', () => { }) }) - it('a Stop whose pause record fails still interrupts; only the pause is lost, and it is reported', async () => { + it('a Stop whose row fails to write still interrupts; only the pause is lost, and it is reported', async () => { await workingSend() const queued = await send('kept by the stop', 'queue-if-active').result if (!queued.ok || !('queued' in queued.value)) { throw new Error('expected a queued receipt') } const record = vi - .spyOn(JournalQueuedMessages.prototype, 'recordPause') + .spyOn(AgentSessionJournal.prototype, 'appendQueuePauseMark') .mockRejectedValueOnce(new Error('disk full')) const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) try { expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(warned).toHaveBeenCalledWith(expect.stringContaining('queue pause'), expect.anything()) + expect(rig.cancelTurn).toHaveBeenCalledTimes(1) + expect(warned).toHaveBeenCalledWith( + expect.stringContaining("Stop's queue row"), + expect.anything() + ) } finally { record.mockRestore() warned.mockRestore() @@ -674,7 +681,7 @@ describe('/clear', () => { } expect(await drafts(replacementId)).toHaveLength(0) const journal = host.collaboratorsForTests().sessions.get(replacementId)?.journal - expect(journal?.queuedMessages.pause()).toBeNull() + expect(journal && structuredQueuePause(journal)).toBeNull() }) it('a returned card carries over as a plain waiting draft on the paused replacement', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index b6d9d2a90beb..bd7b4b0bb03f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -30,6 +30,7 @@ import { structuredAgentSessionHostInstance, structuredQueuePause } from './structured-agent-session-queued-pause' +import { queuePauseHolds } from '../agent-session-journal/queued-message-pause' /** Budget at accept, in the send schema's own unit (`Buffer.byteLength` of the * serialized blocks); refused readably rather than trimmed. */ @@ -57,23 +58,28 @@ export function pendingPromptExists(journal: Pick + journal: Pick ): QueuedMessageRow | null { const rows = journal.queuedMessages.list() // Nothing waiting costs no pause derivation: this runs on every journal publish. - if (!rows.some((row) => row.state === 'waiting') || structuredQueuePause(journal) !== null) { + if (!rows.some((row) => row.state === 'waiting')) { return null } + const pause = structuredQueuePause(journal) for (const row of rows) { if (row.state === 'returned') { // A returned card blocks everything after it until the user acts. return null } - if (row.state === 'waiting' && row.holdReason === null) { + if ( + row.state === 'waiting' && + row.holdReason === null && + !(pause && queuePauseHolds(pause, row)) + ) { return row } } @@ -358,12 +364,13 @@ export class StructuredAgentSessionQueuedMessageDrain { messageId: next.messageId, expect: 'waiting', settledByOp: null, - hostInstance: structuredAgentSessionHostInstance() + hostInstance: structuredAgentSessionHostInstance(), + yieldsToPause: { hostInstance: structuredAgentSessionHostInstance() } } ) } catch (error) { if (error instanceof QueuedMessageNotConsumableError) { - // Lost a race with a Send-now or Delete; their transition stands. + // Lost a race with a Send-now, a Delete or a Stop; their transition stands. return } // Pre-consume failure: the draft stays waiting, held with the marker on diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index 9da974a44a43..ee7fb2c76478 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -80,9 +80,9 @@ export async function withdrawQueuedMessagesForOperation( * cards stay visible where the user now is. The replacement's queue starts * paused ('cleared'), lifted exactly like a Stop's: the cards were written for the context /clear just * discarded, so they wait for the user's next turn there, or Resume, rather than - * sending into the fresh context unasked. Each card lands with the pause in one - * transaction, so the drain never sees a carried card unpaused and no pause is - * left over an empty queue if an insert fails. Runs after the + * sending into the fresh context unasked. Each card records the conversation it + * came from, which IS that pause, so the drain never sees a carried card unpaused + * and no pause outlives the cards. Runs after the * replacement's attach succeeded and before the clear commits. Each insert is * idempotent on (session, message), so a retried clear replays it safely; the source rows are then tombstoned. Bookkeeping around the clear: * a failure leaves the cards on the superseded source — whose supersession @@ -117,7 +117,7 @@ export async function carryQueuedMessagesToClearReplacement( body: row.body, fingerprint: queuedMessageFingerprint(input.replacementSessionId, row.body), hostInstance: structuredAgentSessionHostInstance(), - pausedBy: 'cleared' + carriedFrom: ctx.sessionId }) } await withdrawQueuedMessagesForOperation(ctx.journal, { @@ -321,11 +321,11 @@ export function resumeStructuredAgentQueue( method: 'agentSession.queuedMessagesResume', fields: {}, conversationWrite: true, - // The lift notifies through the journal's commit listener, which publishes the - // cleared pause and wakes the drain. + // The Resume row notifies through the journal's commit listener, which publishes the + // lifted pause and wakes the drain. run: async (ctx) => ({ ok: true, - value: { resumed: await resumeStructuredQueue(ctx.journal) } + value: { resumed: await resumeStructuredQueue(ctx.journal, ctx.fence) } }), // Like Stop's replay: the Resume already ran, so this one lifts nothing. replay: () => ({ resumed: false }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts deleted file mode 100644 index 5e8c7dd027b4..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -// A Stop's queue pause kept alive only by a card's unanswered hand-off ends on every path that -// ends that hand-off, so no lost answer pauses a chat's queue for good: the provider answering -// it, the chat closing, a restart, the provider dying without answering, and a /clear. - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' -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' - -let rig: QueuedMessageTestRig - -beforeEach(async () => { - rig = await createQueuedMessageTestRig({ restartable: true }) -}) - -afterEach(() => rig.dispose()) - -async function queuedDraft(text: string, sessionId = HOST_TEST_SESSION): Promise { - const queued = await rig.send(text, 'queue-if-active').result - if (!queued.ok || !('queued' in queued.value)) { - throw new Error('expected a queued receipt') - } - const { messageId } = queued.value.queued - expect((await rig.drafts(sessionId)).some((card) => card.messageId === messageId)).toBe(true) - return messageId -} - -/** The stored Stop fact, read from the open conversation (opened by a history read if closed). */ -async function stopFact(sessionId = HOST_TEST_SESSION) { - await rig.drafts(sessionId) - const journal = rig.host.collaboratorsForTests().sessions.get(sessionId)?.journal - if (!journal) { - throw new Error('expected the conversation open') - } - return journal.queuedMessages.pause() -} - -/** A card sent now into the running turn, then Stop, with the provider answering neither: the - * pause is recorded over that unanswered hand-off alone, and is not shown. */ -async function stopOverUnansweredHandOff(): Promise<{ working: string; sentId: string }> { - const working = await rig.workingSend() - const sentId = await queuedDraft('sent now into the turn') - await rig.sendNow(sentId) - await eventually(async () => expect((await rig.handoff(sentId))?.handedOverAt).toBeDefined()) - expect(await rig.stop()).toMatchObject({ ok: true }) - expect(await stopFact()).toMatchObject({ reason: 'stopped' }) - expect(await rig.queuePause()).toBeNull() - return { working, sentId } -} - -/** The queue sends on its own again: a card typed during a later mail turn drains after it. */ -async function expectQueueDrains(): Promise { - expect(await stopFact()).toBeNull() - expect(await rig.queuePause()).toBeNull() - const mail = rig.send('coordinator mail', undefined, { internal: true }) - await mail.result - await eventually(async () => expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined()) - const later = await queuedDraft('typed during the mail turn') - await rig.settleAccepted(mail.id, 'mail') - await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) -} - -/** The provider's answer that the Stop took the card back before it reached the agent. */ -async function withdraw(draftId: string): Promise { - await rig.host.settleLateDispatch({ - sessionId: HOST_TEST_SESSION, - clientMessageId: await rig.handoffId(draftId), - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) - }) -} - -function clear() { - const fields = { command: 'clear' as const } - return rig.host.conversationCommand(QUEUED_RIG_CALLER, { - envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), - ...fields - }) -} - -function providerExited() { - return rig.host.handleAdapterEvent({ - type: 'ended', - sessionId: HOST_TEST_SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: 1, - acquisitionGeneration: 'generation-1' - }) -} - -describe('a pause held only by an unanswered hand-off', () => { - it('the provider withdrawing it leaves the card waiting under the pause; Resume sends it', async () => { - const { working, sentId } = await stopOverUnansweredHandOff() - await withdraw(sentId) - await rig.settleAccepted(working, 'stopped') - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - expect(await rig.drafts()).toEqual([{ messageId: sentId, state: 'waiting' }]) - expect(await rig.resume()).toMatchObject({ ok: true }) - await eventually(async () => expect((await rig.handoff(sentId))?.origin).toBe('host')) - }) - - it('ends when the provider dies without answering it', async () => { - const { sentId } = await stopOverUnansweredHandOff() - await providerExited() - // The dead generation's settlement: in doubt, never back to waiting. - await eventually(async () => expect((await rig.handoff(sentId))?.dispatchState).toBe('unknown')) - await expectQueueDrains() - }) - - it('ends when the chat closes', async () => { - const { sentId } = await stopOverUnansweredHandOff() - await rig.host.close(HOST_TEST_SESSION) - expect((await rig.handoff(sentId))?.dispatchState).toBe('unknown') - await expectQueueDrains() - }) - - it('ends when the host restarts under it', async () => { - const { sentId } = await stopOverUnansweredHandOff() - await rig.restartHostProcess() - expect((await rig.handoff(sentId))?.dispatchState).toBe('unknown') - await expectQueueDrains() - }) - - it('a withdrawal the restart finds still owed is settled at open, under the pause; Resume sends it', async () => { - const { working, sentId } = await stopOverUnansweredHandOff() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const hook = vi - .spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction') - .mockImplementationOnce(() => { - throw new Error('bookkeeping failed') - }) - try { - await withdraw(sentId) - } finally { - hook.mockRestore() - warn.mockRestore() - } - await rig.settleAccepted(working, 'stopped') - await rig.restartHostProcess() - expect(await rig.drafts()).toEqual([{ messageId: sentId, state: 'waiting' }]) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - expect(await rig.resume()).toMatchObject({ ok: true }) - await eventually(async () => expect((await rig.handoff(sentId))?.origin).toBe('host')) - }) - - it("waits for it before a /clear, which then carries every waiting card paused 'cleared'", async () => { - const { working, sentId } = await stopOverUnansweredHandOff() - const carriedId = await queuedDraft('waiting behind the stop') - await rig.settleAccepted(working, 'stopped') - // An unanswered send blocks /clear: the hand-off ends first, by one of the paths above. - expect(await clear()).toMatchObject({ - ok: false, - refusal: { details: { reason: 'messagesUnsettled' } } - }) - await withdraw(sentId) - const cleared = await clear() - const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined - if (!replacementId) { - throw new Error(`expected a replacement session: ${JSON.stringify(cleared)}`) - } - // The superseded chat keeps no pause over the cards it gave away. - expect(await stopFact()).toBeNull() - expect(await rig.drafts(replacementId)).toEqual([ - { messageId: sentId, state: 'waiting' }, - { messageId: carriedId, state: 'waiting' } - ]) - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) - const resumed = await rig.host.queuedMessagesResume(QUEUED_RIG_CALLER, { - envelope: rig.envelope( - {}, - 'agentSession.queuedMessagesResume', - hostTestOperationId(), - replacementId - ) - }) - expect(resumed).toMatchObject({ ok: true }) - await eventually(async () => - expect( - (await rig.host.journalSnapshot(replacementId)).submissions.some( - (entry) => entry.queuedMessageId === sentId - ) - ).toBe(true) - ) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 42230f826a30..65cb937eaf79 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -16,6 +16,7 @@ import { type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' import { sameQueuePause } from './structured-agent-session-queued-publication' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { structuredAgentSessionHostInstance, structuredQueuePause @@ -188,61 +189,40 @@ describe('the pause read', () => { } const scan = vi.spyOn(journal, 'submissions') // Read on every publish, per subscriber: it must not walk the submissions. - expect(structuredQueuePause(journal)).toEqual({ reason: 'stopped' }) + expect(structuredQueuePause(journal)).toMatchObject({ reason: 'stopped' }) expect(scan).not.toHaveBeenCalled() scan.mockRestore() - const before = journal.queuedMessages.latestPersonTurnSequence() const mail = rig.send('coordinator mail', undefined, { internal: true }) await mail.result await rig.settleAccepted(mail.id, 'mail') - // Orca's own turn moves nothing; a person's does, and lifts the pause. - expect(journal.queuedMessages.latestPersonTurnSequence()).toBe(before) + // Orca's own turn lifts nothing; a person's does. + expect(structuredQueuePause(journal)).toMatchObject({ reason: 'stopped' }) const next = rig.send('user starts a new turn') await next.result await rig.settleAccepted(next.id, 'next') - expect(journal.queuedMessages.latestPersonTurnSequence()).toBe( - journal.submission(next.id)?.acceptedSequence - ) + expect(structuredQueuePause(journal)).toBeNull() await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) }) }) -describe('a pause is over the cards it paused', () => { - it('a Stop over an empty queue pauses nothing: a card typed during a later mail turn drains', async () => { - const working = await rig.workingSend() - await rig.stop() - await rig.settleAccepted(working, 'stopped') - const mail = rig.send('coordinator mail', undefined, { internal: true }) - await mail.result - await eventually(async () => - expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined() - ) - const followUp = await queuedDraft('typed during the mail turn') - await rig.settleAccepted(mail.id, 'mail') - await eventually(async () => expect(await rig.handoff(followUp)).toBeDefined()) - expect(await rig.queuePause()).toBeNull() - }) - - it('a Stop over an empty queue pauses nothing: a correction typed before the turn ends drains', async () => { +describe('a Stop pauses the queue whatever it held', () => { + it('a Stop over an empty queue holds a correction typed before the turn ends', async () => { const working = await rig.workingSend() await rig.stop() const correction = await queuedDraft('typed right after the stop') await rig.settleAccepted(working, 'stopped') - await eventually(async () => expect(await rig.handoff(correction)).toBeDefined()) + await expectPaused(correction) }) - it('a Stop over a card sent now into the turn holds it until the agent takes it; then a card typed later drains', async () => { + it('a card sent now before the Stop and taken by the agent anyway lifts nothing: it was sent first', async () => { const working = await rig.workingSend() const sentId = await queuedDraft('sent now into the turn') await rig.sendNow(sentId) await handedOver(sentId) await rig.stop() - const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - // Recorded over the unanswered hand-off, which the Stop may yet withdraw; nothing to publish. - expect(journal?.queuedMessages.pause()).toMatchObject({ reason: 'stopped' }) + // Nothing waits, so nothing is published; the pause is still derived. expect(await rig.queuePause()).toBeNull() await rig.settleAccepted(await rig.handoffId(sentId), 'sent-now') - expect(journal?.queuedMessages.pause()).toBeNull() await rig.settleAccepted(working, 'stopped') const mail = rig.send('coordinator mail', undefined, { internal: true }) await mail.result @@ -251,29 +231,27 @@ describe('a pause is over the cards it paused', () => { ) const later = await queuedDraft('typed during the mail turn') await rig.settleAccepted(mail.id, 'mail') - await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) + await expectPaused(later) }) - it('deleting the last paused card ends the pause, so a card typed later is not held by it', async () => { + it("deleting the last paused card hides the pause; a person's next turn is what ends it", async () => { const working = await rig.workingSend() const only = await queuedDraft('paused, then deleted') await rig.stop() await rig.settleAccepted(working, 'stopped') expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) expect(await rig.deleteQueued(only)).toMatchObject({ ok: true, value: { deleted: true } }) - const mail = rig.send('coordinator mail', undefined, { internal: true }) - await mail.result - await eventually(async () => - expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined() - ) - const later = await queuedDraft('typed during the mail turn') - await rig.settleAccepted(mail.id, 'mail') + expect(await rig.queuePause()).toBeNull() + const next = await rig.workingSend() + const later = await queuedDraft('typed during the next turn') + // That send is a person's turn after the Stop: once it starts, the queue sends again. + await rig.settleAccepted(next, 'next') await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) }) }) describe('a pause only over cards Resume could send', () => { - it('a Stop that leaves only a returned card publishes no pause and keeps no fact', async () => { + it('a Stop that leaves only a returned card publishes no pause', async () => { const working = await rig.workingSend() const draftId = await queuedDraft('refused before the stop') await rig.settleAccepted(working, 'a') @@ -288,7 +266,7 @@ describe('a pause only over cards Resume could send', () => { await rig.settleAccepted(next, 'stopped') expect(await rig.queuePause()).toBeNull() const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - expect(journal?.queuedMessages.pause()).toBeNull() + expect(journal && structuredQueuePause(journal)).toMatchObject({ reason: 'stopped' }) }) it('a returned card blocking the paused cards hides the pause but keeps it; deleting that card shows it again, and only Resume sends', async () => { @@ -313,7 +291,7 @@ describe('a pause only over cards Resume could send', () => { } // Resume would send nothing past the returned card, so no header offers it; the pause stays. expect(await rig.queuePause()).toBeNull() - expect(journal.queuedMessages.pause()).toMatchObject({ reason: 'stopped' }) + expect(structuredQueuePause(journal)).toMatchObject({ reason: 'stopped' }) // Deleting the blocking card shows the pause again: the card behind it does not send unasked. expect(await rig.deleteQueued(refusedId)).toMatchObject({ ok: true, value: { deleted: true } }) await expectPaused(behindId) @@ -401,10 +379,10 @@ describe('a card handed off after a restart', () => { structuredAgentSessionHostInstance() ) // With the Stop's pause gone, nothing else holds it: no restart happened since it was sent. - await journal.queuedMessages.liftPause({ - stop: journal.queuedMessages.pause(), - adoptInto: null - }) + await journal.appendQueuePauseMark( + 'resumed', + structuredAgentSessionConversationFence(rig.store, HOST_TEST_SESSION) + ) expect(structuredQueuePause(journal)).toBeNull() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts index 1428de75fe63..4e7d3a606a82 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts @@ -1,20 +1,10 @@ -// Whether the queue is paused, and why — DERIVED, never stored as a flag. The -// queue is paused when: -// - 'stopped': the user's last Stop took effect (its recorded journal -// position) and no turn a person asked for has started since — or -// 'cleared', the same for a /clear's replacement, whose carried cards -// start paused; or -// - 'restarted': a waiting draft was written by another host process and no -// turn a person asked for has started since this conversation opened. -// A person's turn is a submission whose recorded origin is `client` (a send over -// the client send RPC, or a card they sent now) that the provider accepted. -// Orchestration mail, a restart continuation, a host-sent launch prompt and the -// queue's own drain are `host` and never lift it. An explicit Resume lifts any. +// Whether the queue is paused, and why — derived from the journal and the cards +// (`queued-message-pause.ts`), never stored. Stop and Resume are journal rows; an +// explicit Resume lifts any pause. import { randomUUID } from 'node:crypto' -import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { QueuePauseFact } from '../agent-session-journal/queued-message-pause-table' +import type { DerivedQueuePause } from '../agent-session-journal/queued-message-pause' /** A per-process id, minted once per host process like the runtime's own * `runtimeId` (`orca-runtime-runtime-id.ts`); a draft written by another @@ -31,78 +21,48 @@ export function rotateStructuredAgentSessionHostInstanceForTests(): string { return hostInstance } -type PauseJournal = Pick - -// Both read the reducer's latest accepted person turn (its submission row), so a -// derivation on every publish costs no scan of the submissions. - -function stopEnded(journal: PauseJournal, stop: QueuePauseFact): boolean { - const latest = journal.queuedMessages.latestPersonTurnSequence() - // Sent after the Stop: a send made before it no longer lifts it, even if its turn starts later. - return stop.epoch !== journal.cursor().epoch ? latest > 0 : latest > stop.sequence -} - -function restartPending(journal: PauseJournal): boolean { - return journal.queuedMessages - .list() - .some((row) => row.state === 'waiting' && row.hostInstance !== hostInstance) -} - -function restartEnded(journal: PauseJournal): boolean { - const latest = journal.queuedMessages.latestPersonTurnSequence() - return latest > 0 && !journal.wroteBeforeOpen(latest) -} +type PauseJournal = Pick /** The queue's pause, derived; null when the queue sends on its own. */ -export function structuredQueuePause(journal: PauseJournal): AgentSessionQueuePause | null { - const stop = journal.queuedMessages.pause() - if (stop && !stopEnded(journal, stop)) { - return { reason: stop.reason } - } - if (restartPending(journal) && !restartEnded(journal)) { - return { reason: 'restarted' } - } - return null +export function structuredQueuePause(journal: PauseJournal): DerivedQueuePause | null { + return journal.queuedMessages.pause(hostInstance) } /** - * Every journal publish: retire what a person's started turn already ended — the - * Stop fact it superseded, and a restart's rows, adopted into this instance. The - * derivation already reads them as lifted; the write keeps that answer when the - * handle reopens (the restart's "since this conversation opened" moves) and spares - * later derivations the submission scan. Bookkeeping: a failure is reported. + * Every journal publish: a restart's rows are adopted into this instance once a person's turn + * started. The derivation already reads them as lifted; the write keeps that answer when the + * handle reopens (its "since this conversation opened" moves). Bookkeeping: a failure is reported. */ -export async function retireEndedQueuePause( +export async function adoptEndedRestartPause( sessionId: string, journal: PauseJournal ): Promise { try { - const stop = journal.queuedMessages.pause() - const retireStop = stop !== null && stopEnded(journal, stop) ? stop : null - const adopt = restartPending(journal) && restartEnded(journal) - if (retireStop === null && !adopt) { - return + const { queuedMessages } = journal + const restarted = queuedMessages + .list() + .some((row) => row.state === 'waiting' && row.hostInstance !== hostInstance) + if (restarted && queuedMessages.restartEnded()) { + await queuedMessages.adopt(hostInstance) } - await journal.queuedMessages.liftPause({ - stop: retireStop, - adoptInto: adopt ? hostInstance : null - }) } catch (error) { - console.warn("[agent-session] a started turn's queue-pause retirement skipped:", { + console.warn("[agent-session] a started turn's restart-pause adoption skipped:", { sessionId, error: error instanceof Error ? error.message : String(error) }) } } -/** Resume: ends whichever pause holds the queue. Returns whether it was paused. */ -export async function resumeStructuredQueue(journal: PauseJournal): Promise { +/** Resume: a journal row that ends a Stop's or a /clear's pause, and adoption of a restart's + * rows. Returns whether the queue was paused. */ +export async function resumeStructuredQueue( + journal: Pick, + fence: number +): Promise { if (structuredQueuePause(journal) === null) { return false } - await journal.queuedMessages.liftPause({ - stop: journal.queuedMessages.pause(), - adoptInto: hostInstance - }) + await journal.appendQueuePauseMark('resumed', fence) + await journal.queuedMessages.adopt(hostInstance) return true } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts index 10b1f75fc3e3..17bd71a57e0e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts @@ -9,7 +9,7 @@ import { type AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { hasResumableQueuedMessage } from '../agent-session-journal/queued-message-pause-table' +import { queuePauseHoldsResumableCard } from '../agent-session-journal/queued-message-pause' import { structuredQueuePause } from './structured-agent-session-queued-pause' export type QueuePublication = { @@ -81,11 +81,14 @@ export function sameQueuePause( export function readQueuePublication(journal: AgentSessionJournal): QueuePublication { const queuedMessages = readPublishedQueuedMessages(journal) - // Read per emit: the pause also turns on submissions (a person's turn starting). - // Kept over any card it holds back, but shown only over one Resume would send, so its - // header never offers to send nothing; deleting a blocking returned card shows it again. - const pausable = hasResumableQueuedMessage(journal.queuedMessages.list()) - const queuePause = pausable ? structuredQueuePause(journal) : null + // Read per emit: the pause also turns on submissions (a person's turn starting). Shown only + // over a card Resume would send, so its header never offers to send nothing; deleting a + // blocking returned card shows it again. + const pause = structuredQueuePause(journal) + const queuePause = + pause && queuePauseHoldsResumableCard(pause, journal.queuedMessages.list()) + ? { reason: pause.reason } + : null const previous = publications.get(journal) if ( previous && diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts new file mode 100644 index 000000000000..4471c206d0f5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -0,0 +1,240 @@ +// Stop writes one journal row before it interrupts, and the queue's pause is derived from it: +// through the real host, a card queued after a Stop is held (the default of an open decision), a +// withdrawn card comes back under it, a crash keeps it, and no stored pause is ever written. + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import Database from '../../sqlite/sync-database' +import { journalDatabasePath } from '../agent-session-journal/journal-host-database' +import { + HOST_TEST_SESSION, + hostTestMessage, + 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' +import { structuredQueuePause } from './structured-agent-session-queued-pause' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig({ restartable: true }) +}) + +afterEach(() => rig.dispose()) + +async function queuedDraft(text: string): Promise { + const queued = await rig.send(text, 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + return queued.value.queued.messageId +} + +function journal(sessionId = HOST_TEST_SESSION) { + const open = rig.host.collaboratorsForTests().sessions.get(sessionId)?.journal + if (!open) { + throw new Error('expected the conversation open') + } + return open +} + +/** No drain step converts the cards, and the published pause names `reason`. */ +async function expectHeld(reason: string, ...draftIds: string[]): Promise { + await new Promise((resolve) => setTimeout(resolve, 250)) + for (const draftId of draftIds) { + expect(await rig.handoff(draftId)).toBeUndefined() + } + expect(await rig.queuePause()).toEqual({ reason }) +} + +async function mailTurn(): Promise { + const mail = rig.send('coordinator mail', undefined, { internal: true }) + await mail.result + await eventually(async () => expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined()) + return mail.id +} + +function withdraw(clientMessageId: string) { + return rig.host.settleLateDispatch({ + sessionId: HOST_TEST_SESSION, + clientMessageId, + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + }) +} + +function storedPauses(): number { + const db = new Database(journalDatabasePath(rig.root), { readonly: true }) + try { + const row: unknown = db.prepare('SELECT COUNT(*) AS n FROM queued_message_pauses').get() + return typeof row === 'object' && row !== null && 'n' in row ? Number(row.n) : -1 + } finally { + db.close() + } +} + +describe("Stop's row", () => { + it('is written before the interrupt reaches the agent', async () => { + await rig.workingSend() + let pausedAtInterrupt: unknown = 'not interrupted' + rig.cancelTurn.mockImplementationOnce(async () => { + pausedAtInterrupt = structuredQueuePause(journal())?.reason ?? null + return { cancelled: true } + }) + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(pausedAtInterrupt).toBe('stopped') + }) + + it('over an EMPTY queue still pauses: a card typed during a later mail turn waits for the person', async () => { + const working = await rig.workingSend() + await rig.stop() + await rig.settleAccepted(working, 'stopped') + const mail = await mailTurn() + const typed = await queuedDraft('typed during the mail turn') + await rig.settleAccepted(mail, 'mail') + await expectHeld('stopped', typed) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) + }) + + it("a person's accepted turn lifts it; a host turn and a later Stop do not", async () => { + const working = await rig.workingSend() + const first = await queuedDraft('first') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + await rig.settleAccepted(await mailTurn(), 'mail') + await expectHeld('stopped', first) + const person = rig.send('the person asks for a turn') + await person.result + // A later Stop supersedes: the send made before it no longer lifts anything. + await rig.stop() + await rig.settleAccepted(person.id, 'person') + await expectHeld('stopped', first) + const after = rig.send('asked after the second Stop') + await after.result + await rig.settleAccepted(after.id, 'after') + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + }) + + it('a card the Stop withdrew comes back once, under the pause, and is never sent again on its own', async () => { + const working = await rig.workingSend() + const sentId = await queuedDraft('sent now into the turn') + await rig.sendNow(sentId) + await eventually(async () => expect((await rig.handoff(sentId))?.handedOverAt).toBeDefined()) + const handoffId = await rig.handoffId(sentId) + await rig.stop() + await withdraw(handoffId) + await withdraw(handoffId) + await rig.settleAccepted(working, 'stopped') + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.drafts()).toEqual([{ messageId: sentId, state: 'waiting' }]) + const sends = (await rig.host.journalSnapshot(HOST_TEST_SESSION)).submissions.filter( + (entry) => entry.queuedMessageId === sentId + ) + expect(sends.map((entry) => [entry.origin, entry.dispatchState])).toEqual([ + ['client', 'rejected'] + ]) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect((await rig.handoff(sentId))?.origin).toBe('host')) + }) + + it('survives a host crash: the next open marks the hand-off unknown, the pause stays, Resume sends', async () => { + await rig.workingSend() + const sentId = await queuedDraft('sent now into the turn') + const waiting = await queuedDraft('waiting behind it') + await rig.sendNow(sentId) + await eventually(async () => expect((await rig.handoff(sentId))?.handedOverAt).toBeDefined()) + await rig.stop() + // The process dies with no close; a new host opens the same state directory. + rig.crashRestartHostProcess() + expect((await rig.handoff(sentId))?.dispatchState).toBe('unknown') + await expectHeld('stopped', waiting) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect(await rig.handoff(waiting)).toBeDefined()) + }) +}) + +describe("a /clear's carried cards", () => { + function clear() { + const fields = { command: 'clear' as const } + return rig.host.conversationCommand(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) + } + + it("wait 'cleared' on the replacement until a person's turn there", async () => { + const working = await rig.workingSend() + const carried = await queuedDraft('written for the old context') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + const cleared = await clear() + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error(`expected a replacement session: ${JSON.stringify(cleared)}`) + } + expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + // Idle there, so the person's send goes straight out rather than queueing. + const text = hostTestMessage('hi') + const person = rig.host.send(QUEUED_RIG_CALLER, { + envelope: rig.envelope( + { body: text }, + 'agentSession.send', + hostTestOperationId(), + replacementId + ), + body: text, + userSend: true + }) + const sent = await person + if (!sent.ok || !('submission' in sent.value)) { + throw new Error(`expected an immediate send: ${JSON.stringify(sent)}`) + } + await eventually(async () => + expect( + (await rig.host.journalSnapshot(replacementId)).submissions.find( + (entry) => entry.clientMessageId === sent.value.clientMessageId + )?.handedOverAt + ).toBeDefined() + ) + await rig.host.settleLateDispatch({ + sessionId: replacementId, + clientMessageId: sent.value.clientMessageId, + providerIdentity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-hi', ordinal: 0 } + }) + expect(journal(replacementId).queuedMessages.list()[0]?.messageId).toBe(carried) + expect(structuredQueuePause(journal(replacementId))).toBeNull() + }) +}) + +describe('no stored pause', () => { + it('is read or written across Stop, Resume and /clear', async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('paused') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + await expectHeld('stopped', draftId) + expect(storedPauses()).toBe(0) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + expect(storedPauses()).toBe(0) + await eventually(async () => expect((await rig.handoff(draftId))?.handedOverAt).toBeDefined()) + await rig.stop() + await queuedDraft('carried') + await rig.settleAccepted(await rig.handoffId(draftId), 'drained') + const fields = { command: 'clear' as const } + const cleared = await rig.host.conversationCommand(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + expect(replacementId && (await rig.queuePause(replacementId))).toEqual({ reason: 'cleared' }) + expect(storedPauses()).toBe(0) + }) +}) 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 4d5369364b24..c58c11041802 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 @@ -1,10 +1,10 @@ // Stop's pause on the queue. A Stop never withdraws a draft and no text ever -// travels back over the wire: it records WHERE in the journal it took effect, -// and the queue is paused from there (`structured-agent-session-queued-pause.ts` -// derives it) until a turn a person asked for starts, or they Resume. The cards -// stay published, and Send-now sends one card without lifting the pause for the -// rest until that card's turn starts. The record is bookkeeping: a failure is -// reported and never gates the interrupt. +// travels back over the wire: it appends ONE journal row where it took effect, and +// the queue's pause is derived from that row (`queued-message-pause.ts`) until a +// turn a person asked for is sent after it, or they Resume. The cards stay +// published, and Send-now sends one card without lifting the pause for the rest +// until that card's turn starts. The row is bookkeeping: a failure is reported +// and never gates the interrupt. import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { @@ -20,48 +20,27 @@ export function unsettledQueuedMessages(journal: AgentSessionJournal): QueuedMes } /** - * Runs a Stop and records its queue pause at the point it takes effect — after - * it withdrew the queued sends, as it reaches the agent, or, reaching no agent, - * once it withdrew something — and only over cards it then holds back. The Stop - * calls `tookEffect` there. A Stop that throws before then changed nothing and - * recorded nothing, so there is nothing to undo; one that fails after it keeps - * the pause, since the interrupt may have landed. A hand-off still unanswered - * counts as held, so a draft the interrupt withdraws later — at the provider's turn - * end — is back to waiting in its own place, under this same pause; accepted - * instead, the pause retires in that write. The drain cannot slip a draft in - * between: it runs on the same serialized lane as the Stop. + * Runs a Stop, which calls `tookEffect` where it takes effect — after it withdrew the + * queued sends, before it reaches the agent, or, reaching no agent, once it withdrew + * something. That writes the Stop's row, whatever the queue holds, so a card its + * interrupt later withdraws comes back to waiting under the pause. A Stop that throws + * before then changed nothing and wrote nothing. The drain cannot slip a card in + * between: the Stop runs on the drain's serialized lane, and the consume re-judges the + * pause in its own transaction. */ export async function runStopWithQueuePause( ctx: AgentSessionTurnContext, stop: (tookEffect: () => Promise) => Promise> ): Promise> { - let attempted = false - return stop(async () => { - if (attempted) { - return - } - attempted = true - const { queuedMessages } = ctx.journal - // A hand-off this Stop's withdrawal sent back may not have caught up yet (its hook - // was skipped): heal it first, as the drain would, so the pause sees it waiting. - try { - if (queuedMessages.settlementOwed()) { - await queuedMessages.settleOwed() + return stop(() => + ctx.journal.appendQueuePauseMark('stopped', ctx.fence).then( + () => undefined, + (error: unknown) => { + console.warn("[agent-session] Stop's queue row skipped:", { + sessionId: ctx.sessionId, + error: error instanceof Error ? error.message : String(error) + }) } - } catch (error) { - report(ctx, 'owed settlement', error) - } - // Recorded only over a card it holds back — judged in its own transaction, which - // still counts an owed return to waiting if that heal failed. - await queuedMessages - .recordPause('stopped') - .catch((error: unknown) => report(ctx, 'queue pause', error)) - }) -} - -function report(ctx: AgentSessionTurnContext, step: string, error: unknown): void { - console.warn(`[agent-session] Stop's ${step} skipped:`, { - sessionId: ctx.sessionId, - error: error instanceof Error ? error.message : String(error) - }) + ) + ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts index 2b193d5cb82f..17956e2df9bc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts @@ -9,7 +9,7 @@ import type { } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { StructuredAgentSessionQueuedMessageDrain } from './structured-agent-session-queued-messages' -import { retireEndedQueuePause } from './structured-agent-session-queued-pause' +import { adoptEndedRestartPause } from './structured-agent-session-queued-pause' import { deleteQueuedStructuredAgentMessage, resumeStructuredAgentQueue, @@ -39,12 +39,12 @@ export function wireStructuredAgentSessionQueuedMessages( drain, /** Every journal publish: turn, submission, prompt, command and Stop * settlements are all commits, and each re-derives the drain's gates — - * and retires a queue pause a person's started turn already ended. */ + * and adopts a restart's cards once a person's turn started. */ onJournalActivity: (sessionId: string) => { sessions.touch(sessionId) const journal = sessions.get(sessionId)?.journal if (journal && !journal.isReadOnly) { - void retireEndedQueuePause(sessionId, journal) + void adoptEndedRestartPause(sessionId, journal) } drain.schedule(sessionId) }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts index 403cb08a4053..7ae2baada74c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts @@ -170,7 +170,8 @@ async function stopWithSkippedSettlement(): Promise<{ a: string; working: string it("a Stop whose withdrawal's settlement was skipped still pauses the card it sent back", async () => { const { a } = await stopWithSkippedSettlement() - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // The drain heals the skipped return; the Stop's row already pauses whatever comes back. + await eventually(async () => expect(await rig.queuePause()).toEqual({ reason: 'stopped' })) await new Promise((resolve) => setTimeout(resolve, 250)) // Healed back to waiting, but the Stop's pause holds it: it does not send. expect(await rig.drafts()).toEqual([{ messageId: a, state: 'waiting' }]) @@ -183,27 +184,3 @@ it("a Stop whose withdrawal's settlement was skipped still pauses the card it se ).toHaveLength(2) ) }) - -it('the pause is recorded even when healing the skipped settlement fails, since the card is still owed', async () => { - const heal = vi - .spyOn(JournalQueuedMessages.prototype, 'settleOwed') - .mockRejectedValueOnce(new Error('disk full')) - try { - const { a } = await stopWithSkippedSettlement() - const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal - expect(journal?.queuedMessages.pause()).toMatchObject({ reason: 'stopped' }) - // The drain heals it later; the pause recorded over the owed card holds it then. - await eventually(async () => - expect(await rig.drafts()).toEqual([{ messageId: a, state: 'waiting' }]) - ) - await new Promise((resolve) => setTimeout(resolve, 250)) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - expect( - (await rig.host.journalSnapshot(SESSION)).submissions.filter( - (entry) => entry.queuedMessageId === a - ) - ).toHaveLength(1) - } finally { - heal.mockRestore() - } -}) From 438240a4023b073cc61dd32809f963c695fbdfc2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:58:12 -0700 Subject: [PATCH 07/58] fix(native-chat): a card queued after a Stop sends normally, never ahead of held ones A Stop's pause now holds only the cards queued before its row, plus a steer it withdrew, which returns to its own place. Each card records the journal position it was queued at, and the one hold rule compares that with the Stop row. A card queued after the Stop is a new instruction: it sends as usual, but the drain still stops at the first held card, so it never overtakes them. /clear's pause holds the cards it carried. Holding every card again is a one-line switch in that rule. --- .../journal-queued-messages.ts | 9 ++- .../queued-message-pause.test.ts | 71 +++++++++++++++++++ .../queued-message-pause.ts | 49 ++++++++----- .../queued-message-schema.ts | 6 +- .../queued-message-table.ts | 28 ++++++-- ...tructured-agent-session-queued-messages.ts | 16 ++--- ...ed-agent-session-queued-pause-lift.test.ts | 12 ++-- ...ured-agent-session-queued-stop-row.test.ts | 34 +++++++-- 8 files changed, 180 insertions(+), 45 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 34281b1b849e..77d8c3ca4e45 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -113,7 +113,13 @@ export class JournalQueuedMessages { return existing } inserted = true - return insertQueuedMessage(db, { ...input, sessionId, now: this.deps.now() }) + const { epoch, lastSequence } = this.deps.state() + return insertQueuedMessage(db, { + ...input, + sessionId, + queuedAt: { epoch, sequence: lastSequence }, + now: this.deps.now() + }) }, () => inserted ) @@ -140,6 +146,7 @@ export class JournalQueuedMessages { ): DerivedQueuePause | null { const state = this.deps.state() return deriveQueuePause({ + epoch: state.epoch, marks: state.queuePauseMarks, latestPersonTurnSequence: state.latestPersonTurnSequence, cards, diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index e0b5de53eb62..2269786a4ccd 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -20,6 +20,9 @@ import { QueuedMessageNotConsumableError } from './journal-queued-messages' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { parseJournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' +import { queuePauseHolds } from './queued-message-pause' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-p', @@ -90,6 +93,15 @@ function reason(journal: AgentSessionJournal): string | null { return journal.queuedMessages.pause(HOST)?.reason ?? null } +/** Each card, and whether the queue's pause holds it. */ +function held(journal: AgentSessionJournal): [string, boolean][] { + const pause = journal.queuedMessages.pause(HOST) + return journal.queuedMessages + .list() + .filter((card) => card.state === 'waiting') + .map((card) => [card.messageId, pause !== null && queuePauseHolds(pause, card)]) +} + function storedPauses(): number { const db = new Database(journalDatabasePath(root), { readonly: true }) try { @@ -239,6 +251,65 @@ describe("the queue's pause, derived from the journal", () => { }) }) +describe('which cards a pause holds', () => { + it('only cards queued before the Stop row: one queued after it is a new instruction', async () => { + let journal = await open() + await queueDraft(journal, 'before') + await journal.appendQueuePauseMark('stopped', 0) + await queueDraft(journal, 'after') + expect(held(journal)).toEqual([ + ['before', true], + ['after', false] + ]) + await journal.close() + journal = await open() + expect(held(journal)).toEqual([ + ['before', true], + ['after', false] + ]) + }) + + it('a steer the Stop withdrew comes back in its own place, and is held', async () => { + const journal = await open() + await queueDraft(journal, 'steered') + await journal.appendSubmission( + { + clientMessageId: 'send-now-1', + origin: 'client', + payloadFingerprint: 'fp-steered', + body: message('steered'), + fence: 0, + handoverRecorded: true + }, + { messageId: 'steered', expect: 'waiting', settledByOp: null, hostInstance: HOST } + ) + await journal.appendQueuePauseMark('stopped', 0) + await queueDraft(journal, 'after') + await journal.resolveDispatch({ + clientMessageId: 'send-now-1', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }), + fence: 0 + }) + expect(held(journal)).toEqual([ + ['steered', true], + ['after', false] + ]) + }) + + it("'cleared' holds the carried cards, not one typed after them", async () => { + const journal = await open() + await queueDraft(journal, 'carried-1', 'source-session') + await queueDraft(journal, 'carried-2', 'source-session') + await queueDraft(journal, 'typed-here') + expect(held(journal)).toEqual([ + ['carried-1', true], + ['carried-2', true], + ['typed-here', false] + ]) + }) +}) + describe("a restart's pause", () => { it("adopting a restart's rows moves them into this instance and clears an older build's stored 'stopped' hold; send_failed stays", async () => { const journal = await open() 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 ef0cd9950a9e..dcb38d8e46a4 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 @@ -1,5 +1,5 @@ // The queue's pause: a pure function of the journal fold and the cards, never a stored flag, so -// nothing has to retire it. Paused when: +// nothing has to retire it. Which cards it holds is `queuePauseHolds`. Paused when: // - 'stopped': the latest Stop row has no later Resume row, and no turn a person asked for was // sent after it and accepted. A later Stop is simply the latest. // - 'cleared': a card /clear carried into this conversation waits, and no person's turn or @@ -9,6 +9,7 @@ // A person's turn is an accepted submission of origin `client`. Orchestration mail, a restart // continuation, a launch prompt and the queue's own drain are `host` and never lift it. +import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { JournalQueuePauseMark, JournalTombstoneRow } from './journal-row-schema' export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' @@ -16,26 +17,25 @@ export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' /** The latest Stop and Resume rows, folded by the reducer; 0 when none. */ export type JournalQueuePauseMarks = { stoppedSequence: number - stoppedAt: number resumedSequence: number } export type DerivedQueuePause = { reason: QueuePauseReason - /** Host clock the pause began at; a card created later is newer than it. */ - since: number + /** Where a Stop's pause began: a card queued at or after it is newer. Null for the others. */ + since: AgentJournalCursor | null } type QueueCard = { state: string holdReason: string | null - createdAt: number hostInstance: string carriedFrom: string | null + queuedAt: AgentJournalCursor | null } export function createJournalQueuePauseMarks(): JournalQueuePauseMarks { - return { stoppedSequence: 0, stoppedAt: 0, resumedSequence: 0 } + return { stoppedSequence: 0, resumedSequence: 0 } } export function foldJournalQueuePauseMark( @@ -44,7 +44,6 @@ export function foldJournalQueuePauseMark( ): void { if (row.queuePause === 'stopped') { marks.stoppedSequence = row.seq - marks.stoppedAt = row.ts } else if (row.queuePause === 'resumed') { marks.resumedSequence = row.seq } @@ -59,6 +58,8 @@ export function journalQueueStopHolds( } export function deriveQueuePause(input: { + /** The journal's epoch: sequences compare only within one. */ + epoch: string marks: JournalQueuePauseMarks latestPersonTurnSequence: number cards: readonly QueueCard[] @@ -66,33 +67,49 @@ export function deriveQueuePause(input: { /** A person's turn started since this conversation opened. */ restartEnded: boolean }): DerivedQueuePause | null { - const { marks, latestPersonTurnSequence } = input + const { epoch, marks, latestPersonTurnSequence } = input if (journalQueueStopHolds(marks, latestPersonTurnSequence)) { - return { reason: 'stopped', since: marks.stoppedAt } + return { reason: 'stopped', since: { epoch, sequence: marks.stoppedSequence } } } const waiting = input.cards.filter((card) => card.state === 'waiting') const carried = waiting.filter((card) => card.carriedFrom !== null) if (carried.length > 0 && latestPersonTurnSequence === 0 && marks.resumedSequence === 0) { - return { reason: 'cleared', since: Math.max(...carried.map((card) => card.createdAt)) } + return { reason: 'cleared', since: null } } if (!input.restartEnded && waiting.some((card) => card.hostInstance !== input.hostInstance)) { // The process that wrote a card is gone: every card waits, whenever it was written. - return { reason: 'restarted', since: Number.POSITIVE_INFINITY } + return { reason: 'restarted', since: null } } return null } -// Open product decision: whether a pause also holds a card queued AFTER it began (for example -// typed while orchestration mail runs after a Stop). true: every waiting card waits for the person. -// false: only cards that existed when it began, or came back from it, wait; newer ones drain. -const PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT = true +/** Queued before the pause began: for /clear, a card it carried; for a restart, every card. For a + * Stop, a card queued before its row; one from another epoch (before a rewind) or from a build + * that recorded no position counts as before. A withdrawn steer keeps its position, so is held. */ +function queuedBeforePause(pause: DerivedQueuePause, card: QueueCard): boolean { + if (pause.reason === 'cleared') { + return card.carriedFrom !== null + } + const { since } = pause + return ( + since === null || + card.queuedAt === null || + card.queuedAt.epoch !== since.epoch || + card.queuedAt.sequence < since.sequence + ) +} + +// Product decision: a card queued AFTER a Stop is a new instruction and is not held; only cards +// queued before it, and a steer it withdrew, wait. It still never jumps ahead of a held card: the +// drain stops at the first one. true instead holds every waiting card, whenever it was queued. +const PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT = false /** THE rule for which cards a pause holds: the drain, its consume, and publication all read it. */ export function queuePauseHolds(pause: DerivedQueuePause, card: QueueCard): boolean { return ( card.state === 'waiting' && card.holdReason === null && - (PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT || card.createdAt <= pause.since) + (PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT || queuedBeforePause(pause, card)) ) } diff --git a/src/main/native-chat/agent-session-journal/queued-message-schema.ts b/src/main/native-chat/agent-session-journal/queued-message-schema.ts index 34230fe51cf0..690db5eed7f0 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-schema.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-schema.ts @@ -10,7 +10,9 @@ const NULLABLE_COLUMNS: readonly (readonly [name: string, type: string])[] = [ ['settled_at', 'INTEGER'], ['settled_by_op', 'TEXT'], ['consumed_as', 'TEXT'], - ['carried_from', 'TEXT'] + ['carried_from', 'TEXT'], + ['queued_epoch', 'TEXT'], + ['queued_sequence', 'INTEGER'] ] /** @@ -39,6 +41,8 @@ CREATE TABLE IF NOT EXISTS queued_messages ( settled_by_op TEXT, consumed_as TEXT, carried_from TEXT, + queued_epoch TEXT, + queued_sequence INTEGER, PRIMARY KEY (session_id, message_id) ); `) diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index 8b812e33481b..a2bd9c3e05ab 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -9,7 +9,10 @@ import type Database from '../../sqlite/sync-database' import type { UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalCursor, + AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' import { rejectedDraftSettlement } from './journal-dispatch-settlement' import { readStoredRejectionFact } from './journal-dispatch-reducer' @@ -53,10 +56,13 @@ export type QueuedMessageRow = { /** The conversation /clear carried this card from; null for a card written here. What the * replacement's 'cleared' pause is derived from. */ carriedFrom: string | null + /** Where the journal stood when it was queued: a Stop's pause holds only cards queued before + * it. Null on rows from builds before it was recorded, which read as queued before any Stop. */ + queuedAt: AgentJournalCursor | null } const COLUMNS = - 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as, carried_from' + 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as, carried_from, queued_epoch, queued_sequence' export function insertQueuedMessage( db: Database.Database, @@ -67,6 +73,7 @@ export function insertQueuedMessage( fingerprint: string hostInstance: string carriedFrom?: string + queuedAt: AgentJournalCursor now: number } ): QueuedMessageRow { @@ -77,7 +84,7 @@ export function insertQueuedMessage( const position = Number(highest?.p ?? 0) + 1 db.prepare( `INSERT INTO queued_messages (${COLUMNS}) - VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?)` ).run( input.sessionId, input.messageId, @@ -86,7 +93,9 @@ export function insertQueuedMessage( input.fingerprint, input.now, input.hostInstance, - input.carriedFrom ?? null + input.carriedFrom ?? null, + input.queuedAt.epoch, + input.queuedAt.sequence ) return { sessionId: input.sessionId, @@ -103,7 +112,8 @@ export function insertQueuedMessage( settledAt: null, settledByOp: null, consumedAs: null, - carriedFrom: input.carriedFrom ?? null + carriedFrom: input.carriedFrom ?? null, + queuedAt: input.queuedAt } } @@ -283,6 +293,8 @@ function toStoredRow(row: unknown): QueuedMessageRow | null { settled_by_op: string | null consumed_as: string | null carried_from: string | null + queued_epoch: string | null + queued_sequence: number | null } let body: AgentJournalMessageItem try { @@ -317,7 +329,11 @@ function toStoredRow(row: unknown): QueuedMessageRow | null { settledAt: record.settled_at, settledByOp: record.settled_by_op, consumedAs: record.consumed_as, - carriedFrom: record.carried_from + carriedFrom: record.carried_from, + queuedAt: + record.queued_epoch !== null && typeof record.queued_sequence === 'number' + ? { epoch: record.queued_epoch, sequence: record.queued_sequence } + : null } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index bd7b4b0bb03f..c6e7853bd0a9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -58,9 +58,9 @@ export function pendingPromptExists(journal: Pick ): QueuedMessageRow | null { @@ -71,15 +71,11 @@ function oldestActionableQueuedMessage( } const pause = structuredQueuePause(journal) for (const row of rows) { - if (row.state === 'returned') { - // A returned card blocks everything after it until the user acts. + if (row.state === 'returned' || (pause && queuePauseHolds(pause, row))) { + // A returned or paused card blocks everything after it until the user acts. return null } - if ( - row.state === 'waiting' && - row.holdReason === null && - !(pause && queuePauseHolds(pause, row)) - ) { + if (row.state === 'waiting' && row.holdReason === null) { return row } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 65cb937eaf79..09738da3d1d3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -205,16 +205,16 @@ describe('the pause read', () => { }) }) -describe('a Stop pauses the queue whatever it held', () => { - it('a Stop over an empty queue holds a correction typed before the turn ends', async () => { +describe('a card queued after a Stop is a new instruction', () => { + it('a correction typed after a Stop over an empty queue sends when the stopped turn ends', async () => { const working = await rig.workingSend() await rig.stop() const correction = await queuedDraft('typed right after the stop') await rig.settleAccepted(working, 'stopped') - await expectPaused(correction) + await eventually(async () => expect(await rig.handoff(correction)).toBeDefined()) }) - it('a card sent now before the Stop and taken by the agent anyway lifts nothing: it was sent first', async () => { + it('a card sent now before the Stop and taken anyway lifts nothing, and holds nothing typed later', async () => { const working = await rig.workingSend() const sentId = await queuedDraft('sent now into the turn') await rig.sendNow(sentId) @@ -231,7 +231,9 @@ describe('a Stop pauses the queue whatever it held', () => { ) const later = await queuedDraft('typed during the mail turn') await rig.settleAccepted(mail.id, 'mail') - await expectPaused(later) + await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + expect(journal && structuredQueuePause(journal)).toMatchObject({ reason: 'stopped' }) }) it("deleting the last paused card hides the pause; a person's next turn is what ends it", async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index 4471c206d0f5..c171f873fce0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -1,6 +1,7 @@ // Stop writes one journal row before it interrupts, and the queue's pause is derived from it: -// through the real host, a card queued after a Stop is held (the default of an open decision), a -// withdrawn card comes back under it, a crash keeps it, and no stored pause is ever written. +// through the real host, the cards queued before a Stop wait, a card queued after it sends +// normally but never ahead of them, a withdrawn card comes back under it, a crash keeps it, and +// no stored pause is ever written. import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' @@ -91,18 +92,39 @@ describe("Stop's row", () => { expect(pausedAtInterrupt).toBe('stopped') }) - it('over an EMPTY queue still pauses: a card typed during a later mail turn waits for the person', async () => { + it('over an EMPTY queue holds nothing: a card queued during a later mail turn sends normally', async () => { const working = await rig.workingSend() await rig.stop() await rig.settleAccepted(working, 'stopped') const mail = await mailTurn() const typed = await queuedDraft('typed during the mail turn') + expect(await rig.queuePause()).toBeNull() await rig.settleAccepted(mail, 'mail') - await expectHeld('stopped', typed) - expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) }) + it('a card queued after the Stop waits behind the cards it holds, then all send in order', async () => { + const working = await rig.workingSend() + const held = await queuedDraft('queued before the stop') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + const mail = await mailTurn() + const later = await queuedDraft('queued during the mail turn') + await rig.settleAccepted(mail, 'mail') + // The queue never reorders: the newer card waits behind the held one, with no caption of its own. + await expectHeld('stopped', held, later) + expect(await rig.drafts()).toEqual([ + { messageId: held, state: 'waiting' }, + { messageId: later, state: 'waiting' } + ]) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect(await rig.handoff(held)).toBeDefined()) + expect(await rig.handoff(later)).toBeUndefined() + await eventually(async () => expect((await rig.handoff(held))?.handedOverAt).toBeDefined()) + await rig.settleAccepted(await rig.handoffId(held), 'held') + await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) + }) + it("a person's accepted turn lifts it; a host turn and a later Stop do not", async () => { const working = await rig.workingSend() const first = await queuedDraft('first') @@ -225,8 +247,8 @@ describe('no stored pause', () => { expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) expect(storedPauses()).toBe(0) await eventually(async () => expect((await rig.handoff(draftId))?.handedOverAt).toBeDefined()) - await rig.stop() await queuedDraft('carried') + await rig.stop() await rig.settleAccepted(await rig.handoffId(draftId), 'drained') const fields = { command: 'clear' as const } const cleared = await rig.host.conversationCommand(QUEUED_RIG_CALLER, { From 6de6b3238e1c6162642dca542eaf71d296ff5f95 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:03:49 -0700 Subject: [PATCH 08/58] fix(native-chat): the queue's own send re-checks the no-overtake rule in its transaction The drain's pick and its consume now read one function, nextSendableQueuedCard, so a Stop row that lands between them holds a newer card behind an older held one exactly as the pick would. Notes why Stop and Resume ride a tombstone row. --- .../journal-queued-messages.ts | 13 +++++---- .../agent-session-journal/journal-store.ts | 4 ++- .../queued-message-pause.test.ts | 27 +++++++++++++++++++ .../queued-message-pause.ts | 18 +++++++++++++ ...tructured-agent-session-queued-messages.ts | 14 ++-------- 5 files changed, 58 insertions(+), 18 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 77d8c3ca4e45..48a88360be21 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -16,7 +16,11 @@ import type { JournalRow } from './journal-row-schema' import type { JournalRowTransactionHook } from './journal-row-writer' import type { JournalSubmissionConsume } from './journal-store-contracts' import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' -import { deriveQueuePause, queuePauseHolds, type DerivedQueuePause } from './queued-message-pause' +import { + deriveQueuePause, + nextSendableQueuedCard, + type DerivedQueuePause +} from './queued-message-pause' import { consumeQueuedMessageInTransaction, getQueuedMessage, @@ -233,12 +237,11 @@ export class JournalQueuedMessages { throw new AgentSessionJournalError('journal_closed', 'consume crossed database handles') } if (input.yieldsToPause) { - // Judged here, on the same queue as every journal append, so a Stop row that landed - // after the drain chose this card still holds it. + // Judged here, on the same queue as every journal append, by the drain's own rule, so a + // Stop row that landed after the drain chose this card still holds it. const cards = listQueuedMessages(db, this.deps.sessionId) const pause = this.derivePause(cards, input.yieldsToPause.hostInstance) - const card = cards.find((entry) => entry.messageId === input.messageId) - if (pause && card && queuePauseHolds(pause, card)) { + if (nextSendableQueuedCard(pause, cards)?.messageId !== input.messageId) { throw new QueuedMessageNotConsumableError(input.messageId, input.expect) } } 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 1c26eb031f55..90fa5366f3b7 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -308,7 +308,9 @@ export class AgentSessionJournal { ) } - /** A person's Stop or Resume: the rows the queue's pause is derived from. */ + /** A person's Stop or Resume: the rows the queue's pause is derived from. Carried by a + * tombstone because a released host deletes the journal from the first row kind it does not + * know (`journal-open.ts`, `journal-store-open.ts`); an unknown key it ignores. */ appendQueuePauseMark(mark: JournalQueuePauseMark, fence: number): Promise { return this.rowWriter.append(journalQueuePauseRowBuilder(() => this.state, mark, fence)) } diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 2269786a4ccd..2333fc663c90 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -297,6 +297,33 @@ describe('which cards a pause holds', () => { ]) }) + it("the queue's own consume refuses a newer card while an older one is held: nothing overtakes", async () => { + const journal = await open() + await queueDraft(journal, 'held') + await journal.appendQueuePauseMark('stopped', 0) + await queueDraft(journal, 'newer') + await expect( + journal.appendSubmission( + { + clientMessageId: 'drain-newer', + origin: 'host', + payloadFingerprint: 'fp-newer', + body: message('newer'), + fence: 0, + handoverRecorded: true + }, + { + messageId: 'newer', + expect: 'waiting', + settledByOp: null, + hostInstance: HOST, + yieldsToPause: { hostInstance: HOST } + } + ) + ).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) + expect(journal.queuedMessages.get('newer')?.state).toBe('waiting') + }) + it("'cleared' holds the carried cards, not one typed after them", async () => { const journal = await open() await queueDraft(journal, 'carried-1', 'source-session') 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 dcb38d8e46a4..f5f9c1247f3d 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 @@ -113,6 +113,24 @@ export function queuePauseHolds(pause: DerivedQueuePause, card: QueueCard): bool ) } +/** The card the queue sends next: the oldest waiting one with no hold of its own, unless a + * returned card or one the pause holds comes first. The queue never reorders, so a newer card + * never overtakes a held one. The drain's pick and its consume both read this. */ +export function nextSendableQueuedCard( + pause: DerivedQueuePause | null, + cards: readonly T[] +): T | null { + for (const card of cards) { + if (card.state === 'returned' || (pause && queuePauseHolds(pause, card))) { + return null + } + if (card.state === 'waiting' && card.holdReason === null) { + return card + } + } + return null +} + /** Whether Resume would send anything: a card the pause holds, not behind a returned card, which * blocks everything after it until the user acts. Only then is the pause PUBLISHED, so its * header never offers a Resume that sends nothing. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index c6e7853bd0a9..f630e749866c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -30,7 +30,7 @@ import { structuredAgentSessionHostInstance, structuredQueuePause } from './structured-agent-session-queued-pause' -import { queuePauseHolds } from '../agent-session-journal/queued-message-pause' +import { nextSendableQueuedCard } from '../agent-session-journal/queued-message-pause' /** Budget at accept, in the send schema's own unit (`Buffer.byteLength` of the * serialized blocks); refused readably rather than trimmed. */ @@ -69,17 +69,7 @@ function oldestActionableQueuedMessage( if (!rows.some((row) => row.state === 'waiting')) { return null } - const pause = structuredQueuePause(journal) - for (const row of rows) { - if (row.state === 'returned' || (pause && queuePauseHolds(pause, row))) { - // A returned or paused card blocks everything after it until the user acts. - return null - } - if (row.state === 'waiting' && row.holdReason === null) { - return row - } - } - return null + return nextSendableQueuedCard(structuredQueuePause(journal), rows) } /** From d8b9e480bc2b128dc267e2159e67c832c4a74f0e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:21:02 -0700 Subject: [PATCH 09/58] fix(native-chat): stop creating the unused queue pause table The queue's pause is derived from journal rows, so nothing reads or writes queued_message_pauses. It was still created on every open "for downgrade safety", but an older build creates it itself when it opens the database, so the table only sat empty in every new database. The tests now pin that no pause table exists. --- .../queued-message-pause.test.ts | 14 ++++++++------ .../agent-session-journal/queued-message-schema.ts | 9 --------- ...tructured-agent-session-queued-stop-row.test.ts | 14 ++++++++------ 3 files changed, 16 insertions(+), 21 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 2333fc663c90..7678b4aef133 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -102,11 +102,13 @@ function held(journal: AgentSessionJournal): [string, boolean][] { .map((card) => [card.messageId, pause !== null && queuePauseHolds(pause, card)]) } -function storedPauses(): number { +/** Tables that could store a pause: none, the journal rows are the only record. */ +function pauseTables(): number { const db = new Database(journalDatabasePath(root), { readonly: true }) try { - const row: unknown = db.prepare('SELECT COUNT(*) AS n FROM queued_message_pauses').get() - return typeof row === 'object' && row !== null && 'n' in row ? Number(row.n) : -1 + return db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%pause%'") + .all().length } finally { db.close() } @@ -131,7 +133,7 @@ describe("the queue's pause, derived from the journal", () => { await journal.close() journal = await open() expect(reason(journal)).toBe('stopped') - expect(storedPauses()).toBe(0) + expect(pauseTables()).toBe(0) }) it("only a person's turn sent after the Stop and accepted lifts it; host turns never do", async () => { @@ -159,7 +161,7 @@ describe("the queue's pause, derived from the journal", () => { expect(reason(journal)).toBeNull() await journal.appendQueuePauseMark('stopped', 0) expect(reason(journal)).toBe('stopped') - expect(storedPauses()).toBe(0) + expect(pauseTables()).toBe(0) }) it("a card /clear carried in pauses the replacement 'cleared' until a Resume there", async () => { @@ -171,7 +173,7 @@ describe("the queue's pause, derived from the journal", () => { expect(reason(journal)).toBe('cleared') await journal.appendQueuePauseMark('resumed', 0) expect(reason(journal)).toBeNull() - expect(storedPauses()).toBe(0) + expect(pauseTables()).toBe(0) }) it("a person's accepted turn on the replacement lifts 'cleared'; a host turn does not", async () => { diff --git a/src/main/native-chat/agent-session-journal/queued-message-schema.ts b/src/main/native-chat/agent-session-journal/queued-message-schema.ts index 690db5eed7f0..3f9d0122fda2 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-schema.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-schema.ts @@ -67,14 +67,5 @@ CREATE TABLE IF NOT EXISTS queued_messages ( db.exec(` CREATE UNIQUE INDEX IF NOT EXISTS queued_messages_consumed_as ON queued_messages (session_id, consumed_as) WHERE consumed_as IS NOT NULL; --- Unread and unwritten: the queue's pause is derived from the journal. Kept for downgrade --- safety; dropped in a later migration. -CREATE TABLE IF NOT EXISTS queued_message_pauses ( - session_id TEXT PRIMARY KEY, - reason TEXT NOT NULL, - epoch TEXT NOT NULL, - sequence INTEGER NOT NULL, - recorded_at INTEGER NOT NULL -); `) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index c171f873fce0..0cf2ce728705 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -70,11 +70,13 @@ function withdraw(clientMessageId: string) { }) } -function storedPauses(): number { +/** Tables that could store a pause: none, the journal rows are the only record. */ +function pauseTables(): number { const db = new Database(journalDatabasePath(rig.root), { readonly: true }) try { - const row: unknown = db.prepare('SELECT COUNT(*) AS n FROM queued_message_pauses').get() - return typeof row === 'object' && row !== null && 'n' in row ? Number(row.n) : -1 + return db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%pause%'") + .all().length } finally { db.close() } @@ -243,9 +245,9 @@ describe('no stored pause', () => { await rig.stop() await rig.settleAccepted(working, 'stopped') await expectHeld('stopped', draftId) - expect(storedPauses()).toBe(0) + expect(pauseTables()).toBe(0) expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) - expect(storedPauses()).toBe(0) + expect(pauseTables()).toBe(0) await eventually(async () => expect((await rig.handoff(draftId))?.handedOverAt).toBeDefined()) await queuedDraft('carried') await rig.stop() @@ -257,6 +259,6 @@ describe('no stored pause', () => { }) const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined expect(replacementId && (await rig.queuePause(replacementId))).toEqual({ reason: 'cleared' }) - expect(storedPauses()).toBe(0) + expect(pauseTables()).toBe(0) }) }) From 44e5184ec698c13976d7f734d2497deff29a0397 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:22:17 -0700 Subject: [PATCH 10/58] fix(native-chat): a Stop's pause never hides the restart pause A Stop holds only the cards queued before it. The pause derivation still returned the Stop alone whenever it was in force, so the restart pause was never considered: a card queued after the Stop, written by a host process that has since exited, sent by itself after Orca restarted, with no pause header and no Resume. A /clear pause that held nothing could hide it the same way. Every pause in force is now derived. A card is held if any of them holds it, and it names the first that does. The drain's pick, the consume transaction's re-check and the published header all read that one rule; the header names the pause holding the first card Resume would send. --- .../journal-queued-messages.ts | 18 ++--- .../queued-message-pause.test.ts | 79 +++++++++++++++++-- .../queued-message-pause.ts | 65 ++++++++------- ...ured-agent-session-queued-messages.test.ts | 4 +- ...tructured-agent-session-queued-messages.ts | 4 +- ...ed-agent-session-queued-pause-lift.test.ts | 16 ++-- .../structured-agent-session-queued-pause.ts | 8 +- ...ctured-agent-session-queued-publication.ts | 11 +-- ...ured-agent-session-queued-stop-row.test.ts | 38 +++++++-- 9 files changed, 172 insertions(+), 71 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 48a88360be21..a565c007b017 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -17,7 +17,7 @@ import type { JournalRowTransactionHook } from './journal-row-writer' import type { JournalSubmissionConsume } from './journal-store-contracts' import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' import { - deriveQueuePause, + deriveQueuePauses, nextSendableQueuedCard, type DerivedQueuePause } from './queued-message-pause' @@ -139,17 +139,17 @@ export class JournalQueuedMessages { ).then(() => undefined) } - /** The queue's pause, derived from the fold and the cards (`queued-message-pause.ts`). */ - pause(hostInstance: string): DerivedQueuePause | null { - return this.derivePause(this.list(), hostInstance) + /** The queue's pauses in force, derived from the fold and the cards (`queued-message-pause.ts`). */ + pauses(hostInstance: string): DerivedQueuePause[] { + return this.derivePauses(this.list(), hostInstance) } - private derivePause( + private derivePauses( cards: readonly QueuedMessageRow[], hostInstance: string - ): DerivedQueuePause | null { + ): DerivedQueuePause[] { const state = this.deps.state() - return deriveQueuePause({ + return deriveQueuePauses({ epoch: state.epoch, marks: state.queuePauseMarks, latestPersonTurnSequence: state.latestPersonTurnSequence, @@ -240,8 +240,8 @@ export class JournalQueuedMessages { // Judged here, on the same queue as every journal append, by the drain's own rule, so a // Stop row that landed after the drain chose this card still holds it. const cards = listQueuedMessages(db, this.deps.sessionId) - const pause = this.derivePause(cards, input.yieldsToPause.hostInstance) - if (nextSendableQueuedCard(pause, cards)?.messageId !== input.messageId) { + const pauses = this.derivePauses(cards, input.yieldsToPause.hostInstance) + if (nextSendableQueuedCard(pauses, cards)?.messageId !== input.messageId) { throw new QueuedMessageNotConsumableError(input.messageId, input.expect) } } diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 7678b4aef133..75861bcf8a11 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -20,7 +20,12 @@ import { QueuedMessageNotConsumableError } from './journal-queued-messages' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { parseJournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' -import { queuePauseHolds } from './queued-message-pause' +import { + deriveQueuePauses, + nextSendableQueuedCard, + queuePauseHolding, + resumableQueuePause +} from './queued-message-pause' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' @@ -90,16 +95,16 @@ function acceptTurn(journal: AgentSessionJournal, id: string) { } function reason(journal: AgentSessionJournal): string | null { - return journal.queuedMessages.pause(HOST)?.reason ?? null + return journal.queuedMessages.pauses(HOST)[0]?.reason ?? null } -/** Each card, and whether the queue's pause holds it. */ +/** Each card, and whether a pause in force holds it. */ function held(journal: AgentSessionJournal): [string, boolean][] { - const pause = journal.queuedMessages.pause(HOST) + const pauses = journal.queuedMessages.pauses(HOST) return journal.queuedMessages .list() .filter((card) => card.state === 'waiting') - .map((card) => [card.messageId, pause !== null && queuePauseHolds(pause, card)]) + .map((card) => [card.messageId, queuePauseHolding(pauses, card) !== undefined]) } /** Tables that could store a pause: none, the journal rows are the only record. */ @@ -377,3 +382,67 @@ describe("a restart's pause", () => { expect(journal.queuedMessages.revision()).toBe(revision) }) }) + +describe('several pauses in force', () => { + type Card = Parameters[1] & { messageId: string } + const DEAD = 'proc-0' + + function card(messageId: string, queuedAfter: number, fields: Partial = {}): Card { + const queuedAt = { epoch: 'epoch-1', sequence: queuedAfter + 1 } + const base = { state: 'waiting', holdReason: null, hostInstance: HOST, carriedFrom: null } + return { messageId, ...base, queuedAt, ...fields } + } + + /** A Stop at sequence 5 unless `stopped` is 0; no person's turn or Resume since. */ + function pausesOver(cards: readonly Card[], stopped = 5) { + return deriveQueuePauses({ + epoch: 'epoch-1', + marks: { stoppedSequence: stopped, resumedSequence: 0 }, + latestPersonTurnSequence: 0, + cards, + hostInstance: HOST, + restartEnded: false + }) + } + + function holding(cards: readonly Card[], stopped?: number): [string, string | null][] { + const pauses = pausesOver(cards, stopped) + return cards.map((each) => [each.messageId, queuePauseHolding(pauses, each)?.reason ?? null]) + } + + it("a Stop that holds nothing never hides a restart's: a dead process's card queued after it waits", () => { + const after = card('after', 5, { hostInstance: DEAD }) + expect(pausesOver([after]).map((pause) => pause.reason)).toEqual(['stopped', 'restarted']) + expect(holding([after])).toEqual([['after', 'restarted']]) + expect(nextSendableQueuedCard(pausesOver([after]), [after])).toBeNull() + expect(resumableQueuePause(pausesOver([after]), [after])?.reason).toBe('restarted') + // Written by this process, nothing holds it: a card queued after a Stop sends normally. + const live = card('after', 5) + expect(nextSendableQueuedCard(pausesOver([live]), [live])).toBe(live) + }) + + it("a card names the first pause holding it, and the header names the first held card's", () => { + const cards = [ + card('before', 3, { hostInstance: DEAD }), + card('after', 5, { hostInstance: DEAD }) + ] + expect(holding(cards)).toEqual([ + ['before', 'stopped'], + ['after', 'restarted'] + ]) + expect(resumableQueuePause(pausesOver(cards), cards)?.reason).toBe('stopped') + }) + + it("a /clear's pause that holds nothing never hides a restart's", () => { + const cards = [ + card('carried', 1, { carriedFrom: 'source-session', holdReason: 'send_failed' }), + card('typed', 2, { hostInstance: DEAD }) + ] + expect(pausesOver(cards, 0).map((pause) => pause.reason)).toEqual(['cleared', 'restarted']) + expect(holding(cards, 0)).toEqual([ + ['carried', null], + ['typed', 'restarted'] + ]) + expect(nextSendableQueuedCard(pausesOver(cards, 0), cards)).toBeNull() + }) +}) 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 f5f9c1247f3d..ea20622ff0bb 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 @@ -1,5 +1,5 @@ -// The queue's pause: a pure function of the journal fold and the cards, never a stored flag, so -// nothing has to retire it. Which cards it holds is `queuePauseHolds`. Paused when: +// The queue's pauses: a pure function of the journal fold and the cards, never a stored flag, so +// nothing has to retire them. Each holds its own cards (`queuePauseHolding`). In force when: // - 'stopped': the latest Stop row has no later Resume row, and no turn a person asked for was // sent after it and accepted. A later Stop is simply the latest. // - 'cleared': a card /clear carried into this conversation waits, and no person's turn or @@ -57,7 +57,8 @@ export function journalQueueStopHolds( return marks.stoppedSequence > Math.max(latestPersonTurnSequence, marks.resumedSequence) } -export function deriveQueuePause(input: { +/** Every pause in force, in the order a card held by several names its reason. */ +export function deriveQueuePauses(input: { /** The journal's epoch: sequences compare only within one. */ epoch: string marks: JournalQueuePauseMarks @@ -66,21 +67,22 @@ export function deriveQueuePause(input: { hostInstance: string /** A person's turn started since this conversation opened. */ restartEnded: boolean -}): DerivedQueuePause | null { +}): DerivedQueuePause[] { const { epoch, marks, latestPersonTurnSequence } = input + const pauses: DerivedQueuePause[] = [] if (journalQueueStopHolds(marks, latestPersonTurnSequence)) { - return { reason: 'stopped', since: { epoch, sequence: marks.stoppedSequence } } + pauses.push({ reason: 'stopped', since: { epoch, sequence: marks.stoppedSequence } }) } const waiting = input.cards.filter((card) => card.state === 'waiting') const carried = waiting.filter((card) => card.carriedFrom !== null) if (carried.length > 0 && latestPersonTurnSequence === 0 && marks.resumedSequence === 0) { - return { reason: 'cleared', since: null } + pauses.push({ reason: 'cleared', since: null }) } if (!input.restartEnded && waiting.some((card) => card.hostInstance !== input.hostInstance)) { // The process that wrote a card is gone: every card waits, whenever it was written. - return { reason: 'restarted', since: null } + pauses.push({ reason: 'restarted', since: null }) } - return null + return pauses } /** Queued before the pause began: for /clear, a card it carried; for a restart, every card. For a @@ -104,24 +106,28 @@ function queuedBeforePause(pause: DerivedQueuePause, card: QueueCard): boolean { // drain stops at the first one. true instead holds every waiting card, whenever it was queued. const PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT = false -/** THE rule for which cards a pause holds: the drain, its consume, and publication all read it. */ -export function queuePauseHolds(pause: DerivedQueuePause, card: QueueCard): boolean { - return ( - card.state === 'waiting' && - card.holdReason === null && - (PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT || queuedBeforePause(pause, card)) - ) +/** THE rule for which cards are held: by ANY pause in force, named by the first that holds it, so + * a Stop's that holds nothing never hides a restart's. The drain, its consume, and publication + * all read it. */ +export function queuePauseHolding( + pauses: readonly DerivedQueuePause[], + card: QueueCard +): DerivedQueuePause | undefined { + if (card.state !== 'waiting' || card.holdReason !== null) { + return undefined + } + return pauses.find((pause) => PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT || queuedBeforePause(pause, card)) } /** The card the queue sends next: the oldest waiting one with no hold of its own, unless a - * returned card or one the pause holds comes first. The queue never reorders, so a newer card - * never overtakes a held one. The drain's pick and its consume both read this. */ + * returned card or a held one comes first. The queue never reorders, so a newer card never + * overtakes a held one. The drain's pick and its consume both read this. */ export function nextSendableQueuedCard( - pause: DerivedQueuePause | null, + pauses: readonly DerivedQueuePause[], cards: readonly T[] ): T | null { for (const card of cards) { - if (card.state === 'returned' || (pause && queuePauseHolds(pause, card))) { + if (card.state === 'returned' || queuePauseHolding(pauses, card)) { return null } if (card.state === 'waiting' && card.holdReason === null) { @@ -131,20 +137,21 @@ export function nextSendableQueuedCard( return null } -/** Whether Resume would send anything: a card the pause holds, not behind a returned card, which - * blocks everything after it until the user acts. Only then is the pause PUBLISHED, so its - * header never offers a Resume that sends nothing. */ -export function queuePauseHoldsResumableCard( - pause: DerivedQueuePause, +/** The pause to PUBLISH: the one holding the first card Resume would send, not behind a returned + * card, which blocks everything after it until the user acts. None otherwise, so its header + * never offers a Resume that sends nothing. */ +export function resumableQueuePause( + pauses: readonly DerivedQueuePause[], cards: readonly QueueCard[] -): boolean { +): DerivedQueuePause | null { for (const card of cards) { if (card.state === 'returned') { - return false + return null } - if (queuePauseHolds(pause, card)) { - return true + const holding = queuePauseHolding(pauses, card) + if (holding) { + return holding } } - return false + return null } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index d3847af8b728..4453e060df08 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -16,7 +16,7 @@ import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' import { rotateStructuredAgentSessionHostInstanceForTests, - structuredQueuePause + structuredQueuePauses } from './structured-agent-session-queued-pause' import { createQueuedMessageTestRig, @@ -681,7 +681,7 @@ describe('/clear', () => { } expect(await drafts(replacementId)).toHaveLength(0) const journal = host.collaboratorsForTests().sessions.get(replacementId)?.journal - expect(journal && structuredQueuePause(journal)).toBeNull() + expect(journal && structuredQueuePauses(journal)).toEqual([]) }) it('a returned card carries over as a plain waiting draft on the paused replacement', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index f630e749866c..5aeca39b74df 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -28,7 +28,7 @@ import type { QueuedMessageRow } from '../agent-session-journal/queued-message-t import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { structuredAgentSessionHostInstance, - structuredQueuePause + structuredQueuePauses } from './structured-agent-session-queued-pause' import { nextSendableQueuedCard } from '../agent-session-journal/queued-message-pause' @@ -69,7 +69,7 @@ function oldestActionableQueuedMessage( if (!rows.some((row) => row.state === 'waiting')) { return null } - return nextSendableQueuedCard(structuredQueuePause(journal), rows) + return nextSendableQueuedCard(structuredQueuePauses(journal), rows) } /** diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 09738da3d1d3..243da1182a21 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -19,7 +19,7 @@ import { sameQueuePause } from './structured-agent-session-queued-publication' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { structuredAgentSessionHostInstance, - structuredQueuePause + structuredQueuePauses } from './structured-agent-session-queued-pause' let rig: QueuedMessageTestRig @@ -189,18 +189,18 @@ describe('the pause read', () => { } const scan = vi.spyOn(journal, 'submissions') // Read on every publish, per subscriber: it must not walk the submissions. - expect(structuredQueuePause(journal)).toMatchObject({ reason: 'stopped' }) + expect(structuredQueuePauses(journal)).toMatchObject([{ reason: 'stopped' }]) expect(scan).not.toHaveBeenCalled() scan.mockRestore() const mail = rig.send('coordinator mail', undefined, { internal: true }) await mail.result await rig.settleAccepted(mail.id, 'mail') // Orca's own turn lifts nothing; a person's does. - expect(structuredQueuePause(journal)).toMatchObject({ reason: 'stopped' }) + expect(structuredQueuePauses(journal)).toMatchObject([{ reason: 'stopped' }]) const next = rig.send('user starts a new turn') await next.result await rig.settleAccepted(next.id, 'next') - expect(structuredQueuePause(journal)).toBeNull() + expect(structuredQueuePauses(journal)).toEqual([]) await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) }) }) @@ -233,7 +233,7 @@ describe('a card queued after a Stop is a new instruction', () => { await rig.settleAccepted(mail.id, 'mail') await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - expect(journal && structuredQueuePause(journal)).toMatchObject({ reason: 'stopped' }) + expect(journal && structuredQueuePauses(journal)).toMatchObject([{ reason: 'stopped' }]) }) it("deleting the last paused card hides the pause; a person's next turn is what ends it", async () => { @@ -268,7 +268,7 @@ describe('a pause only over cards Resume could send', () => { await rig.settleAccepted(next, 'stopped') expect(await rig.queuePause()).toBeNull() const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - expect(journal && structuredQueuePause(journal)).toMatchObject({ reason: 'stopped' }) + expect(journal && structuredQueuePauses(journal)).toMatchObject([{ reason: 'stopped' }]) }) it('a returned card blocking the paused cards hides the pause but keeps it; deleting that card shows it again, and only Resume sends', async () => { @@ -293,7 +293,7 @@ describe('a pause only over cards Resume could send', () => { } // Resume would send nothing past the returned card, so no header offers it; the pause stays. expect(await rig.queuePause()).toBeNull() - expect(structuredQueuePause(journal)).toMatchObject({ reason: 'stopped' }) + expect(structuredQueuePauses(journal)).toMatchObject([{ reason: 'stopped' }]) // Deleting the blocking card shows the pause again: the card behind it does not send unasked. expect(await rig.deleteQueued(refusedId)).toMatchObject({ ok: true, value: { deleted: true } }) await expectPaused(behindId) @@ -385,7 +385,7 @@ describe('a card handed off after a restart', () => { 'resumed', structuredAgentSessionConversationFence(rig.store, HOST_TEST_SESSION) ) - expect(structuredQueuePause(journal)).toBeNull() + expect(structuredQueuePauses(journal)).toEqual([]) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts index 4e7d3a606a82..7355655476c3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts @@ -23,9 +23,9 @@ export function rotateStructuredAgentSessionHostInstanceForTests(): string { type PauseJournal = Pick -/** The queue's pause, derived; null when the queue sends on its own. */ -export function structuredQueuePause(journal: PauseJournal): DerivedQueuePause | null { - return journal.queuedMessages.pause(hostInstance) +/** The queue's pauses in force, derived; none when the queue sends on its own. */ +export function structuredQueuePauses(journal: PauseJournal): DerivedQueuePause[] { + return journal.queuedMessages.pauses(hostInstance) } /** @@ -59,7 +59,7 @@ export async function resumeStructuredQueue( journal: Pick, fence: number ): Promise { - if (structuredQueuePause(journal) === null) { + if (structuredQueuePauses(journal).length === 0) { return false } await journal.appendQueuePauseMark('resumed', fence) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts index 17bd71a57e0e..1fa46616da18 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts @@ -9,8 +9,8 @@ import { type AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { queuePauseHoldsResumableCard } from '../agent-session-journal/queued-message-pause' -import { structuredQueuePause } from './structured-agent-session-queued-pause' +import { resumableQueuePause } from '../agent-session-journal/queued-message-pause' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' export type QueuePublication = { queuedMessages: AgentSessionQueuedMessage[] @@ -84,11 +84,8 @@ export function readQueuePublication(journal: AgentSessionJournal): QueuePublica // Read per emit: the pause also turns on submissions (a person's turn starting). Shown only // over a card Resume would send, so its header never offers to send nothing; deleting a // blocking returned card shows it again. - const pause = structuredQueuePause(journal) - const queuePause = - pause && queuePauseHoldsResumableCard(pause, journal.queuedMessages.list()) - ? { reason: pause.reason } - : null + const pause = resumableQueuePause(structuredQueuePauses(journal), journal.queuedMessages.list()) + const queuePause = pause ? { reason: pause.reason } : null const previous = publications.get(journal) if ( previous && diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index 0cf2ce728705..e3b8682bd9c8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -1,7 +1,7 @@ // Stop writes one journal row before it interrupts, and the queue's pause is derived from it: // through the real host, the cards queued before a Stop wait, a card queued after it sends -// normally but never ahead of them, a withdrawn card comes back under it, a crash keeps it, and -// no stored pause is ever written. +// normally but never ahead of them, a withdrawn card comes back under it, a crash keeps it, it +// never hides a restart's pause, and no stored pause is ever written. import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' @@ -19,7 +19,7 @@ import { QUEUED_RIG_CALLER, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' -import { structuredQueuePause } from './structured-agent-session-queued-pause' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' let rig: QueuedMessageTestRig @@ -87,7 +87,7 @@ describe("Stop's row", () => { await rig.workingSend() let pausedAtInterrupt: unknown = 'not interrupted' rig.cancelTurn.mockImplementationOnce(async () => { - pausedAtInterrupt = structuredQueuePause(journal())?.reason ?? null + pausedAtInterrupt = structuredQueuePauses(journal())[0]?.reason ?? null return { cancelled: true } }) expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) @@ -185,6 +185,34 @@ describe("Stop's row", () => { }) }) +describe("a Stop never hides a restart's pause", () => { + it('a card queued after a Stop over an empty queue, before a restart, waits restarted', async () => { + const working = await rig.workingSend() + await rig.stop() + await rig.settleAccepted(working, 'stopped') + const mail = await mailTurn() + const typed = await queuedDraft('typed during the mail turn') + await rig.restartHostProcess() + await rig.settleAccepted(mail, 'mail') + await expectHeld('restarted', typed) + }) + + it("a card queued during the queue's own send after a Stop, before a restart, waits restarted", async () => { + const working = await rig.workingSend() + await rig.stop() + const correction = await queuedDraft('typed while the interrupt lands') + await rig.settleAccepted(working, 'stopped') + // The queue's own send is not a person's turn: the Stop stays in force, holding nothing. + await eventually(async () => + expect((await rig.handoff(correction))?.handedOverAt).toBeDefined() + ) + const typed = await queuedDraft('typed during that send') + await rig.restartHostProcess() + await rig.settleAccepted(await rig.handoffId(correction), 'correction') + await expectHeld('restarted', typed) + }) +}) + describe("a /clear's carried cards", () => { function clear() { const fields = { command: 'clear' as const } @@ -234,7 +262,7 @@ describe("a /clear's carried cards", () => { providerIdentity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-hi', ordinal: 0 } }) expect(journal(replacementId).queuedMessages.list()[0]?.messageId).toBe(carried) - expect(structuredQueuePause(journal(replacementId))).toBeNull() + expect(structuredQueuePauses(journal(replacementId))).toEqual([]) }) }) From a90bd87b8bc6be7c92fe3559694839d574c8ac9d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:26:35 -0700 Subject: [PATCH 11/58] test(native-chat): pin the Stop's no-resend, lift and held-card rules - The Claude and Codex Stop-withdraws-a-steer tests checked "not sent again" at one instant, before a queue ignoring the pause re-sends. They now wait for the stopped turn to end and re-check after a quiet window. - The deleted-card test read a card queued after the Stop, which sends whether or not a person's turn lifts it; it now reads the Stop's pause before and after that turn. - Unit cases pin that a Stop holds a card with no recorded position and one queued before a rewind. --- .../queued-message-pause.test.ts | 16 +++++++++++++++- ...ured-agent-session-claude-queued-stop.test.ts | 8 +++++++- ...tured-agent-session-queued-pause-lift.test.ts | 10 +++++++++- ...ent-session-codex-turn-end-settlement.test.ts | 6 +++++- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 75861bcf8a11..3a893c137001 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -383,7 +383,7 @@ describe("a restart's pause", () => { }) }) -describe('several pauses in force', () => { +describe('which cards the pauses in force hold', () => { type Card = Parameters[1] & { messageId: string } const DEAD = 'proc-0' @@ -410,6 +410,20 @@ describe('several pauses in force', () => { return cards.map((each) => [each.messageId, queuePauseHolding(pauses, each)?.reason ?? null]) } + it('a Stop holds a card with no recorded position, and one queued before a rewind', () => { + const cards = [ + card('unrecorded', 5, { queuedAt: null }), + // Later in its own epoch than the Stop is in this one: only the epoch says it came first. + card('before-rewind', 5, { queuedAt: { epoch: 'epoch-0', sequence: 9 } }), + card('after', 5) + ] + expect(holding(cards)).toEqual([ + ['unrecorded', 'stopped'], + ['before-rewind', 'stopped'], + ['after', null] + ]) + }) + it("a Stop that holds nothing never hides a restart's: a dead process's card queued after it waits", () => { const after = card('after', 5, { hostInstance: DEAD }) expect(pausesOver([after]).map((pause) => pause.reason)).toEqual(['stopped', 'restarted']) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index bd960b179ba6..043f093a1bc0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -367,5 +367,11 @@ it('a card sent now into the running turn comes back paused when Stop withdraws sends: [{ origin: 'client', state: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }] }) }, 5_000) + // A drain ignoring the pause re-sends only after the stopped turn ends: watch past that. + await eventually(async () => expect(await liveTurnId()).toBeNull()) + await new Promise((resolve) => setTimeout(resolve, 2_500)) expect(connection.sent).toHaveLength(2) -}, 15_000) + expect(await sends()).toEqual([ + { origin: 'client', state: 'rejected', reason: DISPATCH_REJECTED_CANCELLED } + ]) +}, 20_000) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 243da1182a21..b15f27b9596f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -244,10 +244,18 @@ describe('a card queued after a Stop is a new instruction', () => { expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) expect(await rig.deleteQueued(only)).toMatchObject({ ok: true, value: { deleted: true } }) expect(await rig.queuePause()).toBeNull() + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + // Hidden, not ended: with nothing to hold, no card can show the lift, so read the Stop itself. + expect(structuredQueuePauses(journal)).toMatchObject([{ reason: 'stopped' }]) const next = await rig.workingSend() const later = await queuedDraft('typed during the next turn') - // That send is a person's turn after the Stop: once it starts, the queue sends again. + expect(structuredQueuePauses(journal)).toMatchObject([{ reason: 'stopped' }]) + // That send is a person's turn after the Stop: once it starts, the Stop is over. await rig.settleAccepted(next, 'next') + expect(structuredQueuePauses(journal)).toEqual([]) await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) }) }) 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 ce23a8df5918..4af11020cdd7 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 @@ -292,8 +292,12 @@ describe('a queued card sent now into the turn a Stop ends', () => { }), { timeout: 5_000 } ) + // A drain ignoring the pause re-sends only after the stopped turn ends: watch past that. + await vi.waitFor(() => expect(turns.turnId).toBeNull()) + await new Promise((resolve) => setTimeout(resolve, 2_500)) expect(answers).toBe(2) - }) + expect(await sends(cardId)).toEqual([{ origin: 'client', verdict: 'withdrawn' }]) + }, 20_000) }) describe('a Stop sent after Codex answered a cold send, before it opened the turn', () => { From 99589e93c27825efe71b6ea00149a44fe2799e80 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:37:09 -0700 Subject: [PATCH 12/58] refactor(native-chat): a Stop writes one Stop event with its reason, turn and caller The Stop row that paused the queue becomes the general Stop event { reason, turnId?, at, caller? }, whose reason is the host's existing stop cause. It still rides a tombstone of a host-only id (a released host deletes the journal from the first unknown row kind), and Resume keeps its own marker on its own id. Only a person's Stop (reason user-stop) pauses the queue. --- .../journal-epoch-controller.ts | 7 ++- .../journal-epoch-replacement.test.ts | 2 +- .../journal-epoch-replacement.ts | 26 +++++---- .../agent-session-journal/journal-open.ts | 4 +- .../journal-queue-pause-row.ts | 37 ------------ .../agent-session-journal/journal-reducer.ts | 6 +- .../journal-row-schema.ts | 34 +++++++---- .../journal-stop-and-resume-rows.ts | 58 +++++++++++++++++++ .../journal-store-collaborators.ts | 9 ++- .../agent-session-journal/journal-store.ts | 23 +++++--- .../queued-message-pause.test.ts | 51 ++++++++++------ .../queued-message-pause.ts | 53 ++++++++++++----- .../agent-session-journal-batch.ts | 6 +- ...structured-agent-session-host-mutations.ts | 15 +++-- ...ured-agent-session-queued-messages.test.ts | 6 +- ...ed-agent-session-queued-pause-lift.test.ts | 3 +- .../structured-agent-session-queued-pause.ts | 8 +-- ...ured-agent-session-queued-stop-row.test.ts | 4 +- .../structured-agent-session-queued-stop.ts | 42 ++++++++------ .../structured-agent-session-stop-cause.ts | 5 +- 20 files changed, 249 insertions(+), 150 deletions(-) delete mode 100644 src/main/native-chat/agent-session-journal/journal-queue-pause-row.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts index 2c8b2766c0f2..51d3a4b2fd53 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts @@ -4,6 +4,7 @@ import type { } from '../../../shared/agent-session-journal-types' import type { JournalHostDatabase } from './journal-host-database' import { replaceJournalEpoch, type JournalReplacementItem } from './journal-epoch-replacement' +import type { JournalQueuePauseRestatement } from './queued-message-pause' import { publishNewEpoch } from './journal-epoch-rollover' import type { JournalLoad } from './journal-open' import type { AgentJournalEpochReason } from './journal-row-schema' @@ -20,8 +21,8 @@ export class JournalEpochController { readOnly: () => boolean setReadOnly: (readOnly: boolean) => void highestFence: () => number - /** Whether the live epoch's latest Stop still pauses the queue. */ - queueStopped: () => boolean + /** What of the live epoch's Stop and Resume a replacement restates. */ + queuePauseRestatement: () => JournalQueuePauseRestatement cursor: () => AgentJournalCursor adopt: (loaded: JournalLoad) => void } @@ -69,7 +70,7 @@ export class JournalEpochController { reason, fence, items, - queueStopped: this.deps.queueStopped(), + queuePause: this.deps.queuePauseRestatement(), now: this.deps.now, mintEpoch: this.deps.mintEpoch, onPublished: this.deps.adopt diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts index 3c893545c4e0..2992459a787a 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts @@ -67,7 +67,7 @@ function replace(input: { reason: 'legacy_import', fence: 1, items: input.items, - queueStopped: false, + queuePause: { lifted: false, liveStop: null }, now, mintEpoch: () => `epoch-${clock}`, onPublished: input.onPublished ?? (() => undefined) diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index 1097b5ab5cc7..3f0c7a1e7796 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -16,7 +16,8 @@ import type { JournalLoad } from './journal-open' import { clearJournalRepairMarker } from './journal-repair-marker' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { buildJournalItemRow, journalRowBase } from './journal-row-builders' -import { buildJournalQueuePauseRow } from './journal-queue-pause-row' +import { buildJournalQueueResumeRow, buildJournalStopEventRow } from './journal-stop-and-resume-rows' +import type { JournalQueuePauseRestatement } from './queued-message-pause' import { deleteJournalEpochRows, insertJournalRow, @@ -41,9 +42,9 @@ export function replaceJournalEpoch(input: { reason: AgentJournalEpochReason fence: number items: readonly JournalReplacementItem[] - /** The superseded epoch's latest Stop still paused the queue: the new epoch restates it, or - * the rewind would release cards the person stopped. */ - queueStopped: boolean + /** Restated in the new epoch, or the rewind would release cards the person stopped, or bring + * back a /clear pause they already lifted. */ + queuePause: JournalQueuePauseRestatement now: () => number mintEpoch: () => string /** Called the instant the transaction commits, before any fallible follow-up. */ @@ -74,14 +75,15 @@ export function replaceJournalEpoch(input: { applyJournalRow(state, row) rows.push(row) } - if (input.queueStopped) { - const row = buildJournalQueuePauseRow({ - state, - mark: 'stopped', - seq: state.lastSequence + 1, - fence: input.fence, - ts: input.now() - }) + const { lifted, liveStop } = input.queuePause + const place = () => ({ state, seq: state.lastSequence + 1, fence: input.fence, ts: input.now() }) + if (lifted) { + const row = buildJournalQueueResumeRow(place()) + applyJournalRow(state, row) + rows.push(row) + } + if (liveStop) { + const row = buildJournalStopEventRow({ ...place(), event: liveStop }) applyJournalRow(state, row) rows.push(row) } diff --git a/src/main/native-chat/agent-session-journal/journal-open.ts b/src/main/native-chat/agent-session-journal/journal-open.ts index 8654ad569aa4..8f947272dbc7 100644 --- a/src/main/native-chat/agent-session-journal/journal-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-open.ts @@ -19,7 +19,7 @@ import { } from './journal-row-table' import { JOURNAL_REPAIR_DISCLOSURE_ITEM_ID } from './journal-repair-disclosure' import { pendingJournalRepairSequence } from './journal-repair-marker' -import { isJournalQueuePauseRow, parseJournalRow, type JournalRow } from './journal-row-schema' +import { isJournalStopOrResumeRow, parseJournalRow, type JournalRow } from './journal-row-schema' /** Every epoch row is sequence 1, and no compaction moves that floor. */ const FIRST_JOURNAL_SEQUENCE = 1 @@ -121,7 +121,7 @@ export function startJournalRowFold(input: JournalRowFoldInput): { applyJournalRow(state, row) const disclosure = row.kind === 'item' && row.itemId === JOURNAL_REPAIR_DISCLOSURE_ITEM_ID // A Stop or Resume is no history, so it never reads as a rebuilt or provider-backed epoch. - if (!disclosure && !isJournalQueuePauseRow(row)) { + if (!disclosure && !isJournalStopOrResumeRow(row)) { repairHasContent ||= repairedFrom !== null && row.seq >= repairedFrom providerHasContent ||= row.seq >= FIRST_JOURNAL_SEQUENCE + 1 } diff --git a/src/main/native-chat/agent-session-journal/journal-queue-pause-row.ts b/src/main/native-chat/agent-session-journal/journal-queue-pause-row.ts deleted file mode 100644 index 4be07ce6b8b0..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-queue-pause-row.ts +++ /dev/null @@ -1,37 +0,0 @@ -// The row a person's Stop or Resume appends: a tombstone of an id no item ever takes, carrying -// the mark (`journal-row-schema.ts` says why not a row kind of its own). - -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { JournalReducerState } from './journal-reducer' -import { journalRowBase } from './journal-row-builders' -import type { JournalQueuePauseMark, JournalTombstoneRow } from './journal-row-schema' - -/** The one id the queue's Stop and Resume rows name; no item ever takes it. */ -const JOURNAL_QUEUE_PAUSE_ITEM_ID = agentJournalItemKey({ - provider: 'orca', - clientMessageId: 'queue-pause' -}) - -export function buildJournalQueuePauseRow(input: { - state: JournalReducerState - mark: JournalQueuePauseMark - seq: number - fence: number - ts: number -}): JournalTombstoneRow { - return { - kind: 'tombstone', - itemId: JOURNAL_QUEUE_PAUSE_ITEM_ID, - revision: 1, - queuePause: input.mark, - ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts) - } -} - -export function journalQueuePauseRowBuilder( - state: () => JournalReducerState, - mark: JournalQueuePauseMark, - fence: number -): (seq: number, ts: number) => JournalTombstoneRow { - return (seq, ts) => buildJournalQueuePauseRow({ state: state(), mark, seq, fence, ts }) -} diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index 0af2eccf11dd..a7d6f150e70c 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -26,7 +26,7 @@ import { structuredAgentSessionPayloadFingerprint } from '../../../shared/struct import { JournalDerivedTurnScope } from './journal-derived-turn-scope' import { removeJournalItem, statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold' import { journalItemRevisionIsStale } from './journal-item-revision' -import { isJournalQueuePauseRow, type JournalRow } from './journal-row-schema' +import { isJournalStopOrResumeRow, type JournalRow } from './journal-row-schema' import { acceptSubmissionFromProviderItem, applyJournalSubmission } from './journal-submission-fold' import { applyJournalDispatchRow } from './journal-dispatch-reducer' import { isWriteFailureSubmission } from '../../../shared/structured-agent-session-dispatch-rejection' @@ -62,7 +62,7 @@ export type JournalReducerState = { /** The submission row of the latest turn a person asked for (`origin: 'client'`) that the * provider accepted; 0 when none. Kept as it folds so the queue's pause reads it in O(1). */ latestPersonTurnSequence: number - /** The queue's latest Stop and Resume rows, what its pause is derived from. */ + /** The latest person's Stop event and Resume, what the queue's pause is derived from. */ queuePauseMarks: JournalQueuePauseMarks } @@ -109,7 +109,7 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo ) return } - if (isJournalQueuePauseRow(row)) { + if (isJournalStopOrResumeRow(row)) { foldJournalQueuePauseMark(state.queuePauseMarks, row) return } diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 67c8aa8ca111..ddda8809689c 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -20,6 +20,7 @@ import { isAdmissibleAgentJournalMessageBody } from '../../../shared/agent-session-journal-schemas' import { isAdmissibleAgentSessionContextUsage } from '../../../shared/agent-session-context-usage-schema' +import type { StructuredAgentSessionStopCause } from '../agent-session-wire/structured-agent-session-stop-cause' /** Producer linkage rides the row BASE rather than the body: the two nested * prompt shapes are `.strict()`, so an unknown key on a body would make the @@ -72,19 +73,32 @@ export type JournalTombstoneRow = JournalRowBase & { kind: 'tombstone' itemId: string revision: number - /** Present: not a removal but the queue's Stop or Resume (`queued-message-pause.ts`), on an id - * no item ever takes. A new row kind would make a released host truncate the journal from - * that row; this one it reads as removing nothing. */ - queuePause?: JournalQueuePauseMark + /** Present: not a removal but a Stop's event, on an id no item ever takes. */ + stopEvent?: JournalStopEvent + /** Present: not a removal but a person's Resume of the queue, on an id no item ever takes. */ + queueResume?: true } -export type JournalQueuePauseMark = 'stopped' | 'resumed' +/** One Stop that took effect. Temporary carrier: a tombstone's extra key, because a released host + * deletes the journal from the first row kind it does not know (`journal-open.ts` then + * `journal-store-open.ts`) but ignores an unknown key; a row kind of its own once released hosts + * skip unknown kinds instead. */ +export type JournalStopEvent = { + /** Persisted: never rename an arm. Only `user-stop` pauses the queue. */ + reason: StructuredAgentSessionStopCause + /** The turn the Stop named, else the one running when it took effect. */ + turnId?: string + /** When it took effect; a rewind's restatement keeps it. */ + at: number + /** Who asked (`StructuredAgentSessionCaller.callerKey`). */ + caller?: string +} -/** A Stop or Resume row. Any value counts, so a newer build's mark never removes an item. */ -export function isJournalQueuePauseRow( - row: JournalRow -): row is JournalTombstoneRow & { queuePause: JournalQueuePauseMark } { - return row.kind === 'tombstone' && row.queuePause !== undefined +/** A Stop's event or a Resume. Any value counts, so a newer build's mark never removes an item. */ +export function isJournalStopOrResumeRow(row: JournalRow): row is JournalTombstoneRow { + return ( + row.kind === 'tombstone' && (row.stopEvent !== undefined || row.queueResume !== undefined) + ) } /** The write-ahead row. Durable BEFORE the adapter dispatches anything; it diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts new file mode 100644 index 000000000000..e763af8b2e6c --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts @@ -0,0 +1,58 @@ +// The rows a Stop's event and a person's Resume append: tombstones of ids no item ever takes, +// each carrying its record as an extra key (`journal-row-schema.ts` says why not a row kind). + +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { JournalReducerState } from './journal-reducer' +import { journalRowBase } from './journal-row-builders' +import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' + +/** One id per mark kind; no item ever takes either. */ +const JOURNAL_STOP_EVENT_ITEM_ID = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'stop-event' +}) +const JOURNAL_QUEUE_RESUME_ITEM_ID = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'queue-resume' +}) + +type RowPlace = { state: JournalReducerState; seq: number; fence: number; ts: number } + +export function buildJournalStopEventRow( + input: RowPlace & { event: JournalStopEvent } +): JournalTombstoneRow { + return { + kind: 'tombstone', + itemId: JOURNAL_STOP_EVENT_ITEM_ID, + revision: 1, + stopEvent: input.event, + ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts) + } +} + +export function buildJournalQueueResumeRow(input: RowPlace): JournalTombstoneRow { + return { + kind: 'tombstone', + itemId: JOURNAL_QUEUE_RESUME_ITEM_ID, + revision: 1, + queueResume: true, + ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts) + } +} + +/** A Stop taking effect now: its event's time is its row's. */ +export function journalStopEventRowBuilder( + state: () => JournalReducerState, + event: Omit, + fence: number +): (seq: number, ts: number) => JournalTombstoneRow { + return (seq, ts) => + buildJournalStopEventRow({ state: state(), seq, fence, ts, event: { ...event, at: ts } }) +} + +export function journalQueueResumeRowBuilder( + state: () => JournalReducerState, + fence: number +): (seq: number, ts: number) => JournalTombstoneRow { + return (seq, ts) => buildJournalQueueResumeRow({ state: state(), seq, fence, ts }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 5e90baf7a873..647b64e0f004 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -13,7 +13,7 @@ import { JournalItemAppender } from './journal-item-appender' import { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' import type { JournalLoad } from './journal-open' import { JournalQueuedMessages } from './journal-queued-messages' -import { journalQueueStopHolds } from './queued-message-pause' +import { journalQueuePauseRestatement } from './queued-message-pause' import type { JournalReducerState } from './journal-reducer' import { JournalRowWriter } from './journal-row-writer' import { restoreJournalStore } from './journal-store-restore' @@ -71,8 +71,11 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal readOnly: host.readOnly, setReadOnly: host.setReadOnly, highestFence: () => host.state().highestFence, - queueStopped: () => - journalQueueStopHolds(host.state().queuePauseMarks, host.state().latestPersonTurnSequence), + queuePauseRestatement: () => + journalQueuePauseRestatement( + host.state().queuePauseMarks, + host.state().latestPersonTurnSequence + ), cursor: host.cursor, adopt: host.adopt }) 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 90fa5366f3b7..2e37ca7692b8 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -58,8 +58,11 @@ import type { ResolveDispatchInput } from './journal-store-contracts' import { queuedMessageConsumeHook, type JournalQueuedMessages } from './journal-queued-messages' -import { journalQueuePauseRowBuilder } from './journal-queue-pause-row' -import type { AgentJournalEpochReason, JournalQueuePauseMark } from './journal-row-schema' +import { + journalQueueResumeRowBuilder, + journalStopEventRowBuilder +} from './journal-stop-and-resume-rows' +import type { AgentJournalEpochReason, JournalStopEvent } from './journal-row-schema' import type { JournalRowWriter } from './journal-row-writer' import type { JournalEpochController } from './journal-epoch-controller' import { JournalWriteQueue } from './journal-write-queue' @@ -308,11 +311,17 @@ export class AgentSessionJournal { ) } - /** A person's Stop or Resume: the rows the queue's pause is derived from. Carried by a - * tombstone because a released host deletes the journal from the first row kind it does not - * know (`journal-open.ts`, `journal-store-open.ts`); an unknown key it ignores. */ - appendQueuePauseMark(mark: JournalQueuePauseMark, fence: number): Promise { - return this.rowWriter.append(journalQueuePauseRowBuilder(() => this.state, mark, fence)) + /** A Stop that took effect, timed by its row (`JournalStopEvent`). */ + appendStopEvent( + event: Omit, + fence: number + ): Promise { + return this.rowWriter.append(journalStopEventRowBuilder(() => this.state, event, fence)) + } + + /** A person's Resume of the queue. */ + appendQueueResume(fence: number): Promise { + return this.rowWriter.append(journalQueueResumeRowBuilder(() => this.state, fence)) } appendLifecycleBatch(input: JournalLifecycleBatchInput): Promise { diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 3a893c137001..5986787d25ad 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -94,6 +94,11 @@ function acceptTurn(journal: AgentSessionJournal, id: string) { }) } +/** A person's Stop taking effect. */ +function userStop(journal: AgentSessionJournal) { + return journal.appendStopEvent({ reason: 'user-stop' }, 0) +} + function reason(journal: AgentSessionJournal): string | null { return journal.queuedMessages.pauses(HOST)[0]?.reason ?? null } @@ -130,9 +135,9 @@ afterEach(async () => { }) describe("the queue's pause, derived from the journal", () => { - it('a Stop row pauses the queue, even with no card yet, survives reopen, and stores nothing', async () => { + it("a person's Stop event pauses the queue, even with no card yet, survives reopen, and stores nothing", async () => { let journal = await open() - await journal.appendQueuePauseMark('stopped', 0) + await userStop(journal) expect(reason(journal)).toBe('stopped') await queueDraft(journal, 'draft-1') await journal.close() @@ -144,7 +149,7 @@ describe("the queue's pause, derived from the journal", () => { it("only a person's turn sent after the Stop and accepted lifts it; host turns never do", async () => { const journal = await open() await turn(journal, 'before-stop', 'client', false) - await journal.appendQueuePauseMark('stopped', 0) + await userStop(journal) // Sent before the Stop: its acceptance now does not end a Stop that came after it. await acceptTurn(journal, 'before-stop') await turn(journal, 'mail', 'host') @@ -157,14 +162,14 @@ describe("the queue's pause, derived from the journal", () => { it('a later Stop is the latest, and a Resume row lifts it', async () => { const journal = await open() - await journal.appendQueuePauseMark('stopped', 0) + await userStop(journal) await turn(journal, 'typed', 'client') expect(reason(journal)).toBeNull() - await journal.appendQueuePauseMark('stopped', 0) + await userStop(journal) expect(reason(journal)).toBe('stopped') - await journal.appendQueuePauseMark('resumed', 0) + await journal.appendQueueResume(0) expect(reason(journal)).toBeNull() - await journal.appendQueuePauseMark('stopped', 0) + await userStop(journal) expect(reason(journal)).toBe('stopped') expect(pauseTables()).toBe(0) }) @@ -176,7 +181,7 @@ describe("the queue's pause, derived from the journal", () => { await journal.close() journal = await open() expect(reason(journal)).toBe('cleared') - await journal.appendQueuePauseMark('resumed', 0) + await journal.appendQueueResume(0) expect(reason(journal)).toBeNull() expect(pauseTables()).toBe(0) }) @@ -193,17 +198,24 @@ describe("the queue's pause, derived from the journal", () => { it('rides a tombstone of an id no item takes, so an older build reads it and changes nothing', async () => { const journal = await open() await turn(journal, 'typed', 'client') - await journal.appendQueuePauseMark('stopped', 0) + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 0) const db = new Database(journalDatabasePath(root), { readonly: true }) const stored = liveTestJournalRows(db, IDENTITY.sessionId) db.close() const parsed = parseJournalRow(stored.at(-1)?.rowJson ?? '') - expect(parsed).toMatchObject({ ok: true, row: { kind: 'tombstone', queuePause: 'stopped' } }) + expect(parsed).toMatchObject({ + ok: true, + row: { + kind: 'tombstone', + stopEvent: { reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' } + } + }) if (!parsed.ok || parsed.row.kind !== 'tombstone') { throw new Error('expected a tombstone row') } + expect(parsed.row.stopEvent?.at).toBe(parsed.row.ts) // An older build ignores the unknown key: the row is an ordinary removal of nothing. - const { queuePause: _ignored, ...asOlderBuildReadsIt } = parsed.row + const { stopEvent: _ignored, ...asOlderBuildReadsIt } = parsed.row const state = createJournalReducerState(IDENTITY.sessionId, parsed.row.epoch) for (const row of stored.slice(0, -1)) { const earlier = parseJournalRow(row.rowJson) @@ -221,7 +233,7 @@ describe("the queue's pause, derived from the journal", () => { it("the queue's own consume is refused in its transaction while the pause holds the card; Send-now is not", async () => { const journal = await open() await queueDraft(journal, 'draft-1') - await journal.appendQueuePauseMark('stopped', 0) + await userStop(journal) const consume = (id: string, automatic: boolean) => journal.appendSubmission( { @@ -249,7 +261,7 @@ describe("the queue's pause, derived from the journal", () => { it("a rewind's epoch replacement restates a Stop still pausing, and not one a person ended", async () => { const journal = await open() - await journal.appendQueuePauseMark('stopped', 0) + await userStop(journal) await journal.replaceEpochItems('handle_forked', 0, []) expect(reason(journal)).toBe('stopped') await turn(journal, 'typed', 'client') @@ -259,10 +271,10 @@ describe("the queue's pause, derived from the journal", () => { }) describe('which cards a pause holds', () => { - it('only cards queued before the Stop row: one queued after it is a new instruction', async () => { + it('only cards queued before the Stop event: one queued after it is a new instruction', async () => { let journal = await open() await queueDraft(journal, 'before') - await journal.appendQueuePauseMark('stopped', 0) + await userStop(journal) await queueDraft(journal, 'after') expect(held(journal)).toEqual([ ['before', true], @@ -290,7 +302,7 @@ describe('which cards a pause holds', () => { }, { messageId: 'steered', expect: 'waiting', settledByOp: null, hostInstance: HOST } ) - await journal.appendQueuePauseMark('stopped', 0) + await userStop(journal) await queueDraft(journal, 'after') await journal.resolveDispatch({ clientMessageId: 'send-now-1', @@ -307,7 +319,7 @@ describe('which cards a pause holds', () => { it("the queue's own consume refuses a newer card while an older one is held: nothing overtakes", async () => { const journal = await open() await queueDraft(journal, 'held') - await journal.appendQueuePauseMark('stopped', 0) + await userStop(journal) await queueDraft(journal, 'newer') await expect( journal.appendSubmission( @@ -397,7 +409,10 @@ describe('which cards the pauses in force hold', () => { function pausesOver(cards: readonly Card[], stopped = 5) { return deriveQueuePauses({ epoch: 'epoch-1', - marks: { stoppedSequence: stopped, resumedSequence: 0 }, + marks: { + userStop: stopped ? { sequence: stopped, event: { reason: 'user-stop', at: 0 } } : null, + resumedSequence: 0 + }, latestPersonTurnSequence: 0, cards, hostInstance: HOST, 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 ea20622ff0bb..25de034054a2 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 @@ -1,7 +1,7 @@ // The queue's pauses: a pure function of the journal fold and the cards, never a stored flag, so // nothing has to retire them. Each holds its own cards (`queuePauseHolding`). In force when: -// - 'stopped': the latest Stop row has no later Resume row, and no turn a person asked for was -// sent after it and accepted. A later Stop is simply the latest. +// - 'stopped': the latest person's Stop event (reason `user-stop`) has no later Resume row, and +// no turn a person asked for was sent after it and accepted. A later Stop is simply the latest. // - 'cleared': a card /clear carried into this conversation waits, and no person's turn or // Resume has happened here since. // - 'restarted': a waiting card was written by another host process, and no person's turn has @@ -10,13 +10,14 @@ // continuation, a launch prompt and the queue's own drain are `host` and never lift it. import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' -import type { JournalQueuePauseMark, JournalTombstoneRow } from './journal-row-schema' +import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' -/** The latest Stop and Resume rows, folded by the reducer; 0 when none. */ +/** The latest person's Stop event and Resume row, folded by the reducer. */ export type JournalQueuePauseMarks = { - stoppedSequence: number + userStop: { sequence: number; event: JournalStopEvent } | null + /** 0 when none. */ resumedSequence: number } @@ -35,26 +36,50 @@ type QueueCard = { } export function createJournalQueuePauseMarks(): JournalQueuePauseMarks { - return { stoppedSequence: 0, resumedSequence: 0 } + return { userStop: null, resumedSequence: 0 } } export function foldJournalQueuePauseMark( marks: JournalQueuePauseMarks, - row: JournalTombstoneRow & { queuePause: JournalQueuePauseMark } + row: JournalTombstoneRow ): void { - if (row.queuePause === 'stopped') { - marks.stoppedSequence = row.seq - } else if (row.queuePause === 'resumed') { + // Only a person's Stop pauses; any other reason, or a newer build's shape, is left alone. + if (row.stopEvent?.reason === 'user-stop') { + marks.userStop = { sequence: row.seq, event: row.stopEvent } + } else if (row.queueResume !== undefined) { marks.resumedSequence = row.seq } } -/** The latest Stop still pauses: nothing a person did since, and no Resume, ended it. */ +/** The latest person's Stop still pauses: nothing a person did since, and no Resume, ended it. */ export function journalQueueStopHolds( marks: JournalQueuePauseMarks, latestPersonTurnSequence: number ): boolean { - return marks.stoppedSequence > Math.max(latestPersonTurnSequence, marks.resumedSequence) + return ( + marks.userStop !== null && + marks.userStop.sequence > Math.max(latestPersonTurnSequence, marks.resumedSequence) + ) +} + +/** What a rewind's new epoch restates so its pauses read as they did: a lift of /clear's pause (a + * person's turn or a Resume happened), then the Stop still in force, in that order so the lift + * never ends the Stop. */ +export type JournalQueuePauseRestatement = { + lifted: boolean + liveStop: JournalStopEvent | null +} + +export function journalQueuePauseRestatement( + marks: JournalQueuePauseMarks, + latestPersonTurnSequence: number +): JournalQueuePauseRestatement { + return { + lifted: latestPersonTurnSequence > 0 || marks.resumedSequence > 0, + liveStop: journalQueueStopHolds(marks, latestPersonTurnSequence) + ? (marks.userStop?.event ?? null) + : null + } } /** Every pause in force, in the order a card held by several names its reason. */ @@ -70,8 +95,8 @@ export function deriveQueuePauses(input: { }): DerivedQueuePause[] { const { epoch, marks, latestPersonTurnSequence } = input const pauses: DerivedQueuePause[] = [] - if (journalQueueStopHolds(marks, latestPersonTurnSequence)) { - pauses.push({ reason: 'stopped', since: { epoch, sequence: marks.stoppedSequence } }) + if (marks.userStop && journalQueueStopHolds(marks, latestPersonTurnSequence)) { + pauses.push({ reason: 'stopped', since: { epoch, sequence: marks.userStop.sequence } }) } const waiting = input.cards.filter((card) => card.state === 'waiting') const carried = waiting.filter((card) => card.carriedFrom !== null) diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts index 55782a3756f6..42af5f830420 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts @@ -15,7 +15,7 @@ import type { import type { AgentSessionJournalBatch } from '../../../shared/agent-session-wire' import { findSequenceGap } from '../agent-session-journal/journal-cursor' import { - isJournalQueuePauseRow, + isJournalStopOrResumeRow, type JournalRow } from '../agent-session-journal/journal-row-schema' @@ -53,8 +53,8 @@ export function projectJournalBatch(input: { } continue } - if (isJournalQueuePauseRow(row)) { - // Host-only: the queue pause it feeds is published beside the list, not as an item. + if (isJournalStopOrResumeRow(row)) { + // Host-only: no item; the queue pause it feeds is published beside the list. continue } if (row.kind === 'item' || row.kind === 'tombstone') { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 3055bb2d8789..2c0e37b8d286 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -46,7 +46,7 @@ import { type MutationPlan } from './structured-agent-session-mutation-plans' import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send' -import { runStopWithQueuePause } from './structured-agent-session-queued-stop' +import { runRecordedStop } from './structured-agent-session-queued-stop' import type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, @@ -167,17 +167,21 @@ export function cancelStructuredAgentSessionTurn( ...params, stopChild: () => context.stopAgent(params.envelope.sessionId) }) + const stopEvent = { + reason: 'user-stop' as const, + caller: caller.callerKey, + ...(params.turnId !== undefined ? { turnId: params.turnId } : {}) + } return mutateStructuredAgentSession( context, caller, params.envelope, { ...plan, - // Stop's queue step, the same for every client: once the Stop takes effect - // the queue is paused. The cards stay published; nothing is withdrawn and no - // text ever rides the answer. + // 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. run: (ctx) => - runStopWithQueuePause(ctx, async (tookEffect) => { + runRecordedStop(ctx, stopEvent, async (tookEffect) => { // Stop withdraws every queued SUBMISSION first, whatever the start or the child is doing. const withdrawn = await ctx.journal.rejectQueuedSubmissions( ctx.fence, @@ -201,6 +205,7 @@ export function cancelStructuredAgentSessionTurn( } return { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } } } + // Before the interrupt, and before anything after it that ends the child. await tookEffect() return performCancel( { ...ctx, failureTextContext: structuredAgentSessionFailureWordsContext(record) }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index e2b90f05c403..fabc8d0611b5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -515,21 +515,21 @@ describe('Stop and Delete', () => { }) }) - it('a Stop whose row fails to write still interrupts; only the pause is lost, and it is reported', async () => { + it('a Stop whose event fails to write still interrupts; only the pause is lost, and it is reported', async () => { await workingSend() const queued = await send('kept by the stop', 'queue-if-active').result if (!queued.ok || !('queued' in queued.value)) { throw new Error('expected a queued receipt') } const record = vi - .spyOn(AgentSessionJournal.prototype, 'appendQueuePauseMark') + .spyOn(AgentSessionJournal.prototype, 'appendStopEvent') .mockRejectedValueOnce(new Error('disk full')) const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) try { expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) expect(rig.cancelTurn).toHaveBeenCalledTimes(1) expect(warned).toHaveBeenCalledWith( - expect.stringContaining("Stop's queue row"), + expect.stringContaining("Stop's event row"), expect.anything() ) } finally { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 39add2c2dd82..0760a55c264c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -389,8 +389,7 @@ describe('a card handed off after a restart', () => { structuredAgentSessionHostInstance() ) // With the Stop's pause gone, nothing else holds it: no restart happened since it was sent. - await journal.appendQueuePauseMark( - 'resumed', + await journal.appendQueueResume( structuredAgentSessionConversationFence(rig.store, HOST_TEST_SESSION) ) expect(structuredQueuePauses(journal)).toEqual([]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts index 7355655476c3..982d7cee9ed9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts @@ -1,6 +1,6 @@ // Whether the queue is paused, and why — derived from the journal and the cards -// (`queued-message-pause.ts`), never stored. Stop and Resume are journal rows; an -// explicit Resume lifts any pause. +// (`queued-message-pause.ts`), never stored. A Stop's event and a Resume are journal +// rows; an explicit Resume lifts any pause. import { randomUUID } from 'node:crypto' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -56,13 +56,13 @@ export async function adoptEndedRestartPause( /** Resume: a journal row that ends a Stop's or a /clear's pause, and adoption of a restart's * rows. Returns whether the queue was paused. */ export async function resumeStructuredQueue( - journal: Pick, + journal: Pick, fence: number ): Promise { if (structuredQueuePauses(journal).length === 0) { return false } - await journal.appendQueuePauseMark('resumed', fence) + await journal.appendQueueResume(fence) await journal.queuedMessages.adopt(hostInstance) return true } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index e3b8682bd9c8..aa2c93ba15e7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -1,4 +1,4 @@ -// Stop writes one journal row before it interrupts, and the queue's pause is derived from it: +// Stop writes one event row before it interrupts, and the queue's pause is derived from it: // through the real host, the cards queued before a Stop wait, a card queued after it sends // normally but never ahead of them, a withdrawn card comes back under it, a crash keeps it, it // never hides a restart's pause, and no stored pause is ever written. @@ -82,7 +82,7 @@ function pauseTables(): number { } } -describe("Stop's row", () => { +describe("Stop's event", () => { it('is written before the interrupt reaches the agent', async () => { await rig.workingSend() let pausedAtInterrupt: unknown = 'not interrupted' 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 c58c11041802..7c1640fb3108 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 @@ -1,11 +1,12 @@ -// Stop's pause on the queue. A Stop never withdraws a draft and no text ever -// travels back over the wire: it appends ONE journal row where it took effect, and -// the queue's pause is derived from that row (`queued-message-pause.ts`) until a -// turn a person asked for is sent after it, or they Resume. The cards stay -// published, and Send-now sends one card without lifting the pause for the rest -// until that card's turn starts. The row is bookkeeping: a failure is reported -// and never gates the interrupt. +// A Stop's event and the queue. A Stop never withdraws a draft and no text ever +// travels back over the wire: where it takes effect it appends ONE Stop event +// (`JournalStopEvent`), and the queue's pause is derived from it +// (`queued-message-pause.ts`) until a turn a person asked for is sent after it, or +// they Resume. The cards stay published, and Send-now sends one card without lifting +// the pause for the rest until that card's turn starts. The event is bookkeeping: a +// failure is reported and never gates the interrupt. +import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { isUnsettledQueuedMessage, @@ -20,27 +21,30 @@ export function unsettledQueuedMessages(journal: AgentSessionJournal): QueuedMes } /** - * Runs a Stop, which calls `tookEffect` where it takes effect — after it withdrew the - * queued sends, before it reaches the agent, or, reaching no agent, once it withdrew - * something. That writes the Stop's row, whatever the queue holds, so a card its - * interrupt later withdraws comes back to waiting under the pause. A Stop that throws - * before then changed nothing and wrote nothing. The drain cannot slip a card in - * between: the Stop runs on the drain's serialized lane, and the consume re-judges the - * pause in its own transaction. + * Runs a Stop, which calls `tookEffect` where it takes effect: after it withdrew the queued + * sends, and BEFORE the interrupt or anything that ends the child (the at-start stop, a running + * command's stop, a kill after the interrupt), or, reaching no agent, once it withdrew something. + * That writes the Stop's event, whatever the queue holds, so a card its interrupt later withdraws + * comes back to waiting under the pause, and whatever ends the child finds the event already + * written. A Stop that throws before then, or stops nothing, changed nothing and writes nothing. + * The drain cannot slip a card in between: the Stop runs on the drain's serialized lane. */ -export async function runStopWithQueuePause( +export async function runRecordedStop( ctx: AgentSessionTurnContext, + /** `turnId` absent: the turn running when the Stop takes effect, if any. */ + event: Omit, stop: (tookEffect: () => Promise) => Promise> ): Promise> { - return stop(() => - ctx.journal.appendQueuePauseMark('stopped', ctx.fence).then( + return stop(() => { + const turnId = event.turnId ?? ctx.journal.activeTurnId() ?? undefined + return ctx.journal.appendStopEvent({ ...event, ...(turnId ? { turnId } : {}) }, ctx.fence).then( () => undefined, (error: unknown) => { - console.warn("[agent-session] Stop's queue row skipped:", { + console.warn("[agent-session] Stop's event row skipped:", { sessionId: ctx.sessionId, error: error instanceof Error ? error.message : String(error) }) } ) - ) + }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts index 8b3078df8dfb..c145d6dc9c28 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts @@ -1,4 +1,4 @@ -/** Why a provider child ended. In memory only: never journaled, persisted or sent. */ +/** Why a provider child ended. In memory only, except the stop arms a Stop event journals. */ export type StructuredAgentSessionChildEndCause = | 'user-stop' /** The user closed this chat: its tab, its launch, or a `/clear` that replaces it. */ @@ -8,7 +8,8 @@ export type StructuredAgentSessionChildEndCause = | 'attach-failed' | 'evict' -/** Why the host asked a child to stop. The adapter carries it onto the `ended` it settles with. */ +/** Why the host asked a child to stop. The adapter carries it onto the `ended` it settles with, + * and a Stop event persists it (`JournalStopEvent.reason`), so never rename an arm. */ export type StructuredAgentSessionStopCause = Extract< StructuredAgentSessionChildEndCause, 'user-stop' | 'user-close' | 'host-stop' | 'evict' From e0087eef8f43b72d786de77b868e0b6cdc72c51e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:37:40 -0700 Subject: [PATCH 13/58] test(native-chat): a rewind keeps a lifted /clear pause lifted and restates the same Stop event --- .../queued-message-pause.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 5986787d25ad..89d83425f089 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -112,6 +112,21 @@ function held(journal: AgentSessionJournal): [string, boolean][] { .map((card) => [card.messageId, queuePauseHolding(pauses, card) !== undefined]) } +/** The Stop events stored in the live epoch, oldest first. */ +function stopEvents(): unknown[] { + const db = new Database(journalDatabasePath(root), { readonly: true }) + try { + return liveTestJournalRows(db, IDENTITY.sessionId).flatMap((stored) => { + const parsed = parseJournalRow(stored.rowJson) + return parsed.ok && parsed.row.kind === 'tombstone' && parsed.row.stopEvent + ? [parsed.row.stopEvent] + : [] + }) + } finally { + db.close() + } +} + /** Tables that could store a pause: none, the journal rows are the only record. */ function pauseTables(): number { const db = new Database(journalDatabasePath(root), { readonly: true }) @@ -268,6 +283,40 @@ describe("the queue's pause, derived from the journal", () => { await journal.replaceEpochItems('handle_forked', 0, []) expect(reason(journal)).toBeNull() }) + + it('the restated Stop is the same event: its reason, turn, caller and time', async () => { + const journal = await open() + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-7', caller: 'phone' }, 0) + const stopped = stopEvents() + await journal.replaceEpochItems('handle_forked', 0, []) + expect(stopEvents()).toEqual(stopped) + expect(stopped).toEqual([ + { reason: 'user-stop', turnId: 'turn-7', caller: 'phone', at: expect.any(Number) } + ]) + }) + + it.each([ + ["a person's turn", (journal: AgentSessionJournal) => turn(journal, 'typed', 'client')], + ['a Resume', (journal: AgentSessionJournal) => journal.appendQueueResume(0)] + ])("a /clear pause %s already lifted stays lifted across a rewind", async (_name, lift) => { + const journal = await open() + await queueDraft(journal, 'carried', 'source-session') + await lift(journal) + expect(reason(journal)).toBeNull() + await journal.replaceEpochItems('handle_forked', 0, []) + expect(reason(journal)).toBeNull() + }) + + it('a lifted /clear pause and a later Stop are both restated, the Stop still in force', async () => { + const journal = await open() + await queueDraft(journal, 'carried', 'source-session') + await turn(journal, 'typed', 'client') + await userStop(journal) + await journal.replaceEpochItems('handle_forked', 0, []) + expect(journal.queuedMessages.pauses(HOST).map((pause) => pause.reason)).toEqual(['stopped']) + await journal.appendQueueResume(0) + expect(reason(journal)).toBeNull() + }) }) describe('which cards a pause holds', () => { From e44789976eb48c8a70b2c6fc867e9e9762406f32 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:38:41 -0700 Subject: [PATCH 14/58] test(native-chat): pin that Stop and Resume rows never reach apps or count as history --- .../journal-stop-and-resume-rows.test.ts | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts new file mode 100644 index 000000000000..a65dc4b6cf60 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts @@ -0,0 +1,114 @@ +// A Stop's event and a Resume ride tombstones, but are no history: an app never receives them, +// and they never count as the content that makes a rebuilt or provider-backed epoch usable. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { projectJournalBatch } from '../agent-session-wire/agent-session-journal-batch' +import { createTrackedJournalOpener } from './journal-host-database-test-support' +import { startJournalRowFold } from './journal-open' +import { createJournalReducerState } from './journal-reducer' +import { buildJournalItemRow, journalRowBase } from './journal-row-builders' +import { + serializeJournalRow, + type AgentJournalEpochReason, + type JournalRow +} from './journal-row-schema' +import { buildJournalQueueResumeRow, buildJournalStopEventRow } from './journal-stop-and-resume-rows' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-s', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} +const EPOCH = 'epoch-1' + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-stop-event-rows-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +/** One epoch's rows, folded as an open folds them. */ +function fold(rows: readonly JournalRow[], repairedFrom: number | null = null) { + const folding = startJournalRowFold({ sessionId: IDENTITY.sessionId, epoch: EPOCH, repairedFrom }) + for (const row of rows) { + folding.add({ seq: row.seq, rowJson: serializeJournalRow(row) }) + } + return folding.finish() +} + +/** An epoch row, then `after` built at the next sequences. */ +function epochWith( + reason: AgentJournalEpochReason, + after: readonly ('stop' | 'resume' | 'item')[] +): JournalRow[] { + const state = createJournalReducerState(IDENTITY.sessionId, EPOCH) + const rows: JournalRow[] = [ + { kind: 'epoch', reason, providerHandle: IDENTITY.providerHandle, ...journalRowBase(EPOCH, 1, 1, 1) } + ] + for (const kind of after) { + const place = { state, seq: rows.length + 1, fence: 1, ts: rows.length + 1 } + rows.push( + kind === 'stop' + ? buildJournalStopEventRow({ ...place, event: { reason: 'user-stop', at: place.ts } }) + : kind === 'resume' + ? buildJournalQueueResumeRow(place) + : buildJournalItemRow({ + ...place, + identity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + body: { kind: 'status', text: 'history' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + } + return rows +} + +describe("a Stop's event and a Resume", () => { + it('never reach an app: the batch projection skips them', async () => { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'status', text: 'history' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const cursor = journal.cursor() + await journal.appendStopEvent({ reason: 'user-stop' }, 1) + await journal.appendQueueResume(1) + const since = journal.readSince(cursor) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + expect(since.rows).toHaveLength(2) + const projected = projectJournalBatch({ + rows: since.rows, + snapshot: journal.snapshot(), + afterSequence: cursor.sequence + }) + expect(projected).toMatchObject({ ok: true, batch: { items: [], removedItemIds: [] } }) + }) + + it('are no provider history: an unreconcilable epoch holding only them still reads corrupt', () => { + expect(fold(epochWith('unreconcilable_prefix', ['stop', 'resume'])).corrupt).toBe(true) + expect(fold(epochWith('unreconcilable_prefix', ['stop', 'item'])).corrupt).toBe(false) + }) + + it('are no rebuilt history: a pending repair followed only by them still reads corrupt', () => { + expect(fold(epochWith('handle_forked', ['stop', 'resume']), 2).corrupt).toBe(true) + expect(fold(epochWith('handle_forked', ['stop', 'item']), 2).corrupt).toBe(false) + }) +}) From 60500d8a0478a00d887f9d63ebf0610fec22a5f6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:39:02 -0700 Subject: [PATCH 15/58] test(native-chat): only a person's Stop event pauses the queue --- .../queued-message-pause.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 89d83425f089..5fa65441fb98 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -284,6 +284,18 @@ describe("the queue's pause, derived from the journal", () => { expect(reason(journal)).toBeNull() }) + it("only a person's Stop pauses: a host's, a close's or an unknown reason holds nothing", async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + for (const reason of ['user-close', 'host-stop', 'evict'] as const) { + await journal.appendStopEvent({ reason }, 0) + } + // A newer build's reason this one does not know. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: deliberately outside the type, as a newer build could write it. + await journal.appendStopEvent({ reason: 'future-reason' as 'evict' }, 0) + expect(reason(journal)).toBeNull() + }) + it('the restated Stop is the same event: its reason, turn, caller and time', async () => { const journal = await open() await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-7', caller: 'phone' }, 0) From 839f6f47664490db13206a1f60db9d2e81f90a92 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:40:36 -0700 Subject: [PATCH 16/58] test(native-chat): pin that a Stop's event precedes the interrupt and the at-start stop Through the real host: the event names the turn and who asked and is in the journal when the interrupt reaches the agent; at an agent still starting it is there before the start is ended and holds a card queued before it; an idle Stop writes one only when it withdrew a send; and the queue's claim re-judges a pause that landed after its pick. --- ...session-queued-message-rig.test-fixture.ts | 13 +- ...tructured-agent-session-stop-event.test.ts | 141 ++++++++++++++++++ 2 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index 9cbef23a08e4..c4fc82f5cc99 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -36,8 +36,10 @@ export function eventually(assertion: () => void | Promise): Promise export type QueuedMessageTestRig = Awaited> /** `restartable`: a child started for a chat whose chain already names a thread resumes it, so a - * chat whose child closed or died can start another. */ -export async function createQueuedMessageTestRig(options: { restartable?: true } = {}) { + * chat whose child closed or died can start another. `starting`: every child stays starting. */ +export async function createQueuedMessageTestRig( + options: { restartable?: true; starting?: true } = {} +) { const root = await mkdtemp(join(tmpdir(), 'orca-queued-messages-')) resetHostTestOperationIds() // Admitted: the message is written and unanswered, so the session owes work @@ -56,6 +58,9 @@ export async function createQueuedMessageTestRig(options: { restartable?: true } const cancelTurn: Mock = vi.fn(async () => ({ cancelled: true })) + const closeSession: Mock> = vi.fn( + async () => true + ) let events: StructuredAgentSessionEventSink | undefined const store = await openTestAgentSessionRecordStore(root) const makeHost = () => @@ -75,6 +80,7 @@ export async function createQueuedMessageTestRig(options: { restartable?: true } spawnToken }, acquisitionGeneration: 'generation-1', + ...(options.starting ? { providerChildPhase: 'starting' as const } : {}), link: { linkId: `link-${fence}`, handle: { provider: 'codex' as const, threadId: THREAD }, @@ -86,7 +92,7 @@ export async function createQueuedMessageTestRig(options: { restartable?: true } }, dispatch, awaitStarted, - closeSession: vi.fn(async () => true), + closeSession, releaseAcquisition: vi.fn(async () => true), compact, cancelTurn, @@ -293,6 +299,7 @@ export async function createQueuedMessageTestRig(options: { restartable?: true } }, dispatch, cancelTurn, + closeSession, awaitStarted, compact, finishCompact, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts new file mode 100644 index 000000000000..2023bff82952 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts @@ -0,0 +1,141 @@ +// A Stop's event, through the real host: written in the Stop's serialized step once it takes +// effect, before the interrupt and before anything that ends the child, naming the turn and who +// asked; never by a Stop that stopped nothing. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' +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' + +let rig: QueuedMessageTestRig + +afterEach(() => rig.dispose()) + +function journal() { + const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!open) { + throw new Error('expected the conversation open') + } + return open +} + +/** Every Stop event in the live epoch, oldest first. */ +function stopEvents(): JournalStopEvent[] { + const since = journal().readSince({ epoch: journal().epoch, sequence: 0 }) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + return since.rows.flatMap((row) => + row.kind === 'tombstone' && row.stopEvent ? [row.stopEvent] : [] + ) +} + +async function queuedDraft(text: string): Promise { + const queued = await rig.send(text, 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error(`expected a queued receipt: ${JSON.stringify(queued)}`) + } + return queued.value.queued.messageId +} + +/** Holds every start until the returned release. */ +function holdStart(): () => void { + let release: () => void = () => undefined + rig.awaitStarted.mockImplementation( + () => new Promise((resolve) => (release = () => resolve(undefined))) + ) + return () => release() +} + +describe("a Stop's event", () => { + it('reaches the journal before the interrupt, naming the turn it stopped and who asked', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + let atInterrupt: JournalStopEvent[] = [] + rig.cancelTurn.mockImplementationOnce(async () => { + atInterrupt = stopEvents() + return { cancelled: true } + }) + const fields = { turnId: 'turn-named' } + const stopped = await rig.host.cancel(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.cancel', hostTestOperationId()), + ...fields + }) + expect(stopped).toMatchObject({ ok: true }) + expect(rig.cancelTurn).toHaveBeenCalledTimes(1) + expect(atInterrupt).toEqual([ + { + reason: 'user-stop', + turnId: 'turn-named', + caller: QUEUED_RIG_CALLER.callerKey, + at: expect.any(Number) + } + ]) + }) + + it('at an agent still starting, reaches the journal before the start is ended, and holds a card queued before it', async () => { + rig = await createQueuedMessageTestRig({ starting: true, restartable: true }) + const release = holdStart() + rig.send('work on this') + const held = await queuedDraft('queued while it starts') + let atEnd: JournalStopEvent[] | undefined + rig.closeSession.mockImplementationOnce(async () => { + atEnd = stopEvents() + return true + }) + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + release() + expect(rig.closeSession).toHaveBeenCalledTimes(1) + expect(atEnd).toEqual([ + { reason: 'user-stop', caller: QUEUED_RIG_CALLER.callerKey, at: expect.any(Number) } + ]) + expect(rig.cancelTurn).not.toHaveBeenCalled() + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(held)).toBeUndefined() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + }) + + it('is written by an idle Stop only when it withdrew a send', async () => { + rig = await createQueuedMessageTestRig() + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(stopEvents()).toEqual([]) + const release = holdStart() + const waiting = rig.send('waits for the start') + await eventually(async () => expect(await rig.submission(waiting.id)).toBeDefined()) + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + release() + expect(rig.cancelTurn).not.toHaveBeenCalled() + expect(stopEvents()).toEqual([ + { reason: 'user-stop', caller: QUEUED_RIG_CALLER.callerKey, at: expect.any(Number) } + ]) + }) + + it("holds a card when it lands between the queue's pick and its claim", async () => { + rig = await createQueuedMessageTestRig() + const working = await rig.workingSend() + const draftId = await queuedDraft('picked by the drain') + const open = journal() + const appendSubmission = open.appendSubmission.bind(open) + let injected = false + // Stop and the drain share one serialized lane, so this interleaving is forced: a pause + // written after the drain chose the card must still hold it in the claim's transaction. + vi.spyOn(open, 'appendSubmission').mockImplementation(async (input, consume) => { + if (consume?.yieldsToPause && !injected) { + injected = true + await open.appendStopEvent({ reason: 'user-stop' }, input.fence) + } + return appendSubmission(input, consume) + }) + await rig.settleAccepted(working, 'working') + await eventually(() => expect(injected).toBe(true)) + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(draftId)).toBeUndefined() + expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + }) +}) From d5b48ef269a2b296906ff06a51e9dab26459cbf2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:42:15 -0700 Subject: [PATCH 17/58] test(native-chat): a card held at a starting agent is checked before the Stop's timing Also says precisely what the claim's in-transaction pause check defends against: the Stop and the drain share one serialized lane. --- .../agent-session-journal/journal-queued-messages.ts | 5 +++-- .../structured-agent-session-stop-event.test.ts | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index a565c007b017..fd50b974aada 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -237,8 +237,9 @@ export class JournalQueuedMessages { throw new AgentSessionJournalError('journal_closed', 'consume crossed database handles') } if (input.yieldsToPause) { - // Judged here, on the same queue as every journal append, by the drain's own rule, so a - // Stop row that landed after the drain chose this card still holds it. + // Judged again here, by the drain's own rule. Today a Stop cannot land between the drain's + // pick and this claim (both run on the session's serialized lane, held across the send), so + // this guards any pause-relevant row written off that lane from overtaking a held card. const cards = listQueuedMessages(db, this.deps.sessionId) const pauses = this.derivePauses(cards, input.yieldsToPause.hostInstance) if (nextSendableQueuedCard(pauses, cards)?.messageId !== input.messageId) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts index 2023bff82952..b38a3a45af68 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts @@ -90,14 +90,14 @@ describe("a Stop's event", () => { }) expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) release() + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(held)).toBeUndefined() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(rig.cancelTurn).not.toHaveBeenCalled() expect(rig.closeSession).toHaveBeenCalledTimes(1) expect(atEnd).toEqual([ { reason: 'user-stop', caller: QUEUED_RIG_CALLER.callerKey, at: expect.any(Number) } ]) - expect(rig.cancelTurn).not.toHaveBeenCalled() - await new Promise((resolve) => setTimeout(resolve, 250)) - expect(await rig.handoff(held)).toBeUndefined() - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) }) it('is written by an idle Stop only when it withdrew a send', async () => { @@ -125,7 +125,7 @@ describe("a Stop's event", () => { // Stop and the drain share one serialized lane, so this interleaving is forced: a pause // written after the drain chose the card must still hold it in the claim's transaction. vi.spyOn(open, 'appendSubmission').mockImplementation(async (input, consume) => { - if (consume?.yieldsToPause && !injected) { + if (input.origin === 'host' && consume?.messageId === draftId && !injected) { injected = true await open.appendStopEvent({ reason: 'user-stop' }, input.fence) } From 5689a9d0708eeae78342f2d6eae1fa177e02fc58 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:43:37 -0700 Subject: [PATCH 18/58] test(native-chat): a released build keeps and folds a journal holding Stop events Replays this build's rows from the released build's own journal database: every row is kept, the history after the Stop still folds, and an older client is sent only removed ids no item uses. --- ...-session-stop-event-downgrade.unit.test.ts | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts new file mode 100644 index 000000000000..35927a52bbd0 --- /dev/null +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -0,0 +1,124 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, test } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentSessionJournalIdentity +} from '../../../src/shared/agent-session-journal-types' +import { createTrackedJournalOpener } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +// A release that knows neither the Stop event nor the Resume marker: an unknown row kind would +// make it delete the journal from that row on, so both ride a tombstone it already reads. +const BASELINE_REF = 'v1.4.218' +const JOURNAL = 'src/main/native-chat/agent-session-journal' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-downgrade', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +type OldReplay = { + state: { items: Map } + readOnly: boolean + corrupt: boolean + malformedRows: number + truncateFrom?: number +} + +test("an older build keeps every row around a Stop's event and a Resume, and folds the rows after them", async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-stop-event-downgrade-')) + const journals = createTrackedJournalOpener() + try { + // This build: history, a person's Stop, a Resume, then more history. + const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + const append = (ordinal: number, text: string) => + journal.appendItem( + item(ordinal), + { kind: 'status', text }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await append(0, 'before the Stop') + const beforeMarks = journal.cursor() + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 1) + await journal.appendQueueResume(1) + const afterMarks = journal.cursor() + await append(1, 'after the Stop') + const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + const rows: JournalRow[] = since.rows + + // The older build, after a downgrade, replays the same rows from its own database. + const checkout = await materializeReleaseCheckout(BASELINE_REF) + const [database, table, open, reducer, batch] = await Promise.all( + [ + `${JOURNAL}/journal-database.ts`, + `${JOURNAL}/journal-row-table.ts`, + `${JOURNAL}/journal-open.ts`, + `${JOURNAL}/journal-reducer.ts`, + 'src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts' + ].map((path) => importReleaseCheckoutModule(checkout, path)) + ) + /* oxlint-disable typescript/consistent-type-assertions -- SAFETY: the pinned release exports these functions with the signatures called below; a missing or changed one fails the test. */ + const openJournalDatabase = database.openJournalDatabase as (path: string) => { + db: { close: () => void } + } + const upsertJournalSessionRow = table.upsertJournalSessionRow as ( + ...args: [unknown, string, string, number] + ) => void + const insertJournalRow = table.insertJournalRow as (...args: [unknown, string, JournalRow]) => void + const replayJournal = open.replayJournal as ( + ...args: [unknown, boolean, string] + ) => OldReplay | null + const renderJournalState = reducer.renderJournalState as (state: unknown) => unknown + const projectJournalBatch = batch.projectJournalBatch as (input: { + rows: readonly JournalRow[] + snapshot: unknown + afterSequence: number + }) => { ok: boolean; batch?: { items: unknown[]; removedItemIds: string[] } } + /* oxlint-enable typescript/consistent-type-assertions */ + + const { db } = openJournalDatabase(join(directory, 'older-build-journal.sqlite')) + try { + upsertJournalSessionRow(db, IDENTITY.sessionId, journal.epoch, 1) + for (const row of rows) { + insertJournalRow(db, IDENTITY.sessionId, row) + } + const replayed = replayJournal(db, false, IDENTITY.sessionId) + expect(replayed).toMatchObject({ readOnly: false, corrupt: false, malformedRows: 0 }) + expect(replayed?.truncateFrom).toBeUndefined() + expect([...(replayed?.state.items.keys() ?? [])]).toHaveLength(2) + + // An older client is sent only ids no item uses, removed. + const projected = projectJournalBatch({ + rows: rows.filter( + (row) => row.seq > beforeMarks.sequence && row.seq <= afterMarks.sequence + ), + snapshot: renderJournalState(replayed?.state), + afterSequence: beforeMarks.sequence + }) + expect(projected.ok).toBe(true) + expect(projected.batch?.items).toEqual([]) + expect(projected.batch?.removedItemIds).toHaveLength(2) + const liveIds = new Set(journal.snapshot().items.map((entry) => entry.itemId)) + expect(projected.batch?.removedItemIds.some((id) => liveIds.has(id))).toBe(false) + } finally { + db.close() + } + } finally { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) + } +}) From 8c5523e1392dc0bef4ad05d2ca5fd83cc8a54cd6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:43:47 -0700 Subject: [PATCH 19/58] style(native-chat): format the Stop event changes --- .../journal-epoch-replacement.ts | 5 ++++- .../agent-session-journal/journal-row-schema.ts | 4 +--- .../journal-stop-and-resume-rows.test.ts | 12 ++++++++++-- .../agent-session-journal/journal-store.ts | 5 +---- .../queued-message-pause.test.ts | 2 +- .../agent-session-stop-event-downgrade.unit.test.ts | 4 +++- 6 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index 3f0c7a1e7796..70efc8912837 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -16,7 +16,10 @@ import type { JournalLoad } from './journal-open' import { clearJournalRepairMarker } from './journal-repair-marker' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { buildJournalItemRow, journalRowBase } from './journal-row-builders' -import { buildJournalQueueResumeRow, buildJournalStopEventRow } from './journal-stop-and-resume-rows' +import { + buildJournalQueueResumeRow, + buildJournalStopEventRow +} from './journal-stop-and-resume-rows' import type { JournalQueuePauseRestatement } from './queued-message-pause' import { deleteJournalEpochRows, diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index ddda8809689c..df04dd1e22eb 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -96,9 +96,7 @@ export type JournalStopEvent = { /** A Stop's event or a Resume. Any value counts, so a newer build's mark never removes an item. */ export function isJournalStopOrResumeRow(row: JournalRow): row is JournalTombstoneRow { - return ( - row.kind === 'tombstone' && (row.stopEvent !== undefined || row.queueResume !== undefined) - ) + return row.kind === 'tombstone' && (row.stopEvent !== undefined || row.queueResume !== undefined) } /** The write-ahead row. Durable BEFORE the adapter dispatches anything; it diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts index a65dc4b6cf60..8eee42a70d03 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts @@ -19,7 +19,10 @@ import { type AgentJournalEpochReason, type JournalRow } from './journal-row-schema' -import { buildJournalQueueResumeRow, buildJournalStopEventRow } from './journal-stop-and-resume-rows' +import { + buildJournalQueueResumeRow, + buildJournalStopEventRow +} from './journal-stop-and-resume-rows' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-s', @@ -58,7 +61,12 @@ function epochWith( ): JournalRow[] { const state = createJournalReducerState(IDENTITY.sessionId, EPOCH) const rows: JournalRow[] = [ - { kind: 'epoch', reason, providerHandle: IDENTITY.providerHandle, ...journalRowBase(EPOCH, 1, 1, 1) } + { + kind: 'epoch', + reason, + providerHandle: IDENTITY.providerHandle, + ...journalRowBase(EPOCH, 1, 1, 1) + } ] for (const kind of after) { const place = { state, seq: rows.length + 1, fence: 1, ts: rows.length + 1 } 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 2e37ca7692b8..08113e3bc7db 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -312,10 +312,7 @@ export class AgentSessionJournal { } /** A Stop that took effect, timed by its row (`JournalStopEvent`). */ - appendStopEvent( - event: Omit, - fence: number - ): Promise { + appendStopEvent(event: Omit, fence: number): Promise { return this.rowWriter.append(journalStopEventRowBuilder(() => this.state, event, fence)) } diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 5fa65441fb98..6808a5893c30 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -310,7 +310,7 @@ describe("the queue's pause, derived from the journal", () => { it.each([ ["a person's turn", (journal: AgentSessionJournal) => turn(journal, 'typed', 'client')], ['a Resume', (journal: AgentSessionJournal) => journal.appendQueueResume(0)] - ])("a /clear pause %s already lifted stays lifted across a rewind", async (_name, lift) => { + ])('a /clear pause %s already lifted stays lifted across a rewind', async (_name, lift) => { const journal = await open() await queueDraft(journal, 'carried', 'source-session') await lift(journal) diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index 35927a52bbd0..18dc1a32b9a5 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -78,7 +78,9 @@ test("an older build keeps every row around a Stop's event and a Resume, and fol const upsertJournalSessionRow = table.upsertJournalSessionRow as ( ...args: [unknown, string, string, number] ) => void - const insertJournalRow = table.insertJournalRow as (...args: [unknown, string, JournalRow]) => void + const insertJournalRow = table.insertJournalRow as ( + ...args: [unknown, string, JournalRow] + ) => void const replayJournal = open.replayJournal as ( ...args: [unknown, boolean, string] ) => OldReplay | null From 5c38ea648ac2fd275df627ec15d92da6d31f9143 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:44:23 -0700 Subject: [PATCH 20/58] test(native-chat): type the released build's exports through one checked helper --- ...-session-stop-event-downgrade.unit.test.ts | 55 ++++++++++++------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index 18dc1a32b9a5..c4a5761c367e 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -28,6 +28,16 @@ function item(ordinal: number): AgentJournalItemIdentity { return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } } +/** A function the pinned release exports, typed as the caller calls it. */ +function releaseExport(module: Record, name: string): T { + const value = module[name] + if (typeof value !== 'function') { + throw new Error(`the pinned release exports no ${name}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a function the pinned release exports; each caller names the signature it calls, and a changed one fails the test. + return value as T +} + type OldReplay = { state: { items: Map } readOnly: boolean @@ -71,26 +81,31 @@ test("an older build keeps every row around a Stop's event and a Resume, and fol 'src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts' ].map((path) => importReleaseCheckoutModule(checkout, path)) ) - /* oxlint-disable typescript/consistent-type-assertions -- SAFETY: the pinned release exports these functions with the signatures called below; a missing or changed one fails the test. */ - const openJournalDatabase = database.openJournalDatabase as (path: string) => { - db: { close: () => void } - } - const upsertJournalSessionRow = table.upsertJournalSessionRow as ( - ...args: [unknown, string, string, number] - ) => void - const insertJournalRow = table.insertJournalRow as ( - ...args: [unknown, string, JournalRow] - ) => void - const replayJournal = open.replayJournal as ( - ...args: [unknown, boolean, string] - ) => OldReplay | null - const renderJournalState = reducer.renderJournalState as (state: unknown) => unknown - const projectJournalBatch = batch.projectJournalBatch as (input: { - rows: readonly JournalRow[] - snapshot: unknown - afterSequence: number - }) => { ok: boolean; batch?: { items: unknown[]; removedItemIds: string[] } } - /* oxlint-enable typescript/consistent-type-assertions */ + const openJournalDatabase = releaseExport<(path: string) => { db: { close: () => void } }>( + database, + 'openJournalDatabase' + ) + const upsertJournalSessionRow = releaseExport< + (...args: [unknown, string, string, number]) => void + >(table, 'upsertJournalSessionRow') + const insertJournalRow = releaseExport<(...args: [unknown, string, JournalRow]) => void>( + table, + 'insertJournalRow' + ) + const replayJournal = releaseExport<(...args: [unknown, boolean, string]) => OldReplay | null>( + open, + 'replayJournal' + ) + const renderJournalState = releaseExport<(state: unknown) => unknown>( + reducer, + 'renderJournalState' + ) + const projectJournalBatch = releaseExport< + (input: { rows: readonly JournalRow[]; snapshot: unknown; afterSequence: number }) => { + ok: boolean + batch?: { items: unknown[]; removedItemIds: string[] } + } + >(batch, 'projectJournalBatch') const { db } = openJournalDatabase(join(directory, 'older-build-journal.sqlite')) try { From 26e9419f8bf957bf6576bae01337997cee9dca1f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:54:55 -0700 Subject: [PATCH 21/58] fix(native-chat): the Stop/Resume row guard narrows to those tombstones only --- .../agent-session-journal/journal-row-schema.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index df04dd1e22eb..14d36b4a1926 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -95,7 +95,14 @@ export type JournalStopEvent = { } /** A Stop's event or a Resume. Any value counts, so a newer build's mark never removes an item. */ -export function isJournalStopOrResumeRow(row: JournalRow): row is JournalTombstoneRow { +/** A tombstone that carries a Stop event or a Resume mark instead of removing an item. */ +export type JournalStopOrResumeRow = JournalTombstoneRow & + ( + | { stopEvent: NonNullable } + | { queueResume: NonNullable } + ) + +export function isJournalStopOrResumeRow(row: JournalRow): row is JournalStopOrResumeRow { return row.kind === 'tombstone' && (row.stopEvent !== undefined || row.queueResume !== undefined) } From 806765bb1218741de6bbed3128d0c38748354843 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:19:21 -0700 Subject: [PATCH 22/58] test(native-chat): run the Stop-event downgrade test in CI, and cover a writable downgrade The Stop-event downgrade test ran in no CI lane: unit shards exclude the cross-version folder, and the cross-version lane runs a fixed file list that did not name it. It is now on that list. Its only case replayed the rows into a release's own fresh database, because that release cannot open the current host database. A second case opens the journal this build wrote with a main build that shares the database: it opens writable, keeps every row, appends, and this build then reopens it with the person's Stop still pausing the queue. --- .github/workflows/pr.yml | 1 + ...-session-stop-event-downgrade.unit.test.ts | 85 ++++++++++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c2b35b7bce1f..76d17ce616df 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -862,6 +862,7 @@ jobs: tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts + tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts managed_hook_node18: diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index c4a5761c367e..0a9b5ea5f35d 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -7,7 +7,12 @@ import { type AgentJournalItemIdentity, type AgentSessionJournalIdentity } from '../../../src/shared/agent-session-journal-types' -import { createTrackedJournalOpener } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import Database from '../../../src/main/sqlite/sync-database' +import { journalDatabasePath } from '../../../src/main/native-chat/agent-session-journal/journal-host-database' +import { + createTrackedJournalOpener, + liveTestJournalRows +} from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' @@ -15,6 +20,9 @@ import { importReleaseCheckoutModule, materializeReleaseCheckout } from './relea // make it delete the journal from that row on, so both ride a tombstone it already reads. const BASELINE_REF = 'v1.4.218' const JOURNAL = 'src/main/native-chat/agent-session-journal' +// A main build that shares this one's host database and schema version, so a downgrade to it opens +// the journal writable. No release tag has that database yet; move to the first one that does. +const WRITABLE_BASELINE_REF = '3727100cc9dbcea6201f8a3e506676a3c4b53b18' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-downgrade', @@ -139,3 +147,78 @@ test("an older build keeps every row around a Stop's event and a Resume, and fol rmSync(directory, { recursive: true, force: true }) } }) + +type OlderJournal = { + isReadOnly: boolean + cursor: () => { epoch: string; sequence: number } + snapshot: () => { items: { itemId: string }[] } + appendItem: (...args: [AgentJournalItemIdentity, unknown, unknown]) => Promise +} + +type OlderOpener = { + open: (options: { + identity: AgentSessionJournalIdentity + stateDirectory: string + }) => Promise + closeAll: () => Promise +} + +function storedRows(directory: string): string[] { + const db = new Database(journalDatabasePath(directory), { readonly: true }) + try { + return liveTestJournalRows(db, IDENTITY.sessionId).map((row) => row.rowJson) + } finally { + db.close() + } +} + +test("an older build opens this build's journal writable and appends to it; the pause survives the round trip", async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-stop-event-writable-downgrade-')) + const journals = createTrackedJournalOpener() + const itemIds = (journal: Pick) => + journal.snapshot().items.map((entry) => entry.itemId) + try { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + await journal.appendItem(item(0), { kind: 'status', text: 'before the Stop' }, scope) + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 1) + await journal.appendItem(item(1), { kind: 'status', text: 'after the Stop' }, scope) + const wrote = { cursor: journal.cursor(), items: itemIds(journal) } + expect(journal.queuedMessages.pauses('host-a').map((pause) => pause.reason)).toEqual([ + 'stopped' + ]) + await journals.closeAll() + const rowsBefore = storedRows(directory) + + const checkout = await materializeReleaseCheckout(WRITABLE_BASELINE_REF) + const support = await importReleaseCheckoutModule( + checkout, + `${JOURNAL}/journal-host-database-test-support.ts` + ) + const older = releaseExport<() => OlderOpener>(support, 'createTrackedJournalOpener')() + try { + const downgraded = await older.open({ identity: IDENTITY, stateDirectory: directory }) + expect(downgraded.isReadOnly).toBe(false) + expect(downgraded.cursor()).toEqual(wrote.cursor) + expect(itemIds(downgraded)).toEqual(wrote.items) + await downgraded.appendItem(item(2), { kind: 'status', text: 'the older build' }, scope) + } finally { + await older.closeAll() + } + const rowsAfter = storedRows(directory) + expect(rowsAfter.slice(0, rowsBefore.length)).toEqual(rowsBefore) + expect(rowsAfter).toHaveLength(rowsBefore.length + 1) + + // Upgraded again: the older build's row folds, and the person's Stop still pauses the queue. + const upgraded = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + expect(upgraded.isReadOnly).toBe(false) + expect(upgraded.cursor().sequence).toBe(wrote.cursor.sequence + 1) + expect(itemIds(upgraded)).toEqual([...wrote.items, 'codex:thread-1:turn-1:2']) + expect(upgraded.queuedMessages.pauses('host-a').map((pause) => pause.reason)).toEqual([ + 'stopped' + ]) + } finally { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) + } +}, 120_000) From 8ec0fbf0e8ad9b8d931efac2ed2c0f74cd0b61f4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:25:56 -0700 Subject: [PATCH 23/58] fix(native-chat): a Stop that stops nothing new writes no Stop event A Stop reaching a running agent wrote a Stop event on every press. Two presses before the first interrupt landed wrote two events, so a card queued between them counted as before the latest Stop and was held, though a card queued after a Stop should send normally. A Stop naming a turn that had already ended, as a phone sends late, also wrote an event for a turn it never stopped. It now writes one only when it withdrew a queued send, or stops something no event records yet: not a turn the journal no longer runs, and not the live turn a Stop still in force already names, unless a card was handed over into it since, which this Stop's interrupt sends back and must hold. The interrupt and the "already finished" note are unchanged. A Stop at a starting agent still always writes. --- .../journal-queued-messages.ts | 10 +++- .../queued-message-pause.ts | 12 +++-- ...structured-agent-session-host-mutations.ts | 6 ++- .../structured-agent-session-queued-stop.ts | 34 +++++++++++++- ...tructured-agent-session-stop-event.test.ts | 46 +++++++++++++++++++ 5 files changed, 101 insertions(+), 7 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index fd50b974aada..e2fd2c7e5a15 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -18,8 +18,10 @@ import type { JournalSubmissionConsume } from './journal-store-contracts' import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' import { deriveQueuePauses, + journalUserStopInForce, nextSendableQueuedCard, - type DerivedQueuePause + type DerivedQueuePause, + type JournalQueuePauseMarks } from './queued-message-pause' import { consumeQueuedMessageInTransaction, @@ -144,6 +146,12 @@ export class JournalQueuedMessages { return this.derivePauses(this.list(), hostInstance) } + /** The person's Stop still pausing the queue, if any (`journalUserStopInForce`). */ + userStopInForce(): JournalQueuePauseMarks['userStop'] { + const state = this.deps.state() + return journalUserStopInForce(state.queuePauseMarks, state.latestPersonTurnSequence) + } + private derivePauses( cards: readonly QueuedMessageRow[], hostInstance: string 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 25de034054a2..18f5c2635f4d 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 @@ -62,6 +62,14 @@ export function journalQueueStopHolds( ) } +/** The latest person's Stop while it still pauses, with where it was written; else null. */ +export function journalUserStopInForce( + marks: JournalQueuePauseMarks, + latestPersonTurnSequence: number +): JournalQueuePauseMarks['userStop'] { + return journalQueueStopHolds(marks, latestPersonTurnSequence) ? marks.userStop : null +} + /** What a rewind's new epoch restates so its pauses read as they did: a lift of /clear's pause (a * person's turn or a Resume happened), then the Stop still in force, in that order so the lift * never ends the Stop. */ @@ -76,9 +84,7 @@ export function journalQueuePauseRestatement( ): JournalQueuePauseRestatement { return { lifted: latestPersonTurnSequence > 0 || marks.resumedSequence > 0, - liveStop: journalQueueStopHolds(marks, latestPersonTurnSequence) - ? (marks.userStop?.event ?? null) - : null + liveStop: journalUserStopInForce(marks, latestPersonTurnSequence)?.event ?? null } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 2c0e37b8d286..e85b0f615da0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -46,7 +46,7 @@ import { type MutationPlan } from './structured-agent-session-mutation-plans' import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send' -import { runRecordedStop } from './structured-agent-session-queued-stop' +import { runRecordedStop, stopReachesUnrecordedWork } from './structured-agent-session-queued-stop' import type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, @@ -206,7 +206,9 @@ export function cancelStructuredAgentSessionTurn( return { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } } } // Before the interrupt, and before anything after it that ends the child. - await tookEffect() + if (withdrawn.length > 0 || (await stopReachesUnrecordedWork(ctx, params.turnId))) { + await tookEffect() + } return performCancel( { ...ctx, failureTextContext: structuredAgentSessionFailureWordsContext(record) }, { 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 7c1640fb3108..d5a83d3680d4 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 @@ -13,6 +13,7 @@ import { type QueuedMessageRow } from '../agent-session-journal/queued-message-table' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' +import { isMainAgentWorkingOnceFlushed } from './structured-agent-session-turns-cancel' /** The one unsettled-card predicate /clear's carry and the budget share: * waiting or returned. Pending/unknown/accepted deliveries stay outside it. */ @@ -26,7 +27,8 @@ export function unsettledQueuedMessages(journal: AgentSessionJournal): QueuedMes * command's stop, a kill after the interrupt), or, reaching no agent, once it withdrew something. * That writes the Stop's event, whatever the queue holds, so a card its interrupt later withdraws * comes back to waiting under the pause, and whatever ends the child finds the event already - * written. A Stop that throws before then, or stops nothing, changed nothing and writes nothing. + * written. A Stop that throws before then, or stops nothing (`stopReachesUnrecordedWork`), changed + * nothing and writes nothing. * The drain cannot slip a card in between: the Stop runs on the drain's serialized lane. */ export async function runRecordedStop( @@ -48,3 +50,33 @@ export async function runRecordedStop( ) }) } + +/** + * 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 + * on the same turn with nothing handed over since: a card queued between the presses then sends + * normally, as after one Stop. + */ +export async function stopReachesUnrecordedWork( + ctx: Pick, + namedTurnId: string | undefined +): Promise { + const live = ctx.journal.activeTurnId() + // No turn published yet while the agent works: the named one may still be opening. + if ( + namedTurnId !== undefined && + namedTurnId !== live && + (live !== null || !(await isMainAgentWorkingOnceFlushed(ctx))) + ) { + return false + } + const inForce = ctx.journal.queuedMessages.userStopInForce() + return ( + inForce === null || + (inForce.event.turnId ?? null) !== live || + // A steer handed over after that Stop comes back to waiting, so this Stop must hold it. + ctx.journal + .pendingSubmissions() + .some((entry) => (entry.acceptedSequence ?? Infinity) > inForce.sequence) + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts index b38a3a45af68..d6f22bf7adf4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts @@ -3,6 +3,8 @@ // asked; never by a Stop that stopped nothing. import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' import { @@ -115,6 +117,50 @@ describe("a Stop's event", () => { ]) }) + it('a second press while the first interrupt lands writes nothing: a card queued between them sends normally', async () => { + rig = await createQueuedMessageTestRig() + const working = await rig.workingSend() + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + const between = await queuedDraft('queued between the presses') + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(stopEvents()).toHaveLength(1) + await rig.settleAccepted(working, 'stopped') + await eventually(async () => expect(await rig.handoff(between)).toBeDefined()) + }) + + it('a second press after a card was sent into the turn writes again, and holds that card', async () => { + rig = await createQueuedMessageTestRig() + const working = await rig.workingSend() + await rig.stop() + const steered = await queuedDraft('sent into the turn between the presses') + await rig.sendNow(steered) + await eventually(async () => expect((await rig.handoff(steered))?.handedOverAt).toBeDefined()) + await rig.stop() + expect(stopEvents()).toHaveLength(2) + await rig.host.settleLateDispatch({ + sessionId: HOST_TEST_SESSION, + clientMessageId: await rig.handoffId(steered), + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + }) + await rig.settleAccepted(working, 'stopped') + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.drafts()).toEqual([{ messageId: steered, state: 'waiting' }]) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + }) + + it('names a turn already over, as a late Stop from a phone does: writes nothing', async () => { + rig = await createQueuedMessageTestRig() + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false }) + const fields = { turnId: 'turn-already-over' } + const stopped = await rig.host.cancel(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.cancel', hostTestOperationId()), + ...fields + }) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(stopEvents()).toEqual([]) + }) + it("holds a card when it lands between the queue's pick and its claim", async () => { rig = await createQueuedMessageTestRig() const working = await rig.workingSend() From 722e9640a0701b8d02eb67499691dc5a303b3ea9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:25:56 -0700 Subject: [PATCH 24/58] test(native-chat): pin that a later host, eviction or close Stop never lifts a person's Stop --- .../queued-message-pause.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 6808a5893c30..d9d4185e9480 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -296,6 +296,17 @@ describe("the queue's pause, derived from the journal", () => { expect(reason(journal)).toBeNull() }) + it("a later Stop by the host, an eviction or a close never lifts a person's Stop", async () => { + const journal = await open() + await queueDraft(journal, 'held') + await journal.appendStopEvent({ reason: 'user-stop' }, 0) + for (const reason of ['host-stop', 'evict', 'user-close'] as const) { + await journal.appendStopEvent({ reason }, 0) + } + expect(reason(journal)).toBe('stopped') + expect(held(journal)).toEqual([['held', true]]) + }) + it('the restated Stop is the same event: its reason, turn, caller and time', async () => { const journal = await open() await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-7', caller: 'phone' }, 0) From 74adc162d771cd7579714c85f83a2e65e3c1fb81 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:25:56 -0700 Subject: [PATCH 25/58] chore(native-chat): put each Stop-row doc on its own declaration, and say only user-stop is journaled --- .../native-chat/agent-session-journal/journal-row-schema.ts | 2 +- .../agent-session-wire/structured-agent-session-stop-cause.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 14d36b4a1926..e65d5919b39b 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -94,7 +94,6 @@ export type JournalStopEvent = { caller?: string } -/** A Stop's event or a Resume. Any value counts, so a newer build's mark never removes an item. */ /** A tombstone that carries a Stop event or a Resume mark instead of removing an item. */ export type JournalStopOrResumeRow = JournalTombstoneRow & ( @@ -102,6 +101,7 @@ export type JournalStopOrResumeRow = JournalTombstoneRow & | { queueResume: NonNullable } ) +/** A Stop's event or a Resume. Any value counts, so a newer build's mark never removes an item. */ export function isJournalStopOrResumeRow(row: JournalRow): row is JournalStopOrResumeRow { return row.kind === 'tombstone' && (row.stopEvent !== undefined || row.queueResume !== undefined) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts index c145d6dc9c28..58a36546a046 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts @@ -1,4 +1,5 @@ -/** Why a provider child ended. In memory only, except the stop arms a Stop event journals. */ +/** Why a provider child ended. In memory only, except `user-stop`, the one arm a Stop event + * journals so far. */ export type StructuredAgentSessionChildEndCause = | 'user-stop' /** The user closed this chat: its tab, its launch, or a `/clear` that replaces it. */ From 4750645a5a71f2385da7763a455051233d6aeba1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:29:06 -0700 Subject: [PATCH 26/58] fix(native-chat): any later Stop event ends a person's Stop pause A person's Stop paused the queue until their next accepted turn or Resume, and a later Stop of another reason (the host stopping the agent, an eviction, a close) was ignored. Now the pause is the latest Stop event's: a later Stop of any reason ends a person's pause, and only a person's Stop pauses. The fold keeps the latest Stop event whatever its reason. An eviction of a resting chat writes no Stop event (a Stop that stops nothing writes nothing), so it cannot release held cards; a test pins that no event means no lift. --- .../journal-queued-messages.ts | 2 +- .../queued-message-pause.test.ts | 22 +++++++++---- .../queued-message-pause.ts | 32 ++++++++++--------- 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index e2fd2c7e5a15..b8fcafc0b6c0 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -147,7 +147,7 @@ export class JournalQueuedMessages { } /** The person's Stop still pausing the queue, if any (`journalUserStopInForce`). */ - userStopInForce(): JournalQueuePauseMarks['userStop'] { + userStopInForce(): JournalQueuePauseMarks['latestStop'] { const state = this.deps.state() return journalUserStopInForce(state.queuePauseMarks, state.latestPersonTurnSequence) } diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index d9d4185e9480..ebea1552f7dc 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -296,13 +296,23 @@ describe("the queue's pause, derived from the journal", () => { expect(reason(journal)).toBeNull() }) - it("a later Stop by the host, an eviction or a close never lifts a person's Stop", async () => { + it("a later Stop of any reason ends a person's Stop, without pausing itself", async () => { const journal = await open() - await queueDraft(journal, 'held') - await journal.appendStopEvent({ reason: 'user-stop' }, 0) - for (const reason of ['host-stop', 'evict', 'user-close'] as const) { - await journal.appendStopEvent({ reason }, 0) + await queueDraft(journal, 'draft-1') + for (const later of ['host-stop', 'evict', 'user-close'] as const) { + await userStop(journal) + expect(held(journal)).toEqual([['draft-1', true]]) + await journal.appendStopEvent({ reason: later }, 0) + expect(reason(journal)).toBeNull() + expect(held(journal)).toEqual([['draft-1', false]]) } + }) + + it("no Stop event after a person's Stop means no lift: an idle eviction writes none", async () => { + const journal = await open() + await queueDraft(journal, 'held') + await userStop(journal) + // An eviction writer must write only when it ends a running turn, or it would lift this pause. expect(reason(journal)).toBe('stopped') expect(held(journal)).toEqual([['held', true]]) }) @@ -482,7 +492,7 @@ describe('which cards the pauses in force hold', () => { return deriveQueuePauses({ epoch: 'epoch-1', marks: { - userStop: stopped ? { sequence: stopped, event: { reason: 'user-stop', at: 0 } } : null, + latestStop: stopped ? { sequence: stopped, event: { reason: 'user-stop', at: 0 } } : null, resumedSequence: 0 }, latestPersonTurnSequence: 0, 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 18f5c2635f4d..012b99aa203b 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 @@ -1,7 +1,8 @@ // The queue's pauses: a pure function of the journal fold and the cards, never a stored flag, so // nothing has to retire them. Each holds its own cards (`queuePauseHolding`). In force when: -// - 'stopped': the latest person's Stop event (reason `user-stop`) has no later Resume row, and -// no turn a person asked for was sent after it and accepted. A later Stop is simply the latest. +// - 'stopped': the latest Stop event is a person's (reason `user-stop`), with no later Resume row +// and no turn a person asked for sent after it and accepted. A later Stop of any reason +// supersedes it; only a person's pauses. // - 'cleared': a card /clear carried into this conversation waits, and no person's turn or // Resume has happened here since. // - 'restarted': a waiting card was written by another host process, and no person's turn has @@ -14,9 +15,9 @@ import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' -/** The latest person's Stop event and Resume row, folded by the reducer. */ +/** The latest Stop event, whatever its reason, and the latest Resume row, folded by the reducer. */ export type JournalQueuePauseMarks = { - userStop: { sequence: number; event: JournalStopEvent } | null + latestStop: { sequence: number; event: JournalStopEvent } | null /** 0 when none. */ resumedSequence: number } @@ -36,29 +37,29 @@ type QueueCard = { } export function createJournalQueuePauseMarks(): JournalQueuePauseMarks { - return { userStop: null, resumedSequence: 0 } + return { latestStop: null, resumedSequence: 0 } } export function foldJournalQueuePauseMark( marks: JournalQueuePauseMarks, row: JournalTombstoneRow ): void { - // Only a person's Stop pauses; any other reason, or a newer build's shape, is left alone. - if (row.stopEvent?.reason === 'user-stop') { - marks.userStop = { sequence: row.seq, event: row.stopEvent } + if (row.stopEvent !== undefined) { + marks.latestStop = { sequence: row.seq, event: row.stopEvent } } else if (row.queueResume !== undefined) { marks.resumedSequence = row.seq } } -/** The latest person's Stop still pauses: nothing a person did since, and no Resume, ended it. */ +/** A person's Stop still pauses: it is the latest Stop, and nothing a person did since, and no + * Resume, ended it. A later Stop for any other reason ends it without pausing itself. */ export function journalQueueStopHolds( marks: JournalQueuePauseMarks, latestPersonTurnSequence: number ): boolean { return ( - marks.userStop !== null && - marks.userStop.sequence > Math.max(latestPersonTurnSequence, marks.resumedSequence) + marks.latestStop?.event.reason === 'user-stop' && + marks.latestStop.sequence > Math.max(latestPersonTurnSequence, marks.resumedSequence) ) } @@ -66,8 +67,8 @@ export function journalQueueStopHolds( export function journalUserStopInForce( marks: JournalQueuePauseMarks, latestPersonTurnSequence: number -): JournalQueuePauseMarks['userStop'] { - return journalQueueStopHolds(marks, latestPersonTurnSequence) ? marks.userStop : null +): JournalQueuePauseMarks['latestStop'] { + return journalQueueStopHolds(marks, latestPersonTurnSequence) ? marks.latestStop : null } /** What a rewind's new epoch restates so its pauses read as they did: a lift of /clear's pause (a @@ -101,8 +102,9 @@ export function deriveQueuePauses(input: { }): DerivedQueuePause[] { const { epoch, marks, latestPersonTurnSequence } = input const pauses: DerivedQueuePause[] = [] - if (marks.userStop && journalQueueStopHolds(marks, latestPersonTurnSequence)) { - pauses.push({ reason: 'stopped', since: { epoch, sequence: marks.userStop.sequence } }) + const stop = journalUserStopInForce(marks, latestPersonTurnSequence) + if (stop) { + pauses.push({ reason: 'stopped', since: { epoch, sequence: stop.sequence } }) } const waiting = input.cards.filter((card) => card.state === 'waiting') const carried = waiting.filter((card) => card.carriedFrom !== null) From c0d96898dc848bf692af42cb4565ac4f8f9c1427 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:49:47 -0700 Subject: [PATCH 27/58] fix(native-chat): a second Stop press is a repeat even when the first came before the turn showed A Stop pressed before the agent's turn shows in the journal (before Claude's echo, or before Codex opens the turn) records no turn. A second press once the turn showed compared that missing turn with the live one, wrote a second Stop event, and held a card queued between the presses. A repeat is now judged by what was sent since the Stop in force: with nothing sent after it (a refused send aside), a Stop that named no turn, or named the live one, is repeated and writes nothing. Anything sent since and not refused, including a send whose fate is unknown, makes the new press write, since its interrupt may send that card back to waiting. Tests: the two-press case across the turn showing; a steer between the presses settled unknown; and a Stop naming a turn that ended while the next card is sent but shows no turn yet, which writes and holds that card. The fold test that claimed an eviction path is renamed. --- .../queued-message-pause.test.ts | 2 +- .../structured-agent-session-queued-stop.ts | 26 ++++--- ...tructured-agent-session-stop-event.test.ts | 75 +++++++++++++++++-- 3 files changed, 87 insertions(+), 16 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index ebea1552f7dc..7208755b4193 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -308,7 +308,7 @@ describe("the queue's pause, derived from the journal", () => { } }) - it("no Stop event after a person's Stop means no lift: an idle eviction writes none", async () => { + it("a person's Stop with no later Stop event is not lifted", async () => { const journal = await open() await queueDraft(journal, 'held') await userStop(journal) 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 d5a83d3680d4..72df6fe2ac6a 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 @@ -54,8 +54,8 @@ export async function runRecordedStop( /** * 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 - * on the same turn with nothing handed over since: a card queued between the presses then sends - * normally, as after one Stop. + * 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. */ export async function stopReachesUnrecordedWork( ctx: Pick, @@ -71,12 +71,18 @@ export async function stopReachesUnrecordedWork( return false } const inForce = ctx.journal.queuedMessages.userStopInForce() - return ( - inForce === null || - (inForce.event.turnId ?? null) !== live || - // A steer handed over after that Stop comes back to waiting, so this Stop must hold it. - ctx.journal - .pendingSubmissions() - .some((entry) => (entry.acceptedSequence ?? Infinity) > inForce.sequence) - ) + if (inForce === null) { + return true + } + // 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 + .submissions() + .some( + (entry) => + entry.dispatchState !== 'rejected' && + entry.acceptedSequence !== undefined && + entry.acceptedSequence > inForce.sequence + ) + return sentSince || (inForce.event.turnId !== undefined && inForce.event.turnId !== live) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts index d6f22bf7adf4..88c2e6d74363 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts @@ -5,6 +5,7 @@ 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 } from '../../../shared/agent-session-journal-types' import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' import { @@ -45,6 +46,24 @@ async function queuedDraft(text: string): Promise { return queued.value.queued.messageId } +/** The provider's turn row for the working send, which may land after a Stop. */ +async function turnRow(turnId: string, state: 'running' | 'interrupted'): Promise { + await journal().appendItem( + { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 999 }, + { kind: 'turn', turnId, state, startedAt: 1 }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +function withdraw(clientMessageId: string) { + return rig.host.settleLateDispatch({ + sessionId: HOST_TEST_SESSION, + clientMessageId, + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + }) +} + /** Holds every start until the returned release. */ function holdStart(): () => void { let release: () => void = () => undefined @@ -128,21 +147,46 @@ describe("a Stop's event", () => { await eventually(async () => expect(await rig.handoff(between)).toBeDefined()) }) - it('a second press after a card was sent into the turn writes again, and holds that card', async () => { + it('a second press once the turn shows, after a first before it did, writes nothing: a card queued between sends', async () => { rig = await createQueuedMessageTestRig() const working = await rig.workingSend() await rig.stop() + const between = await queuedDraft('queued between the presses') + await turnRow('turn-1', 'running') + await rig.stop() + expect(stopEvents()).toHaveLength(1) + await rig.settleAccepted(working, 'stopped') + await turnRow('turn-1', 'interrupted') + await eventually(async () => expect(await rig.handoff(between)).toBeDefined()) + }) + + it('a second press after a card sent into the turn settled unknown writes again', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + await rig.stop() const steered = await queuedDraft('sent into the turn between the presses') await rig.sendNow(steered) await eventually(async () => expect((await rig.handoff(steered))?.handedOverAt).toBeDefined()) - await rig.stop() - expect(stopEvents()).toHaveLength(2) await rig.host.settleLateDispatch({ sessionId: HOST_TEST_SESSION, clientMessageId: await rig.handoffId(steered), - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + state: 'unknown', + reason: 'the provider never answered' }) + await rig.stop() + expect(stopEvents()).toHaveLength(2) + }) + + it('a second press after a card was sent into the turn writes again, and holds that card', async () => { + rig = await createQueuedMessageTestRig() + const working = await rig.workingSend() + await rig.stop() + const steered = await queuedDraft('sent into the turn between the presses') + await rig.sendNow(steered) + await eventually(async () => expect((await rig.handoff(steered))?.handedOverAt).toBeDefined()) + await rig.stop() + expect(stopEvents()).toHaveLength(2) + await withdraw(await rig.handoffId(steered)) await rig.settleAccepted(working, 'stopped') await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.drafts()).toEqual([{ messageId: steered, state: 'waiting' }]) @@ -161,6 +205,27 @@ describe("a Stop's event", () => { expect(stopEvents()).toEqual([]) }) + it('names a turn that ended while the next card is sent but shows no turn yet: writes, and holds that card', async () => { + rig = await createQueuedMessageTestRig() + const working = await rig.workingSend() + await turnRow('turn-1', 'running') + const next = await queuedDraft('sent when turn-1 ends') + await rig.settleAccepted(working, 'working') + await turnRow('turn-1', 'interrupted') + await eventually(async () => expect((await rig.handoff(next))?.handedOverAt).toBeDefined()) + expect(journal().activeTurnId()).toBeNull() + const fields = { turnId: 'turn-1' } + await rig.host.cancel(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.cancel', hostTestOperationId()), + ...fields + }) + expect(stopEvents()).toHaveLength(1) + await withdraw(await rig.handoffId(next)) + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.drafts()).toEqual([{ messageId: next, state: 'waiting' }]) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + }) + it("holds a card when it lands between the queue's pick and its claim", async () => { rig = await createQueuedMessageTestRig() const working = await rig.workingSend() From 989f90bdf19edf5b0216dbb3a02678ab8d18a522 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:04:37 -0700 Subject: [PATCH 28/58] fix(native-chat): the queue's pause ignores a Stop or Resume row holding a value no build writes A Stop or Resume row's value is read from disk with no shape check, and the pause fold stored whatever it found. A stored `stopEvent: null` would then throw on every pause check for that chat: the queue's pick, its send, and every queue update to clients. No build writes such a row, so this is hardening. The fold now reads a Stop only when it is an object with a string reason and a finite time, and a Resume only when it is `true`. Anything else is ignored: it pauses nothing and ends nothing. The row is still not treated as malformed, which could cut the history short. --- .../queued-message-pause.test.ts | 51 ++++++++++++++++++- .../queued-message-pause.ts | 17 ++++++- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 7208755b4193..b8e45e0ef590 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -14,7 +14,8 @@ import Database from '../../sqlite/sync-database' import { journalDatabasePath } from './journal-host-database' import { createTrackedJournalOpener, - liveTestJournalRows + liveTestJournalRows, + updateTestJournalRowJson } from './journal-host-database-test-support' import { QueuedMessageNotConsumableError } from './journal-queued-messages' import { applyJournalRow, createJournalReducerState } from './journal-reducer' @@ -127,6 +128,23 @@ function stopEvents(): unknown[] { } } +/** Rewrites one key of each row at a sequence, as a corrupt write would leave it. */ +function rewriteRows(keys: Record): void { + const db = new Database(journalDatabasePath(root)) + try { + for (const stored of liveTestJournalRows(db, IDENTITY.sessionId)) { + const rewrite = keys[stored.seq] + if (rewrite) { + const row: Record = JSON.parse(stored.rowJson) + row[rewrite[0]] = rewrite[1] + updateTestJournalRowJson(db, IDENTITY.sessionId, stored.seq, JSON.stringify(row)) + } + } + } finally { + db.close() + } +} + /** Tables that could store a pause: none, the journal rows are the only record. */ function pauseTables(): number { const db = new Database(journalDatabasePath(root), { readonly: true }) @@ -245,6 +263,37 @@ describe("the queue's pause, derived from the journal", () => { expect([...state.submissions.keys()]).toEqual(submissions) }) + it('a Stop or Resume row holding a value no build writes is ignored on read, never trusted', async () => { + let journal = await open() + await queueDraft(journal, 'held') + /** Appends a row, then reopens with one of its keys rewritten as a corrupt write would. */ + const corrupted = async ( + append: Promise<{ sequence: number }>, + key: string, + value: unknown + ) => { + const { sequence } = await append + await journal.close() + rewriteRows({ [sequence]: [key, value] }) + journal = await open() + } + const malformed = [null, 'stopped', { at: 1 }] + // Pauses nothing as the latest Stop row. + for (const value of malformed) { + await corrupted(userStop(journal), 'stopEvent', value) + expect(reason(journal)).toBeNull() + } + // Ends nothing after a person's Stop: neither as a later Stop nor as a Resume. + await userStop(journal) + for (const value of malformed) { + await corrupted(userStop(journal), 'stopEvent', value) + expect(reason(journal)).toBe('stopped') + } + await corrupted(journal.appendQueueResume(0), 'queueResume', 'yes') + expect(reason(journal)).toBe('stopped') + expect(held(journal)).toEqual([['held', true]]) + }) + it("the queue's own consume is refused in its transaction while the pause holds the card; Send-now is not", async () => { const journal = await open() await queueDraft(journal, 'draft-1') 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 012b99aa203b..9039760ba43c 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 @@ -40,13 +40,26 @@ export function createJournalQueuePauseMarks(): JournalQueuePauseMarks { return { latestStop: null, resumedSequence: 0 } } +/** The keys are read from disk unchecked: a value no build writes (a corrupt row) is ignored. */ +function isReadableStopEvent(value: unknown): value is JournalStopEvent { + return ( + typeof value === 'object' && + value !== null && + 'reason' in value && + typeof value.reason === 'string' && + 'at' in value && + typeof value.at === 'number' && + Number.isFinite(value.at) + ) +} + export function foldJournalQueuePauseMark( marks: JournalQueuePauseMarks, row: JournalTombstoneRow ): void { - if (row.stopEvent !== undefined) { + if (isReadableStopEvent(row.stopEvent)) { marks.latestStop = { sequence: row.seq, event: row.stopEvent } - } else if (row.queueResume !== undefined) { + } else if (row.queueResume === true) { marks.resumedSequence = row.seq } } From be2be9facd963b4e09081c95e8249073615f4e52 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:24:08 -0700 Subject: [PATCH 29/58] fix(native-chat): a Stop still reads as yours after Orca restarts before the turn ends Every stop that ends work now writes the Stop's event before it ends the child: a person's close of the chat, an eviction (worktree teardown, orchestration stop, tab cleanup) and the idle sweep's stop of a start that never landed. A stop that ends nothing writes nothing, and quit writes none: its resume marker records why. The turn-end write reads the latest Stop event where every turn row is built, so the adapter's settle, the host's fallback and the relaunch's settle all agree: a turn a person's Stop or close named, ending with no verdict of its own after that Stop, ends as their cancellation. A relaunch's probe-bounded end is no earlier than a Stop that found the turn running. When the provider refuses the interrupt and the turn runs on, a refusal row answers the Stop, so a later crash still reads Failed; pressing Stop again after a refusal is a new Stop. --- .../journal-lifecycle-batch-partition.ts | 15 +- .../agent-session-journal/journal-reducer.ts | 7 +- .../journal-row-builders.ts | 14 +- .../journal-row-schema.ts | 19 +- .../journal-stop-and-resume-rows.ts | 26 +- .../journal-stop-marks.ts | 25 ++ .../journal-stop-turn-end.ts | 110 +++++++++ .../journal-store-collaborators.ts | 3 + .../agent-session-journal/journal-store.ts | 3 + ...gent-session-dead-generation-settlement.ts | 7 +- .../structured-agent-session-host-lifetime.ts | 52 +++- .../structured-agent-session-idle-sweep.ts | 9 +- ...session-queued-message-rig.test-fixture.ts | 10 +- ...ed-agent-session-queued-pause-lift.test.ts | 10 +- .../structured-agent-session-queued-stop.ts | 3 +- ...d-agent-session-stale-turn-verdict.test.ts | 1 + ...ctured-agent-session-stale-turn-verdict.ts | 31 ++- ...d-agent-session-stop-event-entries.test.ts | 224 ++++++++++++++++++ ...d-agent-session-stop-event-restart.test.ts | 186 +++++++++++++++ .../structured-agent-session-turns-cancel.ts | 25 ++ ...ured-agent-session-unexpected-exit.test.ts | 1 + ...-session-stop-event-downgrade.unit.test.ts | 10 +- 22 files changed, 755 insertions(+), 36 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-stop-marks.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts index 11fb69863f26..2074e4d77c13 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts @@ -71,9 +71,22 @@ function serializedLifecycleBatchFits( return Buffer.byteLength(JSON.stringify(row), 'utf8') + 1 <= MAX_JOURNAL_LIFECYCLE_BATCH_BYTES } +/** Sized as a Stop may write it (`turnEndAfterStop`), so the chunk built from it still fits. */ +function sizedAsStopped(mutation: JournalLifecycleMutationInput): JournalLifecycleMutationInput { + if ( + mutation.kind !== 'item' || + mutation.body.kind !== 'turn' || + mutation.body.state !== 'interrupted' || + mutation.body.outcome !== undefined + ) { + return mutation + } + return { ...mutation, body: { ...mutation.body, outcome: 'cancellation' } } +} + function toLifecycleMutationRow(mutation: JournalLifecycleMutationInput): JournalLifecycleMutation { return journalLifecycleMutationRow( - mutation, + sizedAsStopped(mutation), agentJournalItemKey(mutation.identity), Number.MAX_SAFE_INTEGER ) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index a7d6f150e70c..fec7c50672cb 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -35,6 +35,7 @@ import { foldJournalQueuePauseMark, type JournalQueuePauseMarks } from './queued-message-pause' +import { foldJournalStopRefusal, type JournalStopRefusalMark } from './journal-stop-turn-end' export const MAX_JOURNAL_APPLIED_SETTLEMENT_IDS = 4_096 @@ -64,6 +65,8 @@ export type JournalReducerState = { latestPersonTurnSequence: number /** The latest person's Stop event and Resume, what the queue's pause is derived from. */ queuePauseMarks: JournalQueuePauseMarks + /** The latest refusal of a Stop, what keeps a refused Stop from ending its turn. */ + latestStopRefusal: JournalStopRefusalMark | null } export function createJournalReducerState(sessionId: string, epoch: string): JournalReducerState { @@ -83,7 +86,8 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou appliedSettlementIds: new Set(), derivedTurnScope: new JournalDerivedTurnScope(), latestPersonTurnSequence: 0, - queuePauseMarks: createJournalQueuePauseMarks() + queuePauseMarks: createJournalQueuePauseMarks(), + latestStopRefusal: null } } @@ -111,6 +115,7 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo } if (isJournalStopOrResumeRow(row)) { foldJournalQueuePauseMark(state.queuePauseMarks, row) + foldJournalStopRefusal(state, row) return } if (row.kind === 'tombstone') { diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 46d41ecb7cfe..0865df3a41fa 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -28,6 +28,7 @@ import { } from './journal-row-schema' import { boundInlineText, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import { assertSubmissionIdUnused } from './journal-write-guards' +import { turnEndAfterStop } from './journal-stop-turn-end' import type { ResolveDispatchInput } from './journal-store-contracts' type RowBuilder = (seq: number, ts: number) => T @@ -200,7 +201,13 @@ export function journalLifecycleBatchRowBuilder( current.tombstones.get(resolved) ?? 0 )) + 1 revisions.set(resolved, revision) - return journalLifecycleMutationRow(mutation, itemId, revision) + return journalLifecycleMutationRow( + mutation.kind === 'item' + ? { ...mutation, body: turnEndAfterStop(current, resolved, mutation.body) } + : mutation, + itemId, + revision + ) }) const row: JournalLifecycleBatchRow = { kind: 'lifecycle-batch', @@ -252,12 +259,13 @@ export function buildJournalItemRow(input: { input.state.items.get(resolved)?.revision ?? 0, input.state.tombstones.get(resolved) ?? 0 ) + 1 + const body = turnEndAfterStop(input.state, resolved, input.body) return { kind: 'item', itemId, revision, - body: input.body, - ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts, [input.body]), + body, + ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts, [body]), ...(input.recovered ? { recovered: input.recovered } : {}), turnScope: input.turnScope, ...agentJournalLinkageFields(input.linkage) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index e65d5919b39b..e4bd35bd40d7 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -77,6 +77,8 @@ export type JournalTombstoneRow = JournalRowBase & { stopEvent?: JournalStopEvent /** Present: not a removal but a person's Resume of the queue, on an id no item ever takes. */ queueResume?: true + /** Present: not a removal but the provider's refusal of a Stop, on an id no item ever takes. */ + stopRefusal?: JournalStopRefusal } /** One Stop that took effect. Temporary carrier: a tombstone's extra key, because a released host @@ -94,16 +96,29 @@ export type JournalStopEvent = { caller?: string } +/** The provider refused a Stop's interrupt and its turn ran on, so that Stop never ends the turn + * (`journal-stop-turn-end.ts`). Names the Stop it answers by that event's `turnId` and `at`. Same + * Temporary carrier as `JournalStopEvent`. */ +export type JournalStopRefusal = { + turnId?: string + stopAt: number +} + /** A tombstone that carries a Stop event or a Resume mark instead of removing an item. */ export type JournalStopOrResumeRow = JournalTombstoneRow & ( | { stopEvent: NonNullable } | { queueResume: NonNullable } + | { stopRefusal: NonNullable } ) -/** A Stop's event or a Resume. Any value counts, so a newer build's mark never removes an item. */ +/** A Stop's event, its refusal or a Resume. Any value counts, so a newer build's mark never + * removes an item. */ export function isJournalStopOrResumeRow(row: JournalRow): row is JournalStopOrResumeRow { - return row.kind === 'tombstone' && (row.stopEvent !== undefined || row.queueResume !== undefined) + return ( + row.kind === 'tombstone' && + (row.stopEvent !== undefined || row.queueResume !== undefined || row.stopRefusal !== undefined) + ) } /** The write-ahead row. Durable BEFORE the adapter dispatches anything; it diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts index e763af8b2e6c..079b2281c7c6 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts @@ -1,10 +1,14 @@ -// The rows a Stop's event and a person's Resume append: tombstones of ids no item ever takes, +// The rows a Stop's event, its refusal and a person's Resume append: tombstones of ids no item ever takes, // each carrying its record as an extra key (`journal-row-schema.ts` says why not a row kind). import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { JournalReducerState } from './journal-reducer' import { journalRowBase } from './journal-row-builders' -import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' +import type { + JournalStopEvent, + JournalStopRefusal, + JournalTombstoneRow +} from './journal-row-schema' /** One id per mark kind; no item ever takes either. */ const JOURNAL_STOP_EVENT_ITEM_ID = agentJournalItemKey({ @@ -15,6 +19,10 @@ const JOURNAL_QUEUE_RESUME_ITEM_ID = agentJournalItemKey({ provider: 'orca', clientMessageId: 'queue-resume' }) +const JOURNAL_STOP_REFUSAL_ITEM_ID = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'stop-refusal' +}) type RowPlace = { state: JournalReducerState; seq: number; fence: number; ts: number } @@ -56,3 +64,17 @@ export function journalQueueResumeRowBuilder( ): (seq: number, ts: number) => JournalTombstoneRow { return (seq, ts) => buildJournalQueueResumeRow({ state: state(), seq, fence, ts }) } + +export function journalStopRefusalRowBuilder( + state: () => JournalReducerState, + refusal: JournalStopRefusal, + fence: number +): (seq: number, ts: number) => JournalTombstoneRow { + return (seq, ts) => ({ + kind: 'tombstone', + itemId: JOURNAL_STOP_REFUSAL_ITEM_ID, + revision: 1, + stopRefusal: refusal, + ...journalRowBase(state().epoch, seq, fence, ts) + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts new file mode 100644 index 000000000000..2d9bb308e2ff --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts @@ -0,0 +1,25 @@ +// What the journal answers about its Stops beyond the queue's pause: the latest Stop event and +// whether a refusal answered it, which the turn-end rule reads (`journal-stop-turn-end.ts`). + +import type { JournalReducerState } from './journal-reducer' +import type { JournalRow, JournalStopRefusal } from './journal-row-schema' +import { journalStopRefusalRowBuilder } from './journal-stop-and-resume-rows' +import { journalLatestStop, type JournalLatestStop } from './journal-stop-turn-end' + +export class JournalStopMarks { + constructor( + private readonly deps: { + state: () => JournalReducerState + enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise + } + ) {} + + latest(): JournalLatestStop | null { + return journalLatestStop(this.deps.state()) + } + + /** The provider refused a Stop's interrupt and its turn runs on (`JournalStopRefusal`). */ + async appendRefusal(refusal: JournalStopRefusal, fence: number): Promise { + await this.deps.enqueue(journalStopRefusalRowBuilder(this.deps.state, refusal, fence)) + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts new file mode 100644 index 000000000000..016fe47d7f9e --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts @@ -0,0 +1,110 @@ +// What a Stop decides about the turn it named: the one rule every turn-end write passes through. +// +// A turn a person's Stop or close of this chat named, ending with no verdict of its own after that +// Stop's event and with no refusal answering it, ends as their cancellation. A host stop, an +// eviction, a refused Stop and no Stop at all leave the end as written. It runs where each row is +// built, inside the journal's serialized write, so it reads every Stop folded before the end: the +// adapter's settle, the host's fallback and a relaunch's settle all write through it, and every +// client folds the row it wrote. + +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { JournalReducerState } from './journal-reducer' +import type { + JournalStopEvent, + JournalStopRefusal, + JournalTombstoneRow +} from './journal-row-schema' + +export type JournalStopRefusalMark = { sequence: number; refusal: JournalStopRefusal } + +export type JournalLatestStop = { + sequence: number + event: JournalStopEvent + /** A refusal written after it answers it: its turn ran on. */ + refused: boolean +} + +/** The keys are read from disk unchecked: a value no build writes (a corrupt row) is ignored. */ +export function foldJournalStopRefusal( + state: Pick, + row: JournalTombstoneRow +): void { + const refusal: unknown = row.stopRefusal + if ( + typeof refusal === 'object' && + refusal !== null && + 'stopAt' in refusal && + typeof refusal.stopAt === 'number' && + (!('turnId' in refusal) || typeof refusal.turnId === 'string') + ) { + state.latestStopRefusal = { + sequence: row.seq, + refusal: { + stopAt: refusal.stopAt, + ...('turnId' in refusal && typeof refusal.turnId === 'string' + ? { turnId: refusal.turnId } + : {}) + } + } + } +} + +export function journalLatestStop( + state: Pick +): JournalLatestStop | null { + const stop = state.queuePauseMarks.latestStop + if (!stop) { + return null + } + const answer = state.latestStopRefusal + return { + ...stop, + refused: + answer !== null && + answer.sequence > stop.sequence && + answer.refusal.stopAt === stop.event.at && + answer.refusal.turnId === stop.event.turnId + } +} + +/** Only a person's own Stop, or their close of this chat, makes a cut turn their cancellation. */ +function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { + switch (reason) { + case 'user-stop': + case 'user-close': + return true + case 'host-stop': + case 'evict': + return false + } +} + +/** + * 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 while it was still open (running, + * or unproven) and which no refusal answered. A provider's own verdict always stands. + */ +export function turnEndAfterStop( + state: Pick, + itemId: string, + body: AgentJournalItemBody +): AgentJournalItemBody { + if (body.kind !== 'turn' || body.state !== 'interrupted' || body.outcome !== undefined) { + return body + } + const previous = readAgentJournalTurn(state.items.get(itemId)?.body) + // An end already written stands: the Stop came after it. + if (previous && previous.state !== 'running' && previous.state !== 'unverifiable') { + return body + } + const stop = journalLatestStop(state) + return stop && + !stop.refused && + stop.event.turnId === body.turnId && + stopIsAPersons(stop.event.reason) && + // An exit the provider saw before the Stop was news, whenever its end is written. + (body.completedAt === undefined || body.completedAt >= stop.event.at) + ? { ...body, outcome: 'cancellation' } + : body +} diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 647b64e0f004..5351427f3023 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -13,6 +13,7 @@ import { JournalItemAppender } from './journal-item-appender' import { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' import type { JournalLoad } from './journal-open' import { JournalQueuedMessages } from './journal-queued-messages' +import { JournalStopMarks } from './journal-stop-marks' import { journalQueuePauseRestatement } from './queued-message-pause' import type { JournalReducerState } from './journal-reducer' import { JournalRowWriter } from './journal-row-writer' @@ -56,6 +57,7 @@ export type JournalStoreCollaborators = { itemAppender: JournalItemAppender lifecycleBatchAppender: JournalLifecycleBatchAppender queuedMessages: JournalQueuedMessages + stopMarks: JournalStopMarks /** Restores the store's state from disk. Owned here because it needs the same * collaborators the constructor just built. */ restore: () => Promise @@ -92,6 +94,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal return { epochController, queuedMessages, + stopMarks: new JournalStopMarks({ state: host.state, enqueue: host.enqueue }), // Behind the stored fact: settles drafts whose consumed submission the loaded journal shows // refused (a downgrade wrote no hook), then prunes. Bookkeeping, never failing the open. restore: () => diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 08113e3bc7db..3de15cc3df60 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -70,6 +70,7 @@ import { createJournalStoreCollaborators } from './journal-store-collaborators' import { journalStoreLoadedFields } from './journal-store-open' import type { JournalItemAppender } from './journal-item-appender' import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' +import type { JournalStopMarks } from './journal-stop-marks' export { AgentSessionJournalError } from './journal-write-guards' @@ -93,6 +94,7 @@ export class AgentSessionJournal { private readonly restore: () => Promise /** Draft rows queued while the agent works; never reducer input or owed work. */ readonly queuedMessages: JournalQueuedMessages + readonly stopMarks: JournalStopMarks constructor(options: AgentSessionJournalOptions) { this.identity = options.identity @@ -141,6 +143,7 @@ export class AgentSessionJournal { this.itemAppender = collaborators.itemAppender this.lifecycleBatchAppender = collaborators.lifecycleBatchAppender this.queuedMessages = collaborators.queuedMessages + this.stopMarks = collaborators.stopMarks this.restore = collaborators.restore } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 72caac40844d..6bf0c7346e2f 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 @@ -28,6 +28,7 @@ import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-re import { provenUnverifiableTurnRevisions, runningTurnLifecycleRevisions, + stopFoundTurnLiveAt, turnVerdictFromDeathEvidence, type StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' @@ -216,7 +217,11 @@ export async function settleStaleStructuredAgentSessionState(input: { const items = journal.snapshot().items // Each turn is judged by the evidence only if it names that turn's owner. const verdictFor = (item: AgentJournalRenderItem) => - turnVerdictFromDeathEvidence(input.deathEvidence, journal.itemFence(item.itemId)) + turnVerdictFromDeathEvidence( + input.deathEvidence, + journal.itemFence(item.itemId), + stopFoundTurnLiveAt(journal, item) + ) // Per attempt: a retry re-partitions only what is left, and a reused chunk id would skip it. const generation = input.acquisitionGeneration ?? `seq-${journal.cursor().sequence}` const settlementId = `stale-session:${input.sessionId}:${input.fence}:${generation}` 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 719540da5da8..03639dc8c2b7 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 @@ -32,6 +32,7 @@ import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-ses import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' import { turnVerdictForChildEnd } from './structured-agent-session-stale-turn-verdict' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' export type StructuredAgentSessionLifetimeContext = { deps: StructuredAgentSessionHostDeps @@ -87,6 +88,47 @@ function owedProviderChildWindDown( : session.owesProviderChildWindDown } +/** How a stop ends the child: its cause, the host's text for it, and whether it is quit, whose + * resume marker's trigger already records why, so it writes no Stop event. */ +export type StructuredAgentSessionStopEnding = { + cause: StructuredAgentSessionStopCause + reason?: string + quit?: true +} + +/** + * Writes this stop's event (`JournalStopEvent`) when it ends a running turn or a start: a stop + * that ends nothing writes nothing. A person's Stop wrote its own before it reached here, and quit + * writes none. Issued before the kill and never awaited by it: bookkeeping, reported on failure. + */ +function recordStopEvent( + context: StructuredAgentSessionLifetimeContext, + sessionId: string, + session: StructuredAgentSessionHostSession, + ending: StructuredAgentSessionStopEnding +): Promise { + const { child, journal } = session + const turnId = journal.activeTurnId() + if ( + ending.cause === 'user-stop' || + ending.quit || + !child || + (child.phase !== 'starting' && + !isStructuredAgentSessionMainAgentWorking(turnId, journal.submissions(), child.fence)) + ) { + return Promise.resolve() + } + return journal + .appendStopEvent( + { reason: ending.cause, ...(turnId !== null ? { turnId } : {}) }, + structuredAgentSessionConversationFence(context.deps.store, sessionId) + ) + .then( + () => undefined, + (error: unknown) => context.deps.onEventSinkError?.({ sessionId, error }) + ) +} + /** * The agent goes to rest; the conversation stays. Runs the eviction steps under a deadline. A step * that fails — or runs out of time — aborts the rest and leaves the wind-down owed, so the next @@ -97,12 +139,13 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( context: StructuredAgentSessionLifetimeContext, sessionId: string, // Required: an omitted cause must not default to the user's cancellation. - ending: { cause: StructuredAgentSessionStopCause; reason?: string } + ending: StructuredAgentSessionStopEnding ): Promise { const session = context.sessions.get(sessionId) if (!session) { return } + const recorded = recordStopEvent(context, sessionId, session, ending) // A retry finishes the stop that ended the child, so the turn that stop cut keeps its cause. const cause = session.child ? ending.cause @@ -143,6 +186,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( acknowledgeRelease: () => context.deps.adapter.acknowledgeSessionRelease?.(sessionId), discardSink: () => context.runtimeState.discardEventSink(sessionId), settleWork: async () => { + // Folded before the fallback's end is built, so the end reads it (`turnEndAfterStop`). + await recorded const fence = owed?.fence ?? structuredAgentSessionConversationFence(context.deps.store, sessionId) const settled = await settleStructuredAgentSessionDeadGeneration({ @@ -250,7 +295,10 @@ export async function evictOwnedStructuredAgentSessions( ownedSessionIds.map(async (sessionId) => { try { await context.serialize(sessionId, () => - stopStructuredAgentSessionAgentUnderSerialize(context, sessionId, { cause: 'evict' }) + stopStructuredAgentSessionAgentUnderSerialize(context, sessionId, { + cause: 'evict', + quit: true + }) ) retainOnFailure.delete(sessionId) } catch (error) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index cdf412a7bcd5..92e0eebc3939 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -122,11 +122,10 @@ export class StructuredAgentSessionIdleSweep { } if (session.child) { // A start that has been quiet this long is not coming: the host stops it, with its reason. - if (session.child.phase === 'starting') { - await this.deps.stopStartingAgent(sessionId) - return - } - await this.deps.stopAgent(sessionId) + // The close below keeps a conversation whose delivery loop still owes that start's rows. + await (session.child.phase === 'starting' + ? this.deps.stopStartingAgent(sessionId) + : this.deps.stopAgent(sessionId)) } await this.deps.closeConversation(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index c4fc82f5cc99..1354db74e87c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -38,7 +38,12 @@ export type QueuedMessageTestRig = Awaited 'spawn-1', - now: () => NOW + now: () => NOW, + ...(options.idleSweep ? { idleSweep: options.idleSweep } : {}) }) let host = makeHost() expect(await host.attach(QUEUED_RIG_CALLER, hostTestAttachParams(null))).toMatchObject({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 0760a55c264c..f2ad9a18fa41 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -91,13 +91,11 @@ describe("a Stop's queue pause", () => { it('survives a restart, and a send made after the Stop still ends it when its turn starts there', async () => { const draftId = await stoppedDraft() - const inFlight = await handedOverUserSend('sent before the restart') - // Derived from the journal, not remembered: a restart forgets nothing it needs. - await rig.restartHostProcess() + // Derived from the journal, not remembered: a restart forgets nothing it needs. Quit writes no + // Stop event, so the process dying is that restart; `restartHostProcess` closes with an + // eviction, whose event would end the pause itself. + rig.crashRestartHostProcess() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - await rig.settleAccepted(inFlight, 'after-restart') - // The Stop's pause is over; the restart's own lasts until a turn asked for since it. - await eventually(async () => expect(await rig.queuePause()).toEqual({ reason: 'restarted' })) const next = rig.send('sent after the restart') await next.result await rig.settleAccepted(next.id, 'next') 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 72df6fe2ac6a..ac21829b87a1 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 @@ -71,7 +71,8 @@ export async function stopReachesUnrecordedWork( return false } const inForce = ctx.journal.queuedMessages.userStopInForce() - if (inForce === null) { + // A refused Stop ended nothing, so pressing again is a new Stop. + if (inForce === null || ctx.journal.stopMarks.latest()?.refused) { return true } // Sent after that Stop and not refused, even if its fate is unknown: this interrupt may send it diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts index 71c5df2692d6..8a9deaad48c0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts @@ -276,6 +276,7 @@ describe('stale session state on a cold acquire', () => { const journal = { snapshot: () => ({ items }), itemFence: () => 1, + stopMarks: { latest: () => null }, cursor: () => ({ epoch: 'epoch-1', sequence: 8 }), appendLifecycleBatch } as unknown as AgentSessionJournal diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index d08f536daef4..45f636eccf77 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -36,7 +36,9 @@ export const UNVERIFIABLE_TURN_VERDICT: StructuredAgentSessionTurnVerdict = { export function turnVerdictFromDeathEvidence( evidence: AgentSessionDeathEvidence | null | undefined, /** Fence of the owner that wrote the turn. */ - turnFence: number | undefined + turnFence: number | undefined, + /** When a Stop event found the turn running (`stopFoundTurnLiveAt`): a later proof of life. */ + liveAt?: number ): StructuredAgentSessionTurnVerdict { if (!evidence) { return UNVERIFIABLE_TURN_VERDICT @@ -54,11 +56,20 @@ export function turnVerdictFromDeathEvidence( return { state: 'interrupted', completedAt: evidence.observedAt } } // A probe finds a dead child long after it died; its last renewal bounds the end, so the turn never - // counts the time Orca was down. Timeline rows don't: a send can land there after the death. - return { - state: 'interrupted', - completedAt: Math.min(evidence.lastProvenAliveAt ?? evidence.observedAt, evidence.observedAt) - } + // counts the time Orca was down. Timeline rows don't: a send can land there after the death. A + // Stop that found the turn running is a later renewal, so the end reads after that Stop. + const lastAlive = Math.max(evidence.lastProvenAliveAt ?? evidence.observedAt, liveAt ?? 0) + return { state: 'interrupted', completedAt: Math.min(lastAlive, evidence.observedAt) } +} + +/** When the latest Stop event found `item`'s turn running: E1 writes one only for a live turn. */ +export function stopFoundTurnLiveAt( + journal: Pick, + item: AgentJournalRenderItem +): number | undefined { + const stop = journal.stopMarks.latest() + const turnId = readAgentJournalTurn(item.body)?.turnId + return stop && turnId !== undefined && stop.event.turnId === turnId ? stop.event.at : undefined } /** @@ -119,7 +130,7 @@ export function runningTurnLifecycleRevisions( export function provenUnverifiableTurnRevisions( items: readonly AgentJournalRenderItem[], evidence: AgentSessionDeathEvidence | null | undefined, - journal: Pick + journal: Pick ): JournalLifecycleMutationInput[] { const ownerFence = evidence?.ownerFence if (ownerFence === undefined) { @@ -128,7 +139,11 @@ export function provenUnverifiableTurnRevisions( return items.flatMap((item) => { const turn = readAgentJournalTurn(item.body) return turn?.state === 'unverifiable' && journal.itemFence(item.itemId) === ownerFence - ? turnLifecycleRevision(item, turn, turnVerdictFromDeathEvidence(evidence, ownerFence)) + ? turnLifecycleRevision( + item, + turn, + turnVerdictFromDeathEvidence(evidence, ownerFence, stopFoundTurnLiveAt(journal, item)) + ) : [] }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts new file mode 100644 index 000000000000..a0a1dd5dc511 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -0,0 +1,224 @@ +// Every way a host stop or close begins writes the Stop's event with its reason, before it ends the +// child, and only when it ends work: a running turn, or a start. A stop that ends nothing writes +// nothing, quit writes nothing (its resume marker records why), and any later Stop event ends a +// person's Stop pause. + +import { afterEach, describe, expect, it } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' +import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +afterEach(() => rig.dispose()) + +/** Swept only when a test ticks it. */ +const MANUAL_IDLE_SWEEP = { idleMs: 0, intervalMs: 60 * 60 * 1000 } + +function journal() { + const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!open) { + throw new Error('expected the conversation open') + } + return open +} + +/** Every Stop event in the live epoch, oldest first. */ +function stopEvents(): JournalStopEvent[] { + const since = journal().readSince({ epoch: journal().epoch, sequence: 0 }) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + return since.rows.flatMap((row) => + row.kind === 'tombstone' && row.stopEvent ? [row.stopEvent] : [] + ) +} + +/** The Stop events as the provider's close finds them, or null when no close ran. */ +function stopEventsAtClose(): { events: JournalStopEvent[] | null } { + const seen: { events: JournalStopEvent[] | null } = { events: null } + rig.closeSession.mockImplementationOnce(async () => { + seen.events = stopEvents() + return true + }) + return seen +} + +async function runningTurn(turnId = 'turn-1'): Promise { + const working = await rig.workingSend() + await journal().appendItem( + { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 999 }, + { kind: 'turn', turnId, state: 'running', startedAt: 1 }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return working +} + +async function queuedDraft(text: string): Promise { + const queued = await rig.send(text, 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error(`expected a queued receipt: ${JSON.stringify(queued)}`) + } + return queued.value.queued.messageId +} + +function idleSweep() { + return rig.host.collaboratorsForTests().lifetime.idleSweep +} + +/** Holds every start until the returned release. */ +function holdStart(): () => void { + let release: () => void = () => undefined + rig.awaitStarted.mockImplementation( + () => new Promise((resolve) => (release = () => resolve(undefined))) + ) + return () => release() +} + +describe('every Stop entry writes its event, with its reason, before it ends the child', () => { + it.each([ + // A person closing this chat: its tab, its launch, or a /clear that replaces it. + ['user-close' as const], + // A worktree teardown, an orchestration stop, a discarded half-started worker, a tab cleanup. + ['evict' as const] + ])('a %s close of a running turn', async (cause) => { + rig = await createQueuedMessageTestRig() + await runningTurn() + const atClose = stopEventsAtClose() + + await rig.host.close(HOST_TEST_SESSION, cause) + + expect(atClose.events).toEqual([{ reason: cause, turnId: 'turn-1', at: expect.any(Number) }]) + }) + + it("a person's Stop of a running turn", async () => { + rig = await createQueuedMessageTestRig() + await runningTurn() + let atInterrupt: JournalStopEvent[] = [] + rig.cancelTurn.mockImplementationOnce(async () => { + atInterrupt = stopEvents() + return { cancelled: true } + }) + + await rig.stop() + + expect(atInterrupt).toEqual([ + expect.objectContaining({ reason: 'user-stop', turnId: 'turn-1', at: expect.any(Number) }) + ]) + }) + + it('the idle sweep stopping a start that never landed', async () => { + rig = await createQueuedMessageTestRig({ + starting: true, + restartable: true, + idleSweep: MANUAL_IDLE_SWEEP + }) + holdStart() + rig.send('work on this') + await eventually(async () => + expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe( + 'starting' + ) + ) + const atClose = stopEventsAtClose() + + await idleSweep().tick() + + expect(atClose.events).toEqual([{ reason: 'host-stop', at: expect.any(Number) }]) + }) + + it('writes nothing when it ends nothing: a close of a chat at rest', async () => { + rig = await createQueuedMessageTestRig() + const atClose = stopEventsAtClose() + + await rig.host.close(HOST_TEST_SESSION, 'user-close') + + expect(atClose.events).toEqual([]) + }) + + it("writes nothing at quit, whose resume marker's trigger records why", async () => { + rig = await createQueuedMessageTestRig() + await runningTurn() + const atClose = stopEventsAtClose() + + await rig.host.flushAllStreamedEvents({ trigger: 'quit' }) + + expect(atClose.events).toEqual([]) + }) +}) + +describe("a person's Stop pause and the Stop events after it", () => { + it('holds through an idle eviction of the chat at rest, which writes nothing', async () => { + rig = await createQueuedMessageTestRig({ idleSweep: MANUAL_IDLE_SWEEP }) + const working = await rig.workingSend() + const held = await queuedDraft('queued behind the turn') + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(working, 'stopped') + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + const atClose = stopEventsAtClose() + + await idleSweep().tick() + + expect(rig.closeSession).toHaveBeenCalledTimes(1) + expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.handoff(held)).toBeUndefined() + }) + + it('ends when a host eviction ends a running turn: that Stop event is later', async () => { + rig = await createQueuedMessageTestRig() + const working = await rig.workingSend() + await queuedDraft('queued behind the turn') + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(working, 'stopped') + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // Orchestration mail starts a turn the host sent, which lifts nothing. + await rig.send('mail for the lead', undefined, { internal: true }).result + await journal().appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-mail', ordinal: 999 }, + { kind: 'turn', turnId: 'turn-mail', state: 'running', startedAt: 1 }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + const atClose = stopEventsAtClose() + + await rig.host.close(HOST_TEST_SESSION, 'evict') + + expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop', 'evict']) + expect(await rig.queuePause()).not.toEqual({ reason: 'stopped' }) + }) + + it('ends when the host stops a start that never landed: that Stop event is later', async () => { + rig = await createQueuedMessageTestRig({ + starting: true, + restartable: true, + idleSweep: MANUAL_IDLE_SWEEP + }) + const working = await rig.workingSend() + const held = await queuedDraft('queued behind the turn') + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(working, 'stopped') + // The agent at rest goes, writing nothing; mail then starts a new child, which never lands. + await idleSweep().tick() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + holdStart() + rig.send('mail for the lead', undefined, { internal: true }) + await eventually(async () => + expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe( + 'starting' + ) + ) + const atClose = stopEventsAtClose() + + await idleSweep().tick() + + expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop', 'host-stop']) + expect(await rig.handoff(held)).toBeUndefined() + expect(await rig.queuePause()).not.toEqual({ reason: 'stopped' }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts new file mode 100644 index 000000000000..5dc82d8efd73 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -0,0 +1,186 @@ +// 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 whose Stop the provider refused +// while the turn ran on, still read as the news they are. + +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict' +import { agentTurnVerdict } from '../../../shared/agent-turn-outcome' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { formatNativeChatTurnStatusLabel } from '../../../shared/native-chat-turn-status' +import { selectStructuredAgentSettledTurns } from '../../../shared/structured-agent-session-turn-timing' +import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' +import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +const TURN = 'turn-1' +/** The provider rows a turn lives on: the Claude lane's, and Codex's. */ +const CLAUDE_TURN: AgentJournalItemIdentity = { + provider: 'claude', + sessionId: 'provider-session-1', + uuid: 'uuid-turn' +} +const CODEX_TURN: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: TURN, + ordinal: 999 +} + +let rig: QueuedMessageTestRig + +afterEach(() => rig.dispose()) + +function journal() { + const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!open) { + throw new Error('expected the conversation open') + } + return open +} + +/** A send the provider is working on, with its turn running since half a minute ago. */ +async function runningTurn(identity: AgentJournalItemIdentity): Promise { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + await journal().appendItem( + identity, + { kind: 'turn', turnId: TURN, state: 'running', startedAt: Date.now() - 30_000 }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +/** Orca dies with the turn's end unwritten; the relaunch reopens the chat from disk and proves + * the old child gone (its last renewal came before the Stop), then settles what it left. */ +async function restartAndSettle(proof: 'pid-absent' | 'unproven' = 'pid-absent'): Promise { + rig.crashRestartHostProcess() + await rig.host.journalSnapshot(HOST_TEST_SESSION) + const now = Date.now() + const deathEvidence: AgentSessionDeathEvidence | null = + proof === 'pid-absent' + ? { + kind: 'pid-absent', + observedAt: now + 60_000, + ownerFence: 1, + lastProvenAliveAt: now - 20_000 + } + : null + await settleStaleStructuredAgentSessionState({ + journal: journal(), + sessionId: HOST_TEST_SESSION, + fence: 2, + acquisitionGeneration: 'generation-2', + deathEvidence + }) +} + +/** What the chat's turn bar and the session's mark read. */ +function settled() { + const { items } = journal().snapshot() + const turn = items.map((item) => readAgentJournalTurn(item.body)).find(Boolean) + const [timing] = [...selectStructuredAgentSettledTurns(items).values()] + const verdict = turn + ? agentTurnVerdict({ state: turn.state, outcome: turn.outcome ?? null }) + : null + return { + turn, + label: timing ? formatNativeChatTurnStatusLabel({ elapsedSeconds: 0, ...timing }) : null, + mark: verdict + ? agentVerdictDisplayMark({ state: 'done', mainAgent: { state: 'done', outcome: verdict } }) + : null + } +} + +describe('a restart between a Stop and its turn end', () => { + it.each([ + ['a Claude Stop before its result arrives', CLAUDE_TURN], + ['a Codex Stop before turn/completed', CODEX_TURN] + ])('reads Interrupted after N, marked interrupted: %s', async (_label, identity) => { + await runningTurn(identity) + // The provider took the interrupt; its end never arrived. + expect(await rig.stop()).toMatchObject({ ok: true }) + + await restartAndSettle() + + const { turn, label, mark } = settled() + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(label).toMatch(/^Interrupted after /) + expect(mark).toBe('interrupted') + }) + + it('reads Interrupted after N, marked interrupted: a close of the chat that died midway', async () => { + await runningTurn(CODEX_TURN) + // The host dies inside the close: the provider's close never answers, and nothing settles. + rig.closeSession.mockImplementationOnce(() => Promise.reject(new Error('host died'))) + await expect(rig.host.close(HOST_TEST_SESSION, 'user-close')).rejects.toThrow() + expect(journal().stopMarks.latest()?.event).toMatchObject({ + reason: 'user-close', + turnId: TURN + }) + + await restartAndSettle() + + const { turn, label, mark } = settled() + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(label).toMatch(/^Interrupted after /) + expect(mark).toBe('interrupted') + }) + + it('reads Failed after N, marked failed, when nobody stopped it', async () => { + await runningTurn(CODEX_TURN) + + await restartAndSettle() + + const { turn, label, mark } = settled() + expect(turn).toMatchObject({ state: 'interrupted' }) + expect(turn).not.toHaveProperty('outcome') + expect(label).toMatch(/^Failed after /) + expect(mark).toBe('failed') + }) + + it("reads Couldn't confirm when the relaunch cannot prove the child gone, Stop or not", async () => { + // The Stop says whose end it was, never that the turn ended. + await runningTurn(CODEX_TURN) + expect(await rig.stop()).toMatchObject({ ok: true }) + + await restartAndSettle('unproven') + + const { turn, mark } = settled() + expect(turn).toMatchObject({ state: 'unverifiable' }) + expect(mark).toBe('unconfirmed') + }) + + it('reads Failed after N when the provider refused the Stop and the turn ran on until the crash', async () => { + await runningTurn(CODEX_TURN) + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: {} }) + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + await eventually(async () => expect(journal().stopMarks.latest()?.refused).toBe(true)) + + await restartAndSettle() + + const { turn, label, mark } = settled() + expect(turn).not.toHaveProperty('outcome') + expect(label).toMatch(/^Failed after /) + expect(mark).toBe('failed') + }) + + it('a Stop pressed again after a refusal is a new Stop, which its turn end reads', async () => { + await runningTurn(CODEX_TURN) + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: {} }) + await rig.stop() + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + await restartAndSettle() + + expect(settled().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 eb3784b0a1d7..fb97d87341db 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 @@ -30,6 +30,26 @@ export async function isMainAgentWorkingOnceFlushed( ) } +/** The provider refused the interrupt and `turnId` runs on: its Stop event must not end it + * (`turnEndAfterStop`). Only where nothing then ends the child. Bookkeeping: reported, never thrown. */ +async function recordStopRefusal( + ctx: Pick, + turnId: string | null +): Promise { + const stop = ctx.journal.stopMarks.latest() + if (!stop || stop.refused || turnId === null || stop.event.turnId !== turnId) { + return + } + await ctx.journal.stopMarks + .appendRefusal({ turnId, stopAt: stop.event.at }, ctx.fence) + .catch((error: unknown) => { + console.warn("[agent-session] a refused Stop's answer row skipped:", { + sessionId: ctx.sessionId, + error: error instanceof Error ? error.message : String(error) + }) + }) +} + export async function performCancel( ctx: AgentSessionTurnContext, input: { @@ -52,6 +72,7 @@ export async function performCancel( } } let cancelled = false + let refused = false let note: AgentJournalStatusItem | null = { kind: 'status', text: 'Cancellation requested.' } // The turn the Stop names, read before the cancel settles it: the note reports on that turn. const turnScope = ctx.journal.liveTurnScope() @@ -102,6 +123,7 @@ export async function performCancel( note = null } else if (!cancelled) { // Sent only while the chat reads working, so a Stop that ended nothing must say why. + refused = true const detail = outcome.refusal?.detail note = { kind: 'status', @@ -126,6 +148,9 @@ export async function performCancel( cancelled = true note = { kind: 'status', text: 'Cancellation requested.' } } + if (refused && !cancelled && !input.scope) { + await recordStopRefusal(ctx, liveTurnId) + } if (cancelled && input.prompt) { await ctx.flushStreamedEvents() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index 2cb38a466345..f8a17510f430 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -98,6 +98,7 @@ describe('provider-exit settlement', () => { items: [lifecycleItem('turn-1', 1, { state: 'running', startedAt: 1_000 })] }), itemFence: () => 7, + stopMarks: { latest: () => null }, appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []) } diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index 0a9b5ea5f35d..88abffc1fb4e 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -54,7 +54,7 @@ type OldReplay = { truncateFrom?: number } -test("an older build keeps every row around a Stop's event and a Resume, and folds the rows after them", async () => { +test("an older build keeps every row around a Stop's event, its refusal and a Resume, and folds the rows after them", async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-stop-event-downgrade-')) const journals = createTrackedJournalOpener() try { @@ -69,6 +69,9 @@ test("an older build keeps every row around a Stop's event and a Resume, and fol await append(0, 'before the Stop') const beforeMarks = journal.cursor() await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 1) + const stopAt = journal.stopMarks.latest()?.event.at ?? 0 + await journal.stopMarks.appendRefusal({ turnId: 'turn-1', stopAt }, 1) + await journal.appendStopEvent({ reason: 'user-close', turnId: 'turn-1' }, 1) await journal.appendQueueResume(1) const afterMarks = journal.cursor() await append(1, 'after the Stop') @@ -136,7 +139,7 @@ test("an older build keeps every row around a Stop's event and a Resume, and fol }) expect(projected.ok).toBe(true) expect(projected.batch?.items).toEqual([]) - expect(projected.batch?.removedItemIds).toHaveLength(2) + expect(projected.batch?.removedItemIds).toHaveLength(3) const liveIds = new Set(journal.snapshot().items.map((entry) => entry.itemId)) expect(projected.batch?.removedItemIds.some((id) => liveIds.has(id))).toBe(false) } finally { @@ -182,6 +185,8 @@ test("an older build opens this build's journal writable and appends to it; the const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } await journal.appendItem(item(0), { kind: 'status', text: 'before the Stop' }, scope) await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 1) + const stopAt = journal.stopMarks.latest()?.event.at ?? 0 + await journal.stopMarks.appendRefusal({ turnId: 'turn-1', stopAt }, 1) await journal.appendItem(item(1), { kind: 'status', text: 'after the Stop' }, scope) const wrote = { cursor: journal.cursor(), items: itemIds(journal) } expect(journal.queuedMessages.pauses('host-a').map((pause) => pause.reason)).toEqual([ @@ -217,6 +222,7 @@ test("an older build opens this build's journal writable and appends to it; the expect(upgraded.queuedMessages.pauses('host-a').map((pause) => pause.reason)).toEqual([ 'stopped' ]) + expect(upgraded.stopMarks.latest()).toMatchObject({ event: { at: stopAt }, refused: true }) } finally { await journals.closeAll() rmSync(directory, { recursive: true, force: true }) From cc32533d335abe4f436d92505ded8985e438ee93 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:44:53 -0700 Subject: [PATCH 30/58] refactor(native-chat): a stop no longer carries its cause; the turn's end reads the Stop event The cause of a stop was threaded in memory from each entry through the host's stop step, the adapter router and each adapter's close onto the `ended` it settled with, and Claude kept a per-turn copy of a Stop it sent. All of that is gone: adapters settle a turn they cut as interrupted with no verdict, the host's fallback does the same, and the one rule where a turn row is built (`turnEndAfterStop`) reads the journal's latest Stop event to say whether it was a person's. - `closeSession` / `disposeSession` take no cause; `ended` has no `stopCause`. - Claude reads an error result after a person's Stop as their cancellation from the journal's Stop event (through the event sink), not from a per-turn slot, and a refused interrupt is the host's refusal row, not `withdrawTurnStop`. - An owed wind-down keeps no cause: its retry's fallback reads the Stop event. - The mutation context's Stop passes no cause: its step already wrote the event, and the delivery loop's child-end reason is read back from it. - A Stop pressed before its turn showed applies to the turn that opens under it, unless a send a person made since was accepted. --- .../claude-api-retry-idle-sweep.test.ts | 2 +- ....test.ts => claude-close-turn-end.test.ts} | 31 +++---- .../claude-journal-translator-contract.ts | 6 -- src/main/claude/claude-open-turn.ts | 24 ------ src/main/claude/claude-result-journaling.ts | 14 +++- src/main/claude/claude-result-outcome.ts | 11 ++- .../claude-structured-control-actions.test.ts | 2 - .../claude-structured-control-actions.ts | 15 +--- .../claude-structured-in-turn-stop.test.ts | 36 +++++++-- .../claude-structured-journal-translation.ts | 14 +--- .../claude-structured-prompt-ownership.ts | 15 +--- .../claude-structured-provider-fallback.ts | 5 +- .../claude-structured-session-adapter.ts | 22 ++--- .../claude/claude-structured-session-close.ts | 5 -- .../claude/claude-structured-session-state.ts | 7 +- src/main/claude/claude-turn-lifecycle-item.ts | 5 +- src/main/claude/claude-turn-outcome.test.ts | 47 ++++++----- src/main/claude/claude-turn-ownership.test.ts | 2 - ...e.test.ts => codex-close-turn-end.test.ts} | 81 ++++++------------- .../codex-provider-retry-idle-sweep.test.ts | 2 +- .../codex-structured-journal-translation.ts | 20 ++--- .../codex/codex-structured-session-adapter.ts | 9 +-- .../codex/codex-structured-session-close.ts | 16 +--- .../codex/codex-structured-session-state.ts | 7 +- .../codex-structured-session-teardown.ts | 6 +- .../journal-stop-turn-end.ts | 48 ++++++++--- ...tured-agent-session-adapter-router.test.ts | 2 +- ...structured-agent-session-adapter-router.ts | 18 ++--- .../structured-agent-session-adapter.ts | 7 +- ...ctured-agent-session-close-verdict.test.ts | 44 +++++----- ...d-agent-session-conversation-close.test.ts | 4 +- ...red-agent-session-conversation-lifetime.ts | 10 +-- ...ructured-agent-session-event-sink-queue.ts | 4 + .../structured-agent-session-event-sink.ts | 4 + .../structured-agent-session-eviction.test.ts | 2 +- .../structured-agent-session-eviction.ts | 9 +-- .../structured-agent-session-host-lifetime.ts | 52 +++++++----- .../structured-agent-session-host-types.ts | 10 +-- .../structured-agent-session-host.ts | 3 +- ...tructured-agent-session-idle-sweep.test.ts | 18 ++--- ...ed-agent-session-option-settlement.test.ts | 2 +- ...gent-session-provider-child-record.test.ts | 2 +- ...agent-session-recovered-turn-clock.test.ts | 18 +---- ...ctured-agent-session-stale-turn-verdict.ts | 51 +----------- .../structured-agent-session-stop-cause.ts | 8 +- ...red-agent-session-surface-lifetime.test.ts | 4 +- 46 files changed, 285 insertions(+), 439 deletions(-) rename src/main/claude/{claude-close-stop-cause.test.ts => claude-close-turn-end.test.ts} (69%) rename src/main/codex/{codex-close-stop-cause.test.ts => codex-close-turn-end.test.ts} (59%) diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts index bac1da60fe73..7f3511930292 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -123,7 +123,7 @@ describe('a Claude retrying a refused request', () => { // Once the frames stop, the same clock does let the sweep close it. clock += STRUCTURED_AGENT_SESSION_IDLE_MS await vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict') + expect(closeSession).toHaveBeenCalledWith(SESSION) expect(host.hasSession(SESSION)).toBe(false) }) }) diff --git a/src/main/claude/claude-close-stop-cause.test.ts b/src/main/claude/claude-close-turn-end.test.ts similarity index 69% rename from src/main/claude/claude-close-stop-cause.test.ts rename to src/main/claude/claude-close-turn-end.test.ts index ba93c53a3d85..d115afe61a28 100644 --- a/src/main/claude/claude-close-stop-cause.test.ts +++ b/src/main/claude/claude-close-turn-end.test.ts @@ -1,6 +1,6 @@ -// Closing a Claude child settles its open turn in the adapter, with the cause the host handed the -// close. Only a stop the user aimed at this chat reads as their cancellation; an exit the adapter -// saw before the close settles as that exit. +// Closing a Claude child settles its open turn in the adapter as interrupted, with no verdict: the +// close names no cause. Whether the end was a person's is the journal's Stop event to say; an exit +// the adapter saw before the close settles as that exit. import { describe, expect, it } from 'vitest' import type { @@ -53,33 +53,24 @@ async function childInsideTurn() { return { adapter, connection, events, turns } } -describe('a Claude close settles the open turn with the host-named cause', () => { - it("records the user's close of this chat as their cancellation", async () => { +describe('a Claude close settles the open turn with no verdict of its own', () => { + // Whose end it was is the journal's Stop event to say (`turnEndAfterStop`), never the close's. + it('ends it interrupted, with no outcome', async () => { const { adapter, events, turns } = await childInsideTurn() - await expect(adapter.closeSession('session-1', 'user-close')).resolves.toBe(true) - - expect(turns.at(-1)).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) - expect(events.find((event) => event.type === 'ended')).toMatchObject({ - stopCause: 'user-close' - }) - }) - - it('leaves an eviction as news', async () => { - const { adapter, turns } = await childInsideTurn() - - await adapter.closeSession('session-1', 'evict') + await expect(adapter.closeSession('session-1')).resolves.toBe(true) expect(turns.at(-1)).toMatchObject({ state: 'interrupted' }) expect(turns.at(-1)).not.toHaveProperty('outcome') + expect(events.find((event) => event.type === 'ended')).not.toHaveProperty('stopCause') }) - it('leaves a crash it saw before the user closed the chat as news', async () => { + it('settles a crash it saw before the close once, as that exit', async () => { const { adapter, connection, events, turns } = await childInsideTurn() - // The child dies on its own first; the user's close arrives while that exit is still settling. + // The child dies on its own first; the close arrives while that exit is still settling. connection.handlers.onExit?.(new Error('provider exited')) - await adapter.closeSession('session-1', 'user-close') + await adapter.closeSession('session-1') await tick() const settled = turns.filter((turn) => turn.state !== 'running') diff --git a/src/main/claude/claude-journal-translator-contract.ts b/src/main/claude/claude-journal-translator-contract.ts index ed508b30846e..45feeb84364a 100644 --- a/src/main/claude/claude-journal-translator-contract.ts +++ b/src/main/claude/claude-journal-translator-contract.ts @@ -2,7 +2,6 @@ import type { AgentSessionContextReport } from '../../shared/agent-session-context-usage' import type { StructuredAgentSessionSinkAdmission } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeChildToolQueries } from './claude-child-tool-queries' import type { ClaudeContextReportPart, ClaudeContextReportTarget } from './claude-context-facts' import type { ClaudeJournalPrompts } from './claude-structured-journal-prompts' @@ -15,11 +14,6 @@ export type ClaudeJournalTranslator = { /** The open turn's provider id — the same id its journal row carries, and the one * a client's Stop names. Sole owner: no reader keeps a copy to disagree with. */ readonly currentTurnId: string | null - /** Orca is stopping this turn; its error end reads as the user's cancellation when they asked. - * False when the turn is no longer open. */ - recordTurnStop: (turnId: string, cause: StructuredAgentSessionStopCause) => boolean - /** The provider refused the stop. */ - withdrawTurnStop: (turnId: string) => void /** The open turn's id while it is a conversation command's. */ readonly commandTurnId: string | null /** Makes the host's command turn the open one until the command's result ends it. */ diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index 5aca644143b3..f946e4ee9cfb 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -12,7 +12,6 @@ import { type AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { claudeCurrentTurnIdentity, claudeTurnLifecycleItem, @@ -33,9 +32,6 @@ export type ClaudeOpenTurnDeps = { export class ClaudeOpenTurn { private current: ClaudeCurrentTurn | null = null - /** The stop Orca sent, held against the turn it was sent to: it reads only while that turn is open. */ - private sentStop: { turn: ClaudeCurrentTurn; cause: StructuredAgentSessionStopCause } | null = - null /** Provider output may not reopen a turn after the session ended or a turn * failed: nothing would ever close the turn it opened, and the row would read * working for the life of the session. Only an accepted send lifts it. */ @@ -90,26 +86,6 @@ export class ClaudeOpenTurn { return this.current !== null } - get stop(): StructuredAgentSessionStopCause | null { - return this.current && this.sentStop?.turn === this.current ? this.sentStop.cause : null - } - - /** Orca is stopping `turnId`. False when that turn is no longer the open one. */ - recordStop(turnId: string, cause: StructuredAgentSessionStopCause): boolean { - if (this.current?.turnId !== turnId) { - return false - } - this.sentStop = { turn: this.current, cause } - return true - } - - /** The provider refused the stop, so the turn goes on as if none was sent. */ - withdrawStop(turnId: string): void { - if (this.current?.turnId === turnId) { - this.sentStop = null - } - } - /** Whether a turn is open inside a provider request cycle that has already * done work — the state in which the CLI folds an arriving send into it. A * cycle's first send is its opener, never a fold. */ diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts index 1fe79b626064..2609b4e919c8 100644 --- a/src/main/claude/claude-result-journaling.ts +++ b/src/main/claude/claude-result-journaling.ts @@ -15,6 +15,7 @@ import { } from './claude-structured-provider-fallback' import { claudeTurnEndForResult } from './claude-turn-lifecycle-item' import { claudeFrameParentRef, isRootClaudeFrame } from './claude-turn-opening' +import { stopIsTurnCancellation } from '../native-chat/agent-session-journal/journal-stop-turn-end' export type ClaudeResultJournalContext = Pick< ClaudeMessageJournalContext, @@ -56,15 +57,20 @@ export function journalClaudeResult( } // Read before the settle below closes it: the result reports that turn's end. const endedTurnScope = turn.turnScope - // The stop Orca sent that turn: after the user's own, an error end is their cancellation. - const stop = settlesTurn ? turn.stop : null + // Read before the settle below closes the turn: a person's Stop of it makes an error end theirs. + const turnId = settlesTurn ? turn.id : null + const stoppedByPerson = + turnId !== null && stopIsTurnCancellation(sink.journalLatestStop?.() ?? null, turnId) if (settlesTurn) { prompts.retryPendingCancellations() turn.suppressReopenOnFailure(message.is_error === true) // The turn is over however it ended, so a foreground child still // reported as working will never be settled by an event. subagents.settleTurn(turn.groupKey) - context.settle(message, commandEnd ?? claudeTurnEndForResult(message, observedAt, stop)) + context.settle( + message, + commandEnd ?? claudeTurnEndForResult(message, observedAt, stoppedByPerson) + ) // The turn is over. A block still awaiting its final keeps the text the // flush above journaled, but its live state goes: an interrupted turn // would otherwise retain that text for the life of the session. @@ -72,7 +78,7 @@ export function journalClaudeResult( streamedText.settle() } const kind = claudeProviderFrameKind(message) - const failure = claudeResultFailure(message, stop) + const failure = claudeResultFailure(message, stoppedByPerson) if (failure || !isSettledClaudeResultKind(kind)) { providerFallback.append( kind, diff --git a/src/main/claude/claude-result-outcome.ts b/src/main/claude/claude-result-outcome.ts index cdbf5a529bfc..aa5e0ca6fa23 100644 --- a/src/main/claude/claude-result-outcome.ts +++ b/src/main/claude/claude-result-outcome.ts @@ -6,8 +6,6 @@ // the user's or the provider's. import type { AgentJournalTurnOutcome } from '../../shared/agent-session-journal-types' -import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { stopIsTheUsers } from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' import { claudeText } from './claude-structured-item-translation' /** The SDK reports the user's stop as an error result, so `is_error` alone cannot @@ -15,16 +13,17 @@ import { claudeText } from './claude-structured-item-translation' const CLAUDE_ABORTED_TERMINAL_REASONS = new Set(['aborted_streaming', 'aborted_tools']) /** A success-subtype result still carries `is_error` for an API error, so the flag - * is what decides, never the subtype. `stop` is the stop Orca sent for this turn: an error - * end after the user's own is their cancellation, since older CLIs name no reason. */ + * is what decides, never the subtype. `stoppedByPerson`: a person's Stop event names this turn + * (`stopIsTurnCancellation`), so an error end is their cancellation, since older CLIs name no + * reason. */ export function claudeResultOutcome( message: Record, - stop: StructuredAgentSessionStopCause | null = null + stoppedByPerson = false ): AgentJournalTurnOutcome { if (message.is_error !== true) { return 'success' } - if (stop !== null && stopIsTheUsers(stop)) { + if (stoppedByPerson) { return 'cancellation' } const reason = claudeText(message.terminal_reason) diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index e4aa15352c3f..4ba08d3b642c 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -249,8 +249,6 @@ describe('answerClaudePrompt', () => { resolve: resolvePrompt }, currentTurnId: null, - recordTurnStop: () => true, - withdrawTurnStop: () => {}, commandTurnId: null, beginCommand: vi.fn(), forgetCommand: vi.fn(), diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index 412439a0be95..54629df0fcda 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -4,7 +4,6 @@ import { ClaudeControlRequestError } from './claude-stream-json-connection' import { settleCancelledClaudeDispatchWaiters } from './claude-structured-dispatch' import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' import type { ClaudeSession } from './claude-structured-session-state' -import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' const INTERRUPT_CANCEL_QUEUED_CAPABILITY = 'interrupt_cancel_queued_v1' @@ -26,8 +25,7 @@ export async function cancelClaudeTurn( session: ClaudeSession, timeoutMs: number | undefined, isCurrent: ClaudeTurnCancellationGuard = () => true, - onDispatchSettledLate?: ClaudeLateDispatchSettlement, - stopped?: { turnId: string; cause: StructuredAgentSessionStopCause } + onDispatchSettledLate?: ClaudeLateDispatchSettlement ): Promise<{ cancelled: boolean }> { // The SDK interrupt is session-scoped. Re-check the caller's turn/fence // immediately before issuing it so a delayed request cannot stop a later turn. @@ -35,10 +33,6 @@ export async function cancelClaudeTurn( return { cancelled: false } } const cancelQueued = supportsClaudeQueuedInterruptCancellation(session) - // Recorded before the interrupt goes out, so the result it provokes finds it. - if (stopped) { - session.translator?.recordTurnStop(stopped.turnId, stopped.cause) - } try { const receipt = await session.connection.interrupt({ ...(cancelQueued ? { cancelQueued: true } : {}), @@ -58,11 +52,8 @@ export async function cancelClaudeTurn( return { cancelled: true } } catch (error) { if (error instanceof ClaudeControlRequestError) { - // The CLI refused, so the turn runs on and its own end means what it says. Any other error - // leaves the interrupt's effect unknown, and the stop the user asked for stands. - if (stopped) { - session.translator?.withdrawTurnStop(stopped.turnId) - } + // The CLI refused, so the turn runs on: the host answers the Stop's event with a refusal. + // Any other error leaves the interrupt's effect unknown, and the Stop stands. return { cancelled: false } } throw error diff --git a/src/main/claude/claude-structured-in-turn-stop.test.ts b/src/main/claude/claude-structured-in-turn-stop.test.ts index c5bbc51d8bc7..4575179dca08 100644 --- a/src/main/claude/claude-structured-in-turn-stop.test.ts +++ b/src/main/claude/claude-structured-in-turn-stop.test.ts @@ -1,10 +1,12 @@ // A user's Stop inside a live Claude chat interrupts the turn and keeps the session. The turn's end -// then comes from the CLI's result frame, which CLIs before 2.1.91 send with no terminal_reason. +// then comes from the CLI's result frame, which CLIs before 2.1.91 send with no terminal_reason; the +// host's Stop event, written before the interrupt, says whose end it was. import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { JournalLatestStop } from '../native-chat/agent-session-journal/journal-stop-turn-end' import { ClaudeControlRequestError } from './claude-stream-json-connection' import { PROVIDER_SESSION_ID, @@ -28,8 +30,11 @@ async function runningChat(claude: ReturnType): Promise<{ bodies: Map connection: FakeConnection turnId: string + /** What the host's Stop writes before the interrupt, and its refusal answer after one. */ + stopEvent: (turnId: string, refused?: boolean) => void }> { const bodies = new Map() + let latestStop: JournalLatestStop | null = null const adapter = adapterFor(claude) await adapter.acquire({ identity: identityFor(), @@ -38,7 +43,8 @@ async function runningChat(claude: ReturnType): Promise<{ events: { appendItem: (identity, body) => bodies.set(agentJournalItemKey(identity), body), appendTombstone: (identity) => bodies.delete(agentJournalItemKey(identity)), - publish: vi.fn() + publish: vi.fn(), + journalLatestStop: () => latestStop } }) await adapter.dispatch({ @@ -56,7 +62,14 @@ async function runningChat(claude: ReturnType): Promise<{ if (!turnId) { throw new Error('expected a running turn') } - return { adapter, bodies, connection, turnId } + const stopEvent = (stoppedTurnId: string, refused = false) => { + latestStop = { + sequence: 9, + event: { reason: 'user-stop', turnId: stoppedTurnId, at: 1 }, + refused + } + } + return { adapter, bodies, connection, turnId, stopEvent } } function settled(bodies: Map, turnId: string) { @@ -80,7 +93,8 @@ describe("a user's Stop inside a live Claude chat", () => { } } }) - const { adapter, bodies, turnId } = await runningChat(claude) + const { adapter, bodies, turnId, stopEvent } = await runningChat(claude) + stopEvent(turnId) await expect(adapter.cancelTurn({ sessionId: 'session-1', turnId, fence: 7 })).resolves.toEqual( { cancelled: true } @@ -103,7 +117,9 @@ describe("a user's Stop inside a live Claude chat", () => { } } }) - const { adapter, bodies, turnId } = await runningChat(claude) + const { adapter, bodies, turnId, stopEvent } = await runningChat(claude) + // The host names the open turn on the Stop's event. + stopEvent(turnId) await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ cancelled: true @@ -127,6 +143,8 @@ describe("a user's Stop inside a live Claude chat", () => { it('keeps a Stop naming no turn off the turn after it', async () => { const claude = fakeClaude({ + // Each turn is its own uuid, as Claude's are: the Stop names the first. + replayUuids: ['user-uuid-0', 'user-uuid-1'], routes: { interrupt: () => { claude.connections[0]!.handlers.onMessage?.(CUT_SHORT) @@ -134,7 +152,8 @@ describe("a user's Stop inside a live Claude chat", () => { } } }) - const { adapter, bodies, connection } = await runningChat(claude) + const { adapter, bodies, connection, turnId, stopEvent } = await runningChat(claude) + stopEvent(turnId) await adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) await adapter.dispatch({ @@ -168,11 +187,14 @@ describe("a user's Stop inside a live Claude chat", () => { } } }) - const { adapter, bodies, connection, turnId } = await runningChat(claude) + const { adapter, bodies, connection, turnId, stopEvent } = await runningChat(claude) + stopEvent(turnId) await expect( adapter.cancelTurn({ sessionId: 'session-1', ...(named ? { turnId } : {}), fence: 7 }) ).resolves.toEqual({ cancelled: false }) + // The host answers the refused interrupt on the Stop's event. + stopEvent(turnId, true) connection.handlers.onMessage?.(CUT_SHORT) expect(settled(bodies, turnId)).toMatchObject({ state: 'completed', outcome: 'failure' }) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index dcf3a3a67209..c036b7d67405 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -1,7 +1,3 @@ -import { - childEndCauseOfEndedEvent, - turnVerdictForChildEnd -} from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' @@ -189,11 +185,9 @@ export function createClaudeJournalTranslator( streamedText.flush() subagents.settleSession() backgroundTasks.settleSession() - // The host saw the child end, so the turn's end is observed, not lost; its verdict is only - // what the host's own cause says, a user's stop of this chat or else news. - turn.settle( - turnVerdictForChildEnd(childEndCauseOfEndedEvent(event), event.observedAt ?? Date.now()) - ) + // The host saw the child end, so the turn's end is observed, not lost. Whether it was a + // person's Stop is the journal's Stop event to say (`turnEndAfterStop`), else it is news. + turn.settle({ state: 'interrupted', completedAt: event.observedAt ?? Date.now() }) // A frame that arrives after the child is gone must not open a turn no // event can close. turn.suppressReopen() @@ -282,8 +276,6 @@ export function createClaudeJournalTranslator( get currentTurnId() { return turn.id }, - recordTurnStop: (turnId, cause) => turn.recordStop(turnId, cause), - withdrawTurnStop: (turnId) => turn.withdrawStop(turnId), get commandTurnId() { return turn.command ? turn.id : null }, diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index 1014455324ea..78c63af80752 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -75,16 +75,7 @@ function cancelClaudeConversation( session.fence === request.fence && session.acquisitionGeneration === acquisitionGeneration && (claudeLiveTurnId(session, request) !== null || session.dispatchWaiters.length > 0) - // A turn is cancelled only at a client's request, so the stop is the user's: on the named turn, - // else on whatever turn is open. - const stoppedTurnId = request.turnId ?? session.translator?.currentTurnId ?? null - return cancelClaudeTurn( - session, - timeoutMs, - isCurrent, - onDispatchSettledLate, - stoppedTurnId === null ? undefined : { turnId: stoppedTurnId, cause: 'user-stop' } - ) + return cancelClaudeTurn(session, timeoutMs, isCurrent, onDispatchSettledLate) } export async function cancelClaudeStructuredTurn(input: { @@ -175,7 +166,6 @@ export async function cancelClaudeStructuredTurn(input: { : compactionOwnsTurn() || (ownsRequestedTurn() && dispatchAdmissionAllowsCancellation())) let interruptConfirmed = false try { - // A turn is cancelled only at a client's request, so the stop is the user's. const result = await cancelClaudeTurn( session, timeoutMs, @@ -187,8 +177,7 @@ export async function cancelClaudeStructuredTurn(input: { } return current }, - input.onDispatchSettledLate, - { turnId: requestedTurnId, cause: 'user-stop' } + input.onDispatchSettledLate ) if (result.cancelled && claim && cancellationObserved) { interruptConfirmed = true diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts index 0da42846fe54..c73f22105999 100644 --- a/src/main/claude/claude-structured-provider-fallback.ts +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -16,7 +16,6 @@ import { type ClaudeMessageEnvelope } from './claude-structured-item-translation' import { claudeResultOutcome } from './claude-result-outcome' -import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeRowStamp } from './claude-provisional-row-corrections' import { CLAUDE_API_RETRY_FRAME_KIND, @@ -53,11 +52,11 @@ export function isSettledClaudeResultKind(kind: string): boolean { */ export function claudeResultFailure( message: Record, - stop: StructuredAgentSessionStopCause | null = null + stoppedByPerson = false ): { text: string | null } | null { // A cancellation is not a fault and earns no error row; the outcome classifier // owns that distinction so this reader cannot drift from the turn's verdict. - if (claudeResultOutcome(message, stop) !== 'failure') { + if (claudeResultOutcome(message, stoppedByPerson) !== 'failure') { return null } const result = claudeText(message.result)?.trim() diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 43576cc44e96..0ca9ca2a3b9e 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -3,8 +3,7 @@ import { dispatchClaudeCommand } from './claude-structured-command-dispatch' import type { AgentSessionAcquisition, StructuredAgentSessionAcquireInput, - StructuredAgentSessionAdapter, - StructuredAgentSessionStopCause + StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { stopClaudeBackgroundTasks } from './claude-structured-control-actions' import { dispatchClaudeTurn } from './claude-structured-dispatch' @@ -283,30 +282,21 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda ...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {}) }) - closeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => + closeSession = (sessionId: string): Promise => // After the close, not before: releasing an exit still settling settles it on the way. - this.closeSessionProcess(sessionId, cause).finally(() => - this.settledExitErrors.delete(sessionId) - ) + this.closeSessionProcess(sessionId).finally(() => this.settledExitErrors.delete(sessionId)) - private closeSessionProcess( - sessionId: string, - cause: StructuredAgentSessionStopCause | undefined - ): Promise { + private closeSessionProcess(sessionId: string): Promise { // An exit seen first settles as that exit, whoever asked for the close after it. if (this.exits.has(sessionId)) { return this.releaseAcquisition({ sessionId }) } - return this.afterClose(sessionId, () => this.closeProviderSession(sessionId, cause)) + return this.afterClose(sessionId, () => this.closeProviderSession(sessionId)) } - private closeProviderSession = ( - sessionId: string, - stopCause?: StructuredAgentSessionStopCause - ): Promise => + private closeProviderSession = (sessionId: string): Promise => closeClaudeSession({ sessionId, - ...(stopCause ? { stopCause } : {}), sessions: this.sessions, acquisitions: this.acquisitions, ...(this.deps.persistHandle ? { persistHandle: this.deps.persistHandle } : {}), diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index 91a0df1e2b19..f49b6a8d88f9 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -6,7 +6,6 @@ import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' import { cancelClaudeAcquisitionAttempt } from './claude-structured-session-state' -import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { AgentSessionAcquisitionExitUnprovenError, AgentSessionAcquisitionRootExitObservedError, @@ -78,8 +77,6 @@ export function settleClaudeExitedSession(session: ClaudeSession): void { type CloseClaudePublishedSessionInput = { sessions: Map sessionId: string - /** Who asked for the close; the translator settles the open turn with it. */ - stopCause?: StructuredAgentSessionStopCause persistHandle?: (handle: { sessionId: string providerSessionId: string @@ -138,7 +135,6 @@ async function finalizeClaudePublishedSession( type: 'ended', sessionId: input.sessionId, reason: 'claude session closed', - ...(input.stopCause ? { stopCause: input.stopCause } : {}), observedAt: Date.now() } as const let callbackError: unknown @@ -246,7 +242,6 @@ export function closeClaudePublishedSessionForDeps( export async function closeClaudeSession(input: { sessionId: string - stopCause?: StructuredAgentSessionStopCause sessions: Map acquisitions: ClaudeAcquisitionRegistry persistHandle?: (handle: { diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index fd51d2987e02..0d43dbb99ec8 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -5,10 +5,7 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { - StructuredAgentSessionStartedEvent, - StructuredAgentSessionStopCause -} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionStartedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection, openClaudeStreamJsonConnection @@ -76,8 +73,6 @@ export type ClaudeStructuredSessionEvent = failure?: SubmissionRejectionFact /** Present for first-hand child exits so the host can fence recovery. */ cause?: 'unexpected-exit' | 'requested-close' - /** Who asked for a close; the translator settles the open turn with it. */ - stopCause?: StructuredAgentSessionStopCause fence?: number acquisitionGeneration?: string /** Host clock when the end was observed. */ diff --git a/src/main/claude/claude-turn-lifecycle-item.ts b/src/main/claude/claude-turn-lifecycle-item.ts index da3af8823093..63cf708f1d47 100644 --- a/src/main/claude/claude-turn-lifecycle-item.ts +++ b/src/main/claude/claude-turn-lifecycle-item.ts @@ -5,7 +5,6 @@ import type { } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' -import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionAppendOptions } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { claudeResultOutcome } from './claude-result-outcome' import type { ClaudeCommandTurn } from './claude-command-turn' @@ -46,9 +45,9 @@ export type ClaudeTurnEnd = { export function claudeTurnEndForResult( message: Record, completedAt: number, - stop: StructuredAgentSessionStopCause | null = null + stoppedByPerson = false ): ClaudeTurnEnd { - const outcome = claudeResultOutcome(message, stop) + const outcome = claudeResultOutcome(message, stoppedByPerson) const durationMs = message.duration_ms return { state: outcome === 'cancellation' ? 'interrupted' : 'completed', diff --git a/src/main/claude/claude-turn-outcome.test.ts b/src/main/claude/claude-turn-outcome.test.ts index c8de34cee080..97fdf36de938 100644 --- a/src/main/claude/claude-turn-outcome.test.ts +++ b/src/main/claude/claude-turn-outcome.test.ts @@ -12,6 +12,8 @@ import { import { describeNativeChatTurnStatus } from '../../shared/native-chat-turn-status' import { selectStructuredAgentSettledTurns } from '../../shared/structured-agent-session-turn-timing' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { JournalStopEvent } from '../native-chat/agent-session-journal/journal-row-schema' +import type { JournalLatestStop } from '../native-chat/agent-session-journal/journal-stop-turn-end' import { claudeResultOutcome } from './claude-result-outcome' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { claudeResultFailure } from './claude-structured-provider-fallback' @@ -233,10 +235,24 @@ describe('a turn end the host inferred', () => { }) }) -describe("a user's Stop inside a live turn", () => { +describe("a person's Stop inside a live turn", () => { // Claude CLIs before 2.1.91 send no terminal_reason, and later ones may omit it. const cutShort = { type: 'result', subtype: 'error_during_execution', is_error: true } + /** A sink whose journal's latest Stop event is `stop`, as the host's would answer. */ + function sinkWithStop(stop: JournalLatestStop | null) { + const state = sinkState() + return { ...state, sink: { ...state.sink, journalLatestStop: () => stop } } + } + + function stopOf( + turnId: string, + reason: JournalStopEvent['reason'] = 'user-stop', + refused = false + ): JournalLatestStop { + return { sequence: 9, event: { reason, turnId, at: 1 }, refused } + } + function settledTurn(items: ReturnType['items'], turnId: string) { return items .map((item) => readAgentJournalTurn(item.body)) @@ -252,11 +268,10 @@ describe("a user's Stop inside a live turn", () => { } it('reads an error result with no terminal reason as the cancellation it asked for', () => { - const state = sinkState() + const state = sinkWithStop(stopOf('user-1')) const translator = createClaudeJournalTranslator({ sink: state.sink }) translator.handle(userTurn('user-1')) - expect(translator.recordTurnStop('user-1', 'user-stop')).toBe(true) translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) expect(settledTurn(state.items, 'user-1')).toMatchObject({ @@ -273,7 +288,7 @@ describe("a user's Stop inside a live turn", () => { { ...cutShort, terminal_reason: 'api_error', result: 'API Error' } ] ])('keeps a result with %s and no Stop a failure', (_label, frame) => { - const state = sinkState() + const state = sinkWithStop(null) const translator = createClaudeJournalTranslator({ sink: state.sink }) translator.handle(userTurn('user-1')) @@ -287,11 +302,10 @@ describe("a user's Stop inside a live turn", () => { }) it('keeps a turn that finished during the Stop a success', () => { - const state = sinkState() + const state = sinkWithStop(stopOf('user-1')) const translator = createClaudeJournalTranslator({ sink: state.sink }) translator.handle(userTurn('user-1')) - translator.recordTurnStop('user-1', 'user-stop') translator.handle({ type: 'message', sessionId: 'orca-session', @@ -302,15 +316,12 @@ describe("a user's Stop inside a live turn", () => { }) it('does not carry a Stop onto the next turn', () => { - const state = sinkState() + // The Stop named user-1, which a newer send superseded before its result. + const state = sinkWithStop(stopOf('user-1')) const translator = createClaudeJournalTranslator({ sink: state.sink }) translator.handle(userTurn('user-1')) - translator.recordTurnStop('user-1', 'user-stop') - // Superseded before its result: the Stop was for user-1 only. translator.handle(userTurn('user-2')) translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) - // A late Stop names a turn that already ended. - expect(translator.recordTurnStop('user-2', 'user-stop')).toBe(false) translator.handle(userTurn('user-3')) translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) @@ -319,10 +330,9 @@ describe("a user's Stop inside a live turn", () => { }) it('ends the Stop with its turn, so a result after the turn settled reads on its own', () => { - const state = sinkState() + const state = sinkWithStop(stopOf('user-1')) const translator = createClaudeJournalTranslator({ sink: state.sink }) translator.handle(userTurn('user-1')) - translator.recordTurnStop('user-1', 'user-stop') translator.handle({ type: 'message', sessionId: 'orca-session', @@ -334,24 +344,21 @@ describe("a user's Stop inside a live turn", () => { expect(providerRows(state.items)).toBe(1) }) - it('forgets a Stop the CLI refused', () => { - const state = sinkState() + it('reads a Stop the CLI refused as no Stop', () => { + const state = sinkWithStop(stopOf('user-1', 'user-stop', true)) const translator = createClaudeJournalTranslator({ sink: state.sink }) translator.handle(userTurn('user-1')) - translator.recordTurnStop('user-1', 'user-stop') - translator.withdrawTurnStop('user-1') translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) expect(settledTurn(state.items, 'user-1')).toMatchObject({ outcome: 'failure' }) }) - it('does not read a host stop as the user asking', () => { - const state = sinkState() + it.each(['host-stop', 'evict'] as const)('does not read a %s as the person asking', (reason) => { + const state = sinkWithStop(stopOf('user-1', reason)) const translator = createClaudeJournalTranslator({ sink: state.sink }) translator.handle(userTurn('user-1')) - translator.recordTurnStop('user-1', 'host-stop') translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) expect(settledTurn(state.items, 'user-1')).toMatchObject({ outcome: 'failure' }) diff --git a/src/main/claude/claude-turn-ownership.test.ts b/src/main/claude/claude-turn-ownership.test.ts index a7255aab558a..6bfcb32cb863 100644 --- a/src/main/claude/claude-turn-ownership.test.ts +++ b/src/main/claude/claude-turn-ownership.test.ts @@ -95,8 +95,6 @@ function sessionHoldingTurn(turnId: string | null): ReturnType true, - withdrawTurnStop: () => {}, commandTurnId: null, beginCommand: vi.fn(), forgetCommand: vi.fn(), diff --git a/src/main/codex/codex-close-stop-cause.test.ts b/src/main/codex/codex-close-turn-end.test.ts similarity index 59% rename from src/main/codex/codex-close-stop-cause.test.ts rename to src/main/codex/codex-close-turn-end.test.ts index 69618f22dd87..1e3475301bf0 100644 --- a/src/main/codex/codex-close-stop-cause.test.ts +++ b/src/main/codex/codex-close-turn-end.test.ts @@ -1,10 +1,9 @@ -// Closing a Codex child settles its open turn in the adapter, with the cause the host handed the -// close. Only a stop the user aimed at this chat reads as their cancellation. +// Closing a Codex child settles its open turn in the adapter as interrupted, with no verdict: the +// close names no cause. Whether the end was a person's is the journal's Stop event to say. import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' -import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' @@ -75,48 +74,26 @@ function sessionWithRunningTurn() { return { sessions: new Map([['session-1', session]]), session, turnBodies } } -describe('a Codex close settles the open turn with the host-named cause', () => { - it.each(['user-close', 'user-stop'] satisfies StructuredAgentSessionStopCause[])( - "records the user's %s as their cancellation", - async (stopCause) => { - const { sessions, turnBodies } = sessionWithRunningTurn() - const onEvent = vi.fn() - - await expect( - closeCodexPublishedSession(sessions, 'session-1', onEvent, { stopCause }) - ).resolves.toBe(true) - - expect(turnBodies).toEqual([ - expect.objectContaining({ turnId: 'turn-1', state: 'interrupted', outcome: 'cancellation' }) - ]) - expect(onEvent).toHaveBeenCalledWith( - expect.objectContaining({ type: 'ended', cause: 'requested-close', stopCause }) - ) - } - ) - - it.each([['evict'], ['host-stop'], [undefined]] as const)( - 'leaves a close for %s as news', - async (stopCause) => { - const { sessions, turnBodies } = sessionWithRunningTurn() +describe('a Codex close settles the open turn with no verdict of its own', () => { + // Whose end it was is the journal's Stop event to say (`turnEndAfterStop`), never the close's. + it('ends it interrupted at the exit, with no outcome', async () => { + const { sessions, turnBodies } = sessionWithRunningTurn() + const onEvent = vi.fn() - await closeCodexPublishedSession( - sessions, - 'session-1', - undefined, - stopCause ? { stopCause } : {} - ) + await expect(closeCodexPublishedSession(sessions, 'session-1', onEvent)).resolves.toBe(true) - expect(turnBodies).toEqual([ - expect.objectContaining({ turnId: 'turn-1', state: 'interrupted' }) - ]) - expect(turnBodies[0]).not.toHaveProperty('outcome') - } - ) + expect(turnBodies).toEqual([ + expect.objectContaining({ turnId: 'turn-1', state: 'interrupted' }) + ]) + expect(turnBodies[0]).not.toHaveProperty('outcome') + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'ended', cause: 'requested-close' }) + ) + }) - it('leaves a crash it saw before the user closed the chat as news', async () => { + it('settles a crash it saw before the close once, as that exit', async () => { const { sessions, session, turnBodies } = sessionWithRunningTurn() - // The child died on its own first; the user's close then finds it already ended. + // The child died on its own first; the close then finds it already ended. handleCodexSessionExit({ sessions, sessionId: 'session-1', @@ -124,7 +101,7 @@ describe('a Codex close settles the open turn with the host-named cause', () => error: new Error('app-server exited') }) - await closeCodexPublishedSession(sessions, 'session-1', undefined, { stopCause: 'user-close' }) + await closeCodexPublishedSession(sessions, 'session-1') expect(turnBodies).toEqual([ expect.objectContaining({ turnId: 'turn-1', state: 'interrupted' }) @@ -132,18 +109,7 @@ describe('a Codex close settles the open turn with the host-named cause', () => expect(turnBodies[0]).not.toHaveProperty('outcome') }) - it('never carries a user cause onto a close forced after a sink failure', async () => { - const { sessions, turnBodies } = sessionWithRunningTurn() - - await closeCodexPublishedSession(sessions, 'session-1', undefined, { - requestedClose: false, - stopCause: 'user-close' - }) - - expect(turnBodies[0]).not.toHaveProperty('outcome') - }) - - it("carries the host's cause from the adapter's close to the ended batch", async () => { + it("settles the same through the adapter's close", async () => { const { sessions, turnBodies } = sessionWithRunningTurn() const teardown = new CodexStructuredSessionTeardown({ sessions, @@ -151,8 +117,11 @@ describe('a Codex close settles the open turn with the host-named cause', () => forgetNotificationRetries: () => {} }) - await expect(teardown.close('session-1', 'user-close')).resolves.toBe(true) + await expect(teardown.close('session-1')).resolves.toBe(true) - expect(turnBodies[0]).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(turnBodies).toEqual([ + expect.objectContaining({ turnId: 'turn-1', state: 'interrupted' }) + ]) + expect(turnBodies[0]).not.toHaveProperty('outcome') }) }) diff --git a/src/main/codex/codex-provider-retry-idle-sweep.test.ts b/src/main/codex/codex-provider-retry-idle-sweep.test.ts index b1e8b8b6510c..8836c3157117 100644 --- a/src/main/codex/codex-provider-retry-idle-sweep.test.ts +++ b/src/main/codex/codex-provider-retry-idle-sweep.test.ts @@ -132,7 +132,7 @@ describe('a Codex reconnecting a dropped stream', () => { // Once the frames stop, the same clock does let the sweep close it. clock += STRUCTURED_AGENT_SESSION_IDLE_MS await vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict') + expect(closeSession).toHaveBeenCalledWith(SESSION) expect(host.hasSession(SESSION)).toBe(false) }) }) diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index 7200e5bc76ac..20fd8bf58df3 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -1,7 +1,3 @@ -import { - childEndCauseOfEndedEvent, - turnVerdictForChildEnd -} from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' import { createCodexProviderActivityReader } from '../native-chat/agent-session-wire/provider-frame-activity' import { CODEX_TOKEN_USAGE_METHOD } from './codex-subagent-activity' import { @@ -162,18 +158,14 @@ export function createCodexJournalTranslator( currentTurnIds: activeTurns.byThread, primaryThreadId: deps.primaryThreadId?.() ?? null, ordinals: items.ordinals, - // The host saw the child go, not what Codex made of the turn: the verdict is only what - // the host's own cause says, a user's stop of this chat or else news. + // The host saw the child go, not what Codex made of the turn: whether it was a person's + // Stop is the journal's Stop event to say (`turnEndAfterStop`), else it is news. settledTurnLifecycle: (threadId, turnId) => turnBoundaries.ownsRecord(threadId, turnId) - ? turnBoundaries.settled( - threadId, - turnId, - turnVerdictForChildEnd( - childEndCauseOfEndedEvent(event), - event.observedAt ?? deps.now?.() ?? Date.now() - ) - ) + ? turnBoundaries.settled(threadId, turnId, { + state: 'interrupted', + completedAt: event.observedAt ?? deps.now?.() ?? Date.now() + }) : null, attributionFor }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 2ab884d7cd1e..e0b1a93b395a 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -9,8 +9,7 @@ import type { AgentSessionDispatchOutcome, StructuredAgentSessionAcquireInput, StructuredAgentSessionAdapter, - StructuredAgentSessionSetOptionInput, - StructuredAgentSessionStopCause + StructuredAgentSessionSetOptionInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation' import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' @@ -286,11 +285,9 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap identity: AgentSessionJournalIdentity }): Promise => this.sessions.get(input.identity.sessionId)?.historyPath ?? null - closeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => - this.teardown.close(sessionId, cause) + closeSession = (sessionId: string): Promise => this.teardown.close(sessionId) forceCloseSession = (sessionId: string): Promise => this.teardown.forceClose(sessionId) - disposeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => - this.teardown.close(sessionId, cause) + disposeSession = (sessionId: string): Promise => this.teardown.close(sessionId) closeAll = (): Promise => this.teardown.closeAll() releaseAcquisition = (input: { sessionId: string }): Promise => this.teardown.close(input.sessionId) diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 0a017f37f191..5bf973308b10 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -8,10 +8,7 @@ import { type CodexStructuredSessionAdapterDeps, type CodexStructuredSessionEvent } from './codex-structured-session-state' -import type { - StructuredAgentSessionEndedEvent, - StructuredAgentSessionStopCause -} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' export function handleCodexSessionExit(input: { sessions: Map @@ -43,9 +40,6 @@ export function handleCodexSessionExit(input: { ? agentSessionFailureFact('hostFault') : agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(input.error) }), cause: session.requestedClose ? 'requested-close' : 'unexpected-exit', - ...(session.requestedClose && session.closeStopCause - ? { stopCause: session.closeStopCause } - : {}), fence: session.fence, acquisitionGeneration: session.acquisitionGeneration, observedAt: session.exitObservedAt @@ -80,8 +74,6 @@ export async function closeCodexPublishedSession( options?: { allowFailedSettlement?: boolean requestedClose?: boolean - /** Who asked for a requested close; the translator settles the open turn with it. */ - stopCause?: StructuredAgentSessionStopCause expectedFence?: number expectedAcquisitionGeneration?: string unexpectedReason?: Error @@ -101,7 +93,6 @@ export async function closeCodexPublishedSession( // Sink-failure recovery force-closes the child but must preserve the // observed-exit cause so host lease settlement runs as an unexpected death. session.requestedClose = options?.requestedClose ?? true - session.closeStopCause = options?.stopCause // Keep the session indexed until the child exit is observed. A timeout or // failed kill must leave the live connection available for a safe retry. const exited = await session.connection.close() @@ -133,8 +124,7 @@ export async function closeCodexSession( sessionId: string, sessions: Map, acquisitions: CodexAcquisitionRegistry, - onEvent?: (event: CodexStructuredSessionEvent) => void, - stopCause?: StructuredAgentSessionStopCause + onEvent?: (event: CodexStructuredSessionEvent) => void ): Promise { const attempt = acquisitions.get(sessionId) if (!(await cancelCodexAcquisitionAttempt(attempt))) { @@ -143,7 +133,7 @@ export async function closeCodexSession( if (attempt) { acquisitions.deleteIfCurrent(sessionId, attempt) } - return closeCodexPublishedSession(sessions, sessionId, onEvent, stopCause ? { stopCause } : {}) + return closeCodexPublishedSession(sessions, sessionId, onEvent) } export async function closeAllCodexSessions( diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 78864fe90448..fcfb120fe821 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -19,10 +19,7 @@ import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexJournalTranslator } from './codex-structured-journal-translation' -import type { - StructuredAgentSessionEndedEvent, - StructuredAgentSessionStopCause -} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' import type { AgentModelCatalogSessionAccess, @@ -114,8 +111,6 @@ export type CodexSession = { /** First observed child exit survives rejected settlement admission. */ exitObservedAt?: number requestedClose: boolean - /** Who asked for the requested close in flight, carried onto its `ended`. */ - closeStopCause?: StructuredAgentSessionStopCause fence: number acquisitionGeneration: string threadId: string diff --git a/src/main/codex/codex-structured-session-teardown.ts b/src/main/codex/codex-structured-session-teardown.ts index f302e15b3671..dcd38eb82fe2 100644 --- a/src/main/codex/codex-structured-session-teardown.ts +++ b/src/main/codex/codex-structured-session-teardown.ts @@ -5,7 +5,6 @@ // proven stopped — a refused close leaves the session indexed for a retry. import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' -import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { closeAllCodexSessions, closeCodexPublishedSession, @@ -31,13 +30,12 @@ export type CodexStructuredSessionTeardownDeps = { export class CodexStructuredSessionTeardown { constructor(private readonly deps: CodexStructuredSessionTeardownDeps) {} - close = async (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => { + close = async (sessionId: string): Promise => { const closed = await closeCodexSession( sessionId, this.deps.sessions, this.deps.acquisitions, - this.deps.onEvent, - cause + this.deps.onEvent ) return this.settled(sessionId, closed) } diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts index 016fe47d7f9e..1f03dd9e2388 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 @@ -80,31 +80,59 @@ function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { } } +/** Whether `stop` makes the end of turn `turnId` a person's cancellation: a person's Stop or close + * that named that turn (`opened`: or named none, and the turn opened under it), and that no + * refusal answered. */ +export function stopIsTurnCancellation( + stop: JournalLatestStop | null, + turnId: string, + opened?: { createdAt: number | null; latestPersonTurnSequence: number } +): boolean { + if (stop === null || stop.refused || !stopIsAPersons(stop.event.reason)) { + return false + } + if (stop.event.turnId !== undefined || !opened) { + return stop.event.turnId === turnId + } + // Pressed before the turn showed: the turn its row opened after the Stop, unless a send a + // person made since was accepted, whose turn it would be. + return ( + (opened.createdAt === null || opened.createdAt > stop.sequence) && + opened.latestPersonTurnSequence < stop.sequence + ) +} + /** * 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 while it was still open (running, * or unproven) and which no refusal answered. A provider's own verdict always stands. */ export function turnEndAfterStop( - state: Pick, + state: Pick< + JournalReducerState, + 'items' | 'queuePauseMarks' | 'latestStopRefusal' | 'latestPersonTurnSequence' + >, itemId: string, body: AgentJournalItemBody ): AgentJournalItemBody { if (body.kind !== 'turn' || body.state !== 'interrupted' || body.outcome !== undefined) { return body } - const previous = readAgentJournalTurn(state.items.get(itemId)?.body) + const existing = state.items.get(itemId) + const previous = readAgentJournalTurn(existing?.body) // An end already written stands: the Stop came after it. if (previous && previous.state !== 'running' && previous.state !== 'unverifiable') { return body } const stop = journalLatestStop(state) - return stop && - !stop.refused && - stop.event.turnId === body.turnId && - stopIsAPersons(stop.event.reason) && - // An exit the provider saw before the Stop was news, whenever its end is written. - (body.completedAt === undefined || body.completedAt >= stop.event.at) - ? { ...body, outcome: 'cancellation' } - : body + const opened = { + createdAt: existing?.sequence ?? null, + latestPersonTurnSequence: state.latestPersonTurnSequence + } + if (!stop || !stopIsTurnCancellation(stop, body.turnId, opened)) { + return body + } + // An exit the provider saw before the Stop was news, whenever its end is written. + const endedAfterStop = body.completedAt === undefined || body.completedAt >= stop.event.at + return endedAfterStop ? { ...body, outcome: 'cancellation' } : body } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts index fb6c001b320a..cc8decab8ffb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts @@ -162,7 +162,7 @@ describe('StructuredAgentSessionAdapterRouter optional lifecycle methods', () => await expect(stopSession('session-1')).resolves.toBe(true) // A host with no cause to name passes none; the adapter settles its turn as news. - expect(closeSession).toHaveBeenCalledWith('session-1', undefined) + expect(closeSession).toHaveBeenCalledWith('session-1') } ) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 72291de48245..9a759ba0411b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -4,10 +4,7 @@ import type { AgentSessionAccountHome, AgentSessionExecutionLocation } from '../../../shared/agent-session-record' -import type { - StructuredAgentSessionAdapter, - StructuredAgentSessionStopCause -} from './structured-agent-session-adapter' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' type RoutedAgent = 'claude' | 'codex' type SessionRoute = { adapter: StructuredAgentSessionAdapter; state: 'live' | 'stopped' } @@ -151,21 +148,20 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi accountHome: AgentSessionAccountHome }) => this.requireAgent(input.identity).providerHistoryWindow?.(input) ?? Promise.resolve(null) - closeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => - this.stopSession(sessionId, (adapter) => adapter.closeSession, cause) + closeSession = (sessionId: string): Promise => + this.stopSession(sessionId, (adapter) => adapter.closeSession) forceCloseSession = (sessionId: string): Promise => this.stopSession(sessionId, (adapter) => adapter.forceCloseSession ?? adapter.closeSession) - disposeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => - this.stopSession(sessionId, (adapter) => adapter.disposeSession ?? adapter.closeSession, cause) + disposeSession = (sessionId: string): Promise => + this.stopSession(sessionId, (adapter) => adapter.disposeSession ?? adapter.closeSession) private async stopSession( sessionId: string, selectStop: ( adapter: StructuredAgentSessionAdapter - ) => NonNullable | undefined, - cause?: StructuredAgentSessionStopCause + ) => NonNullable | undefined ): Promise { const route = this.routes.get(sessionId) if (!route) { @@ -178,7 +174,7 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return true } const stop = selectStop(route.adapter) - const stopped = await stop?.call(route.adapter, sessionId, cause) + const stopped = await stop?.call(route.adapter, sessionId) if (stopped === true) { route.state = 'stopped' return true diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 6d19c8523bb7..f12735032984 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -38,7 +38,6 @@ import type { ProviderDiagnostic, SubmissionRejectionFact } from '../../../shared/agent-session-failure' -import type { StructuredAgentSessionStopCause } from './structured-agent-session-stop-cause' export type { StructuredAgentSessionChildEndCause, StructuredAgentSessionStopCause @@ -191,8 +190,6 @@ export type StructuredAgentSessionEndedEvent = { * Orca fault. Absent reads as a provider exit with nothing to add. */ failure?: SubmissionRejectionFact cause: 'unexpected-exit' | 'requested-close' - /** With `requested-close`: who asked for it. Absent when the host named no cause. */ - stopCause?: StructuredAgentSessionStopCause fence: number acquisitionGeneration: string /** Host receipt of the child exit: the end time of a turn it interrupted. */ @@ -382,11 +379,11 @@ export type StructuredAgentSessionAdapter = { /** Gracefully stops the structured owner after its event stream is drained. */ /** Returns true only after the provider child exit is proven. A root-exit or processless verdict * is thrown only once the session is finalized; read it through `stopAgentSessionProviderRoot`. */ - closeSession?(sessionId: string, cause?: StructuredAgentSessionStopCause): Promise + closeSession?(sessionId: string): Promise /** Stops a provider after a sink failure; the resulting exit is recovered as unexpected. */ forceCloseSession?(sessionId: string): Promise /** Stops a provider child for teardown without requiring a future-resume cursor. */ - disposeSession?(sessionId: string, cause?: StructuredAgentSessionStopCause): Promise + disposeSession?(sessionId: string): Promise /** Host acknowledgement that the proven-dead child, lease and journal owner are released. */ acknowledgeSessionRelease?(sessionId: string): void } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index 83d80437de3c..1a42f5803c86 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -1,7 +1,8 @@ // A turn the host cuts short by closing its provider is the user's cancellation only when the user // closed this chat. A quit, an idle eviction or a teardown aimed elsewhere leaves it news: the user -// needs to learn it did not finish. The adapter settles its own open turn with the cause the host -// hands its close, and the host's fallback settles any turn no adapter did, through one mapping. +// needs to learn it did not finish. The adapter settles its own open turn interrupted, the host's +// fallback settles any turn no adapter did, and both ends read the close's Stop event where the +// row is built: no cause travels with the close. import { beforeEach, describe, expect, it, vi } from 'vitest' import { @@ -15,28 +16,26 @@ import { selectStructuredAgentSettledTurns } from '../../../shared/structured-ag import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' -import type { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' import { adapter, attach, + CALLER, + envelope, hostTestState, replaceHostTestState } from './structured-agent-session-host-test-harness' import { + hostTestMessage, HOST_TEST_NOW, HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD } from './structured-agent-session-host-test-data' -import { - childEndCauseOfEndedEvent, - turnVerdictForChildEnd -} from './structured-agent-session-stale-turn-verdict' const CUT_TURN = { provider: 'codex' as const, threadId: THREAD, turnId: 'cut-turn', ordinal: 1 } let host: StructuredAgentSessionHost -/** What the provider writes on the open turn as it exits: settled through the mapping with the - * cause its close was handed, as both adapters do, or its own verdict; null writes nothing. */ +/** What the provider writes on the open turn as it exits: interrupted at the exit it saw, as both + * adapters do, or its own verdict; null writes nothing. */ let providerEnd: | 'mapped' | Pick @@ -54,22 +53,16 @@ beforeEach(() => { store: state.store, adapter: { ...adapter(), - closeSession: async (_sessionId, cause) => { + closeSession: async () => { closeCalls += 1 const events = state.acquire.mock.calls.at(-1)?.[0].events if (providerEnd === null) { return true } + // An exit it saw first ended before the close's Stop; a close it made ends after it. const end = providerEnd === 'mapped' - ? turnVerdictForChildEnd( - childEndCauseOfEndedEvent({ - type: 'ended', - cause: exitObservedFirst ? 'unexpected-exit' : 'requested-close', - ...(cause ? { stopCause: cause } : {}) - }), - 1_500 - ) + ? { state: 'interrupted' as const, completedAt: exitObservedFirst ? 1_500 : Date.now() } : providerEnd events?.appendItem( CUT_TURN, @@ -178,9 +171,15 @@ describe('a turn cut short by closing its provider', () => { }) it("records the user's close on a turn whose start landed only as the provider stopped", async () => { - // A send echo still in flight when the close arrives: the journal has no turn yet. + // A send still unanswered when the close arrives: the journal has no turn yet. await attach() + hostTestState().dispatch.mockResolvedValueOnce({ state: 'admitted' }) + const body = hostTestMessage('long job') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) await host.flushStreamedEvents(SESSION) + await vi.waitFor(async () => + expect((await host.journalSnapshot(SESSION)).submissions[0]?.handedOverAt).toBeDefined() + ) await host.close(SESSION, 'user-close') @@ -331,10 +330,3 @@ describe('a turn cut short by closing its provider', () => { } ) }) - -it('requires every stop to name its cause', () => { - type StopArgs = Parameters - // @ts-expect-error a stop that names no cause must not compile, or it would default to one - const omitted: StopArgs = [host['lifetimeContext'](), SESSION] - expect(omitted).toHaveLength(2) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index 5fd28ff3927a..f5607777ecb2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -138,7 +138,7 @@ describe('closing the handle', () => { await foundRestTestChat(rig) await rig.host.close(SESSION, 'evict') - expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict') + expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) // The stop says not-running; the row belongs to the tab, so nothing forgets it. expect(rig.sink.forget).not.toHaveBeenCalled() expect(rig.sink.publish.mock.calls.at(-1)?.[0]).toMatchObject({ sessionId: SESSION }) @@ -261,7 +261,7 @@ describe('a start that never finishes (P2-15)', () => { rig.clock.now += IDLE_MS + 1 await sweepOnce(rig.host) - expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'host-stop') + expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) await vi.waitFor(() => expect(readerSaw(reader.events).submissions).toContainEqual( expect.objectContaining({ dispatchState: 'rejected', reason: stopReason }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 1d06a61f88ca..b82fd5b305d3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -16,9 +16,9 @@ import { closeStructuredAgentSessionConversationUnderSerialize, stopStructuredAgentSessionAgentUnderSerialize, type StructuredAgentSessionCloseCause, - type StructuredAgentSessionLifetimeContext + type StructuredAgentSessionLifetimeContext, + type StructuredAgentSessionStopEnding } from './structured-agent-session-host-lifetime' -import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' @@ -48,8 +48,8 @@ export function createStructuredAgentSessionConversationLifetime(host: { session.journal.whenImported().catch((error: unknown) => { throw readRefusals.refusal(sessionId, error) }) - const stopAgent = (sessionId: string, cause: StructuredAgentSessionStopCause) => - stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) + const stopAgent = (sessionId: string, ending: StructuredAgentSessionStopEnding) => + stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, ending) const closeConversation = (sessionId: string): Promise => closeStructuredAgentSessionConversationUnderSerialize( @@ -77,7 +77,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { }, providerHoldsDispatch: (sessionId) => deps().adapter.holdsDispatch?.(sessionId) === true, // The host puts an idle agent to rest: a turn it cuts short is news, not the user's Stop. - stopAgent: (sessionId) => stopAgent(sessionId, 'evict'), + stopAgent: (sessionId) => stopAgent(sessionId, { cause: 'evict' }), // A host stop: the delivery loop waiting on this child writes the one error row and rejects // what is queued with it, both worded from the hostStopped fact. stopStartingAgent: (sessionId) => 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 2d0766a4bce8..f406cf9ecc89 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 @@ -8,6 +8,7 @@ import type { StructuredAgentSessionSinkState, StructuredAgentSessionSinkWatermarks } from './structured-agent-session-event-sink' +import type { JournalLatestStop } from '../agent-session-journal/journal-stop-turn-end' export type StructuredAgentSessionSinkOperation = { sequence: number @@ -67,6 +68,9 @@ export class StructuredAgentSessionSinkQueue { journalLinkage = (): StructuredAgentSessionLinkageJournal | null => this.target?.journal ?? null + journalLatestStop = (): JournalLatestStop | null => + this.target?.journal.stopMarks.latest() ?? null + bindReadingControl(control: StructuredAgentSessionReadingControl): () => void { this.readingControl = control if (this.backpressured) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 121fb865db4a..400c86c885a0 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 @@ -8,6 +8,7 @@ import type { import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import type { JournalLatestStop } from '../agent-session-journal/journal-stop-turn-end' import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate' import { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue' import { structuredAgentSessionJournalAppendOptions } from './structured-agent-session-journal-append-options' @@ -135,6 +136,8 @@ export type StructuredAgentSessionEventSink = { journalEpoch?(): string | null /** The bound journal's producer linkage; null until bound. */ journalLinkage?(): StructuredAgentSessionLinkageJournal | null + /** The bound journal's latest Stop event (`JournalStopMarks.latest`); null until bound. */ + journalLatestStop?(): JournalLatestStop | null appendLifecycleBatch?( settlementId: string, mutations: readonly JournalLifecycleMutationInput[], @@ -277,6 +280,7 @@ export function createDeferredStructuredAgentSessionEventSink( ...resolvedAppend, journalEpoch: queue.journalEpoch, journalLinkage: queue.journalLinkage, + journalLatestStop: queue.journalLatestStop, appendLifecycleBatch: (settlementId, mutations, options = {}) => { const admission = appendLifecycleBatch(settlementId, mutations, options) if (!admission.accepted) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts index 38349f95115e..8fc810de2a4d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts @@ -76,7 +76,7 @@ describe('structured agent session eviction', () => { await evictStructuredAgentSession(ctx) - expect(disposeSession).toHaveBeenCalledWith('session-1', undefined) + expect(disposeSession).toHaveBeenCalledWith('session-1') expect(closeSession).not.toHaveBeenCalled() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts index fc45a2c77154..226781117bdf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts @@ -19,8 +19,7 @@ import { stopAgentSessionProviderRoot, - type StructuredAgentSessionAdapter, - type StructuredAgentSessionStopCause + type StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { StructuredAgentSessionStopVerdict } from './structured-agent-session-host-types' @@ -28,8 +27,6 @@ import { withTimeout } from '../../../shared/promise-timeout-fallback' export type StructuredAgentSessionEvictionContext = { sessionId: string - /** Why the host stops the child; the adapter settles the turn it cuts with it. */ - stopCause?: StructuredAgentSessionStopCause hasProviderChild?: boolean eventSink: DeferredStructuredAgentSessionEventSink adapter: StructuredAgentSessionAdapter @@ -89,9 +86,7 @@ export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSe // An adapter with no close has nothing to stop; anything else must PROVE the exit. const stop = context.adapter.disposeSession ?? context.adapter.closeSession const rootGone = stop - ? await stopAgentSessionProviderRoot(() => - stop.call(context.adapter, context.sessionId, context.stopCause) - ) + ? await stopAgentSessionProviderRoot(() => stop.call(context.adapter, context.sessionId)) : true if (!rootGone) { throw new Error('provider child exit was not proven') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 03639dc8c2b7..63ff659702f0 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 @@ -30,7 +30,6 @@ import { } from './structured-agent-session-provider-child' import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' -import { turnVerdictForChildEnd } from './structured-agent-session-stale-turn-verdict' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' @@ -88,18 +87,33 @@ function owedProviderChildWindDown( : session.owesProviderChildWindDown } -/** How a stop ends the child: its cause, the host's text for it, and whether it is quit, whose - * resume marker's trigger already records why, so it writes no Stop event. */ -export type StructuredAgentSessionStopEnding = { - cause: StructuredAgentSessionStopCause - reason?: string - quit?: true +/** How a stop ends the child. A person's Stop wrote its event in its own step (`recorded`); 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: true } + | { + cause: Exclude + reason?: string + quit?: true + } + +/** Why the child this stop ends ended, for the delivery loop (`lastEndedChild`): a person's Stop's + * reason is its event's, which its own step wrote before it got here. */ +function childEndCause( + session: StructuredAgentSessionHostSession, + ending: StructuredAgentSessionStopEnding +): StructuredAgentSessionStopCause { + // A failed write of that event leaves the Stop no less the person's. + return 'recorded' in ending + ? (session.journal.stopMarks.latest()?.event.reason ?? 'user-stop') + : ending.cause } /** * Writes this stop's event (`JournalStopEvent`) when it ends a running turn or a start: a stop - * that ends nothing writes nothing. A person's Stop wrote its own before it reached here, and quit - * writes none. Issued before the kill and never awaited by it: bookkeeping, reported on failure. + * that ends nothing writes nothing. Issued before the kill and never awaited by it: bookkeeping, + * reported on failure. */ function recordStopEvent( context: StructuredAgentSessionLifetimeContext, @@ -110,7 +124,7 @@ function recordStopEvent( const { child, journal } = session const turnId = journal.activeTurnId() if ( - ending.cause === 'user-stop' || + 'recorded' in ending || ending.quit || !child || (child.phase !== 'starting' && @@ -138,7 +152,6 @@ function recordStopEvent( export async function stopStructuredAgentSessionAgentUnderSerialize( context: StructuredAgentSessionLifetimeContext, sessionId: string, - // Required: an omitted cause must not default to the user's cancellation. ending: StructuredAgentSessionStopEnding ): Promise { const session = context.sessions.get(sessionId) @@ -146,15 +159,11 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( return } const recorded = recordStopEvent(context, sessionId, session, ending) - // A retry finishes the stop that ended the child, so the turn that stop cut keeps its cause. - const cause = session.child - ? ending.cause - : (session.owesProviderChildWindDown?.cause ?? ending.cause) // The obligation OUTLIVES the child. `child` is ended the instant the adapter proves the exit, // so a step that aborts after that point would otherwise leave the retry reading "no child // here" and skipping the settlement and the lease release it still owes. const owed = owedProviderChildWindDown(session) - session.owesProviderChildWindDown = owed ? { ...owed, cause } : undefined + session.owesProviderChildWindDown = owed const stopping = session.child let settlementError: unknown const eviction: StructuredAgentSessionEvictionContext = { @@ -164,8 +173,6 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( owesProviderChildWindDown: owed !== undefined, eventSink: context.runtimeState.eventSinkFor(sessionId), adapter: context.deps.adapter, - // The adapter settles its own open turn with this, so who asked travels with the stop. - stopCause: cause, ...(context.restartWitness ? { beforeProviderChildStop: () => context.restartWitness?.beforeStop(sessionId) } : {}), @@ -175,8 +182,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( endProviderChild(session, { generation: stopping.generation, fence: stopping.fence, - cause, - reason: ending.reason ?? null, + cause: childEndCause(session, ending), + reason: ('reason' in ending ? ending.reason : undefined) ?? null, duringStartup: stopping.phase === 'starting', ...verdict }) @@ -196,8 +203,9 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( fence, settlementId: `expected-close:${sessionId}:${fence}:${owed?.generation ?? 'unknown'}`, pendingSubmissionReason: 'provider_closed_before_acknowledgement', - // Only a turn no adapter settled: one with no close, or whose settle threw. - verdict: turnVerdictForChildEnd(cause, context.now()), + // Only a turn no adapter settled: one with no close, or whose settle threw. Whether it was + // a person's Stop is its event's to say (`turnEndAfterStop`). + verdict: { state: 'interrupted', completedAt: context.now() }, showUnexpectedExitOutcome: false, onError: (id, error) => { settlementError = error 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 c2ec18332882..cfb1ab885e06 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 @@ -11,8 +11,7 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store import type { StructuredAgentSessionAdapter, StructuredAgentSessionChildEndCause, - StructuredAgentSessionProviderChildPhase, - StructuredAgentSessionStopCause + StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' @@ -37,11 +36,6 @@ export type StructuredAgentSessionProviderChildIdentity = { readonly fence: number } -/** A wind-down still owed, with the cause of the stop that owes it: a retry finishes that stop. */ -export type StructuredAgentSessionOwedWindDown = StructuredAgentSessionProviderChildIdentity & { - readonly cause: StructuredAgentSessionStopCause -} - /** The provider process behind a conversation. Written only in * `structured-agent-session-provider-child`. */ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProviderChildIdentity & { @@ -89,7 +83,7 @@ export type StructuredAgentSessionHostSession = { /** The wind-down this host still owes for a child it started: settling that generation's work * and handing the lease back. Outlives `child`, which ends the moment the adapter proves the * exit — an eviction that aborts after that point must still finish it on the next close. */ - owesProviderChildWindDown?: StructuredAgentSessionOwedWindDown + owesProviderChildWindDown?: StructuredAgentSessionProviderChildIdentity lastEndedChild?: StructuredAgentSessionEndedChild } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 62603707ded9..e06fc0561269 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -280,7 +280,8 @@ export class StructuredAgentSessionHost { ensureAgent: (sessionId) => ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), - stopAgent: (sessionId) => this.lifetime.stopAgent(sessionId, 'user-stop'), + // A person's Stop: its own step wrote the Stop's event first. + stopAgent: (sessionId) => this.lifetime.stopAgent(sessionId, { recorded: true }), wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), now: () => this.now() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index 533904d5fa0f..1345adc574f7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -54,7 +54,7 @@ describe('the idle sweep', () => { await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) await vi.waitFor(() => expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released')) await vi.waitFor(() => expect(rig.adapter.acknowledgeSessionRelease).toHaveBeenCalledOnce()) expect(rig.adapter.acknowledgeSessionRelease).toHaveBeenCalledWith(SESSION) @@ -122,7 +122,7 @@ describe('the idle sweep', () => { expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.adapter.backgroundTaskState.mockReturnValue(undefined) rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) }) // A finished child reads done before the lead's wake-up turn writes its first row; stopping the @@ -143,7 +143,7 @@ describe('the idle sweep', () => { await sweepTicks() expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) }) // Owed work is read every tick, not once a window: work that ends just before a window would @@ -165,7 +165,7 @@ describe('the idle sweep', () => { await sweepTicks() expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.clock.now += IDLE_MS - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) }) it('stops an agent whose roster holds only children that went idle or finished', async () => { @@ -179,7 +179,7 @@ describe('the idle sweep', () => { }) rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) }) it('never stops an agent while its lead turn runs, however quiet (P2-10)', async () => { @@ -202,7 +202,7 @@ describe('the idle sweep', () => { await rig.host.subscribe({ id: 'on-screen', sessionId: SESSION, emit: reader.emit }) rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) expect(reader.events.some((event) => event.type === 'end')).toBe(false) }) @@ -221,7 +221,7 @@ describe('the idle sweep', () => { await sweepTicks() expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.clock.now += IDLE_MS - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) }) it('never stops a worker whose orchestration dispatch is open, and stops it once it settles (P2-19 i)', async () => { @@ -237,7 +237,7 @@ describe('the idle sweep', () => { expect(hasOpenDispatch).toHaveBeenCalledWith(expect.objectContaining({ sessionId: SESSION })) open = false rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) }) // A Claude retrying a rate-limited request has taken the send but echoes nothing, so no turn row @@ -254,7 +254,7 @@ describe('the idle sweep', () => { await sweepTicks() expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) }) it('keeps a child an unanswered prompt waits on (P2-22 i)', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index aa7b8a17295f..58572980aafd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -214,7 +214,7 @@ describe('structured session options and close', () => { await host.close(SESSION, 'evict') - expect(closeNativeSession).toHaveBeenCalledWith(SESSION, 'evict') + expect(closeNativeSession).toHaveBeenCalledWith(SESSION) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', ownerProcess: null, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 230709e5d213..9d24d7e24c0a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -646,7 +646,7 @@ describe('a quit with a message still queued', () => { starting.resolve() await quit - expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict') + expect(closeSession).toHaveBeenCalledWith(SESSION) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) expect(dispatch).not.toHaveBeenCalled() expect(await afterRelaunch(id)).toMatchObject({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index 538fb0b9dbc6..ddaae1474f4f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -26,11 +26,7 @@ import { settleStaleStructuredAgentSessionState, settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' -import { - childEndCauseOfEndedEvent, - turnVerdictForChildEnd, - type StructuredAgentSessionTurnVerdict -} from './structured-agent-session-stale-turn-verdict' +import type { StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' import { StructuredAgentSessionTurnCompletionFeed } from './structured-agent-session-turn-completion-feed' @@ -195,7 +191,7 @@ describe('a turn recovery settled after its host went away', () => { ], [ 'quitting Orca', - // A quit evicts the child, and its adapter settles the open turn through the one mapping. + // A quit evicts the child, writing no Stop event, and its adapter settles the open turn. (journal: AgentSessionJournal) => journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 9 }, @@ -203,14 +199,8 @@ describe('a turn recovery settled after its host went away', () => { kind: 'turn', turnId: 'turn-1', startedAt: TURN_STARTED, - ...turnVerdictForChildEnd( - childEndCauseOfEndedEvent({ - type: 'ended', - cause: 'requested-close', - stopCause: 'evict' - }), - EXIT_OBSERVED - ) + state: 'interrupted', + completedAt: EXIT_OBSERVED }, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index 45f636eccf77..9002cff14a72 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -17,17 +17,12 @@ import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' -import type { - StructuredAgentSessionChildEndCause, - StructuredAgentSessionEndedEvent -} from './structured-agent-session-adapter' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' export type StructuredAgentSessionTurnVerdict = - /** `cancellation` only for a stop the user aimed at this chat: every other cut is news. */ - | { state: 'interrupted'; completedAt: number; outcome?: 'cancellation' } - | { state: 'unverifiable' } + /** Whose end it was is the Stop event's to say, where the row is built (`turnEndAfterStop`). */ + { state: 'interrupted'; completedAt: number } | { state: 'unverifiable' } export const UNVERIFIABLE_TURN_VERDICT: StructuredAgentSessionTurnVerdict = { state: 'unverifiable' @@ -72,45 +67,6 @@ export function stopFoundTurnLiveAt( return stop && turnId !== undefined && stop.event.turnId === turnId ? stop.event.at : undefined } -/** - * The one mapping from why a provider child ended to what the turn it cut reads as. Each adapter - * settles its own open turn through it on `ended`, and the host's fallback settles through it any - * turn no adapter did. Only a stop the user aimed at this chat is their cancellation. - */ -export function turnVerdictForChildEnd( - cause: StructuredAgentSessionChildEndCause, - completedAt: number -): Extract { - return stopIsTheUsers(cause) - ? { state: 'interrupted', completedAt, outcome: 'cancellation' } - : { state: 'interrupted', completedAt } -} - -/** Whether the user asked for this end. Only then is a cut turn their cancellation. */ -export function stopIsTheUsers(cause: StructuredAgentSessionChildEndCause): boolean { - switch (cause) { - case 'user-stop': - case 'user-close': - return true - case 'host-stop': - case 'evict': - case 'exit': - case 'attach-failed': - return false - } -} - -/** Why the child an `ended` event reports ended: who asked for a close, else an exit it had. A - * requested close with no cause named is the host's own. */ -export function childEndCauseOfEndedEvent( - event: { type: 'ended' } & Partial> -): StructuredAgentSessionChildEndCause { - if (event.cause === 'unexpected-exit') { - return 'exit' - } - return event.stopCause ?? 'host-stop' -} - /** Revises every still-running lifecycle item in place, keeping its identity and start. */ export function runningTurnLifecycleRevisions( items: readonly AgentJournalRenderItem[], @@ -187,7 +143,6 @@ function settledLifecycle( return { ...kept, state: verdict.state, - completedAt: Math.max(verdict.completedAt, began), - ...(verdict.outcome ? { outcome: verdict.outcome } : {}) + completedAt: Math.max(verdict.completedAt, began) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts index 58a36546a046..36002750cd57 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts @@ -1,5 +1,5 @@ -/** Why a provider child ended. In memory only, except `user-stop`, the one arm a Stop event - * journals so far. */ +/** Why a provider child ended, for the delivery loop (`lastEndedChild`). A stop's arms are also + * its Stop event's reason (`JournalStopEvent`); the others are in memory only. */ export type StructuredAgentSessionChildEndCause = | 'user-stop' /** The user closed this chat: its tab, its launch, or a `/clear` that replaces it. */ @@ -9,8 +9,8 @@ export type StructuredAgentSessionChildEndCause = | 'attach-failed' | 'evict' -/** Why the host asked a child to stop. The adapter carries it onto the `ended` it settles with, - * and a Stop event persists it (`JournalStopEvent.reason`), so never rename an arm. */ +/** Why a child was asked to stop. A Stop event persists it (`JournalStopEvent.reason`), which + * is what a turn's end reads, so never rename an arm. */ export type StructuredAgentSessionStopCause = Extract< StructuredAgentSessionChildEndCause, 'user-stop' | 'user-close' | 'host-stop' | 'evict' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 871c05a6e337..7d7d4861cd0b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -132,7 +132,7 @@ function emitTurnLifecycle(state: 'running' | 'completed', ordinal: number): voi /** The sweep stops the child first and closes the conversation last. */ function waitForEviction(): Promise { return vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict') + expect(closeSession).toHaveBeenCalledWith(SESSION) expect(host.hasSession(SESSION)).toBe(false) }) } @@ -247,7 +247,7 @@ describe('a chat that closes', () => { await host.close(SESSION, 'evict') - expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict') + expect(closeSession).toHaveBeenCalledWith(SESSION) expect(host.hasSession(SESSION)).toBe(false) expect(hostErrors).toEqual([]) // The record and its journal stay; only the process and the claim on it go. From 93e2f55646bd4fdf44d69a5113c2821b1e6fbbac Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:49:24 -0700 Subject: [PATCH 31/58] test(native-chat): a turn a later send opened is no Stop's that named no turn --- ...d-agent-session-stop-event-restart.test.ts | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) 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 5dc82d8efd73..987c0fd69105 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 @@ -61,19 +61,16 @@ async function runningTurn(identity: AgentJournalItemIdentity): Promise { /** Orca dies with the turn's end unwritten; the relaunch reopens the chat from disk and proves * the old child gone (its last renewal came before the Stop), then settles what it left. */ -async function restartAndSettle(proof: 'pid-absent' | 'unproven' = 'pid-absent'): Promise { +async function restartAndSettle( + proof: 'pid-absent' | 'exit-observed' | 'unproven' = 'pid-absent' +): Promise { rig.crashRestartHostProcess() await rig.host.journalSnapshot(HOST_TEST_SESSION) const now = Date.now() const deathEvidence: AgentSessionDeathEvidence | null = - proof === 'pid-absent' - ? { - kind: 'pid-absent', - observedAt: now + 60_000, - ownerFence: 1, - lastProvenAliveAt: now - 20_000 - } - : null + proof === 'unproven' + ? null + : { kind: proof, observedAt: now + 60_000, ownerFence: 1, lastProvenAliveAt: now - 20_000 } await settleStaleStructuredAgentSessionState({ journal: journal(), sessionId: HOST_TEST_SESSION, @@ -173,6 +170,29 @@ describe('a restart between a Stop and its turn end', () => { expect(mark).toBe('failed') }) + it('reads a turn a send made after a Stop pressed before any turn showed as no Stop of its', async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + // Pressed before the turn showed: the Stop names no turn. + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined() + await rig.settleAccepted(stopped, 'stopped') + const next = rig.send('sent after the Stop') + await next.result + await rig.settleAccepted(next.id, 'next') + await journal().appendItem( + CODEX_TURN, + { kind: 'turn', turnId: TURN, state: 'running', startedAt: Date.now() - 30_000 }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + + // Its exit is proven after the Stop, so only whose turn it is decides. + await restartAndSettle('exit-observed') + + expect(settled().turn).toMatchObject({ state: 'interrupted' }) + expect(settled().turn).not.toHaveProperty('outcome') + }) + it('a Stop pressed again after a refusal is a new Stop, which its turn end reads', async () => { await runningTurn(CODEX_TURN) rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: {} }) From 4be2b8b2ba78f8161cb57f0ba7326474911e8c46 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:56:51 -0700 Subject: [PATCH 32/58] test(native-chat): the restart test's death proof carries its detail --- .../structured-agent-session-stop-event-restart.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 987c0fd69105..0ec9e7dcc2d8 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 @@ -70,7 +70,13 @@ async function restartAndSettle( const deathEvidence: AgentSessionDeathEvidence | null = proof === 'unproven' ? null - : { kind: proof, observedAt: now + 60_000, ownerFence: 1, lastProvenAliveAt: now - 20_000 } + : { + kind: proof, + detail: 'the relaunch proved the old child gone', + observedAt: now + 60_000, + ownerFence: 1, + lastProvenAliveAt: now - 20_000 + } await settleStaleStructuredAgentSessionState({ journal: journal(), sessionId: HOST_TEST_SESSION, From d388b9cdd222af9df8ecc56b8b88d4dc22ba48dc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:51:11 -0700 Subject: [PATCH 33/58] refactor(native-chat): a refused Stop leaves no record; a Stop only ever ends the turn it names The stop-refused mark is gone: its tombstone kind, its fold, the clock-keyed match that tied it to a Stop, and the exception that let a second press after a refusal write a new Stop. A Stop that stops nothing writes nothing. A Codex refusal names a turn that is no longer its active one, and the Stop names that turn, so the turn running instead never reads as the person's by its id alone. --- .../claude-structured-in-turn-stop.test.ts | 9 +- src/main/claude/claude-turn-outcome.test.ts | 3 +- .../agent-session-journal/journal-reducer.ts | 7 +- .../journal-row-schema.ts | 19 +--- .../journal-stop-and-resume-rows.ts | 26 +----- .../journal-stop-marks.ts | 22 ++--- .../journal-stop-turn-end.ts | 82 +++-------------- .../journal-store-collaborators.ts | 2 +- .../structured-agent-session-queued-stop.ts | 3 +- ...d-agent-session-stop-event-restart.test.ts | 87 ++++++++++++++----- .../structured-agent-session-turns-cancel.ts | 26 ------ ...-session-stop-event-downgrade.unit.test.ts | 9 +- 12 files changed, 95 insertions(+), 200 deletions(-) diff --git a/src/main/claude/claude-structured-in-turn-stop.test.ts b/src/main/claude/claude-structured-in-turn-stop.test.ts index 3962b97dbf40..427ec1aaf480 100644 --- a/src/main/claude/claude-structured-in-turn-stop.test.ts +++ b/src/main/claude/claude-structured-in-turn-stop.test.ts @@ -30,8 +30,7 @@ async function runningChat(claude: ReturnType): Promise<{ bodies: Map connection: FakeConnection turnId: string - /** What the host's Stop writes before the interrupt. Its child's end follows, so no refusal - * ever answers it. */ + /** What the host's Stop writes before the interrupt. */ stopEvent: (turnId: string) => void }> { const bodies = new Map() @@ -64,11 +63,7 @@ async function runningChat(claude: ReturnType): Promise<{ throw new Error('expected a running turn') } const stopEvent = (stoppedTurnId: string) => { - latestStop = { - sequence: 9, - event: { reason: 'user-stop', turnId: stoppedTurnId, at: 1 }, - refused: false - } + latestStop = { sequence: 9, event: { reason: 'user-stop', turnId: stoppedTurnId, at: 1 } } } return { adapter, bodies, connection, turnId, stopEvent } } diff --git a/src/main/claude/claude-turn-outcome.test.ts b/src/main/claude/claude-turn-outcome.test.ts index c099a704dc93..ba064e3b4852 100644 --- a/src/main/claude/claude-turn-outcome.test.ts +++ b/src/main/claude/claude-turn-outcome.test.ts @@ -245,12 +245,11 @@ describe("a person's Stop inside a live turn", () => { return { ...state, sink: { ...state.sink, journalLatestStop: () => stop } } } - // A Claude Stop ends the child, so no refusal ever answers its event. function stopOf( turnId: string, reason: JournalStopEvent['reason'] = 'user-stop' ): JournalLatestStop { - return { sequence: 9, event: { reason, turnId, at: 1 }, refused: false } + return { sequence: 9, event: { reason, turnId, at: 1 } } } function settledTurn(items: ReturnType['items'], turnId: string) { diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index fec7c50672cb..a7d6f150e70c 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -35,7 +35,6 @@ import { foldJournalQueuePauseMark, type JournalQueuePauseMarks } from './queued-message-pause' -import { foldJournalStopRefusal, type JournalStopRefusalMark } from './journal-stop-turn-end' export const MAX_JOURNAL_APPLIED_SETTLEMENT_IDS = 4_096 @@ -65,8 +64,6 @@ export type JournalReducerState = { latestPersonTurnSequence: number /** The latest person's Stop event and Resume, what the queue's pause is derived from. */ queuePauseMarks: JournalQueuePauseMarks - /** The latest refusal of a Stop, what keeps a refused Stop from ending its turn. */ - latestStopRefusal: JournalStopRefusalMark | null } export function createJournalReducerState(sessionId: string, epoch: string): JournalReducerState { @@ -86,8 +83,7 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou appliedSettlementIds: new Set(), derivedTurnScope: new JournalDerivedTurnScope(), latestPersonTurnSequence: 0, - queuePauseMarks: createJournalQueuePauseMarks(), - latestStopRefusal: null + queuePauseMarks: createJournalQueuePauseMarks() } } @@ -115,7 +111,6 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo } if (isJournalStopOrResumeRow(row)) { foldJournalQueuePauseMark(state.queuePauseMarks, row) - foldJournalStopRefusal(state, row) return } if (row.kind === 'tombstone') { diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index e4bd35bd40d7..e65d5919b39b 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -77,8 +77,6 @@ export type JournalTombstoneRow = JournalRowBase & { stopEvent?: JournalStopEvent /** Present: not a removal but a person's Resume of the queue, on an id no item ever takes. */ queueResume?: true - /** Present: not a removal but the provider's refusal of a Stop, on an id no item ever takes. */ - stopRefusal?: JournalStopRefusal } /** One Stop that took effect. Temporary carrier: a tombstone's extra key, because a released host @@ -96,29 +94,16 @@ export type JournalStopEvent = { caller?: string } -/** The provider refused a Stop's interrupt and its turn ran on, so that Stop never ends the turn - * (`journal-stop-turn-end.ts`). Names the Stop it answers by that event's `turnId` and `at`. Same - * Temporary carrier as `JournalStopEvent`. */ -export type JournalStopRefusal = { - turnId?: string - stopAt: number -} - /** A tombstone that carries a Stop event or a Resume mark instead of removing an item. */ export type JournalStopOrResumeRow = JournalTombstoneRow & ( | { stopEvent: NonNullable } | { queueResume: NonNullable } - | { stopRefusal: NonNullable } ) -/** A Stop's event, its refusal or a Resume. Any value counts, so a newer build's mark never - * removes an item. */ +/** A Stop's event or a Resume. Any value counts, so a newer build's mark never removes an item. */ export function isJournalStopOrResumeRow(row: JournalRow): row is JournalStopOrResumeRow { - return ( - row.kind === 'tombstone' && - (row.stopEvent !== undefined || row.queueResume !== undefined || row.stopRefusal !== undefined) - ) + return row.kind === 'tombstone' && (row.stopEvent !== undefined || row.queueResume !== undefined) } /** The write-ahead row. Durable BEFORE the adapter dispatches anything; it diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts index 079b2281c7c6..e763af8b2e6c 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts @@ -1,14 +1,10 @@ -// The rows a Stop's event, its refusal and a person's Resume append: tombstones of ids no item ever takes, +// The rows a Stop's event and a person's Resume append: tombstones of ids no item ever takes, // each carrying its record as an extra key (`journal-row-schema.ts` says why not a row kind). import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { JournalReducerState } from './journal-reducer' import { journalRowBase } from './journal-row-builders' -import type { - JournalStopEvent, - JournalStopRefusal, - JournalTombstoneRow -} from './journal-row-schema' +import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' /** One id per mark kind; no item ever takes either. */ const JOURNAL_STOP_EVENT_ITEM_ID = agentJournalItemKey({ @@ -19,10 +15,6 @@ const JOURNAL_QUEUE_RESUME_ITEM_ID = agentJournalItemKey({ provider: 'orca', clientMessageId: 'queue-resume' }) -const JOURNAL_STOP_REFUSAL_ITEM_ID = agentJournalItemKey({ - provider: 'orca', - clientMessageId: 'stop-refusal' -}) type RowPlace = { state: JournalReducerState; seq: number; fence: number; ts: number } @@ -64,17 +56,3 @@ export function journalQueueResumeRowBuilder( ): (seq: number, ts: number) => JournalTombstoneRow { return (seq, ts) => buildJournalQueueResumeRow({ state: state(), seq, fence, ts }) } - -export function journalStopRefusalRowBuilder( - state: () => JournalReducerState, - refusal: JournalStopRefusal, - fence: number -): (seq: number, ts: number) => JournalTombstoneRow { - return (seq, ts) => ({ - kind: 'tombstone', - itemId: JOURNAL_STOP_REFUSAL_ITEM_ID, - revision: 1, - stopRefusal: refusal, - ...journalRowBase(state().epoch, seq, fence, ts) - }) -} 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 2d9bb308e2ff..87a87f26e194 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,25 +1,13 @@ -// What the journal answers about its Stops beyond the queue's pause: the latest Stop event and -// whether a refusal answered it, which the turn-end rule reads (`journal-stop-turn-end.ts`). +// 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`). import type { JournalReducerState } from './journal-reducer' -import type { JournalRow, JournalStopRefusal } from './journal-row-schema' -import { journalStopRefusalRowBuilder } from './journal-stop-and-resume-rows' -import { journalLatestStop, type JournalLatestStop } from './journal-stop-turn-end' +import type { JournalLatestStop } from './journal-stop-turn-end' export class JournalStopMarks { - constructor( - private readonly deps: { - state: () => JournalReducerState - enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise - } - ) {} + constructor(private readonly deps: { state: () => JournalReducerState }) {} latest(): JournalLatestStop | null { - return journalLatestStop(this.deps.state()) - } - - /** The provider refused a Stop's interrupt and its turn runs on (`JournalStopRefusal`). */ - async appendRefusal(refusal: JournalStopRefusal, fence: number): Promise { - await this.deps.enqueue(journalStopRefusalRowBuilder(this.deps.state, refusal, fence)) + return this.deps.state().queuePauseMarks.latestStop } } 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 1f03dd9e2388..2d08bb502b6d 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,72 +1,18 @@ // What a Stop decides about the turn it named: the one rule every turn-end write passes through. // // A turn a person's Stop or close of this chat named, ending with no verdict of its own after that -// Stop's event and with no refusal answering it, ends as their cancellation. A host stop, an -// eviction, a refused Stop 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. +// Stop's event, ends as their cancellation. A host stop, an eviction and no Stop at all leave the +// end as written. It runs where each row is built, inside the journal's serialized write, so it +// reads every Stop folded before the end: the adapter's settle, the host's fallback and a +// relaunch's settle all write through it, and every client folds the row it wrote. import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { JournalReducerState } from './journal-reducer' -import type { - JournalStopEvent, - JournalStopRefusal, - JournalTombstoneRow -} from './journal-row-schema' +import type { JournalStopEvent } from './journal-row-schema' +import type { JournalQueuePauseMarks } from './queued-message-pause' -export type JournalStopRefusalMark = { sequence: number; refusal: JournalStopRefusal } - -export type JournalLatestStop = { - sequence: number - event: JournalStopEvent - /** A refusal written after it answers it: its turn ran on. */ - refused: boolean -} - -/** The keys are read from disk unchecked: a value no build writes (a corrupt row) is ignored. */ -export function foldJournalStopRefusal( - state: Pick, - row: JournalTombstoneRow -): void { - const refusal: unknown = row.stopRefusal - if ( - typeof refusal === 'object' && - refusal !== null && - 'stopAt' in refusal && - typeof refusal.stopAt === 'number' && - (!('turnId' in refusal) || typeof refusal.turnId === 'string') - ) { - state.latestStopRefusal = { - sequence: row.seq, - refusal: { - stopAt: refusal.stopAt, - ...('turnId' in refusal && typeof refusal.turnId === 'string' - ? { turnId: refusal.turnId } - : {}) - } - } - } -} - -export function journalLatestStop( - state: Pick -): JournalLatestStop | null { - const stop = state.queuePauseMarks.latestStop - if (!stop) { - return null - } - const answer = state.latestStopRefusal - return { - ...stop, - refused: - answer !== null && - answer.sequence > stop.sequence && - answer.refusal.stopAt === stop.event.at && - answer.refusal.turnId === stop.event.turnId - } -} +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 { @@ -81,14 +27,13 @@ function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { } /** Whether `stop` makes the end of turn `turnId` a person's cancellation: a person's Stop or close - * that named that turn (`opened`: or named none, and the turn opened under it), and that no - * refusal answered. */ + * that named that turn (`opened`: or named none, and the turn opened under it). */ export function stopIsTurnCancellation( stop: JournalLatestStop | null, turnId: string, opened?: { createdAt: number | null; latestPersonTurnSequence: number } ): boolean { - if (stop === null || stop.refused || !stopIsAPersons(stop.event.reason)) { + if (stop === null || !stopIsAPersons(stop.event.reason)) { return false } if (stop.event.turnId !== undefined || !opened) { @@ -105,13 +50,10 @@ export function stopIsTurnCancellation( /** * 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 while it was still open (running, - * or unproven) and which no refusal answered. A provider's own verdict always stands. + * or unproven). A provider's own verdict always stands. */ export function turnEndAfterStop( - state: Pick< - JournalReducerState, - 'items' | 'queuePauseMarks' | 'latestStopRefusal' | 'latestPersonTurnSequence' - >, + state: Pick, itemId: string, body: AgentJournalItemBody ): AgentJournalItemBody { @@ -124,7 +66,7 @@ export function turnEndAfterStop( if (previous && previous.state !== 'running' && previous.state !== 'unverifiable') { return body } - const stop = journalLatestStop(state) + const stop = state.queuePauseMarks.latestStop const opened = { createdAt: existing?.sequence ?? null, latestPersonTurnSequence: state.latestPersonTurnSequence diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 5351427f3023..b938efa3c5c9 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -94,7 +94,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal return { epochController, queuedMessages, - stopMarks: new JournalStopMarks({ state: host.state, enqueue: host.enqueue }), + stopMarks: new JournalStopMarks({ state: host.state }), // Behind the stored fact: settles drafts whose consumed submission the loaded journal shows // refused (a downgrade wrote no hook), then prunes. Bookkeeping, never failing the open. restore: () => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts index d7240c249e69..e66c2a9b9765 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 @@ -77,8 +77,7 @@ export async function stopReachesUnrecordedWork( return false } const inForce = ctx.journal.queuedMessages.userStopInForce() - // A refused Stop ended nothing, so pressing again is a new Stop. - if (inForce === null || ctx.journal.stopMarks.latest()?.refused) { + if (inForce === null) { return true } // Sent after that Stop and not refused, even if its fate is unknown: this interrupt may send it 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 4c09f76504df..d0beae7df677 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 whose Stop the provider refused -// while the turn ran on, still read as the news they are. +// with the muted mark, not "Failed". A turn nobody stopped, and one a Stop never named, still read +// as the news they are. import { afterEach, describe, expect, it } from 'vitest' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' @@ -15,10 +15,10 @@ import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { formatNativeChatTurnStatusLabel } from '../../../shared/native-chat-turn-status' import { selectStructuredAgentSettledTurns } from '../../../shared/structured-agent-session-turn-timing' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' -import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' import { + QUEUED_RIG_CALLER, createQueuedMessageTestRig, - eventually, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' @@ -35,6 +35,12 @@ const CODEX_TURN: AgentJournalItemIdentity = { turnId: TURN, ordinal: 999 } +const NEXT_TURN = 'turn-2' +const CODEX_NEXT_TURN: AgentJournalItemIdentity = { + ...CODEX_TURN, + turnId: NEXT_TURN, + ordinal: 1000 +} let rig: QueuedMessageTestRig @@ -89,10 +95,12 @@ async function restartAndSettle( }) } -/** What the chat's turn bar and the session's mark read. */ -function settled() { +/** What the chat's turn bar and the session's mark read, for `turnId` or else the first turn. */ +function settled(turnId?: string) { const { items } = journal().snapshot() - const turn = items.map((item) => readAgentJournalTurn(item.body)).find(Boolean) + const turn = items + .map((item) => readAgentJournalTurn(item.body)) + .find((entry) => entry && (turnId === undefined || entry.turnId === turnId)) const [timing] = [...selectStructuredAgentSettledTurns(items).values()] const verdict = turn ? agentTurnVerdict({ state: turn.state, outcome: turn.outcome ?? null }) @@ -165,21 +173,55 @@ describe('a restart between a Stop and its turn end', () => { expect(mark).toBe('unconfirmed') }) - it('reads Failed after N when the provider refused the Stop and the turn ran on until the crash', async () => { + // Codex refuses a Stop naming a turn that is no longer its active one ("expected active turn id + // X but found Y"). The Stop names X, so Y's end is never the person's, by its turn id alone. + it('reads Failed for the turn running when the provider refused a Stop naming the one before it', async () => { await runningTurn(CODEX_TURN) - rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: {} }) - expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) - await eventually(async () => expect(journal().stopMarks.latest()?.refused).toBe(true)) + rig.cancelTurn.mockResolvedValueOnce({ + cancelled: false, + refusal: { + detail: { + text: `expected active turn id ${TURN} but found ${NEXT_TURN}`, + audience: 'person' + } + } + }) + const fields = { turnId: TURN } + expect( + await rig.host.cancel(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.cancel', hostTestOperationId()), + ...fields + }) + ).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(journal().stopMarks.latest()?.event).toMatchObject({ reason: 'user-stop', turnId: TURN }) + // The journal catches up: X had finished, and Y runs on until the crash. + await journal().appendItem( + CODEX_TURN, + { + kind: 'turn', + turnId: TURN, + state: 'completed', + outcome: 'success', + completedAt: Date.now() + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal().appendItem( + CODEX_NEXT_TURN, + { kind: 'turn', turnId: NEXT_TURN, state: 'running', startedAt: Date.now() - 10_000 }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) - await restartAndSettle() + // Its exit is proven after the Stop, so only the turn the Stop named decides. + await restartAndSettle('exit-observed') - const { turn, label, mark } = settled() + const { turn, mark } = settled(NEXT_TURN) + expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') - expect(label).toMatch(/^Failed after /) expect(mark).toBe('failed') }) - // Claude's Stop ends its child whatever the interrupt answered, so only Codex writes a refusal. + // Claude's Stop ends its child whatever the interrupt answered. it('reads Interrupted after N when the provider refused a Stop that ends its child', async () => { await runningTurn(CLAUDE_TURN, { stopEndsSession: true }) rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: {} }) @@ -187,7 +229,6 @@ describe('a restart between a Stop and its turn end', () => { // The Stop's next step on the session's lane ends the child. await rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) expect(rig.closeSession).toHaveBeenCalled() - expect(journal().stopMarks.latest()).toMatchObject({ refused: false }) await restartAndSettle() @@ -217,14 +258,18 @@ describe('a restart between a Stop and its turn end', () => { expect(settled().turn).not.toHaveProperty('outcome') }) - it('a Stop pressed again after a refusal is a new Stop, which its turn end reads', async () => { + // A refusal is no record: the first Stop stays the one in force, so pressing again repeats it. + it('writes nothing for a Stop pressed again after the provider refused one', async () => { await runningTurn(CODEX_TURN) - rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: {} }) + rig.cancelTurn.mockResolvedValueOnce({ + cancelled: false, + refusal: { detail: { text: 'no active turn to interrupt', audience: 'person' } } + }) await rig.stop() - expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + const first = journal().stopMarks.latest() - await restartAndSettle() + await rig.stop() - expect(settled().turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(journal().stopMarks.latest()).toEqual(first) }) }) 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 d92ac0bf583c..cdb70ff41d45 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,27 +59,6 @@ export async function endStoppedStructuredAgentSession( } } -/** The provider refused the interrupt and `turnId` runs on: its Stop event must not end it - * (`turnEndAfterStop`). Only where nothing then ends the child, so never for a provider whose Stop - * ends its session (Claude). Bookkeeping: reported, never thrown. */ -async function recordStopRefusal( - ctx: Pick, - turnId: string | null -): Promise { - const stop = ctx.journal.stopMarks.latest() - if (!stop || stop.refused || turnId === null || stop.event.turnId !== turnId) { - return - } - await ctx.journal.stopMarks - .appendRefusal({ turnId, stopAt: stop.event.at }, ctx.fence) - .catch((error: unknown) => { - console.warn("[agent-session] a refused Stop's answer row skipped:", { - sessionId: ctx.sessionId, - error: error instanceof Error ? error.message : String(error) - }) - }) -} - export async function performCancel( ctx: AgentSessionTurnContext, input: { @@ -107,7 +86,6 @@ export async function performCancel( } } let cancelled = false - let refused = false let note: AgentJournalStatusItem | null = { kind: 'status', text: 'Cancellation requested.' } // The turn the Stop names, read before the cancel settles it: the note reports on that turn. const turnScope = ctx.journal.liveTurnScope() @@ -165,7 +143,6 @@ export async function performCancel( note = null } else if (!cancelled) { // Sent only while the chat reads working, so a Stop that ended nothing must say why. - refused = true const detail = outcome.refusal?.detail note = { kind: 'status', @@ -204,9 +181,6 @@ export async function performCancel( cancelled = true note = { kind: 'status', text: 'Cancellation requested.' } } - if (refused && !cancelled && !input.scope) { - await recordStopRefusal(ctx, liveTurnId) - } if (cancelled && input.prompt) { await ctx.flushStreamedEvents() } diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index 88abffc1fb4e..20b64c7f6dbb 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -54,7 +54,7 @@ type OldReplay = { truncateFrom?: number } -test("an older build keeps every row around a Stop's event, its refusal and a Resume, and folds the rows after them", async () => { +test("an older build keeps every row around a Stop's event and a Resume, and folds the rows after them", async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-stop-event-downgrade-')) const journals = createTrackedJournalOpener() try { @@ -69,8 +69,6 @@ test("an older build keeps every row around a Stop's event, its refusal and a Re await append(0, 'before the Stop') const beforeMarks = journal.cursor() await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 1) - const stopAt = journal.stopMarks.latest()?.event.at ?? 0 - await journal.stopMarks.appendRefusal({ turnId: 'turn-1', stopAt }, 1) await journal.appendStopEvent({ reason: 'user-close', turnId: 'turn-1' }, 1) await journal.appendQueueResume(1) const afterMarks = journal.cursor() @@ -139,7 +137,7 @@ test("an older build keeps every row around a Stop's event, its refusal and a Re }) expect(projected.ok).toBe(true) expect(projected.batch?.items).toEqual([]) - expect(projected.batch?.removedItemIds).toHaveLength(3) + expect(projected.batch?.removedItemIds).toHaveLength(2) const liveIds = new Set(journal.snapshot().items.map((entry) => entry.itemId)) expect(projected.batch?.removedItemIds.some((id) => liveIds.has(id))).toBe(false) } finally { @@ -185,8 +183,6 @@ test("an older build opens this build's journal writable and appends to it; the const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } await journal.appendItem(item(0), { kind: 'status', text: 'before the Stop' }, scope) await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 1) - const stopAt = journal.stopMarks.latest()?.event.at ?? 0 - await journal.stopMarks.appendRefusal({ turnId: 'turn-1', stopAt }, 1) await journal.appendItem(item(1), { kind: 'status', text: 'after the Stop' }, scope) const wrote = { cursor: journal.cursor(), items: itemIds(journal) } expect(journal.queuedMessages.pauses('host-a').map((pause) => pause.reason)).toEqual([ @@ -222,7 +218,6 @@ test("an older build opens this build's journal writable and appends to it; the expect(upgraded.queuedMessages.pauses('host-a').map((pause) => pause.reason)).toEqual([ 'stopped' ]) - expect(upgraded.stopMarks.latest()).toMatchObject({ event: { at: stopAt }, refused: true }) } finally { await journals.closeAll() rmSync(directory, { recursive: true, force: true }) From f85a98403785d82666b7425479ed936ee9a7a2be Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:54:49 -0700 Subject: [PATCH 34/58] fix(native-chat): a Stop pressed before any turn showed stops only the turn opened next A Stop that named no turn read as the person's cancellation for every later turn that opened after it, until a send a person made was accepted. The queue's drain, orchestration mail and a restart continuation send as the host, so a turn they opened long after, cut by a crash, read "Interrupted" as if the person had stopped it. The Stop now applies only to the first turn opened after it. --- .../journal-stop-turn-end.ts | 58 +++++++++++++------ ...d-agent-session-stop-event-restart.test.ts | 36 ++++++++++++ 2 files changed, 76 insertions(+), 18 deletions(-) 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 2d08bb502b6d..266abbb7fd9c 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 @@ -26,12 +26,17 @@ function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { } } +type TurnEndState = Pick< + JournalReducerState, + 'items' | 'queuePauseMarks' | 'latestPersonTurnSequence' +> + /** Whether `stop` makes the end of turn `turnId` a person's cancellation: a person's Stop or close - * that named that turn (`opened`: or named none, and the turn opened under it). */ + * that named that turn (`opened`: or named none, and stopped the turn item `itemId` opened). */ export function stopIsTurnCancellation( stop: JournalLatestStop | null, turnId: string, - opened?: { createdAt: number | null; latestPersonTurnSequence: number } + opened?: { state: TurnEndState; itemId: string } ): boolean { if (stop === null || !stopIsAPersons(stop.event.reason)) { return false @@ -39,39 +44,56 @@ export function stopIsTurnCancellation( if (stop.event.turnId !== undefined || !opened) { return stop.event.turnId === turnId } - // Pressed before the turn showed: the turn its row opened after the Stop, unless a send a - // person made since was accepted, whose turn it would be. - return ( - (opened.createdAt === null || opened.createdAt > stop.sequence) && - opened.latestPersonTurnSequence < stop.sequence - ) + return turnlessStopStopped(opened.state, stop, opened.itemId) +} + +/** Pressed before any turn showed, a Stop stopped the first turn opened after it, and no later + * one: unless a send a person made since was accepted, whose turn that is. */ +function turnlessStopStopped( + state: TurnEndState, + stop: JournalLatestStop, + itemId: string +): boolean { + const createdAt = state.items.get(itemId)?.sequence ?? null + if ( + (createdAt !== null && createdAt <= stop.sequence) || + state.latestPersonTurnSequence >= stop.sequence + ) { + return false + } + for (const [otherId, item] of state.items) { + if ( + otherId !== itemId && + item.body.kind === 'turn' && + item.sequence > stop.sequence && + (createdAt === null || item.sequence < createdAt) + ) { + return false + } + } + return true } /** * 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 while it was still open (running, - * or unproven). A provider's own verdict always stands. + * earlier than the latest Stop event, a person's, which named it, or stopped it before it showed, + * while it was still open (running, or unproven). A provider's own verdict always stands. */ export function turnEndAfterStop( - state: Pick, + state: TurnEndState, itemId: string, body: AgentJournalItemBody ): AgentJournalItemBody { if (body.kind !== 'turn' || body.state !== 'interrupted' || body.outcome !== undefined) { return body } - const existing = state.items.get(itemId) - const previous = readAgentJournalTurn(existing?.body) + const previous = readAgentJournalTurn(state.items.get(itemId)?.body) // An end already written stands: the Stop came after it. if (previous && previous.state !== 'running' && previous.state !== 'unverifiable') { return body } const stop = state.queuePauseMarks.latestStop - const opened = { - createdAt: existing?.sequence ?? null, - latestPersonTurnSequence: state.latestPersonTurnSequence - } - if (!stop || !stopIsTurnCancellation(stop, body.turnId, opened)) { + if (!stop || !stopIsTurnCancellation(stop, body.turnId, { state, itemId })) { return body } // An exit the provider saw before the Stop was news, whenever its end is written. 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 d0beae7df677..1d1554d9174b 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 @@ -258,6 +258,42 @@ 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. + it('reads a turn a host send opened after the turnless Stop ended its own turn as no Stop of its', async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined() + await rig.settleAccepted(stopped, 'stopped') + const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + await journal().appendItem( + CODEX_TURN, + { kind: 'turn', turnId: TURN, state: 'running', startedAt: Date.now() }, + scope + ) + await journal().appendItem( + CODEX_TURN, + { kind: 'turn', turnId: TURN, state: 'interrupted', completedAt: Date.now() + 1 }, + scope + ) + expect(settled(TURN).turn).toMatchObject({ outcome: 'cancellation' }) + // The queue's drain sends as the host, so no person's send voids the Stop. + const drained = rig.send('drained after the Stop', undefined, { internal: true }) + await drained.result + await rig.settleAccepted(drained.id, 'drained') + await journal().appendItem( + CODEX_NEXT_TURN, + { kind: 'turn', turnId: NEXT_TURN, state: 'running', startedAt: Date.now() }, + scope + ) + + // Its exit is proven after the Stop, so only which turn the Stop stopped decides. + await restartAndSettle('exit-observed') + + expect(settled(NEXT_TURN).turn).toMatchObject({ state: 'interrupted' }) + expect(settled(NEXT_TURN).turn).not.toHaveProperty('outcome') + }) + // A refusal is no record: the first Stop stays the one in force, so pressing again repeats it. it('writes nothing for a Stop pressed again after the provider refused one', async () => { await runningTurn(CODEX_TURN) From 2ba34a95f9b2cc225448d6878643477e4e55eef4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:59:28 -0700 Subject: [PATCH 35/58] fix(native-chat): an older Claude's error end after a Stop pressed before its echo reads Interrupted Claude CLIs before 2.1.91 end an interrupted turn with an error result that names no reason. The translator judged whether a person's Stop explained it by its own copy of the Stop rule, which ignored a Stop that named no turn, so a Stop pressed before Claude echoed the send read "Failed". The translator now writes such an end as interrupted with no verdict and no error row whenever a person's Stop may name the turn, and the journal's one rule decides as it writes the end. --- src/main/claude/claude-result-journaling.ts | 16 ++++------ src/main/claude/claude-result-outcome.ts | 16 +++++----- .../claude-structured-in-turn-stop.test.ts | 27 ++++++++-------- .../claude-structured-provider-fallback.ts | 4 +-- src/main/claude/claude-turn-lifecycle-item.ts | 9 +++--- src/main/claude/claude-turn-outcome.test.ts | 32 +++++++++++-------- .../journal-stop-turn-end.ts | 32 ++++++++++++------- ...t-session-claude-stop-ends-session.test.ts | 22 +++++++++++++ 8 files changed, 96 insertions(+), 62 deletions(-) diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts index 2609b4e919c8..4ea153b82754 100644 --- a/src/main/claude/claude-result-journaling.ts +++ b/src/main/claude/claude-result-journaling.ts @@ -15,7 +15,7 @@ import { } from './claude-structured-provider-fallback' import { claudeTurnEndForResult } from './claude-turn-lifecycle-item' import { claudeFrameParentRef, isRootClaudeFrame } from './claude-turn-opening' -import { stopIsTurnCancellation } from '../native-chat/agent-session-journal/journal-stop-turn-end' +import { personStopMayNameTurn } from '../native-chat/agent-session-journal/journal-stop-turn-end' export type ClaudeResultJournalContext = Pick< ClaudeMessageJournalContext, @@ -57,20 +57,18 @@ export function journalClaudeResult( } // Read before the settle below closes it: the result reports that turn's end. const endedTurnScope = turn.turnScope - // Read before the settle below closes the turn: a person's Stop of it makes an error end theirs. + // Read before the settle below closes the turn: an error end a person's Stop may have caused is + // the journal's to decide as it writes the end (`turnEndAfterStop`). const turnId = settlesTurn ? turn.id : null - const stoppedByPerson = - turnId !== null && stopIsTurnCancellation(sink.journalLatestStop?.() ?? null, turnId) + const leftToStop = + turnId !== null && personStopMayNameTurn(sink.journalLatestStop?.() ?? null, turnId) if (settlesTurn) { prompts.retryPendingCancellations() turn.suppressReopenOnFailure(message.is_error === true) // The turn is over however it ended, so a foreground child still // reported as working will never be settled by an event. subagents.settleTurn(turn.groupKey) - context.settle( - message, - commandEnd ?? claudeTurnEndForResult(message, observedAt, stoppedByPerson) - ) + context.settle(message, commandEnd ?? claudeTurnEndForResult(message, observedAt, leftToStop)) // The turn is over. A block still awaiting its final keeps the text the // flush above journaled, but its live state goes: an interrupted turn // would otherwise retain that text for the life of the session. @@ -78,7 +76,7 @@ export function journalClaudeResult( streamedText.settle() } const kind = claudeProviderFrameKind(message) - const failure = claudeResultFailure(message, stoppedByPerson) + const failure = claudeResultFailure(message, leftToStop) if (failure || !isSettledClaudeResultKind(kind)) { providerFallback.append( kind, diff --git a/src/main/claude/claude-result-outcome.ts b/src/main/claude/claude-result-outcome.ts index aa5e0ca6fa23..e7cc73737686 100644 --- a/src/main/claude/claude-result-outcome.ts +++ b/src/main/claude/claude-result-outcome.ts @@ -13,19 +13,19 @@ import { claudeText } from './claude-structured-item-translation' const CLAUDE_ABORTED_TERMINAL_REASONS = new Set(['aborted_streaming', 'aborted_tools']) /** A success-subtype result still carries `is_error` for an API error, so the flag - * is what decides, never the subtype. `stoppedByPerson`: a person's Stop event names this turn - * (`stopIsTurnCancellation`), so an error end is their cancellation, since older CLIs name no - * reason. */ + * is what decides, never the subtype. `leftToStop`: a person's Stop may name this turn + * (`personStopMayNameTurn`), so an error end with no abort reason gives no verdict (undefined): + * older CLIs name no reason, and the journal's Stop rule decides as the end is written. */ export function claudeResultOutcome( message: Record, - stoppedByPerson = false -): AgentJournalTurnOutcome { + leftToStop = false +): AgentJournalTurnOutcome | undefined { if (message.is_error !== true) { return 'success' } - if (stoppedByPerson) { + const reason = claudeText(message.terminal_reason) + if (reason !== null && CLAUDE_ABORTED_TERMINAL_REASONS.has(reason)) { return 'cancellation' } - const reason = claudeText(message.terminal_reason) - return reason !== null && CLAUDE_ABORTED_TERMINAL_REASONS.has(reason) ? 'cancellation' : 'failure' + return leftToStop ? undefined : 'failure' } diff --git a/src/main/claude/claude-structured-in-turn-stop.test.ts b/src/main/claude/claude-structured-in-turn-stop.test.ts index 427ec1aaf480..abadd8c3043c 100644 --- a/src/main/claude/claude-structured-in-turn-stop.test.ts +++ b/src/main/claude/claude-structured-in-turn-stop.test.ts @@ -1,6 +1,8 @@ // A user's Stop inside a live Claude chat interrupts the turn before the host ends its child. The // turn's end then comes from the CLI's result frame, which CLIs before 2.1.91 send with no -// terminal_reason; the host's Stop event, written before the interrupt, says whose end it was. +// terminal_reason. The translator then writes an interrupted end with no verdict and no error row, +// and the host's Stop event, written before the interrupt, decides whose end it was as the journal +// writes it (`turnEndAfterStop`). import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' @@ -79,7 +81,7 @@ function providerRows(bodies: Map): string[] { } describe("a user's Stop inside a live Claude chat", () => { - it('records the turn the interrupt cut as their cancellation when the CLI names no reason', async () => { + it('leaves the turn the interrupt cut to the Stop when the CLI names no reason', async () => { const claude = fakeClaude({ routes: { // The CLI aborts the turn, then acknowledges the interrupt. @@ -96,15 +98,13 @@ describe("a user's Stop inside a live Claude chat", () => { { cancelled: true } ) - expect(settled(bodies, turnId)).toMatchObject({ - state: 'interrupted', - outcome: 'cancellation' - }) + expect(settled(bodies, turnId)).toMatchObject({ state: 'interrupted' }) + expect(settled(bodies, turnId)).not.toHaveProperty('outcome') expect(providerRows(bodies)).toEqual([]) }) // The chat's Stop button names no turn: it stops whatever the conversation has open. - it('records the open turn a Stop naming no turn cut as their cancellation', async () => { + it('leaves the open turn a Stop naming no turn cut to that Stop', async () => { const claude = fakeClaude({ routes: { interrupt: () => { @@ -121,10 +121,8 @@ describe("a user's Stop inside a live Claude chat", () => { cancelled: true }) - expect(settled(bodies, turnId)).toMatchObject({ - state: 'interrupted', - outcome: 'cancellation' - }) + expect(settled(bodies, turnId)).toMatchObject({ state: 'interrupted' }) + expect(settled(bodies, turnId)).not.toHaveProperty('outcome') expect(providerRows(bodies)).toEqual([]) }) @@ -170,12 +168,12 @@ describe("a user's Stop inside a live Claude chat", () => { expect(settled(bodies, nextTurnId)).toMatchObject({ state: 'completed', outcome: 'failure' }) }) - // The Stop ends the child next, so the turn it was asked for reads Interrupted however it ends. + // The Stop ends the child next, so the turn it was asked for is the Stop's however it ends. it.each([ ['naming the turn', true], ['naming no turn', false] ] as const)( - 'reads a turn the CLI refused to interrupt as the user stopping it, %s', + 'leaves a turn the CLI refused to interrupt to the Stop, %s', async (_label, named) => { const claude = fakeClaude({ routes: { @@ -192,7 +190,8 @@ describe("a user's Stop inside a live Claude chat", () => { ).resolves.toEqual({ cancelled: false }) connection.handlers.onMessage?.(CUT_SHORT) - expect(settled(bodies, turnId)).toMatchObject({ outcome: 'cancellation' }) + expect(settled(bodies, turnId)).toMatchObject({ state: 'interrupted' }) + expect(settled(bodies, turnId)).not.toHaveProperty('outcome') expect(providerRows(bodies)).toHaveLength(0) } ) diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts index c73f22105999..0fa645ea648d 100644 --- a/src/main/claude/claude-structured-provider-fallback.ts +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -52,11 +52,11 @@ export function isSettledClaudeResultKind(kind: string): boolean { */ export function claudeResultFailure( message: Record, - stoppedByPerson = false + leftToStop = false ): { text: string | null } | null { // A cancellation is not a fault and earns no error row; the outcome classifier // owns that distinction so this reader cannot drift from the turn's verdict. - if (claudeResultOutcome(message, stoppedByPerson) !== 'failure') { + if (claudeResultOutcome(message, leftToStop) !== 'failure') { return null } const result = claudeText(message.result)?.trim() diff --git a/src/main/claude/claude-turn-lifecycle-item.ts b/src/main/claude/claude-turn-lifecycle-item.ts index 63cf708f1d47..e816be7f2b29 100644 --- a/src/main/claude/claude-turn-lifecycle-item.ts +++ b/src/main/claude/claude-turn-lifecycle-item.ts @@ -45,14 +45,15 @@ export type ClaudeTurnEnd = { export function claudeTurnEndForResult( message: Record, completedAt: number, - stoppedByPerson = false + leftToStop = false ): ClaudeTurnEnd { - const outcome = claudeResultOutcome(message, stoppedByPerson) + const outcome = claudeResultOutcome(message, leftToStop) const durationMs = message.duration_ms return { - state: outcome === 'cancellation' ? 'interrupted' : 'completed', + // No verdict: an interrupted end, which a person's Stop of it makes their cancellation. + state: outcome === undefined || outcome === 'cancellation' ? 'interrupted' : 'completed', completedAt, - outcome, + ...(outcome !== undefined ? { outcome } : {}), ...(typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0 ? { durationMs } : {}) diff --git a/src/main/claude/claude-turn-outcome.test.ts b/src/main/claude/claude-turn-outcome.test.ts index ba064e3b4852..ff18ac3a5571 100644 --- a/src/main/claude/claude-turn-outcome.test.ts +++ b/src/main/claude/claude-turn-outcome.test.ts @@ -266,19 +266,25 @@ describe("a person's Stop inside a live turn", () => { ).length } - it('reads an error result with no terminal reason as the cancellation it asked for', () => { - const state = sinkWithStop(stopOf('user-1')) - const translator = createClaudeJournalTranslator({ sink: state.sink }) - translator.handle(userTurn('user-1')) - - translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) - - expect(settledTurn(state.items, 'user-1')).toMatchObject({ - state: 'interrupted', - outcome: 'cancellation' - }) - expect(providerRows(state.items)).toBe(0) - }) + // Whose end it was is the journal's Stop rule to say as it writes the end (`turnEndAfterStop`). + it.each([ + ['naming the turn', stopOf('user-1')], + ['naming no turn', { sequence: 9, event: { reason: 'user-stop' as const, at: 1 } }] + ])( + "leaves an error result with no terminal reason after a person's Stop %s to that Stop", + (_label, stop) => { + const state = sinkWithStop(stop) + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + + translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) + + const turn = settledTurn(state.items, 'user-1') + expect(turn).toMatchObject({ state: 'interrupted' }) + expect(turn).not.toHaveProperty('outcome') + expect(providerRows(state.items)).toBe(0) + } + ) it.each([ ['no terminal reason', cutShort], 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 266abbb7fd9c..ac15fe04f0b3 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 @@ -31,20 +31,28 @@ type TurnEndState = Pick< 'items' | 'queuePauseMarks' | 'latestPersonTurnSequence' > +/** Whether the latest Stop is a person's that names turn `turnId` or names none: an end that gives + * no verdict of its own is then theirs to decide, here, where its row is built. */ +export function personStopMayNameTurn(stop: JournalLatestStop | null, turnId: string): boolean { + return ( + stop !== null && + stopIsAPersons(stop.event.reason) && + (stop.event.turnId === undefined || stop.event.turnId === turnId) + ) +} + /** Whether `stop` makes the end of turn `turnId` a person's cancellation: a person's Stop or close - * that named that turn (`opened`: or named none, and stopped the turn item `itemId` opened). */ -export function stopIsTurnCancellation( - stop: JournalLatestStop | null, + * that named that turn, or named none and stopped the turn item `itemId` opened. */ +function stopIsTurnCancellation( + stop: JournalLatestStop, turnId: string, - opened?: { state: TurnEndState; itemId: string } + state: TurnEndState, + itemId: string ): boolean { - if (stop === null || !stopIsAPersons(stop.event.reason)) { - return false - } - if (stop.event.turnId !== undefined || !opened) { - return stop.event.turnId === turnId - } - return turnlessStopStopped(opened.state, stop, opened.itemId) + return ( + personStopMayNameTurn(stop, turnId) && + (stop.event.turnId !== undefined || turnlessStopStopped(state, stop, itemId)) + ) } /** Pressed before any turn showed, a Stop stopped the first turn opened after it, and no later @@ -93,7 +101,7 @@ export function turnEndAfterStop( return body } const stop = state.queuePauseMarks.latestStop - if (!stop || !stopIsTurnCancellation(stop, body.turnId, { state, itemId })) { + if (!stop || !stopIsTurnCancellation(stop, body.turnId, state, itemId)) { return body } // An exit the provider saw before the Stop was news, whenever its end is written. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index eed9878e004b..2b02ff6266c2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -362,6 +362,28 @@ it('reads a Stop pressed before Claude echoed the send as interrupted, not as a expect(await agentStatus()).toMatchObject({ mainAgent: { outcome: 'cancellation' } }) }) +// CLIs before 2.1.91 end an interrupted turn with an error result that names no reason. +it("reads an older CLI's error end after a Stop pressed before the echo as interrupted, not failed", async () => { + const connection = claude.connections[0]! + const { terminal_reason: _reason, ...olderResult } = INTERRUPTED_RESULT + claude.routes.interrupt = () => { + setTimeout(() => { + const written = connection.sent.find((message) => message.type === 'user')! + frame(connection, { ...written, uuid: written.uuid }) + frame(connection, olderResult) + frame(connection, { type: 'system', subtype: 'session_state_changed', state: 'idle' }) + }, 5) + return { still_queued: [], cancelled: [] } + } + await sendUnechoed(connection) + + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + + expect(await turnOutcome()).toBe('cancellation') + expect(await agentStatus()).toMatchObject({ mainAgent: { outcome: 'cancellation' } }) +}) + it('ends the child once the grace runs out when Claude says nothing after a Stop before the echo', async () => { const connection = claude.connections[0]! const eventsAtClose = stopEventsAtClose(connection) From 4fdd9adf5c6071f929df085ce6aee503cdedbbfd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:03:47 -0700 Subject: [PATCH 36/58] fix(native-chat): a person's Stop and /clear each name why they end the agent The host's mutation path ended the agent with one "recorded" ending for every caller, which read back the reason of whatever Stop event the journal held last, however old. /clear writes no Stop event, so its end took an unrelated earlier reason. Each caller now names its own: the chat's Stop `user-stop`, whose event its own step wrote, and /clear `user-close`, the user replacing this chat. --- .../structured-agent-session-chat-stop.ts | 8 ++++--- .../structured-agent-session-host-lifetime.ts | 22 +++++-------------- .../structured-agent-session-host.ts | 3 +-- ...ructured-agent-session-mutation-context.ts | 6 +++-- .../structured-conversation-command.test.ts | 19 ++++++++++++++++ .../structured-conversation-command.ts | 3 ++- 6 files changed, 36 insertions(+), 25 deletions(-) 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 952ea4a214eb..22f91b3ba405 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 @@ -51,6 +51,8 @@ export function mutateWithChatStop( let windDown: StructuredAgentSessionStopWindDown | undefined const named = turnId !== undefined ? { turnId } : {} const stopEvent = { reason: 'user-stop' as const, caller: caller.callerKey, ...named } + // Its own step wrote the Stop's event first. + const stopChild = () => context.stopAgent(sessionId, { recorded: 'user-stop' }) // The same for every client: once the Stop takes effect its event is written, and the queue's // pause follows from it. The cards stay published; no text rides the answer. const stop = (ctx: AgentSessionTurnContext): Promise => @@ -64,7 +66,7 @@ export function mutateWithChatStop( if (child?.phase === 'starting') { // A start that may never land is the one thing here Stop has to end; the chat stays. await tookEffect() - await context.stopAgent(ctx.sessionId) + await stopChild() return { ok: true, value: { ...named, cancelled: true } } } // A Stop naming no turn ends nothing more unless the session reads working, by the rule @@ -86,7 +88,7 @@ export function mutateWithChatStop( { clientOperationId: envelope.clientOperationId, ...named, - stopChild: () => context.stopAgent(sessionId), + stopChild, endSession: (owed) => { windDown = owed }, @@ -111,7 +113,7 @@ export function mutateWithChatStop( await endStoppedStructuredAgentSession( { sessionId, adapter: context.deps.adapter }, windDown, - () => context.stopAgent(sessionId), + stopChild, (error) => context.deps.onEventSinkError?.({ sessionId, error }) ) } 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 63ff659702f0..4ec95bf170d2 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 @@ -87,29 +87,17 @@ function owedProviderChildWindDown( : session.owesProviderChildWindDown } -/** How a stop ends the child. A person's Stop wrote its event in its own step (`recorded`); 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. */ +/** How a stop ends the child, and why (`lastEndedChild`). A person's Stop wrote its event in its + * own step (`recorded` names its reason); any other stop names the reason its event records, with + * the host's text for it. Quit writes none: its resume marker's trigger records why. */ export type StructuredAgentSessionStopEnding = - | { recorded: true } + | { recorded: 'user-stop' } | { cause: Exclude reason?: string quit?: true } -/** Why the child this stop ends ended, for the delivery loop (`lastEndedChild`): a person's Stop's - * reason is its event's, which its own step wrote before it got here. */ -function childEndCause( - session: StructuredAgentSessionHostSession, - ending: StructuredAgentSessionStopEnding -): StructuredAgentSessionStopCause { - // A failed write of that event leaves the Stop no less the person's. - return 'recorded' in ending - ? (session.journal.stopMarks.latest()?.event.reason ?? 'user-stop') - : ending.cause -} - /** * Writes this stop's event (`JournalStopEvent`) when it ends a running turn or a start: a stop * that ends nothing writes nothing. Issued before the kill and never awaited by it: bookkeeping, @@ -182,7 +170,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( endProviderChild(session, { generation: stopping.generation, fence: stopping.fence, - cause: childEndCause(session, ending), + cause: 'recorded' in ending ? ending.recorded : ending.cause, reason: ('reason' in ending ? ending.reason : undefined) ?? null, duringStartup: stopping.phase === 'starting', ...verdict diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 366805935052..405580406ed3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -283,8 +283,7 @@ export class StructuredAgentSessionHost { ensureAgent: (sessionId) => ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), - // A person's Stop: its own step wrote the Stop's event first. - stopAgent: (sessionId) => this.lifetime.stopAgent(sessionId, { recorded: true }), + stopAgent: (sessionId, ending) => this.lifetime.stopAgent(sessionId, ending), wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), now: () => this.now() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index 73b47f1fe6e2..520a1dbfa13a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -12,6 +12,7 @@ import { type AgentSessionMutationSessionPreparation } from './structured-agent-session-mutation-admission' import type { MutationPlan } from './structured-agent-session-mutation-plans' +import type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-lifetime' import type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, @@ -34,8 +35,9 @@ export type StructuredAgentSessionMutationContext = { ensureAgent: (sessionId: string) => Promise /** A message was accepted: the session's delivery loop hands it over. */ wakeDelivery: (sessionId: string) => void - /** Stops the session's provider child, keeping its conversation; inside the caller's serialize. */ - stopAgent: (sessionId: string) => Promise + /** Stops the session's provider child, keeping its conversation; inside the caller's serialize. + * Each caller names why (`ending`). */ + stopAgent: (sessionId: string, ending: StructuredAgentSessionStopEnding) => Promise /** Only for gate inputs living in the RECORD store, which can settle with no * journal commit (a conversation command). Draft-table changes need no call: * the draft store notifies through the journal's own commit listener. */ diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index 336e120d8baf..70513003b868 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -348,6 +348,25 @@ describe('/clear starts nothing', () => { expect(atCommit).toEqual({ child: null, claim: 'released' }) }) + // Its own cause, never the reason of whatever Stop the journal holds last. + it("ends a running source's agent as the user closing the chat", async () => { + const session = host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)! + await session.journal.appendStopEvent( + { reason: 'host-stop' }, + store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence + ) + const commit = store.commitConversationClear + let endedAs: string | undefined + vi.spyOn(store, 'commitConversationClear').mockImplementationOnce(async (clear) => { + endedAs = session.lastEndedChild?.cause + return commit(clear) + }) + + await clearCommits() + + expect(endedAs).toBe('user-close') + }) + it('founds one record per /clear through a chain of clears, starting neither', async () => { const first = await clearCommits() const second = await host.conversationCommand(caller, { diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 4a825d6081d5..efdf0ad4676f 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -104,7 +104,8 @@ export function runStructuredConversationCommand( } // Stopped before the marker, so nothing the old agent does can land after the clear. The // stop releases the lease, which moves its fence: the marker is written at the new one. - await context.stopAgent(sessionId) + // A /clear replaces this chat: the user closing it. + await context.stopAgent(sessionId, { cause: 'user-close' }) const fence = store.getRecord(sessionId)!.lease.runtimeFence const completed = { command, From 98ee326cff4b3bb7214e4a8540b3e99357107eda Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:17:44 -0700 Subject: [PATCH 37/58] fix(native-chat): a host stop judges whether it ends work after the provider's rows land A close, eviction or host stop decided whether it ended a running turn from the journal as it stood, while the provider's own rows (the turn its echo opened) could still be in the session's event sink. A close landing in that gap wrote no Stop event, so the turn it cut read as news. It now reads after the sink drains, as a person's Stop does, through the same check; a drain that fails or takes over a second reads working. --- ...ctured-agent-session-close-verdict.test.ts | 21 ++++---- .../structured-agent-session-host-lifetime.ts | 54 +++++++++++++------ .../structured-agent-session-turns-cancel.ts | 15 ++++-- 3 files changed, 61 insertions(+), 29 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index 1a42f5803c86..89878aee78d7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -19,13 +19,10 @@ import { StructuredAgentSessionHost } from './structured-agent-session-host' import { adapter, attach, - CALLER, - envelope, hostTestState, replaceHostTestState } from './structured-agent-session-host-test-harness' import { - hostTestMessage, HOST_TEST_NOW, HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD @@ -171,14 +168,20 @@ describe('a turn cut short by closing its provider', () => { }) it("records the user's close on a turn whose start landed only as the provider stopped", async () => { - // A send still unanswered when the close arrives: the journal has no turn yet. + // The provider opened the turn, but its rows are still in the event sink when the close + // arrives: the journal has no turn yet. await attach() - hostTestState().dispatch.mockResolvedValueOnce({ state: 'admitted' }) - const body = hostTestMessage('long job') - await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) await host.flushStreamedEvents(SESSION) - await vi.waitFor(async () => - expect((await host.journalSnapshot(SESSION)).submissions[0]?.handedOverAt).toBeDefined() + const events = hostTestState().acquire.mock.calls[0]?.[0].events + events?.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'cut-turn', ordinal: 0 }, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + events?.appendItem( + CUT_TURN, + { kind: 'turn', turnId: 'cut-turn', state: 'running', startedAt: 1_000, requestedAt: 1_000 }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.close(SESSION, 'user-close') 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 4ec95bf170d2..18391bb7f6e2 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 @@ -31,7 +31,7 @@ import { import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' -import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { isMainAgentWorkingOnceFlushed } from './structured-agent-session-turns-cancel' export type StructuredAgentSessionLifetimeContext = { deps: StructuredAgentSessionHostDeps @@ -98,29 +98,50 @@ export type StructuredAgentSessionStopEnding = quit?: true } +/** How long a host stop waits for the session's sink before it judges whether the stop ends work. */ +const STOP_EVENT_DRAIN_TIMEOUT_MS = 1_000 + /** - * Writes this stop's event (`JournalStopEvent`) when it ends a running turn or a start: a stop - * that ends nothing writes nothing. Issued before the kill and never awaited by it: bookkeeping, - * reported on failure. + * Whether this stop ends work its event must record: a start, or a running turn or unanswered send + * read once the sink drained what the provider already said (`isMainAgentWorkingOnceFlushed`). A + * person's Stop wrote its own event, and quit writes none. */ +async function stopEndsWork( + context: StructuredAgentSessionLifetimeContext, + sessionId: string, + session: StructuredAgentSessionHostSession, + ending: StructuredAgentSessionStopEnding +): Promise { + const { child, journal } = session + if ('recorded' in ending || ending.quit || !child) { + return false + } + return ( + child.phase === 'starting' || + isMainAgentWorkingOnceFlushed( + { + journal, + fence: child.fence, + flushStreamedEvents: () => context.runtimeState.flushEventSink(sessionId) + }, + STOP_EVENT_DRAIN_TIMEOUT_MS + ) + ) +} + +/** Writes this stop's event (`JournalStopEvent`). Issued before the kill and never awaited by it: + * bookkeeping, reported on failure. */ function recordStopEvent( context: StructuredAgentSessionLifetimeContext, sessionId: string, session: StructuredAgentSessionHostSession, ending: StructuredAgentSessionStopEnding ): Promise { - const { child, journal } = session - const turnId = journal.activeTurnId() - if ( - 'recorded' in ending || - ending.quit || - !child || - (child.phase !== 'starting' && - !isStructuredAgentSessionMainAgentWorking(turnId, journal.submissions(), child.fence)) - ) { + if ('recorded' in ending) { return Promise.resolve() } - return journal + const turnId = session.journal.activeTurnId() + return session.journal .appendStopEvent( { reason: ending.cause, ...(turnId !== null ? { turnId } : {}) }, structuredAgentSessionConversationFence(context.deps.store, sessionId) @@ -146,7 +167,10 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( if (!session) { return } - const recorded = recordStopEvent(context, sessionId, session, ending) + // Judged before the kill: a stop that ends nothing writes nothing. + const recorded = (await stopEndsWork(context, sessionId, session, ending)) + ? recordStopEvent(context, sessionId, session, ending) + : Promise.resolve() // The obligation OUTLIVES the child. `child` is ended the instant the adapter proves the exit, // so a step that aborts after that point would otherwise leave the retry reading "no child // here" and skipping the settlement and the lease release it still owes. 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 cdb70ff41d45..b90b4ad8645e 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 @@ -13,16 +13,21 @@ import { } from './structured-agent-session-command-turn' import { validatePendingPrompt } from './structured-agent-session-prompt-state' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { withTimeout } from '../../../shared/promise-timeout-fallback' 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. - * A failed drain reads working: bookkeeping never talks a Stop out of stopping. */ + * A failed drain, or one still running after `timeoutMs`, reads working: bookkeeping never talks a + * Stop out of stopping. */ export async function isMainAgentWorkingOnceFlushed( - ctx: Pick + ctx: Pick, + timeoutMs?: number ): Promise { - try { - await ctx.flushStreamedEvents() - } catch { + const flushed = ctx.flushStreamedEvents().then( + () => true, + () => false + ) + if (!(await (timeoutMs === undefined ? flushed : withTimeout(flushed, timeoutMs, false)))) { return true } return isStructuredAgentSessionMainAgentWorking( From d0e4cfc107b20ff99ee465e5d0114360ce272a98 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:28:10 -0700 Subject: [PATCH 38/58] fix(native-chat): a Claude Stop naming a turn that just ended still marks the follow-up it cuts A phone names the turn it last saw. When that turn had ended and a follow-up was still unechoed, Claude's Stop interrupted the follow-up and ended the child, but the Stop's event named the ended turn, so the follow-up's turn the child's end cut read "Failed" under "Cancellation requested.". A Stop that ends the provider's session ends whatever is in flight, so its event now names the live turn or none, and a Stop that names none binds the turn opened next. Codex keeps naming only the turn the Stop names. The Claude Stop turn-end tests move to their own file, since the session-ending Stop suite is at its line budget. --- .../structured-agent-session-chat-stop.ts | 85 ++++---- ...t-session-claude-stop-ends-session.test.ts | 22 -- ...agent-session-claude-stop-turn-end.test.ts | 200 ++++++++++++++++++ 3 files changed, 247 insertions(+), 60 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.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 22f91b3ba405..3a9602dc6026 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 @@ -50,52 +50,61 @@ export function mutateWithChatStop( // Set by the Stop's step only when its provider's session ends; a replay leaves it unset. let windDown: StructuredAgentSessionStopWindDown | undefined const named = turnId !== undefined ? { turnId } : {} - const stopEvent = { reason: 'user-stop' as const, caller: caller.callerKey, ...named } // Its own step wrote the Stop's event first. const stopChild = () => context.stopAgent(sessionId, { recorded: 'user-stop' }) // The same for every client: once the Stop takes effect its event is written, and the queue's // pause follows from it. The cards stay published; no text rides the answer. const stop = (ctx: AgentSessionTurnContext): Promise => - runRecordedStop(ctx, stopEvent, async (tookEffect) => { - // Stop withdraws every queued SUBMISSION first, whatever the start or the child is doing. - const withdrawn = await ctx.journal.rejectQueuedSubmissions( - ctx.fence, - agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) - ) - const child = context.sessions.get(ctx.sessionId)?.child - if (child?.phase === 'starting') { - // A start that may never land is the one thing here Stop has to end; the chat stays. - await tookEffect() - await stopChild() - return { ok: true, value: { ...named, cancelled: true } } - } - // A Stop naming no turn ends nothing more unless the session reads working, by the rule - // every session list and the chat's own Stop read it. - const inFlight = turnId !== undefined || (await isMainAgentWorkingOnceFlushed(ctx)) - const record = context.deps.store.getRecord(ctx.sessionId) - if (!child || !inFlight) { - if (withdrawn.length > 0) { + runRecordedStop( + ctx, + { + reason: 'user-stop', + caller: caller.callerKey, + // A Stop that ends the provider's session ends whatever is in flight, so its event names + // the live turn, or none (the turn opened next), never a named turn that already ended. + ...(ctx.adapter.stopEndsSession?.(ctx.sessionId) === true ? {} : named) + }, + async (tookEffect) => { + // Stop withdraws every queued SUBMISSION first, whatever the start or the child is doing. + const withdrawn = await ctx.journal.rejectQueuedSubmissions( + ctx.fence, + agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + ) + const child = context.sessions.get(ctx.sessionId)?.child + if (child?.phase === 'starting') { + // A start that may never land is the one thing here Stop has to end; the chat stays. await tookEffect() + await stopChild() + return { ok: true, value: { ...named, cancelled: true } } } - return { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } } - } - // Awaited until journal appends are synchronous; then issued here, and a `finally` awaits it. - if (withdrawn.length > 0 || (await stopReachesUnrecordedWork(ctx, turnId))) { - await tookEffect() - } - return performCancel( - { ...ctx, failureTextContext: structuredAgentSessionFailureWordsContext(record) }, - { - clientOperationId: envelope.clientOperationId, - ...named, - stopChild, - endSession: (owed) => { - windDown = owed - }, - withdrewQueued: withdrawn.length > 0 + // A Stop naming no turn ends nothing more unless the session reads working, by the rule + // every session list and the chat's own Stop read it. + const inFlight = turnId !== undefined || (await isMainAgentWorkingOnceFlushed(ctx)) + const record = context.deps.store.getRecord(ctx.sessionId) + if (!child || !inFlight) { + if (withdrawn.length > 0) { + await tookEffect() + } + return { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } } } - ) - }) + // Awaited until journal appends are synchronous; then issued here, and a `finally` awaits it. + if (withdrawn.length > 0 || (await stopReachesUnrecordedWork(ctx, turnId))) { + await tookEffect() + } + return performCancel( + { ...ctx, failureTextContext: structuredAgentSessionFailureWordsContext(record) }, + { + clientOperationId: envelope.clientOperationId, + ...named, + stopChild, + endSession: (owed) => { + windDown = owed + }, + withdrewQueued: withdrawn.length > 0 + } + ) + } + ) const result = mutateStructuredAgentSession( context, caller, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index 2b02ff6266c2..eed9878e004b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -362,28 +362,6 @@ it('reads a Stop pressed before Claude echoed the send as interrupted, not as a expect(await agentStatus()).toMatchObject({ mainAgent: { outcome: 'cancellation' } }) }) -// CLIs before 2.1.91 end an interrupted turn with an error result that names no reason. -it("reads an older CLI's error end after a Stop pressed before the echo as interrupted, not failed", async () => { - const connection = claude.connections[0]! - const { terminal_reason: _reason, ...olderResult } = INTERRUPTED_RESULT - claude.routes.interrupt = () => { - setTimeout(() => { - const written = connection.sent.find((message) => message.type === 'user')! - frame(connection, { ...written, uuid: written.uuid }) - frame(connection, olderResult) - frame(connection, { type: 'system', subtype: 'session_state_changed', state: 'idle' }) - }, 5) - return { still_queued: [], cancelled: [] } - } - await sendUnechoed(connection) - - await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) - await laneDrained() - - expect(await turnOutcome()).toBe('cancellation') - expect(await agentStatus()).toMatchObject({ mainAgent: { outcome: 'cancellation' } }) -}) - it('ends the child once the grace runs out when Claude says nothing after a Stop before the echo', async () => { const connection = claude.connections[0]! const eventsAtClose = stopEventsAtClose(connection) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts new file mode 100644 index 000000000000..a9cfcaea9fc5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts @@ -0,0 +1,200 @@ +// Which turn a Claude Stop's event makes the person's cancellation, on the shipping adapter: the +// Stop ends the child, so whatever its event binds is what the child's end cut. Older CLIs end an +// interrupted turn with an error result that names no reason, and the journal's Stop rule decides +// it as it writes the end. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + fakeClaude, + PROVIDER_SESSION_ID, + type FakeConnection +} from '../../claude/claude-structured-session-test-support' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +// As Claude Code 2.1.280 advertises them on a turn's system/init frame. +const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1'] + +let root: string +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let store: AgentSessionRecordStore +let claude: ReturnType + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-stop-turn-end-')) + resetHostTestOperationIds() + claude = fakeClaude({ replayUuid: null }) + const lifecycle: Promise[] = [] + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, + continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + lifecycle.push(host.handleAdapterEvent(mapped)) + } + }, + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => NOW + }) + store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) + const params = hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + await adapter.awaitStarted(SESSION) + await Promise.all(lifecycle) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function eventually(assertion: () => T | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +function envelope(method: 'agentSession.send' | 'agentSession.cancel', fields: object) { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields: { ...fields } + }) + } +} + +/** A send Claude takes but has not echoed yet. */ +async function sendUnechoed(connection: FakeConnection, text: string): Promise { + const body = hostTestMessage(text) + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + await eventually(() => + expect(connection.sent.some((message) => JSON.stringify(message).includes(text))).toBe(true) + ) +} + +function frame(connection: FakeConnection, message: Record): void { + connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) +} + +/** Claude echoes its latest send, which opens that send's turn. */ +function echoLatest(connection: FakeConnection): void { + const written = connection.sent.findLast((message) => message.type === 'user')! + frame(connection, { ...written, uuid: written.uuid }) +} + +function stop(turnId?: string) { + const fields = turnId === undefined ? {} : { turnId } + return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields }) +} + +/** Resolves once everything queued on the session's lane so far has run: a Stop's second step. */ +function laneDrained(): Promise { + return host['tasks'].serialize(SESSION, async () => {}) +} + +async function lastTurn() { + await host.flushStreamedEvents(SESSION) + const { items } = await host.journalSnapshot(SESSION) + return readAgentJournalTurn(items.findLast((item) => item.body.kind === 'turn')?.body) +} + +it("reads an older CLI's error end after a Stop pressed before the echo as interrupted, not failed", async () => { + const connection = claude.connections[0]! + claude.routes.interrupt = () => { + setTimeout(() => { + echoLatest(connection) + frame(connection, { type: 'result', subtype: 'error_during_execution', is_error: true }) + frame(connection, { type: 'system', subtype: 'session_state_changed', state: 'idle' }) + }, 5) + return { still_queued: [], cancelled: [] } + } + await sendUnechoed(connection, 'Write a long reply.') + + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + + expect(await lastTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) +}) + +// A phone names the turn it last saw. The Stop ends the child, which ends whatever is in flight, +// so its event names no ended turn: it binds the turn the follow-up's echo opens. +it('reads a follow-up the child end cut as interrupted when the Stop named the turn before it', async () => { + const connection = claude.connections[0]! + await sendUnechoed(connection, 'Write a long reply.') + frame(connection, { type: 'system', subtype: 'init', uuid: 'init-1', capabilities: CAPABILITIES }) + echoLatest(connection) + const ended = await eventually(async () => { + const turnId = activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items) + expect(turnId).not.toBeNull() + return turnId! + }) + frame(connection, { type: 'result', subtype: 'success', is_error: false, uuid: 'ended-result' }) + await eventually(async () => + expect(activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items)).toBe( + null + ) + ) + await sendUnechoed(connection, 'Follow up.') + // Claude takes the interrupt; the follow-up's echo opens its turn, which outlives the grace. + claude.routes.interrupt = () => { + setTimeout(() => echoLatest(connection), 5) + return { still_queued: [], cancelled: [] } + } + + await expect(stop(ended)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + + expect(connection.closed).toBe(true) + const cut = await lastTurn() + expect(cut?.turnId).not.toBe(ended) + expect(cut).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) +}, 15_000) From 771ad35d74cb310e7d338dbf8811a0bd2dfb1069 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:38:44 -0700 Subject: [PATCH 39/58] fix(native-chat): the idle sweep reads working by the same rule as a stop's event The sweep judged a chat resting while a send whose reply was lost was still unanswered, but the stop's event writer counts that send as work. So the sweep evicted it and wrote an evict event, which ends a person's Stop pause and let the cards behind it drain on their own. The sweep's owed work now reads the main agent working the way every session list and the event writer do. --- .../structured-agent-session-idle-sweep.ts | 11 ++++++--- ...d-agent-session-stop-event-entries.test.ts | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index b57898626a81..2cbc1c3b8147 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -9,7 +9,7 @@ // Owed work is derived on every tick, never stored, so there is nothing to disagree with it. import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' -import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' @@ -139,11 +139,16 @@ export class StructuredAgentSessionIdleSweep { } /** Work the running child still owes. Scoped to the child: with none, nothing here can pin the - * handle, and a leftover prompt or turn row is only history. */ + * handle, and a leftover prompt or turn row is only history. Working reads as a stop's event + * does (`isStructuredAgentSessionMainAgentWorking`), so a chat swept as resting writes none. */ private owesWork(sessionId: string, session: StructuredAgentSessionHostSession): boolean { const items = session.journal.snapshot().items return ( - activeStructuredAgentSessionTurnId(items) !== null || + isStructuredAgentSessionMainAgentWorking( + session.journal.activeTurnId(), + session.journal.submissions(), + session.child?.fence + ) || this.queuedOrDelivering(sessionId, session) || agentChildWorkLiveness(this.deps.childWork(sessionId)) !== null || this.deps.hasOpenDispatch(sessionId) || diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index a0a1dd5dc511..97558d95c2f2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -170,6 +170,29 @@ describe("a person's Stop pause and the Stop events after it", () => { expect(await rig.handoff(held)).toBeUndefined() }) + // The sweep and the Stop event's writer read working by one rule: a send whose reply was lost + // may still be running, so neither calls the chat resting. + it('holds through an idle tick while a send with a lost reply is unanswered: nothing is evicted', async () => { + rig = await createQueuedMessageTestRig({ idleSweep: MANUAL_IDLE_SWEEP }) + const working = await rig.workingSend() + await queuedDraft('queued behind the turn') + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(working, 'stopped') + rig.dispatch.mockRejectedValueOnce(new Error('reply lost')) + const lost = rig.send('sent as the reply was lost') + await lost.result + await eventually(async () => + expect(await rig.submission(lost.id)).toMatchObject({ dispatchState: 'unknown' }) + ) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + + await idleSweep().tick() + + expect(rig.closeSession).not.toHaveBeenCalled() + expect(stopEvents().map((event) => event.reason)).toEqual(['user-stop']) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + }) + it('ends when a host eviction ends a running turn: that Stop event is later', async () => { rig = await createQueuedMessageTestRig() const working = await rig.workingSend() From a51a1ee36cebf4c67ed0e0960e621c26700c1c72 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:47:07 -0700 Subject: [PATCH 40/58] test(native-chat): an aborted eviction's injected drain failure lands on the eviction's own drain A host stop now drains the session's sink once to judge whether it ends work, so the tests that fail the eviction's drain-published step skip that first drain. --- ...red-agent-session-surface-lifetime.test.ts | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 7d7d4861cd0b..aebdfd8a9c40 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -142,12 +142,14 @@ function waitOutSeveralSweeps(): Promise { return new Promise((resolve) => setTimeout(resolve, SWEEP_MS * 20)) } -/** Fails the next eviction at `drain-published`, which leaves the session indexed for a retry. */ -function failNextDrain(): void { - vi.spyOn(host['runtimeState'].eventSinkFor(SESSION), 'drained').mockResolvedValueOnce({ - ok: false, - error: new Error('drain barrier lost') - }) +/** Fails the next eviction at `drain-published`, which leaves the session indexed for a retry. The + * stop drains once before it, to judge whether it ends work. */ +function failEvictionDrain(): void { + const sink = host['runtimeState'].eventSinkFor(SESSION) + const drained = sink.drained.bind(sink) + vi.spyOn(sink, 'drained') + .mockImplementationOnce(drained) + .mockResolvedValueOnce({ ok: false, error: new Error('drain barrier lost') }) } /** The submissions as they stood when the session was forgotten; its journal is gone after that. */ @@ -343,10 +345,7 @@ describe('a chat that closes', () => { expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) const session = host['sessions'].get(SESSION) expect(session).toBeDefined() - vi.spyOn(host['runtimeState'].eventSinkFor(SESSION), 'drained').mockResolvedValueOnce({ - ok: false, - error: new Error('drain barrier lost') - }) + failEvictionDrain() const settled = captureSettledSubmissions() await expect(host.close(SESSION, 'evict')).rejects.toMatchObject({ step: 'drain-published' }) @@ -818,7 +817,7 @@ describe('a quit over an eviction that never got its retry', () => { await attach() await sendPending('pending across an abandoned eviction') const settled = captureSettledSubmissions() - failNextDrain() + failEvictionDrain() await expect(host.close(SESSION, 'evict')).rejects.toMatchObject({ step: 'drain-published' }) expect(host['sessions'].get(SESSION)?.child).toBeNull() From eb27559bcbbc9689b262305ba7469217ad35cf43 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:48:23 -0700 Subject: [PATCH 41/58] fix(native-chat): the idle sweep's rest writes no Stop event; it evicts a send that never echoes The previous commit made the sweep count an unanswered send as owed work, which pins a chat whose admitted send Codex never echoes forever, and the sweep exists to retire exactly that. That rule returns. The sweep stops only an agent it judged resting, so its eviction now writes no Stop event, whatever send it retires: a person's Stop pause holds through it. --- .../structured-agent-session-conversation-lifetime.ts | 2 +- .../structured-agent-session-host-lifetime.ts | 7 +++++-- .../structured-agent-session-idle-sweep.ts | 11 +++-------- ...tructured-agent-session-stop-event-entries.test.ts | 11 ++++++----- 4 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 2ea8820e7580..43100a17e265 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -80,7 +80,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { }, providerHoldsDispatch: (sessionId) => deps().adapter.holdsDispatch?.(sessionId) === true, // The host puts an idle agent to rest: a turn it cuts short is news, not the user's Stop. - stopAgent: (sessionId) => stopAgent(sessionId, { cause: 'evict' }), + stopAgent: (sessionId) => stopAgent(sessionId, { cause: 'evict', resting: true }), // A host stop: the delivery loop waiting on this child writes the one error row and rejects // what is queued with it, both worded from the hostStopped fact. stopStartingAgent: (sessionId) => 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 18391bb7f6e2..653c806cf89c 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 @@ -96,6 +96,9 @@ export type StructuredAgentSessionStopEnding = cause: Exclude reason?: string quit?: true + /** The idle sweep judged the agent resting (`owesWork`): a send it retires unanswered is + * no work its event records. */ + resting?: true } /** How long a host stop waits for the session's sink before it judges whether the stop ends work. */ @@ -104,7 +107,7 @@ const STOP_EVENT_DRAIN_TIMEOUT_MS = 1_000 /** * Whether this stop ends work its event must record: a start, or a running turn or unanswered send * read once the sink drained what the provider already said (`isMainAgentWorkingOnceFlushed`). A - * person's Stop wrote its own event, and quit writes none. + * person's Stop wrote its own event, and quit and the idle sweep's rest write none. */ async function stopEndsWork( context: StructuredAgentSessionLifetimeContext, @@ -113,7 +116,7 @@ async function stopEndsWork( ending: StructuredAgentSessionStopEnding ): Promise { const { child, journal } = session - if ('recorded' in ending || ending.quit || !child) { + if ('recorded' in ending || ending.quit || ending.resting || !child) { return false } return ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index 2cbc1c3b8147..b57898626a81 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -9,7 +9,7 @@ // Owed work is derived on every tick, never stored, so there is nothing to disagree with it. import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' -import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' @@ -139,16 +139,11 @@ export class StructuredAgentSessionIdleSweep { } /** Work the running child still owes. Scoped to the child: with none, nothing here can pin the - * handle, and a leftover prompt or turn row is only history. Working reads as a stop's event - * does (`isStructuredAgentSessionMainAgentWorking`), so a chat swept as resting writes none. */ + * handle, and a leftover prompt or turn row is only history. */ private owesWork(sessionId: string, session: StructuredAgentSessionHostSession): boolean { const items = session.journal.snapshot().items return ( - isStructuredAgentSessionMainAgentWorking( - session.journal.activeTurnId(), - session.journal.submissions(), - session.child?.fence - ) || + activeStructuredAgentSessionTurnId(items) !== null || this.queuedOrDelivering(sessionId, session) || agentChildWorkLiveness(this.deps.childWork(sessionId)) !== null || this.deps.hasOpenDispatch(sessionId) || diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index 97558d95c2f2..acc72441a008 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -170,9 +170,9 @@ describe("a person's Stop pause and the Stop events after it", () => { expect(await rig.handoff(held)).toBeUndefined() }) - // The sweep and the Stop event's writer read working by one rule: a send whose reply was lost - // may still be running, so neither calls the chat resting. - it('holds through an idle tick while a send with a lost reply is unanswered: nothing is evicted', async () => { + // The sweep rests an agent with no turn running even while a send whose reply was lost waits, + // so that send cannot pin it forever; that rest ends no work, so it writes no event. + it('holds through an idle eviction that retires a send whose reply was lost', async () => { rig = await createQueuedMessageTestRig({ idleSweep: MANUAL_IDLE_SWEEP }) const working = await rig.workingSend() await queuedDraft('queued behind the turn') @@ -185,11 +185,12 @@ describe("a person's Stop pause and the Stop events after it", () => { expect(await rig.submission(lost.id)).toMatchObject({ dispatchState: 'unknown' }) ) expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + const atClose = stopEventsAtClose() await idleSweep().tick() - expect(rig.closeSession).not.toHaveBeenCalled() - expect(stopEvents().map((event) => event.reason)).toEqual(['user-stop']) + expect(rig.closeSession).toHaveBeenCalledTimes(1) + expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) }) From 693583d6f3a7433da8c384ddbfaffa5b09083303 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:50:33 -0700 Subject: [PATCH 42/58] fix(native-chat): stopping a start that carries no send writes no Stop event A host stop, eviction or close of a starting child wrote a Stop event whatever the start carried. A start with a send already reads working, so the clause only mattered for a start with none, which ends no turn and no send: its event only lifted a person's Stop pause and bumped the idle clock, which is why the idle sweep had been changed to close the conversation in the same pass. The clause goes and the sweep is #24072's again. The child's end still reads host-stop, as before. --- .../structured-agent-session-host-lifetime.ts | 24 +++++++++---------- .../structured-agent-session-idle-sweep.ts | 9 +++---- ...d-agent-session-stop-event-entries.test.ts | 21 ++++++++++++++-- 3 files changed, 35 insertions(+), 19 deletions(-) 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 653c806cf89c..0fdd577daf6d 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 @@ -105,9 +105,10 @@ export type StructuredAgentSessionStopEnding = const STOP_EVENT_DRAIN_TIMEOUT_MS = 1_000 /** - * Whether this stop ends work its event must record: a start, or a running turn or unanswered send - * read once the sink drained what the provider already said (`isMainAgentWorkingOnceFlushed`). A - * person's Stop wrote its own event, and quit and the idle sweep's rest write none. + * Whether this stop ends work its event must record: a running turn or an unanswered send, a start's + * own included, read once the sink drained what the provider already said + * (`isMainAgentWorkingOnceFlushed`). A start that carries no send ends nothing. A person's Stop + * wrote its own event, and quit and the idle sweep's rest write none. */ async function stopEndsWork( context: StructuredAgentSessionLifetimeContext, @@ -119,16 +120,13 @@ async function stopEndsWork( if ('recorded' in ending || ending.quit || ending.resting || !child) { return false } - return ( - child.phase === 'starting' || - isMainAgentWorkingOnceFlushed( - { - journal, - fence: child.fence, - flushStreamedEvents: () => context.runtimeState.flushEventSink(sessionId) - }, - STOP_EVENT_DRAIN_TIMEOUT_MS - ) + return isMainAgentWorkingOnceFlushed( + { + journal, + fence: child.fence, + flushStreamedEvents: () => context.runtimeState.flushEventSink(sessionId) + }, + STOP_EVENT_DRAIN_TIMEOUT_MS ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index b57898626a81..14cc0e56030d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -123,10 +123,11 @@ export class StructuredAgentSessionIdleSweep { } if (session.child) { // A start that has been quiet this long is not coming: the host stops it, with its reason. - // The close below keeps a conversation whose delivery loop still owes that start's rows. - await (session.child.phase === 'starting' - ? this.deps.stopStartingAgent(sessionId) - : this.deps.stopAgent(sessionId)) + if (session.child.phase === 'starting') { + await this.deps.stopStartingAgent(sessionId) + return + } + await this.deps.stopAgent(sessionId) } await this.deps.closeConversation(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index acc72441a008..5748a7f2c4e0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -1,5 +1,5 @@ // Every way a host stop or close begins writes the Stop's event with its reason, before it ends the -// child, and only when it ends work: a running turn, or a start. A stop that ends nothing writes +// child, and only when it ends work: a running turn or a send. A stop that ends nothing writes // nothing, quit writes nothing (its resume marker records why), and any later Stop event ends a // person's Stop pause. @@ -112,7 +112,7 @@ describe('every Stop entry writes its event, with its reason, before it ends the ]) }) - it('the idle sweep stopping a start that never landed', async () => { + it('the idle sweep stopping a start, with its send, that never landed', async () => { rig = await createQueuedMessageTestRig({ starting: true, restartable: true, @@ -194,6 +194,23 @@ describe("a person's Stop pause and the Stop events after it", () => { expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) }) + // A start that carries no send ends no turn and no send, so stopping it writes nothing. + it('holds through the sweep stopping a start that carries no send, which writes nothing', async () => { + rig = await createQueuedMessageTestRig({ starting: true, idleSweep: MANUAL_IDLE_SWEEP }) + expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe( + 'starting' + ) + // A person's Stop of an earlier turn still pauses the queue. + await journal().appendStopEvent({ reason: 'user-stop', caller: 'client-1' }, 1) + expect(journal().queuedMessages.userStopInForce()).not.toBeNull() + const atClose = stopEventsAtClose() + + await idleSweep().tick() + + expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) + expect(journal().queuedMessages.userStopInForce()).not.toBeNull() + }) + it('ends when a host eviction ends a running turn: that Stop event is later', async () => { rig = await createQueuedMessageTestRig() const working = await rig.workingSend() From 3b8c6e74e4917f37515fcec45ff5be27b965db6b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:56:50 -0700 Subject: [PATCH 43/58] test(native-chat): a Stop's pause across a restart is tested with a restart that writes no event The rig's restart closes the chat with an eviction, which now writes a Stop event when work runs and so ends a person's Stop pause. "A Stop never hides a restart's pause" then passed with no Stop pause left to hide anything. Those tests, and the pause-lift test whose dropped assertion returns, restart as a process that dies with no close, which like a quit writes no Stop event, and assert that both the Stop's and the restart's pauses are in force first. --- ...nt-session-queued-message-rig.test-fixture.ts | 4 +++- ...tured-agent-session-queued-pause-lift.test.ts | 9 ++++++--- ...uctured-agent-session-queued-stop-row.test.ts | 16 ++++++++++++++-- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index f39c2d8cb195..83fa0c39c8ab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -268,7 +268,9 @@ export async function createQueuedMessageTestRig( } /** A host-process restart, as the queue sees it: the conversation closes, and - * opens afresh under a new instance id while its rows survive. */ + * opens afresh under a new instance id while its rows survive. The close is an eviction, whose + * Stop event ends a person's Stop pause if work runs; a quit writes none, so a test of that + * pause across a restart uses `crashRestartHostProcess`. */ async function restartHostProcess(): Promise { await host.close(SESSION, 'evict') rotateStructuredAgentSessionHostInstanceForTests() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index f2ad9a18fa41..d5aa5e497d45 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -91,11 +91,14 @@ describe("a Stop's queue pause", () => { it('survives a restart, and a send made after the Stop still ends it when its turn starts there', async () => { const draftId = await stoppedDraft() - // Derived from the journal, not remembered: a restart forgets nothing it needs. Quit writes no - // Stop event, so the process dying is that restart; `restartHostProcess` closes with an - // eviction, whose event would end the pause itself. + const inFlight = await handedOverUserSend('sent before the restart') + // Derived from the journal, not remembered: a restart forgets nothing it needs. The process + // dies with no close, as a quit writes no Stop event to end the pause either. rig.crashRestartHostProcess() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await rig.settleAccepted(inFlight, 'after-restart') + // The Stop's pause is over; the restart's own lasts until a turn asked for since it. + await eventually(async () => expect(await rig.queuePause()).toEqual({ reason: 'restarted' })) const next = rig.send('sent after the restart') await next.result await rig.settleAccepted(next.id, 'next') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index aa2c93ba15e7..d1db51426c50 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -192,8 +192,15 @@ describe("a Stop never hides a restart's pause", () => { await rig.settleAccepted(working, 'stopped') const mail = await mailTurn() const typed = await queuedDraft('typed during the mail turn') - await rig.restartHostProcess() + // The process dies with no close: a quit writes no Stop event either, so the Stop's pause stays. + rig.crashRestartHostProcess() await rig.settleAccepted(mail, 'mail') + // The new host opens the conversation for its first reader. + await rig.queuePause() + expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual([ + 'stopped', + 'restarted' + ]) await expectHeld('restarted', typed) }) @@ -207,8 +214,13 @@ describe("a Stop never hides a restart's pause", () => { expect((await rig.handoff(correction))?.handedOverAt).toBeDefined() ) const typed = await queuedDraft('typed during that send') - await rig.restartHostProcess() + rig.crashRestartHostProcess() await rig.settleAccepted(await rig.handoffId(correction), 'correction') + await rig.queuePause() + expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual([ + 'stopped', + 'restarted' + ]) await expectHeld('restarted', typed) }) }) From 37ed748f8daa73ae6823207830824b5348722ada Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:02:34 -0700 Subject: [PATCH 44/58] fix(native-chat): a host stop of a turn a person's Stop is still ending keeps that Stop's reason An eviction or host stop that landed while a person's Stop or close was already ending the same turn wrote a newer Stop event, and the turn's end reads only the latest, so the person's Stop of that turn read as news. A host reason now writes nothing while a person's Stop still decides what runs: the live turn it names or bound, or, with none, the turn a send opens next. The person's own close still writes. The E2 tests now open and end the stopped send's own turn, as Codex does, so the mail turn after it is not the turnless Stop's. --- .../journal-stop-marks.ts | 9 +++- .../journal-stop-turn-end.ts | 25 +++++++++-- .../structured-agent-session-host-lifetime.ts | 7 +++- ...d-agent-session-stop-event-entries.test.ts | 42 +++++++++++++++++++ 4 files changed, 76 insertions(+), 7 deletions(-) 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 87a87f26e194..27cfa28e536d 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,8 +1,8 @@ // 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`). +// the turn-end rule reads (`journal-stop-turn-end.ts`), and whether a person's still decides. import type { JournalReducerState } from './journal-reducer' -import type { JournalLatestStop } from './journal-stop-turn-end' +import { personStopInForce, type JournalLatestStop } from './journal-stop-turn-end' export class JournalStopMarks { constructor(private readonly deps: { state: () => JournalReducerState }) {} @@ -10,4 +10,9 @@ export class JournalStopMarks { latest(): JournalLatestStop | null { return this.deps.state().queuePauseMarks.latestStop } + + /** `personStopInForce`: a person's Stop already decides what runs now. */ + personStopInForce(turnId: string | null): boolean { + return personStopInForce(this.deps.state(), turnId) + } } 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 ac15fe04f0b3..9fed007679f6 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 @@ -47,7 +47,7 @@ function stopIsTurnCancellation( stop: JournalLatestStop, turnId: string, state: TurnEndState, - itemId: string + itemId: string | null ): boolean { return ( personStopMayNameTurn(stop, turnId) && @@ -56,13 +56,14 @@ function stopIsTurnCancellation( } /** Pressed before any turn showed, a Stop stopped the first turn opened after it, and no later - * one: unless a send a person made since was accepted, whose turn that is. */ + * one: unless a send a person made since was accepted, whose turn that is. `itemId` null: a turn + * not yet opened. */ function turnlessStopStopped( state: TurnEndState, stop: JournalLatestStop, - itemId: string + itemId: string | null ): boolean { - const createdAt = state.items.get(itemId)?.sequence ?? null + const createdAt = itemId === null ? null : (state.items.get(itemId)?.sequence ?? null) if ( (createdAt !== null && createdAt <= stop.sequence) || state.latestPersonTurnSequence >= stop.sequence @@ -82,6 +83,22 @@ function turnlessStopStopped( return true } +/** Whether a person's Stop already decides the end of what runs now: the live turn `turnId`, or + * with none, the turn a send opens next. A host stop of that work must not supersede it. */ +export function personStopInForce(state: TurnEndState, turnId: string | null): boolean { + const stop = state.queuePauseMarks.latestStop + if (!stop || !stopIsAPersons(stop.event.reason)) { + return false + } + if (turnId === null) { + return stop.event.turnId === undefined && turnlessStopStopped(state, stop, null) + } + const itemId = [...state.items].find( + ([, item]) => readAgentJournalTurn(item.body)?.turnId === turnId + )?.[0] + return stopIsTurnCancellation(stop, turnId, state, itemId ?? null) +} + /** * 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 it before it showed, 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 0fdd577daf6d..d3e5c728feb4 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 @@ -120,7 +120,7 @@ async function stopEndsWork( if ('recorded' in ending || ending.quit || ending.resting || !child) { return false } - return isMainAgentWorkingOnceFlushed( + const working = await isMainAgentWorkingOnceFlushed( { journal, fence: child.fence, @@ -128,6 +128,11 @@ async function stopEndsWork( }, STOP_EVENT_DRAIN_TIMEOUT_MS ) + // A host stop of work a person's Stop is already ending must not supersede that Stop's reason. + return ( + working && + (ending.cause === 'user-close' || !journal.stopMarks.personStopInForce(journal.activeTurnId())) + ) } /** Writes this stop's event (`JournalStopEvent`). Issued before the kill and never awaited by it: diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index 5748a7f2c4e0..26f191a7f90c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' import { @@ -59,6 +60,28 @@ async function runningTurn(turnId = 'turn-1'): Promise { return working } +/** The stopped send's turn opens after its turnless Stop and ends cut: the turn that Stop bound. */ +async function stoppedTurnEnded(): Promise { + const fence = rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1 + const scope = { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + const identity = { + provider: 'codex' as const, + threadId: 'thread-1', + turnId: 'turn-stopped', + ordinal: 998 + } + await journal().appendItem( + identity, + { kind: 'turn', turnId: 'turn-stopped', state: 'running', startedAt: 1 }, + scope + ) + await journal().appendItem( + identity, + { kind: 'turn', turnId: 'turn-stopped', state: 'interrupted', completedAt: Date.now() }, + scope + ) +} + async function queuedDraft(text: string): Promise { const queued = await rig.send(text, 'queue-if-active').result if (!queued.ok || !('queued' in queued.value)) { @@ -141,6 +164,23 @@ describe('every Stop entry writes its event, with its reason, before it ends the expect(atClose.events).toEqual([]) }) + // The person's reason survives to the turn's end. + it("writes nothing when the host evicts a turn a person's Stop is still ending", async () => { + rig = await createQueuedMessageTestRig() + await runningTurn() + expect(await rig.stop()).toMatchObject({ ok: true }) + const atClose = stopEventsAtClose() + + await rig.host.close(HOST_TEST_SESSION, 'evict') + + expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) + const { items } = await rig.host.journalSnapshot(HOST_TEST_SESSION) + expect(items.map((item) => readAgentJournalTurn(item.body)).find(Boolean)).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) + }) + it("writes nothing at quit, whose resume marker's trigger records why", async () => { rig = await createQueuedMessageTestRig() await runningTurn() @@ -217,6 +257,7 @@ describe("a person's Stop pause and the Stop events after it", () => { await queuedDraft('queued behind the turn') expect(await rig.stop()).toMatchObject({ ok: true }) await rig.settleAccepted(working, 'stopped') + await stoppedTurnEnded() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) // Orchestration mail starts a turn the host sent, which lifts nothing. await rig.send('mail for the lead', undefined, { internal: true }).result @@ -244,6 +285,7 @@ describe("a person's Stop pause and the Stop events after it", () => { const held = await queuedDraft('queued behind the turn') expect(await rig.stop()).toMatchObject({ ok: true }) await rig.settleAccepted(working, 'stopped') + await stoppedTurnEnded() // The agent at rest goes, writing nothing; mail then starts a new child, which never lands. await idleSweep().tick() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) From 69b4dec08aa64dd117e75f97cdae457cdddd383c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:09:35 -0700 Subject: [PATCH 45/58] fix(native-chat): an older Claude's error on a later turn keeps its error text after a Stop The translator left an error result that names no reason to the journal's Stop rule whenever a person's Stop named the turn or none, but the rule binds a Stop naming no turn only to the turn opened next. So a real error on a later turn read "Failed" with its error text dropped. The translator now asks the journal's rule itself (`personStopDecidesTurn`, the one core `turnEndAfterStop` and a host stop's in-force check share), so the two cannot disagree. --- src/main/claude/claude-result-journaling.ts | 8 +- src/main/claude/claude-result-outcome.ts | 6 +- .../claude-structured-in-turn-stop.test.ts | 12 ++- src/main/claude/claude-turn-outcome.test.ts | 19 ++++- .../journal-stop-marks.ts | 8 +- .../journal-stop-turn-end.ts | 85 +++++++++++-------- ...agent-session-claude-stop-turn-end.test.ts | 42 +++++++++ ...ructured-agent-session-event-sink-queue.ts | 5 +- .../structured-agent-session-event-sink.ts | 8 +- .../structured-agent-session-host-lifetime.ts | 2 +- 10 files changed, 133 insertions(+), 62 deletions(-) diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts index 4ea153b82754..1473886b9eac 100644 --- a/src/main/claude/claude-result-journaling.ts +++ b/src/main/claude/claude-result-journaling.ts @@ -15,7 +15,6 @@ import { } from './claude-structured-provider-fallback' import { claudeTurnEndForResult } from './claude-turn-lifecycle-item' import { claudeFrameParentRef, isRootClaudeFrame } from './claude-turn-opening' -import { personStopMayNameTurn } from '../native-chat/agent-session-journal/journal-stop-turn-end' export type ClaudeResultJournalContext = Pick< ClaudeMessageJournalContext, @@ -57,11 +56,10 @@ export function journalClaudeResult( } // Read before the settle below closes it: the result reports that turn's end. const endedTurnScope = turn.turnScope - // Read before the settle below closes the turn: an error end a person's Stop may have caused is - // the journal's to decide as it writes the end (`turnEndAfterStop`). + // 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 && personStopMayNameTurn(sink.journalLatestStop?.() ?? null, turnId) + const leftToStop = turnId !== null && sink.journalStopDecidesTurn?.(turnId, observedAt) === true if (settlesTurn) { prompts.retryPendingCancellations() turn.suppressReopenOnFailure(message.is_error === true) diff --git a/src/main/claude/claude-result-outcome.ts b/src/main/claude/claude-result-outcome.ts index e7cc73737686..de3cadad3387 100644 --- a/src/main/claude/claude-result-outcome.ts +++ b/src/main/claude/claude-result-outcome.ts @@ -13,9 +13,9 @@ import { claudeText } from './claude-structured-item-translation' const CLAUDE_ABORTED_TERMINAL_REASONS = new Set(['aborted_streaming', 'aborted_tools']) /** A success-subtype result still carries `is_error` for an API error, so the flag - * is what decides, never the subtype. `leftToStop`: a person's Stop may name this turn - * (`personStopMayNameTurn`), so an error end with no abort reason gives no verdict (undefined): - * older CLIs name no reason, and the journal's Stop rule decides as the end is written. */ + * is what decides, never the subtype. `leftToStop`: the journal's Stop rule makes this turn's end + * a person's cancellation (`personStopDecidesTurn`), so an error end with no abort reason gives no + * verdict (undefined): older CLIs name no reason, and that rule writes it with the end. */ export function claudeResultOutcome( message: Record, leftToStop = false diff --git a/src/main/claude/claude-structured-in-turn-stop.test.ts b/src/main/claude/claude-structured-in-turn-stop.test.ts index abadd8c3043c..93e8e9ee6d6a 100644 --- a/src/main/claude/claude-structured-in-turn-stop.test.ts +++ b/src/main/claude/claude-structured-in-turn-stop.test.ts @@ -8,7 +8,8 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' -import type { JournalLatestStop } from '../native-chat/agent-session-journal/journal-stop-turn-end' +import { personStopDecidesTurn } from '../native-chat/agent-session-journal/journal-stop-turn-end' +import { createJournalReducerState } from '../native-chat/agent-session-journal/journal-reducer' import { ClaudeControlRequestError } from './claude-stream-json-connection' import { PROVIDER_SESSION_ID, @@ -36,7 +37,7 @@ async function runningChat(claude: ReturnType): Promise<{ stopEvent: (turnId: string) => void }> { const bodies = new Map() - let latestStop: JournalLatestStop | null = null + const journal = createJournalReducerState('session-1', 'epoch-1') const adapter = adapterFor(claude) await adapter.acquire({ identity: identityFor(), @@ -46,7 +47,7 @@ async function runningChat(claude: ReturnType): Promise<{ appendItem: (identity, body) => bodies.set(agentJournalItemKey(identity), body), appendTombstone: (identity) => bodies.delete(agentJournalItemKey(identity)), publish: vi.fn(), - journalLatestStop: () => latestStop + journalStopDecidesTurn: (turnId, endedAt) => personStopDecidesTurn(journal, turnId, endedAt) } }) await adapter.dispatch({ @@ -65,7 +66,10 @@ async function runningChat(claude: ReturnType): Promise<{ throw new Error('expected a running turn') } const stopEvent = (stoppedTurnId: string) => { - latestStop = { sequence: 9, event: { reason: 'user-stop', turnId: stoppedTurnId, at: 1 } } + journal.queuePauseMarks.latestStop = { + sequence: 9, + event: { reason: 'user-stop', turnId: stoppedTurnId, at: 1 } + } } return { adapter, bodies, connection, turnId, stopEvent } } diff --git a/src/main/claude/claude-turn-outcome.test.ts b/src/main/claude/claude-turn-outcome.test.ts index ff18ac3a5571..d5bc5366606c 100644 --- a/src/main/claude/claude-turn-outcome.test.ts +++ b/src/main/claude/claude-turn-outcome.test.ts @@ -13,7 +13,11 @@ import { describeNativeChatTurnStatus } from '../../shared/native-chat-turn-stat import { selectStructuredAgentSettledTurns } from '../../shared/structured-agent-session-turn-timing' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { JournalStopEvent } from '../native-chat/agent-session-journal/journal-row-schema' -import type { JournalLatestStop } from '../native-chat/agent-session-journal/journal-stop-turn-end' +import { + personStopDecidesTurn, + type JournalLatestStop +} from '../native-chat/agent-session-journal/journal-stop-turn-end' +import { createJournalReducerState } from '../native-chat/agent-session-journal/journal-reducer' import { claudeResultOutcome } from './claude-result-outcome' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { claudeResultFailure } from './claude-structured-provider-fallback' @@ -239,10 +243,19 @@ describe("a person's Stop inside a live turn", () => { // Claude CLIs before 2.1.91 send no terminal_reason, and later ones may omit it. const cutShort = { type: 'result', subtype: 'error_during_execution', is_error: true } - /** A sink whose journal's latest Stop event is `stop`, as the host's would answer. */ + /** A sink whose journal's latest Stop event is `stop`, answering by the journal's own rule. */ function sinkWithStop(stop: JournalLatestStop | null) { const state = sinkState() - return { ...state, sink: { ...state.sink, journalLatestStop: () => stop } } + const journal = createJournalReducerState('orca-session', 'epoch-1') + journal.queuePauseMarks.latestStop = stop + return { + ...state, + sink: { + ...state.sink, + journalStopDecidesTurn: (turnId: string, endedAt: number) => + personStopDecidesTurn(journal, turnId, endedAt) + } + } } function stopOf( 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 27cfa28e536d..0094c30e07c8 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 @@ -2,7 +2,7 @@ // the turn-end rule reads (`journal-stop-turn-end.ts`), and whether a person's still decides. import type { JournalReducerState } from './journal-reducer' -import { personStopInForce, type JournalLatestStop } from './journal-stop-turn-end' +import { personStopDecidesTurn, type JournalLatestStop } from './journal-stop-turn-end' export class JournalStopMarks { constructor(private readonly deps: { state: () => JournalReducerState }) {} @@ -11,8 +11,8 @@ export class JournalStopMarks { return this.deps.state().queuePauseMarks.latestStop } - /** `personStopInForce`: a person's Stop already decides what runs now. */ - personStopInForce(turnId: string | null): boolean { - return personStopInForce(this.deps.state(), turnId) + /** `personStopDecidesTurn`: a person's Stop decides how turn `turnId` ends. */ + personStopDecides(turnId: string | null, endedAt?: number): boolean { + return personStopDecidesTurn(this.deps.state(), turnId, endedAt) } } 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 9fed007679f6..b0975d0d6073 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 @@ -31,28 +31,20 @@ type TurnEndState = Pick< 'items' | 'queuePauseMarks' | 'latestPersonTurnSequence' > -/** Whether the latest Stop is a person's that names turn `turnId` or names none: an end that gives - * no verdict of its own is then theirs to decide, here, where its row is built. */ -export function personStopMayNameTurn(stop: JournalLatestStop | null, turnId: string): boolean { - return ( - stop !== null && - stopIsAPersons(stop.event.reason) && - (stop.event.turnId === undefined || stop.event.turnId === turnId) - ) -} - -/** Whether `stop` makes the end of turn `turnId` a person's cancellation: a person's Stop or close - * that named that turn, or named none and stopped the turn item `itemId` opened. */ +/** Whether `stop`, a person's, makes the end of turn `turnId` theirs: it named that turn, or named + * none and stopped the turn item `itemId` opened. */ function stopIsTurnCancellation( stop: JournalLatestStop, turnId: string, state: TurnEndState, itemId: string | null ): boolean { - return ( - personStopMayNameTurn(stop, turnId) && - (stop.event.turnId !== undefined || turnlessStopStopped(state, stop, itemId)) - ) + if (!stopIsAPersons(stop.event.reason)) { + return false + } + return stop.event.turnId !== undefined + ? stop.event.turnId === turnId + : turnlessStopStopped(state, stop, itemId) } /** Pressed before any turn showed, a Stop stopped the first turn opened after it, and no later @@ -83,20 +75,47 @@ function turnlessStopStopped( return true } -/** Whether a person's Stop already decides the end of what runs now: the live turn `turnId`, or - * with none, the turn a send opens next. A host stop of that work must not supersede it. */ -export function personStopInForce(state: TurnEndState, turnId: string | null): boolean { +/** THE rule: whether the latest Stop makes turn `turnId` (item `itemId`, null if not yet opened), + * 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, + itemId: string | null, + endedAt: number | undefined +): boolean { const stop = state.queuePauseMarks.latestStop - if (!stop || !stopIsAPersons(stop.event.reason)) { - return false - } - if (turnId === null) { - return stop.event.turnId === undefined && turnlessStopStopped(state, stop, null) + return ( + stop !== null && + stopIsTurnCancellation(stop, turnId, state, itemId) && + (endedAt === undefined || endedAt >= stop.event.at) + ) +} + +/** + * Whether a person's Stop decides the end of turn `turnId` (null: the turn a send opens next), + * by `turnEndAfterStop`'s rule: ending at `endedAt` it is their cancellation, and still running it + * is theirs to end. For a writer that must choose before the end is written: a host stop must not + * supersede it, and a Claude error result naming no reason leaves its verdict to it. + */ +export function personStopDecidesTurn( + state: TurnEndState, + turnId: string | null, + endedAt?: number +): boolean { + if (turnId !== null) { + const itemId = [...state.items].find( + ([, item]) => readAgentJournalTurn(item.body)?.turnId === turnId + )?.[0] + return stopEndsTurnAsCancellation(state, turnId, itemId ?? null, endedAt) } - const itemId = [...state.items].find( - ([, item]) => readAgentJournalTurn(item.body)?.turnId === turnId - )?.[0] - return stopIsTurnCancellation(stop, turnId, state, itemId ?? null) + const stop = state.queuePauseMarks.latestStop + return ( + stop !== null && + stop.event.turnId === undefined && + stopIsAPersons(stop.event.reason) && + turnlessStopStopped(state, stop, null) + ) } /** @@ -117,11 +136,7 @@ export function turnEndAfterStop( if (previous && previous.state !== 'running' && previous.state !== 'unverifiable') { return body } - const stop = state.queuePauseMarks.latestStop - if (!stop || !stopIsTurnCancellation(stop, body.turnId, state, itemId)) { - return body - } - // An exit the provider saw before the Stop was news, whenever its end is written. - const endedAfterStop = body.completedAt === undefined || body.completedAt >= stop.event.at - return endedAfterStop ? { ...body, outcome: 'cancellation' } : body + return stopEndsTurnAsCancellation(state, body.turnId, itemId, body.completedAt) + ? { ...body, outcome: 'cancellation' } + : body } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts index a9cfcaea9fc5..1c46f0ffc70c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts @@ -198,3 +198,45 @@ it('reads a follow-up the child end cut as interrupted when the Stop named the t expect(cut?.turnId).not.toBe(ended) expect(cut).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) }, 15_000) + +// The Stop bound only the turn it stopped: a later turn's error end is the provider's own. +it("keeps an older CLI's error end on a later turn a failure, with its error text, after a turnless Stop", async () => { + const stopped = claude.connections[0]! + claude.routes.interrupt = () => { + setTimeout(() => { + echoLatest(stopped) + frame(stopped, { type: 'result', subtype: 'error_during_execution', is_error: true }) + frame(stopped, { type: 'system', subtype: 'session_state_changed', state: 'idle' }) + }, 5) + return { still_queued: [], cancelled: [] } + } + await sendUnechoed(stopped, 'Write a long reply.') + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + expect(await lastTurn()).toMatchObject({ outcome: 'cancellation' }) + + // The next send starts a new child on the same conversation. + const body = hostTestMessage('Carry on.') + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + const resumed = await eventually(() => { + const started = claude.connections.at(-1)! + expect(started).not.toBe(stopped) + expect(started.sent.some((message) => JSON.stringify(message).includes('Carry on.'))).toBe(true) + return started + }) + echoLatest(resumed) + frame(resumed, { + type: 'result', + subtype: 'error_during_execution', + is_error: true, + result: 'API Error: overloaded' + }) + + await eventually(async () => + expect(await lastTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) + ) + const { items } = await host.journalSnapshot(SESSION) + expect(JSON.stringify(items.map((item) => item.body))).toContain('API Error: overloaded') +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index f406cf9ecc89..4f2491f7b617 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 @@ -8,7 +8,6 @@ import type { StructuredAgentSessionSinkState, StructuredAgentSessionSinkWatermarks } from './structured-agent-session-event-sink' -import type { JournalLatestStop } from '../agent-session-journal/journal-stop-turn-end' export type StructuredAgentSessionSinkOperation = { sequence: number @@ -68,8 +67,8 @@ export class StructuredAgentSessionSinkQueue { journalLinkage = (): StructuredAgentSessionLinkageJournal | null => this.target?.journal ?? null - journalLatestStop = (): JournalLatestStop | null => - this.target?.journal.stopMarks.latest() ?? null + 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 400c86c885a0..cd15160df88d 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 @@ -8,7 +8,6 @@ import type { import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' -import type { JournalLatestStop } from '../agent-session-journal/journal-stop-turn-end' import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate' import { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue' import { structuredAgentSessionJournalAppendOptions } from './structured-agent-session-journal-append-options' @@ -136,8 +135,9 @@ export type StructuredAgentSessionEventSink = { journalEpoch?(): string | null /** The bound journal's producer linkage; null until bound. */ journalLinkage?(): StructuredAgentSessionLinkageJournal | null - /** The bound journal's latest Stop event (`JournalStopMarks.latest`); null until bound. */ - journalLatestStop?(): JournalLatestStop | 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. */ + journalStopDecidesTurn?(turnId: string, endedAt: number): boolean appendLifecycleBatch?( settlementId: string, mutations: readonly JournalLifecycleMutationInput[], @@ -280,7 +280,7 @@ export function createDeferredStructuredAgentSessionEventSink( ...resolvedAppend, journalEpoch: queue.journalEpoch, journalLinkage: queue.journalLinkage, - journalLatestStop: queue.journalLatestStop, + journalStopDecidesTurn: queue.journalStopDecidesTurn, appendLifecycleBatch: (settlementId, mutations, options = {}) => { const admission = appendLifecycleBatch(settlementId, mutations, options) if (!admission.accepted) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index d3e5c728feb4..8145be82eebd 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 @@ -131,7 +131,7 @@ async function stopEndsWork( // A host stop of work a person's Stop is already ending must not supersede that Stop's reason. return ( working && - (ending.cause === 'user-close' || !journal.stopMarks.personStopInForce(journal.activeTurnId())) + (ending.cause === 'user-close' || !journal.stopMarks.personStopDecides(journal.activeTurnId())) ) } From 5ead1f6bccd9875104a223282050476cff9f5a97 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:47:03 -0700 Subject: [PATCH 46/58] fix(native-chat): a Stop of a start that never landed binds no later turn, whatever sent it A person's Stop pressed while the agent starts names no turn, and the send it stopped is cancelled before it opens one. The Stop then bound the next turn anything opened (orchestration mail, a restart continuation, the queue's drain, all of which send as the host), so a host eviction of that turn wrote nothing and its crash or close read as the person's cancellation. A Stop that named no turn now binds only a turn no send journaled after it opened: any send since, of any origin and not refused, opens its own. The E2 test's mail send is accepted as Codex accepts it, instead of opening the stopped send's own turn first. --- .../journal-stop-turn-end.ts | 45 +++--- ...d-agent-session-stop-event-binding.test.ts | 128 ++++++++++++++++++ ...d-agent-session-stop-event-entries.test.ts | 28 +--- 3 files changed, 154 insertions(+), 47 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts 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 b0975d0d6073..821ceb781382 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 @@ -26,10 +26,7 @@ function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { } } -type TurnEndState = Pick< - JournalReducerState, - 'items' | 'queuePauseMarks' | 'latestPersonTurnSequence' -> +type TurnEndState = Pick /** Whether `stop`, a person's, makes the end of turn `turnId` theirs: it named that turn, or named * none and stopped the turn item `itemId` opened. */ @@ -48,20 +45,26 @@ function stopIsTurnCancellation( } /** Pressed before any turn showed, a Stop stopped the first turn opened after it, and no later - * one: unless a send a person made since was accepted, whose turn that is. `itemId` null: a turn - * not yet opened. */ + * one: unless a send journaled since, of any origin, was not refused, whose turn that is. A Stop + * whose stopped send never opens a turn so binds nothing. `itemId` null: a turn not yet opened. */ function turnlessStopStopped( state: TurnEndState, stop: JournalLatestStop, itemId: string | null ): boolean { const createdAt = itemId === null ? null : (state.items.get(itemId)?.sequence ?? null) - if ( - (createdAt !== null && createdAt <= stop.sequence) || - state.latestPersonTurnSequence >= stop.sequence - ) { + if (createdAt !== null && createdAt <= stop.sequence) { return false } + for (const submission of state.submissions.values()) { + if ( + submission.dispatchState !== 'rejected' && + submission.acceptedSequence !== undefined && + submission.acceptedSequence > stop.sequence + ) { + return false + } + } for (const [otherId, item] of state.items) { if ( otherId !== itemId && @@ -103,19 +106,17 @@ export function personStopDecidesTurn( turnId: string | null, endedAt?: number ): boolean { - if (turnId !== null) { - const itemId = [...state.items].find( - ([, item]) => readAgentJournalTurn(item.body)?.turnId === turnId - )?.[0] - return stopEndsTurnAsCancellation(state, turnId, itemId ?? null, endedAt) - } const stop = state.queuePauseMarks.latestStop - return ( - stop !== null && - stop.event.turnId === undefined && - stopIsAPersons(stop.event.reason) && - turnlessStopStopped(state, stop, null) - ) + if (stop === null || !stopIsAPersons(stop.event.reason)) { + return false + } + if (turnId === null) { + return stop.event.turnId === undefined && turnlessStopStopped(state, stop, null) + } + const itemId = [...state.items].find( + ([, item]) => readAgentJournalTurn(item.body)?.turnId === turnId + )?.[0] + return stopEndsTurnAsCancellation(state, turnId, itemId ?? null, endedAt) } /** diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts new file mode 100644 index 000000000000..c7cef759d542 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -0,0 +1,128 @@ +// Which turn a person's Stop that named no turn binds: only the one its stopped send opens. A Stop of +// a start that never landed stopped a send that opens no turn, and a send journaled after the Stop +// opens its own; neither is the Stop's, whatever sent it. + +import { afterEach, describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' +import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' +import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +afterEach(() => rig.dispose()) + +const MAIL_TURN: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-mail', + ordinal: 999 +} + +function journal() { + const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!open) { + throw new Error('expected the conversation open') + } + return open +} + +function stopEvents(): JournalStopEvent[] { + const since = journal().readSince({ epoch: journal().epoch, sequence: 0 }) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + return since.rows.flatMap((row) => + row.kind === 'tombstone' && row.stopEvent ? [row.stopEvent] : [] + ) +} + +function childPhase() { + return rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase +} + +function fence(): number { + return rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1 +} + +function mailTurn() { + return journal() + .snapshot() + .items.map((item) => readAgentJournalTurn(item.body)) + .find((turn) => turn?.turnId === 'turn-mail') +} + +/** A person's Stop of a start that never landed, whose send opens no turn; then orchestration mail + * starts a new child, which lands and runs the mail's turn. */ +async function mailTurnAfterStopOfStart(): Promise { + rig = await createQueuedMessageTestRig({ starting: true, restartable: true }) + let release: () => void = () => undefined + rig.awaitStarted.mockImplementation( + () => new Promise((resolve) => (release = () => resolve(undefined))) + ) + rig.send('work on this') + await eventually(() => expect(childPhase()).toBe('starting')) + expect(await rig.stop()).toMatchObject({ ok: true }) + release() + expect(stopEvents()).toEqual([expect.objectContaining({ reason: 'user-stop' })]) + expect(stopEvents()[0]).not.toHaveProperty('turnId') + await eventually(() => expect(childPhase()).toBeUndefined()) + rig.awaitStarted.mockImplementation(async () => undefined) + await rig.send('mail for the worker', undefined, { internal: true }).result + await eventually(() => expect(rig.dispatch).toHaveBeenCalled()) + await journal().appendItem( + MAIL_TURN, + { kind: 'turn', turnId: 'turn-mail', state: 'running', startedAt: Date.now() }, + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +describe('a Stop of a start that never landed binds no later turn', () => { + it("writes the host's event when it evicts the mail turn, which reads as news", async () => { + await mailTurnAfterStopOfStart() + let atClose: JournalStopEvent[] = [] + rig.closeSession.mockImplementationOnce(async () => { + atClose = stopEvents() + return true + }) + + await rig.host.close(HOST_TEST_SESSION, 'evict') + + expect(atClose.map((event) => event.reason)).toEqual(['user-stop', 'evict']) + await rig.host.journalSnapshot(HOST_TEST_SESSION) + expect(mailTurn()).toMatchObject({ state: 'interrupted' }) + expect(mailTurn()).not.toHaveProperty('outcome') + }) + + it('settles a crash of the mail turn on relaunch as news', async () => { + await mailTurnAfterStopOfStart() + const owner = fence() + rig.crashRestartHostProcess() + await rig.host.journalSnapshot(HOST_TEST_SESSION) + + await settleStaleStructuredAgentSessionState({ + journal: journal(), + sessionId: HOST_TEST_SESSION, + fence: owner + 1, + acquisitionGeneration: 'generation-2', + deathEvidence: { + kind: 'exit-observed', + detail: 'the relaunch proved the old child gone', + observedAt: Date.now() + 60_000, + ownerFence: owner + } + }) + + expect(mailTurn()).toMatchObject({ state: 'interrupted' }) + expect(mailTurn()).not.toHaveProperty('outcome') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index 26f191a7f90c..bccc020f7cbe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -60,28 +60,6 @@ async function runningTurn(turnId = 'turn-1'): Promise { return working } -/** The stopped send's turn opens after its turnless Stop and ends cut: the turn that Stop bound. */ -async function stoppedTurnEnded(): Promise { - const fence = rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1 - const scope = { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - const identity = { - provider: 'codex' as const, - threadId: 'thread-1', - turnId: 'turn-stopped', - ordinal: 998 - } - await journal().appendItem( - identity, - { kind: 'turn', turnId: 'turn-stopped', state: 'running', startedAt: 1 }, - scope - ) - await journal().appendItem( - identity, - { kind: 'turn', turnId: 'turn-stopped', state: 'interrupted', completedAt: Date.now() }, - scope - ) -} - async function queuedDraft(text: string): Promise { const queued = await rig.send(text, 'queue-if-active').result if (!queued.ok || !('queued' in queued.value)) { @@ -257,10 +235,11 @@ describe("a person's Stop pause and the Stop events after it", () => { await queuedDraft('queued behind the turn') expect(await rig.stop()).toMatchObject({ ok: true }) await rig.settleAccepted(working, 'stopped') - await stoppedTurnEnded() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) // Orchestration mail starts a turn the host sent, which lifts nothing. - await rig.send('mail for the lead', undefined, { internal: true }).result + const mail = rig.send('mail for the lead', undefined, { internal: true }) + await mail.result + await rig.settleAccepted(mail.id, 'mail') await journal().appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-mail', ordinal: 999 }, { kind: 'turn', turnId: 'turn-mail', state: 'running', startedAt: 1 }, @@ -285,7 +264,6 @@ describe("a person's Stop pause and the Stop events after it", () => { const held = await queuedDraft('queued behind the turn') expect(await rig.stop()).toMatchObject({ ok: true }) await rig.settleAccepted(working, 'stopped') - await stoppedTurnEnded() // The agent at rest goes, writing nothing; mail then starts a new child, which never lands. await idleSweep().tick() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) From aa7f3a7b9d3e7874f50e93b53c60cd6e699310f9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:47:16 -0700 Subject: [PATCH 47/58] test(native-chat): a rewind's restated turnless Stop binds no turn opened after the rewind A Codex rewind restates a person's Stop still in force after the turns it keeps, at a new sequence, so by sequence alone it would bind the next turn opened after the rewind. A send journaled after the restated row voids that binding (the previous commit), which this pins. --- ...d-agent-session-stop-event-binding.test.ts | 44 ++++++++++++++++++- 1 file changed, 43 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 c7cef759d542..2962041b8be9 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,6 +1,6 @@ // Which turn a person's Stop that named no turn binds: only the one its stopped send opens. A Stop of // a start that never landed stopped a send that opens no turn, and a send journaled after the Stop -// opens its own; neither is the Stop's, whatever sent it. +// opens its own; neither is the Stop's, whatever sent it, through a rewind too. import { afterEach, describe, expect, it } from 'vitest' import { @@ -126,3 +126,45 @@ describe('a Stop of a start that never landed binds no later turn', () => { expect(mailTurn()).not.toHaveProperty('outcome') }) }) + +describe('a rewind that restates a turnless Stop', () => { + // The rewind writes the Stop still in force after the turns it keeps, so it follows the turn it + // already bound; the mail after the rewind is still its own. + it('binds no turn opened after the rewind', async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + await rig.settleAccepted(stopped, 'stopped') + const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + const stoppedTurn = { ...MAIL_TURN, turnId: 'turn-stopped', ordinal: 998 } + const ended = { kind: 'turn' as const, turnId: 'turn-stopped', state: 'interrupted' as const } + await journal().appendItem( + stoppedTurn, + { kind: 'turn', turnId: 'turn-stopped', state: 'running', startedAt: Date.now() }, + scope + ) + await journal().appendItem(stoppedTurn, { ...ended, completedAt: Date.now() + 1 }, scope) + + await journal().replaceEpochItems('handle_forked', 1, [ + { + identity: stoppedTurn, + body: { ...ended, completedAt: Date.now() + 1, outcome: 'cancellation' } + } + ]) + await rig.send('mail after the rewind', undefined, { internal: true }).result + await journal().appendItem( + MAIL_TURN, + { kind: 'turn', turnId: 'turn-mail', state: 'running', startedAt: Date.now() }, + scope + ) + await journal().appendItem( + MAIL_TURN, + { kind: 'turn', turnId: 'turn-mail', state: 'interrupted', completedAt: Date.now() + 5 }, + scope + ) + + expect(mailTurn()).toMatchObject({ state: 'interrupted' }) + expect(mailTurn()).not.toHaveProperty('outcome') + }) +}) From a5960f76c27be348d7088d4314c6903e3b2f317b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:49:07 -0700 Subject: [PATCH 48/58] fix(native-chat): a relaunch settles a person's stopped turn with no "stopped while in progress" row After a restart, a turn a person's Stop ended reads "Interrupted after N" with the muted mark, but the relaunch still added the error row saying the provider stopped mid-response, which a live Stop never writes. The settle now skips that row when every turn it interrupts is the person's Stop's by the journal's one rule; a crash nobody stopped keeps it. --- ...gent-session-dead-generation-settlement.ts | 9 +++++--- ...ctured-agent-session-stale-turn-verdict.ts | 16 ++++++++++++++ ...d-agent-session-stop-event-restart.test.ts | 21 ++++++++++++++----- 3 files changed, 38 insertions(+), 8 deletions(-) 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 6bf0c7346e2f..3e2831c1109e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -26,6 +26,7 @@ import { } from './structured-agent-session-start-failure-row' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' import { + endedByPersonsStop, provenUnverifiableTurnRevisions, runningTurnLifecycleRevisions, stopFoundTurnLiveAt, @@ -239,15 +240,17 @@ export async function settleStaleStructuredAgentSessionState(input: { } } const proven = provenUnverifiableTurnRevisions(items, input.deathEvidence, journal) - mutations.push( + const turnEnds = [ ...items.flatMap((item) => runningTurnLifecycleRevisions([item], verdictFor(item))), ...proven - ) + ] + mutations.push(...turnEnds) const evidence = input.deathEvidence if ( evidence && (proven.length > 0 || - items.some((item) => isInProgressItem(item) && verdictFor(item).state === 'interrupted')) + items.some((item) => isInProgressItem(item) && verdictFor(item).state === 'interrupted')) && + !endedByPersonsStop(journal, turnEnds) ) { mutations.unshift({ kind: 'item', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index 9002cff14a72..d7cd404e468c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -67,6 +67,22 @@ export function stopFoundTurnLiveAt( return stop && turnId !== undefined && stop.event.turnId === turnId ? stop.event.at : undefined } +/** Every turn this settle interrupts is a person's Stop's to end (`turnEndAfterStop`), so it reads + * as theirs, muted, with no row saying the provider stopped: as a live Stop writes none. */ +export function endedByPersonsStop( + journal: Pick, + turnEnds: readonly JournalLifecycleMutationInput[] +): boolean { + const interrupted = turnEnds.flatMap((mutation) => { + const turn = mutation.kind === 'item' ? readAgentJournalTurn(mutation.body) : undefined + return turn?.state === 'interrupted' ? [turn] : [] + }) + return ( + interrupted.length > 0 && + interrupted.every((turn) => journal.stopMarks.personStopDecides(turn.turnId, turn.completedAt)) + ) +} + /** Revises every still-running lifecycle item in place, keeping its identity and start. */ export function runningTurnLifecycleRevisions( items: readonly AgentJournalRenderItem[], 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 1d1554d9174b..85ec7a619995 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 @@ -95,7 +95,8 @@ async function restartAndSettle( }) } -/** What the chat's turn bar and the session's mark read, for `turnId` or else the first turn. */ +/** What the chat's turn bar and the session's mark read, for `turnId` or else the first turn, and + * the error rows beside it. */ function settled(turnId?: string) { const { items } = journal().snapshot() const turn = items @@ -110,7 +111,10 @@ function settled(turnId?: string) { label: timing ? formatNativeChatTurnStatusLabel({ elapsedSeconds: 0, ...timing }) : null, mark: verdict ? agentVerdictDisplayMark({ state: 'done', mainAgent: { state: 'done', outcome: verdict } }) - : null + : null, + errorRows: items.flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] + ) } } @@ -125,10 +129,12 @@ describe('a restart between a Stop and its turn end', () => { await restartAndSettle() - const { turn, label, mark } = settled() + const { turn, label, mark, errorRows } = settled() expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) expect(label).toMatch(/^Interrupted after /) expect(mark).toBe('interrupted') + // A live Stop writes no row saying the provider stopped; nor does its relaunch. + expect(errorRows).toEqual([]) }) it('reads Interrupted after N, marked interrupted: a close of the chat that died midway', async () => { @@ -143,10 +149,12 @@ describe('a restart between a Stop and its turn end', () => { await restartAndSettle() - const { turn, label, mark } = settled() + const { turn, label, mark, errorRows } = settled() expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) expect(label).toMatch(/^Interrupted after /) expect(mark).toBe('interrupted') + // A live Stop writes no row saying the provider stopped; nor does its relaunch. + expect(errorRows).toEqual([]) }) it('reads Failed after N, marked failed, when nobody stopped it', async () => { @@ -154,11 +162,14 @@ describe('a restart between a Stop and its turn end', () => { await restartAndSettle() - const { turn, label, mark } = settled() + const { turn, label, mark, errorRows } = settled() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') expect(label).toMatch(/^Failed after /) expect(mark).toBe('failed') + expect(errorRows).toEqual([ + expect.stringContaining('stopped while this response was in progress') + ]) }) it("reads Couldn't confirm when the relaunch cannot prove the child gone, Stop or not", async () => { From 78369ee9ca61ef56faafd17f111d849ef350affd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:50:57 -0700 Subject: [PATCH 49/58] test(native-chat): the unexpected-exit settle's journal fake answers whether a person's Stop decides a turn --- .../structured-agent-session-unexpected-exit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index f8a17510f430..db5915e585cd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -98,7 +98,7 @@ describe('provider-exit settlement', () => { items: [lifecycleItem('turn-1', 1, { state: 'running', startedAt: 1_000 })] }), itemFence: () => 7, - stopMarks: { latest: () => null }, + stopMarks: { latest: () => null, personStopDecides: () => false }, appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []) } From e9c8af84024a1f157cf11dd55a4f650c996056b7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:57:56 -0700 Subject: [PATCH 50/58] fix(native-chat): a host stop whose sink drain fails reads the journal as it stands A host stop drains the session's sink before judging whether it ends work, and a failed or slow drain read as working. So an eviction of an agent at rest wrote a Stop event that ended nothing, which lifts a person's Stop pause, and a close wrote a person's event naming no turn. The drain is now best effort: the stop goes ahead either way and only its record is at stake, so a failed or slow drain leaves the journal's read as it stands. A person's Stop keeps its own rule. --- .../structured-agent-session-host-lifetime.ts | 27 +++++++++++-------- ...d-agent-session-stop-event-entries.test.ts | 26 +++++++++++++++++- .../structured-agent-session-turns-cancel.ts | 15 ++++------- 3 files changed, 46 insertions(+), 22 deletions(-) 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 8145be82eebd..054e51d4974a 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 @@ -31,7 +31,8 @@ import { import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' -import { isMainAgentWorkingOnceFlushed } from './structured-agent-session-turns-cancel' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { withTimeout } from '../../../shared/promise-timeout-fallback' export type StructuredAgentSessionLifetimeContext = { deps: StructuredAgentSessionHostDeps @@ -106,9 +107,9 @@ const STOP_EVENT_DRAIN_TIMEOUT_MS = 1_000 /** * Whether this stop ends work its event must record: a running turn or an unanswered send, a start's - * own included, read once the sink drained what the provider already said - * (`isMainAgentWorkingOnceFlushed`). A start that carries no send ends nothing. A person's Stop - * wrote its own event, and quit and the idle sweep's rest write none. + * own included, read once the sink drained what the provider already said. A start that carries + * no send ends nothing. A person's Stop wrote its own event, and quit and the idle sweep's rest + * write none. */ async function stopEndsWork( context: StructuredAgentSessionLifetimeContext, @@ -120,13 +121,17 @@ async function stopEndsWork( if ('recorded' in ending || ending.quit || ending.resting || !child) { return false } - const working = await isMainAgentWorkingOnceFlushed( - { - journal, - fence: child.fence, - flushStreamedEvents: () => context.runtimeState.flushEventSink(sessionId) - }, - STOP_EVENT_DRAIN_TIMEOUT_MS + // Best effort: the stop goes ahead either way, so a failed or slow drain leaves the journal's + // read as it stands rather than calling the agent working. + await withTimeout( + context.runtimeState.flushEventSink(sessionId).catch(() => undefined), + STOP_EVENT_DRAIN_TIMEOUT_MS, + undefined + ) + const working = isStructuredAgentSessionMainAgentWorking( + journal.activeTurnId(), + journal.submissions(), + child.fence ) // A host stop of work a person's Stop is already ending must not supersede that Stop's reason. return ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index bccc020f7cbe..1e2e707ac1cb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -3,7 +3,7 @@ // nothing, quit writes nothing (its resume marker records why), and any later Stop event ends a // person's Stop pause. -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' @@ -159,6 +159,30 @@ describe('every Stop entry writes its event, with its reason, before it ends the }) }) + // The drain is best effort: when it fails, the journal as it stands says the agent rests. + it('writes nothing when the drain fails as it evicts a chat at rest', async () => { + rig = await createQueuedMessageTestRig() + const working = await runningTurn() + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(working, 'stopped') + await journal().appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 999 }, + { kind: 'turn', turnId: 'turn-1', state: 'interrupted', completedAt: Date.now() }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + expect(journal().activeTurnId()).toBeNull() + const sink = rig.host['runtimeState'].eventSinkFor(HOST_TEST_SESSION) + const drained = sink.drained.bind(sink) + vi.spyOn(sink, 'drained') + .mockResolvedValueOnce({ ok: false, error: new Error('drain lost once') }) + .mockImplementation(drained) + const atClose = stopEventsAtClose() + + await rig.host.close(HOST_TEST_SESSION, 'evict') + + expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) + }) + it("writes nothing at quit, whose resume marker's trigger records why", async () => { rig = await createQueuedMessageTestRig() await runningTurn() 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 b90b4ad8645e..cdb70ff41d45 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 @@ -13,21 +13,16 @@ import { } from './structured-agent-session-command-turn' import { validatePendingPrompt } from './structured-agent-session-prompt-state' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' -import { withTimeout } from '../../../shared/promise-timeout-fallback' 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. - * A failed drain, or one still running after `timeoutMs`, reads working: bookkeeping never talks a - * Stop out of stopping. */ + * A failed drain reads working: bookkeeping never talks a Stop out of stopping. */ export async function isMainAgentWorkingOnceFlushed( - ctx: Pick, - timeoutMs?: number + ctx: Pick ): Promise { - const flushed = ctx.flushStreamedEvents().then( - () => true, - () => false - ) - if (!(await (timeoutMs === undefined ? flushed : withTimeout(flushed, timeoutMs, false)))) { + try { + await ctx.flushStreamedEvents() + } catch { return true } return isStructuredAgentSessionMainAgentWorking( From 8550aa1a8daa85acb6b86a1367e11dc26fa7817d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:07:41 -0700 Subject: [PATCH 51/58] fix(native-chat): a Stop that named no turn applies only to a turn a send it stopped opened A person's Stop pressed before any turn showed names no turn. It bound the first turn opened after it, then (5ead1f6bccd) any turn opened by no later send, so a turn the host started for a card the Stop held, or for orchestration mail, read as the person's cancellation, and a host eviction of it wrote no Stop event when its send had been abandoned by the close first. The rule is now the concept itself: a Stop naming no turn applies to a turn opened by a send it stopped, one already handed to the agent at the Stop's position. Nothing new is stored. The turn's row names the send that opened it (Codex: the submission's key; Claude: the echo, which the journal aliases to the submission), and a handed-over send's item sits at its handover, so the target set is derived from the journal. A card the Stop held is handed over after it, so it is no target; a Stop of a start whose send never opens a turn binds nothing; a rewind keeps no submissions, so a restated Stop binds no turn opened after it. With no turn running, a host stop defers to the person's Stop only while every unanswered send is one it stopped. Claude's translator, which asks before its echo row lands, passes the send its echo acknowledged. --- src/main/claude/claude-message-journaling.ts | 5 +- src/main/claude/claude-open-turn.ts | 5 + .../claude/claude-replay-turn-resolution.ts | 20 +- src/main/claude/claude-result-journaling.ts | 4 +- ...aude-structured-dispatch-admission.test.ts | 11 +- .../claude-structured-journal-translation.ts | 9 +- .../claude-structured-session-acquisition.ts | 1 + .../claude/claude-structured-session-state.ts | 2 + src/main/claude/claude-turn-lifecycle-item.ts | 2 + src/main/claude/claude-turn-opening.ts | 5 +- src/main/claude/claude-turn-outcome.test.ts | 30 +-- .../journal-stop-marks.ts | 4 +- .../journal-stop-turn-end.ts | 150 +++++++------ ...ructured-agent-session-event-sink-queue.ts | 4 +- .../structured-agent-session-event-sink.ts | 5 +- ...d-agent-session-stop-event-binding.test.ts | 205 +++++++++++++----- ...d-agent-session-stop-event-entries.test.ts | 4 +- ...d-agent-session-stop-event-restart.test.ts | 23 +- 18 files changed, 329 insertions(+), 160 deletions(-) diff --git a/src/main/claude/claude-message-journaling.ts b/src/main/claude/claude-message-journaling.ts index 9d75db1fca9f..0ade48d58125 100644 --- a/src/main/claude/claude-message-journaling.ts +++ b/src/main/claude/claude-message-journaling.ts @@ -63,7 +63,9 @@ export function journalClaudeMessage( startsTurn: boolean, observedAt: number, /** Host clock on the submission that produced this send, when known. */ - requestedAt?: number + requestedAt?: number, + /** The submission this send echo acknowledged. */ + openedBy?: string ): boolean { const envelope = readClaudeMessageEnvelope(message) if (!envelope) { @@ -176,6 +178,7 @@ export function journalClaudeMessage( startsTurn, observedAt, ...(requestedAt === undefined ? {} : { requestedAt }), + ...(openedBy === undefined ? {} : { openedBy }), userItemId: agentJournalItemKey(identity) }) if (sendEchoTurn) { diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index f946e4ee9cfb..f6edf49d56b2 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -60,6 +60,11 @@ export class ClaudeOpenTurn { } /** The open turn's row, where a fact about the running turn lands. */ + /** The submission that opened the open turn, when known (`ClaudeCurrentTurn.openedBy`). */ + get openedBy(): string | null { + return this.current?.openedBy ?? null + } + get identity(): AgentJournalItemIdentity | null { return this.current ? claudeCurrentTurnIdentity(this.current) : null } diff --git a/src/main/claude/claude-replay-turn-resolution.ts b/src/main/claude/claude-replay-turn-resolution.ts index fe2bb1523f95..c8887b4f3f16 100644 --- a/src/main/claude/claude-replay-turn-resolution.ts +++ b/src/main/claude/claude-replay-turn-resolution.ts @@ -27,7 +27,11 @@ import { claudeDispatchContentKey } from './claude-structured-dispatch-content' /** Settles a provider-proven late outcome; replay rows independently reconcile acceptance. */ export type ClaudeLateDispatchSettlement = (input: ClaudeLateDispatchOutcome) => void -export type ClaudeReplayTurnOrigin = { requestedAt: number | null } +export type ClaudeReplayTurnOrigin = { + requestedAt: number | null + /** The submission this replay acknowledged, which opens the turn; null for a provider-control turn. */ + clientMessageId: string | null +} export function resolveClaudeReplayTurn( session: ClaudeSession, @@ -89,7 +93,9 @@ export function resolveClaudeReplayTurn( if (exact) { const foldReceipt = isUserReplay && claudeReplayIsFoldReceipt(session, exact, uuid) settleWaiter(session, exact, uuid, onSettledLate) - return isUserReplay && !foldReceipt ? { requestedAt: exact.requestedAt } : null + return isUserReplay && !foldReceipt + ? { requestedAt: exact.requestedAt, clientMessageId: exact.clientMessageId } + : null } const retired = session.retiredDispatchWaiters.find( (candidate) => candidate.sentUuid === userMessageUuid @@ -111,7 +117,9 @@ export function resolveClaudeReplayTurn( if (exact) { const foldReceipt = isUserReplay && claudeReplayIsFoldReceipt(session, exact, uuid) settleWaiter(session, exact, uuid, onSettledLate) - return isUserReplay && !foldReceipt ? { requestedAt: exact.requestedAt } : null + return isUserReplay && !foldReceipt + ? { requestedAt: exact.requestedAt, clientMessageId: exact.clientMessageId } + : null } const retired = session.retiredDispatchWaiters.find((candidate) => candidate.sentUuid === uuid) if (retired) { @@ -133,7 +141,7 @@ export function resolveClaudeReplayTurn( if (compatible.length === 1) { const [candidate] = compatible settleWaiter(session, candidate!, uuid, onSettledLate) - return { requestedAt: candidate!.requestedAt } + return { requestedAt: candidate!.requestedAt, clientMessageId: candidate!.clientMessageId } } } else if (!session.replayContentFallbackBlocked && session.dispatchWaiters.length === 0) { const lateCompatible = session.retiredDispatchWaiters.filter( @@ -164,7 +172,9 @@ export function resolveClaudeReplayTurn( const waiter = uuid ? session.dispatchWaiters.shift() : undefined if (waiter && uuid) { settleWaiter(session, waiter, uuid, onSettledLate) - return isUserReplay ? { requestedAt: waiter.requestedAt } : null + return isUserReplay + ? { requestedAt: waiter.requestedAt, clientMessageId: waiter.clientMessageId } + : null } return null } diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts index 1473886b9eac..a4226067b98e 100644 --- a/src/main/claude/claude-result-journaling.ts +++ b/src/main/claude/claude-result-journaling.ts @@ -59,7 +59,9 @@ 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) === true + const leftToStop = + turnId !== null && + sink.journalStopDecidesTurn?.(turnId, observedAt, turn.openedBy ?? undefined) === true if (settlesTurn) { prompts.retryPendingCancellations() turn.suppressReopenOnFailure(message.is_error === true) diff --git a/src/main/claude/claude-structured-dispatch-admission.test.ts b/src/main/claude/claude-structured-dispatch-admission.test.ts index c4578ca38d8d..a7501b39d88f 100644 --- a/src/main/claude/claude-structured-dispatch-admission.test.ts +++ b/src/main/claude/claude-structured-dispatch-admission.test.ts @@ -16,7 +16,7 @@ function resolveClaudeReplayWaiter(...args: Parameters { - it('opens queued exact replays with the origin owned by each send', async () => { + it('opens queued exact replays with the origin and the send owned by each send', async () => { const session = sessionFor() await dispatchClaudeTurn(session, { clientMessageId: 'client-a', @@ -25,7 +25,8 @@ describe('Claude structured dispatch admission', () => { }) const aUuid = session.dispatchWaiters[0]!.sentUuid expect(resolveClaudeReplayTurn(session, userReplayFrame(aUuid, 'a'))).toEqual({ - requestedAt: 100 + requestedAt: 100, + clientMessageId: 'client-a' }) await dispatchClaudeTurn(session, { @@ -41,10 +42,12 @@ describe('Claude structured dispatch admission', () => { const [b, c] = session.dispatchWaiters expect(resolveClaudeReplayTurn(session, userReplayFrame(b!.sentUuid, 'b'))).toEqual({ - requestedAt: 200 + requestedAt: 200, + clientMessageId: 'client-b' }) expect(resolveClaudeReplayTurn(session, userReplayFrame(c!.sentUuid, 'c'))).toEqual({ - requestedAt: 300 + requestedAt: 300, + clientMessageId: 'client-c' }) }) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index c036b7d67405..f47b71b51180 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -175,8 +175,10 @@ export function createClaudeJournalTranslator( message: Record, startsTurn: boolean, observedAt: number, - requestedAt?: number - ): boolean => journalClaudeMessage(messageContext, message, startsTurn, observedAt, requestedAt) + requestedAt?: number, + openedBy?: string + ): boolean => + journalClaudeMessage(messageContext, message, startsTurn, observedAt, requestedAt, openedBy) return { handle: (event) => { @@ -244,7 +246,8 @@ export function createClaudeJournalTranslator( event.message, event.startsTurn === true, event.observedAt ?? Date.now(), - event.requestedAt + event.requestedAt, + event.clientMessageId ) ) { providerFallback.append( diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 92f84f6c4896..404787620e95 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -145,6 +145,7 @@ export async function acquireClaudeSession({ message, ...(startsTurn ? { startsTurn: true } : {}), ...(requestedAt === null || requestedAt === undefined ? {} : { requestedAt }), + ...(turnOrigin?.clientMessageId ? { clientMessageId: turnOrigin.clientMessageId } : {}), ...observedAt }) ) diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index ec7813ef33b8..d61df04ac75c 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -47,6 +47,8 @@ export type ClaudeStructuredSessionEvent = /** Submission instant of the dispatch this replay acknowledged; the origin * of the turn it opens. Absent when the host cannot name a send. */ requestedAt?: number + /** The submission this replay acknowledged, which opens the turn. */ + clientMessageId?: string /** Host clock at receipt; stamped on turn boundaries only. */ observedAt?: number } diff --git a/src/main/claude/claude-turn-lifecycle-item.ts b/src/main/claude/claude-turn-lifecycle-item.ts index e816be7f2b29..1d0db5fa21f4 100644 --- a/src/main/claude/claude-turn-lifecycle-item.ts +++ b/src/main/claude/claude-turn-lifecycle-item.ts @@ -19,6 +19,8 @@ export type ClaudeCurrentTurn = { /** Provider key of the user echo, or the lifecycle row itself when provider * output opened a turn with no user row to receive its timing. */ userItemId: string + /** The submission whose send opened the turn, while its echo has yet to land in the journal. */ + openedBy?: string /** Present when the turn is the host's record of a conversation command. */ command?: ClaudeCommandTurn } diff --git a/src/main/claude/claude-turn-opening.ts b/src/main/claude/claude-turn-opening.ts index 1406928b37ed..b5e13a39c71c 100644 --- a/src/main/claude/claude-turn-opening.ts +++ b/src/main/claude/claude-turn-opening.ts @@ -30,6 +30,8 @@ export type ClaudeSendEchoTurnInput = { requestedAt?: number /** Provider key of the user row this turn is anchored to. */ userItemId: string + /** The submission this echo acknowledged. */ + openedBy?: string } /** The turn a replayed send echo opens, or null when this frame is not one. */ @@ -46,7 +48,8 @@ export function claudeTurnOpenedBySendEcho( turnId: envelope.uuid, startedAt: input.observedAt, ...(input.requestedAt === undefined ? {} : { requestedAt: input.requestedAt }), - userItemId: input.userItemId + userItemId: input.userItemId, + ...(input.openedBy === undefined ? {} : { openedBy: input.openedBy }) } : null } diff --git a/src/main/claude/claude-turn-outcome.test.ts b/src/main/claude/claude-turn-outcome.test.ts index d5bc5366606c..de88da76635f 100644 --- a/src/main/claude/claude-turn-outcome.test.ts +++ b/src/main/claude/claude-turn-outcome.test.ts @@ -280,24 +280,18 @@ describe("a person's Stop inside a live turn", () => { } // Whose end it was is the journal's Stop rule to say as it writes the end (`turnEndAfterStop`). - it.each([ - ['naming the turn', stopOf('user-1')], - ['naming no turn', { sequence: 9, event: { reason: 'user-stop' as const, at: 1 } }] - ])( - "leaves an error result with no terminal reason after a person's Stop %s to that Stop", - (_label, stop) => { - const state = sinkWithStop(stop) - const translator = createClaudeJournalTranslator({ sink: state.sink }) - translator.handle(userTurn('user-1')) - - translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) - - const turn = settledTurn(state.items, 'user-1') - expect(turn).toMatchObject({ state: 'interrupted' }) - expect(turn).not.toHaveProperty('outcome') - expect(providerRows(state.items)).toBe(0) - } - ) + it("leaves an error result with no terminal reason after a person's Stop of the turn to that Stop", () => { + const state = sinkWithStop(stopOf('user-1')) + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + + translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) + + const turn = settledTurn(state.items, 'user-1') + expect(turn).toMatchObject({ state: 'interrupted' }) + expect(turn).not.toHaveProperty('outcome') + expect(providerRows(state.items)).toBe(0) + }) it.each([ ['no terminal reason', cutShort], 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 0094c30e07c8..cdcf7048bc6f 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 @@ -12,7 +12,7 @@ export class JournalStopMarks { } /** `personStopDecidesTurn`: a person's Stop decides how turn `turnId` ends. */ - personStopDecides(turnId: string | null, endedAt?: number): boolean { - return personStopDecidesTurn(this.deps.state(), turnId, endedAt) + personStopDecides(turnId: string | null, endedAt?: number, openedBy?: string): boolean { + return personStopDecidesTurn(this.deps.state(), turnId, endedAt, openedBy) } } diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts index 821ceb781382..dfa448c1c81c 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,13 +1,22 @@ // What a Stop decides about the turn it named: the one rule every turn-end write passes through. // -// A turn a person's Stop or close of this chat named, 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, a turn opened by a +// send it stopped, ending with no verdict of its own after that Stop's event, ends as their +// cancellation. A host stop, an eviction and no Stop at all leave the end as written. It runs +// where each row is built, inside the journal's serialized write, so it reads every Stop folded +// before the end: the adapter's settle, the host's fallback and a relaunch's settle all write +// through it, and every client folds the row it wrote. -import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { + agentJournalSubmissionKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { isUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-unanswered-dispatch' import type { JournalReducerState } from './journal-reducer' import type { JournalStopEvent } from './journal-row-schema' import type { JournalQueuePauseMarks } from './queued-message-pause' @@ -26,103 +35,121 @@ function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { } } -type TurnEndState = Pick +type TurnEndState = Pick< + JournalReducerState, + 'items' | 'queuePauseMarks' | 'submissions' | 'aliases' +> -/** Whether `stop`, a person's, makes the end of turn `turnId` theirs: it named that turn, or named - * none and stopped the turn item `itemId` opened. */ -function stopIsTurnCancellation( - stop: JournalLatestStop, - turnId: string, +/** The send whose journal item `userItemId` (a turn's opener, by its own or its provider key) is. */ +function openingSubmission( state: TurnEndState, - itemId: string | null + userItemId: string | undefined +): AgentJournalSubmission | undefined { + if (userItemId === undefined) { + return undefined + } + const identity = parseAgentJournalItemKey(state.aliases.get(userItemId) ?? userItemId) + return identity?.provider === 'orca' && 'clientMessageId' in identity + ? state.submissions.get(identity.clientMessageId) + : undefined +} + +/** 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 (!stopIsAPersons(stop.event.reason)) { + if ( + submission.dispatchState === 'rejected' || + (submission.handoverRecorded === true && submission.handedOverAt === undefined) + ) { return false } - return stop.event.turnId !== undefined - ? stop.event.turnId === turnId - : turnlessStopStopped(state, stop, itemId) + // 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 } -/** Pressed before any turn showed, a Stop stopped the first turn opened after it, and no later - * one: unless a send journaled since, of any origin, was not refused, whose turn that is. A Stop - * whose stopped send never opens a turn so binds nothing. `itemId` null: a turn not yet opened. */ -function turnlessStopStopped( +/** 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, - itemId: string | null + turnId: string, + userItemId: string | undefined ): boolean { - const createdAt = itemId === null ? null : (state.items.get(itemId)?.sequence ?? null) - if (createdAt !== null && createdAt <= stop.sequence) { + if (!stopIsAPersons(stop.event.reason)) { return false } - for (const submission of state.submissions.values()) { - if ( - submission.dispatchState !== 'rejected' && - submission.acceptedSequence !== undefined && - submission.acceptedSequence > stop.sequence - ) { - return false - } - } - for (const [otherId, item] of state.items) { - if ( - otherId !== itemId && - item.body.kind === 'turn' && - item.sequence > stop.sequence && - (createdAt === null || item.sequence < createdAt) - ) { - return false - } + if (stop.event.turnId !== undefined) { + return stop.event.turnId === turnId } - return true + const opener = openingSubmission(state, userItemId) + return opener !== undefined && isStopTarget(state, stop, opener) } -/** THE rule: whether the latest Stop makes turn `turnId` (item `itemId`, null if not yet opened), - * 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`, opened by `userItemId` and ending at + * `endedAt` with no verdict of its own, a person's cancellation. An exit the provider saw before + * the Stop was news, whenever its end is written. */ function stopEndsTurnAsCancellation( state: TurnEndState, turnId: string, - itemId: string | null, + userItemId: string | undefined, endedAt: number | undefined ): boolean { const stop = state.queuePauseMarks.latestStop return ( stop !== null && - stopIsTurnCancellation(stop, turnId, state, itemId) && + stopIsTurnCancellation(state, stop, turnId, userItemId) && (endedAt === undefined || endedAt >= stop.event.at) ) } +/** With no turn running, the work in flight is the person's Stop's: every send still unanswered is + * one it stopped. */ +function unansweredSendsAreStopTargets(state: TurnEndState, stop: JournalLatestStop): boolean { + const unanswered = [...state.submissions.values()].filter((submission) => + isUnansweredStructuredAgentSessionDispatch(submission) + ) + return ( + unanswered.length > 0 && unanswered.every((submission) => isStopTarget(state, stop, submission)) + ) +} + /** - * Whether a person's Stop decides the end of turn `turnId` (null: the turn a send opens next), - * 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. + * Whether a person's Stop decides the end of turn `turnId` (null: the sends in flight with no turn + * running), by `turnEndAfterStop`'s rule: ending at `endedAt` it is their cancellation, and still + * running it is theirs to end. For a writer that must choose before the end is written: a host + * stop must not supersede it, and a Claude error result naming no reason leaves its verdict to it. */ export function personStopDecidesTurn( state: TurnEndState, turnId: string | null, - endedAt?: number + endedAt?: number, + /** The submission that opened the turn, for one whose rows have yet to land. */ + openedBy?: string ): boolean { const stop = state.queuePauseMarks.latestStop if (stop === null || !stopIsAPersons(stop.event.reason)) { return false } if (turnId === null) { - return stop.event.turnId === undefined && turnlessStopStopped(state, stop, null) + return stop.event.turnId === undefined && unansweredSendsAreStopTargets(state, stop) } - const itemId = [...state.items].find( - ([, item]) => readAgentJournalTurn(item.body)?.turnId === turnId - )?.[0] - return stopEndsTurnAsCancellation(state, turnId, itemId ?? null, endedAt) + const turn = [...state.items.values()] + .map((item) => readAgentJournalTurn(item.body)) + .find((candidate) => candidate?.turnId === turnId) + const userItemId = + turn?.userItemId ?? (openedBy === undefined ? undefined : agentJournalSubmissionKey(openedBy)) + return stopEndsTurnAsCancellation(state, turnId, userItemId, endedAt) } /** * The body to write for item `itemId`: unchanged unless it ends, with no verdict of its own and no - * earlier than the latest Stop event, a person's, which named it, or stopped it before it showed, - * while it was still open (running, or unproven). A provider's own verdict always stands. + * earlier than the latest Stop event, a person's, which named it, or stopped the send that opened + * it, while it was still open (running, or unproven). A provider's own verdict always stands. */ export function turnEndAfterStop( state: TurnEndState, @@ -137,7 +164,8 @@ export function turnEndAfterStop( if (previous && previous.state !== 'running' && previous.state !== 'unverifiable') { return body } - return stopEndsTurnAsCancellation(state, body.turnId, itemId, body.completedAt) + const userItemId = body.userItemId ?? previous?.userItemId + return stopEndsTurnAsCancellation(state, body.turnId, userItemId, body.completedAt) ? { ...body, outcome: 'cancellation' } : body } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index 4f2491f7b617..fe8d6108aae1 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 @@ -67,8 +67,8 @@ export class StructuredAgentSessionSinkQueue { journalLinkage = (): StructuredAgentSessionLinkageJournal | null => this.target?.journal ?? null - journalStopDecidesTurn = (turnId: string, endedAt: number): boolean => - this.target?.journal.stopMarks.personStopDecides(turnId, endedAt) ?? false + journalStopDecidesTurn = (turnId: string, endedAt: number, openedBy?: string): boolean => + this.target?.journal.stopMarks.personStopDecides(turnId, endedAt, openedBy) ?? 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 cd15160df88d..90cc2334690f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -136,8 +136,9 @@ 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. */ - journalStopDecidesTurn?(turnId: string, endedAt: number): boolean + * of its own, a person's cancellation (`personStopDecidesTurn`); false until bound. `openedBy`: + * the submission that opened it, for a turn whose rows have yet to land. */ + journalStopDecidesTurn?(turnId: string, endedAt: number, openedBy?: string): boolean appendLifecycleBatch?( settlementId: string, mutations: readonly JournalLifecycleMutationInput[], 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 2962041b8be9..7bfe85b3765d 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,8 +1,10 @@ -// Which turn a person's Stop that named no turn binds: only the one its stopped send opens. A Stop of -// a start that never landed stopped a send that opens no turn, and a send journaled after the Stop -// opens its own; neither is the Stop's, whatever sent it, through a rewind too. +// Which turn a person's Stop that named no turn binds: only a turn a send it stopped opens. A Stop +// of a start that never landed stopped a send that opens no turn; a card it held, which Resume +// releases, and anything sent after it open their own; a rewind keeps the binding. Turn rows name +// the send that opened them, as Codex writes them. import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemIdentity @@ -21,10 +23,10 @@ let rig: QueuedMessageTestRig afterEach(() => rig.dispose()) -const MAIL_TURN: AgentJournalItemIdentity = { +const LATER_TURN: AgentJournalItemIdentity = { provider: 'codex', threadId: 'thread-1', - turnId: 'turn-mail', + turnId: 'turn-later', ordinal: 999 } @@ -54,16 +56,39 @@ function fence(): number { return rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1 } -function mailTurn() { - return journal() - .snapshot() - .items.map((item) => readAgentJournalTurn(item.body)) - .find((turn) => turn?.turnId === 'turn-mail') +async function laterTurn() { + const { items } = await rig.host.journalSnapshot(HOST_TEST_SESSION) + return items + .map((item) => readAgentJournalTurn(item.body)) + .find((turn) => turn?.turnId === 'turn-later') } -/** A person's Stop of a start that never landed, whose send opens no turn; then orchestration mail - * starts a new child, which lands and runs the mail's turn. */ -async function mailTurnAfterStopOfStart(): Promise { +/** The turn send `clientMessageId` opens, running, named by its row as Codex writes it. */ +async function turnOpenedBy(clientMessageId: string, state: 'running' | 'interrupted' = 'running') { + await journal().appendItem( + LATER_TURN, + { + kind: 'turn', + turnId: 'turn-later', + startedAt: Date.now(), + userItemId: agentJournalSubmissionKey(clientMessageId), + ...(state === 'running' ? { state } : { state, completedAt: Date.now() + 5 }) + }, + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +async function queuedDraft(text: string): Promise { + const queued = await rig.send(text, 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error(`expected a queued receipt: ${JSON.stringify(queued)}`) + } + return queued.value.queued.messageId +} + +/** A person's Stop of a start that never landed, whose send opens no turn. `held`: a card queued + * behind the start, which the Stop holds. */ +async function stopOfStart(options: { held?: true } = {}): Promise { rig = await createQueuedMessageTestRig({ starting: true, restartable: true }) let release: () => void = () => undefined rig.awaitStarted.mockImplementation( @@ -71,40 +96,64 @@ async function mailTurnAfterStopOfStart(): Promise { ) rig.send('work on this') await eventually(() => expect(childPhase()).toBe('starting')) + const held = options.held ? await queuedDraft('queued behind the start') : undefined expect(await rig.stop()).toMatchObject({ ok: true }) release() expect(stopEvents()).toEqual([expect.objectContaining({ reason: 'user-stop' })]) expect(stopEvents()[0]).not.toHaveProperty('turnId') await eventually(() => expect(childPhase()).toBeUndefined()) rig.awaitStarted.mockImplementation(async () => undefined) - await rig.send('mail for the worker', undefined, { internal: true }).result + return held +} + +/** Orchestration mail after the Stop starts a new child and its turn runs. */ +async function mailTurn(): Promise { + const mail = rig.send('mail for the worker', undefined, { internal: true }) + await mail.result await eventually(() => expect(rig.dispatch).toHaveBeenCalled()) - await journal().appendItem( - MAIL_TURN, - { kind: 'turn', turnId: 'turn-mail', state: 'running', startedAt: Date.now() }, - { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) + await turnOpenedBy(mail.id) +} + +/** The host evicts the chat; the Stop events as its provider close finds them. */ +async function evictedAt(): Promise { + let atClose: JournalStopEvent[] = [] + rig.closeSession.mockImplementationOnce(async () => { + atClose = stopEvents() + return true + }) + await rig.host.close(HOST_TEST_SESSION, 'evict') + return atClose.map((event) => event.reason) +} + +async function expectNews(): Promise { + const turn = await laterTurn() + expect(turn).toMatchObject({ state: 'interrupted' }) + expect(turn).not.toHaveProperty('outcome') } describe('a Stop of a start that never landed binds no later turn', () => { - it("writes the host's event when it evicts the mail turn, which reads as news", async () => { - await mailTurnAfterStopOfStart() - let atClose: JournalStopEvent[] = [] - rig.closeSession.mockImplementationOnce(async () => { - atClose = stopEvents() - return true - }) + it("writes the host's event when it evicts a mail turn, which reads as news", async () => { + await stopOfStart() + await mailTurn() - await rig.host.close(HOST_TEST_SESSION, 'evict') + expect(await evictedAt()).toEqual(['user-stop', 'evict']) + await expectNews() + }) - expect(atClose.map((event) => event.reason)).toEqual(['user-stop', 'evict']) - await rig.host.journalSnapshot(HOST_TEST_SESSION) - expect(mailTurn()).toMatchObject({ state: 'interrupted' }) - expect(mailTurn()).not.toHaveProperty('outcome') + it('reads a mail turn the child end cut, with no verdict of its own, as news', async () => { + await stopOfStart() + const mail = rig.send('mail for the worker', undefined, { internal: true }) + await mail.result + await turnOpenedBy(mail.id) + + await turnOpenedBy(mail.id, 'interrupted') + + await expectNews() }) - it('settles a crash of the mail turn on relaunch as news', async () => { - await mailTurnAfterStopOfStart() + it('settles a crash of a mail turn on relaunch as news', async () => { + await stopOfStart() + await mailTurn() const owner = fence() rig.crashRestartHostProcess() await rig.host.journalSnapshot(HOST_TEST_SESSION) @@ -122,14 +171,67 @@ describe('a Stop of a start that never landed binds no later turn', () => { } }) - expect(mailTurn()).toMatchObject({ state: 'interrupted' }) - expect(mailTurn()).not.toHaveProperty('outcome') + await expectNews() + }) + + it("writes the host's event when it evicts the turn of a card the Stop held, which Resume sent", async () => { + const held = (await stopOfStart({ held: true }))! + expect(await rig.resume()).toMatchObject({ ok: true }) + await eventually(async () => expect(await rig.handoff(held)).toBeDefined()) + await turnOpenedBy(await rig.handoffId(held)) + + expect(await evictedAt()).toEqual(['user-stop', 'evict']) + await expectNews() + }) +}) + +describe('a Stop pressed before its send opened a turn binds only that turn', () => { + /** The send the Stop stopped is handed over and unopened; a card waits behind it. */ + async function stopBeforeTheTurnShowed(): Promise<{ stopped: string; held: string }> { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + const held = await queuedDraft('queued behind the turn') + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + return { stopped, held } + } + + it("binds the stopped send's own turn", async () => { + const { stopped } = await stopBeforeTheTurnShowed() + await rig.settleAccepted(stopped, 'stopped') + + await turnOpenedBy(stopped, 'interrupted') + + expect(await laterTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) + + it("writes the host's event when it evicts the turn of the card Resume sent", async () => { + const { stopped, held } = await stopBeforeTheTurnShowed() + await rig.settleAccepted(stopped, 'stopped') + expect(await rig.resume()).toMatchObject({ ok: true }) + await eventually(async () => expect(await rig.handoff(held)).toBeDefined()) + await turnOpenedBy(await rig.handoffId(held)) + + expect(await evictedAt()).toEqual(['user-stop', 'evict']) + await expectNews() + }) + + it('reads a mail turn the child end cut as news', async () => { + const { stopped } = await stopBeforeTheTurnShowed() + await rig.settleAccepted(stopped, 'stopped') + const mail = rig.send('mail for the lead', undefined, { internal: true }) + await mail.result + await turnOpenedBy(mail.id) + + await turnOpenedBy(mail.id, 'interrupted') + + await expectNews() }) }) describe('a rewind that restates a turnless Stop', () => { - // The rewind writes the Stop still in force after the turns it keeps, so it follows the turn it - // already bound; the mail after the rewind is still its own. + // The rewind writes the Stop still in force after the turns it keeps, at a new position; the + // mail after the rewind is still its own. it('binds no turn opened after the rewind', async () => { rig = await createQueuedMessageTestRig() const stopped = await rig.workingSend() @@ -137,11 +239,16 @@ describe('a rewind that restates a turnless Stop', () => { expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') await rig.settleAccepted(stopped, 'stopped') const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - const stoppedTurn = { ...MAIL_TURN, turnId: 'turn-stopped', ordinal: 998 } - const ended = { kind: 'turn' as const, turnId: 'turn-stopped', state: 'interrupted' as const } + const stoppedTurn = { ...LATER_TURN, turnId: 'turn-stopped', ordinal: 998 } + const ended = { + kind: 'turn' as const, + turnId: 'turn-stopped', + state: 'interrupted' as const, + userItemId: agentJournalSubmissionKey(stopped) + } await journal().appendItem( stoppedTurn, - { kind: 'turn', turnId: 'turn-stopped', state: 'running', startedAt: Date.now() }, + { ...ended, state: 'running', startedAt: Date.now() }, scope ) await journal().appendItem(stoppedTurn, { ...ended, completedAt: Date.now() + 1 }, scope) @@ -152,19 +259,11 @@ describe('a rewind that restates a turnless Stop', () => { body: { ...ended, completedAt: Date.now() + 1, outcome: 'cancellation' } } ]) - await rig.send('mail after the rewind', undefined, { internal: true }).result - await journal().appendItem( - MAIL_TURN, - { kind: 'turn', turnId: 'turn-mail', state: 'running', startedAt: Date.now() }, - scope - ) - await journal().appendItem( - MAIL_TURN, - { kind: 'turn', turnId: 'turn-mail', state: 'interrupted', completedAt: Date.now() + 5 }, - scope - ) + const mail = rig.send('mail after the rewind', undefined, { internal: true }) + await mail.result + await turnOpenedBy(mail.id) + await turnOpenedBy(mail.id, 'interrupted') - expect(mailTurn()).toMatchObject({ state: 'interrupted' }) - expect(mailTurn()).not.toHaveProperty('outcome') + await expectNews() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index 1e2e707ac1cb..0a77eebbff22 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -261,9 +261,7 @@ describe("a person's Stop pause and the Stop events after it", () => { await rig.settleAccepted(working, 'stopped') expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) // Orchestration mail starts a turn the host sent, which lifts nothing. - const mail = rig.send('mail for the lead', undefined, { internal: true }) - await mail.result - await rig.settleAccepted(mail.id, 'mail') + await rig.send('mail for the lead', undefined, { internal: true }).result await journal().appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-mail', ordinal: 999 }, { kind: 'turn', turnId: 'turn-mail', state: 'running', startedAt: 1 }, 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 85ec7a619995..18d28db12407 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 @@ -9,6 +9,7 @@ import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict' import { agentTurnVerdict } from '../../../shared/agent-turn-outcome' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' @@ -277,24 +278,38 @@ describe('a restart between a Stop and its turn end', () => { expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined() await rig.settleAccepted(stopped, 'stopped') const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + // Codex names the send that opened a turn on its row. + const opener = agentJournalSubmissionKey(stopped) await journal().appendItem( CODEX_TURN, - { kind: 'turn', turnId: TURN, state: 'running', startedAt: Date.now() }, + { kind: 'turn', turnId: TURN, state: 'running', startedAt: Date.now(), userItemId: opener }, scope ) await journal().appendItem( CODEX_TURN, - { kind: 'turn', turnId: TURN, state: 'interrupted', completedAt: Date.now() + 1 }, + { + kind: 'turn', + turnId: TURN, + state: 'interrupted', + completedAt: Date.now() + 1, + userItemId: opener + }, scope ) expect(settled(TURN).turn).toMatchObject({ outcome: 'cancellation' }) - // The queue's drain sends as the host, so no person's send voids the Stop. + // A send after the Stop, the queue's drain here, opens its own turn. const drained = rig.send('drained after the Stop', undefined, { internal: true }) await drained.result await rig.settleAccepted(drained.id, 'drained') await journal().appendItem( CODEX_NEXT_TURN, - { kind: 'turn', turnId: NEXT_TURN, state: 'running', startedAt: Date.now() }, + { + kind: 'turn', + turnId: NEXT_TURN, + state: 'running', + startedAt: Date.now(), + userItemId: agentJournalSubmissionKey(drained.id) + }, scope ) From ee548614a2d4cad687e6a3f331c05a15daf8e714 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:31:06 -0700 Subject: [PATCH 52/58] fix(native-chat): a host stop whose sink drain runs long reads the agent working; a failed one reads the journal A drain past its bound may still hold the turn's row, while the echo's acceptance has already landed, so the journal as it stands read nothing running: a person's close of that turn wrote no Stop event and the turn read as news. The two drain outcomes now differ: one that failed has nothing more to deliver, so the journal's read holds (as before); one still running reads working. --- .../structured-agent-session-host-lifetime.ts | 25 +++++++------ ...d-agent-session-stop-event-entries.test.ts | 35 +++++++++++++++++++ 2 files changed, 50 insertions(+), 10 deletions(-) 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 054e51d4974a..263a1f5d91ae 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 @@ -121,18 +121,23 @@ async function stopEndsWork( if ('recorded' in ending || ending.quit || ending.resting || !child) { return false } - // Best effort: the stop goes ahead either way, so a failed or slow drain leaves the journal's - // read as it stands rather than calling the agent working. - await withTimeout( - context.runtimeState.flushEventSink(sessionId).catch(() => undefined), + // A failed drain has nothing more to deliver, so the journal's read as it stands holds. One + // still running past its bound may hold the turn's row, so the agent reads working. + const drain = await withTimeout( + context.runtimeState.flushEventSink(sessionId).then( + () => 'drained' as const, + () => 'failed' as const + ), STOP_EVENT_DRAIN_TIMEOUT_MS, - undefined - ) - const working = isStructuredAgentSessionMainAgentWorking( - journal.activeTurnId(), - journal.submissions(), - child.fence + 'slow' as const ) + const working = + drain === 'slow' || + isStructuredAgentSessionMainAgentWorking( + journal.activeTurnId(), + journal.submissions(), + child.fence + ) // A host stop of work a person's Stop is already ending must not supersede that Stop's reason. return ( working && diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index 0a77eebbff22..db71694dbb7c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' @@ -159,6 +160,40 @@ describe('every Stop entry writes its event, with its reason, before it ends the }) }) + // A drain still running after its bound may hold the turn's row: the agent reads working. + it("writes a person's close while the running turn's row waits behind a slow sink", async () => { + rig = await createQueuedMessageTestRig() + const sent = await rig.workingSend() + const open = journal() + const append = open.appendItem.bind(open) + let held = false + vi.spyOn(open, 'appendItem').mockImplementation(async (...args: Parameters) => { + if (!held && args[1].kind === 'turn' && args[1].state === 'running') { + held = true + await new Promise((resolve) => setTimeout(resolve, 1_500)) + } + return append(...args) + }) + rig.host['runtimeState'].eventSinkFor(HOST_TEST_SESSION).sink.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 999 }, + { + kind: 'turn', + turnId: 'turn-1', + state: 'running', + startedAt: Date.now(), + userItemId: agentJournalSubmissionKey(sent) + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + // The echo's acceptance lands straight in the journal, ahead of the turn row. + await rig.settleAccepted(sent, 'turn-1') + const atClose = stopEventsAtClose() + + await rig.host.close(HOST_TEST_SESSION, 'user-close') + + expect(atClose.events?.map((event) => event.reason)).toEqual(['user-close']) + }, 20_000) + // The drain is best effort: when it fails, the journal as it stands says the agent rests. it('writes nothing when the drain fails as it evicts a chat at rest', async () => { rig = await createQueuedMessageTestRig() From bd16848e4addb679a620ecaee5df8c45859baae0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:31:40 -0700 Subject: [PATCH 53/58] test(native-chat): a host stop with no turn running defers only while every unanswered send is the Stop's The branch had no test. An eviction with only the stopped send unanswered writes nothing; one with a send made after the Stop still unanswered writes its event. --- ...d-agent-session-stop-event-binding.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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 7bfe85b3765d..a284bd9b12f9 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 @@ -229,6 +229,31 @@ describe('a Stop pressed before its send opened a turn binds only that turn', () }) }) +describe('a host stop with no turn running after a Stop that named none', () => { + it('writes nothing while every unanswered send is one the Stop stopped', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + + expect(await evictedAt()).toEqual(['user-stop']) + }) + + it("writes the host's event when a send after the Stop is unanswered too", async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(stopped, 'stopped') + const mail = rig.send('mail for the lead', undefined, { internal: true }) + await mail.result + await eventually(async () => + expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined() + ) + + expect(await evictedAt()).toEqual(['user-stop', 'evict']) + }) +}) + describe('a rewind that restates a turnless Stop', () => { // The rewind writes the Stop still in force after the turns it keeps, at a new position; the // mail after the rewind is still its own. From ec0533b527658275aba9fcc45dc6bac58f4bc319 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:36:41 -0700 Subject: [PATCH 54/58] fix(native-chat): a slow sink drain reads working only while an accepted send's turn row is due The previous commit read every drain past its bound as working, so a host eviction or stop of an agent at rest during a sink backlog wrote a Stop event that ended nothing and lifted a person's Stop pause. A slow drain now reads working only when the latest send the agent accepted has opened no turn the journal holds, the race it was for; otherwise the journal's read holds. --- .../journal-stop-marks.ts | 11 ++++- .../journal-stop-turn-end.ts | 29 ++++++++++++ .../structured-agent-session-host-lifetime.ts | 5 +- ...d-agent-session-stop-event-entries.test.ts | 47 +++++++++++++++++++ 4 files changed, 89 insertions(+), 3 deletions(-) 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 cdcf7048bc6f..e79958a952c7 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 @@ -2,7 +2,11 @@ // the turn-end rule reads (`journal-stop-turn-end.ts`), and whether a person's still decides. import type { JournalReducerState } from './journal-reducer' -import { personStopDecidesTurn, type JournalLatestStop } from './journal-stop-turn-end' +import { + latestAcceptedSendUnopened, + personStopDecidesTurn, + type JournalLatestStop +} from './journal-stop-turn-end' export class JournalStopMarks { constructor(private readonly deps: { state: () => JournalReducerState }) {} @@ -11,6 +15,11 @@ export class JournalStopMarks { return this.deps.state().queuePauseMarks.latestStop } + /** `latestAcceptedSendUnopened`: the latest accepted send's turn row may still be on its way. */ + latestAcceptedSendUnopened(): boolean { + return latestAcceptedSendUnopened(this.deps.state()) + } + /** `personStopDecidesTurn`: a person's Stop decides how turn `turnId` ends. */ personStopDecides(turnId: string | null, endedAt?: number, openedBy?: string): boolean { return personStopDecidesTurn(this.deps.state(), turnId, endedAt, openedBy) diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts index dfa448c1c81c..df7e3f0118c8 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 @@ -54,6 +54,35 @@ function openingSubmission( : undefined } +/** Whether the latest send the agent accepted has opened no turn the journal holds: its turn's row + * may still be on its way. */ +export function latestAcceptedSendUnopened(state: TurnEndState): boolean { + let latest: { submission: AgentJournalSubmission; sequence: number } | undefined + for (const submission of state.submissions.values()) { + const sequence = state.items.get( + agentJournalSubmissionKey(submission.clientMessageId) + )?.sequence + if ( + submission.dispatchState === 'accepted' && + sequence !== undefined && + sequence >= (latest?.sequence ?? -1) + ) { + latest = { submission, sequence } + } + } + if (!latest) { + return false + } + for (const item of state.items.values()) { + if ( + openingSubmission(state, readAgentJournalTurn(item.body)?.userItemId) === latest.submission + ) { + return false + } + } + return true +} + /** A send a Stop that named no turn stopped: one already handed to the agent at the Stop's * position, whose turn had not opened. A card the Stop held, or anything sent after it, is not. */ function isStopTarget( 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 263a1f5d91ae..95e5591c4306 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 @@ -122,7 +122,8 @@ async function stopEndsWork( return false } // A failed drain has nothing more to deliver, so the journal's read as it stands holds. One - // still running past its bound may hold the turn's row, so the agent reads working. + // still running past its bound may hold the turn row of a send already accepted: that reads + // working. const drain = await withTimeout( context.runtimeState.flushEventSink(sessionId).then( () => 'drained' as const, @@ -132,7 +133,7 @@ async function stopEndsWork( 'slow' as const ) const working = - drain === 'slow' || + (drain === 'slow' && journal.stopMarks.latestAcceptedSendUnopened()) || isStructuredAgentSessionMainAgentWorking( journal.activeTurnId(), journal.submissions(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index db71694dbb7c..e24d92bf6c3b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -194,6 +194,53 @@ describe('every Stop entry writes its event, with its reason, before it ends the expect(atClose.events?.map((event) => event.reason)).toEqual(['user-close']) }, 20_000) + // A slow drain counts as working only for a send accepted with no turn row yet. + it('writes nothing when the drain runs long as it evicts a chat at rest', async () => { + rig = await createQueuedMessageTestRig() + const working = await rig.workingSend() + const opener = agentJournalSubmissionKey(working) + const identity = { + provider: 'codex' as const, + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 999 + } + const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + await journal().appendItem( + identity, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1, userItemId: opener }, + scope + ) + await queuedDraft('queued behind the turn') + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(working, 'turn-1') + await journal().appendItem( + identity, + { + kind: 'turn', + turnId: 'turn-1', + state: 'interrupted', + completedAt: Date.now(), + userItemId: opener + }, + scope + ) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + const sink = rig.host['runtimeState'].eventSinkFor(HOST_TEST_SESSION) + const drained = sink.drained.bind(sink) + vi.spyOn(sink, 'drained') + .mockImplementationOnce( + () => new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 1_500)) + ) + .mockImplementation(drained) + const atClose = stopEventsAtClose() + + await rig.host.close(HOST_TEST_SESSION, 'evict') + + expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + }, 20_000) + // The drain is best effort: when it fails, the journal as it stands says the agent rests. it('writes nothing when the drain fails as it evicts a chat at rest', async () => { rig = await createQueuedMessageTestRig() From 3fba742bdd17ab7ae97b1ebd7e1eeb15b47e4ae5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:37:04 -0700 Subject: [PATCH 55/58] test(native-chat): the host-stop control keeps the stopped send unanswered beside the later one With both unanswered, the host writes only because not every unanswered send is the Stop's; a rule that deferred when any one was would pass the old control. --- .../structured-agent-session-stop-event-binding.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 a284bd9b12f9..2654b20ac730 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 @@ -239,11 +239,10 @@ describe('a host stop with no turn running after a Stop that named none', () => expect(await evictedAt()).toEqual(['user-stop']) }) - it("writes the host's event when a send after the Stop is unanswered too", async () => { + it("writes the host's event when a send after the Stop is unanswered beside the stopped one", async () => { rig = await createQueuedMessageTestRig() - const stopped = await rig.workingSend() + await rig.workingSend() expect(await rig.stop()).toMatchObject({ ok: true }) - await rig.settleAccepted(stopped, 'stopped') const mail = rig.send('mail for the lead', undefined, { internal: true }) await mail.result await eventually(async () => From 6a1f65bc96537eca10f836d0ab0d3fc1cd574e4e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:41:50 -0700 Subject: [PATCH 56/58] fix(native-chat): a steer is no send owed a turn when a slow drain judges a host stop A slow drain reads working when the latest accepted send has opened no turn yet. A Codex steer or a Claude fold is accepted into the running turn and never opens one, so a chat at rest whose last send was a steer still read working, and an eviction lifted a person's Stop pause. Sends delivered into a running turn, whose item carries that turn's scope, are skipped. --- .../journal-stop-turn-end.ts | 16 +-- ...d-agent-session-stop-event-entries.test.ts | 107 +++++++++++------- 2 files changed, 72 insertions(+), 51 deletions(-) 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 df7e3f0118c8..4c28a0422a6d 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 @@ -54,20 +54,20 @@ function openingSubmission( : undefined } -/** Whether the latest send the agent accepted has opened no turn the journal holds: its turn's row - * may still be on its way. */ +/** Whether the latest send the agent accepted to open a turn has opened none the journal holds: its + * turn's row may still be on its way. A send delivered into a running turn (a steer, a fold) + * opens none, and its item carries that turn's scope (`placeHandedOverMessage`). */ export function latestAcceptedSendUnopened(state: TurnEndState): boolean { let latest: { submission: AgentJournalSubmission; sequence: number } | undefined for (const submission of state.submissions.values()) { - const sequence = state.items.get( - agentJournalSubmissionKey(submission.clientMessageId) - )?.sequence + const item = state.items.get(agentJournalSubmissionKey(submission.clientMessageId)) if ( submission.dispatchState === 'accepted' && - sequence !== undefined && - sequence >= (latest?.sequence ?? -1) + item !== undefined && + item.turnScope?.kind !== 'turn' && + item.sequence >= (latest?.sequence ?? -1) ) { - latest = { submission, sequence } + latest = { submission, sequence: item.sequence } } } if (!latest) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index e24d92bf6c3b..a40633381454 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -195,51 +195,72 @@ describe('every Stop entry writes its event, with its reason, before it ends the }, 20_000) // A slow drain counts as working only for a send accepted with no turn row yet. - it('writes nothing when the drain runs long as it evicts a chat at rest', async () => { - rig = await createQueuedMessageTestRig() - const working = await rig.workingSend() - const opener = agentJournalSubmissionKey(working) - const identity = { - provider: 'codex' as const, - threadId: 'thread-1', - turnId: 'turn-1', - ordinal: 999 - } - const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - await journal().appendItem( - identity, - { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1, userItemId: opener }, - scope - ) - await queuedDraft('queued behind the turn') - expect(await rig.stop()).toMatchObject({ ok: true }) - await rig.settleAccepted(working, 'turn-1') - await journal().appendItem( - identity, - { - kind: 'turn', + // A steer delivered into the running turn opens no turn of its own, so it is never owed one. + it.each([ + ['', false], + [', its last send a steer into the stopped turn', true] + ])( + 'writes nothing when the drain runs long as it evicts a chat at rest%s', + async (_label, steered) => { + rig = await createQueuedMessageTestRig() + const working = await rig.workingSend() + const opener = agentJournalSubmissionKey(working) + const identity = { + provider: 'codex' as const, + threadId: 'thread-1', turnId: 'turn-1', - state: 'interrupted', - completedAt: Date.now(), - userItemId: opener - }, - scope - ) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - const sink = rig.host['runtimeState'].eventSinkFor(HOST_TEST_SESSION) - const drained = sink.drained.bind(sink) - vi.spyOn(sink, 'drained') - .mockImplementationOnce( - () => new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 1_500)) + ordinal: 999 + } + const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + await journal().appendItem( + identity, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1, userItemId: opener }, + scope ) - .mockImplementation(drained) - const atClose = stopEventsAtClose() - - await rig.host.close(HOST_TEST_SESSION, 'evict') - - expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - }, 20_000) + if (steered) { + const steer = rig.send('steer the running turn') + await steer.result + await eventually(async () => + expect((await rig.submission(steer.id))?.handedOverAt).toBeDefined() + ) + await rig.settleAccepted(steer.id, 'turn-1') + expect( + journal() + .snapshot() + .items.find((item) => item.itemId === agentJournalSubmissionKey(steer.id))?.turnScope + ).toMatchObject({ kind: 'turn' }) + } + await queuedDraft('queued behind the turn') + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(working, 'turn-1') + await journal().appendItem( + identity, + { + kind: 'turn', + turnId: 'turn-1', + state: 'interrupted', + completedAt: Date.now(), + userItemId: opener + }, + scope + ) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + const sink = rig.host['runtimeState'].eventSinkFor(HOST_TEST_SESSION) + const drained = sink.drained.bind(sink) + vi.spyOn(sink, 'drained') + .mockImplementationOnce( + () => new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 1_500)) + ) + .mockImplementation(drained) + const atClose = stopEventsAtClose() + + await rig.host.close(HOST_TEST_SESSION, 'evict') + + expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + }, + 20_000 + ) // The drain is best effort: when it fails, the journal as it stands says the agent rests. it('writes nothing when the drain fails as it evicts a chat at rest', async () => { From b10fb505d3eb2af8bf5074f9996d718d4021c8aa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:40:15 -0700 Subject: [PATCH 57/58] test(native-chat): type the Stop test envelope's fields narrowly --- .../structured-agent-session-claude-stop-turn-end.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts index 1c46f0ffc70c..809abef834bf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts @@ -97,7 +97,10 @@ function eventually(assertion: () => T | Promise): Promise { return vi.waitFor(assertion, { timeout: 10_000 }) } -function envelope(method: 'agentSession.send' | 'agentSession.cancel', fields: object) { +function envelope( + method: 'agentSession.send' | 'agentSession.cancel', + fields: Record +) { return { sessionId: SESSION, clientOperationId: hostTestOperationId(), From 32c945f44c7d272f406736448f2ab5e94f6593bc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:23:51 -0700 Subject: [PATCH 58/58] test(native-chat): the retry of a close whose exit was unproven writes no second Stop event The idle sweep finishes a stop left owed with that stop's own cause. It is the same stop, so its event stands alone and the child's end keeps the cause, for a person's close and an eviction. --- ...d-agent-session-stop-event-entries.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index a40633381454..dd0acf82b371 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -286,6 +286,28 @@ describe('every Stop entry writes its event, with its reason, before it ends the expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) }) + // The idle sweep finishes a stop whose exit was unproven: the same stop, so its event stands alone. + it.each([['user-close' as const], ['evict' as const]])( + 'writes one event for a close (%s) whose exit was unproven, and none for its retry', + async (cause) => { + rig = await createQueuedMessageTestRig({ idleSweep: MANUAL_IDLE_SWEEP }) + await runningTurn() + rig.closeSession.mockResolvedValueOnce(false) + const session = () => rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)! + + await rig.host.close(HOST_TEST_SESSION, cause).catch(() => undefined) + expect(session().child).not.toBeNull() + expect(session().owesProviderChildWindDown).toMatchObject({ cause }) + expect(stopEvents()).toEqual([{ reason: cause, turnId: 'turn-1', at: expect.any(Number) }]) + + await idleSweep().tick() + + expect(session().owesProviderChildWindDown).toBeUndefined() + expect(stopEvents().map((event) => event.reason)).toEqual([cause]) + expect(session().lastEndedChild?.cause).toBe(cause) + } + ) + it("writes nothing at quit, whose resume marker's trigger records why", async () => { rig = await createQueuedMessageTestRig() await runningTurn()