Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
570b677
feat(native-chat): a status for a message waiting on an exit Orca cou…
brennanb2025 Oct 1, 2026
20e5651
fix(native-chat): a message after a Stop whose exit was unproven retr…
brennanb2025 Oct 1, 2026
53172a3
fix(native-chat): every operation that reaches the agent finishes an …
brennanb2025 Oct 1, 2026
c0e87a3
test(native-chat): type the option-change envelope in the unproven-st…
brennanb2025 Oct 1, 2026
53118b3
fix(native-chat): the stop that proves the exit hands over the messag…
brennanb2025 Oct 1, 2026
7f855c4
docs(native-chat): say who retries a wind-down a Stop leaves owed
brennanb2025 Oct 1, 2026
bc4af0d
fix(native-chat): a waiting message retries the unproven stop once, a…
brennanb2025 Oct 1, 2026
baab367
fix(native-chat): only a retry continues an owed stop; a new close is…
brennanb2025 Oct 1, 2026
a8d87c3
fix(native-chat): a held message always says why it waits
brennanb2025 Oct 1, 2026
e10a102
fix(native-chat): an option change waits only on an unproven exit, no…
brennanb2025 Oct 1, 2026
ad83641
test(native-chat): type the unproven-stop test's envelope fields by t…
brennanb2025 Oct 1, 2026
1436ffe
fix(native-chat): a child seen gone after its stop gave up finishes t…
brennanb2025 Oct 1, 2026
031ab2d
test(native-chat): a held message goes out when the old child exits a…
brennanb2025 Oct 1, 2026
2efc967
fix(native-chat): a Codex close that proved the exit never reports it…
brennanb2025 Oct 1, 2026
5d040c8
fix(native-chat): a late exit retries an owed stop once, however many…
brennanb2025 Oct 1, 2026
6d23c5f
refactor(native-chat): the restart restore owns the lease reconciler …
brennanb2025 Oct 1, 2026
58351a1
test(native-chat): an exit report with nothing waiting finishes the o…
brennanb2025 Oct 1, 2026
d9771b9
Merge commit '56c7642aae2' into brennanb2025/send-after-unproven-stop
brennanb2025 Oct 1, 2026
b2390ae
Merge remote-tracking branch 'origin/main' into brennanb2025/send-aft…
brennanb2025 Oct 1, 2026
5ed0911
test(native-chat): a message after a Codex Stop whose exit was unprov…
brennanb2025 Oct 1, 2026
90dcb74
Merge remote-tracking branch 'origin/main' into brennanb2025/send-aft…
brennanb2025 Oct 1, 2026
cef967c
Merge remote-tracking branch 'origin/brennanb2025/send-after-unproven…
brennanb2025 Oct 1, 2026
de6cffe
Merge remote-tracking branch 'origin/main' into brennanb2025/late-exi…
brennanb2025 Oct 1, 2026
72279e3
refactor(native-chat): the late exit report lives beside the other li…
brennanb2025 Oct 1, 2026
05e26ce
Merge remote-tracking branch 'origin/main' into brennanb2025/late-exi…
brennanb2025 Oct 2, 2026
9d91204
test(native-chat): a late exit's retry keeps the person's Stop as the…
brennanb2025 Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/main/claude/claude-stream-json-connection-close.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,4 +248,57 @@ describe('Claude stream-json close ordering', () => {
await expect(closing).resolves.toBe(true)
expect(child.stdin.writableEnded).toBe(true)
})

it('reports a root exit seen after a close gave up, and not one a running close sees', async () => {
mocks.refresh.mockReset()
mocks.proveClaudeChildExit.mockReset()
mocks.refresh.mockResolvedValue(undefined)
const open = async (child: ChildProcessWithoutNullStreams, reports: string[]) => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection.
const queryImpl = ((params: Parameters<typeof query>[0]) => {
params.options?.spawnClaudeCodeProcess?.({
command: 'claude',
args: [],
env: {},
signal: new AbortController().signal
})
return {
[Symbol.asyncIterator]: () => ({
next: async (): Promise<IteratorResult<Record<string, unknown>>> => ({
value: undefined,
done: true
})
})
}
}) as unknown as typeof query
return openClaudeStreamJsonConnection(
{ pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' },
{
onExit: () => reports.push('exit'),
onExitAfterClose: () => reports.push('exit-after-close')
},
() => child,
queryImpl
)
}

const gaveUp: string[] = []
const lateChild = fakeChild()
mocks.proveClaudeChildExit.mockResolvedValueOnce(false)
const late = await open(lateChild, gaveUp)
await expect(late.close()).resolves.toBe(false)
lateChild.emit('exit', 0, null)
expect(gaveUp).toEqual(['exit-after-close'])

const running: string[] = []
const runningChild = fakeChild()
const proof = Promise.withResolvers<boolean>()
mocks.proveClaudeChildExit.mockReturnValueOnce(proof.promise)
const closing = (await open(runningChild, running)).close()
await vi.waitFor(() => expect(mocks.proveClaudeChildExit).toHaveBeenCalledTimes(2))
runningChild.emit('exit', 0, null)
proof.resolve(true)
await expect(closing).resolves.toBe(true)
expect(running).toEqual([])
})
})
5 changes: 5 additions & 0 deletions src/main/claude/claude-stream-json-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export type ClaudeStreamJsonConnectionHandlers = {
/** A transport/process fault that is not itself first-hand root exit proof. */
onFault?: (error: Error) => void
onExit?: (error: Error) => void
/** The root exited after a close gave up proving it; no close is running to see that. */
onExitAfterClose?: () => void
}

