From 45036bb52c2c9bcbf10072e5aaecc0cf8c70757e Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Wed, 9 Sep 2026 21:46:06 -0700 Subject: [PATCH] Preserve Codex exit receipt across close retries --- .../codex-requested-close-turn-timing.test.ts | 97 +++++++++++++++++++ .../codex-structured-journal-translation.ts | 7 +- ...ructured-session-adapter-lifecycle.test.ts | 3 +- .../codex/codex-structured-session-close.ts | 4 +- .../codex/codex-structured-session-state.ts | 4 +- .../structured-agent-session-adapter.ts | 2 + ...ured-agent-session-unexpected-exit.test.ts | 7 +- ...tructured-agent-session-unexpected-exit.ts | 2 +- 8 files changed, 118 insertions(+), 8 deletions(-) create mode 100644 src/main/codex/codex-requested-close-turn-timing.test.ts diff --git a/src/main/codex/codex-requested-close-turn-timing.test.ts b/src/main/codex/codex-requested-close-turn-timing.test.ts new file mode 100644 index 00000000000..29039ffca3c --- /dev/null +++ b/src/main/codex/codex-requested-close-turn-timing.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { closeCodexPublishedSession } from './codex-structured-session-close' +import type { CodexSession } from './codex-structured-session-state' + +afterEach(() => vi.useRealTimers()) + +describe('requested-close durable turn timing', () => { + it.each([true, false])( + 'keeps the first exit receipt when retry requestedClose=%s', + async (requestedClose) => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const terminalBodies: AgentJournalItemBody[] = [] + let refuseSettlement = true + const sink: StructuredAgentSessionEventSink = { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {}, + tryAppendLifecycleBatch: (_id, mutations) => { + if (refuseSettlement) { + return { accepted: false, reason: 'backpressure' } + } + for (const mutation of mutations) { + if (mutation.kind === 'item') { + terminalBodies.push(mutation.body) + } + } + return { accepted: true } + } + } + const translator = createCodexJournalTranslator({ + sink, + sessionId: 'session-1', + primaryThreadId: () => 'thread-1', + now: () => Date.now() + }) + expect( + translator.handle({ + type: 'notification', + sessionId: 'session-1', + threadId: 'thread-1', + method: 'turn/started', + params: { turn: { id: 'turn-1' } }, + observedAt: 1_000 + }) + ).toEqual({ accepted: true }) + const session = { + connection: { close: vi.fn(async () => true) }, + backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), + ended: false, + requestedClose: false, + fence: 7, + acquisitionGeneration: 'generation-1', + threadId: 'thread-1', + prompts: { clear: vi.fn() }, + translator + } as unknown as CodexSession + const sessions = new Map([['session-1', session]]) + const onEvent = vi.fn() + + vi.setSystemTime(2_000) + await expect(closeCodexPublishedSession(sessions, 'session-1')).resolves.toBe(false) + expect(sessions.get('session-1')).toBe(session) + expect(session.ended).toBe(false) + + refuseSettlement = false + vi.setSystemTime(60_000) + await expect( + closeCodexPublishedSession(sessions, 'session-1', onEvent, { + expectedAcquisitionGeneration: 'replacement-generation' + }) + ).resolves.toBe(false) + expect(onEvent).not.toHaveBeenCalled() + await expect( + closeCodexPublishedSession(sessions, 'session-1', onEvent, { requestedClose }) + ).resolves.toBe(true) + expect(sessions.has('session-1')).toBe(false) + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + cause: requestedClose ? 'requested-close' : 'unexpected-exit', + acquisitionGeneration: 'generation-1', + observedAt: 2_000 + }) + ) + expect(terminalBodies.find((body) => body.kind === 'turn')).toMatchObject({ + kind: 'turn', + state: 'interrupted', + startedAt: 1_000, + completedAt: 2_000 + }) + } + ) +}) diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index 674797b03ba..2907b34bc70 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -157,7 +157,12 @@ export function createCodexJournalTranslator( primaryThreadId: deps.primaryThreadId?.() ?? null, ordinals: items.ordinals, settledTurnLifecycle: (threadId, turnId) => - turnBoundaries.settled(threadId, turnId, 'interrupted', deps.now?.() ?? Date.now()) + turnBoundaries.settled( + threadId, + turnId, + 'interrupted', + event.observedAt ?? deps.now?.() ?? Date.now() + ) }) if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts index e7f540730b2..b2c579770fd 100644 --- a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts +++ b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts @@ -182,7 +182,8 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { reason: 'codex app-server connection ended', cause: 'unexpected-exit', fence: 7, - acquisitionGeneration: 'generation-1' + acquisitionGeneration: 'generation-1', + observedAt: expect.any(Number) }) await expect( adapter.dispatch({ diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 5b29dc6c056..af814c51d9b 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -24,13 +24,15 @@ export function handleCodexSessionExit(input: { input.prompts?.clear() return false } + session.exitObservedAt ??= Date.now() const event: StructuredAgentSessionLifecycleEvent = { type: 'ended', sessionId: input.sessionId, reason: input.error.message, cause: session.requestedClose ? 'requested-close' : 'unexpected-exit', fence: session.fence, - acquisitionGeneration: session.acquisitionGeneration + acquisitionGeneration: session.acquisitionGeneration, + observedAt: session.exitObservedAt } as const // A synchronous sink rejection (usually backpressure) is handed to host // recovery, which appends the bounded fallback before reacquisition. diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index cfb48601344..b341862d218 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -45,7 +45,7 @@ export type CodexStructuredSessionEvent = } | StructuredAgentSessionLifecycleEvent /** Translator-only compatibility for callers that do not participate in host recovery. */ - | { type: 'ended'; sessionId: string; reason: string } + | { type: 'ended'; sessionId: string; reason: string; observedAt?: number } export type CodexStructuredSessionAdapterDeps = { resolveLaunch: (input: { @@ -74,6 +74,8 @@ export type CodexStructuredSessionAdapterDeps = { export type CodexSession = { connection: CodexAppServerConnection ended: boolean + /** First observed child exit survives rejected settlement admission. */ + exitObservedAt?: number requestedClose: boolean fence: number acquisitionGeneration: string 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 9a480438c25..50e4a704fbf 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 @@ -102,6 +102,8 @@ export type StructuredAgentSessionLifecycleEvent = { cause: 'unexpected-exit' | 'requested-close' fence: number acquisitionGeneration: string + /** Host receipt of the child exit, retained across settlement retries. */ + observedAt?: number /** Translator could not admit terminal rows; host recovery must append its bounded fallback. */ settlementRetryRequired?: boolean } 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 ae175d9b407..80ec49a4d07 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 @@ -60,8 +60,8 @@ function lifecycleItem( } describe('provider-exit recovery tickets', () => { - it('retains the exit receipt through failed settlement and a later loaded retry', async () => { - let now = 2_000 + it.each([undefined, 2_000])('keeps exit receipt %s on retry', async (observedAt) => { + let now = observedAt === undefined ? 2_000 : 30_000 let record = { lease: { handoffStage: null, @@ -115,7 +115,8 @@ describe('provider-exit recovery tickets', () => { reason: 'provider exited', cause: 'unexpected-exit', fence: 7, - acquisitionGeneration: GENERATION + acquisitionGeneration: GENERATION, + observedAt } ) expect(record.lease.settlementRetryRequired).toBe(true) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index ea7d5b56cca..b01aa583a39 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -52,7 +52,7 @@ export async function settleUnexpectedStructuredAgentSessionExit( } const unexpectedEvent = event as UnexpectedExitLifecycleEvent // Receipt of the exit is the one end time the host may record for a running turn. - const observedAt = context.now() + const observedAt = event.observedAt ?? context.now() return context.serialize(unexpectedEvent.sessionId, async () => { const session = context.sessions.get(unexpectedEvent.sessionId) if (