From 0610f76ed4e57a47c3b8ca0221181105fe144e28 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:20:29 -0700 Subject: [PATCH 01/67] fix(claude): open structured chat without a startup deadline, and make Retry start fresh Publish the Claude session as soon as its process is spawned instead of racing initialize against a fixed 10s deadline. Prompts sent before startup lands are held and written in order once it does. An exit or sign-in failure before startup ends the session with the reason and the CLI's stderr. A create that failed because the process provably exited now carries ownerVerdict 'exited', so the client marks the launch failed and Retry mints a new operation instead of replaying the stored failure. --- .../claude-agent-sdk-control-requests.ts | 38 ++-- .../claude-stream-json-connection.test.ts | 14 +- ...claude-structured-dispatch-test-support.ts | 4 +- src/main/claude/claude-structured-dispatch.ts | 36 +++- ...claude-structured-effort-reporting.test.ts | 4 +- .../claude/claude-structured-init-deadline.ts | 68 ------- ...laude-structured-launch-resolution.test.ts | 35 +++- .../claude-structured-launch-resolution.ts | 32 +++- ...aude-structured-model-confirmation.test.ts | 4 +- .../claude-structured-model-preflight.test.ts | 4 +- ...ude-structured-option-confirmation.test.ts | 4 +- .../claude/claude-structured-options.test.ts | 4 +- src/main/claude/claude-structured-options.ts | 15 ++ .../claude-structured-prompt-ownership.ts | 8 +- .../claude/claude-structured-real-cli.test.ts | 33 ++-- ...ed-session-acquisition-processless.test.ts | 3 +- .../claude-structured-session-acquisition.ts | 173 +++++++---------- .../claude-structured-session-adapter.test.ts | 103 +++++----- .../claude-structured-session-adapter.ts | 27 ++- .../claude/claude-structured-session-close.ts | 10 +- ...aude-structured-session-journal-control.ts | 6 +- .../claude-structured-session-options.ts | 12 +- .../claude-structured-session-publication.ts | 46 ++--- ...structured-session-reading-control.test.ts | 22 ++- ...claude-structured-session-recovery.test.ts | 1 + .../claude-structured-session-startup-gate.ts | 145 ++++++++++++++ .../claude-structured-session-startup.test.ts | 160 ++++++++++++++++ .../claude-structured-session-startup.ts | 179 ++++++++++++++++++ .../claude/claude-structured-session-state.ts | 6 +- .../claude-structured-session-test-support.ts | 28 ++- .../structured-agent-session-adapter.ts | 2 + .../structured-agent-session-attach-flow.ts | 11 +- ...gent-session-dead-generation-settlement.ts | 28 ++- ...ession-failed-create-owner-verdict.test.ts | 116 ++++++++++++ ...red-agent-session-failed-create-refusal.ts | 18 ++ ...agent-session-startup-failure-exit.test.ts | 104 ++++++++++ ...tructured-agent-session-unexpected-exit.ts | 20 +- ...gent-session-reservation-admission.test.ts | 45 +++++ .../agent-session-reservation-admission.ts | 11 +- ...ude-structured-session-integration.test.ts | 26 ++- .../structured-claude-runtime-adapter.ts | 3 +- .../native-chat/NativeChatLaunchRetry.tsx | 7 +- ...tructuredSession.launch-lifecycle.test.tsx | 16 ++ ...tiveChatStructuredSession.test-harness.tsx | 5 +- .../NativeChatStructuredSession.tsx | 1 + .../use-native-chat-provisional-launch.ts | 3 + .../lib/launch-structured-agent-session.ts | 5 +- ...-agent-session-launch-exited-owner.test.ts | 153 +++++++++++++++ ...tructured-agent-session-launch-registry.ts | 25 +++ .../lib/structured-agent-session-launch.ts | 3 + .../agent-session-lease-adjudication.ts | 14 ++ src/shared/agent-session-wire-refusals.ts | 6 + 52 files changed, 1482 insertions(+), 364 deletions(-) delete mode 100644 src/main/claude/claude-structured-init-deadline.ts create mode 100644 src/main/claude/claude-structured-session-startup-gate.ts create mode 100644 src/main/claude/claude-structured-session-startup.test.ts create mode 100644 src/main/claude/claude-structured-session-startup.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch-exited-owner.test.ts diff --git a/src/main/claude/claude-agent-sdk-control-requests.ts b/src/main/claude/claude-agent-sdk-control-requests.ts index 71498f28421b..8ceb7a683814 100644 --- a/src/main/claude/claude-agent-sdk-control-requests.ts +++ b/src/main/claude/claude-agent-sdk-control-requests.ts @@ -46,33 +46,34 @@ export type ClaudeControlOptions = { timeoutMs?: number } /** * Run one native Query control method under Orca's deadline and error classification. * - * The SDK owns correlation but applies no deadline, so the timeout stays here — and its - * message is load-bearing: the init proof matches on `claude initialize request timed out`. + * The SDK owns correlation but applies no deadline, so the timeout stays here. `null` means + * none: the request then settles only on the CLI's answer or on the query closing under it. * A closed query is a transport failure, not the CLI rejecting the request, so only the * latter is re-thrown as a `ClaudeControlRequestError` a caller may surface as a rejection. */ export function runClaudeControl( subtype: string, run: () => Promise, - timeoutMs: number = CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS + timeoutMs: number | null = CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS ): Promise { let timer: ReturnType | null = null + const request = Promise.resolve() + .then(run) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + if (error instanceof ClaudeControlRequestError || message === QUERY_CLOSED_MESSAGE) { + throw error + } + throw new ClaudeControlRequestError(subtype, message) + }) + if (timeoutMs === null) { + return request + } const deadline = new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error(`claude ${subtype} request timed out`)), timeoutMs) timer.unref?.() }) - return Promise.race([ - Promise.resolve() - .then(run) - .catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error) - if (error instanceof ClaudeControlRequestError || message === QUERY_CLOSED_MESSAGE) { - throw error - } - throw new ClaudeControlRequestError(subtype, message) - }), - deadline - ]).finally(() => { + return Promise.race([request, deadline]).finally(() => { if (timer) { clearTimeout(timer) } @@ -93,7 +94,8 @@ export type ClaudeControlSurface = { ) => Promise stopTask: (taskId: string, options?: ClaudeControlOptions) => Promise supportedModels: (options?: ClaudeControlOptions) => Promise - initializationResult: (options?: ClaudeControlOptions) => Promise + /** Untimed: a slow start is still a start, and the child's exit closes the query under it. */ + initializationResult: () => Promise getSettings: (options?: ClaudeControlOptions) => Promise } @@ -142,8 +144,8 @@ export function createClaudeControlSurface(query: Query): ClaudeControlSurface { ), supportedModels: (options) => runClaudeControl('list_models', () => query.supportedModels(), options?.timeoutMs), - initializationResult: (options) => - runClaudeControl('initialize', () => query.initializationResult(), options?.timeoutMs), + initializationResult: () => + runClaudeControl('initialize', () => query.initializationResult(), null), getSettings: (options) => { const read = claudeQuerySettingsReader(query) return read diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index eb69a66a897d..15c4ac201834 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -501,10 +501,12 @@ describe('Claude stream-json connection', () => { } }) const connection = await open(launchFor(scenario)) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture supplies every session member the option paths under test read. const session = { connection, options: new Map(), - reportedOptions: {} + reportedOptions: {}, + startup: { state: 'proven' } } as unknown as ClaudeSession const options = await readClaudeStructuredSessionOptions(session, 5_000) @@ -553,17 +555,15 @@ describe('Claude stream-json connection', () => { expect(JSON.stringify(diagnostic)).not.toContain('secret') }) - it('reports an unauthenticated start through the init deadline instead of hanging', async () => { - // The scripted CLI never answers, which is the shape of a silently unauthenticated CLI. - const scenario = scriptScenario([HOLD_OPEN]) + it('settles an unanswered initialize when the child exits, with no timer of its own', async () => { + // The scripted CLI never answers, then leaves: only its exit can settle the request. + const scenario = scriptScenario([{ stderr: 'claude: not signed in\n' }, { exit: 1 }]) const connection = await open({ ...launchFor(scenario), env: { ...launchFor(scenario).env, ORCA_SDK_CONTRACT_IGNORE_CONTROL_REQUESTS: '1' } }) - await expect(connection.initializationResult({ timeoutMs: 200 })).rejects.toThrow( - 'claude initialize request timed out' - ) + await expect(connection.initializationResult()).rejects.toThrow() }) it('reports a self-exit with its status, stderr, and observed tree verdict', async () => { diff --git a/src/main/claude/claude-structured-dispatch-test-support.ts b/src/main/claude/claude-structured-dispatch-test-support.ts index 83971a38d132..854d4be96203 100644 --- a/src/main/claude/claude-structured-dispatch-test-support.ts +++ b/src/main/claude/claude-structured-dispatch-test-support.ts @@ -4,6 +4,7 @@ import { retireClaudeDispatchWaiters } from './claude-structured-dispatch' import type { ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' +import { createClaudeSessionStartupGate } from './claude-structured-session-startup-gate' export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): ClaudeSession { return { @@ -28,7 +29,8 @@ export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): C restoreSkippedOptions: new Set(), capabilities: [], events: undefined, - translator: null + translator: null, + startup: { ...createClaudeSessionStartupGate(), state: 'proven' } } } diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 13d29ae9258f..05c152261912 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -33,6 +33,12 @@ import { claudeUserMessageWasProvablyUnwritten } from './claude-agent-sdk-user-message-queue' import { AgentSessionPreDispatchError } from '../native-chat/agent-session-wire/structured-agent-session-operation-settlement' +import { + claudeStartupFailureReason, + claudeStartupHoldsWrites, + failClaudeStartupGate, + holdClaudeStartupWrite +} from './claude-structured-session-startup-gate' const MAX_ACTIVE_DISPATCH_WAITERS = 64 @@ -220,6 +226,7 @@ export function settleCancelledClaudeDispatchWaiters( * Retired rather than dropped: their identities stay joinable, bounded by * `MAX_RETIRED_DISPATCH_WAITERS`. */ export function retireClaudeDispatchWaiters(session: ClaudeSession): void { + failClaudeStartupGate(session, new Error('claude stream-json ended before startup completed')) for (const waiter of session.dispatchWaiters.splice(0)) { retireWaiter(session, waiter) waiter.resolve(null) @@ -229,7 +236,8 @@ export function retireClaudeDispatchWaiters(session: ClaudeSession): void { export async function dispatchClaudeTurn( session: ClaudeSession, input: { clientMessageId?: string; body: AgentJournalMessageItem; requestedAt?: number }, - beforeDispatch?: () => Promise + beforeDispatch?: () => Promise, + onSettledLate?: ClaudeLateDispatchSettlement ): Promise { let content: unknown[] try { @@ -240,6 +248,10 @@ export async function dispatchClaudeTurn( if (session.dispatchWaiters.length >= MAX_ACTIVE_DISPATCH_WAITERS) { return { state: 'rejected', reason: DISPATCH_REJECTED_QUEUE_FULL } } + const startupFailure = claudeStartupFailureReason(session) + if (startupFailure) { + return { state: 'rejected', reason: startupFailure } + } // Read the sent content, not the journal blocks: only the mapped trailing prompt decides // whether Claude runs a command, so the two cannot disagree about which frame settles this. const acceptsResult = claudeDispatchInvokesSlashCommand(content) @@ -255,6 +267,21 @@ export async function dispatchClaudeTurn( input.requestedAt ?? null ) } + const message = { + type: 'user', + uuid: sentUuid, + message: { role: 'user', content }, + parent_tool_use_id: null, + session_id: session.providerSessionId + } + if (claudeStartupHoldsWrites(session)) { + return holdClaudeStartupWrite(session, { + message, + arm, + ...(beforeDispatch ? { beforeDispatch } : {}), + ...(onSettledLate ? { settleLate: onSettledLate } : {}) + }) + } const pending = { replay: beforeDispatch ? undefined : arm() } const authorize = beforeDispatch ? async () => { @@ -266,13 +293,6 @@ export async function dispatchClaudeTurn( } : undefined try { - const message = { - type: 'user', - uuid: sentUuid, - message: { role: 'user', content }, - parent_tool_use_id: null, - session_id: session.providerSessionId - } await (authorize ? session.connection.send(message, authorize) : session.connection.send(message)) diff --git a/src/main/claude/claude-structured-effort-reporting.test.ts b/src/main/claude/claude-structured-effort-reporting.test.ts index be022d869568..7cf59ee555bc 100644 --- a/src/main/claude/claude-structured-effort-reporting.test.ts +++ b/src/main/claude/claude-structured-effort-reporting.test.ts @@ -22,6 +22,7 @@ function sessionWith( listed?: { model: string; catalog: readonly Record[] } ) { return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture supplies every session member the option paths under test read. session: { options: new Map(listed ? [['model', listed.model]] : []), reportedOptions: {} as { model?: string; effort?: string }, @@ -47,7 +48,8 @@ function sessionWith( ? { applied: {}, effective: {}, sources: {} } : { applied: { effort: reported }, effective: { effortLevel: reported }, sources: {} } } - } + }, + startup: { state: 'proven' } } as unknown as ClaudeSession, calls } diff --git a/src/main/claude/claude-structured-init-deadline.ts b/src/main/claude/claude-structured-init-deadline.ts deleted file mode 100644 index f3acd6c3af98..000000000000 --- a/src/main/claude/claude-structured-init-deadline.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { ClaudeInitObservation } from './claude-structured-init-proof' -import { claudeInitializationAuthError } from './claude-structured-init-proof' -import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' -import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' - -export type ClaudeInitDeadline = { - promise: Promise - resolve: (init: ClaudeInitObservation) => void - reject: (error: Error) => void - start: () => void - clear: () => void -} - -export function claudeInitTimeoutError( - sessionId: string, - timeoutMs: number -): AgentSessionAcquisitionRefusal { - return new AgentSessionAcquisitionRefusal( - `Claude did not finish starting session ${sessionId} within ${Math.ceil(timeoutMs / 1000)} seconds. Verify the selected Claude account is signed in and CLAUDE_CONFIG_DIR contains valid credentials, then retry; no SessionStart or system/init proof arrived.` - ) -} - -export async function requestClaudeInitialization( - connection: ClaudeStreamJsonConnection, - sessionId: string, - timeoutMs: number -): Promise { - try { - const result = await connection.initializationResult({ timeoutMs }) - const authError = claudeInitializationAuthError(result) - if (authError) { - throw authError - } - return result - } catch (error) { - if (error instanceof Error && error.message === 'claude initialize request timed out') { - throw claudeInitTimeoutError(sessionId, timeoutMs) - } - throw error - } -} - -export function createClaudeInitDeadline(sessionId: string, timeoutMs: number): ClaudeInitDeadline { - let resolve = (_init: ClaudeInitObservation): void => {} - let reject = (_error: Error): void => {} - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise - reject = rejectPromise - }) - void promise.catch(() => {}) - let timer: ReturnType | null = null - - return { - promise, - resolve, - reject, - start: () => { - timer = setTimeout(() => reject(claudeInitTimeoutError(sessionId, timeoutMs)), timeoutMs) - timer.unref?.() - }, - clear: () => { - if (timer) { - clearTimeout(timer) - timer = null - } - } - } -} diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 0ffa70d28622..fce3fd976426 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -1,7 +1,7 @@ import { chmodSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' @@ -58,7 +58,8 @@ function resolverFor( resolveEnv?: () => Record, stripAuthEnv = false, // Manual by default so a test that is not about permissions is not silently about them. - agentDefaultArgs: Record = { claude: '' } + agentDefaultArgs: Record = { claude: '' }, + hasTranscript: () => Promise = async () => true ) { return createClaudeStructuredLaunchResolver({ store: { getRecord: () => value } as unknown as AgentSessionRecordStore, @@ -66,6 +67,7 @@ function resolverFor( resolveCommand: () => '/usr/local/bin/claude', resolveAuthPolicy: () => ({ stripAuthEnv }), resolvePermissionMode: () => claudeStructuredPermissionModeForSettings({ agentDefaultArgs }), + hasTranscript, ...(resolveEnv ? { resolveEnv } : {}) }) } @@ -203,6 +205,35 @@ describe('claude structured launch resolution', () => { expect(launch.options.resumeSessionAt).toBeUndefined() }) + it('launches a leafless head fresh under its own id when Claude never wrote its transcript', async () => { + // A start that failed before its first turn: `--resume` would exit "No conversation found". + const hasTranscript = vi.fn(async () => false) + const launch = await resolverFor( + record({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only each link's handle. + providerHandleChain: [ + { handle: { provider: 'claude', sessionId: 'provider-current', leafUuid: null } } + ] as AgentSessionRecord['providerHandleChain'] + }), + undefined, + false, + { claude: '' }, + hasTranscript + )({ identity: identityAt(null) }) + + expect(hasTranscript).toHaveBeenCalledWith({ + providerSessionId: 'provider-current', + claudeConfigDir: expect.any(String) + }) + expect(launch.options.resume).toBeUndefined() + expect(launch.options.sessionId).toBe('provider-current') + expect(launch).toMatchObject({ + providerSessionId: 'provider-current', + resumeLeafUuid: null, + resumed: false + }) + }) + // Agent Permissions is stored as the bypass flag inside the launch arguments, so presence of // that flag — not the whole string — is what Yolo means, exactly as a terminal launch reads it. it.each([ diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 2e1544ff9764..c0b110652cfe 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto' +import { join } from 'node:path' import type { Options as ClaudeAgentSdkOptions, PermissionMode @@ -25,6 +26,7 @@ import { type ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { resolveClaudeCommand } from '../codex-cli/command' +import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const @@ -119,6 +121,21 @@ export type ClaudeStructuredLaunchResolverDeps = { authSwitchSettleTimeoutMs?: number /** Account state for the managed-account gate; null when it cannot be read, which refuses. */ readManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null + /** Whether Claude wrote a transcript for this id; defaults to the transcript resolver. */ + hasTranscript?: (input: { + providerSessionId: string + claudeConfigDir: string + }) => Promise +} + +async function claudeTranscriptExists(input: { + providerSessionId: string + claudeConfigDir: string +}): Promise { + const path = await resolveSessionFilePath('claude', input.providerSessionId, { + claudeProjectsDir: join(input.claudeConfigDir, 'projects') + }) + return path !== null } /** @@ -192,6 +209,15 @@ export function createClaudeStructuredLaunchResolver( head?.handle.provider === 'claude' ? head.handle.sessionId : claudeSessionIdForOrcaSession(identity.sessionId) + // A start that failed before its first turn wrote no transcript, and `--resume` of an absent + // one exits; launch that id fresh instead. With a transcript, `--session-id` would collide. + const resumed = + head?.handle.provider === 'claude' && + (head.handle.leafUuid !== null || + (await (deps.hasTranscript ?? claudeTranscriptExists)({ + providerSessionId, + claudeConfigDir: record.accountHome.path + }))) // `record.launchArgs` is deliberately not read: the configured CLI arguments are a terminal // concern, and the permission mode they used to smuggle in is an owned provider option now. const permission = claudeStructuredPermissionOptions( @@ -238,7 +264,7 @@ export function createClaudeStructuredLaunchResolver( ...CLAUDE_STRUCTURED_BASE_OPTIONS, ...permission, extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, ...permission.extraArgs }, - ...(head?.handle.provider === 'claude' + ...(resumed && head?.handle.provider === 'claude' ? { resume: providerSessionId, ...(head.handle.leafUuid === null ? {} : { resumeSessionAt: head.handle.leafUuid }) @@ -249,8 +275,8 @@ export function createClaudeStructuredLaunchResolver( env, claudeConfigDir: record.accountHome.path, providerSessionId, - resumeLeafUuid: head?.handle.provider === 'claude' ? head.handle.leafUuid : null, - resumed: head?.handle.provider === 'claude' + resumeLeafUuid: resumed && head?.handle.provider === 'claude' ? head.handle.leafUuid : null, + resumed } } } diff --git a/src/main/claude/claude-structured-model-confirmation.test.ts b/src/main/claude/claude-structured-model-confirmation.test.ts index 7bd8f6291194..96d7e9daa391 100644 --- a/src/main/claude/claude-structured-model-confirmation.test.ts +++ b/src/main/claude/claude-structured-model-confirmation.test.ts @@ -168,6 +168,7 @@ describe('Claude effort the settings readback cannot report', () => { calls: string[] = [] ): { session: ClaudeSession; calls: string[] } { return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture supplies every session member the option paths under test read. session: { options: new Map([['model', 'sonnet']]), reportedOptions: {}, @@ -189,7 +190,8 @@ describe('Claude effort the settings readback cannot report', () => { sources: {} } } - } + }, + startup: { state: 'proven' } } as unknown as ClaudeSession, calls } diff --git a/src/main/claude/claude-structured-model-preflight.test.ts b/src/main/claude/claude-structured-model-preflight.test.ts index e4f5d00b1964..930947e112db 100644 --- a/src/main/claude/claude-structured-model-preflight.test.ts +++ b/src/main/claude/claude-structured-model-preflight.test.ts @@ -18,6 +18,7 @@ const HAIKU = { function sessionWith(catalog: readonly Record[] | 'unavailable') { const calls: string[] = [] return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture supplies every session member the option paths under test read. session: { options: new Map(), reportedOptions: {} as { model?: string; effort?: string }, @@ -36,7 +37,8 @@ function sessionWith(catalog: readonly Record[] | 'unavailable' setModel: async (model: string) => { calls.push(`set_model:${model}`) } - } + }, + startup: { state: 'proven' } } as unknown as ClaudeSession, calls } diff --git a/src/main/claude/claude-structured-option-confirmation.test.ts b/src/main/claude/claude-structured-option-confirmation.test.ts index ca7b8b70f1c8..d5ac5fd4d2dd 100644 --- a/src/main/claude/claude-structured-option-confirmation.test.ts +++ b/src/main/claude/claude-structured-option-confirmation.test.ts @@ -162,6 +162,7 @@ describe('confirmation never outlives the write it belongs to', () => { it('drops an earlier effort confirmation when the value changes', async () => { const calls: string[] = [] let reported = 'low' + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture supplies every session member the option paths under test read. const session = { options: new Map([['model', 'sonnet']]), reportedOptions: {}, @@ -177,7 +178,8 @@ describe('confirmation never outlives the write it belongs to', () => { effective: { effortLevel: reported }, sources: {} }) - } + }, + startup: { state: 'proven' } } as unknown as ClaudeSession await setClaudeStructuredOption(session, { key: 'effort', value: 'low' }, undefined) diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index 9b962fc33d71..6b994c8bb2cf 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -6,6 +6,7 @@ import { import type { ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' +import { createClaudeSessionStartupGate } from './claude-structured-session-startup-gate' import { observeClaudeFastModeFacts, readClaudeStructuredSessionOptions @@ -39,7 +40,8 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe restoreSkippedOptions: new Set(), capabilities: [], events: undefined, - translator: null + translator: null, + startup: { ...createClaudeSessionStartupGate(), state: 'proven' } } } diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index c85e233cde12..0678ed97a63f 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -37,6 +37,21 @@ export function restoredClaudeStructuredSessionOptions( ) } +/** A client's write; the startup restore writes through `setClaudeStructuredOption` directly. */ +export function setClaudeStructuredSessionOption( + session: ClaudeSession, + input: { key: string; value: string }, + timeoutMs: number | undefined +): Promise>> { + // Each write is a control request the CLI answers only after initialize. + if (session.startup.state !== 'proven') { + return Promise.reject( + new AgentSessionOptionRejectedError('Claude is still starting. Try again in a moment.') + ) + } + return setClaudeStructuredOption(session, input, timeoutMs) +} + export async function setClaudeStructuredOption( session: ClaudeSession, input: { key: string; value: string }, diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index 399dd98506ab..666281a6b1b4 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -11,8 +11,10 @@ import { } from './claude-structured-control-actions' import type { ClaudeLateDispatchSettlement } from './claude-structured-dispatch' import type { ClaudeSession } from './claude-structured-session-state' +import { rejectClaudeStartupWrites } from './claude-structured-session-startup-gate' +import { DISPATCH_REJECTED_CANCELLED } from '../../shared/structured-agent-session-dispatch-rejection' -/** Conservative user-facing window: below the 10s init and 30s control deadlines, trading +/** Conservative user-facing window: below the 30s control deadline, trading * residual slow-pump risk for ensuring delivery bookkeeping cannot block Stop indefinitely. */ export const CLAUDE_DISPATCH_ADMISSION_TIMEOUT_MS = 3_000 const CLAUDE_DISPATCH_ADMISSION_POLL_MS = 50 @@ -98,6 +100,10 @@ export async function cancelClaudeStructuredTurn(input: { const session = requireSession(sessions, request.sessionId) const acquisitionGeneration = session.acquisitionGeneration const prompt = request.prompt + // Nothing held before startup was written, so Stop withdraws it without an interrupt. + if (!prompt && session.startup.state === 'pending' && session.fence === request.fence) { + return { cancelled: rejectClaudeStartupWrites(session, DISPATCH_REJECTED_CANCELLED) } + } if (prompt && session.fence !== request.fence) { return { cancelled: false } } diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index cb3bb2b72ead..1b39237af04c 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -51,7 +51,7 @@ function realAdapter( events: ClaudeStructuredSessionEvent[] = [], cwd = process.cwd() ): ClaudeStructuredSessionAdapter { - return new ClaudeStructuredSessionAdapter({ + const adapter = new ClaudeStructuredSessionAdapter({ resolveLaunch: async () => ({ pathToClaudeCodeExecutable: command, options: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, sessionId: providerSessionId }, @@ -63,9 +63,16 @@ function realAdapter( }), onEvent: (event) => events.push(event), readProcessStartTime: async () => 1, - now: () => 2, - initTimeoutMs: 5_000 + now: () => 2 }) + // These proofs read startup facts, which land after the session is published. + const acquire = adapter.acquire + adapter.acquire = async (input) => { + const acquisition = await acquire(input) + await adapter.drainStartup(input.identity.sessionId) + return acquisition + } + return adapter } function identity(providerSessionId: string): AgentSessionJournalIdentity { @@ -297,16 +304,20 @@ describe.skipIf(!realClaudeAvailable)('Claude structured real CLI handshake', () it('turns a real silent unauthenticated startup into sign-in guidance', async () => { const claudeConfigDir = await mkdtemp(join(tmpdir(), 'orca-claude-no-auth-')) const providerSessionId = randomUUID() - const adapter = realAdapter(providerSessionId, claudeConfigDir) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = realAdapter(providerSessionId, claudeConfigDir, events) try { - await expect( - adapter.acquire({ - identity: identity(providerSessionId), - fence: 1, - spawnToken: 'real-cli-no-auth' - }) - ).rejects.toThrow(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s) + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-no-auth' + }) + await adapter.drainObservedExits() + expect(events.find((event) => event.type === 'ended')).toMatchObject({ + reason: expect.stringMatching(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s), + startupUnproven: true + }) } finally { await adapter.closeAll() await rm(claudeConfigDir, { recursive: true, force: true }) diff --git a/src/main/claude/claude-structured-session-acquisition-processless.test.ts b/src/main/claude/claude-structured-session-acquisition-processless.test.ts index 2c83dce18750..89b605fe9662 100644 --- a/src/main/claude/claude-structured-session-acquisition-processless.test.ts +++ b/src/main/claude/claude-structured-session-acquisition-processless.test.ts @@ -24,12 +24,13 @@ describe('Claude structured processless acquisition', () => { _launch, handlers = {} ) => { + // A failed spawn reports its error on a later tick, as child_process does. + setTimeout(() => handlers.onFault?.(fault), 0) const connection: ClaudeStreamJsonConnection = { pid: undefined, closed: true, exitVerdict: { root: 'processless', tree: 'exited' }, initializationResult: async () => { - handlers.onFault?.(fault) throw fault }, getSettings: async () => ({}), diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index a10a2dca671c..734d8f6ab5c6 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -9,31 +9,19 @@ import { isClaudeAuthSwitchInProgress } from '../claude-accounts/live-pty-gate' import { openClaudeStreamJsonConnection } from './claude-stream-json-connection' import { buildClaudePermissionCallbacks } from './claude-structured-inbound-control' import { resolveClaudeReplayTurn } from './claude-structured-dispatch' -import { - claudeAuthDiagnostic, - readClaudeCapabilities, - readClaudeFrameString, - readClaudeInit, - readClaudeModels -} from './claude-structured-init-proof' -import { - createClaudeInitDeadline, - requestClaudeInitialization -} from './claude-structured-init-deadline' +import { readClaudeFrameString, readClaudeInit } from './claude-structured-init-proof' import { claudeConfigDirEnvPatch } from './claude-config-dir-pin' import { CLAUDE_SPAWN_TOKEN_ENV, claudeProcessIdentity } from './claude-structured-owner-identity' -import { restoreClaudeStructuredSessionOptions } from './claude-structured-options' import { ClaudePromptRegistry } from './claude-structured-prompt-replies' +import { restoredClaudeStructuredSessionOptions } from './claude-structured-options' import { createClaudeSessionJournalTranslator } from './claude-structured-journal-translation' +import { observeClaudeFastModeFacts } from './claude-structured-session-options' import { - observeClaudeFastModeFacts, - readClaudeSettingsEffort -} from './claude-structured-session-options' -import { - claudeStructuredSessionPublicationOptions, - prepareClaudeStructuredSessionAcquisitionOptions, - readClaudeStructuredSessionSettings -} from './claude-structured-session-acquisition-options' + createClaudeInitProof, + readClaudeStartupFacts, + settleClaudeSessionStartup, + type ClaudeStartupFacts +} from './claude-structured-session-startup' import { createClaudeSessionPublication } from './claude-structured-session-publication' import { mintClaudeAcquisitionGeneration, @@ -52,8 +40,6 @@ import { createClaudeJournalFailureHandler } from './claude-structured-session-journal-control' -export const CLAUDE_STRUCTURED_INIT_TIMEOUT_MS = 10_000 - export async function acquireClaudeSession({ input, deps, @@ -83,13 +69,12 @@ export async function acquireClaudeSession({ expectedProviderSessionId: string | null = null // Frames are admitted only after launch resolution proves the provider session // this acquisition owns. Keep the check ahead of every stateful consumer. - const initTimeoutMs = deps.initTimeoutMs ?? CLAUDE_STRUCTURED_INIT_TIMEOUT_MS - const initDeadline = createClaudeInitDeadline(sessionId, initTimeoutMs) + const initProof = createClaudeInitProof() const translator = createClaudeSessionJournalTranslator( input.events, prompts, String(input.fence), - createClaudeJournalFailureHandler({ attempt, initDeadline, callbacks, sessionId }) + createClaudeJournalFailureHandler({ attempt, initProof, callbacks, sessionId }) ) const rewind = new ClaudeRewindAttempt(input.rewind, input.rewind?.onProved) @@ -99,17 +84,17 @@ export async function acquireClaudeSession({ // An init proof for another (or unnamed) provider must fail acquisition // promptly, while ordinary foreign frames stay quarantined silently. if (init || (message.type === 'system' && message.subtype === 'init')) { - initDeadline.reject(new Error('claude provider session expected')) + initProof.reject(new Error('claude provider session expected')) } return } const refusal = rewind.observe(message) if (refusal) { - initDeadline.reject(refusal) + initProof.reject(refusal) return } if (init) { - initDeadline.resolve(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. @@ -189,15 +174,9 @@ export async function acquireClaudeSession({ onMessage, canUseTool, onUserDialog, - onFault: (error) => { - if (!attempt.published) { - initDeadline.reject(error) - } - }, + onFault: (error) => initProof.reject(error), onExit: (error) => { - if (!attempt.published) { - initDeadline.reject(error) - } + initProof.reject(error) callbacks.handleExit(sessionId, attempt, error) } } @@ -206,44 +185,34 @@ export async function acquireClaudeSession({ attempt.connection = connection unbindReadingControl = bindClaudeJournalReadingControl(input.events, connection, translator) acquisitions.assertCurrent(sessionId, attempt) - initDeadline.start() - const [initialization, init] = await withAgentSessionCreatePhase( - 'init', - input.recordPhase, - () => - Promise.all([ - requestClaudeInitialization(connection, sessionId, initTimeoutMs), - initDeadline.promise - ]) - ) - const models = readClaudeModels(initialization) - callbacks.deliver(attempt, sessionId, () => - callbacks.emit(liveSession, input.events, { type: 'options', sessionId, models }) - ) - initDeadline.clear() - acquisitions.assertCurrent(sessionId, attempt) - if (init.providerSessionId !== launch.providerSessionId) { - throw new Error( - `claude proved session ${init.providerSessionId}, expected ${launch.providerSessionId}` - ) - } - const settings = await readClaudeStructuredSessionSettings(connection, deps.requestTimeoutMs) - const acquisitionOptions = prepareClaudeStructuredSessionAcquisitionOptions({ - settings, - initialization, - inputOptions: input.options, - resumed: launch.resumed - }) - callbacks.deliver(attempt, sessionId, () => - callbacks.emit(liveSession, input.events, { - type: 'auth-diagnostic', + const emit = (event: Parameters[2]): void => + callbacks.deliver(attempt, sessionId, () => callbacks.emit(liveSession, input.events, event)) + const readFacts = (recordPhase?: typeof input.recordPhase): Promise => + readClaudeStartupFacts({ + connection, + initProof, sessionId, - diagnostic: claudeAuthDiagnostic(init, settings) + providerSessionId: launch.providerSessionId, + resumed: launch.resumed, + inputOptions: input.options, + requestTimeoutMs: deps.requestTimeoutMs, + ...(recordPhase ? { recordPhase } : {}), + emit }) - ) - observedLeafUuid = (await rewind.prove(launch, deps)) ?? observedLeafUuid - observedLeafUuid = - (await proveClaudeRewindRecovery(input.rewindRecovery, launch, deps)) ?? observedLeafUuid + // A rewind publishes its target leaf as the durable cursor, so it alone stays unpublished + // until the CLI has proven the rewind; still untimed. + const proofBeforePublish = Boolean(input.rewind || input.rewindRecovery) + let facts: Promise | null = null + if (proofBeforePublish) { + facts = Promise.resolve(await readFacts(input.recordPhase)) + observedLeafUuid = (await rewind.prove(launch, deps)) ?? observedLeafUuid + observedLeafUuid = + (await proveClaudeRewindRecovery(input.rewindRecovery, launch, deps)) ?? observedLeafUuid + } + if (connection.pid === undefined) { + // A pid-less spawn always reports its error next; surface that, not the missing pid. + await initProof.promise + } const process = await claudeProcessIdentity( { ...input, pid: connection.pid }, deps.readProcessStartTime @@ -252,46 +221,42 @@ export async function acquireClaudeSession({ if (connection.closed) { throw new Error(`claude stream-json for session ${sessionId} exited while being acquired`) } - const publication = await withAgentSessionCreatePhase('publish', input.recordPhase, async () => - createClaudeSessionPublication({ - connection, - init, - initialization, - claudeConfigDir: launch.claudeConfigDir, - leafUuid: observedLeafUuid, - fence: input.fence, - effort: readClaudeSettingsEffort(settings), - ...claudeStructuredSessionPublicationOptions(acquisitionOptions), - resumed: launch.resumed, - prompts, - translator, - events: input.events, - ...(unbindReadingControl ? { unbindReadingControl } : {}), - process, - acquisitionGeneration: mintClaudeAcquisitionGeneration(deps), - options: acquisitionOptions.options, - capabilities: readClaudeCapabilities(init, initialization), - ...(deps.mintLinkId ? { linkId: deps.mintLinkId() } : {}), - observedAt: deps.now?.() ?? Date.now() - }) - ) - const acquired: AgentSessionAcquisition = publication.acquisition - liveSession = publication.session - await withAgentSessionCreatePhase('restore_options', input.recordPhase, () => - restoreClaudeStructuredSessionOptions(liveSession!, deps.requestTimeoutMs) - ) - acquisitions.assertCurrent(sessionId, attempt) + const publication = createClaudeSessionPublication({ + connection, + providerSessionId: launch.providerSessionId, + claudeConfigDir: launch.claudeConfigDir, + leafUuid: observedLeafUuid, + fence: input.fence, + resumed: launch.resumed, + prompts, + translator, + events: input.events, + ...(unbindReadingControl ? { unbindReadingControl } : {}), + process, + acquisitionGeneration: mintClaudeAcquisitionGeneration(deps), + options: restoredClaudeStructuredSessionOptions(input.options), + ...(deps.mintLinkId ? { linkId: deps.mintLinkId() } : {}), + observedAt: deps.now?.() ?? Date.now() + }) + const session = publication.session + liveSession = session acquisitions.deleteIfCurrent(sessionId, attempt) await withAgentSessionCreatePhase('publish', input.recordPhase, async () => { - sessions.set(sessionId, liveSession!) + sessions.set(sessionId, session) attempt.published = true for (const event of attempt.buffered.splice(0)) { event() } }) - return acquired + session.startup.settled = settleClaudeSessionStartup({ + session, + facts: facts ?? readFacts(), + isCurrent: () => sessions.get(sessionId) === session, + requestTimeoutMs: deps.requestTimeoutMs, + fault: (error) => callbacks.handleExit(sessionId, attempt, error) + }) + return publication.acquisition } catch (error) { - initDeadline.clear() unbindReadingControl?.() const acquisitionError = await resolveClaudeAcquisitionError({ error, diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index c4fc4b7ff611..ebb7cd936059 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -3,17 +3,15 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { AgentSessionAcquisitionExitUnprovenError, - AgentSessionAcquisitionRefusal, AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' import { ClaudeControlRequestError } from './claude-stream-json-connection' import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity' import { encodeClaudeQuestionOptionId } from './claude-structured-prompt-replies' -import { - CLAUDE_STRUCTURED_INIT_TIMEOUT_MS, - type ClaudeStructuredSessionAdapter, - type ClaudeStructuredSessionEvent +import type { + ClaudeStructuredSessionAdapter, + ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' import { acquired, @@ -28,10 +26,6 @@ import { } from './claude-structured-session-test-support' describe('ClaudeStructuredSessionAdapter.acquire', () => { - it('finishes its startup deadline before the paired mobile request deadline', () => { - expect(CLAUDE_STRUCTURED_INIT_TIMEOUT_MS).toBeLessThan(30_000) - }) - it('pins the account and proves init without treating the system-frame uuid as a chain leaf', async () => { const claude = fakeClaude() const events: ClaudeStructuredSessionEvent[] = [] @@ -221,7 +215,6 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { } } }) - const adapter = adapterFor(claude) const input = { identity: identityFor(), fence: 7, @@ -229,7 +222,11 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { options: { model: 'temporarily-unavailable' } } - await expect(adapter.acquire(input)).rejects.toThrow('claude set_model request timed out') + // Restore runs after publish, so its failure ends the session rather than the create. + await expect(endedAtStartup(claude, input)).resolves.toMatchObject({ + reason: 'claude set_model request timed out', + startupUnproven: true + }) expect(claude.connections[0]?.closeCount).toBe(1) }) @@ -481,36 +478,22 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { }) const wrongClaude = fakeClaude({ initSessionId: 'different-session' }) - const wrong = adapterFor(wrongClaude) - await expect( - wrong.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - ).rejects.toThrow(/expected/) + await expect(endedAtStartup(wrongClaude)).resolves.toMatchObject({ + reason: expect.stringMatching(/expected/), + startupUnproven: true + }) expect(wrongClaude.connections[0].closeCount).toBe(1) }) - it('surfaces a CLI startup failure instead of waiting for the init deadline', async () => { + it('ends the published session with the CLI startup failure, never a timer', async () => { const claude = fakeClaude({ exitBeforeInit: 'Claude login required' }) - const adapter = adapterFor(claude) - - await expect( - adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - ).rejects.toThrow('Claude login required') - expect(claude.connections[0].closeCount).toBe(1) - }) - it('closes a silent unauthenticated startup with actionable account guidance', async () => { - const claude = fakeClaude({ initProof: 'none' }) - const adapter = adapterFor(claude, {}, [], [], 20) - - const error = await adapter - .acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - .catch((cause: unknown) => cause) - - expect(error).toBeInstanceOf(AgentSessionAcquisitionRefusal) - expect(error).toMatchObject({ - message: expect.stringMatching(/selected Claude account is signed in.*CLAUDE_CONFIG_DIR/s) + await expect(endedAtStartup(claude)).resolves.toMatchObject({ + type: 'ended', + reason: 'Claude login required', + cause: 'unexpected-exit', + startupUnproven: true }) - expect(claude.connections[0].calls[0]).toEqual({ subtype: 'initialize' }) expect(claude.connections[0].closeCount).toBe(1) }) @@ -519,16 +502,42 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { initProof: 'session-start', initAccount: { apiProvider: 'firstParty', tokenSource: 'none' } }) - const adapter = adapterFor(claude) - await expect( - adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - ).rejects.toThrow(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s) + await expect(endedAtStartup(claude)).resolves.toMatchObject({ + reason: expect.stringMatching(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s), + startupUnproven: true + }) expect(claude.connections[0].closeCount).toBe(1) }) }) +/** Acquires, then returns the `ended` event the startup failure published. */ +async function endedAtStartup( + claude: ReturnType, + input: Parameters[0] = { + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9' + }, + launch: Parameters[1] = {} +): Promise { + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, launch, events) + await adapter.acquire(input) + await adapter.drainObservedExits() + return events.find((event) => event.type === 'ended') +} + describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => { + // Only a rewind still proves startup before publish, so it is the start that can fail here. + const kept = async (): Promise => 'kept' + const rewind = { + targetUuid: 'kept', + previousLeafUuid: 'tip', + dropsTurn: 'drop', + onProved: async () => {} + } + /** A start that fails after the child self-exited, with its close verdict scripted. */ function failedStart( unprovenCloseVerdict: ClaudeStreamJsonConnection['exitVerdict'] @@ -537,8 +546,8 @@ describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => { exitBeforeInit: 'claude stream-json exited (code 1): not logged in', unprovenCloseVerdict }) - return adapterFor(claude) - .acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + return adapterFor(claude, { resumed: true, resumeLeafUuid: 'tip' }, [], [], undefined, kept) + .acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', rewind }) .catch((error: unknown) => error) } @@ -601,7 +610,15 @@ describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => { it('forgets a retained exit once the session is acquired again', async () => { const options: Parameters[0] = {} const claude = fakeClaude(options) - const adapter = await acquired(claude) + const adapter = adapterFor( + claude, + { resumed: true, resumeLeafUuid: 'tip' }, + [], + [], + undefined, + kept + ) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) const first = claude.connections[0] first.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) first.exitVerdict = { root: 'exited', tree: 'unverifiable' } @@ -609,7 +626,7 @@ describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => { options.exitBeforeInit = 'claude stream-json exited (code 1): not logged in' await expect( - adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) + adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10', rewind }) ).rejects.toThrow('not logged in') // The second start's own proven close is the answer; the first exit is stale. await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index bfb34fa972a8..0f269144ecde 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -9,9 +9,9 @@ import { dispatchClaudeTurn } from './claude-structured-dispatch' import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { releaseClaudeAcquisition } from './claude-structured-acquisition-release' import { acquireClaudeSession } from './claude-structured-session-acquisition' -export { CLAUDE_STRUCTURED_INIT_TIMEOUT_MS } from './claude-structured-session-acquisition' +import { failClaudeStartupGate } from './claude-structured-session-startup-gate' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' -import { setClaudeStructuredOption } from './claude-structured-options' +import { setClaudeStructuredSessionOption } from './claude-structured-options' import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' import { ClaudeAcquisitionRegistry, @@ -24,6 +24,7 @@ import { import { closeAllClaudeSessions, closeClaudeSession, + claudeRootExitObserved, settleClaudeExitedSession } from './claude-structured-session-close' import { @@ -97,6 +98,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda return } this.sessions.delete(sessionId) + failClaudeStartupGate(session, error) // Re-enter the provider's close ladder before publishing lifecycle recovery. // An exit callback is root evidence only; the retained tree proof must run // before the host releases and reacquires this exact child. @@ -110,7 +112,9 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda this.exits.set(sessionId, exit) exit.publication = closePromise .then((proven) => { - if (!proven) { + // A failed startup keeps the failed-create bar: a first-hand root exit releases it. + const startupFailed = session.startup.state === 'failed' + if (!proven && !(startupFailed && claudeRootExitObserved(session.connection))) { return undefined } return this.settleUnexpectedExit(sessionId, exit) @@ -125,6 +129,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda * apart without guessing at wall-clock. */ drainObservedExits = (): Promise => drainClaudeObservedExits(this.exits) + /** Resolves once a published session's startup has landed or faulted it. */ + drainStartup = (sessionId: string): Promise => + this.sessions.get(sessionId)?.startup.settled ?? Promise.resolve() + /** Lifecycle recovery is published only after the child tree proof is true. */ private settleUnexpectedExit(sessionId: string, exit: ClaudeSessionExit): Promise { exit.settlementPromise ??= (async () => { @@ -148,7 +156,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda cause: 'unexpected-exit', fence: exit.session.fence, acquisitionGeneration: exit.session.acquisitionGeneration, - observedAt: this.deps.now?.() ?? Date.now() + observedAt: this.deps.now?.() ?? Date.now(), + ...(exit.session.startup.state === 'proven' ? {} : { startupUnproven: true }) } try { this.emit(exit.session, ended) @@ -206,7 +215,9 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => - dispatchClaudeTurn(this.session(input.sessionId), input, input.beforeDispatch) + dispatchClaudeTurn(this.session(input.sessionId), input, input.beforeDispatch, (settlement) => + this.deps.onDispatchSettledLate?.({ sessionId: input.sessionId, ...settlement }) + ) compact: NonNullable = (input) => compactClaudeSession(this.session(input.sessionId), this.compactions, input) @@ -249,7 +260,11 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) => answerClaudeStructuredPrompt({ request, sessions: this.sessions }) setOption: StructuredAgentSessionAdapter['setOption'] = (input) => - setClaudeStructuredOption(this.session(input.sessionId), input, this.deps.requestTimeoutMs) + setClaudeStructuredSessionOption( + this.session(input.sessionId), + input, + this.deps.requestTimeoutMs + ) readOptions = (input: { sessionId: string; fence: number }) => readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index f6a58c36357e..4df67c440790 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -19,6 +19,14 @@ import { closeProcessRegistry } from '../../shared/child-process/close-process-r import { retireClaudeDispatchWaiters } from './claude-structured-dispatch' import { readClaudeTranscriptLeafWithReproof } from './claude-transcript-branch-proof' +/** The root's own exit was seen first-hand; only its descendants went unverified. */ +export function claudeRootExitObserved( + connection: ClaudeStreamJsonConnection | null | undefined +): boolean { + const verdict = connection?.exitVerdict + return verdict?.root === 'exited' && verdict.tree === 'unverifiable' +} + export function claudeAcquisitionCleanupError( connection: ClaudeStreamJsonConnection | null | undefined, cause: unknown @@ -27,7 +35,7 @@ export function claudeAcquisitionCleanupError( if (verdict?.root === 'processless') { return new AgentSessionPreSpawnError(cause) } - return verdict?.root === 'exited' && verdict.tree === 'unverifiable' + return claudeRootExitObserved(connection) ? new AgentSessionAcquisitionRootExitObservedError(cause) : new AgentSessionAcquisitionExitUnprovenError(cause) } diff --git a/src/main/claude/claude-structured-session-journal-control.ts b/src/main/claude/claude-structured-session-journal-control.ts index dc8a5c9d262a..5517457904d4 100644 --- a/src/main/claude/claude-structured-session-journal-control.ts +++ b/src/main/claude/claude-structured-session-journal-control.ts @@ -1,7 +1,7 @@ import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' -import type { createClaudeInitDeadline } from './claude-structured-init-deadline' +import type { ClaudeInitProof } from './claude-structured-session-startup' import type { ClaudeAcquisitionAttempt, ClaudeAcquireCallbacks @@ -9,13 +9,13 @@ import type { export function createClaudeJournalFailureHandler(input: { attempt: ClaudeAcquisitionAttempt - initDeadline: ReturnType + initProof: ClaudeInitProof callbacks: ClaudeAcquireCallbacks sessionId: string }): (error: Error) => void { return (error) => { if (!input.attempt.published) { - input.initDeadline.reject(error) + input.initProof.reject(error) return } const connection = input.attempt.connection diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index 4ad95223dec2..abedc62966ef 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -202,10 +202,14 @@ export async function readClaudeStructuredSessionOptions( timeoutMs: number | undefined ): Promise { const readMutationSequence = session.optionMutationSequence - const [catalog, settings] = await Promise.all([ - session.connection.supportedModels({ timeoutMs }).catch(() => null), - session.connection.getSettings({ timeoutMs }).catch(() => null) - ]) + // Before startup both requests would wait on initialize; answer from the saved options. + const [catalog, settings] = + session.startup.state === 'proven' + ? await Promise.all([ + session.connection.supportedModels({ timeoutMs }).catch(() => null), + session.connection.getSettings({ timeoutMs }).catch(() => null) + ]) + : [null, null] if (settings !== null && readMutationSequence === session.optionMutationSequence) { const effort = readClaudeSettingsEffort(settings) const fastMode = readClaudeSettingsFastMode(settings) diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index 2434f5226613..a6a035c3f848 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -1,16 +1,16 @@ import type { AgentSessionAcquisition } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { ClaudeInitObservation } from './claude-structured-init-proof' import { claudeProviderHandleLink } from './claude-structured-owner-identity' import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' import type { ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' +import { createClaudeSessionStartupGate } from './claude-structured-session-startup-gate' +/** The session as published at spawn: nothing the CLI reports at init is assumed yet. */ export function createClaudeSessionPublication(input: { connection: ClaudeSession['connection'] - init: ClaudeInitObservation - initialization?: unknown + providerSessionId: string claudeConfigDir: string leafUuid: string | null fence: number @@ -24,22 +24,12 @@ export function createClaudeSessionPublication(input: { linkId?: string observedAt: number options?: ReadonlyMap - capabilities: readonly string[] - /** Read from `get_settings`; `system/init` never reports an effort. */ - effort: string | null - fastMode: boolean | null - fastModePerSessionOptIn: boolean | null - fastModeState?: ClaudeSession['fastModeState'] - fastModeDisabledReason?: string }): { acquisition: AgentSessionAcquisition; session: ClaudeSession } { - const model = input.init.model - const effort = input.effort - const fastMode = input.fastMode return { acquisition: { process: input.process, link: claudeProviderHandleLink({ - sessionId: input.init.providerSessionId, + sessionId: input.providerSessionId, leafUuid: input.leafUuid, resumed: input.resumed, fence: input.fence, @@ -50,7 +40,7 @@ export function createClaudeSessionPublication(input: { }, session: { connection: input.connection, - providerSessionId: input.init.providerSessionId, + providerSessionId: input.providerSessionId, claudeConfigDir: input.claudeConfigDir, leafUuid: input.leafUuid, fence: input.fence, @@ -60,32 +50,20 @@ export function createClaudeSessionPublication(input: { retiredDispatchWaiters: [], replayContentFallbackBlocked: false, backgroundTasks: new ClaudeBackgroundTaskTracker(), - commands: new ClaudeSlashCommandCatalog(input.init.message, input.initialization), + // Undefined until init: an unread catalog is unavailable, not empty. + commands: new ClaudeSlashCommandCatalog(), dispatchSequence: 0, optionMutationSequence: 0, options: new Map(input.options), - capabilities: input.capabilities, - reportedOptions: { - ...(model ? { model } : {}), - ...(effort ? { effort } : {}), - ...(fastMode !== null ? { fastMode } : {}) - }, - ...(input.fastModeState ? { fastModeState: input.fastModeState } : {}), - ...(input.fastModeDisabledReason - ? { fastModeDisabledReason: input.fastModeDisabledReason } - : {}), - ...(input.fastModePerSessionOptIn !== null - ? { fastModePerSessionOptIn: input.fastModePerSessionOptIn } - : {}), + capabilities: [], + reportedOptions: {}, reportedModelMutation: 0, - confirmedOptions: new Set([ - ...(effort ? ['effort'] : []), - ...(fastMode !== null ? ['fastMode'] : []) - ]), + confirmedOptions: new Set(), restoreSkippedOptions: new Set(), translator: input.translator, events: input.events, - ...(input.unbindReadingControl ? { unbindReadingControl: input.unbindReadingControl } : {}) + ...(input.unbindReadingControl ? { unbindReadingControl: input.unbindReadingControl } : {}), + startup: createClaudeSessionStartupGate() } } } diff --git a/src/main/claude/claude-structured-session-reading-control.test.ts b/src/main/claude/claude-structured-session-reading-control.test.ts index 7113abd5bf5e..c9cec7996a28 100644 --- a/src/main/claude/claude-structured-session-reading-control.test.ts +++ b/src/main/claude/claude-structured-session-reading-control.test.ts @@ -89,8 +89,16 @@ describe('Claude structured reading control', () => { }) it('unbinds when acquisition fails after the connection opens', async () => { - const claude = fakeClaude({ initProof: 'none' }) - const adapter = adapterFor(claude, {}, [], [], 1) + // A rewind proves startup before publish, so it is the acquisition that can still fail. + const claude = fakeClaude({ exitBeforeInit: 'claude stream-json exited (code 1)' }) + const adapter = adapterFor( + claude, + { resumed: true, resumeLeafUuid: 'tip' }, + [], + [], + undefined, + async () => 'kept' + ) const events = controlledSink() await expect( @@ -98,9 +106,15 @@ describe('Claude structured reading control', () => { identity: identityFor(), fence: 7, spawnToken: 'spawn-9', - events: events.sink + events: events.sink, + rewind: { + targetUuid: 'kept', + previousLeafUuid: 'tip', + dropsTurn: 'drop', + onProved: async () => {} + } }) - ).rejects.toThrow('did not finish starting') + ).rejects.toThrow('exited (code 1)') expect(events.unbind).toHaveBeenCalledOnce() }) diff --git a/src/main/claude/claude-structured-session-recovery.test.ts b/src/main/claude/claude-structured-session-recovery.test.ts index bd8f8cb40b29..0675d8c0acf7 100644 --- a/src/main/claude/claude-structured-session-recovery.test.ts +++ b/src/main/claude/claude-structured-session-recovery.test.ts @@ -513,6 +513,7 @@ describe('ClaudeStructuredSessionAdapter transcript-derived recovery', () => { spawnToken: 'spawn-9', events: journalSink }) + await adapter.drainStartup('session-1') const first = claude.connections[0] const oldPrompt = invokeCanUseTool(first, 'Bash', 'permission-retained', 'tool-retained') const oldSession = ( diff --git a/src/main/claude/claude-structured-session-startup-gate.ts b/src/main/claude/claude-structured-session-startup-gate.ts new file mode 100644 index 000000000000..e8739f933dd1 --- /dev/null +++ b/src/main/claude/claude-structured-session-startup-gate.ts @@ -0,0 +1,145 @@ +// A Claude session is published once its child is spawned, before the CLI has answered +// initialize. Prompts sent in that window are held here and written, in order, once startup +// lands (init facts read and saved options restored), so a first turn never runs under +// defaults the restore was about to replace. A held prompt was never written, so a startup +// that fails rejects it rather than leaving its delivery in doubt. + +import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { AgentSessionPreDispatchError } from '../native-chat/agent-session-wire/structured-agent-session-operation-settlement' +import { dispatchWriteFailureReason } from '../../shared/structured-agent-session-dispatch-rejection' +import { claudeUserMessageWasProvablyUnwritten } from './claude-agent-sdk-user-message-queue' +import { + forgetRetiredWaiter, + forgetWaiter, + retireWaiter +} from './claude-structured-dispatch-waiters' +import type { + ClaudeDispatchWaiter, + ClaudeLateDispatchOutcome, + ClaudeSession +} from './claude-structured-session-state' + +type ClaudeStartupHeldWrite = { + waiter: ClaudeDispatchWaiter + message: Record + settleLate?: (outcome: ClaudeLateDispatchOutcome) => void +} + +export type ClaudeSessionStartupGate = { + state: 'pending' | 'proven' | 'failed' + held: ClaudeStartupHeldWrite[] + /** Held prompts are still being written; later prompts must queue behind them. */ + draining: boolean + failure: Error | null + /** Resolves once startup has landed or faulted the session; never rejects. */ + settled: Promise +} + +export function createClaudeSessionStartupGate(): ClaudeSessionStartupGate { + return { state: 'pending', held: [], draining: false, failure: null, settled: Promise.resolve() } +} + +export function claudeStartupFailureReason(session: ClaudeSession): string | null { + return session.startup.state === 'failed' + ? dispatchWriteFailureReason(session.startup.failure ?? new Error('claude startup failed')) + : null +} + +export function claudeStartupHoldsWrites(session: ClaudeSession): boolean { + return session.startup.state === 'pending' || session.startup.draining +} + +/** Admits a prompt while startup is pending; it is written when `openClaudeStartupGate` runs. */ +export async function holdClaudeStartupWrite( + session: ClaudeSession, + input: { + message: Record + arm: () => { waiter: ClaudeDispatchWaiter } + beforeDispatch?: () => Promise + settleLate?: (outcome: ClaudeLateDispatchOutcome) => void + } +): Promise { + if (input.beforeDispatch) { + try { + await input.beforeDispatch() + } catch (error) { + if (error instanceof AgentSessionPreDispatchError) { + throw error + } + return { state: 'rejected', reason: dispatchWriteFailureReason(error) } + } + } + // Startup may have failed while the admission barrier ran. + const failed = claudeStartupFailureReason(session) + if (failed) { + return { state: 'rejected', reason: failed } + } + const { waiter } = input.arm() + session.startup.held.push({ + waiter, + message: input.message, + ...(input.settleLate ? { settleLate: input.settleLate } : {}) + }) + return { state: 'admitted' } +} + +export async function openClaudeStartupGate(session: ClaudeSession): Promise { + const gate = session.startup + if (gate.state !== 'pending') { + return + } + gate.state = 'proven' + gate.draining = true + try { + for (let held = gate.held.shift(); held; held = gate.held.shift()) { + await writeHeld(session, held) + } + } finally { + gate.draining = false + } +} + +async function writeHeld(session: ClaudeSession, held: ClaudeStartupHeldWrite): Promise { + try { + await session.connection.send(held.message) + } catch (error) { + if (held.waiter.settledUuid) { + return + } + if (claudeUserMessageWasProvablyUnwritten(error)) { + rejectHeld(session, held, dispatchWriteFailureReason(error)) + return + } + // Possibly written: only a replay or the child's exit can settle it now. + retireWaiter(session, held.waiter) + held.waiter.resolve(null) + } +} + +function rejectHeld(session: ClaudeSession, held: ClaudeStartupHeldWrite, reason: string): void { + forgetWaiter(session, held.waiter) + forgetRetiredWaiter(session, held.waiter) + held.waiter.resolve(null) + if (held.waiter.clientMessageId) { + held.settleLate?.({ clientMessageId: held.waiter.clientMessageId, state: 'rejected', reason }) + } +} + +/** Rejects every held prompt with `reason`; true when any was held. */ +export function rejectClaudeStartupWrites(session: ClaudeSession, reason: string): boolean { + const held = session.startup.held.splice(0) + for (const entry of held) { + rejectHeld(session, entry, reason) + } + return held.length > 0 +} + +/** Startup cannot land any more; nothing held was written, so all of it is rejected. */ +export function failClaudeStartupGate(session: ClaudeSession, error: Error): void { + const gate = session.startup + if (gate.state === 'pending') { + gate.state = 'failed' + gate.failure = error + } + rejectClaudeStartupWrites(session, dispatchWriteFailureReason(error)) +} diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts new file mode 100644 index 000000000000..184716a978b9 --- /dev/null +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-adapter' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { + adapterAtPublishFor, + fakeClaude, + identityFor, + USER_MESSAGE +} from './claude-structured-session-test-support' + +type LateSettlement = Parameters< + NonNullable +>[0] + +const SLOW_INIT_MS = 12_000 + +function startingAdapter(claude: ReturnType): { + adapter: ReturnType + events: ClaudeStructuredSessionEvent[] + late: LateSettlement[] +} { + const events: ClaudeStructuredSessionEvent[] = [] + const late: LateSettlement[] = [] + const adapter = adapterAtPublishFor( + claude, + {}, + events, + [], + undefined, + undefined, + undefined, + undefined, + (settlement) => late.push(settlement) + ) + return { adapter, events, late } +} + +const ACQUIRE = { identity: identityFor(), fence: 7, spawnToken: 'spawn-9' } +const PROMPT = { sessionId: 'session-1', clientMessageId: 'client-1', body: USER_MESSAGE, fence: 7 } + +describe('Claude structured session publishes before the CLI answers initialize', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('creates a session whose init takes longer than any old deadline, then reports its facts', async () => { + const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) + const { adapter, events } = startingAdapter(claude) + + await expect(adapter.acquire(ACQUIRE)).resolves.toBeDefined() + expect(events.some((event) => event.type === 'options')).toBe(false) + expect(adapter.readCommands('session-1')).toBeUndefined() + + await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) + await adapter.drainStartup('session-1') + + expect(events.find((event) => event.type === 'options')).toMatchObject({ + models: [{ value: 'claude-sonnet' }] + }) + expect(events.some((event) => event.type === 'ended')).toBe(false) + expect(claude.connections[0].closeCount).toBe(0) + await adapter.closeAll() + }) + + it('holds a prompt sent before init and writes it once startup lands', async () => { + const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) + const { adapter } = startingAdapter(claude) + await adapter.acquire(ACQUIRE) + + await expect(adapter.dispatch(PROMPT)).resolves.toEqual({ state: 'admitted' }) + expect(claude.connections[0].sent).toEqual([]) + + await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) + await adapter.drainStartup('session-1') + + expect(claude.connections[0].sent).toHaveLength(1) + expect(claude.connections[0].sent[0]).toMatchObject({ type: 'user' }) + await adapter.closeAll() + }) + + it('ends the session with the exit reason when the CLI dies before init, and rejects held prompts', async () => { + const claude = fakeClaude({ + initDelayMs: SLOW_INIT_MS, + exitBeforeInit: 'claude stream-json exited (code 1): stderr says no' + }) + const { adapter, events, late } = startingAdapter(claude) + await adapter.acquire(ACQUIRE) + await adapter.dispatch(PROMPT) + + await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) + await adapter.drainStartup('session-1') + await adapter.drainObservedExits() + + expect(events.find((event) => event.type === 'ended')).toMatchObject({ + reason: 'claude stream-json exited (code 1): stderr says no', + cause: 'unexpected-exit', + startupUnproven: true + }) + expect(late).toEqual([ + expect.objectContaining({ clientMessageId: 'client-1', state: 'rejected' }) + ]) + expect(claude.connections[0].sent).toEqual([]) + expect(claude.connections[0].closeCount).toBe(1) + }) + + it('ends an unauthenticated start with sign-in guidance', async () => { + const claude = fakeClaude({ initAccount: { apiProvider: 'firstParty', tokenSource: 'none' } }) + const { adapter, events } = startingAdapter(claude) + await adapter.acquire(ACQUIRE) + await adapter.drainStartup('session-1') + await adapter.drainObservedExits() + + expect(events.find((event) => event.type === 'ended')).toMatchObject({ + reason: expect.stringMatching(/not signed in/), + startupUnproven: true + }) + }) + + it('closes a session stopped before init without faulting it or writing held prompts', async () => { + const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) + const { adapter, events, late } = startingAdapter(claude) + await adapter.acquire(ACQUIRE) + await adapter.dispatch(PROMPT) + + await expect(adapter.closeSession('session-1')).resolves.toBe(true) + await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) + await adapter.drainStartup('session-1') + + const connection = claude.connections[0] + expect(connection.closeCount).toBe(1) + expect(connection.sent).toEqual([]) + expect(connection.calls.map(({ subtype }) => subtype)).not.toContain('get_settings') + expect(late).toEqual([ + expect.objectContaining({ clientMessageId: 'client-1', state: 'rejected' }) + ]) + expect(events.some((event) => event.type === 'ended' && event.startupUnproven)).toBe(false) + }) + + it('withdraws a held prompt when the turn is cancelled before init', async () => { + const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) + const { adapter, late } = startingAdapter(claude) + await adapter.acquire(ACQUIRE) + await adapter.dispatch(PROMPT) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + ).resolves.toEqual({ cancelled: true }) + await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) + await adapter.drainStartup('session-1') + + expect(claude.connections[0].sent).toEqual([]) + expect(late).toEqual([ + expect.objectContaining({ clientMessageId: 'client-1', state: 'rejected' }) + ]) + await adapter.closeAll() + }) +}) diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts new file mode 100644 index 000000000000..be0d9bb4bc55 --- /dev/null +++ b/src/main/claude/claude-structured-session-startup.ts @@ -0,0 +1,179 @@ +// What Claude reports at initialize, read after the session is already published. None of it +// gates the create: a slow start is still a start, and every way it can fail (exit, auth, +// a foreign session id, a rewind refusal) faults the published session through its exit path. + +import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { withAgentSessionCreatePhase } from '../observability/agent-session-instrumentation' +import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' +import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' +import { + claudeAuthDiagnostic, + claudeInitializationAuthError, + readClaudeCapabilities, + readClaudeModels, + type ClaudeInitObservation +} from './claude-structured-init-proof' +import { restoreClaudeStructuredSessionOptions } from './claude-structured-options' +import { + claudeStructuredSessionPublicationOptions, + prepareClaudeStructuredSessionAcquisitionOptions, + readClaudeStructuredSessionSettings +} from './claude-structured-session-acquisition-options' +import { readClaudeSettingsEffort } from './claude-structured-session-options' +import { + failClaudeStartupGate, + openClaudeStartupGate +} from './claude-structured-session-startup-gate' +import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-structured-session-state' + +export type ClaudeInitProof = { + promise: Promise + resolve: (init: ClaudeInitObservation) => void + reject: (error: Error) => void +} + +export function createClaudeInitProof(): ClaudeInitProof { + let resolve = (_init: ClaudeInitObservation): void => {} + let reject = (_error: Error): void => {} + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + void promise.catch(() => {}) + return { promise, resolve, reject } +} + +export type ClaudeStartupFacts = { + init: ClaudeInitObservation + initialization: unknown + settings: unknown + prepared: ReturnType +} + +/** Settles on the CLI's answers or on its exit; there is no startup timer. */ +export async function readClaudeStartupFacts(input: { + connection: ClaudeStreamJsonConnection + initProof: ClaudeInitProof + sessionId: string + providerSessionId: string + resumed: boolean + inputOptions: StructuredAgentSessionAcquireInput['options'] + requestTimeoutMs: number | undefined + recordPhase?: StructuredAgentSessionAcquireInput['recordPhase'] + emit: (event: ClaudeStructuredSessionEvent) => void +}): Promise { + const [initialization, init] = await withAgentSessionCreatePhase('init', input.recordPhase, () => + Promise.all([ + input.connection.initializationResult().then((result) => { + const authError = claudeInitializationAuthError(result) + if (authError) { + throw authError + } + return result + }), + input.initProof.promise + ]) + ) + if (input.connection.closed) { + throw new Error('claude session closed before startup completed') + } + input.emit({ + type: 'options', + sessionId: input.sessionId, + models: readClaudeModels(initialization) + }) + if (init.providerSessionId !== input.providerSessionId) { + throw new Error( + `claude proved session ${init.providerSessionId}, expected ${input.providerSessionId}` + ) + } + const settings = await readClaudeStructuredSessionSettings( + input.connection, + input.requestTimeoutMs + ) + input.emit({ + type: 'auth-diagnostic', + sessionId: input.sessionId, + diagnostic: claudeAuthDiagnostic(init, settings) + }) + return { + init, + initialization, + settings, + prepared: prepareClaudeStructuredSessionAcquisitionOptions({ + settings, + initialization, + inputOptions: input.inputOptions, + resumed: input.resumed + }) + } +} + +function applyClaudeStartupFacts(session: ClaudeSession, facts: ClaudeStartupFacts): void { + const { init, initialization, settings, prepared } = facts + const effort = readClaudeSettingsEffort(settings) + const published = claudeStructuredSessionPublicationOptions(prepared) + // A turn's own init frame may already have reported the running model. + if (init.model && session.reportedOptions.model === undefined) { + session.reportedOptions.model = init.model + session.reportedModelMutation = session.optionMutationSequence + } + if (effort) { + session.reportedOptions.effort = effort + session.confirmedOptions.add('effort') + } + if (published.fastMode !== null) { + session.reportedOptions.fastMode = published.fastMode + session.confirmedOptions.add('fastMode') + } + if (published.fastModePerSessionOptIn !== null) { + session.fastModePerSessionOptIn = published.fastModePerSessionOptIn + } + session.fastModeState ??= published.fastModeState + session.fastModeDisabledReason ??= published.fastModeDisabledReason + session.options = prepared.options + session.capabilities = readClaudeCapabilities(init, initialization) + // A catalog frame that streamed in after publish is newer than the initialize answer. + if (session.commands.commands === undefined) { + session.commands = new ClaudeSlashCommandCatalog(init.message, initialization) + } + session.events?.publish() +} + +/** Applies startup facts to the published session, restores saved options, then releases + * held prompts. Any failure faults the session so the user sees why it never started. */ +export async function settleClaudeSessionStartup(input: { + session: ClaudeSession + facts: Promise + isCurrent: () => boolean + requestTimeoutMs: number | undefined + fault: (error: Error) => void +}): Promise { + const { session } = input + const superseded = (): boolean => { + if (input.isCurrent()) { + return false + } + failClaudeStartupGate(session, new Error('claude session closed before startup completed')) + return true + } + try { + const facts = await input.facts + if (superseded()) { + return + } + applyClaudeStartupFacts(session, facts) + await restoreClaudeStructuredSessionOptions(session, input.requestTimeoutMs) + if (!superseded()) { + await openClaudeStartupGate(session) + } + } catch (caught) { + const error = caught instanceof Error ? caught : new Error(String(caught)) + // A close or exit that already ended startup owns how the session ends. + const endedElsewhere = session.startup.state !== 'pending' + failClaudeStartupGate(session, error) + if (!endedElsewhere && input.isCurrent()) { + input.fault(error) + } + } +} diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index de697192074b..08792aaca2e1 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -18,6 +18,7 @@ import type { } from '../../shared/agent-session-wire' import type { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import type { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' +import type { ClaudeSessionStartupGate } from './claude-structured-session-startup-gate' export type ClaudeAuthDiagnostic = { apiKeySourceConfigured: boolean @@ -63,6 +64,8 @@ export type ClaudeStructuredSessionEvent = settlementRetryRequired?: boolean /** Host clock when the end was observed. */ observedAt?: number + /** The child ended before proving startup, so reacquiring would repeat the same start. */ + startupUnproven?: true } export type ClaudeLateDispatchOutcome = @@ -89,7 +92,6 @@ export type ClaudeStructuredSessionAdapterDeps = { mintAcquisitionGeneration?: () => string now?: () => number requestTimeoutMs?: number - initTimeoutMs?: number persistHandle?: (input: { sessionId: string providerSessionId: string @@ -171,6 +173,8 @@ export type ClaudeSession = { translator: ClaudeJournalTranslator | null events: StructuredAgentSessionEventSink | undefined unbindReadingControl?: () => void + /** Published at spawn; init facts, option restore and queued prompts land when startup does. */ + startup: ClaudeSessionStartupGate } export function mintClaudeAcquisitionGeneration(deps: ClaudeStructuredSessionAdapterDeps): string { diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index dbff80182d45..9cf137c634a9 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -55,6 +55,8 @@ export function fakeClaude( initAccount?: unknown initCommands?: unknown exitBeforeInit?: string + /** Host-clock delay before the CLI answers initialize, as on a loaded machine. */ + initDelayMs?: number settings?: unknown replayUuid?: string | null replayUuids?: (string | null)[] @@ -87,9 +89,14 @@ export function fakeClaude( resumeReading: () => {}, initializationResult: async () => { connection.calls.push({ subtype: 'initialize' }) + if (options.initDelayMs !== undefined) { + await new Promise((resolve) => setTimeout(resolve, options.initDelayMs)) + } if (options.exitBeforeInit) { + connection.closed = true handlers.onExit?.(new Error(options.exitBeforeInit)) - return { models: [] } + // The SDK rejects pending control requests once the transport ends. + throw new Error('Query closed before response received') } if (options.initProof === 'session-start') { handlers.onMessage?.({ @@ -196,12 +203,27 @@ export function fakeClaude( return { connections, openConnection, routes } } +/** Acquisition resolves only once startup has landed, as suites written before + * publish-first expect; `adapterAtPublishFor` observes the published window itself. */ export function adapterFor( + ...args: Parameters +): ClaudeStructuredSessionAdapter { + const adapter = adapterAtPublishFor(...args) + const acquire = adapter.acquire + adapter.acquire = async (input) => { + const acquisition = await acquire(input) + await adapter.drainStartup(input.identity.sessionId) + return acquisition + } + return adapter +} + +export function adapterAtPublishFor( claude: ReturnType, launch: Partial = {}, events: ClaudeStructuredSessionEvent[] = [], persistedHandles: unknown[] = [], - initTimeoutMs?: number, + requestTimeoutMs?: number, readTranscriptLeaf?: ClaudeStructuredSessionAdapterDeps['readTranscriptLeaf'], persistHandle?: ClaudeStructuredSessionAdapterDeps['persistHandle'], onBackgroundTasksChanged?: ClaudeStructuredSessionAdapterDeps['onBackgroundTasksChanged'], @@ -222,7 +244,7 @@ export function adapterFor( openConnection: claude.openConnection, readProcessStartTime: async () => 1_700_000_000_000, now: () => 1_700_000_000_500, - ...(initTimeoutMs === undefined ? {} : { initTimeoutMs }), + ...(requestTimeoutMs === undefined ? {} : { requestTimeoutMs }), persistHandle: persistHandle ?? (async (handle) => { 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 ac73526fd1cb..ab9ee0673bd1 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 @@ -124,6 +124,8 @@ export type StructuredAgentSessionLifecycleEvent = { observedAt?: number /** Translator could not admit terminal rows; host recovery must append its bounded fallback. */ settlementRetryRequired?: boolean + /** The provider ended before it finished starting, so resuming it would repeat the failure. */ + startupUnproven?: true } export type StructuredAgentSessionAcquireInput = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index 3abfd42b8aac..2aad1d2fafe5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -1,5 +1,6 @@ import { settlePostAcquisitionAttachFailure } from './structured-agent-session-attach-failure' import { rewindRefusal } from './structured-rewind-refusal' +import { failedCreateRefusal } from './structured-agent-session-failed-create-refusal' import { AgentSessionRewindRefusal, AgentSessionAcquisitionExitUnprovenError, @@ -147,7 +148,7 @@ export async function performAttach( reconstruct: () => null }) if (replay.decision === 'refuse') { - return { ok: false, refusal: replay.refusal } + return failedCreateRefusal(replay.refusal, reserved.operationRow.outcome.status, record) } } // Sample provider history before a new child is acquired. Once acquireOwner @@ -168,6 +169,7 @@ export async function performAttach( acquiredOwner = true } } catch (error) { + let settled: AgentSessionRecord | null = null const spawnToken = reservedRecord?.lease.reservedSpawnToken if (reservedRecord && spawnToken && !unsupportedReservationSettlementAttempted) { // Settle processless proof and failed operation atomically. @@ -197,7 +199,7 @@ export async function performAttach( message: error instanceof Error ? error.message : String(error) } try { - await store.settleFailedAcquisition({ + settled = await store.settleFailedAcquisition({ sessionId, fence: reservedRecord.lease.runtimeFence, spawnToken, @@ -215,10 +217,11 @@ export async function performAttach( } } if (error instanceof AgentSessionRewindRefusal) { - return rewindRefusal(error.rewindReason) + return failedCreateRefusal(rewindRefusal(error.rewindReason).refusal, 'failed', settled) } if (error instanceof AgentSessionAcquisitionRefusal) { - return { ok: false, refusal: { code: error.code, message: error.message } } + const refusal = { code: error.code, message: error.message } + return failedCreateRefusal(refusal, 'failed', settled) } return { ok: false, 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 9a3f3a7c091b..259df8a3a55e 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,15 +26,27 @@ export const MAX_UNEXPECTED_EXIT_REASON_CHARS = 512 /** The cause is the only thing separating an auth failure from an OOM kill, so it is carried * into the copy rather than left in the durable record nothing renders. */ export function unexpectedProviderExitOutcome(reason?: string): string { - const detail = reason - ?.slice(0, MAX_UNEXPECTED_EXIT_REASON_CHARS) - .trim() - .replace(/[.\s]+$/, '') + const detail = exitReasonDetail(reason) return detail ? `The provider stopped while this response was in progress: ${detail}. You can continue in this conversation.` : UNEXPECTED_PROVIDER_EXIT_OUTCOME } +/** A start that never finished has no response to interrupt; its cause is the whole story. */ +export function providerStartupFailureOutcome(reason?: string): string { + const detail = exitReasonDetail(reason) + return detail + ? `The provider stopped before it finished starting: ${detail}.` + : 'The provider stopped before it finished starting.' +} + +function exitReasonDetail(reason: string | undefined): string | undefined { + return reason + ?.slice(0, MAX_UNEXPECTED_EXIT_REASON_CHARS) + .trim() + .replace(/[.\s]+$/, '') +} + type DeadGenerationSubmission = Pick< ReturnType[number], 'clientMessageId' | 'dispatchState' | 'recovered' @@ -106,6 +118,8 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { showUnexpectedExitOutcome?: boolean /** Why the provider stopped, when the host has it. Rendered with the outcome copy. */ unexpectedExitReason?: string + /** The provider never finished starting; the outcome says so instead of naming a response. */ + exitedDuringStartup?: boolean onError?: (sessionId: string, error: unknown) => void }): Promise { try { @@ -123,7 +137,11 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { identity: { provider: 'orca', clientMessageId: input.settlementId }, body: { kind: 'status', - text: boundJournalStatusText(unexpectedProviderExitOutcome(input.unexpectedExitReason)) + text: boundJournalStatusText( + input.exitedDuringStartup + ? providerStartupFailureOutcome(input.unexpectedExitReason) + : unexpectedProviderExitOutcome(input.unexpectedExitReason) + ) } }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts new file mode 100644 index 000000000000..013f08527be6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -0,0 +1,116 @@ +// A durably failed create tells a client what the host proved about the provider process, so a +// client can tell "retry under a new operation" (exited) from "the session may exist" (anything else). + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + AgentSessionAcquisitionExitUnprovenError, + AgentSessionAcquisitionRefusal, + type StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail' + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-failed-create-verdict-')) + resetHostTestOperationIds() + acquire = vi.fn(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + })) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire, + releaseAcquisition: vi.fn(async () => true), + dispatch: vi.fn(async () => ({ state: 'admitted' as const })), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('failed create owner verdict', () => { + it('replays an exit-proven failure as exited with its reason, and a new operation starts fresh', async () => { + acquire.mockRejectedValueOnce(new Error(EXIT_REASON)) + const first = hostTestAttachParams(null) + + await expect(host.attach(CALLER, first)).rejects.toThrow(EXIT_REASON) + await expect(host.attach(CALLER, first)).resolves.toEqual({ + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: EXIT_REASON, + ownerVerdict: 'exited' + } + }) + expect(acquire).toHaveBeenCalledOnce() + + const retry = hostTestAttachParams(null) + expect(retry.envelope.clientOperationId).not.toBe(first.envelope.clientOperationId) + await expect(host.attach(CALLER, retry)).resolves.toMatchObject({ ok: true }) + expect(acquire).toHaveBeenCalledTimes(2) + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') + }) + + it('answers an acquisition refusal with its verdict directly', async () => { + acquire.mockRejectedValueOnce(new AgentSessionAcquisitionRefusal('not signed in')) + + await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toEqual({ + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: 'not signed in', + ownerVerdict: 'exited' + } + }) + }) + + it('never claims exited when the failed attempt could not prove its process gone', async () => { + acquire.mockRejectedValueOnce(new AgentSessionAcquisitionExitUnprovenError(new Error('hung'))) + const first = hostTestAttachParams(null) + + await expect(host.attach(CALLER, first)).rejects.toThrow() + const replay = await host.attach(CALLER, first) + + expect(replay).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_ownership_unknown', ownerVerdict: 'unverifiable' } + }) + expect(store.getRecord(SESSION)?.lease.claimStatus).not.toBe('released') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts new file mode 100644 index 000000000000..7302d6ded8df --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts @@ -0,0 +1,18 @@ +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' +import { agentSessionLeaseOwnerVerdict } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' + +/** Only a durably failed operation says anything about retrying under a new one. */ +export function failedCreateRefusal( + refusal: AgentSessionWireRefusal, + status: AgentSessionOperationOutcome['status'], + record: AgentSessionRecord | null +): { ok: false; refusal: AgentSessionWireRefusal } { + return status === 'failed' && record + ? { + ok: false, + refusal: { ...refusal, ownerVerdict: agentSessionLeaseOwnerVerdict(record.lease) } + } + : { ok: false, refusal } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts new file mode 100644 index 000000000000..1faa497e5fd6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import { providerStartupFailureOutcome } from './structured-agent-session-dead-generation-settlement' +import { + settleUnexpectedStructuredAgentSessionExit, + type StructuredAgentSessionUnexpectedExitContext, + type StructuredAgentSessionUnexpectedExitSession +} from './structured-agent-session-unexpected-exit' + +const SESSION = 'session-1' +const GENERATION = 'generation-1' +const REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' + +function startedSession(): StructuredAgentSessionUnexpectedExitSession & { + journal: { appendLifecycleBatch: ReturnType } +} { + return { + hasProviderChild: true, + fence: 7, + acquisitionGeneration: GENERATION, + journal: { + // Nothing ran: the start failed before any response or acknowledged prompt. + snapshot: () => ({ items: [] }), + appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), + markPendingSubmissionsUnknown: vi.fn(async () => []) + } + } +} + +function contextFor(session: StructuredAgentSessionUnexpectedExitSession) { + let record: AgentSessionRecord = agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId: SESSION, + runtimeKind: 'native', + runtimeFence: 7, + handoffStage: null, + ownerProcess: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-1' }, + reservedSpawnToken: 'spawn-1', + claimStatus: 'live', + unreconciled: false + }) + ) + const context: StructuredAgentSessionUnexpectedExitContext = { + store: { + getRecord: () => record, + transitionHandoff: async ( + _sessionId: string, + transition: (current: AgentSessionRecord) => AgentSessionRecord + ) => (record = transition(record)) + }, + sessions: new Map([[SESSION, session]]), + flushLifecycle: async () => ({ ok: true }), + publishFence: vi.fn(), + hasResumeCapableHolder: () => true, + serialize: async (_sessionId: string, task: () => Promise) => task(), + now: () => 1 + } + return context +} + +const ended = { + type: 'ended' as const, + sessionId: SESSION, + reason: REASON, + cause: 'unexpected-exit' as const, + fence: 7, + acquisitionGeneration: GENERATION +} + +describe('a provider that ends before it finished starting', () => { + it('tells the user why, even with no response in progress, and does not auto-resume', async () => { + const session = startedSession() + + const ticket = await settleUnexpectedStructuredAgentSessionExit(contextFor(session), { + ...ended, + startupUnproven: true + }) + + expect(ticket).toBeNull() + expect(session.journal.appendLifecycleBatch).toHaveBeenCalledWith( + expect.objectContaining({ + mutations: [ + expect.objectContaining({ + body: { kind: 'status', text: providerStartupFailureOutcome(REASON) } + }) + ] + }) + ) + expect(providerStartupFailureOutcome(REASON)).toContain('not signed in') + }) + + it('keeps an ordinary idle exit silent and resumable', async () => { + const session = startedSession() + + const ticket = await settleUnexpectedStructuredAgentSessionExit(contextFor(session), ended) + + expect(ticket).not.toBeNull() + expect(session.journal.appendLifecycleBatch).not.toHaveBeenCalled() + }) +}) 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 11d004089ca6..5e90d7d79089 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 @@ -96,11 +96,14 @@ export async function settleUnexpectedStructuredAgentSessionExit< session, stableSettlementId, verdict: { state: 'interrupted', completedAt: observedAt }, - showUnexpectedExitOutcome: unfinishedStructuredAgentSessionWorkWasInterrupted( - unfinishedWork, - session.journal, - observedAt - ) + // A failed start always says why: no response was running to carry the reason. + showUnexpectedExitOutcome: + unexpectedEvent.startupUnproven === true || + unfinishedStructuredAgentSessionWorkWasInterrupted( + unfinishedWork, + session.journal, + observedAt + ) })) } finally { // Provider exit was positively observed, so release the owner even when @@ -138,7 +141,11 @@ export async function settleUnexpectedStructuredAgentSessionExit< if (settlementFailed || !released) { return null } - if (!context.hasResumeCapableHolder(unexpectedEvent.sessionId)) { + // Resuming a start that failed would respawn into the same failure; the next send retries. + if ( + unexpectedEvent.startupUnproven || + !context.hasResumeCapableHolder(unexpectedEvent.sessionId) + ) { return null } return { @@ -194,6 +201,7 @@ async function retryUnexpectedExitSettlement(input: { pendingSubmissionReason: 'provider_exited_before_acknowledgement', showUnexpectedExitOutcome: input.showUnexpectedExitOutcome, unexpectedExitReason: input.event.reason, + exitedDuringStartup: input.event.startupUnproven === true, onError: input.context.onBarrierError }) } diff --git a/src/main/runtime/agent-session-reservation-admission.test.ts b/src/main/runtime/agent-session-reservation-admission.test.ts index 80bcb77cfa5b..30d54a108c9c 100644 --- a/src/main/runtime/agent-session-reservation-admission.test.ts +++ b/src/main/runtime/agent-session-reservation-admission.test.ts @@ -197,3 +197,48 @@ describe('adopted conversation ownership', () => { ).toThrow('agent_session_conflict') }) }) + +describe('re-create over a failed create', () => { + const EXITED = agentSessionLeaseFixture({ + sessionId: 'session-adopting', + runtimeKind: 'native', + runtimeFence: 2, + provenHandleLinkId: null, + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released' + }) + function failedCreate(overrides: Partial = {}): AgentSessionRecord { + return { + ...agentSessionRecordFixture(EXITED), + location: LOCATION, + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + providerHandleChain: [], + ...overrides + } + } + + it('reserves a record that never bound a conversation and whose attempt is proven gone', () => { + const { record, disposition } = applyAgentSessionReservation( + storeState([failedCreate()]), + reserveRequest(), + LEASE_TTL_MS + ) + + expect(disposition).toBe('reserved') + expect(record.lease).toMatchObject({ claimStatus: 'reserved', runtimeFence: 3 }) + }) + + it('refuses when the record bound a conversation, or its attempt may still run', () => { + const bound = failedCreate({ providerHandleChain: [adoptedLink()] }) + const unproven = failedCreate({ + lease: { ...EXITED, claimStatus: 'reserved', handoffStage: 'manual-recovery' } + }) + + for (const record of [bound, unproven]) { + expect(() => + applyAgentSessionReservation(storeState([record]), reserveRequest(), LEASE_TTL_MS) + ).toThrow('agent_session_conflict') + } + }) +}) diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index 662ea481921d..436267e24eac 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -17,6 +17,7 @@ import { type AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import { + agentSessionLeaseOwnerVerdict, evaluateAgentSessionAcquisition, type AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' @@ -181,7 +182,13 @@ export function applyAgentSessionReservation( // Why: location, provider, and account are the session identity; changing one is a fork. throw new Error('agent_session_conflict') } - if (request.expectedFence === null) { + // A create may take over only a record that never bound a conversation and whose last + // attempt is proven gone: that is the same as creating it fresh, under a fresh provider id. + const recreatable = + existing.providerHandleChain.length === 0 && + !request.adoptedHandleLink && + agentSessionLeaseOwnerVerdict(existing.lease) === 'exited' + if (request.expectedFence === null && !recreatable) { throw new Error('agent_session_conflict') } const pinned = { @@ -191,7 +198,7 @@ export function applyAgentSessionReservation( } return reserveAgentSessionOwner({ record: pinned, - expectedFence: request.expectedFence, + expectedFence: request.expectedFence ?? existing.lease.runtimeFence, probe: request.probe, reservation }) diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 2fccc11fb3ce..80e2c4d42fb8 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -458,24 +458,22 @@ describe('a structured Claude session over agentSession.*', () => { expect(claude.connections).toHaveLength(0) }) - it('durably returns actionable sign-in guidance when initialization has no credentials', async () => { + it('publishes, then ends the session with sign-in guidance when initialization has no credentials', async () => { claude.setInitializeAccount({ apiProvider: 'firstParty', tokenSource: 'none' }) - const params = createIntentParams() - const first = await call('agentSession.create', params) - const retry = await call('agentSession.create', params) + // The create answers once the child is spawned; the missing credentials arrive after. + await ok<{ fence: number }>('agentSession.create', createIntentParams()) + await waitForStructuredAgentSessionRecovery() - expect(first).toMatchObject({ - ok: true, - result: { - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: expect.stringMatching(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s) - } - } + const guidance = itemsOf(await subscribe()).find((item) => item.body?.kind === 'status') + expect(guidance?.body).toMatchObject({ + kind: 'status', + text: expect.stringMatching( + /stopped before it finished starting: .*not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s + ) }) - expect((retry as { result: unknown }).result).toEqual((first as { result: unknown }).result) + expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) + // A failed start is not auto-resumed into the same failure. expect(claude.connections).toHaveLength(1) }) diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 9a070db60af4..fd0f31dd533d 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -116,7 +116,8 @@ export function createStructuredClaudeRuntimeAdapter( acquisitionGeneration: event.acquisitionGeneration, ...(event.settlementRetryRequired ? { settlementRetryRequired: event.settlementRetryRequired } - : {}) + : {}), + ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}) }) } }, diff --git a/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx b/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx index 23b52615ee97..1a4204a326fa 100644 --- a/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx +++ b/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx @@ -5,9 +5,11 @@ import type { StructuredAgentSessionLaunchLifecycle } from '@/lib/structured-age export function NativeChatLaunchRetry({ lifecycle, + failureReason = null, onRetry }: { lifecycle: StructuredAgentSessionLaunchLifecycle | null + failureReason?: string | null onRetry: () => void }): React.JSX.Element | null { if (lifecycle !== 'failed' && lifecycle !== 'visibility-unknown') { @@ -25,7 +27,10 @@ export function NativeChatLaunchRetry({ ) return (
- {message} + + {message} + {lifecycle === 'failed' && failureReason ? ` ${failureReason}` : null} +