/**
Expand Down Expand Up @@ -212,6 +214,9 @@ export async function openClaudeStreamJsonConnection(
exitStatus = { code, signal }
markExited()
handleUnexpectedEnd()
if (closing && !closePromise) {
handlers.onExitAfterClose?.()
}
})

const handleUnexpectedEnd = (cause?: Error): void => {
Expand Down
13 changes: 12 additions & 1 deletion src/main/claude/claude-structured-init-proof.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CLAUDE_DEFAULT_SETTING_SOURCES } from './claude-structured-launch-resolution'
import type { ClaudeAuthDiagnostic } from './claude-structured-session-state'
import type { ClaudeAuthDiagnostic, ClaudeSession } from './claude-structured-session-state'
import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter'

export type ClaudeInitObservation = {
Expand Down Expand Up @@ -67,6 +67,17 @@ export function readClaudeCapabilities(
return observed
}

/** Every turn opens with an init frame naming the model the CLI is actually running; set_model
* answers success for a model it never resolves, so this report is the session's only adoption
* evidence. */
export function observeClaudeInitFacts(session: ClaudeSession, init: ClaudeInitObservation): void {
if (init.model) {
session.reportedOptions.model = init.model
session.reportedModelMutation = session.optionMutationSequence
}
session.capabilities = readClaudeCapabilities(session.capabilities, init.message)
}

export function claudeInitializationAuthError(
initialization: unknown
): AgentSessionAcquisitionRefusal | null {
Expand Down
14 changes: 4 additions & 10 deletions src/main/claude/claude-structured-session-acquisition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { resolveClaudeReplayTurn } from './claude-replay-turn-resolution'
import { claudeSessionStateEndsTurn } from './claude-session-state-turn-over'
import { settleClaudeTurnEndWaiters } from './claude-request-end-wait'
import {
readClaudeCapabilities,
observeClaudeInitFacts,
readClaudeFrameString,
readClaudeInit
} from './claude-structured-init-proof'
Expand Down Expand Up @@ -100,15 +100,8 @@ export async function acquireClaudeSession({
}
if (init) {
initProof.resolve(init)
// Every turn opens with an init frame naming the model the CLI is actually
// running; set_model answers success for a model it never resolves, so this
// report is the session's only adoption evidence.
if (liveSession && init.model) {
liveSession.reportedOptions.model = init.model
liveSession.reportedModelMutation = liveSession.optionMutationSequence
}
if (liveSession) {
liveSession.capabilities = readClaudeCapabilities(liveSession.capabilities, init.message)
observeClaudeInitFacts(liveSession, init)
}
}
observedLeafUuid = readClaudeTranscriptEntryUuid(message) ?? observedLeafUuid
Expand Down Expand Up @@ -206,7 +199,8 @@ export async function acquireClaudeSession({
childEnded ??= error
initProof.reject(error)
callbacks.handleExit(sessionId, attempt, error)
}
},
onExitAfterClose: () => callbacks.handleExitAfterClose(sessionId, attempt)
}
)
)
Expand Down
3 changes: 3 additions & 0 deletions src/main/claude/claude-structured-session-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { claudeStoppedRequestEndWait } from './claude-request-end-wait'
import {
drainClaudeObservedExits,
observeClaudeSessionExit,
reportClaudeExitAfterClose,
settleClaudeUnexpectedExit,
type ClaudeExitLifecycle
} from './claude-structured-session-exit-lifecycle'
Expand Down Expand Up @@ -95,6 +96,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
emit: (session, _events, event) => this.emit(session, event),
handleExit: (sessionId, attempt, error) =>
observeClaudeSessionExit(this.exitLifecycle, sessionId, attempt, error),
handleExitAfterClose: (sessionId, attempt) =>
reportClaudeExitAfterClose(this.exitLifecycle, sessionId, attempt),
settleExit: (sessionId, exit) =>
settleClaudeUnexpectedExit(this.exitLifecycle, sessionId, exit)
}
Expand Down
21 changes: 20 additions & 1 deletion src/main/claude/claude-structured-session-exit-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export type ClaudeExitLifecycle = {
exits: Map<string, ClaudeSessionExit>
/** A settled exit's diagnostic, kept for a send admitted before the host heard of the exit. */
settledExitErrors: Map<string, Error>
deps: Pick<ClaudeStructuredSessionAdapterDeps, 'persistHandle' | 'now'>
deps: Pick<ClaudeStructuredSessionAdapterDeps, 'persistHandle' | 'now' | 'onEvent'>
emit: (session: ClaudeSession, event: ClaudeStructuredSessionEvent) => void
}

Expand Down Expand Up @@ -62,6 +62,25 @@ export function observeClaudeSessionExit(
.catch(() => undefined)
}

/** A close that gave up on this child has since seen its root exit. The host's retry of that close
* settles it; this only tells the host the retry can land now. */
export function reportClaudeExitAfterClose(
lifecycle: ClaudeExitLifecycle,
sessionId: string,
attempt: ClaudeAcquisitionAttempt
): void {
const session = lifecycle.sessions.get(sessionId)
if (!session || session.connection !== attempt.connection) {
return
}
lifecycle.deps.onEvent?.({
type: 'exitAfterClose',
sessionId,
fence: session.fence,
acquisitionGeneration: session.acquisitionGeneration
})
}

/** Lifecycle recovery is published only after the close ladder ran and proved the tree gone or
* observed the root's own exit. */
export function settleClaudeUnexpectedExit(
Expand Down
8 changes: 7 additions & 1 deletion src/main/claude/claude-structured-session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ 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 } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type {
StructuredAgentSessionExitAfterCloseEvent,
StructuredAgentSessionStartedEvent
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type {
ClaudeStreamJsonConnection,
openClaudeStreamJsonConnection
Expand Down Expand Up @@ -66,6 +69,7 @@ export type ClaudeStructuredSessionEvent =
| { type: 'auth-diagnostic'; sessionId: string; diagnostic: ClaudeAuthDiagnostic }
/** Startup facts applied and saved options restored; held prompts are about to be written. */
| StructuredAgentSessionStartedEvent
| StructuredAgentSessionExitAfterCloseEvent
| {
type: 'ended'
sessionId: string
Expand Down Expand Up @@ -345,5 +349,7 @@ export type ClaudeAcquireCallbacks = {
event: ClaudeStructuredSessionEvent
) => void
handleExit: (sessionId: string, attempt: ClaudeAcquisitionAttempt, error: Error) => void
/** The child's root exited after a close of it gave up proving that. */
handleExitAfterClose: (sessionId: string, attempt: ClaudeAcquisitionAttempt) => void
settleExit: (sessionId: string, exit: ClaudeSessionExit) => Promise<void>
}
2 changes: 2 additions & 0 deletions src/main/codex/codex-app-server-connection-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export type CodexAppServerConnectionHandlers = {
onServerRequest?: (request: CodexAppServerServerRequest) => void
onUnhandledFrame?: (kind: string, payload: unknown) => void
onExit?: (error: Error) => void
/** The child exited after a close gave up proving it; no close is running to see that. */
onExitAfterClose?: () => void
/** Awaited once the child has a pid and before the handshake; a rejection reaps the child. */
onSpawned?: (pid: number) => Promise<void>
}
Expand Down
38 changes: 38 additions & 0 deletions src/main/codex/codex-app-server-connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,44 @@ describe('openCodexAppServerConnection', () => {
await expect(connection.close()).resolves.toBe(true)
})

it('reports an exit seen after a close gave up, and not one a running or proven close sees', async () => {
vi.useFakeTimers()
const late = stubChild({ exitOnStdinEnd: false })
answerInitialize(late.child)
const gaveUp: string[] = []
const lateConnection = await openCodexAppServerConnection(
{ command: 'codex', args: ['app-server'] },
{
onExit: () => gaveUp.push('exit'),
onExitAfterClose: () => gaveUp.push('exit-after-close')
},
late.spawnImpl
)
const first = lateConnection.close()
await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS + 3_500)
await expect(first).resolves.toBe(false)
late.child.emit('exit', 0, null)
expect(gaveUp).toEqual(['exit-after-close'])

const running = stubChild({ exitOnStdinEnd: false })
answerInitialize(running.child)
const seen: string[] = []
const runningConnection = await openCodexAppServerConnection(
{ command: 'codex', args: ['app-server'] },
{
onExit: () => seen.push('exit'),
onExitAfterClose: () => seen.push('exit-after-close')
},
running.spawnImpl
)
const closing = runningConnection.close()
running.child.emit('exit', 0, null)
await expect(closing).resolves.toBe(true)
// Stdio 'close' can land after the proof settled; the close still proved the exit.
running.child.emit('close', 0, null)
expect(seen).toEqual([])
})

it.each([1_090_188, 2_900_090])(
'accepts a realistic %i-byte escaped command completion and keeps processing',
async (frameBytes) => {
Expand Down
3 changes: 3 additions & 0 deletions src/main/codex/codex-app-server-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ export async function openCodexAppServerConnection(
if (exitObserved && !closing && !exitReported) {
exitReported = true
handlers.onExit?.(terminalError)
} else if (exitObserved && closing && !exitProof.holdsAttempt && !exitReported) {
exitReported = true
handlers.onExitAfterClose?.()
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/main/codex/codex-structured-journal-translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ export function createCodexJournalTranslator(
})
},
handle: (event) => {
// Evidence for the host's retry of a close, never routed here: nothing for the journal.
if (event.type === 'exitAfterClose') {
return CODEX_JOURNAL_ADMITTED
}
if (event.type === 'ended') {
const streamAdmission = flushStreams()
if (!streamAdmission.accepted) {
Expand Down
12 changes: 10 additions & 2 deletions src/main/codex/codex-structured-session-acquire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import { buildCodexStructuredChildEnvironment } from './codex-structured-child-e
import { openCodexThread } from './codex-structured-thread-open'
import {
closeCodexPublishedSession,
handleCodexSessionExit
handleCodexSessionExit,
reportCodexExitAfterClose
} from './codex-structured-session-close'
import { restoredCodexSessionOptions } from './codex-structured-session-options'
import {
Expand Down Expand Up @@ -176,7 +177,14 @@ export async function acquireCodexStructuredSession(input: {
} finally {
notificationRetries.clear(sessionId, acquisition.connection)
}
}
},
onExitAfterClose: () =>
reportCodexExitAfterClose({
sessions,
sessionId,
connection: acquisition.connection,
...(deps.onEvent ? { onEvent: deps.onEvent } : {})
})
}
)
acquisition.connection = connection
Expand Down
20 changes: 20 additions & 0 deletions src/main/codex/codex-structured-session-close.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,26 @@ export function handleCodexSessionExit(input: {
return true
}

/** A close that gave up on this child has since seen it exit. The host's retry of that close
* settles it; this only tells the host the retry can land now. */
export function reportCodexExitAfterClose(input: {
sessions: Map<string, CodexSession>
sessionId: string
connection: CodexAppServerConnection | null
onEvent?: (event: CodexStructuredSessionEvent) => void
}): void {
const session = input.sessions.get(input.sessionId)
if (!session || session.connection !== input.connection || session.ended) {
return
}
input.onEvent?.({
type: 'exitAfterClose',
sessionId: input.sessionId,
fence: session.fence,
acquisitionGeneration: session.acquisitionGeneration
})
}

export async function closeCodexPublishedSession(
sessions: Map<string, CodexSession>,
sessionId: string,
Expand Down
6 changes: 5 additions & 1 deletion src/main/codex/codex-structured-session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo'
import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence'
import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import type { CodexJournalTranslator } from './codex-structured-journal-translation'
import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type {
StructuredAgentSessionEndedEvent,
StructuredAgentSessionExitAfterCloseEvent
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy'
import type {
AgentModelCatalogSessionAccess,
Expand Down Expand Up @@ -67,6 +70,7 @@ export type CodexStructuredSessionEvent =
promptKey: string
}
| StructuredAgentSessionEndedEvent
| StructuredAgentSessionExitAfterCloseEvent
/** Translator-only compatibility for callers that do not participate in host recovery. */
| { type: 'ended'; sessionId: string; reason: string; observedAt?: number }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,9 +212,19 @@ export type StructuredAgentSessionStartedEvent = {
restoreSkippedOptions: readonly string[]
}

/** A close that gave up proving this child's exit has since seen its root exit. Evidence to retry
* the stop owed for this child, not a verdict: that stop's own proof decides. */
export type StructuredAgentSessionExitAfterCloseEvent = {
type: 'exitAfterClose'
sessionId: string
fence: number
acquisitionGeneration: string
}

export type StructuredAgentSessionLifecycleEvent =
| StructuredAgentSessionEndedEvent
| StructuredAgentSessionStartedEvent
| StructuredAgentSessionExitAfterCloseEvent

/** Whether the provider child behind an acquisition has proven its start. A publish-first
* acquire hands over a `starting` child and the `started` lifecycle event flips it. */
Expand Down
Loading
Loading