diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 1f0e9924e03..74e2796d830 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -18778,6 +18778,78 @@ "Default config paths are shared with GNOME on a disposable hosted CI runner; nested mode refuses non-GitHub-Actions execution." ], "demotionRule": "Keep experimental on unexplained failures; retain exact bytes and participation checks without retries, skips, or longer deadlines." + }, + { + "id": "terminal-session.settled-worker-main-owned-attach", + "title": "Main owns settled worker attachment and preserves fenced pane history", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "provider-contract-and-electron-restart", + "surfaces": ["stable pane spawn", "settled worker restart", "paired terminal create"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "daemon", "ssh", "remote-runtime"], + "coverageNotes": "macOS hidden Electron restart exercises live and absent daemon sessions. Unit contracts cover direct SSH evidence and paired host/client refusal; other operating systems and real remote topologies remain unverified.", + "motivatingLinks": ["https://github.com/stablyai/orca/pull/19358"], + "invariant": "Main reads the persisted pane resume fence and permits attachment only. Observed exit preserves the historical tab and binding; unverifiable evidence preserves ownership and permits recovery without replacement.", + "oracle": "A fenced stable owner gets one attach attempt and no retirement or second provider call. A restarted worker with a live or exited daemon session retains its exact tab and PTY binding without another agent launch. A fence committed during attachment or claim reconciliation prevents retirement and fresh provider spawn. Unsupported paired resolution never certifies exit; owner absence cannot create until host fenced-create semantics are negotiated.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 npx vitest run --config config/vitest.config.ts src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 npx vitest run --config config/vitest.config.ts src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts src/main/ipc/pty/pane/stable-pane-resume-fence-host.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-fenced-outcome.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 SKIP_BUILD=1 npx playwright test tests/e2e/settled-worker-tab-survives-restart.spec.ts --config tests/playwright.config.ts --project=electron-headless --workers=1 --retries=0" + ], + "testFiles": [ + "src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts", + "src/main/ipc/pty/pane/stable-pane-resume-fence-host.test.ts", + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-fenced-outcome.test.ts", + "tests/e2e/settled-worker-tab-survives-restart.spec.ts" + ], + "assertionRefs": [ + { + "file": "tests/e2e/settled-worker-tab-survives-restart.spec.ts", + "assertions": [ + "reveal must not tear the settled worker tab down", + "main must report observed exit without retiring the pane" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-07", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 npx vitest run --config config/vitest.config.ts src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts", + "result": "passed", + "durationSeconds": 20.18, + "summary": "Main boundary and adjacent contracts passed 19 tests across three suites." + } + ], + "runtimeBudget": { + "p95Seconds": 120, + "scope": "Two hidden Electron restart cases; unit contracts use injected provider failures." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Round-2 local validation; CI soak has not begun." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Round-1 reviewers reproduced destructive retirement and false exit classification. Round-2 mutation and restart results are recorded in PR #19358." + }, + "performanceBudget": { + "required": true, + "evidence": "Fence lookup reads one host-scoped workspace session. Fenced absence stops after one provider attach with zero persistence retirement or replacement calls; recovery reuses the existing bounded scheduler." + }, + "knownGaps": [ + "No real Windows, Linux, WSL, SSH server, or paired two-process UI journey in this gate.", + "No CI soak history; archived probes requiring renderer attachOnly or contradictory identical-message outcomes cannot serve as the new contract." + ], + "promotionCriteria": [ + "Complete CI soak and add real paired/SSH topology evidence before promotion." + ], + "demotionRule": "Keep experimental while any restart ordering failure is unexplained; never weaken binding retention to silence failures." } ] } diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index f9160cdb005..667ebfb8ddd 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -38,6 +38,7 @@ export type ClaudeStructuredSdkOptions = Pick< | 'sessionId' | 'resume' | 'resumeSessionAt' + | 'resumeDropsTurn' > /** diff --git a/src/main/claude/claude-structured-rewind.test.ts b/src/main/claude/claude-structured-rewind.test.ts new file mode 100644 index 00000000000..5642e6cd088 --- /dev/null +++ b/src/main/claude/claude-structured-rewind.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from 'vitest' +import { + adapterFor, + fakeClaude, + identityFor, + PROVIDER_SESSION_ID +} from './claude-structured-session-test-support' +import { ClaudeRewindAttempt } from './claude-structured-rewind' +import { AgentSessionRewindRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' + +const intent = { targetUuid: 'kept', previousLeafUuid: 'tip', dropsTurn: 'drop' } +const proofLaunch = { + providerSessionId: PROVIDER_SESSION_ID, + claudeConfigDir: '/claude', + options: {}, + resumed: true, + resumeLeafUuid: 'tip', + cwd: '/workspace', + pathToClaudeCodeExecutable: 'claude' +} + +describe('Claude rewind acquisition', () => { + it('executes a cursor resume in place and proves the exact target before publication', async () => { + const fake = fakeClaude() + const proof = vi.fn(async (_input: { intentionalRewindUuid?: string }) => 'kept') + const adapter = adapterFor( + fake, + { resumed: true, resumeLeafUuid: 'tip' }, + [], + [], + undefined, + proof + ) + try { + const acquired = await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn', + rewind: intent + }) + expect(acquired.link.handle).toMatchObject({ + provider: 'claude', + sessionId: PROVIDER_SESSION_ID, + leafUuid: 'kept' + }) + expect(fake.connections[0]!.launch.options).toMatchObject({ + resume: PROVIDER_SESSION_ID, + resumeSessionAt: 'kept', + resumeDropsTurn: 'drop' + }) + expect(fake.connections[0]!.launch.options).not.toHaveProperty('forkSession') + expect(proof).toHaveBeenCalledWith( + expect.objectContaining({ previousLeafUuid: 'tip', intentionalRewindUuid: 'kept' }) + ) + await adapter.closeSession('session-1') + await adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-next' }) + expect(fake.connections[1]!.launch.options).not.toHaveProperty('resumeDropsTurn') + expect( + proof.mock.calls.filter(([input]) => input.intentionalRewindUuid !== undefined) + ).toHaveLength(1) + } finally { + await adapter.closeAll() + } + }) + it('recognizes the documented refusal and closes the failed child without retry', async () => { + const fake = fakeClaude() + const openConnection = fake.openConnection + fake.openConnection = async (launch, handlers) => { + const connection = await openConnection(launch, handlers) + const initialize = connection.initializationResult + connection.initializationResult = async (...args) => { + const result = await initialize(...args) + handlers?.onMessage?.({ + type: 'result', + subtype: 'error_during_execution', + session_id: PROVIDER_SESSION_ID, + errors: ['Resume rejected by --resume-drops-turn: additional prompt observed'] + }) + return result + } + return connection + } + const proof = vi.fn(async (_input: { intentionalRewindUuid?: string }) => 'kept') + const adapter = adapterFor(fake, { resumed: true }, [], [], undefined, proof) + await expect( + adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn', rewind: intent }) + ).rejects.toMatchObject({ rewindReason: 'provider-refused' }) + expect(fake.connections).toHaveLength(1) + expect(fake.connections[0]?.closed).toBe(true) + expect(proof).not.toHaveBeenCalled() + await adapter.closeAll() + }) + it('consumes proof authorization even if its first read fails', async () => { + const proof = vi.fn(async () => { + throw new Error('torn transcript') + }) + const attempt = new ClaudeRewindAttempt(intent) + const launch = { + providerSessionId: PROVIDER_SESSION_ID, + claudeConfigDir: '/claude', + options: {}, + resumed: true, + resumeLeafUuid: 'tip', + cwd: '/workspace', + pathToClaudeCodeExecutable: 'claude' + } + await expect(attempt.prove(launch, { readTranscriptLeaf: proof })).rejects.toBeInstanceOf( + AgentSessionRewindRefusal + ) + expect(await attempt.prove(launch, { readTranscriptLeaf: proof })).toBeNull() + expect(proof).toHaveBeenCalledTimes(1) + }) + it('never persists success for a mismatching leaf', async () => { + const onProved = vi.fn(async () => {}) + const attempt = new ClaudeRewindAttempt(intent, onProved) + await expect( + attempt.prove(proofLaunch, { readTranscriptLeaf: async () => 'other' }) + ).rejects.toMatchObject({ rewindReason: 'proof-mismatch' }) + expect(onProved).not.toHaveBeenCalled() + }) + it('preserves commit failure as unknown and consumes the override before persisting', async () => { + const diskError = new Error('record write failed') + const onProved = vi.fn(async () => { + throw diskError + }) + const proof = vi.fn(async () => 'kept') + const attempt = new ClaudeRewindAttempt(intent, onProved) + const launch = { + providerSessionId: PROVIDER_SESSION_ID, + claudeConfigDir: '/claude', + options: {}, + resumed: true, + resumeLeafUuid: 'tip', + cwd: '/workspace', + pathToClaudeCodeExecutable: 'claude' + } + await expect(attempt.prove(launch, { readTranscriptLeaf: proof })).rejects.toBe(diskError) + expect(onProved).toHaveBeenCalledWith('kept') + expect(await attempt.prove(launch, { readTranscriptLeaf: proof })).toBeNull() + expect(proof).toHaveBeenCalledTimes(1) + }) + it('checkpoints the proved target before late acquisition failure without persisting a stale cursor', async () => { + const fake = fakeClaude() + const launch = { resumed: true, resumeLeafUuid: 'tip' } + const persisted: unknown[] = [] + const proof = vi.fn(async () => 'kept') + const adapter = adapterFor(fake, launch, [], persisted, undefined, proof) + const onProved = vi.fn(async (leafUuid: string) => { + launch.resumeLeafUuid = leafUuid + fake.connections[0]!.closed = true + }) + try { + await expect( + adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn', + rewind: { ...intent, onProved } + }) + ).rejects.toThrow('exited while being acquired') + expect(onProved).toHaveBeenCalledWith('kept') + expect(persisted).toEqual([]) + const acquired = await adapter.acquire({ + identity: identityFor(), + fence: 8, + spawnToken: 'retry' + }) + expect(acquired.link.handle).toMatchObject({ leafUuid: 'kept' }) + expect(fake.connections[1]!.launch.options).not.toHaveProperty('resumeDropsTurn') + expect(proof).toHaveBeenCalledTimes(1) + } finally { + await adapter.closeAll() + } + }) + it('restores an interrupted unproved rewind only after exact ordinary branch proof', async () => { + const fake = fakeClaude() + const proof = vi.fn(async (_input: { intentionalRewindUuid?: string }) => 'kept') + const restored = vi.fn(async () => {}) + const adapter = adapterFor( + fake, + { resumed: true, resumeLeafUuid: 'tip' }, + [], + [], + undefined, + proof + ) + const input = { + identity: identityFor(), + fence: 7, + spawnToken: 'spawn', + rewindRecovery: { leafUuid: 'tip', onProved: restored } + } + try { + await expect(adapter.acquire(input)).rejects.toMatchObject({ rewindReason: 'proof-mismatch' }) + expect(restored).not.toHaveBeenCalled() + proof.mockResolvedValue('tip') + await adapter.acquire({ ...input, fence: 8, spawnToken: 'retry' }) + expect(restored).toHaveBeenCalledOnce() + expect(proof).toHaveBeenCalledWith(expect.objectContaining({ previousLeafUuid: 'tip' })) + for (const [request] of proof.mock.calls) { + expect(request).not.toHaveProperty('intentionalRewindUuid') + } + } finally { + await adapter.closeAll() + } + }) +}) diff --git a/src/main/claude/claude-structured-rewind.ts b/src/main/claude/claude-structured-rewind.ts new file mode 100644 index 00000000000..79587327a09 --- /dev/null +++ b/src/main/claude/claude-structured-rewind.ts @@ -0,0 +1,118 @@ +import { AgentSessionRewindRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' + +export function claudeRewindRefusalFromMessage( + message: Record +): AgentSessionRewindRefusal | null { + return message.type === 'result' && + message.subtype === 'error_during_execution' && + Array.isArray(message.errors) && + message.errors.some( + (error) => + typeof error === 'string' && error.startsWith('Resume rejected by --resume-drops-turn:') + ) + ? new AgentSessionRewindRefusal('provider-refused') + : null +} + +import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' +import type { ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-state' + +type Intent = NonNullable + +/** The proof authorization exists only for this acquisition's first proof attempt. */ +export class ClaudeRewindAttempt { + private refusal: AgentSessionRewindRefusal | null = null + constructor( + private intent: Intent | undefined, + private readonly onProved?: (leafUuid: string) => Promise + ) {} + + observe(message: Record): AgentSessionRewindRefusal | null { + if (!this.intent) { + return null + } + this.refusal ??= claudeRewindRefusalFromMessage(message) + return this.refusal + } + + applyLaunch( + launch: ClaudeStructuredLaunch, + deps: Pick + ): void { + if (!this.intent) { + return + } + if (!launch.resumed || !deps.readTranscriptLeaf) { + throw new AgentSessionRewindRefusal('unsupported') + } + launch.options = { + ...launch.options, + resume: launch.providerSessionId, + resumeSessionAt: this.intent.targetUuid, + ...(this.intent.dropsTurn ? { resumeDropsTurn: this.intent.dropsTurn } : {}) + } + launch.resumeLeafUuid = this.intent.targetUuid + } + + async prove( + launch: ClaudeStructuredLaunch, + deps: Pick + ): Promise { + const intent = this.intent + this.clear() + if (this.refusal) { + throw this.refusal + } + if (!intent) { + return null + } + let leaf: string | null + try { + leaf = await deps.readTranscriptLeaf!({ + providerSessionId: launch.providerSessionId, + previousLeafUuid: intent.previousLeafUuid, + intentionalRewindUuid: intent.targetUuid, + claudeConfigDir: launch.claudeConfigDir + }) + if (leaf !== intent.targetUuid) { + throw new AgentSessionRewindRefusal('proof-mismatch') + } + } catch (error) { + throw error instanceof AgentSessionRewindRefusal + ? error + : new AgentSessionRewindRefusal('proof-mismatch') + } + // Persistence failure is an unknown outcome, never evidence that the provider refused. + await this.onProved?.(leaf) + return leaf + } + + clear(): void { + this.intent = undefined + } +} + +/** An interrupted, unproved rewind restores its original cursor without ancestor authorization. */ +export async function proveClaudeRewindRecovery( + recovery: StructuredAgentSessionAcquireInput['rewindRecovery'], + launch: ClaudeStructuredLaunch, + deps: Pick +): Promise { + if (!recovery) { + return null + } + if (!launch.resumed || launch.resumeLeafUuid !== recovery.leafUuid || !deps.readTranscriptLeaf) { + throw new AgentSessionRewindRefusal('proof-mismatch') + } + const leaf = await deps.readTranscriptLeaf({ + providerSessionId: launch.providerSessionId, + previousLeafUuid: recovery.leafUuid, + claudeConfigDir: launch.claudeConfigDir + }) + if (leaf !== recovery.leafUuid) { + throw new AgentSessionRewindRefusal('proof-mismatch') + } + await recovery.onProved() + return leaf +} diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 56b40b27177..20ddde819b9 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -1,3 +1,4 @@ +import { ClaudeRewindAttempt, proveClaudeRewindRecovery } from './claude-structured-rewind' import { AgentSessionAcquisitionExitUnprovenError, AgentSessionPreSpawnError @@ -85,6 +86,7 @@ export async function acquireClaudeSession({ const initTimeoutMs = deps.initTimeoutMs ?? CLAUDE_STRUCTURED_INIT_TIMEOUT_MS const initDeadline = createClaudeInitDeadline(sessionId, initTimeoutMs) + const rewind = new ClaudeRewindAttempt(input.rewind, input.rewind?.onProved) const onMessage = (message: Record): void => { const init = readClaudeInit(message) if (readClaudeFrameString(message, 'session_id') !== expectedProviderSessionId) { @@ -95,6 +97,11 @@ export async function acquireClaudeSession({ } return } + const refusal = rewind.observe(message) + if (refusal) { + initDeadline.reject(refusal) + return + } if (init) { initDeadline.resolve(init) // Every turn opens with an init frame naming the model the CLI is actually @@ -178,6 +185,7 @@ export async function acquireClaudeSession({ ? error : new AgentSessionPreSpawnError(error) }) + rewind.applyLaunch(launch, deps) expectedProviderSessionId = launch.providerSessionId observedLeafUuid = launch.resumeLeafUuid acquisitions.assertCurrent(sessionId, attempt) @@ -241,6 +249,9 @@ export async function acquireClaudeSession({ diagnostic: claudeAuthDiagnostic(init, settings) }) ) + observedLeafUuid = (await rewind.prove(launch, deps)) ?? observedLeafUuid + observedLeafUuid = + (await proveClaudeRewindRecovery(input.rewindRecovery, launch, deps)) ?? observedLeafUuid const process = await claudeProcessIdentity( { ...input, pid: connection.pid }, deps.readProcessStartTime @@ -298,6 +309,7 @@ export async function acquireClaudeSession({ acquisitions.deleteIfCurrent(sessionId, attempt) throw acquisitionError } finally { + rewind.clear() attempt.finish() } } diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index a6d47fc2d0f..bcae132f695 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -4,7 +4,6 @@ import type { StructuredAgentSessionAcquireInput, StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { answerClaudePrompt, cancelClaudeTurn, @@ -58,6 +57,9 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda supportsLocation = supportsClaudeStructuredLocation + rewindSupport: NonNullable = () => + this.deps.readTranscriptLeaf ? { supported: true } : { supported: false, reason: 'unsupported' } + acquire = (input: StructuredAgentSessionAcquireInput): Promise => acquireClaudeSession({ input, @@ -67,7 +69,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda exits: this.exits, callbacks: { deliver: (attempt, sessionId, event) => this.deliver(attempt, sessionId, event), - emit: (session, events, event) => this.emit(session, events, event), + emit: (session, _events, event) => this.emit(session, event), handleExit: (sessionId, attempt, error) => this.handleExit(sessionId, attempt, error), settleExit: (sessionId, exit) => this.settleUnexpectedExit(sessionId, exit) } @@ -155,7 +157,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda acquisitionGeneration: exit.session.acquisitionGeneration } try { - this.emit(exit.session, exit.session.events, ended) + this.emit(exit.session, ended) } finally { settleClaudeExitedSession(exit.session) } @@ -187,11 +189,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda }) } - private emit( - session: ClaudeSession | null, - _events: StructuredAgentSessionEventSink | undefined, - event: ClaudeStructuredSessionEvent - ): void { + private emit(session: ClaudeSession | null, event: ClaudeStructuredSessionEvent): void { const backgroundTasksChanged = event.type === 'ended' ? (session?.backgroundTasks.clear() ?? false) diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 441d8f68af0..ea539065920 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -88,6 +88,7 @@ export type ClaudeStructuredSessionAdapterDeps = { readTranscriptLeaf?: (input: { providerSessionId: string previousLeafUuid: string | null + intentionalRewindUuid?: string /** Account-scoped Claude config root that owns this provider session. */ claudeConfigDir: string }) => Promise diff --git a/src/main/claude/claude-transcript-branch-proof.ts b/src/main/claude/claude-transcript-branch-proof.ts index 605f619eb92..c27f1281340 100644 --- a/src/main/claude/claude-transcript-branch-proof.ts +++ b/src/main/claude/claude-transcript-branch-proof.ts @@ -13,7 +13,7 @@ type TranscriptNode = { export type ClaudeTranscriptBranchProof = { leafUuid: string - relation: 'initial' | 'same' | 'descendant' + relation: 'initial' | 'same' | 'descendant' | 'intentional-rewind' } function nonEmptyString(value: unknown): string | null { @@ -83,6 +83,7 @@ export function proveClaudeTranscriptBranchFromJsonl(input: { contents: string providerSessionId: string previousLeafUuid: string | null + intentionalRewindUuid?: string }): ClaudeTranscriptBranchProof { const nodes = new Map() let leafUuid: string | null = null @@ -156,6 +157,21 @@ export function proveClaudeTranscriptBranchFromJsonl(input: { throw transcriptError('marker precedes its leaf record') } const previousLeafUuid = input.previousLeafUuid + if (input.intentionalRewindUuid !== undefined) { + if (leafUuid !== input.intentionalRewindUuid || !input.previousLeafUuid) { + throw transcriptError('rewind target does not match the observed leaf') + } + proveMainLineAncestry(nodes, input.previousLeafUuid, input.providerSessionId) + proveAppendOrder(nodes) + let ancestor = nodes.get(input.previousLeafUuid)?.parentUuid ?? null + for (let depth = 0; ancestor !== null && depth < MAX_CLAUDE_TRANSCRIPT_ANCESTRY; depth += 1) { + if (ancestor === leafUuid) { + return { leafUuid, relation: 'intentional-rewind' } + } + ancestor = nodes.get(ancestor)?.parentUuid ?? null + } + throw transcriptError('rewind target is not an ancestor of the previous cursor') + } if (!previousLeafUuid) { proveMainLineAncestry(nodes, leafUuid, input.providerSessionId) // A branch proof is based on an append-only snapshot. A child that appears @@ -210,11 +226,13 @@ export async function proveClaudeTranscriptBranch(input: { transcriptPath: string providerSessionId: string previousLeafUuid: string | null + intentionalRewindUuid?: string }): Promise { return proveClaudeTranscriptBranchFromJsonl({ contents: await readFile(input.transcriptPath, 'utf8'), providerSessionId: input.providerSessionId, - previousLeafUuid: input.previousLeafUuid + previousLeafUuid: input.previousLeafUuid, + intentionalRewindUuid: input.intentionalRewindUuid }) } diff --git a/src/main/claude/claude-transcript-rewind-proof.test.ts b/src/main/claude/claude-transcript-rewind-proof.test.ts new file mode 100644 index 00000000000..08be8c4c1f4 --- /dev/null +++ b/src/main/claude/claude-transcript-rewind-proof.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { proveClaudeTranscriptBranchFromJsonl } from './claude-transcript-branch-proof' + +const row = (uuid: string, parentUuid: string | null, extra = {}) => + JSON.stringify({ type: 'assistant', sessionId: 'provider', uuid, parentUuid, ...extra }) +const marker = (leafUuid: string) => + JSON.stringify({ type: 'last-prompt', sessionId: 'provider', leafUuid }) +const graph = [row('root', null), row('kept', 'root'), row('old', 'kept')] +const prove = (rows: string[], leaf: string, intentionalRewindUuid?: string) => + proveClaudeTranscriptBranchFromJsonl({ + contents: `${[...rows, marker(leaf)].join('\n')}\n`, + providerSessionId: 'provider', + previousLeafUuid: 'old', + intentionalRewindUuid + }) + +describe('explicit Claude rewind ancestry', () => { + it('admits only the exact requested main-chain ancestor', () => { + expect(prove(graph, 'kept', 'kept')).toEqual({ + leafUuid: 'kept', + relation: 'intentional-rewind' + }) + expect(() => prove(graph, 'kept')).toThrow('sibling') + expect(() => prove(graph, 'kept', 'root')).toThrow('target') + expect(() => prove(graph, 'old', 'old')).toThrow('not an ancestor') + }) + it('keeps sibling and sidechain rejection even with explicit intent', () => { + expect(() => prove([...graph, row('sibling', 'root')], 'sibling', 'sibling')).toThrow( + 'not an ancestor' + ) + expect(() => + prove( + [row('root', null), row('kept', 'root', { isSidechain: true }), row('old', 'kept')], + 'kept', + 'kept' + ) + ).toThrow() + }) + it('refuses missing, reordered, or cyclic ancestry', () => { + expect(() => prove(graph.slice(1), 'kept', 'kept')).toThrow('missing ancestor') + expect(() => prove([graph[1]!, graph[0]!, graph[2]!], 'kept', 'kept')).toThrow( + 'parent row follows' + ) + expect(() => prove([row('root', 'old'), ...graph.slice(1)], 'kept', 'kept')).toThrow('cycle') + }) +}) diff --git a/src/main/codex/codex-structured-rewind.test.ts b/src/main/codex/codex-structured-rewind.test.ts new file mode 100644 index 00000000000..64ebff43e38 --- /dev/null +++ b/src/main/codex/codex-structured-rewind.test.ts @@ -0,0 +1,334 @@ +import { describe, expect, it, vi } from 'vitest' +import { CodexAppServerRequestError } from './codex-app-server-connection' +import type { CodexSession } from './codex-structured-session-state' +import { recoverCodexRewind, rewindCodexSession } from './codex-structured-rewind' +import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from '../native-chat/agent-session-wire/agent-session-history-page-bounds' +import { openCodexThread } from './codex-structured-thread-open' + +function fixture(reverted = true) { + const request = vi.fn(async (method: string): Promise => { + if (method === 'thread/read') { + return { thread: { id: 'thread', historyMode: 'paginated', status: { type: 'idle' } } } + } + if (method === 'thread/revert') { + reverted = true + return { + thread: { id: 'thread', turns: [] }, + turnsBackwardsCursor: 'turn-cursor', + itemsBackwardsCursor: 'item-cursor' + } + } + if (method === 'thread/turns/list') { + return { data: [...(reverted ? [] : [{ id: 'drop' }]), { id: 'kept' }], nextCursor: null } + } + return { + data: [ + { + turnId: 'kept', + item: { + id: 'item-1', + type: 'userMessage', + content: [{ type: 'text', text: 'kept prompt' }] + } + } + ], + nextCursor: null + } + }) + const session = { + connection: { request }, + threadId: 'thread', + fence: 2, + ended: false, + historyMode: 'paginated', + activeTurnIds: new Set() + } as unknown as CodexSession + return { request, session } +} + +describe('Codex rewind', () => { + it('recovers verified history from fresh cursors without repeating revert', async () => { + const { session, request } = fixture() + expect(await recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).toMatchObject({ + ok: true, + items: [{ body: { kind: 'message', blocks: [{ type: 'text', text: 'kept prompt' }] } }] + }) + expect(request.mock.calls.map(([method]) => method)).toEqual([ + 'thread/read', + 'thread/turns/list', + 'thread/items/list' + ]) + for (const method of ['thread/turns/list', 'thread/items/list']) { + expect(request).toHaveBeenCalledWith( + method, + expect.objectContaining({ cursor: null, sortDirection: 'desc' }), + expect.anything() + ) + } + }) + it('recognizes an unapplied rewind from the still-present target', async () => { + const { session, request } = fixture() + const original = request.getMockImplementation()! + request.mockImplementation(async (method) => + method === 'thread/turns/list' + ? { data: [{ id: 'drop' }, { id: 'kept' }], nextCursor: null } + : original(method) + ) + expect(await recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({ + ok: false, + reason: 'provider-refused' + }) + expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false) + }) + it.each(['cycle', 'pages', 'entries', 'bytes'] as const)( + 'bounds recovery by %s and never returns partial history', + async (limit) => { + const { session, request } = fixture() + const original = request.getMockImplementation()! + let pages = 0 + request.mockImplementation(async (method) => { + if (method !== 'thread/turns/list') { + return original(method) + } + pages++ + if (limit === 'entries') { + return { + data: Array.from({ length: 1025 }, (_, i) => ({ id: String(i) })), + nextCursor: null + } + } + if (limit === 'bytes') { + return { + data: [], + padding: 'x'.repeat(AGENT_SESSION_HISTORY_MAX_PAGE_BYTES), + nextCursor: null + } + } + return { data: [], nextCursor: limit === 'cycle' ? 'repeated' : String(pages) } + }) + await expect(recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).rejects.toThrow( + 'history-limit' + ) + expect(pages).toBeLessThanOrEqual(100) + expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false) + } + ) + it('keeps an interrupted recovery retryable with read-only requests', async () => { + const { session, request } = fixture() + const original = request.getMockImplementation()! + request.mockImplementation(async (method) => { + if (method === 'thread/items/list') { + throw new Error('offline') + } + return original(method) + }) + await expect(recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).rejects.toThrow( + 'offline' + ) + request.mockImplementation(original) + expect(await recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).toMatchObject({ + ok: true + }) + expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false) + }) + it('refuses activity arriving during recovery hydration', async () => { + const { session, request } = fixture() + const original = request.getMockImplementation()! + request.mockImplementation(async (method) => { + if (method === 'thread/items/list') { + session.activeTurnIds!.add('racing-turn') + } + return original(method) + }) + expect(await recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({ + ok: false, + reason: 'busy' + }) + }) + it('uses native revert and reads both retained indexes despite empty response turns', async () => { + const { session, request } = fixture(false) + const onPrepared = vi.fn[1]['onPrepared']>>( + async (items) => { + expect(items).toMatchObject([{ identity: { turnId: 'kept' } }]) + expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false) + } + ) + expect( + await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop', onPrepared }) + ).toMatchObject({ + ok: true, + items: [{ body: { kind: 'message' } }] + }) + expect(onPrepared).toHaveBeenCalledTimes(1) + expect(request).toHaveBeenCalledWith( + 'thread/revert', + { threadId: 'thread', beforeTurnId: 'drop' }, + { timeoutMs: undefined } + ) + expect(request).toHaveBeenCalledWith( + 'thread/turns/list', + expect.objectContaining({ cursor: 'turn-cursor', sortDirection: 'desc' }), + expect.anything() + ) + expect(request).toHaveBeenCalledWith( + 'thread/items/list', + expect.objectContaining({ cursor: 'item-cursor', sortDirection: 'desc' }), + expect.anything() + ) + }) + it('refuses a known legacy thread before making a request', async () => { + const { session, request } = fixture() + session.historyMode = 'legacy' + expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({ + ok: false, + reason: 'history-not-paginated' + }) + expect(request).not.toHaveBeenCalled() + }) + it('refuses history exceeding hydration capacity before mutating the provider', async () => { + const { session, request } = fixture(false) + const original = request.getMockImplementation()! + const turns = Array.from({ length: 600 }, (_, i) => String(i)) + request.mockImplementation(async (method) => { + if (method === 'thread/turns/list') { + return { data: [{ id: 'drop' }, ...turns.map((id) => ({ id }))], nextCursor: null } + } + if (method === 'thread/items/list') { + return { + data: turns.map((turnId) => ({ + turnId, + item: { id: turnId, type: 'userMessage', content: [{ type: 'text', text: 'x' }] } + })), + nextCursor: null + } + } + return original(method) + }) + const onReverted = vi.fn() + expect( + await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop', onReverted }) + ).toEqual({ ok: false, reason: 'history-limit' }) + expect(onReverted).not.toHaveBeenCalled() + expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false) + }) + it('refuses a missing target before mutation', async () => { + const { session, request } = fixture() + expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'missing' })).toEqual({ + ok: false, + reason: 'invalid-target' + }) + expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false) + }) + it('rechecks provider idleness after preflight hydration', async () => { + const { session, request } = fixture(false) + const original = request.getMockImplementation()! + let reads = 0 + request.mockImplementation(async (method) => { + if (method === 'thread/read' && ++reads === 2) { + return { thread: { id: 'thread', status: { type: 'active' } } } + } + return original(method) + }) + expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({ + ok: false, + reason: 'busy' + }) + expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false) + }) + it('maps native legacy refusal without exposing provider text or falling back', async () => { + const { session, request } = fixture(false) + const original = request.getMockImplementation()! + request.mockImplementation(async (method) => { + if (method === 'thread/read') { + return { thread: { id: 'thread', status: { type: 'idle' } } } + } + if (method !== 'thread/revert') { + return original(method) + } + throw new CodexAppServerRequestError( + 'thread/revert', + -32600, + 'thread/revert only supports paginated threads' + ) + }) + expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({ + ok: false, + reason: 'history-not-paginated' + }) + expect(request.mock.calls.map(([method]) => method)).toEqual([ + 'thread/read', + 'thread/turns/list', + 'thread/items/list', + 'thread/read', + 'thread/revert' + ]) + }) + it('refuses activity arriving during the preflight await', async () => { + const { session, request } = fixture() + request.mockImplementationOnce(async () => { + session.activeTurnIds!.add('racing-turn') + return { thread: { id: 'thread', status: { type: 'idle' } } } + }) + expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({ + ok: false, + reason: 'busy' + }) + expect(request).toHaveBeenCalledTimes(1) + }) + it('treats hydration failure after revert as unknown and never retries revert', async () => { + const { session, request } = fixture(false) + const original = request.getMockImplementation()! + let reverted = false + request.mockImplementation(async (method) => { + if (method === 'thread/revert') { + reverted = true + } + if (method === 'thread/items/list' && reverted) { + throw new Error('offline') + } + return original(method) + }) + await expect(rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).rejects.toThrow( + 'offline' + ) + expect(request.mock.calls.filter(([method]) => method === 'thread/revert')).toHaveLength(1) + }) + it('captures history mode at both start and resume without changing defaults', async () => { + for (const resumeThreadId of [null, 'thread']) { + const request = vi.fn(async (_method: string, _params?: unknown) => ({ + thread: { id: 'thread', historyMode: 'legacy' } + })) + expect( + await openCodexThread({ request }, { cwd: '/workspace', resumeThreadId }, 10) + ).toMatchObject({ historyMode: 'legacy' }) + expect(request.mock.calls[0]?.[1]).not.toHaveProperty('historyMode') + } + }) + it('rejects post-revert history missing an item within a retained turn', async () => { + const { session, request } = fixture(false) + const original = request.getMockImplementation()! + let reverted = false + request.mockImplementation(async (method) => { + if (method === 'thread/revert') { + reverted = true + } + if (method === 'thread/items/list' && !reverted) { + return { + data: [2, 1].map((i) => ({ + turnId: 'kept', + item: { + id: `item-${i}`, + type: 'userMessage', + content: [{ type: 'text', text: `prompt ${i}` }] + } + })), + nextCursor: null + } + } + return original(method) + }) + await expect(rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).rejects.toThrow( + 'proof-mismatch' + ) + }) +}) diff --git a/src/main/codex/codex-structured-rewind.ts b/src/main/codex/codex-structured-rewind.ts new file mode 100644 index 00000000000..e5913b37be5 --- /dev/null +++ b/src/main/codex/codex-structured-rewind.ts @@ -0,0 +1,309 @@ +import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { CODEX_RESTORE_MAX_OPERATIONS } from './codex-structured-journal-translation-restore' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../shared/agent-session-wire' +import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from '../native-chat/agent-session-wire/agent-session-history-page-bounds' +import { isCodexAppServerRequestError } from './codex-app-server-connection' +import type { CodexSession } from './codex-structured-session-state' + +const MAX_PAGES = 100 +const MAX_ENTRIES = CODEX_RESTORE_MAX_OPERATIONS + +class CodexRewindTargetRetainedError extends Error {} +class CodexRewindTargetMissingError extends Error {} + +function record(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('agent_session_rewind:invalid-provider-response') + } + return value as Record +} + +function cursor(value: unknown): string | null { + if (value === null || (typeof value === 'string' && value.length > 0)) { + return value + } + throw new Error('agent_session_rewind:invalid-provider-cursor') +} + +/** Read both indexes to completion before accepting the retained history. */ +export async function verifyCodexRevertedHistory( + session: Pick, + reply: Record, + beforeTurnId: string, + timeoutMs?: number, + targetPresence: 'absent' | 'present' = 'absent' +): Promise<{ identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[]> { + let bytes = 0 + let entries = 0 + const turns = new Map() + for (const [method, firstCursor] of [ + ['thread/turns/list', cursor(reply.turnsBackwardsCursor)], + ['thread/items/list', cursor(reply.itemsBackwardsCursor)] + ] as const) { + let next = firstCursor + const seen = new Set() + for (let page = 0; ; page += 1) { + if (page >= MAX_PAGES || (next !== null && seen.has(next))) { + throw new Error('agent_session_rewind:history-limit') + } + if (next !== null) { + seen.add(next) + } + const result = record( + await session.connection.request( + method, + { + threadId: session.threadId, + cursor: next, + sortDirection: 'desc', + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + }, + { timeoutMs } + ) + ) + if (!Array.isArray(result.data)) { + throw new Error('agent_session_rewind:invalid-provider-page') + } + bytes += Buffer.byteLength(JSON.stringify(result), 'utf8') + entries += result.data.length + if (bytes > AGENT_SESSION_HISTORY_MAX_PAGE_BYTES || entries > MAX_ENTRIES) { + throw new Error('agent_session_rewind:history-limit') + } + for (const raw of result.data) { + const item = record(raw) + const turnId = method === 'thread/turns/list' ? item.id : item.turnId + if (turnId === beforeTurnId && targetPresence === 'absent') { + throw new CodexRewindTargetRetainedError('agent_session_rewind:target-retained') + } + if (typeof turnId !== 'string' || !turnId) { + throw new Error('agent_session_rewind:invalid-retained-turn') + } + if (method === 'thread/turns/list') { + if (turns.has(turnId)) { + throw new Error('agent_session_rewind:duplicate-retained-turn') + } + turns.set(turnId, { id: turnId, items: [] }) + } else { + const turn = turns.get(turnId) + if (!turn) { + throw new Error('agent_session_rewind:foreign-retained-item') + } + turn.items.push(record(item.item)) + } + } + next = cursor(result.nextCursor) + if (next === null) { + break + } + } + } + if (targetPresence === 'present' && !turns.has(beforeTurnId)) { + throw new CodexRewindTargetMissingError('agent_session_rewind:target-missing') + } + const items = new Map< + string, + { identity: AgentJournalItemIdentity; body: AgentJournalItemBody } + >() + const translator = createCodexJournalTranslator({ + sink: { + appendItem: (identity, body) => { + items.set(agentJournalItemKey(identity), { identity, body }) + }, + appendTombstone: (identity) => { + items.delete(agentJournalItemKey(identity)) + }, + publish: () => {} + }, + primaryThreadId: () => session.threadId + }) + try { + const chronological = [...turns.values()].toReversed() + const retained = + targetPresence === 'present' + ? chronological.slice( + 0, + chronological.findIndex((turn) => turn.id === beforeTurnId) + ) + : chronological + const admission = translator.restoreThread(session.threadId, { + turns: retained.map((turn) => ({ ...turn, items: turn.items.toReversed() })) + }) + if (!admission.accepted) { + throw new Error('agent_session_rewind:history-unreadable') + } + return [...items.values()] + } finally { + translator.dispose() + } +} + +async function preflightCodexRewind( + session: CodexSession, + fence: number, + timeoutMs?: number +): Promise< + { ok: true } | { ok: false; reason: 'invalid-target' | 'history-not-paginated' | 'busy' } +> { + if (session.fence !== fence || session.ended) { + return { ok: false, reason: 'invalid-target' } + } + if (session.historyMode === 'legacy') { + return { ok: false, reason: 'history-not-paginated' } + } + if (session.activeTurnIds?.size || session.dispatchPending) { + return { ok: false, reason: 'busy' } + } + const metadata = record( + await session.connection.request( + 'thread/read', + { threadId: session.threadId, includeTurns: false }, + { timeoutMs } + ) + ) + const thread = record(metadata.thread) + if (thread.id !== session.threadId) { + return { ok: false, reason: 'invalid-target' } + } + if (thread.historyMode === 'legacy') { + session.historyMode = 'legacy' + return { ok: false, reason: 'history-not-paginated' } + } + if ( + record(thread.status).type !== 'idle' || + session.activeTurnIds?.size || + session.dispatchPending + ) { + return { ok: false, reason: 'busy' } + } + if (session.fence !== fence || session.ended) { + return { ok: false, reason: 'invalid-target' } + } + return { ok: true } +} + +export async function recoverCodexRewind( + session: CodexSession, + input: { fence: number; beforeTurnId: string }, + timeoutMs?: number +): ReturnType> { + const admission = await preflightCodexRewind(session, input.fence, timeoutMs) + if (!admission.ok) { + return admission + } + try { + const items = await verifyCodexRevertedHistory( + session, + { turnsBackwardsCursor: null, itemsBackwardsCursor: null }, + input.beforeTurnId, + timeoutMs + ) + if (session.fence !== input.fence || session.ended) { + return { ok: false, reason: 'invalid-target' } + } + if (session.activeTurnIds?.size || session.dispatchPending) { + return { ok: false, reason: 'busy' } + } + return { ok: true, items } + } catch (error) { + if (error instanceof CodexRewindTargetRetainedError) { + return { ok: false, reason: 'provider-refused' } + } + throw error + } +} + +export async function rewindCodexSession( + session: CodexSession, + input: Omit>[0], 'sessionId'>, + timeoutMs?: number +): ReturnType> { + const admission = await preflightCodexRewind(session, input.fence, timeoutMs) + if (!admission.ok) { + return admission + } + let expectedItems: Set + try { + const retained = await verifyCodexRevertedHistory( + session, + { turnsBackwardsCursor: null, itemsBackwardsCursor: null }, + input.beforeTurnId, + timeoutMs, + 'present' + ) + expectedItems = new Set(retained.map(({ identity }) => agentJournalItemKey(identity))) + await input.onPrepared?.(retained) + } catch (error) { + return { + ok: false, + reason: + error instanceof CodexRewindTargetMissingError + ? 'invalid-target' + : error instanceof Error && error.message === 'agent_session_rewind:history-limit' + ? 'history-limit' + : 'provider-refused' + } + } + const current = await preflightCodexRewind(session, input.fence, timeoutMs) + if (!current.ok) { + return current + } + let result: unknown + try { + result = await session.connection.request( + 'thread/revert', + { + threadId: session.threadId, + beforeTurnId: input.beforeTurnId + }, + { timeoutMs } + ) + } catch (error) { + if (isCodexAppServerRequestError(error)) { + if (error.message === 'thread/revert only supports paginated threads') { + session.historyMode = 'legacy' + return { ok: false, reason: 'history-not-paginated' } + } + if (error.code === -32601) { + return { ok: false, reason: 'unsupported' } + } + } + throw error + } + const reply = record(result) + if (record(reply.thread).id !== session.threadId) { + throw new Error('agent_session_rewind:foreign-thread') + } + await input.onReverted?.() + const items = await verifyCodexRevertedHistory(session, reply, input.beforeTurnId, timeoutMs) + if ( + items.length !== expectedItems.size || + items.some(({ identity }) => !expectedItems.has(agentJournalItemKey(identity))) + ) { + throw new Error('agent_session_rewind:proof-mismatch') + } + return { ok: true, items } +} + +export function observeCodexRewindActivity( + session: CodexSession, + method: string, + params: unknown +): void { + if ((readCodexThreadId(params) ?? session.threadId) !== session.threadId) { + return + } + const turnId = readCodexTurnId(params) + if (turnId && method === 'turn/started') { + session.activeTurnIds?.add(turnId) + } + if (turnId && method === 'turn/completed') { + session.activeTurnIds?.delete(turnId) + } +} diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 78855842332..8c8b39ca48b 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -192,6 +192,8 @@ export async function acquireCodexStructuredSession(input: { ...codexSessionLifecycle(acquireInput.fence, acquired.acquisitionGeneration as string), threadId: opened.threadId, historyPath: opened.historyPath, + historyMode: opened.historyMode, + activeTurnIds: new Set(), prompts: acquisition.prompts, options: restoredCodexSessionOptions(acquireInput.options), reportedOptions: reportedCodexThreadOptions(opened), diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 5b551c8b01e..ebd7c3331bf 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -1,3 +1,4 @@ +import * as codexRewind from './codex-structured-rewind' import type { AgentJournalMessageItem, AgentSessionJournalIdentity @@ -119,6 +120,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap method: string, params: unknown ): CodexJournalTranslationAdmission { + codexRewind.observeCodexRewindActivity(session, method, params) if (this.turnCancellation.handleNotification(sessionId, session, method, params)) { return { accepted: true } } @@ -177,8 +179,13 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap fence: number }): Promise { const session = this.session(input.sessionId) - await this.turnCancellation.captureBaseline(session) - return dispatchCodexTurn(session, input, this.deps.requestTimeoutMs) + session.dispatchPending = true + try { + await this.turnCancellation.captureBaseline(session) + return await dispatchCodexTurn(session, input, this.deps.requestTimeoutMs) + } finally { + session.dispatchPending = false + } } async cancelTurn(input: { @@ -191,6 +198,17 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap return turnId ? this.turnCancellation.cancel(session, turnId) : { cancelled: false } } + rewindSupport: NonNullable = (sessionId) => + this.sessions.get(sessionId)?.historyMode === 'legacy' + ? { supported: false, reason: 'history-not-paginated' } + : { supported: true } + + rewind: NonNullable = (input) => + codexRewind.rewindCodexSession(this.session(input.sessionId), input, this.deps.requestTimeoutMs) + + recoverRewind: NonNullable = (input) => + codexRewind.recoverCodexRewind(this.session(input.sessionId), input, this.deps.requestTimeoutMs) + compact: NonNullable = (input) => { const session = this.session(input.sessionId) return this.compactions.run( diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 5fd82f22ff9..12ba28d712f 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -65,6 +65,9 @@ export type CodexSession = { acquisitionGeneration: string threadId: string historyPath: string | null + historyMode?: 'legacy' | 'paginated' + activeTurnIds?: Set + dispatchPending?: boolean prompts: CodexAcquisitionWindow['prompts'] options: Map reportedOptions: { model?: string; effort?: string } diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index de3dbe235d8..ac4c16d8a6a 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -16,6 +16,7 @@ export type CodexOpenedThread = { thread?: Record /** Rollout file Codex named, when it named one. */ historyPath: string | null + historyMode?: 'legacy' | 'paginated' model?: string effort?: string } @@ -92,6 +93,9 @@ export async function openCodexThread( threadId, thread, historyPath: readCodexThreadPath(opened), + ...(thread.historyMode === 'legacy' || thread.historyMode === 'paginated' + ? { historyMode: thread.historyMode } + : {}), ...(model ? { model } : {}), ...(effort ? { effort } : {}) } diff --git a/src/main/daemon/daemon-errors.ts b/src/main/daemon/daemon-errors.ts index 12136d37a5b..ff3ce0ede97 100644 --- a/src/main/daemon/daemon-errors.ts +++ b/src/main/daemon/daemon-errors.ts @@ -1,5 +1,7 @@ // Error classes shared across the daemon protocol boundary (client, server, // host). Split from types.ts, which is capped for wire-shape declarations. +import { SESSION_NOT_FOUND_MESSAGE_PREFIX } from '../../shared/pty-attach-absence-evidence' + const ATTACH_CANCELED_PREFIX = 'Attach canceled for session ' export class TerminalAttachCanceledError extends Error { @@ -56,7 +58,7 @@ export const DAEMON_UNAVAILABLE_RECONNECT_MESSAGE = 'Daemon temporarily unavaila export class SessionNotFoundError extends Error { constructor(sessionId: string) { - super(`Session not found: ${sessionId}`) + super(`${SESSION_NOT_FOUND_MESSAGE_PREFIX}${sessionId}`) this.name = 'SessionNotFoundError' } } @@ -87,8 +89,7 @@ export function isDaemonEndpointGoneError(err: unknown): boolean { } export function decodeDaemonResponseError(message: string): Error { - const prefix = 'Session not found: ' - return message.startsWith(prefix) - ? new SessionNotFoundError(message.slice(prefix.length)) + return message.startsWith(SESSION_NOT_FOUND_MESSAGE_PREFIX) + ? new SessionNotFoundError(message.slice(SESSION_NOT_FOUND_MESSAGE_PREFIX.length)) : new DaemonProtocolError(message) } diff --git a/src/main/daemon/daemon-pty-adapter-attach-only-ownership.test.ts b/src/main/daemon/daemon-pty-adapter-attach-only-ownership.test.ts new file mode 100644 index 00000000000..fae84b17f38 --- /dev/null +++ b/src/main/daemon/daemon-pty-adapter-attach-only-ownership.test.ts @@ -0,0 +1,63 @@ +/* A fresh adapter (new app process) taking over a session an older process spawned. */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { rmSync } from 'node:fs' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { SessionNotFoundError } from './daemon-errors' +import { createMockSubprocess, startDaemonAdapterHarness } from './daemon-pty-adapter-test-harness' + +// After an app restart a settled orchestration worker's pane reattaches under `attachOnly`. That +// reattach is what makes the restarted main process an owner of the daemon session, so every +// downstream consumer of the adapter cache — `hasPty`, resize, write, liveness, the serializer — +// sees a session main actually owns. The pane used to attach renderer-only, leaving this cache +// empty and `pty:hasPty` fabricating an absence the renderer closed the tab on (#16904 regression). +describe('DaemonPtyAdapter attach-only ownership after a restart', () => { + let harness: Awaited> + let restartedAdapter: DaemonPtyAdapter + + beforeEach(async () => { + harness = await startDaemonAdapterHarness(() => createMockSubprocess()) + restartedAdapter = new DaemonPtyAdapter({ + socketPath: harness.socketPath, + tokenPath: harness.tokenPath + }) + }) + afterEach(async () => { + restartedAdapter.dispose() + harness.adapter.dispose() + await harness.server.shutdown() + rmSync(harness.dir, { recursive: true, force: true }) + }) + + it('owns the session after an attach-only spawn, so the cache alone answers present', async () => { + const { id } = await harness.adapter.spawn({ cols: 80, rows: 24 }) + expect(restartedAdapter.hasPty(id)).toBe(false) + + const attached = await restartedAdapter.spawn({ + cols: 80, + rows: 24, + sessionId: id, + attachOnly: true + }) + + expect(attached.id).toBe(id) + expect(attached.isReattach).toBe(true) + expect(restartedAdapter.hasPty(id)).toBe(true) + }) + + it('refuses to create a session the daemon does not have', async () => { + await expect( + restartedAdapter.spawn({ cols: 80, rows: 24, sessionId: 'never-spawned', attachOnly: true }) + ).rejects.toBeInstanceOf(SessionNotFoundError) + expect(restartedAdapter.hasPty('never-spawned')).toBe(false) + }) + + it('refuses to replace a session that exited before the attach', async () => { + const { id } = await harness.adapter.spawn({ cols: 80, rows: 24 }) + await harness.adapter.shutdown(id, { immediate: true }) + + await expect( + restartedAdapter.spawn({ cols: 80, rows: 24, sessionId: id, attachOnly: true }) + ).rejects.toBeInstanceOf(SessionNotFoundError) + expect(restartedAdapter.hasPty(id)).toBe(false) + }) +}) diff --git a/src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts b/src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts new file mode 100644 index 00000000000..bce6f88aa40 --- /dev/null +++ b/src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts @@ -0,0 +1,482 @@ +import { describe, expect, it, vi } from 'vitest' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { SessionNotFoundError } from '../daemon/daemon-errors' +import { makePaneKey } from '../../shared/stable-pane-id' +import { registerPtyHandlers } from './pty' +import * as stableAdoption from './pty/pane/adopt-stable' +import { ptySizes } from './pty/delivery/visibility-state' +import { isHiddenRendererPty } from './pty-hidden-delivery-gate' +import { paneSpawnReservationsByOwnerKey } from './pty/pane/spawn-reservation' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +describe('pty:spawn under a persisted main-owned resume fence', () => { + const { + handlers, + mainWindow, + installDaemonTestProvider, + recoveredAgentClaim, + recoveredAgentSurface + } = setupPtyIpcSuite() + + function buildFencedPaneContext(name: string) { + const worktreeId = `repo-1::/tmp/${name}` + const tabId = `tab-${name}` + const leafId = '5b5b5b5b-5b5b-4b5b-8b5b-5b5b5b5b5b5b' + const ptyId = `pty-${name}` + const paneKey = makePaneKey(tabId, leafId) + let session = { + tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, ptyId }] }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } as Record + } + }, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { worktreeId, automaticResumeBlockedBy: 'legacy-orchestration-worker' } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: `inc-${name}` } + } + const store = { + getWorkspaceSession: vi.fn(() => session), + setWorkspaceSession: vi.fn((next: typeof session) => { + session = next + }), + flushOrThrow: vi.fn(), + persistPtyBinding: vi.fn(), + getFolderWorkspace: vi.fn(() => undefined), + getFolderWorkspaces: vi.fn(() => []), + getProjectGroups: vi.fn(() => []), + getRepos: vi.fn(() => []) + } + const runtime = { + setPtyController: vi.fn((_controller: unknown) => {}), + resolveTerminalPane: vi.fn(() => { + throw new Error('terminal_not_found') + }), + createPreAllocatedTerminalHandle: vi.fn(() => `term-${name}`), + preAllocateHandleForPty: vi.fn(() => `term-${name}`), + registerPreAllocatedHandleForPty: vi.fn(), + beginPtyRegistration: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + assertPtyRegistrationAllowed: vi.fn(), + preparePtyExecutionContext: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + seedHeadlessTerminal: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + const spawnArgs = { + cols: 80, + rows: 24, + cwd: `/tmp/${name}`, + worktreeId, + tabId, + leafId, + sessionId: ptyId, + initiallyHidden: true, + env: { ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: worktreeId } + } + return { ptyId, store, runtime, spawnArgs } + } + + it('attaches the restored session so main owns it, without a fresh spawn', async () => { + const { ptyId, store, runtime, spawnArgs } = buildFencedPaneContext('fenced-live-worker') + const providerSpawn = vi.fn(async (options: { attachOnly?: boolean; sessionId?: string }) => { + if (!options.attachOnly) { + throw new Error('a fenced pane must never reach a fresh spawn') + } + return { id: options.sessionId!, incarnationId: 'inc-fenced-live-worker', isReattach: true } + }) + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + + await expect(handlers.get('pty:spawn')!(null, spawnArgs)).resolves.toMatchObject({ id: ptyId }) + expect(providerSpawn.mock.calls.every(([options]) => options.attachOnly === true)).toBe(true) + expect(runtime.onPtyExit).not.toHaveBeenCalled() + }) + + it('refuses to mint a replacement session when the owner proves the session absent', async () => { + const { store, runtime, spawnArgs } = buildFencedPaneContext('fenced-absent-worker') + const providerSpawn = vi.fn(async (options: { attachOnly?: boolean; sessionId?: string }) => { + if (options.attachOnly) { + throw new SessionNotFoundError(options.sessionId ?? '') + } + return { id: 'pty-replacement', incarnationId: 'inc-replacement' } + }) + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + + await expect(handlers.get('pty:spawn')!(null, spawnArgs)).resolves.toEqual({ + id: spawnArgs.sessionId, + exitedBeforeAttach: true + }) + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + expect(store.persistPtyBinding).not.toHaveBeenCalled() + expect(runtime.beginPtyRegistration).not.toHaveBeenCalled() + expect(isHiddenRendererPty(spawnArgs.sessionId)).toBe(false) + expect(runtime.registerPty).not.toHaveBeenCalled() + expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled() + expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled() + expect(runtime.onPtyExit).not.toHaveBeenCalled() + expect(providerSpawn).toHaveBeenCalledTimes(1) + expect(providerSpawn.mock.calls.every(([options]) => options.attachOnly === true)).toBe(true) + }) + + it('allows an unfenced fresh tab to spawn', async () => { + const { store, runtime, spawnArgs } = buildFencedPaneContext('fenced-no-session') + const providerSpawn = installDaemonTestProvider() + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + + await handlers.get('pty:spawn')!(null, { + ...spawnArgs, + sessionId: undefined, + tabId: 'tab-unbound', + leafId: '6c6c6c6c-6c6c-4c6c-8c6c-6c6c6c6c6c6c' + }) + + expect(providerSpawn.mock.calls.at(-1)?.[0]).not.toMatchObject({ attachOnly: true }) + }) + it.each([new Error('daemon timeout'), new Error('Session not found: untyped')])( + 'preserves every binding on unverifiable local evidence: %s', + async (error) => { + const { store, runtime, spawnArgs } = buildFencedPaneContext('unverifiable-worker') + const providerSpawn = vi.fn(async () => { + throw error + }) + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + await expect(handlers.get('pty:spawn')!(null, spawnArgs)).resolves.toEqual({ + id: spawnArgs.sessionId, + reattachUnverifiable: true + }) + expect(providerSpawn).toHaveBeenCalledTimes(1) + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + expect(store.persistPtyBinding).not.toHaveBeenCalled() + expect(runtime.registerPty).not.toHaveBeenCalled() + expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled() + expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled() + expect(runtime.onPtyExit).not.toHaveBeenCalled() + } + ) + + it.each([true, false])( + 'never spawns an ownerless fenced pane (session supplied: %s)', + async (supplied) => { + const { store, runtime, spawnArgs } = buildFencedPaneContext('ownerless-worker') + store.getWorkspaceSession().terminalLayoutsByTabId[spawnArgs.tabId].ptyIdsByLeafId = {} + const providerSpawn = installDaemonTestProvider() + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + await expect( + handlers.get('pty:spawn')!(null, { + ...spawnArgs, + sessionId: supplied ? spawnArgs.sessionId : undefined + }) + ).resolves.toMatchObject({ reattachUnverifiable: true }) + expect(providerSpawn).not.toHaveBeenCalled() + expect(runtime.beginPtyRegistration).not.toHaveBeenCalled() + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + } + ) + + it.each([true, false])( + 'runtime controller refuses ownerless fenced pane (session supplied: %s)', + async (supplied) => { + const { store, runtime, spawnArgs } = buildFencedPaneContext('runtime-ownerless-worker') + store.getWorkspaceSession().terminalLayoutsByTabId[spawnArgs.tabId].ptyIdsByLeafId = {} + const providerSpawn = installDaemonTestProvider() + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + const controller = runtime.setPtyController.mock.calls[0]![0] as { + spawn: (args: unknown) => Promise + } + await expect( + controller.spawn({ ...spawnArgs, sessionId: supplied ? spawnArgs.sessionId : undefined }) + ).resolves.toMatchObject({ reattachUnverifiable: true }) + expect(providerSpawn).not.toHaveBeenCalled() + expect(runtime.beginPtyRegistration).not.toHaveBeenCalled() + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + } + ) + it('settles execute-stage refusal without registration, hidden marks or size changes', async () => { + const { store, runtime, spawnArgs } = buildFencedPaneContext('execute-refusal') + const earlyAdoption = vi.spyOn(stableAdoption, 'adoptStablePane').mockResolvedValueOnce(null) + const providerSpawn = vi.fn(async () => { + runtime.preparePtyExecutionContext.mockClear() + throw new SessionNotFoundError(spawnArgs.sessionId) + }) + installDaemonTestProvider({ spawn: providerSpawn }) + ptySizes.set(spawnArgs.sessionId, { cols: 120, rows: 40 }) + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + try { + await expect( + handlers.get('pty:spawn')!(null, { ...spawnArgs, initiallyHidden: true }) + ).resolves.toEqual({ id: spawnArgs.sessionId, exitedBeforeAttach: true }) + expect(providerSpawn).toHaveBeenCalledTimes(1) + expect(runtime.cancelPendingPtyRegistration).toHaveBeenCalledWith(spawnArgs.sessionId) + expect(runtime.registerPty).not.toHaveBeenCalled() + expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled() + expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled() + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + expect(store.persistPtyBinding).not.toHaveBeenCalled() + expect(isHiddenRendererPty(spawnArgs.sessionId)).toBe(false) + expect(ptySizes.get(spawnArgs.sessionId)).toEqual({ cols: 120, rows: 40 }) + expect(paneSpawnReservationsByOwnerKey.size).toBe(0) + } finally { + earlyAdoption.mockRestore() + } + }) + it.each([false, true])( + 'runtime controller carries a fenced owner refusal (claim: %s)', + async (ensureClaim) => { + const { store, runtime, spawnArgs } = buildFencedPaneContext('runtime-absent-worker') + const providerSpawn = vi.fn(async () => { + runtime.preparePtyExecutionContext.mockClear() + throw new SessionNotFoundError(spawnArgs.sessionId) + }) + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + const controller = runtime.setPtyController.mock.calls[0]![0] as { + spawn: (args: unknown) => Promise + } + await expect( + controller.spawn({ + ...spawnArgs, + ...(ensureClaim + ? { agentSessionEnsure: { claim: recoveredAgentClaim, surface: recoveredAgentSurface } } + : {}) + }) + ).resolves.toEqual({ + id: spawnArgs.sessionId, + exitedBeforeAttach: true + }) + expect(providerSpawn).toHaveBeenCalledTimes(1) + expect(runtime.registerPty).not.toHaveBeenCalled() + expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled() + expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled() + expect(runtime.cancelPendingPtyRegistration).toHaveBeenCalledWith(spawnArgs.sessionId) + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + expect(store.persistPtyBinding).not.toHaveBeenCalled() + expect(paneSpawnReservationsByOwnerKey.size).toBe(0) + } + ) + it.each([false, true])( + 'carries unverifiable through the runtime controller (pre-adopted: %s)', + async (preAdopted) => { + const { store, runtime, spawnArgs } = buildFencedPaneContext('unverifiable-controller') + const spawn = vi.fn(async () => { + throw new Error('daemon unavailable') + }) + installDaemonTestProvider({ spawn }) + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + const controller = runtime.setPtyController.mock.calls[0]![0] as { + spawn: (args: unknown) => Promise + } + const result = await controller.spawn({ + ...spawnArgs, + ...(preAdopted + ? { + adoptedStablePane: { + result: { id: spawnArgs.sessionId, reattachUnverifiable: true }, + owner: { + ptyId: spawnArgs.sessionId, + tabId: spawnArgs.tabId, + leafId: spawnArgs.leafId + } + } + } + : {}) + }) + expect(result).toEqual({ id: spawnArgs.sessionId, reattachUnverifiable: true }) + expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled() + if (preAdopted) { + expect(spawn).not.toHaveBeenCalled() + expect(runtime.beginPtyRegistration).not.toHaveBeenCalled() + expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled() + } + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + } + ) + it.each(['ipc', 'runtime'] as const)( + 'preserves a fence committed during %s attach', + async (entry) => { + const { store, runtime, spawnArgs } = buildFencedPaneContext('late-fenced-worker') + const record = + store.getWorkspaceSession().sleepingAgentSessionsByPaneKey[ + makePaneKey(spawnArgs.tabId, spawnArgs.leafId) + ] + record.automaticResumeBlockedBy = '' + const spawn = vi.fn(async () => { + record.automaticResumeBlockedBy = 'legacy-orchestration-worker' + throw new SessionNotFoundError(spawnArgs.sessionId) + }) + installDaemonTestProvider({ spawn }) + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + const controller = runtime.setPtyController.mock.calls[0]![0] as { + spawn: (args: unknown) => Promise + } + const result = + entry === 'ipc' + ? await handlers.get('pty:spawn')!(null, spawnArgs) + : await controller.spawn(spawnArgs) + expect(result).toEqual({ id: spawnArgs.sessionId, reattachUnverifiable: true }) + expect(spawn).toHaveBeenCalledTimes(1) + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + expect(runtime.onPtyExit).not.toHaveBeenCalled() + } + ) + it('refuses a claim spawn when the fence commits during owner reconciliation', async () => { + const { store, runtime, spawnArgs } = buildFencedPaneContext('late-claim-fence') + const record = + store.getWorkspaceSession().sleepingAgentSessionsByPaneKey[ + makePaneKey(spawnArgs.tabId, spawnArgs.leafId) + ] + record.automaticResumeBlockedBy = '' + const spawn = vi.fn(async () => ({ id: 'replacement' })) + const listProcesses = vi.fn(async () => { + record.automaticResumeBlockedBy = 'legacy-orchestration-worker' + return [] + }) + installDaemonTestProvider({ spawn, listProcesses }) + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + const controller = runtime.setPtyController.mock.calls[0]![0] as { + spawn: (args: unknown) => Promise + } + await expect( + controller.spawn({ + ...spawnArgs, + agentSessionEnsure: { claim: recoveredAgentClaim, surface: recoveredAgentSurface } + }) + ).resolves.toEqual({ id: spawnArgs.sessionId, reattachUnverifiable: true }) + expect(listProcesses).toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() + expect(runtime.registerPty).not.toHaveBeenCalled() + expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled() + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + expect(runtime.cancelPendingPtyRegistration).toHaveBeenCalledWith(spawnArgs.sessionId) + expect(paneSpawnReservationsByOwnerKey.size).toBe(0) + }) +}) diff --git a/src/main/ipc/pty-runtime-kill-and-exit.test.ts b/src/main/ipc/pty-runtime-kill-and-exit.test.ts index 2200b2b6043..3786ee3c23f 100644 --- a/src/main/ipc/pty-runtime-kill-and-exit.test.ts +++ b/src/main/ipc/pty-runtime-kill-and-exit.test.ts @@ -542,4 +542,29 @@ describe('registerPtyHandlers', () => { vi.useRealTimers() } }) + it.each(['renderer', 'runtime'] as const)( + '%s kill treats daemon session-not-found as completed shutdown', + async (entry) => { + const id = 'daemon-missing-session' + const shutdown = vi.fn(async () => { + throw new Error(`Session not found: ${id}`) + }) + installDaemonTestProvider({ shutdown }) + setPtyOwnership(id, null) + const runtime = { setPtyController: vi.fn(), onPtyExit: vi.fn() } + registerPtyHandlers(mainWindow as never, runtime as never) + if (entry === 'renderer') { + await expect(handlers.get('pty:kill')!(null, { id })).resolves.toBeUndefined() + } else { + const controller = runtime.setPtyController.mock.calls[0]![0] as { + kill: (id: string) => boolean + } + expect(controller.kill(id)).toBe(true) + } + await vi.waitFor(() => expect(runtime.onPtyExit).toHaveBeenCalled()) + expect(shutdown).toHaveBeenCalledTimes(1) + const { ptyOwnership } = await import('./pty/provider/ownership-state') + expect(ptyOwnership.has(id)).toBe(false) + } + ) }) diff --git a/src/main/ipc/pty/ipc/spawn-begin.ts b/src/main/ipc/pty/ipc/spawn-begin.ts index 0b59d347e4d..b7202567312 100644 --- a/src/main/ipc/pty/ipc/spawn-begin.ts +++ b/src/main/ipc/pty/ipc/spawn-begin.ts @@ -12,6 +12,7 @@ import { paneSpawnReservationsByOwnerKey, pendingRuntimePaneCreatesByOwnerKey } from '../pane/spawn-reservation' +import { isStablePaneResumeBlocked } from '../pane/stable-pane-resume-fence' import { resolveStablePaneOwner } from '../pane/stable-owner' import type { PtyIpcSpawnState } from './spawn-state' @@ -96,6 +97,12 @@ export async function beginPtyIpcSpawn( args.connectionId ) : null + if ( + !ctx.earlyStablePaneOwner && + isStablePaneResumeBlocked(ctx.deps.store, earlyPaneKey, args.worktreeId, args.connectionId) + ) { + return { id: args.sessionId ?? '', reattachUnverifiable: true } + } ctx.earlyWorktreeId = args.worktreeId // Reserve early so renderer/runtime materialization cannot start duplicate provider spawns. ctx.paneSpawnReservationKey = earlyReservationKey diff --git a/src/main/ipc/pty/ipc/spawn-execute.ts b/src/main/ipc/pty/ipc/spawn-execute.ts index 2d9de676c39..c6fad43d1ff 100644 --- a/src/main/ipc/pty/ipc/spawn-execute.ts +++ b/src/main/ipc/pty/ipc/spawn-execute.ts @@ -67,6 +67,9 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { }) ctx.result = stablePaneSpawn.result ctx.stablePaneOwner = stablePaneSpawn.owner + if (ctx.result.exitedBeforeAttach || ctx.result.reattachUnverifiable) { + return + } if ( ctx.stablePaneOwner && ctx.isMintedSessionId && diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts index 82e2d33f383..3024d454213 100644 --- a/src/main/ipc/pty/ipc/spawn-run.ts +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -1,4 +1,4 @@ -import { rejectPaneSpawnReservation } from '../pane/spawn-reservation' +import { rejectPaneSpawnReservation, resolvePaneSpawnReservation } from '../pane/spawn-reservation' import { ptySizes } from '../delivery/visibility-state' import { beginPtyIpcSpawn } from './spawn-begin' import { preparePtyIpcSpawnPreflight } from './spawn-preflight' @@ -39,6 +39,17 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg } try { await preparePtyIpcSpawnPreflight(ctx) + const attachOutcome = ctx.preAdoptedStablePane?.result + if (attachOutcome?.exitedBeforeAttach || attachOutcome?.reattachUnverifiable) { + if (ctx.preSpawnHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, false) + } + return resolvePaneSpawnReservation( + ctx.paneSpawnReservationKey, + ctx.paneSpawnReservation, + attachOutcome + ) + } await assemblePtyIpcSpawnEnv(ctx) const earlyReserved = await buildPtyIpcSpawnOptions(ctx).catch((error: unknown) => { restoreProvisionalPtySize(ctx) @@ -53,6 +64,20 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg return earlyReserved } await executePtyIpcSpawn(ctx) + if (ctx.result.exitedBeforeAttach || ctx.result.reattachUnverifiable) { + releaseAbandonedAgentTeamsLeader(ctx) + restoreProvisionalPtySize(ctx) + if (ctx.preSpawnHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, false) + } + deps.runtime?.cancelPendingPtyRegistration?.(ctx.pendingRegistrationPtyId ?? ctx.result.id) + ctx.pendingRegistrationPtyId = null + return resolvePaneSpawnReservation( + ctx.paneSpawnReservationKey, + ctx.paneSpawnReservation, + ctx.result + ) + } return await commitPtyIpcSpawn(ctx) } catch (err) { releaseAbandonedAgentTeamsLeader(ctx) diff --git a/src/main/ipc/pty/pane/stable-owner-retirement.ts b/src/main/ipc/pty/pane/stable-owner-retirement.ts new file mode 100644 index 00000000000..4bf7ccf75f7 --- /dev/null +++ b/src/main/ipc/pty/pane/stable-owner-retirement.ts @@ -0,0 +1,42 @@ +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { Store } from '../../../persistence' +import { retireTerminalSurfaceFromPersistence } from '../../../runtime/mobile-session-terminal-persistence-retirement' +import { resolvePersistedStablePaneOwner, type StablePaneOwner } from './stable-owner' + +export function retirePersistedStablePaneOwner( + store: Store | undefined, + owner: StablePaneOwner, + worktreeId: string, + connectionId: string | null | undefined +): boolean { + if (!store) { + return false + } + const paneKey = makePaneKey(owner.tabId, owner.leafId) + const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined + const current = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) + if (!current) { + // Why: persistence already dropped this pane binding (an earlier stop retired it while the + // runtime kept history), so there is nothing left to clear — that is a completed retirement, + // not a competing owner. Reporting failure here strands the pane after its PTY is proven dead. + return true + } + if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) { + return false + } + const session = store.getWorkspaceSession(hostId) + const retired = retireTerminalSurfaceFromPersistence(session, { + worktreeId, + parentTabId: owner.tabId, + leafId: owner.leafId, + ptyId: owner.ptyId, + ...(current.incarnationId ? { incarnationId: current.incarnationId } : {}) + }) + if (retired === session) { + return false + } + store.setWorkspaceSession(retired, hostId) + store.flushOrThrow() + return true +} diff --git a/src/main/ipc/pty/pane/stable-owner.ts b/src/main/ipc/pty/pane/stable-owner.ts index 731065e59ab..6f1574833d1 100644 --- a/src/main/ipc/pty/pane/stable-owner.ts +++ b/src/main/ipc/pty/pane/stable-owner.ts @@ -1,8 +1,9 @@ +import { isStablePaneResumeBlocked } from './stable-pane-resume-fence' import { toSshExecutionHostId } from '../../../../shared/execution-host' import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' import { UNVERIFIED_PROCESS_EXIT_CODE } from '../../../../shared/terminal-exit-cause' import type { Store } from '../../../persistence' -import { retireTerminalSurfaceFromPersistence } from '../../../runtime/mobile-session-terminal-persistence-retirement' +import { retirePersistedStablePaneOwner } from './stable-owner-retirement' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../../../providers/types' import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' @@ -120,43 +121,6 @@ export function resolveStablePaneOwner( } } -export function retirePersistedStablePaneOwner( - store: Store | undefined, - owner: StablePaneOwner, - worktreeId: string, - connectionId: string | null | undefined -): boolean { - if (!store) { - return false - } - const paneKey = makePaneKey(owner.tabId, owner.leafId) - const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined - const current = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) - if (!current) { - // Why: persistence already dropped this pane binding (an earlier stop retired it while the - // runtime kept history), so there is nothing left to clear — that is a completed retirement, - // not a competing owner. Reporting failure here strands the pane after its PTY is proven dead. - return true - } - if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) { - return false - } - const session = store.getWorkspaceSession(hostId) - const retired = retireTerminalSurfaceFromPersistence(session, { - worktreeId, - parentTabId: owner.tabId, - leafId: owner.leafId, - ptyId: owner.ptyId, - ...(current.incarnationId ? { incarnationId: current.incarnationId } : {}) - }) - if (retired === session) { - return false - } - store.setWorkspaceSession(retired, hostId) - store.flushOrThrow() - return true -} - export type StablePaneSpawnContext = { runtime: OrcaRuntimeService | undefined store?: Store @@ -214,6 +178,13 @@ export async function attachStablePaneOwner( args: StablePaneSpawnContext & { owner: StablePaneOwner } ): Promise<{ result: PtySpawnResult; owner: StablePaneOwner } | null> { const { owner, provider, runtime, spawnOptions } = args + const paneKey = makePaneKey(owner.tabId, owner.leafId) + const blockedAtAttach = isStablePaneResumeBlocked( + args.store, + paneKey, + args.worktreeId, + args.connectionId + ) let result: PtySpawnResult try { result = await provider.spawn({ @@ -233,6 +204,17 @@ export async function attachStablePaneOwner( onPtySpawnCommitted: undefined }) } catch (error) { + if (isStablePaneResumeBlocked(args.store, paneKey, args.worktreeId, args.connectionId)) { + return { + owner, + result: { + id: owner.ptyId, + ...(blockedAtAttach && isObservedPtyExitEvidence(error) + ? { exitedBeforeAttach: true as const } + : { reattachUnverifiable: true as const }) + } + } + } if (error instanceof TerminalSessionOwnerUnverifiedError) { throw new Error('terminal_pane_owner_unverified') } @@ -298,6 +280,19 @@ export async function spawnForStablePane( return attached } } + if ( + isStablePaneResumeBlocked( + args.store, + args.spawnOptions.paneKey, + args.worktreeId, + args.connectionId + ) + ) { + return { + result: { id: args.spawnOptions.sessionId ?? '', reattachUnverifiable: true }, + owner: null + } + } const result = await args.provider.spawn(args.spawnOptions) args.onFreshSpawn?.(result) return { result, owner: null } diff --git a/src/main/ipc/pty/pane/stable-pane-fenced-evidence.test.ts b/src/main/ipc/pty/pane/stable-pane-fenced-evidence.test.ts new file mode 100644 index 00000000000..22a4ce203ca --- /dev/null +++ b/src/main/ipc/pty/pane/stable-pane-fenced-evidence.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { Store } from '../../../persistence' +import { SessionNotFoundError } from '../../../daemon/daemon-errors' +import { + SshPtyAbsentFromRelayError, + SshPtyProvenExitedOnRelayError +} from '../../../providers/ssh-pty-errors' +import type { IPtyProvider } from '../../../providers/types' +import { resolveStablePaneOwner, spawnForStablePane } from './stable-owner' +import { ptyOwnership } from '../provider/ownership-state' + +const tabId = 'tab-worker' +const leafId = '5b5b5b5b-5b5b-4b5b-8b5b-5b5b5b5b5b5b' +const paneKey = makePaneKey(tabId, leafId) +const worktreeId = 'folder-worker' + +function fixture(connectionId: string | null) { + const ptyId = connectionId ? `ssh:${connectionId}@@worker` : 'worker' + const session = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, type: 'terminal' }] }, + terminalLayoutsByTabId: { [tabId]: { ptyIdsByLeafId: { [leafId]: ptyId } } }, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { worktreeId, automaticResumeBlockedBy: 'legacy-orchestration-worker' } + } + } + const store = { + getWorkspaceSession: vi.fn(() => session), + setWorkspaceSession: vi.fn(), + flushOrThrow: vi.fn() + } + return { + ptyId, + store, + owner: resolveStablePaneOwner( + undefined, + store as unknown as Store, + paneKey, + worktreeId, + connectionId + ) + } +} + +describe('stable-pane fenced attach evidence', () => { + it.each([ + [null, new SessionNotFoundError('worker'), 'exitedBeforeAttach'], + [null, new Error('Session not found: worker'), 'reattachUnverifiable'], + ['host', new SshPtyAbsentFromRelayError('worker'), 'reattachUnverifiable'], + ['host', new SshPtyProvenExitedOnRelayError('worker'), 'exitedBeforeAttach'], + ['host', new Error('socket closed'), 'reattachUnverifiable'] + ] as const)('preserves %s ownership for %s', async (connectionId, error, outcome) => { + const { ptyId, store, owner } = fixture(connectionId) + const spawn = vi.fn(async () => { + throw error + }) + ptyOwnership.set(ptyId, connectionId) + try { + const result = await spawnForStablePane({ + store: store as unknown as Store, + runtime: undefined, + provider: { spawn } as unknown as IPtyProvider, + owner, + worktreeId, + connectionId, + spawnOptions: { cols: 80, rows: 24, paneKey, command: 'must never run' } + }) + expect(result.result).toEqual({ id: ptyId, [outcome]: true }) + expect(spawn).toHaveBeenCalledTimes(1) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ attachOnly: true, command: undefined }) + ) + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + expect(ptyOwnership.get(ptyId)).toBe(connectionId) + expect(store.getWorkspaceSession).toHaveBeenCalledWith(connectionId ? 'ssh:host' : undefined) + } finally { + ptyOwnership.delete(ptyId) + } + }) + it('refuses a newly fenced ownerless pane at the final spawn boundary', async () => { + const { store } = fixture(null) + const spawn = vi.fn(async () => ({ id: 'must-not-spawn' })) + const result = await spawnForStablePane({ + store: store as unknown as Store, + runtime: undefined, + provider: { spawn } as unknown as IPtyProvider, + owner: null, + worktreeId, + spawnOptions: { cols: 80, rows: 24, paneKey } + }) + expect(result.result).toMatchObject({ reattachUnverifiable: true }) + expect(spawn).not.toHaveBeenCalled() + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + }) + it('rechecks a fence committed while attachment is awaiting the host', async () => { + const { store, ptyId } = fixture(null) + const record = store.getWorkspaceSession().sleepingAgentSessionsByPaneKey[paneKey] + delete (record as { automaticResumeBlockedBy?: string }).automaticResumeBlockedBy + const resolveOwner = () => + resolveStablePaneOwner(undefined, store as unknown as Store, paneKey, worktreeId, null) + const owner = resolveOwner() + let calls = 0 + const spawn = vi.fn(async () => { + if (++calls === 1) { + record.automaticResumeBlockedBy = 'legacy-orchestration-worker' + throw new SessionNotFoundError(ptyId) + } + return { id: 'replacement-after-fence' } + }) + const result = await spawnForStablePane({ + runtime: undefined, + store: store as unknown as Store, + provider: { spawn } as unknown as IPtyProvider, + owner, + worktreeId, + connectionId: null, + resolveOwner, + spawnOptions: { cols: 80, rows: 24, paneKey } + }) + expect(result.result).toEqual({ id: ptyId, reattachUnverifiable: true }) + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ipc/pty/pane/stable-pane-final-fence-recheck.test.ts b/src/main/ipc/pty/pane/stable-pane-final-fence-recheck.test.ts new file mode 100644 index 00000000000..75ce1546fdb --- /dev/null +++ b/src/main/ipc/pty/pane/stable-pane-final-fence-recheck.test.ts @@ -0,0 +1,93 @@ +import { expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { Store } from '../../../persistence' +import { SessionNotFoundError } from '../../../daemon/daemon-errors' +import type { IPtyProvider } from '../../../providers/types' +import { resolveStablePaneOwner, spawnForStablePane } from './stable-owner' + +const tabId = 'tab-worker' +const leafId = '5b5b5b5b-5b5b-4b5b-8b5b-5b5b5b5b5b5b' +const paneKey = makePaneKey(tabId, leafId) +const worktreeId = 'folder-worker' + +function fixture(connectionId: string | null) { + const ptyId = connectionId ? `ssh:${connectionId}@@worker` : 'worker' + let session = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, type: 'terminal' }] }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + ptyIdsByLeafId: { [leafId]: ptyId } + } + }, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { worktreeId, automaticResumeBlockedBy: 'legacy-orchestration-worker' } + } + } + const store = { + getWorkspaceSession: vi.fn(() => session), + setWorkspaceSession: vi.fn((next) => { + session = next + }), + flushOrThrow: vi.fn() + } + return { + ptyId, + store, + owner: resolveStablePaneOwner( + undefined, + store as unknown as Store, + paneKey, + worktreeId, + connectionId + ) + } +} + +it('a fence queued after retirement still stops fresh fallback', async () => { + const { store, ptyId } = fixture(null) + delete ( + store.getWorkspaceSession().sleepingAgentSessionsByPaneKey[paneKey] as { + automaticResumeBlockedBy?: string + } + ).automaticResumeBlockedBy + const resolveOwner = () => { + const owner = resolveStablePaneOwner( + undefined, + store as unknown as Store, + paneKey, + worktreeId, + null + ) + if (!owner) { + queueMicrotask(() => { + store.getWorkspaceSession().sleepingAgentSessionsByPaneKey[ + paneKey + ].automaticResumeBlockedBy = 'legacy-orchestration-worker' + }) + } + return owner + } + const owner = resolveOwner() + const spawn = vi.fn(async (options) => { + if (options.attachOnly) { + throw new SessionNotFoundError(ptyId) + } + return { id: 'replacement-despite-new-fence' } + }) + const result = await spawnForStablePane({ + runtime: undefined, + store: store as unknown as Store, + provider: { spawn } as unknown as IPtyProvider, + owner, + worktreeId, + connectionId: null, + resolveOwner, + spawnOptions: { cols: 80, rows: 24, paneKey } + }) + expect(result.result).toMatchObject({ reattachUnverifiable: true }) + expect(spawn).toHaveBeenCalledTimes(1) +}) diff --git a/src/main/ipc/pty/pane/stable-pane-resume-fence-host.test.ts b/src/main/ipc/pty/pane/stable-pane-resume-fence-host.test.ts new file mode 100644 index 00000000000..a276414ef25 --- /dev/null +++ b/src/main/ipc/pty/pane/stable-pane-resume-fence-host.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it, vi } from 'vitest' +import { isStablePaneResumeBlocked } from './stable-pane-resume-fence' + +describe('review host-scoped fence', () => { + it('reads SSH policy when the identical local pane is not fenced', () => { + const paneKey = 'tab-worker:5b5b5b5b-5b5b-4b5b-8b5b-5b5b5b5b5b5b' + const worktreeId = 'folder-worker' + const local = { sleepingAgentSessionsByPaneKey: {} } + const remote = { + sleepingAgentSessionsByPaneKey: { + [paneKey]: { worktreeId, automaticResumeBlockedBy: 'legacy-orchestration-worker' } + } + } + const store = { getWorkspaceSession: vi.fn((host) => (host === 'ssh:host' ? remote : local)) } + expect(isStablePaneResumeBlocked(store as never, paneKey, worktreeId, 'host')).toBe(true) + }) +}) diff --git a/src/main/ipc/pty/pane/stable-pane-resume-fence.ts b/src/main/ipc/pty/pane/stable-pane-resume-fence.ts new file mode 100644 index 00000000000..2f8f2e512c1 --- /dev/null +++ b/src/main/ipc/pty/pane/stable-pane-resume-fence.ts @@ -0,0 +1,21 @@ +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import { isPaneAutomaticResumeBlocked } from '../../../../shared/terminal-pane-resume-fence' +import type { Store } from '../../../persistence' + +export function isStablePaneResumeBlocked( + store: Store | undefined, + paneKey: string | null | undefined, + worktreeId: string | undefined, + connectionId: string | null | undefined +): boolean { + if (!paneKey || !worktreeId || !store?.getWorkspaceSession) { + return false + } + return isPaneAutomaticResumeBlocked( + store.getWorkspaceSession(connectionId ? toSshExecutionHostId(connectionId) : undefined), + paneKey, + worktreeId + ) +} + +export class StablePaneResumeBlockedError extends Error {} diff --git a/src/main/ipc/pty/provider/liveness.ts b/src/main/ipc/pty/provider/liveness.ts index 8a362b74aaf..59aa6b1011a 100644 --- a/src/main/ipc/pty/provider/liveness.ts +++ b/src/main/ipc/pty/provider/liveness.ts @@ -1,6 +1,7 @@ import { isRemoteAgentHooksEnabled } from '../../../../shared/agent-hook-relay' import type { AgentSessionOwnerBinding } from '../../../../shared/agent-session-host-authority' import { agentSessionOwnerBindingsEqual } from '../../../../shared/claimed-agent-pty-owner' +import { isSessionNotFoundRefusal } from '../../../../shared/pty-attach-absence-evidence' import { addNodePtyRecoveryHint } from '../../../daemon/node-pty-error-hints' import { SessionNotFoundError } from '../../../daemon/daemon-errors' import type { Store } from '../../../persistence' @@ -64,7 +65,7 @@ export function isPtyAlreadyGoneError(err: unknown): boolean { // Why: the reattach path rewrites the relay's wording to SSH_SESSION_EXPIRED and only this // class preserves that the relay itself answered "absent" rather than the link dropping. isSshPtyAbsentFromRelayError(err) || - /Session not found/i.test(message) + isSessionNotFoundRefusal(message) ) } diff --git a/src/main/ipc/pty/runtime/spawn-early.ts b/src/main/ipc/pty/runtime/spawn-early.ts index 575f89b22e7..cf1dc2f9504 100644 --- a/src/main/ipc/pty/runtime/spawn-early.ts +++ b/src/main/ipc/pty/runtime/spawn-early.ts @@ -32,6 +32,12 @@ export function adoptMaterializedRuntimePtySpawn( if (startupPromise && !startupAlreadyAwaited) { return startupPromise.then(() => adoptMaterializedRuntimePtySpawn(ctx, true)) } + if ( + ctx.preAdoptedStablePane?.result.exitedBeforeAttach || + ctx.preAdoptedStablePane?.result.reattachUnverifiable + ) { + return ctx.preAdoptedStablePane.result + } if (!ctx.preAdoptedStablePane?.materialized) { return null } diff --git a/src/main/ipc/pty/runtime/spawn-execute.ts b/src/main/ipc/pty/runtime/spawn-execute.ts index 05a9ca82d15..12439cb78a5 100644 --- a/src/main/ipc/pty/runtime/spawn-execute.ts +++ b/src/main/ipc/pty/runtime/spawn-execute.ts @@ -1,3 +1,7 @@ +import { + isStablePaneResumeBlocked, + StablePaneResumeBlockedError +} from '../pane/stable-pane-resume-fence' import type { PtySpawnResult } from '../../../providers/types' import { ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state' import { ptySizes } from '../delivery/visibility-state' @@ -31,15 +35,13 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise } const stablePaneOwnerCandidate = ctx.preAdoptedStablePane ? ctx.preAdoptedStablePane.owner - : args.agentSessionEnsure - ? null - : resolveStablePaneOwner( - ctx.deps.runtime, - ctx.deps.store, - ctx.spawnIdentityPaneKey, - args.worktreeId, - args.connectionId - ) + : resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + ctx.spawnIdentityPaneKey, + args.worktreeId, + args.connectionId + ) const expectedPtyId = stablePaneOwnerCandidate?.ptyId ?? ctx.effectiveSessionAppId ?? ctx.sessionId if (expectedPtyId) { @@ -61,7 +63,16 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise throw new Error('client_disconnected') } } - if (args.agentSessionEnsure && !ctx.preAdoptedStablePane) { + if ( + args.agentSessionEnsure && + !ctx.preAdoptedStablePane && + !isStablePaneResumeBlocked( + ctx.deps.store, + ctx.spawnIdentityPaneKey, + args.worktreeId, + args.connectionId + ) + ) { // Why: daemon-backed claims can outlive this controller; import all // proven owners before deciding that an identity is absent. await reconcileAgentSessionOwnerListings() @@ -82,6 +93,16 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise surface: args.agentSessionEnsure.surface, spawn: async () => { assertClientStillConnected() + if ( + isStablePaneResumeBlocked( + ctx.deps.store, + ctx.spawnIdentityPaneKey, + args.worktreeId, + args.connectionId + ) + ) { + throw new StablePaneResumeBlockedError() + } providerResult = await ctx.provider.spawn(ctx.spawnOptions) ctx.rejectedRegistrationCandidate = providerResult // Why: a successful lower-owner return proves physical work committed even if admission sees an early exit. @@ -149,6 +170,9 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise }) ctx.result = stablePaneSpawn.result ctx.stablePaneOwner = stablePaneSpawn.owner + if (ctx.result.exitedBeforeAttach || ctx.result.reattachUnverifiable) { + return + } if ( ctx.stablePaneOwner && ctx.isNewDaemonSession && @@ -196,6 +220,10 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise : ctx.result.wslDistro ) } catch (err) { + if (err instanceof StablePaneResumeBlockedError) { + ctx.result = { id: ctx.sessionId ?? '', reattachUnverifiable: true } + return + } if ( (ctx.isNewDaemonSession || ctx.preparedProvisionalExecutionContext) && ctx.effectiveSessionAppId diff --git a/src/main/ipc/pty/runtime/spawn.ts b/src/main/ipc/pty/runtime/spawn.ts index 2af050cd796..a8cc44c209e 100644 --- a/src/main/ipc/pty/runtime/spawn.ts +++ b/src/main/ipc/pty/runtime/spawn.ts @@ -5,8 +5,10 @@ import { makePaneSpawnReservationKey, paneSpawnReservationsByOwnerKey, rejectPaneSpawnReservation, - reservePaneSpawn + reservePaneSpawn, + resolvePaneSpawnReservation } from '../pane/spawn-reservation' +import { isStablePaneResumeBlocked } from '../pane/stable-pane-resume-fence' import { resolveStablePaneOwner } from '../pane/stable-owner' import { ptySizes } from '../delivery/visibility-state' import type { PtyRuntimeControllerDeps } from './controller-deps' @@ -19,6 +21,8 @@ import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-st function toRuntimeSpawnReply(result: { id: string + exitedBeforeAttach?: true + reattachUnverifiable?: true incarnationId?: string wslDistro?: string | null stablePaneOwner?: { handle: string; tabId: string; leafId: string } @@ -26,6 +30,8 @@ function toRuntimeSpawnReply(result: { }) { return { id: result.id, + ...(result.exitedBeforeAttach ? { exitedBeforeAttach: true as const } : {}), + ...(result.reattachUnverifiable ? { reattachUnverifiable: true as const } : {}), ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), ...(typeof result.wslDistro === 'string' ? { wslDistro: result.wslDistro } : {}), ...(result.stablePaneOwner ? { stablePaneOwner: result.stablePaneOwner } : {}), @@ -71,6 +77,12 @@ export async function spawnPtyFromRuntimeController( args.connectionId ) : null + if ( + !existingOwner && + isStablePaneResumeBlocked(deps.store, paneKey, args.worktreeId, args.connectionId) + ) { + return { id: args.sessionId ?? '', reattachUnverifiable: true as const } + } if (ownerKey && !existingOwner && !paneSpawnReservationsByOwnerKey.has(ownerKey)) { ctx.paneSpawnReservationKey = ownerKey ctx.paneSpawnReservation = reservePaneSpawn(ownerKey) @@ -95,6 +107,18 @@ export async function spawnPtyFromRuntimeController( return toRuntimeSpawnReply(earlyReserved) } await executeRuntimePtySpawn(ctx) + if (ctx.result.exitedBeforeAttach || ctx.result.reattachUnverifiable) { + restoreProvisionalPtySize(ctx) + deps.runtime?.cancelPendingPtyRegistration?.(ctx.pendingRegistrationPtyId ?? ctx.result.id) + ctx.pendingRegistrationPtyId = null + return toRuntimeSpawnReply( + resolvePaneSpawnReservation( + ctx.paneSpawnReservationKey, + ctx.paneSpawnReservation, + ctx.result + ) + ) + } return toRuntimeSpawnReply(await commitRuntimePtySpawn(ctx)) } catch (err) { if (ctx.pendingRegistrationPtyId) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index cbaafa5ff32..ad6cd2433e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -1,4 +1,5 @@ import { isDeepStrictEqual } from 'node:util' +import { claudeRewindAcquisitionProofs } from './structured-rewind-claude-proof' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { AgentSessionPreSpawnError, @@ -15,6 +16,7 @@ export async function acquireOwner( input: AttachFlowInput, record: AgentSessionRecord ): Promise<{ record: AgentSessionRecord; acquisitionGeneration: string | null }> { + const { store, rewind, now } = input const fence = record.lease.runtimeFence const spawnToken = record.lease.reservedSpawnToken if (!spawnToken) { @@ -36,6 +38,7 @@ export async function acquireOwner( } const acquired = await input.adapter.acquire({ identity: journalIdentityFor(record, input.params), + ...claudeRewindAcquisitionProofs({ store, record, rewind, now }), fence, // Retries must recover the original reservation, not mint a second child. spawnToken, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index cf8a9f7f76d..42f9289783a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -46,6 +46,20 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => this.owner(input.sessionId).dispatch(input) + rewindSupport: NonNullable = (sessionId) => + this.owners.get(sessionId)?.rewindSupport?.(sessionId) ?? { + supported: false, + reason: 'unsupported' + } + + rewind: NonNullable = (input) => + this.owner(input.sessionId).rewind?.(input) ?? + Promise.resolve({ ok: false, reason: 'unsupported' }) + + recoverRewind: NonNullable = (input) => + this.owner(input.sessionId).recoverRewind?.(input) ?? + Promise.resolve({ ok: false, reason: 'unsupported' }) + compact: NonNullable = (input) => { const compact = this.owner(input.sessionId).compact if (!compact) { 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 4ce57a5d59d..9a480438c25 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 @@ -1,3 +1,7 @@ +import type { + AgentSessionRewindReason, + AgentSessionRewindSupport +} from '../../../shared/agent-session-rewind' // What the wire needs from a provider adapter. // // Phase 2 implements this over the Codex app-server and the Claude Agent SDK; @@ -8,6 +12,7 @@ import type { AgentJournalItemIdentity, + AgentJournalItemBody, AgentJournalMessageItem, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' @@ -34,6 +39,12 @@ export class AgentSessionAcquisitionRefusal extends Error { } } +export class AgentSessionRewindRefusal extends AgentSessionAcquisitionRefusal { + constructor(readonly rewindReason: AgentSessionRewindReason) { + super(`agent_session_rewind:${rewindReason}`) + } +} + /** * The provider's own root process was observed to exit, but its descendant tree * could not be verified. The lease keys on the root's pid and start time, so its @@ -97,6 +108,14 @@ export type StructuredAgentSessionLifecycleEvent = { export type StructuredAgentSessionAcquireInput = { identity: AgentSessionJournalIdentity + rewind?: { + targetUuid: string + previousLeafUuid: string + dropsTurn?: string + onProved?: (leafUuid: string) => Promise + } + /** Recovery restores an unproved rewind's original cursor with ordinary branch proof. */ + rewindRecovery?: { leafUuid: string; onProved: () => Promise } fence: number spawnToken: string options?: Readonly> @@ -131,6 +150,27 @@ export type StructuredAgentSessionAdapter = { body: AgentJournalMessageItem fence: number }): Promise + rewindSupport?(sessionId: string): AgentSessionRewindSupport + recoverRewind?(input: { + sessionId: string + fence: number + beforeTurnId: string + }): Promise< + | { ok: true; items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] } + | { ok: false; reason: AgentSessionRewindReason } + > + rewind?(input: { + sessionId: string + fence: number + beforeTurnId: string + onPrepared?: ( + items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] + ) => Promise + onReverted?: () => Promise + }): Promise< + | { ok: true; items?: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] } + | { ok: false; reason: AgentSessionRewindReason } + > compact?(input: { turnId: string sessionId: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts new file mode 100644 index 00000000000..3a77aa2d6cc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts @@ -0,0 +1,51 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AttachFlowInput } from './structured-agent-session-attach-flow' +import { + AgentSessionAcquisitionExitUnprovenError, + AgentSessionAcquisitionRootExitObservedError, + rethrowAfterAgentSessionAcquisitionCleanup +} from './structured-agent-session-adapter' + +export async function settlePostAcquisitionAttachFailure( + input: AttachFlowInput, + record: AgentSessionRecord, + cause: unknown +): Promise { + let cleanupError: unknown = cause + let exitProof: 'exit-proven' | 'root-exit-observed' | 'unproven' = 'unproven' + try { + await rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, cause) + } catch (error) { + cleanupError = error + exitProof = + error instanceof AgentSessionAcquisitionExitUnprovenError + ? 'unproven' + : error instanceof AgentSessionAcquisitionRootExitObservedError + ? 'root-exit-observed' + : 'exit-proven' + } + // A failed close must not prevent durable failure settlement. + await Promise.resolve(input.onAttachFailed?.()).catch(() => undefined) + try { + await input.store.settleFailedPostAcquisitionAttachment({ + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + spawnToken: record.lease.reservedSpawnToken ?? '', + callerKey: input.callerKey, + operationId: input.params.envelope.clientOperationId, + outcome: { + status: 'failed', + code: 'agent_session_operation_invalid', + message: cause instanceof Error ? cause.message : String(cause) + }, + exitProof, + now: input.now() + }) + } catch (settlementError) { + throw new AggregateError( + [cleanupError, settlementError], + 'agent session post-acquisition attachment failure settlement failed' + ) + } + throw cleanupError +} 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 4bbdd51cdf9..bb889ad23e3 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,9 +1,15 @@ -// The attach transition end to end: reserve the lease, make the reservation -// real, open the journal. -// -// Split out of the host so the sequence reads in one place. The host still owns -// the decisions that must not be client-supplied — the spawn token, the claim -// key, the owner probe — and passes them in. +import { settlePostAcquisitionAttachFailure } from './structured-agent-session-attach-failure' +import { rewindRefusal } from './structured-rewind-refusal' +import { + AgentSessionRewindRefusal, + AgentSessionAcquisitionExitUnprovenError, + AgentSessionAcquisitionRootExitObservedError, + AgentSessionAcquisitionRefusal, + isAgentSessionPreSpawnError, + type StructuredAgentSessionAcquireInput, + type StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +// The host supplies owner authority; this flow reserves, proves, and publishes the session. import type { AgentSessionAttachResult, @@ -21,15 +27,7 @@ import { type AttachedJournal } from './structured-agent-session-attach' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { adapterSupportsCreateIfDeclared } from './structured-agent-session-provider-support' -import { - AgentSessionAcquisitionExitUnprovenError, - AgentSessionAcquisitionRootExitObservedError, - AgentSessionAcquisitionRefusal, - isAgentSessionPreSpawnError, - rethrowAfterAgentSessionAcquisitionCleanup -} from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' import { readAgentSessionHydrationPage } from './agent-session-history-page' @@ -40,6 +38,7 @@ import { } from './structured-agent-session-adopted-import' export type AttachFlowInput = { + rewind?: StructuredAgentSessionAcquireInput['rewind'] store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter journalRoot: string @@ -47,22 +46,18 @@ export type AttachFlowInput = { callerKey: string params: AgentSessionAttachParams now: () => number - /** Registers the opened journal and fans out to subscribers before the caller - * sees the result, so no client can send against a session the host has not - * finished publishing. */ + /** Publishes the journal before clients can send against the new owner. */ onAttached: ( attached: AttachedJournal, acquisitionGeneration: string | null ) => Promise | void - /** Handed to the adapter so it can journal what the provider streams. The - * host owns it and binds it to the journal inside `onAttached`. */ + /** Host-owned provider sink, bound to the journal inside `onAttached`. */ eventSink?: StructuredAgentSessionEventSink /** Stops acquisition-window events targeting the superseded journal. */ onAcquiring?: () => Promise | void /** Settles writes already captured by the superseded journal before opening another. */ beforeJournalOpen?: () => Promise | void - /** Removes any partial host publication after journal attachment fails, and - * closes the journal handle of the map entry it drops. Awaited: see eviction. */ + /** Closes and removes partial publication after journal attachment fails. */ onAttachFailed?: () => Promise } @@ -148,8 +143,7 @@ export async function performAttach( } catch (error) { const spawnToken = reservedRecord?.lease.reservedSpawnToken if (reservedRecord && spawnToken && !unsupportedReservationSettlementAttempted) { - // A pre-spawn failure is its own processless proof; the settlement records the - // evidence and the failed operation in one durable transaction. + // Settle processless proof and failed operation atomically. const exitProof = isAgentSessionPreSpawnError(error) ? 'processless' : error instanceof AgentSessionAcquisitionExitUnprovenError @@ -193,6 +187,9 @@ export async function performAttach( ) } } + if (error instanceof AgentSessionRewindRefusal) { + return rewindRefusal(error.rewindReason) + } if (error instanceof AgentSessionAcquisitionRefusal) { return { ok: false, refusal: { code: error.code, message: error.message } } } @@ -268,48 +265,3 @@ async function settleUnsupportedReservation( throw new AggregateError([error], 'agent session unsupported reservation settlement failed') } } - -async function settlePostAcquisitionAttachFailure( - input: AttachFlowInput, - record: AgentSessionRecord, - cause: unknown -): Promise { - let cleanupError: unknown = cause - let exitProof: 'exit-proven' | 'root-exit-observed' | 'unproven' = 'unproven' - try { - await rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, cause) - } catch (error) { - cleanupError = error - exitProof = - error instanceof AgentSessionAcquisitionExitUnprovenError - ? 'unproven' - : error instanceof AgentSessionAcquisitionRootExitObservedError - ? 'root-exit-observed' - : 'exit-proven' - } - // Why: the close is awaited so the map entry is gone only once its handle is - // released, but a failed close must not also cost the store settlement below. - await Promise.resolve(input.onAttachFailed?.()).catch(() => undefined) - try { - await input.store.settleFailedPostAcquisitionAttachment({ - sessionId: record.sessionId, - fence: record.lease.runtimeFence, - spawnToken: record.lease.reservedSpawnToken ?? '', - callerKey: input.callerKey, - operationId: input.params.envelope.clientOperationId, - outcome: { - status: 'failed', - code: 'agent_session_operation_invalid', - message: cause instanceof Error ? cause.message : String(cause) - }, - exitProof, - now: input.now() - }) - } catch (settlementError) { - throw new AggregateError( - [cleanupError, settlementError], - 'agent session post-acquisition attachment failure settlement failed' - ) - } - throw cleanupError -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index fb3e8db31bd..3a58d71b625 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -1,3 +1,5 @@ +import type { StructuredAgentSessionAcquireInput } from './structured-agent-session-adapter' +import { recoverStructuredRewind } from './structured-rewind-recovery' import { recoverInterruptedCompaction } from './structured-compaction-recovery' // The host's attach, lifted out of the host class. // @@ -29,7 +31,8 @@ export function attachStructuredAgentSession( context: StructuredAgentSessionAttachContext, callerKey: string, params: AgentSessionAttachParams, - admitRecoveryTicket?: () => boolean + admitRecoveryTicket?: () => boolean, + rewind?: StructuredAgentSessionAcquireInput['rewind'] ): Promise> { const sessionId = params.envelope.sessionId const attaching = context.serialize(sessionId, async () => { @@ -61,6 +64,7 @@ export function attachStructuredAgentSession( } const eventSink = context.runtimeState.eventSinkFor(sessionId) const attached = await performAttach({ + rewind, store: context.deps.store, adapter: context.deps.adapter, journalRoot: context.deps.journalRoot, @@ -124,6 +128,16 @@ export function attachStructuredAgentSession( hasProviderChild: true, acquisitionGeneration: acquisitionGeneration ?? previous?.acquisitionGeneration ?? null }) + if (!rewind) { + await recoverStructuredRewind( + context.deps.store, + sessionId, + attached.journal, + fence, + context.deps.adapter, + context.now + ) + } await recoverInterruptedCompaction(context.deps.store, sessionId, attached.journal, fence) if (attached.recovery) { context.subscribers.reset(sessionId, attached.journal, attached.recovery.reset, fence) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 5edb9f1ab2f..91e9ac91fa1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -1,3 +1,4 @@ +import { rewindRefusal } from './structured-rewind-refusal' // Everything a client can ask an ALREADY-ATTACHED session to do: send a turn, cancel one, answer a // prompt, change an option, read the options back. // @@ -75,6 +76,10 @@ export function sendStructuredAgentSessionTurn( return mutate(context, caller, params.envelope, { ...plan, run: (ctx) => { + const rewind = context.deps.store.getRecord(ctx.sessionId)?.rewind + if (rewind?.phase === 'prepared' || rewind?.phase === 'provider-succeeded') { + return Promise.resolve(rewindRefusal('outcome-unknown')) + } const command = context.deps.store.getRecord(ctx.sessionId)?.conversationCommand if ( command && @@ -153,6 +158,14 @@ export function readStructuredAgentSessionOptions( const options = await context.deps.adapter.readOptions({ sessionId, fence: session.fence }) return { ...options, + rewind: + context.deps.store.getRecord(sessionId)?.rewind?.phase === 'prepared' || + context.deps.store.getRecord(sessionId)?.rewind?.phase === 'provider-succeeded' + ? { supported: false, reason: 'outcome-unknown' } + : (context.deps.adapter.rewindSupport?.(sessionId) ?? { + supported: false, + reason: 'unsupported' + }), conversationCommands: context.deps.adapter.compact ? ['clear', 'compact'] : ['clear'] } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 22557e87c52..257c7a4e4f7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -1,3 +1,5 @@ +import type { AgentSessionRewindParams } from '../../../shared/agent-session-rewind' +import { rewindStructuredAgentSession } from './structured-agent-session-rewind' import { StructuredConversationCommandController } from './structured-conversation-command-controller' // Structured agent-session host: where the lease, journal, and provider adapter meet. // Mutations share one durable admission path and serialize per session. @@ -211,13 +213,11 @@ export class StructuredAgentSessionHost { listSessionTabs = () => listStructuredAgentSessionTabs(this.sessions) - getPersistedVisibleSessionTabIndex(): { present: boolean; sessionIds: string[] } { - return this.deps.store.getVisibleSessionTabIndex() - } + getPersistedVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } => + this.deps.store.getVisibleSessionTabIndex() - setSessionTabVisibility(sessionId: string, visible: boolean): Promise { - return this.deps.store.setSessionTabVisibility(sessionId, visible) - } + setSessionTabVisibility = (sessionId: string, visible: boolean): Promise => + this.deps.store.setSessionTabVisibility(sessionId, visible) reconcileRestartLeases = async (): Promise => { const refusal = await this.reconcileLeases('startup') @@ -233,8 +233,7 @@ export class StructuredAgentSessionHost { revealSession = (sessionId: string): Promise => this.restore.revealSession(sessionId) - private serialize = (sessionId: string, task: () => Promise): Promise => - this.tasks.serialize(sessionId, task) + private serialize = this.tasks.serialize.bind(this.tasks) private restoreRenewedHandoff(sessionId: string): Promise { return this.serialize(sessionId, async () => { @@ -307,6 +306,9 @@ export class StructuredAgentSessionHost { readOptions = (sessionId: string): Promise => readStructuredAgentSessionOptions(this.mutationContext(), sessionId) + rewind = (caller: StructuredAgentSessionCaller, params: AgentSessionRewindParams) => + rewindStructuredAgentSession(this.mutationContext(), this.attachContext(), caller, params) + conversationCommand = (...args: Parameters) => this.conversationCommands.run(...args) conversationReplacements = () => this.conversationCommands.replacements() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts index da2bfbda0c0..e4cb0fc2d79 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts @@ -26,7 +26,11 @@ export async function runSettledAgentSessionMutation(input: { status: 'succeeded', sessionId: input.envelope.sessionId }) - : { status: 'failed', code: outcome.refusal.code } + : { + status: 'failed', + code: outcome.refusal.code, + ...(outcome.refusal.rewindReason ? { rewindReason: outcome.refusal.rewindReason } : {}) + } ) return outcome } catch (error) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts index afa5d73bfb7..c81c45dfba5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts @@ -1,3 +1,4 @@ +import { rewindRefusal } from './structured-rewind-refusal' import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' import { AGENT_SESSION_WIRE_REFUSAL_CODES, @@ -19,6 +20,9 @@ export function resolveAgentSessionReplayOutcome(input: { }): AgentSessionReplayOutcomeDecision { const { operationId, outcome } = input if (outcome.status === 'failed') { + if (outcome.rewindReason) { + return { decision: 'refuse', refusal: rewindRefusal(outcome.rewindReason).refusal } + } const code = (AGENT_SESSION_WIRE_REFUSAL_CODES as readonly string[]).includes(outcome.code) ? (outcome.code as AgentSessionWireRefusalCode) : 'agent_session_operation_invalid' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts new file mode 100644 index 00000000000..554b8d34395 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -0,0 +1,514 @@ +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../shared/agent-session-journal-item-key' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { AgentSessionRewindRefusal } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { + StructuredAgentSessionAdapter, + StructuredAgentSessionAcquireInput, + AgentSessionDispatchOutcome +} from './structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION, + HOST_TEST_THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const caller = { callerKey: 'desktop' } +let directory: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let sink: StructuredAgentSessionEventSink +let adapter: StructuredAgentSessionAdapter +let acquires: StructuredAgentSessionAcquireInput[] +const rewind = vi.fn>() +const recoverRewind = vi.fn>() +let failClaude = false + +beforeEach(async () => { + resetHostTestOperationIds() + rewind.mockReset().mockResolvedValue({ ok: true }) + recoverRewind.mockReset().mockResolvedValue({ + ok: true, + items: [ + { + identity: { provider: 'codex', threadId: HOST_TEST_THREAD, turnId: 'kept', ordinal: 0 }, + body: hostTestMessage('verified history') + } + ] + }) + failClaude = false + acquires = [] + directory = await mkdtemp(join(tmpdir(), 'orca-rewind-')) + store = await AgentSessionRecordStore.open({ + directory: join(directory, 'store'), + hostId: 'local' + }) + adapter = { + supportsCreate: (_location, agent) => agent === 'codex' || agent === 'claude', + supportsLocation: () => true, + acquire: async (input) => { + acquires.push(input) + if (input.rewind && failClaude) { + throw new AgentSessionRewindRefusal('provider-refused') + } + if (input.rewind) { + await input.rewind.onProved?.(input.rewind.targetUuid) + } + await input.rewindRecovery?.onProved() + sink = input.events! + const handle = input.identity.providerHandle + return { + process: { + hostId: 'local', + pid: 4000 + acquires.length, + processStartTimeMs: HOST_TEST_NOW, + spawnToken: input.spawnToken + }, + acquisitionGeneration: `generation-${acquires.length}`, + link: { + linkId: `link-${acquires.length}`, + mintedAtFence: input.fence, + observedAt: HOST_TEST_NOW, + origin: acquires.length === 1 ? 'created' : 'resumed', + handle: + handle.kind === 'claude' + ? { + provider: 'claude', + sessionId: handle.sessionId, + leafUuid: input.rewind?.targetUuid ?? 'tip' + } + : { provider: 'codex', threadId: HOST_TEST_THREAD } + } + } + }, + dispatch: vi.fn(async (): Promise => ({ + state: 'unknown', + reason: 'test' + })), + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => {}, + setOption: async () => {}, + rewindSupport: () => ({ supported: true }), + rewind, + recoverRewind, + releaseAcquisition: async () => true, + closeSession: async () => true + } + host = new StructuredAgentSessionHost({ + store, + adapter, + journalRoot: directory, + claimKeyId: 'key', + now: () => HOST_TEST_NOW, + probeOwner: async () => ({ outcome: 'exit-observed' }) + }) +}) +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(directory, { recursive: true, force: true }) +}) + +async function seed(provider: 'codex' | 'claude' = 'codex', acceptedSubmissions = false) { + const params = + provider === 'codex' + ? hostTestAttachParams(null) + : hostTestAttachParams(null, { + provider, + agent: provider, + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/claude' }, + providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: 'tip' } + }) + expect(await host.attach(caller, params)).toMatchObject({ ok: true }) + const keys = ['kept', 'drop', 'tip'].map((uuid) => + provider === 'codex' + ? { provider, threadId: HOST_TEST_THREAD, turnId: uuid, ordinal: 0 } + : { provider, sessionId: 'claude-session', uuid } + ) + let selectedItemId = agentJournalItemKey(keys[1]!) + for (const [i, identity] of keys.entries()) { + const body = { + ...hostTestMessage(String(i)), + role: i === 2 ? ('assistant' as const) : ('user' as const) + } + if (acceptedSubmissions && i !== 2) { + const clientOperationId = hostTestOperationId() + vi.mocked(adapter.dispatch).mockResolvedValueOnce({ + state: 'accepted', + providerIdentity: identity + }) + expect( + await host.send(caller, { + body, + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId, + expectedRuntimeFence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: HOST_TEST_SESSION, + fields: { body } + }) + } + }) + ).toMatchObject({ ok: true }) + if (i === 1) { + selectedItemId = agentJournalSubmissionKey(clientOperationId) + } + } else { + sink.appendItem(identity, body) + } + } + await host.flushStreamedEvents(HOST_TEST_SESSION) + return selectedItemId +} +function params( + itemId: string, + expectedEpoch = host.journalSnapshot(HOST_TEST_SESSION).cursor.epoch +) { + return { + itemId, + expectedEpoch, + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.rewind', + sessionId: HOST_TEST_SESSION, + fields: { itemId, expectedEpoch } + }) + } + } +} + +describe('host rewind', () => { + it.each(['codex', 'claude'] as const)( + 'resolves accepted %s user submissions to provider targets', + async (provider) => { + const target = await seed(provider, true) + expect(target.startsWith('orca:')).toBe(true) + expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true }) + expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1) + if (provider === 'codex') { + expect(rewind).toHaveBeenCalledWith(expect.objectContaining({ beforeTurnId: 'drop' })) + } else { + expect(acquires[1]?.rewind).toMatchObject({ targetUuid: 'kept', dropsTurn: 'drop' }) + } + } + ) + + it('retains the preceding accepted Claude prompt when rewinding its assistant response', async () => { + await seed('claude', true) + const target = agentJournalItemKey({ + provider: 'claude', + sessionId: 'claude-session', + uuid: 'tip' + }) + expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true }) + expect(acquires[1]?.rewind).toMatchObject({ targetUuid: 'drop' }) + expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(2) + }) + it('finishes a durable provider success on reattach without repeating the provider mutation', async () => { + const target = await seed() + const request = params(target) + const replace = vi + .spyOn(AgentSessionJournal.prototype, 'replaceEpochItems') + .mockRejectedValueOnce(new Error('disk failed')) + await expect(host.rewind(caller, request)).rejects.toThrow('disk failed') + expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('provider-succeeded') + replace.mockRestore() + const fence = store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence + expect(await host.attach(caller, hostTestAttachParams(fence))).toMatchObject({ ok: true }) + expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1) + expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') + expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) + expect(rewind).toHaveBeenCalledTimes(1) + }) + + it('retries complete hydration after native acknowledgement without committing partial history', async () => { + const target = await seed() + const before = host.journalSnapshot(HOST_TEST_SESSION) + rewind.mockImplementation(async (input) => { + await input.onReverted?.() + throw new Error('history unavailable') + }) + await expect(host.rewind(caller, params(target))).rejects.toThrow('history unavailable') + expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) + expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({ + phase: 'prepared', + providerApplied: true + }) + recoverRewind.mockRejectedValueOnce(new Error('history still unavailable')) + await expect( + host.attach( + caller, + hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) + ) + ).rejects.toThrow('history still unavailable') + expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('prepared') + expect( + await host.attach( + caller, + hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) + ) + ).toMatchObject({ ok: true }) + expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1) + expect(host.journalSnapshot(HOST_TEST_SESSION).items[0]?.body).toEqual( + hostTestMessage('verified history') + ) + expect(recoverRewind).toHaveBeenCalledTimes(2) + expect(rewind).toHaveBeenCalledTimes(1) + }) + it('fences stale owners and the second of two concurrent rewinds', async () => { + const target = await seed() + const stale = params(target) + stale.envelope.expectedRuntimeFence++ + expect(await host.rewind(caller, stale)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_checkpoint_stale' } + }) + let finish!: () => void + rewind.mockImplementation( + () => + new Promise((resolve) => { + finish = () => resolve({ ok: true }) + }) + ) + const first = host.rewind(caller, params(target)) + const second = host.rewind(caller, params(target)) + await vi.waitFor(() => expect(finish).toBeTypeOf('function')) + finish() + expect(await first).toMatchObject({ ok: true }) + expect(await second).toMatchObject({ ok: false, refusal: { rewindReason: 'stale-epoch' } }) + expect(rewind).toHaveBeenCalledTimes(1) + }) + it('replaces the epoch with the retained prefix and replays without another provider call', async () => { + const target = await seed() + const request = params(target) + const result = await host.rewind(caller, request) + expect(result).toMatchObject({ ok: true }) + expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1) + expect(host.journalSnapshot(HOST_TEST_SESSION).cursor.epoch).not.toBe(request.expectedEpoch) + expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) + expect(rewind).toHaveBeenCalledTimes(1) + }) + it('reacquires Claude at the retained cursor with the same session and a new lease fence', async () => { + const target = await seed('claude') + const before = store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence + expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true }) + const emit = vi.fn() + const unsubscribe = host.subscribe({ id: 'after-rewind', sessionId: HOST_TEST_SESSION, emit }) + emit.mockClear() + sink.appendItem( + { provider: 'claude', sessionId: 'claude-session', uuid: 'next' }, + hostTestMessage('next') + ) + sink.publish() + await host.flushStreamedEvents(HOST_TEST_SESSION) + expect(emit).toHaveBeenCalledWith(expect.objectContaining({ type: 'batch' })) + unsubscribe() + expect(acquires[1]?.rewind).toMatchObject({ + targetUuid: 'kept', + previousLeafUuid: 'tip', + dropsTurn: 'drop' + }) + expect(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence).toBeGreaterThan(before) + expect(store.getRecord(HOST_TEST_SESSION)!.lease.ownerProcess?.pid).toBe(4002) + expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(2) + }) + it('recovers a Claude refusal with one plain resume and preserves the journal', async () => { + const target = await seed('claude') + failClaude = true + const before = host.journalSnapshot(HOST_TEST_SESSION) + expect(await host.rewind(caller, params(target))).toMatchObject({ + ok: false, + refusal: { rewindReason: 'provider-refused' } + }) + expect(acquires).toHaveLength(3) + expect(acquires[2]?.rewind).toBeUndefined() + expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) + expect(store.getRecord(HOST_TEST_SESSION)!.lease.claimStatus).toBe('live') + }) + it('refuses a rewind racing an active turn before provider execution', async () => { + const target = await seed() + sink.appendItem( + { provider: 'orca', clientMessageId: 'active' }, + { kind: 'status', text: 'working', turnLifecycle: { turnId: 'active', state: 'running' } } + ) + expect(await host.rewind(caller, params(target))).toMatchObject({ + ok: false, + refusal: { rewindReason: 'busy' } + }) + expect(rewind).not.toHaveBeenCalled() + }) + it('refuses stale epochs and targets from another provider', async () => { + const target = await seed() + expect(await host.rewind(caller, params(target, 'old-epoch'))).toMatchObject({ + ok: false, + refusal: { rewindReason: 'stale-epoch' } + }) + expect(await host.rewind(caller, params('claude:foreign'))).toMatchObject({ + ok: false, + refusal: { rewindReason: 'invalid-target' } + }) + expect(rewind).not.toHaveBeenCalled() + }) + it('keeps a failed hydration epoch intact and blocks sends and duplicate rewind', async () => { + const target = await seed() + const request = params(target) + const before = host.journalSnapshot(HOST_TEST_SESSION) + rewind.mockRejectedValue(new Error('hydration failed')) + await expect(host.rewind(caller, request)).rejects.toThrow('hydration failed') + expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) + expect(await host.rewind(caller, request)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown' } + }) + const body = hostTestMessage('new prompt') + const envelope = { + ...params(target).envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: HOST_TEST_SESSION, + fields: { body } + }) + } + expect(await host.send(caller, { envelope, body })).toMatchObject({ + ok: false, + refusal: { rewindReason: 'outcome-unknown' } + }) + expect(adapter.dispatch).not.toHaveBeenCalled() + expect( + await host.attach( + caller, + hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) + ) + ).toMatchObject({ ok: true }) + expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') + expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) + expect(rewind).toHaveBeenCalledTimes(1) + }) + + it('clears an unapplied prepared rewind after observing the target still present', async () => { + const target = await seed() + const before = host.journalSnapshot(HOST_TEST_SESSION) + rewind.mockRejectedValueOnce(new Error('read failed before revert')) + await expect(host.rewind(caller, params(target))).rejects.toThrow('read failed') + recoverRewind.mockResolvedValueOnce({ ok: false, reason: 'provider-refused' }) + expect( + await host.attach( + caller, + hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) + ) + ).toMatchObject({ ok: true }) + expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) + expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('refused') + expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true }) + }) + + it('recovers against the complete provider preflight when the local journal omitted an older turn', async () => { + const target = await seed() + const items = ['older', 'kept'].map((turnId) => ({ + identity: { provider: 'codex' as const, threadId: HOST_TEST_THREAD, turnId, ordinal: 0 }, + body: hostTestMessage(turnId) + })) + rewind.mockImplementationOnce(async (input) => { + await input.onPrepared?.(items) + await input.onReverted?.() + throw new Error('lost after revert') + }) + await expect(host.rewind(caller, params(target))).rejects.toThrow('lost after revert') + recoverRewind.mockResolvedValueOnce({ ok: true, items }) + expect( + await host.attach( + caller, + hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) + ) + ).toMatchObject({ ok: true }) + expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(2) + expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') + }) + + it.each(['turn', 'item'] as const)( + 'never commits a recovered prefix that omits an expected retained %s', + async (missing) => { + const target = await seed() + const before = host.journalSnapshot(HOST_TEST_SESSION) + const items = [0, 1].map((ordinal) => ({ + identity: { + provider: 'codex' as const, + threadId: HOST_TEST_THREAD, + turnId: 'kept', + ordinal + }, + body: hostTestMessage(String(ordinal)) + })) + rewind.mockImplementationOnce(async (input) => { + await input.onPrepared?.(items) + throw new Error('reply lost') + }) + await expect(host.rewind(caller, params(target))).rejects.toThrow('reply lost') + recoverRewind.mockResolvedValueOnce({ + ok: true, + items: missing === 'turn' ? [] : items.slice(0, 1) + }) + const replace = vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems') + await expect( + host.attach( + caller, + hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) + ) + ).rejects.toThrow('proof-mismatch') + expect(replace).not.toHaveBeenCalled() + replace.mockRestore() + expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.expectedEpoch).toBe(before.cursor.epoch) + expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('prepared') + } + ) + + it('settles the existing epoch after a crash between journal commit and record completion', async () => { + const target = await seed() + const request = params(target) + const transition = store.transitionHandoff.bind(store) + const checkpoint = vi + .spyOn(store, 'transitionHandoff') + .mockImplementation((sessionId, update) => + transition(sessionId, (record) => { + const next = update(record) + if (next.rewind?.phase === 'completed') { + throw new Error('completion write failed') + } + return next + }) + ) + await expect(host.rewind(caller, request)).rejects.toThrow('completion write failed') + const committed = host.journalSnapshot(HOST_TEST_SESSION) + expect(committed.cursor.epoch).not.toBe(request.expectedEpoch) + checkpoint.mockRestore() + const replace = vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems') + expect( + await host.attach( + caller, + hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) + ) + ).toMatchObject({ ok: true }) + expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(committed) + expect(replace).not.toHaveBeenCalled() + replace.mockRestore() + expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts new file mode 100644 index 00000000000..053707b9206 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -0,0 +1,252 @@ +import { + agentJournalItemKey, + agentJournalSubmissionKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' +import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle' +import type { + AgentSessionRewindParams, + AgentSessionRewindRecord, + AgentSessionRewindResult +} from '../../../shared/agent-session-rewind' +import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire' +import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' +import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' +import { conversationCommandBlocked } from './structured-conversation-command-admission' +import { rewindRefusal } from './structured-rewind-refusal' +import { persistRewindRecord, recoverStructuredRewind } from './structured-rewind-recovery' +import { replaceClaudeRewindOwner } from './structured-rewind-claude-owner' + +export async function rewindStructuredAgentSession( + context: StructuredAgentSessionMutationContext, + attachContext: StructuredAgentSessionAttachContext, + caller: StructuredAgentSessionCaller, + params: AgentSessionRewindParams +): Promise> { + const { sessionId, clientOperationId } = params.envelope + const store = context.deps.store + return context.serialize(sessionId, async () => { + const result = await admitAndRunAgentSessionMutation({ + store, + adapter: context.deps.adapter, + callerKey: caller.callerKey, + envelope: params.envelope, + journal: context.sessions.get(sessionId)?.journal, + publish: (journal) => context.publish(sessionId, journal), + now: context.now, + plan: { + method: 'agentSession.rewind', + fields: { itemId: params.itemId, expectedEpoch: params.expectedEpoch }, + recoverUnknownFromDurableState: true, + settledOutcome: (rewind) => ({ status: 'succeeded', sessionId, rewind }), + replay: (_ctx, outcome) => { + if (outcome.status === 'succeeded' && outcome.rewind) { + return outcome.rewind + } + const prior = store.getRecord(sessionId)?.rewind + return prior?.operationId === clientOperationId && + prior.callerKey === caller.callerKey && + prior.phase === 'completed' && + prior.epoch + ? { itemId: prior.itemId, epoch: prior.epoch } + : null + }, + run: async (ctx) => { + await attachContext.runtimeState.flushEventSink(sessionId) + const record = store.getRecord(sessionId)! + const support = ctx.adapter.rewindSupport?.(sessionId) + if (!support?.supported) { + return rewindRefusal(support?.reason ?? 'unsupported') + } + if ( + record.rewind?.phase === 'prepared' || + record.rewind?.phase === 'provider-succeeded' + ) { + return rewindRefusal('outcome-unknown') + } + if (conversationCommandBlocked(ctx, record)) { + return rewindRefusal('busy') + } + if (ctx.journal.isReadOnly) { + return rewindRefusal('unsupported') + } + const snapshot = ctx.journal.snapshot() + const providerKeys = new Map( + snapshot.submissions.flatMap((submission) => + submission.dispatchState === 'accepted' && submission.providerItemId + ? [ + [ + agentJournalSubmissionKey(submission.clientMessageId), + submission.providerItemId + ] as const + ] + : [] + ) + ) + const providerKey = (itemId: string) => providerKeys.get(itemId) ?? itemId + if (ctx.journal.cursor().epoch !== params.expectedEpoch) { + return rewindRefusal('stale-epoch') + } + const selected = snapshot.items.findIndex((item) => item.itemId === params.itemId) + const key = selected === -1 ? null : parseAgentJournalItemKey(providerKey(params.itemId)) + const head = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle + if (!key || !head || key.provider !== head.provider) { + return rewindRefusal('invalid-target') + } + let boundary = selected + let claude: Parameters[3] | undefined + if (key.provider === 'codex' && head.provider === 'codex') { + if (key.threadId !== head.threadId) { + return rewindRefusal('invalid-target') + } + boundary = snapshot.items.findIndex((item) => { + const identity = parseAgentJournalItemKey(providerKey(item.itemId)) + return ( + (identity?.provider === 'codex' && + identity.threadId === key.threadId && + identity.turnId === key.turnId) || + (item.body.kind === 'status' && item.body.turnLifecycle?.turnId === key.turnId) + ) + }) + } else if (key.provider === 'claude' && head.provider === 'claude') { + if (key.sessionId !== head.sessionId) { + return rewindRefusal('invalid-target') + } + const previous = snapshot.items + .slice(0, boundary) + .map((item) => parseAgentJournalItemKey(providerKey(item.itemId))) + .findLast( + (identity) => + identity?.provider === 'claude' && identity.sessionId === key.sessionId + ) + if (previous?.provider !== 'claude') { + return rewindRefusal('invalid-target') + } + const prompts = snapshot.items + .slice(boundary) + .filter((item) => item.body.kind === 'message' && item.body.role === 'user') + const prompt = + prompts.length === 1 + ? parseAgentJournalItemKey(providerKey(prompts[0]!.itemId)) + : null + claude = { + targetUuid: previous.uuid, + previousLeafUuid: head.leafUuid ?? '', + ...(prompt?.provider === 'claude' ? { dropsTurn: prompt.uuid } : {}) + } + } else { + return rewindRefusal('invalid-target') + } + const retained = snapshot.items + .slice(0, boundary) + .map(({ itemId, body, observedAt }) => ({ + itemId: providerKey(itemId), + body, + observedAt + })) + if ( + retained.length > 10_000 || + Buffer.byteLength(JSON.stringify(retained), 'utf8') > + AGENT_SESSION_HISTORY_MAX_PAGE_BYTES + ) { + return rewindRefusal('history-limit') + } + let prepared: AgentSessionRewindRecord = { + operationId: clientOperationId, + callerKey: caller.callerKey, + itemId: params.itemId, + providerItemId: providerKey(params.itemId), + expectedEpoch: params.expectedEpoch, + phase: 'prepared', + retained + } + await persistRewindRecord(store, sessionId, ctx.fence, prepared) + ctx.publish() + const provider = claude + ? await replaceClaudeRewindOwner(attachContext, caller.callerKey, params, claude) + : await ctx.adapter.rewind!({ + sessionId, + fence: ctx.fence, + beforeTurnId: key.provider === 'codex' ? key.turnId : '', + onPrepared: async (items) => { + const retained = items.map(({ identity, body }) => ({ + itemId: agentJournalItemKey(identity), + body, + observedAt: ctx.now() + })) + if ( + retained.length > 10_000 || + Buffer.byteLength(JSON.stringify(retained), 'utf8') > + AGENT_SESSION_HISTORY_MAX_PAGE_BYTES + ) { + throw new Error('agent_session_rewind:history-limit') + } + prepared = { ...prepared, retained } + await persistRewindRecord(store, sessionId, ctx.fence, prepared) + }, + onReverted: async () => { + await persistRewindRecord(store, sessionId, ctx.fence, { + ...prepared, + providerApplied: true + }) + } + }) + const fence = store.getRecord(sessionId)!.lease.runtimeFence + if (!provider.ok) { + const reason = + 'reason' in provider + ? provider.reason + : (provider.refusal.rewindReason ?? 'outcome-unknown') + if (reason !== 'outcome-unknown') { + await persistRewindRecord(store, sessionId, fence, { + ...prepared, + phase: 'refused', + reason, + retained: [] + }) + const currentJournal = context.sessions.get(sessionId)?.journal + if (currentJournal) { + context.publish(sessionId, currentJournal) + } + } + return rewindRefusal(reason) + } + const confirmed = provider.items + ? provider.items.map(({ identity, body }) => ({ + itemId: agentJournalItemKey(identity), + body, + observedAt: ctx.now() + })) + : prepared.retained + if ( + Buffer.byteLength(JSON.stringify(confirmed), 'utf8') > + AGENT_SESSION_HISTORY_MAX_PAGE_BYTES + ) { + throw new Error('agent_session_rewind:history-limit') + } + await persistRewindRecord(store, sessionId, fence, { + ...prepared, + retained: confirmed, + phase: 'provider-succeeded', + hydrationVerified: true + }) + const journal = context.sessions.get(sessionId)!.journal + await attachContext.runtimeState.flushEventSink(sessionId) + await recoverStructuredRewind(store, sessionId, journal, fence) + context.publish(sessionId, journal) + return { ok: true, value: { itemId: params.itemId, epoch: journal.cursor().epoch } } + } + } + }) + return result.ok + ? { + ...result, + fence: store.getRecord(sessionId)!.lease.runtimeFence, + cursor: context.sessions.get(sessionId)!.journal.cursor() + } + : result + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 348b5aebc21..cfd85ff4648 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -46,6 +46,7 @@ function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSumma a.workspaceId === b.workspaceId && a.agent === b.agent && a.status === b.status && + a.rewindBlockedReason === b.rewindBlockedReason && // Settled activity changes ranking; streaming active turns must stay quiet. (a.status !== 'idle' || a.updatedAt === b.updatedAt) && a.latestPrompt === b.latestPrompt && @@ -126,6 +127,9 @@ export class StructuredAgentSessionStatusFeed { workspaceId: session.params.location.workspaceId, agent: session.params.provider, ...projectStructuredAgentSessionStatusSummary(items), + ...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded' + ? { rewindBlockedReason: 'outcome-unknown' as const } + : {}), ...(model ? { model } : {}), ...(providerSession ? { providerSession } : {}), updatedAt: journal.lastActivityAt() || this.deps.now() diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts index 25919fe0393..a69a5163b67 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts @@ -7,6 +7,9 @@ export function conversationCommandBlocked( record: AgentSessionRecord ): string | null { const items = ctx.journal.snapshot().items + if (record.rewind?.phase === 'prepared' || record.rewind?.phase === 'provider-succeeded') { + return 'agent_session_rewind:outcome-unknown' + } if ( record.conversationCommand?.command === 'clear' && record.conversationCommand.phase === 'committed' && diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-claude-owner.ts b/src/main/native-chat/agent-session-wire/structured-rewind-claude-owner.ts new file mode 100644 index 00000000000..f1108df181c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-rewind-claude-owner.ts @@ -0,0 +1,77 @@ +import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle' +import { createHash } from 'node:crypto' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionRewindParams } from '../../../shared/agent-session-rewind' +import type { StructuredAgentSessionAcquireInput } from './structured-agent-session-adapter' +import { attachFingerprintFields } from './structured-agent-session-attach' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' +import { rewindRefusal } from './structured-rewind-refusal' + +/** Runs within the rewind's session queue; acquisition still uses the normal reservation CAS. */ +export async function replaceClaudeRewindOwner( + context: StructuredAgentSessionAttachContext, + callerKey: string, + params: AgentSessionRewindParams, + rewind: NonNullable +): Promise<{ ok: true; items?: never } | ReturnType> { + const sessionId = params.envelope.sessionId + const session = context.sessions.get(sessionId)! + if (!(await context.deps.adapter.closeSession?.(sessionId))) { + return rewindRefusal('outcome-unknown') + } + session.hasProviderChild = false + const head = agentSessionProviderHandleChainHead( + context.deps.store.getRecord(sessionId)!.providerHandleChain + )?.handle + if (head?.provider !== 'claude' || !head.leafUuid) { + return rewindRefusal('invalid-target') + } + rewind = { ...rewind, previousLeafUuid: head.leafUuid } + const attach = async (intent: typeof rewind | undefined, stage: string) => { + const current = context.deps.store.getRecord(sessionId)! + const operationId = `${params.envelope.clientOperationId.split('-')[0]}-${createHash('sha256') + .update(JSON.stringify([callerKey, params.envelope.clientOperationId, stage])) + .digest('hex') + .slice(0, 32)}` + const attachParams = { + ...session.params, + envelope: { + sessionId, + clientOperationId: operationId, + expectedRuntimeFence: current.lease.runtimeFence, + payloadFingerprint: '' + } + } + attachParams.envelope.payloadFingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId, + fields: attachFingerprintFields(attachParams) + }) + return attachStructuredAgentSession( + { + ...context, + serialize: (_id, run) => run() + }, + callerKey, + attachParams, + undefined, + intent + ) + } + const result = await attach(rewind, 'rewind') + if (result.ok) { + return { ok: true } as const + } + if ( + result.refusal.rewindReason === 'provider-refused' || + result.refusal.rewindReason === 'proof-mismatch' + ) { + const recovered = await attach(undefined, 'resume') + if (!recovered.ok) { + return rewindRefusal('outcome-unknown') + } + return rewindRefusal(result.refusal.rewindReason) + } + return rewindRefusal(result.refusal.rewindReason ?? 'outcome-unknown') +} diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-claude-proof.test.ts b/src/main/native-chat/agent-session-wire/structured-rewind-claude-proof.test.ts new file mode 100644 index 00000000000..7803dc7245d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-rewind-claude-proof.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { claudeRewindAcquisitionProofs } from './structured-rewind-claude-proof' + +function setup() { + let current = agentSessionRecordFixture() + current.providerHandleChain = current.providerHandleChain.map((link) => ({ + ...link, + handle: { provider: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: 'tip' } + })) + current.rewind = { + operationId: 'rewind-operation', + callerKey: 'desktop', + itemId: 'selected', + expectedEpoch: 'old-epoch', + phase: 'prepared', + retained: [] + } + const store: Pick = { + transitionHandoff: async (_sessionId, transition) => { + current = transition(current) + return current + } + } + return { + store, + record: () => current, + setFence: () => { + current = { ...current, lease: { ...current.lease, runtimeFence: 8 } } + } + } +} + +describe('Claude rewind durable proof checkpoints', () => { + it('atomically checkpoints the exact target and resumable head before owner publication', async () => { + const state = setup() + const proofs = claudeRewindAcquisitionProofs({ + store: state.store, + record: state.record(), + now: () => 3_000, + rewind: { previousLeafUuid: 'tip', targetUuid: 'kept' } + }) + await expect(proofs.rewind!.onProved!('wrong')).rejects.toThrow('proof-mismatch') + expect(state.record().rewind?.phase).toBe('prepared') + expect(state.record().providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'tip' }) + await proofs.rewind!.onProved!('kept') + expect(state.record().rewind).toMatchObject({ + phase: 'provider-succeeded', + hydrationVerified: true + }) + expect(state.record().providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'kept' }) + expect( + claudeRewindAcquisitionProofs({ + store: state.store, + record: state.record(), + now: () => 3_001, + rewind: undefined + }) + ).toEqual({}) + }) + it('restores prepared recovery through ordinary proof without carrying rewind authorization', async () => { + const state = setup() + const proofs = claudeRewindAcquisitionProofs({ + store: state.store, + record: state.record(), + now: () => 3_000, + rewind: undefined + }) + expect(proofs.rewind).toBeUndefined() + expect(proofs.rewindRecovery?.leafUuid).toBe('tip') + expect(state.record().rewind?.phase).toBe('prepared') + await proofs.rewindRecovery!.onProved() + expect(state.record().rewind).toMatchObject({ phase: 'refused', retained: [] }) + expect(state.record().providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'tip' }) + }) + it('refuses a proof checkpoint from a superseded acquisition', async () => { + const state = setup() + const proofs = claudeRewindAcquisitionProofs({ + store: state.store, + record: state.record(), + now: () => 3_000, + rewind: { previousLeafUuid: 'tip', targetUuid: 'kept' } + }) + state.setFence() + await expect(proofs.rewind!.onProved!('kept')).rejects.toThrow('checkpoint_stale') + expect(state.record().rewind?.phase).toBe('prepared') + expect(state.record().providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'tip' }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-claude-proof.ts b/src/main/native-chat/agent-session-wire/structured-rewind-claude-proof.ts new file mode 100644 index 00000000000..87dd9dbb4e7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-rewind-claude-proof.ts @@ -0,0 +1,69 @@ +import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { claudeProviderHandleLink } from '../../claude/claude-structured-owner-identity' +import { recordAgentSessionProviderHandle } from '../../runtime/agent-session-provider-handle-transition' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAcquireInput } from './structured-agent-session-adapter' + +/** Proof checkpoints survive failures later in acquisition, before an owner can be published. */ +export function claudeRewindAcquisitionProofs(input: { + store: Pick + record: AgentSessionRecord + rewind: StructuredAgentSessionAcquireInput['rewind'] + now: () => number +}): Pick { + const { record, store } = input + const pending = record.rewind + const head = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle + if ( + record.provider !== 'claude' || + pending?.phase !== 'prepared' || + head?.provider !== 'claude' + ) { + return input.rewind ? { rewind: input.rewind } : {} + } + const checkpoint = async (leafUuid?: string): Promise => { + await store.transitionHandoff(record.sessionId, (current) => { + if ( + current.lease.runtimeFence !== record.lease.runtimeFence || + current.rewind?.operationId !== pending.operationId || + current.rewind.callerKey !== pending.callerKey || + current.rewind.phase !== 'prepared' + ) { + throw new Error('agent_session_checkpoint_stale') + } + if (leafUuid === undefined) { + return { + ...current, + rewind: { ...pending, phase: 'refused', reason: 'outcome-unknown', retained: [] } + } + } + if (leafUuid !== input.rewind?.targetUuid) { + throw new Error('agent_session_rewind:proof-mismatch') + } + const observedAt = input.now() + return { + ...recordAgentSessionProviderHandle({ + record: current, + fence: record.lease.runtimeFence, + link: claudeProviderHandleLink({ + sessionId: head.sessionId, + leafUuid, + resumed: true, + fence: record.lease.runtimeFence, + observedAt + }), + now: observedAt + }), + rewind: { ...pending, phase: 'provider-succeeded', hydrationVerified: true } + } + }) + } + if (input.rewind) { + return { rewind: { ...input.rewind, onProved: checkpoint } } + } + if (!head.leafUuid) { + throw new Error('agent_session_rewind:invalid-target') + } + return { rewindRecovery: { leafUuid: head.leafUuid, onProved: () => checkpoint() } } +} diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts new file mode 100644 index 00000000000..3e6b70c2bcc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { AgentSessionRewindRecordSchema } from '../../../shared/agent-session-rewind' +import { restoreRewindJournalBody } from './structured-rewind-journal-body' + +describe('rewind recovery of newer durable records', () => { + it('keeps an unknown message role and block readable without discarding the row', () => { + expect( + restoreRewindJournalBody({ + kind: 'message', + role: 'future-role', + blocks: [{ type: 'future-block' }] + }) + ).toEqual({ + kind: 'message', + role: 'system', + blocks: [{ type: 'text', text: '{"type":"future-block"}' }] + }) + }) + it('preserves unknown state as evidence rather than inventing success or pending work', () => { + const body = { + kind: 'tool-call' as const, + name: 'future-tool', + input: { path: 'file' }, + state: 'paused-by-provider' + } + expect(restoreRewindJournalBody(body)).toEqual({ kind: 'status', text: JSON.stringify(body) }) + const status = { + kind: 'status' as const, + text: 'state', + turnLifecycle: { turnId: 'turn', state: 'future-state' } + } + expect(restoreRewindJournalBody(status)).toEqual({ + kind: 'status', + text: JSON.stringify(status) + }) + }) + it('does not reject a saved recovery prefix over a newer refusal reason', () => { + expect( + AgentSessionRewindRecordSchema.safeParse({ + operationId: 'operation', + callerKey: 'caller', + itemId: 'selected', + expectedEpoch: 'old', + phase: 'provider-succeeded', + reason: 'future-reason', + retained: [] + }).success + ).toBe(true) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts new file mode 100644 index 00000000000..b1c32633af4 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts @@ -0,0 +1,61 @@ +import { isAdmissibleAgentJournalItemBody } from '../../../shared/agent-session-journal-schemas' +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind' +import { NATIVE_CHAT_ROLES } from '../../../shared/native-chat-types' + +type StoredBody = AgentSessionRewindRecord['retained'][number]['body'] + +/** Unknown future values remain visible evidence, never invented turn or prompt state. */ +export function restoreRewindJournalBody(body: StoredBody): AgentJournalItemBody { + let normalized: unknown = body + const fallback = () => ({ kind: 'status', text: JSON.stringify(body) }) + if (body.kind === 'message') { + normalized = { + ...body, + role: NATIVE_CHAT_ROLES.find((role) => role === body.role) ?? 'system', + blocks: body.blocks.map((block) => { + if ( + (block.type === 'text' && 'text' in block) || + (block.type === 'tool-call' && 'name' in block && !('state' in block)) || + (block.type === 'tool-result' && 'output' in block) || + block.type === 'image-ref' + ) { + return block + } + if ( + block.type === 'tool-call' && + 'state' in block && + (block.state === 'running' || block.state === 'completed' || block.state === 'failed') + ) { + return block + } + return { type: 'text', text: JSON.stringify(block) } + }) + } + } else if ( + body.kind === 'tool-call' && + body.state !== 'running' && + body.state !== 'completed' && + body.state !== 'failed' + ) { + normalized = fallback() + } else if ( + (body.kind === 'approval' || body.kind === 'question') && + body.resolution.state !== 'pending' && + body.resolution.state !== 'resolved' && + body.resolution.state !== 'cancelled' + ) { + normalized = fallback() + } else if ( + body.kind === 'status' && + body.turnLifecycle && + body.turnLifecycle.state !== 'running' && + body.turnLifecycle.state !== 'completed' + ) { + normalized = fallback() + } + if (!isAdmissibleAgentJournalItemBody(normalized)) { + throw new Error('agent_session_rewind:invalid-retained-body') + } + return normalized +} diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts new file mode 100644 index 00000000000..41ccab4ae28 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts @@ -0,0 +1,132 @@ +import { restoreRewindJournalBody } from './structured-rewind-journal-body' +import { isDeepStrictEqual } from 'node:util' +import { + agentJournalItemKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' +import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds' + +export function persistRewindRecord( + store: AgentSessionRecordStore, + sessionId: string, + fence: number, + rewind: AgentSessionRewindRecord +): Promise { + return store.transitionHandoff(sessionId, (record) => { + if (record.lease.runtimeFence !== fence) { + throw new Error('agent_session_checkpoint_stale') + } + return { ...record, rewind } + }) +} + +/** Recovery observes provider state; it never repeats an ambiguous native mutation. */ +export async function recoverStructuredRewind( + store: AgentSessionRecordStore, + sessionId: string, + journal: AgentSessionJournal, + fence: number, + adapter?: StructuredAgentSessionAdapter, + now: () => number = Date.now +): Promise { + let rewind = store.getRecord(sessionId)?.rewind + if (rewind?.phase !== 'provider-succeeded' && rewind?.phase !== 'prepared') { + return + } + const target = parseAgentJournalItemKey(rewind.providerItemId ?? rewind.itemId) + if (target?.provider === 'codex' && !rewind.hydrationVerified) { + const recovered = await adapter?.recoverRewind?.({ + sessionId, + fence, + beforeTurnId: target.turnId + }) + if (!recovered?.ok) { + if ( + recovered?.reason === 'provider-refused' && + rewind.phase === 'prepared' && + !rewind.providerApplied + ) { + await persistRewindRecord(store, sessionId, fence, { + ...rewind, + phase: 'refused', + reason: recovered.reason, + retained: [] + }) + return + } + throw new Error(`agent_session_rewind:${recovered?.reason ?? 'outcome-unknown'}`) + } + const expectedItems = new Set(rewind.retained.map((item) => item.itemId)) + const observedItems = new Set() + for (const { identity } of recovered.items) { + const itemId = agentJournalItemKey(identity) + if ( + identity.provider !== 'codex' || + identity.threadId !== target.threadId || + !expectedItems.has(itemId) + ) { + throw new Error('agent_session_rewind:proof-mismatch') + } + observedItems.add(itemId) + } + if (observedItems.size !== expectedItems.size) { + throw new Error('agent_session_rewind:proof-mismatch') + } + const retained = recovered.items.map(({ identity, body }) => ({ + itemId: agentJournalItemKey(identity), + body, + observedAt: now() + })) + if ( + retained.length > 10_000 || + Buffer.byteLength(JSON.stringify(retained), 'utf8') > AGENT_SESSION_HISTORY_MAX_PAGE_BYTES + ) { + throw new Error('agent_session_rewind:history-limit') + } + rewind = { ...rewind, retained, phase: 'provider-succeeded', hydrationVerified: true } + await persistRewindRecord(store, sessionId, fence, rewind) + } + if (rewind.phase !== 'provider-succeeded') { + return + } + const replacement = rewind.retained.map((item) => { + const identity = parseAgentJournalItemKey(item.itemId) + if (!identity) { + throw new Error('agent_session_rewind:invalid-retained-identity') + } + return { identity, body: restoreRewindJournalBody(item.body), observedAt: item.observedAt } + }) + // A crash after the journal transaction must settle its existing epoch, not replace it twice. + const alreadyReplaced = journal.cursor().epoch !== rewind.expectedEpoch + if ( + alreadyReplaced && + !isDeepStrictEqual( + journal.snapshot().items.map(({ itemId, body }) => ({ itemId, body })), + replacement.map(({ identity, body }) => ({ itemId: agentJournalItemKey(identity), body })) + ) + ) { + throw new Error('agent_session_rewind:stale-epoch') + } + const cursor = alreadyReplaced + ? journal.cursor() + : await journal.replaceEpochItems('handle_forked', fence, replacement) + await persistRewindRecord(store, sessionId, fence, { + ...rewind, + phase: 'completed', + epoch: cursor.epoch, + retained: [] + }) + await store.recordOperationOutcome({ + callerKey: rewind.callerKey, + operationId: rewind.operationId, + outcome: { + status: 'succeeded', + sessionId, + rewind: { itemId: rewind.itemId, epoch: cursor.epoch } + } + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts b/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts new file mode 100644 index 00000000000..5ea205a527c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts @@ -0,0 +1,24 @@ +import { + AGENT_SESSION_REWIND_REASONS, + type AgentSessionRewindReason +} from '../../../shared/agent-session-rewind' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' + +export function rewindRefusal(reason: AgentSessionRewindReason): { + ok: false + refusal: AgentSessionWireRefusal +} { + const knownReason = + AGENT_SESSION_REWIND_REASONS.find((value) => value === reason) ?? 'outcome-unknown' + return { + ok: false, + refusal: { + code: + knownReason === 'outcome-unknown' + ? 'agent_session_operation_unknown' + : 'agent_session_operation_invalid', + message: `agent_session_rewind:${knownReason}`, + rewindReason: knownReason + } + } +} diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 90b41d9656a..fec7e6883b1 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -6,6 +6,8 @@ import type { PtySourceReceivingActivation } from '../../shared/pty-source-recei import type { TerminalOwner } from '../../shared/terminal-owner' export type PtySpawnResult = { + exitedBeforeAttach?: true + reattachUnverifiable?: true agentSessionEnsure?: AgentSessionClaimedSpawnResult /** App-facing PTY id. Remote providers must return globally routable ids, * not relay-local handles, because renderer/runtime IPC routes by this key. */ diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index ec8a3e78941..3245f2914c1 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -4,6 +4,9 @@ import * as dependencies from './orca-runtime-create-terminal-dependencies' import { createDesktopTerminal } from './orca-runtime-create-terminal-desktop' import { buildRuntimeAgentTeamsLaunchPlan } from './orca-runtime-agent-teams-launch-plan' import { createPtySpawnCommitReporter } from './orca-runtime-report-pty-spawn-commit' +import { terminalAttachRefusal } from './terminal-attach-refusal' +import { revealCreatedTerminal } from './terminal-create-reveal' +import { resolveTerminalCreatePaneIdentity } from './terminal-create-pane-identity' export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreateDeduplication { async createTerminal( @@ -33,15 +36,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) ?? workspace.path let preAllocatedHandle = launchOpts.preAllocatedHandle ?? this.createPreAllocatedTerminalHandle() - const hintedTabId = launchOpts.tabId?.trim() - const canAdoptPaneIdentity = - hintedTabId !== undefined && - dependencies.isValidHostTerminalTabId(hintedTabId) && - launchOpts.leafId !== undefined && - dependencies.isTerminalLeafId(launchOpts.leafId) - let tabId = canAdoptPaneIdentity ? (hintedTabId as string) : dependencies.randomUUID() - let leafId = canAdoptPaneIdentity ? (launchOpts.leafId as string) : dependencies.randomUUID() - let paneKey = dependencies.makePaneKey(tabId, leafId) + let { tabId, leafId, paneKey } = resolveTerminalCreatePaneIdentity(launchOpts) const claimedStablePaneCreate = this.ptyController.claimStablePaneCreate?.({ worktreeId: workspace.id, connectionId: workspace.connectionId, @@ -70,6 +65,16 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate tabId, leafId }) + const refusalPane = { tabId, paneKey, worktreeId: workspace.id } + const refusedAdoption = + adoptedBeforeLaunch && + terminalAttachRefusal(adoptedBeforeLaunch.result, { + handle: adoptedBeforeLaunch.owner.handle ?? preAllocatedHandle, + ...refusalPane + }) + if (refusedAdoption) { + return refusedAdoption + } const launchToken = launchOpts.launchConfig ? (launchOpts.launchToken ?? dependencies.randomUUID()) : undefined @@ -175,6 +180,13 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate } finally { releaseStablePaneCreate?.() } + const refusedSpawn = terminalAttachRefusal(result, { + handle: result.stablePaneOwner?.handle ?? preAllocatedHandle, + ...refusalPane + }) + if (refusedSpawn) { + return refusedSpawn + } if (!result.stablePaneOwner) { reportPtySpawnCommitted() } @@ -250,32 +262,21 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate ...(cwd !== workspace.path ? { startupCwd: cwd } : {}) }) } - let surface: dependencies.RuntimeTerminalCreate['surface'] = 'background' - let warning: string | undefined - if (presentation !== 'background' && this.notifier?.revealTerminalSession) { - try { - await this.notifier.revealTerminalSession(workspace.id, { - ptyId: result.id, - title: launchOpts.title ?? null, - ...(cwd !== workspace.path ? { cwd } : {}), - ...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}), - ...(launchToken ? { launchToken } : {}), - ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}), - ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), - activate: presentation === 'focused', - ...(presentation ? { presentation } : {}), - ...dependencies.ownerSurfacing(opts.surfaceOwner !== false), - tabId, - leafId - }) - surface = 'visible' - } catch (err) { - console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err) - warning = dependencies.createTerminalRevealWarning(handle, err) - } - } else if (presentation !== 'background') { - warning = dependencies.createTerminalRevealWarning(handle) - } + const { surface, warning } = await revealCreatedTerminal({ + notifier: this.notifier, + worktreeId: workspace.id, + workspacePath: workspace.path, + handle, + ptyId: result.id, + tabId, + leafId, + cwd, + presentation, + launchOpts, + launchConfig: effectiveLaunchConfig, + launchToken, + surfaceOwner: opts.surfaceOwner + }) return { handle, tabId, diff --git a/src/main/runtime/orca-runtime-terminal-attach-refusal.test.ts b/src/main/runtime/orca-runtime-terminal-attach-refusal.test.ts new file mode 100644 index 00000000000..0ed77eb42b8 --- /dev/null +++ b/src/main/runtime/orca-runtime-terminal-attach-refusal.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { + on: vi.fn(), + removeListener: vi.fn() + }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +function stubLaunchScope(runtime: OrcaRuntimeService, path = '/repo/app'): void { + const internals = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise<{ + id: string + path: string + connectionId: string | null + repo: null + folderWorkspace: null + }> + } + vi.spyOn(internals, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ + id: 'wt-1', + path, + connectionId: null, + repo: null, + folderWorkspace: null + }) +} + +describe('paired terminal create attach refusal', () => { + it.each(['exitedBeforeAttach', 'reattachUnverifiable'] as const)( + 'returns %s before registering a terminal', + async (outcome) => { + const runtime = new OrcaRuntimeService() + stubLaunchScope(runtime) + const spawn = vi.fn().mockResolvedValue({ id: 'retained-pty', [outcome]: true }) + const registerPty = vi.spyOn(runtime, 'registerPty') + const committed = vi.fn() + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const result = await runtime.createTerminal('id:wt-1', { + onPtySpawnCommitted: committed + }) + expect(result).toMatchObject({ ptyId: 'retained-pty', [outcome]: true }) + expect(registerPty).not.toHaveBeenCalled() + expect(committed).not.toHaveBeenCalled() + } + ) + + it.each(['exitedBeforeAttach', 'reattachUnverifiable'] as const)( + 'returns adopted %s before requesting a spawn', + async (outcome) => { + const runtime = new OrcaRuntimeService() + stubLaunchScope(runtime) + const spawn = vi.fn().mockResolvedValue({ id: 'wrong-pty' }) + const registerPty = vi.spyOn(runtime, 'registerPty') + runtime.setPtyController({ + spawn, + adoptStablePane: vi.fn().mockResolvedValue({ + result: { id: 'retained-pty', [outcome]: true }, + owner: { + handle: 'retained-handle', + tabId: 'tab-1', + leafId: 'pane:1', + ptyId: 'retained-pty' + } + }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const result = await runtime.createTerminal('id:wt-1') + expect(result).toMatchObject({ + handle: 'retained-handle', + ptyId: 'retained-pty', + [outcome]: true + }) + expect(spawn).not.toHaveBeenCalled() + expect(registerPty).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/runtime/rpc/methods/agent-session.ts b/src/main/runtime/rpc/methods/agent-session.ts index 08aaa91038e..77684befee9 100644 --- a/src/main/runtime/rpc/methods/agent-session.ts +++ b/src/main/runtime/rpc/methods/agent-session.ts @@ -1,3 +1,4 @@ +import { negotiateTerminalCreateRefusal } from '../terminal-create-refusal-negotiation' import { z } from 'zod' import { getAgentResumeArgv, @@ -237,20 +238,32 @@ export const AGENT_SESSION_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'terminal.ensureAgentSession', params: EnsureAgentSessionParams, - handler: (params, { runtime, pairedDeviceId, clientId, clientKind, signal }) => - (runtime as AgentSessionRuntime).ensureAgentSession( - withExecutionHostAgentPresentation(params, clientKind), - callerContext(pairedDeviceId ?? clientId, clientKind, signal) + handler: async ( + params, + { runtime, pairedDeviceId, clientId, clientKind, signal, clientCapabilities } + ) => + negotiateTerminalCreateRefusal( + await (runtime as AgentSessionRuntime).ensureAgentSession( + withExecutionHostAgentPresentation(params, clientKind), + callerContext(pairedDeviceId ?? clientId, clientKind, signal) + ), + clientCapabilities ) }), defineMethod({ name: 'terminal.createAgentSession', params: CreateAgentSessionParams, - handler: (params, { runtime, pairedDeviceId, clientId, clientKind, signal }) => { + handler: async ( + params, + { runtime, pairedDeviceId, clientId, clientKind, signal, clientCapabilities } + ) => { assertOperationTimestampWithinFutureSkew(params.clientOperationId) - return (runtime as AgentSessionRuntime).createAgentSession( - withExecutionHostAgentPresentation(params, clientKind), - callerContext(pairedDeviceId ?? clientId, clientKind, signal) + return negotiateTerminalCreateRefusal( + await (runtime as AgentSessionRuntime).createAgentSession( + withExecutionHostAgentPresentation(params, clientKind), + callerContext(pairedDeviceId ?? clientId, clientKind, signal) + ), + clientCapabilities ) } }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts index 07616a9d843..9570fe0abb0 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts @@ -53,6 +53,10 @@ export const ADMISSION_METHODS = [ }, { method: 'agentSession.ensure', params: attachParams() }, { method: 'agentSession.send', params: sendParams() }, + { + method: 'agentSession.rewind', + params: { envelope: envelope(), itemId: 'chosen', expectedEpoch: 'epoch' } + }, { method: 'agentSession.respondToApproval', params: { envelope: envelope(), itemId: 'item-1', expectedRevision: 1, optionId: 'allow' } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index 360af5d4d31..a702bda5afc 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -139,6 +139,7 @@ export function hostStub(): StructuredAgentSessionHost { unconfirmedClientMessageIds: [] } })), + rewind: vi.fn(async () => ({ ok: true, value: { itemId: 'chosen', epoch: 'next' } })), send: vi.fn(async () => ({ ok: true, replayed: false })), cancel: vi.fn(async () => ({ ok: true, replayed: false })), close: vi.fn(async () => undefined), diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 5ec7a31d80d..5c7f40d7f35 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -237,3 +237,11 @@ export const UnsubscribeParams = z /** Read-only owner classification retained for restart safety; mutation handoff is separate. */ export const HandoffStatusParams = z.object({ sessionId: SessionId }).strict() + +export const RewindParams = z + .object({ + envelope: MutationEnvelope, + itemId: Identifier('Invalid item id', 4096), + expectedEpoch: Identifier('Invalid journal epoch') + }) + .strict() diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 13e2383e667..94a344b6f18 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -160,7 +160,7 @@ describe('capability gating', () => { } // Bump deliberately: the whole agentSession.* surface is behind the structured capability, // so an additive method is invisible to old clients and needs no protocol bump. - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(21) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(22) }) it('hides the surface from a declared client that did not advertise it', async () => { @@ -668,3 +668,21 @@ describe('agentSession.subscribeStatus', () => { expect(hostCalls.subscribeStatus).toHaveBeenCalledOnce() }) }) + +describe('rewind wire boundary', () => { + it('routes the exact item and epoch through the structured capability gate', async () => { + const params = { envelope: envelope(), itemId: 'chosen', expectedEpoch: 'current' } + const result = await call('agentSession.rewind', params, STRUCTURED_CLIENT) + expect(result).toMatchObject({ result: { ok: true } }) + expect(hostCalls.rewind).toHaveBeenCalledWith(expect.anything(), params) + }) + it('rejects absent epoch and caller-supplied provider keys', async () => { + for (const params of [ + { envelope: envelope(), itemId: 'chosen' }, + { envelope: envelope(), itemId: 'chosen', expectedEpoch: 'current', beforeTurnId: 'forged' } + ]) { + expect(await call('agentSession.rewind', params, STRUCTURED_CLIENT)).toHaveProperty('error') + } + expect(hostCalls.rewind).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index ba3a5d7d6a0..3078c9fff61 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -46,6 +46,7 @@ import { HandoffStatusParams, OptionsParams, RespondParams, + RewindParams, SendParams, SetOptionParams, SubscribeParams, @@ -82,6 +83,15 @@ async function attachClientSuppliedLocation( } export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'agentSession.rewind', + params: RewindParams, + handler: async (params, ctx) => { + requireStructuredCapability(ctx) + await ensureHostInstalled(ctx) + return requireHost(ctx).rewind(callerFor(ctx), params) + } + }), defineMethod({ name: 'agentSession.conversationCommand', params: ConversationCommandParams, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts index 51dde7df4d8..8da76b3a5d0 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -1,3 +1,4 @@ +import { negotiateTerminalCreateRefusal } from '../../terminal-create-refusal-negotiation' import { defineMethod, type RpcAnyMethod } from '../../core' import { navigationTargetsHost, @@ -34,7 +35,10 @@ export const TERMINAL_LIFECYCLE_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'terminal.create', params: TerminalCreateParams, - handler: async (params, { runtime, pairedDeviceId, clientId, clientKind }) => { + handler: async ( + params, + { runtime, pairedDeviceId, clientId, clientKind, clientCapabilities } + ) => { // A focused terminal create predates paired-client navigation. Keep the // authority boundary here so a remote caller cannot activate the host // renderer. This legacy RPC remains a background create for paired viewers; @@ -44,38 +48,41 @@ export const TERMINAL_LIFECYCLE_METHODS: RpcAnyMethod[] = [ const activate = pairedViewer ? false : params.activate === true const presentation = pairedViewer && params.presentation === 'focused' ? 'background' : params.presentation - return { - terminal: await runtime.dedupeTerminalCreate( - pairedDeviceId ?? clientId ?? 'local', - params.worktree, - params.clientMutationId, - params.reconcileExisting === true, - (canonicalWorktreeSelector, preAllocatedHandle) => - runtime.createTerminal(canonicalWorktreeSelector, { - command: params.command, - startupCommandDelivery: params.startupCommandDelivery, - env: params.env, - envToDelete: params.envToDelete, - ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), - ...(params.resumeProviderSession - ? { resumeProviderSession: params.resumeProviderSession } - : {}), - ...(params.launchToken ? { launchToken: params.launchToken } : {}), - ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), - ...(params.terminalColorQueryReplies - ? { terminalColorQueryReplies: params.terminalColorQueryReplies } - : {}), - title: params.title, - focus, - rendererBacked: params.rendererBacked === true, - activate, - presentation, - tabId: params.tabId, - leafId: params.leafId, - ...(preAllocatedHandle ? { preAllocatedHandle } : {}) - }) - ) - } + return negotiateTerminalCreateRefusal( + { + terminal: await runtime.dedupeTerminalCreate( + pairedDeviceId ?? clientId ?? 'local', + params.worktree, + params.clientMutationId, + params.reconcileExisting === true, + (canonicalWorktreeSelector, preAllocatedHandle) => + runtime.createTerminal(canonicalWorktreeSelector, { + command: params.command, + startupCommandDelivery: params.startupCommandDelivery, + env: params.env, + envToDelete: params.envToDelete, + ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), + ...(params.resumeProviderSession + ? { resumeProviderSession: params.resumeProviderSession } + : {}), + ...(params.launchToken ? { launchToken: params.launchToken } : {}), + ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), + ...(params.terminalColorQueryReplies + ? { terminalColorQueryReplies: params.terminalColorQueryReplies } + : {}), + title: params.title, + focus, + rendererBacked: params.rendererBacked === true, + activate, + presentation, + tabId: params.tabId, + leafId: params.leafId, + ...(preAllocatedHandle ? { preAllocatedHandle } : {}) + }) + ) + }, + clientCapabilities + ) } }), defineMethod({ diff --git a/src/main/runtime/rpc/terminal-create-refusal-negotiation.test.ts b/src/main/runtime/rpc/terminal-create-refusal-negotiation.test.ts new file mode 100644 index 00000000000..0b227f74303 --- /dev/null +++ b/src/main/runtime/rpc/terminal-create-refusal-negotiation.test.ts @@ -0,0 +1,64 @@ +import type { RpcMethod } from './core' +import { expect, it, vi } from 'vitest' +import { TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { terminalAttachRefusal } from '../terminal-attach-refusal' +import { AGENT_SESSION_METHODS } from './methods/agent-session' +import { TERMINAL_LIFECYCLE_METHODS } from './methods/terminal/terminal-lifecycle-methods' +import { mapRuntimeError } from './errors' + +it.each(['terminal.create', 'terminal.createAgentSession', 'terminal.ensureAgentSession'])( + '%s negotiates both refusal outcomes without changing ordinary success', + async (method) => { + const definition = [...TERMINAL_LIFECYCLE_METHODS, ...AGENT_SESSION_METHODS].find( + (entry) => entry.name === method + )! as RpcMethod + for (const outcome of ['exitedBeforeAttach', 'reattachUnverifiable', 'success'] as const) { + const owner = { + handle: 'retained', + tabId: 'tab-1', + paneKey: 'tab-1:pane', + worktreeId: 'wt-1' + } + const terminal = + outcome === 'success' + ? { ...owner, ptyId: 'worker', title: null } + : terminalAttachRefusal({ id: 'worker', [outcome]: true }, owner)! + const result = { terminal, disposition: 'created' } + const runtime = { + createTerminal: vi.fn(async () => terminal), + dedupeTerminalCreate: vi.fn(async () => terminal), + createAgentSession: vi.fn(async () => result), + ensureAgentSession: vi.fn(async () => result) + } + for (const clientCapabilities of [ + undefined, + [], + [TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY] + ]) { + const call = definition.handler( + { + worktree: 'wt-1', + agent: 'codex', + kind: 'explicit', + providerSession: { key: 'session_id', id: 'session-1' }, + clientOperationId: '1752883200000-0123456789abcdef0123456789abcdef' + }, + { runtime, clientCapabilities } as never + ) + if (outcome === 'success' || clientCapabilities?.length) { + await expect(call).resolves.toMatchObject({ terminal }) + } else { + await expect(call).rejects.toMatchObject({ code: 'remote_runtime_unavailable' }) + try { + await call + } catch (error) { + expect(mapRuntimeError('request', { runtimeId: 'host' }, error)).toMatchObject({ + ok: false, + error: { code: 'remote_runtime_unavailable' } + }) + } + } + } + } + } +) diff --git a/src/main/runtime/rpc/terminal-create-refusal-negotiation.ts b/src/main/runtime/rpc/terminal-create-refusal-negotiation.ts new file mode 100644 index 00000000000..4a29c52164e --- /dev/null +++ b/src/main/runtime/rpc/terminal-create-refusal-negotiation.ts @@ -0,0 +1,19 @@ +import { TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import type { RuntimeTerminalCreate } from '../../../shared/runtime-terminal-contracts' +import type { RpcContext } from './core' + +export function negotiateTerminalCreateRefusal( + result: T, + clientCapabilities: RpcContext['clientCapabilities'] +): T { + if ( + (result.terminal.exitedBeforeAttach || result.terminal.reattachUnverifiable) && + !clientCapabilities?.includes(TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY) + ) { + // Older clients interpret every successful create as permission to publish a fresh binding. + throw Object.assign(new Error('Remote terminal attachment is temporarily unavailable.'), { + code: 'remote_runtime_unavailable' + }) + } + return result +} diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 73a75af017e..5845db9dd47 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -82,6 +82,8 @@ export type RuntimePtyController = { } }): Promise<{ id: string + exitedBeforeAttach?: true + reattachUnverifiable?: true pid?: number | null incarnationId?: PtyIncarnationId wslDistro?: string diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 26c9922bb07..7e743220741 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -1,3 +1,4 @@ +import { proveClaudeTranscriptBranch } from '../claude/claude-transcript-branch-proof' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { join } from 'node:path' @@ -70,10 +71,25 @@ export function createStructuredClaudeRuntimeAdapter( }) ) }, - readTranscriptLeaf: async ({ providerSessionId, previousLeafUuid, claudeConfigDir }) => { + readTranscriptLeaf: async ({ + providerSessionId, + previousLeafUuid, + intentionalRewindUuid, + claudeConfigDir + }) => { const transcriptPath = await resolveSessionFilePath('claude', providerSessionId, { claudeProjectsDir: join(claudeConfigDir, 'projects') }) + if (transcriptPath && intentionalRewindUuid !== undefined) { + return ( + await proveClaudeTranscriptBranch({ + transcriptPath, + providerSessionId, + previousLeafUuid, + intentionalRewindUuid + }) + ).leafUuid + } return transcriptPath ? await readClaudeTranscriptLeafUuid(transcriptPath, providerSessionId, previousLeafUuid) : null diff --git a/src/main/runtime/terminal-attach-refusal.ts b/src/main/runtime/terminal-attach-refusal.ts new file mode 100644 index 00000000000..d21b26630c8 --- /dev/null +++ b/src/main/runtime/terminal-attach-refusal.ts @@ -0,0 +1,20 @@ +import type { RuntimeTerminalCreate } from '../../shared/runtime-terminal-contracts' + +export function terminalAttachRefusal( + result: { + id: string + exitedBeforeAttach?: true + reattachUnverifiable?: true + }, + owner: { handle: string; tabId: string; paneKey: string; worktreeId: string } +): RuntimeTerminalCreate | null { + if (!result.exitedBeforeAttach && !result.reattachUnverifiable) { + return null + } + return { + ...owner, + ptyId: result.id, + title: null, + ...(result.exitedBeforeAttach ? { exitedBeforeAttach: true } : { reattachUnverifiable: true }) + } +} diff --git a/src/main/runtime/terminal-create-pane-identity.ts b/src/main/runtime/terminal-create-pane-identity.ts new file mode 100644 index 00000000000..98d0f7fb78d --- /dev/null +++ b/src/main/runtime/terminal-create-pane-identity.ts @@ -0,0 +1,19 @@ +import { randomUUID } from 'node:crypto' +import { isValidHostTerminalTabId } from '../../shared/terminal-tab-id' +import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' + +export function resolveTerminalCreatePaneIdentity(options: { tabId?: string; leafId?: string }): { + tabId: string + leafId: string + paneKey: string +} { + const hintedTabId = options.tabId?.trim() + const canAdopt = + hintedTabId !== undefined && + isValidHostTerminalTabId(hintedTabId) && + options.leafId !== undefined && + isTerminalLeafId(options.leafId) + const tabId = canAdopt ? hintedTabId! : randomUUID() + const leafId = canAdopt ? options.leafId! : randomUUID() + return { tabId, leafId, paneKey: makePaneKey(tabId, leafId) } +} diff --git a/src/main/runtime/terminal-create-reveal.ts b/src/main/runtime/terminal-create-reveal.ts new file mode 100644 index 00000000000..87f270a4250 --- /dev/null +++ b/src/main/runtime/terminal-create-reveal.ts @@ -0,0 +1,48 @@ +import type { RuntimeTerminalCreate, RuntimeTerminalPresentation } from '../../shared/runtime-types' +import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' +import type { RuntimeNotifier } from './runtime-notifier-contract' +import type { TerminalCreateOptions } from './runtime-terminal-contracts' +import { createTerminalRevealWarning, ownerSurfacing } from './orca-runtime-core' + +export async function revealCreatedTerminal(args: { + notifier?: RuntimeNotifier | null + worktreeId: string + workspacePath: string + handle: string + ptyId: string + tabId: string + leafId: string + cwd: string + presentation: RuntimeTerminalPresentation | undefined + launchOpts: TerminalCreateOptions + launchConfig?: SleepingAgentLaunchConfig | null + launchToken?: string + surfaceOwner?: boolean +}): Promise> { + if (args.presentation === 'background') { + return { surface: 'background' } + } + if (!args.notifier?.revealTerminalSession) { + return { surface: 'background', warning: createTerminalRevealWarning(args.handle) } + } + try { + await args.notifier.revealTerminalSession(args.worktreeId, { + ptyId: args.ptyId, + title: args.launchOpts.title ?? null, + ...(args.cwd !== args.workspacePath ? { cwd: args.cwd } : {}), + ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), + ...(args.launchToken ? { launchToken: args.launchToken } : {}), + ...(args.launchOpts.launchAgent ? { launchAgent: args.launchOpts.launchAgent } : {}), + ...(args.launchOpts.viewMode ? { viewMode: args.launchOpts.viewMode } : {}), + activate: args.presentation === 'focused', + ...(args.presentation ? { presentation: args.presentation } : {}), + ...ownerSurfacing(args.surfaceOwner !== false), + tabId: args.tabId, + leafId: args.leafId + }) + return { surface: 'visible' } + } catch (error) { + console.warn(`[terminal-create] failed to create inactive tab for ${args.ptyId}:`, error) + return { surface: 'background', warning: createTerminalRevealWarning(args.handle, error) } + } +} diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index d2d547d98cb..594a850b7c1 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -65,6 +65,8 @@ export type PtyApi = { isReattach?: boolean isAlternateScreen?: boolean replay?: string + exitedBeforeAttach?: boolean + reattachUnverifiable?: boolean sessionExpired?: boolean coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } startupCwdFallback?: { kind: 'worktree'; cwd: string } diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx index 791b5f75beb..8ab52d952b8 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx @@ -177,13 +177,13 @@ describe('BrowserPaneOverlayLayer', () => { expect(view.container.querySelectorAll('[data-browser-overlay-tab-id]')).toHaveLength(0) }) - it('keeps inactive browser panes mounted for a visible worktree', () => { + it('defers inactive browser panes while retaining their viewport slots', () => { const markup = renderOverlay({ isWorktreeActive: true }) expect(markup).toContain('data-browser-pane-id="browser-a"') expect(markup).toContain('data-browser-pane-active="true"') - expect(markup).toContain('data-browser-pane-id="browser-b"') - expect(markup).toContain('data-browser-pane-active="false"') + expect(markup).not.toContain('data-browser-pane-id="browser-b"') + expect(markup).toContain('data-browser-overlay-tab-id="browser-b"') }) it('marks the active browser pane focused when its own group holds focus', () => { @@ -195,6 +195,82 @@ describe('BrowserPaneOverlayLayer', () => { ) }) + it('restores 200 tabs on demand and preserves viewport roots across parking and selection', () => { + const browsers = Array.from({ length: 200 }, (_, index) => + createBrowserTab(`browser-${index}`, [`page-${index}`]) + ) + const tabs = browsers.map((browser, index) => + createUnifiedBrowserTab(`tab-${index}`, browser.id, index) + ) + mocks.state!.browserTabsByWorktree['wt-1'] = browsers + mocks.state!.unifiedTabsByWorktree['wt-1'] = tabs + const group = mocks.state!.groupsByWorktree['wt-1'][0] + mocks.state!.groupsByWorktree['wt-1'] = [ + { ...group, activeTabId: tabs[0].id, tabOrder: tabs.map((tab) => tab.id) } + ] + const view = render() + const slot = view.container.querySelector('[data-browser-overlay-tab-id="browser-0"]')! + const viewport = slot.firstElementChild + const pane = slot.querySelector('[data-browser-pane-id]') + expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(1) + expect(view.container.querySelectorAll('[data-browser-overlay-tab-id]')).toHaveLength(200) + + view.rerender() + expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(0) + expect(pane!.isConnected).toBe(false) + expect((slot as HTMLElement).style.display).toBe('none') + view.rerender() + expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(1) + expect(slot.querySelector('[data-browser-pane-id]')).not.toBe(pane) + view.rerender() + mocks.state!.groupsByWorktree['wt-1'] = [{ ...group, activeTabId: tabs[199].id }] + view.rerender() + expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(1) + expect(view.container.querySelector('[data-browser-pane-id="browser-199"]')).not.toBeNull() + expect(slot.firstElementChild).toBe(viewport) + expect(viewport!.isConnected).toBe(true) + }) + + it('retains zero unclaimed hidden panes after visiting 50 worktrees with 20 tabs each', () => { + const worktreeIds = Array.from({ length: 50 }, (_, index) => `wt-scale-${index}`) + for (const worktreeId of worktreeIds) { + const browsers = Array.from({ length: 20 }, (_, index) => ({ + ...createBrowserTab(`${worktreeId}-browser-${index}`, [`${worktreeId}-page-${index}`]), + worktreeId + })) + const tabs = browsers.map((browser, index) => ({ + ...createUnifiedBrowserTab(`${worktreeId}-tab-${index}`, browser.id, index), + worktreeId, + groupId: `${worktreeId}-group-${index}` + })) + mocks.state!.browserTabsByWorktree[worktreeId] = browsers + mocks.state!.unifiedTabsByWorktree[worktreeId] = tabs + mocks.state!.groupsByWorktree[worktreeId] = tabs.map((tab) => ({ + id: tab.groupId, + worktreeId, + activeTabId: tab.id, + tabOrder: [tab.id] + })) + } + const surfaces = (activeId: string | null) => + worktreeIds.map((worktreeId) => ( + + )) + const view = render(surfaces(null)) + for (const worktreeId of worktreeIds) { + view.rerender(surfaces(worktreeId)) + expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(20) + view.rerender(surfaces(null)) + expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(0) + } + expect(view.container.querySelectorAll('[data-browser-overlay-tab-id]')).toHaveLength(1000) + }) + it('keeps an active browser pane unfocused when another split holds focus (#11348)', () => { mocks.state = createState() mocks.state.groupsByWorktree = { diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx index d262e05b43a..3aee63c0af7 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx @@ -1,10 +1,11 @@ -import { memo, useCallback, useLayoutEffect, useMemo, useState } from 'react' +import { memo, useCallback, useMemo } from 'react' import { registerBrowserOverlaySlotViewport } from '../host-guest/browser-page-viewport' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '../../../store' import type { BrowserTab as BrowserTabState } from '../../../../../shared/browser-workspace-types' import type { Tab, TabGroup } from '../../../../../shared/tab-types' import BrowserPane from './browser-workspace-pane' +import { DeferredBrowserContent } from './DeferredBrowserContent' import type { BrowserChromeShortcutScope } from '../describe-page/browser-page-types' import { tabGroupBodyAnchorName } from '../../tab-group/tab-group-body-anchor' import { useBrowserGuestPaintRetention } from '../host-guest/browser-guest-paint-retention' @@ -28,23 +29,23 @@ const EMPTY_GROUPS: readonly TabGroup[] = [] type BrowserOverlaySlotProps = { browserTab: BrowserTabState + isWorktreeActive: boolean // Why: undefined = orphan tab (in browserTabs but not referenced by any group's unified-tab list); the fallback branch keeps these hidden. groupId: string | undefined isActive: boolean chromeShortcutScope: BrowserChromeShortcutScope // Why: overlay is a sibling of the group layout, so pane focus doesn't bubble to TabGroupPanel; re-sync it here or split-view clicks leave activeGroupIdByWorktree stale. onFocusOwningGroup: ((groupId: string) => void) | undefined - isWorktreeActive: boolean } // Why: memoize each slot so unrelated worktree mutations don't cascade a re-render into every BrowserPane subtree. const BrowserOverlaySlot = memo(function BrowserOverlaySlot({ browserTab, + isWorktreeActive, groupId, isActive, chromeShortcutScope, - onFocusOwningGroup, - isWorktreeActive + onFocusOwningGroup }: BrowserOverlaySlotProps): React.JSX.Element { // Why: persistent page viewports (webview guests) live under this root so they survive BrowserPane chrome unmounts without reparenting. const setSlotViewportRef = useCallback( @@ -60,8 +61,6 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({ : [browserTab.activePageId ?? browserTab.id] const needsGuestPaint = useBrowserGuestPaintRetention(browserPageIds) const isPaintable = isActive || needsGuestPaint - // Why: hidden worktrees keep lightweight overlay slots, but park their webviews unless a remote controller or viewer needs the guest. - const shouldMountPane = isWorktreeActive || needsGuestPaint // Why: CSS anchor positioning pins the overlay to its owning group's body — a tab move only swaps positionAnchor, no measurement/state. // Orphan branch (no anchorName) stays display:none until the tab is reassigned or destroyed. const style: React.CSSProperties = useMemo( @@ -104,14 +103,14 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({ onFocusCapture={handleFocus} >
- {/* Why: hidden worktrees park the heavy pane subtree; visible ones keep stable slots so reparenting can't destroy the webview guest. */} - {shouldMountPane ? ( + - ) : null} +
) }) @@ -188,11 +187,11 @@ const BrowserPaneOverlayLayer = memo(function BrowserPaneOverlayLayer({ ) })} @@ -265,17 +264,11 @@ export const RetainedBrowserPaneOverlayLayer = memo(function RetainedBrowserPane isWorktreeActive: boolean mountEligible: boolean }): React.JSX.Element | null { - const [hasCommittedMount, setHasCommittedMount] = useState(false) - // Why: commit the latch with the persistent slot DOM so discarded renders cannot retain a guest host. - useLayoutEffect(() => { - if (mountEligible && !hasCommittedMount) { - setHasCommittedMount(true) - } - }, [hasCommittedMount, mountEligible]) - if (!mountEligible && !hasCommittedMount) { - return null - } - return + return ( + + + + ) }) export default BrowserPaneOverlayLayer diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/DeferredBrowserContent.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/DeferredBrowserContent.tsx new file mode 100644 index 00000000000..4cc96b7c19c --- /dev/null +++ b/src/renderer/src/components/browser-pane/assemble-chrome/DeferredBrowserContent.tsx @@ -0,0 +1,22 @@ +import { useLayoutEffect, useState, type ReactNode } from 'react' + +export function DeferredBrowserContent({ + mountEligible, + retainMounted = true, + children +}: { + mountEligible: boolean + retainMounted?: boolean + children: ReactNode +}): React.JSX.Element | null { + const [hasCommittedMount, setHasCommittedMount] = useState(false) + // Only committed, retainable mounts may survive the loss of eligibility. + useLayoutEffect(() => { + if (!retainMounted) { + setHasCommittedMount(false) + } else if (mountEligible && !hasCommittedMount) { + setHasCommittedMount(true) + } + }, [hasCommittedMount, mountEligible, retainMounted]) + return mountEligible || (retainMounted && hasCommittedMount) ? <>{children} : null +} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-deferred-lifecycle.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-deferred-lifecycle.test.tsx new file mode 100644 index 00000000000..9d536c1ba6f --- /dev/null +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-deferred-lifecycle.test.tsx @@ -0,0 +1,299 @@ +// @vitest-environment happy-dom +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createStore, type StoreApi } from 'zustand' +import type { BrowserPage, BrowserWorkspace } from '../../../../../shared/browser-workspace-types' +import type { Tab, TabGroup } from '../../../../../shared/tab-types' + +type MockState = { + browserTabsByWorktree: Record + browserPagesByWorkspace: Record + unifiedTabsByWorktree: Record + groupsByWorktree: Record + activeGroupIdByWorktree: Record + remoteBrowserPageHandlesByPageId: Record + focusGroup: () => void + updateBrowserPageState: () => void + setBrowserPageUrl: () => void + settings: { browserSshWorkspaceRoutingEnabled: boolean } +} + +const mocks = vi.hoisted(() => ({ + state: null as MockState | null, + store: null as StoreApi | null, + executionHostId: 'local', + prepare: vi.fn(), + destroy: vi.fn() +})) + +vi.mock('@/store', async () => { + const { useStore } = await import('zustand') + return { + useAppStore: (selector: (state: MockState) => unknown) => useStore(mocks.store!, selector) + } +}) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: () => null, + getExecutionHostIdForWorktree: () => mocks.executionHostId +})) +vi.mock('@/components/contextual-tours/use-contextual-tour', () => ({ + useContextualTour: () => {} +})) +vi.mock('../host-guest/webview-registry', () => ({ destroyPersistentWebview: mocks.destroy })) +vi.mock('./BrowserMobileDriverOverlay', () => ({ BrowserMobileDriverOverlay: () => null })) +vi.mock('./browser-page-pane', () => ({ + BrowserPagePane: ({ browserTab, isActive }: { browserTab: BrowserPage; isActive: boolean }) => ( + + ) +})) +vi.mock('../workspace-doc/workspace-doc-page-pane', () => ({ + WorkspaceDocPagePane: ({ page, isActive }: { page: BrowserPage; isActive: boolean }) => ( + + ) +})) + +import BrowserPaneOverlayLayer from './BrowserPaneOverlayLayer' +import { + acquireBrowserAutomationVisibility, + releaseBrowserAutomationVisibility +} from '../host-guest/browser-automation-visibility' +import { hydrateBrowserDrivers } from '@/lib/pane-manager/browser-mobile-driver-state' +import { hydrateBrowserRemoteViewerPages } from '@/lib/pane-manager/browser-remote-viewer-state' + +function createState(): MockState { + const browsers: BrowserWorkspace[] = ['a', 'b'].map((id) => ({ + id, + worktreeId: 'wt-1', + label: id, + sessionProfileId: null, + activePageId: `${id}-1`, + pageIds: [`${id}-1`, `${id}-2`], + url: 'about:blank', + title: id, + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + })) + return { + browserTabsByWorktree: { 'wt-1': browsers }, + browserPagesByWorkspace: Object.fromEntries( + browsers.map((browser) => [ + browser.id, + (browser.pageIds ?? []).map((id) => ({ ...browser, id, workspaceId: browser.id })) + ]) + ), + unifiedTabsByWorktree: { + 'wt-1': browsers.map((browser, index) => ({ + id: browser.id, + entityId: browser.id, + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'browser', + label: browser.id, + customLabel: null, + color: null, + sortOrder: index, + createdAt: 1 + })) + }, + groupsByWorktree: { + 'wt-1': [{ id: 'group-1', worktreeId: 'wt-1', activeTabId: 'a', tabOrder: ['a', 'b'] }] + }, + activeGroupIdByWorktree: { 'wt-1': 'group-1' }, + remoteBrowserPageHandlesByPageId: {}, + focusGroup: () => {}, + updateBrowserPageState: () => {}, + setBrowserPageUrl: () => {}, + settings: { browserSshWorkspaceRoutingEnabled: true } + } +} + +function selectTab(id: string): void { + mocks.state!.groupsByWorktree['wt-1'] = [ + { ...mocks.state!.groupsByWorktree['wt-1'][0], activeTabId: id } + ] +} + +function selectPage(id: string): void { + mocks.state!.browserTabsByWorktree['wt-1'] = mocks.state!.browserTabsByWorktree['wt-1'].map( + (browser) => (browser.id === 'a' ? { ...browser, activePageId: id } : browser) + ) +} + +const surface = (active = true) => ( + +) +function redraw(view: ReturnType, active = true): void { + act(() => mocks.store!.setState({ ...mocks.state! })) + view.rerender(surface(active)) +} +const settle = () => act(async () => {}) + +describe('deferred browser lifecycle through the overlay and SSH gate', () => { + beforeEach(() => { + mocks.state = createState() + mocks.store = createStore(() => mocks.state!) + mocks.executionHostId = 'local' + mocks.destroy.mockReset() + mocks.prepare.mockReset().mockResolvedValue({ partition: 'persist:orca-browser-v1-routed' }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { browser: { prepareSshWorkspacePartition: mocks.prepare } } + }) + }) + afterEach(() => { + cleanup() + hydrateBrowserDrivers([]) + hydrateBrowserRemoteViewerPages([]) + }) + + it.each(['automation', 'mobile', 'viewer'])( + 'releases hidden sibling chrome without remounting the %s-claimed page', + (consumer) => { + const view = render(surface()) + selectPage('a-2') + redraw(view) + const claimed = view.container.querySelector('[data-page-id="a-2"]') + selectTab('b') + redraw(view) + let token: string | null = null + act(() => { + if (consumer === 'automation') { + token = acquireBrowserAutomationVisibility('a-2') + } + if (consumer === 'mobile') { + hydrateBrowserDrivers([ + { browserPageId: 'a-2', driver: { kind: 'mobile', clientId: 'phone-1' } } + ]) + } + if (consumer === 'viewer') { + hydrateBrowserRemoteViewerPages(['a-2']) + } + }) + try { + redraw(view, false) + expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(1) + expect(view.container.querySelector('[data-page-id="a-2"]')).toBe(claimed) + redraw(view) + expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(2) + expect(view.container.querySelector('[data-page-id="a-1"]')).toBeNull() + expect(view.container.querySelector('[data-page-id="a-2"]')).toBe(claimed) + redraw(view, false) + act(() => { + if (token) { + releaseBrowserAutomationVisibility(token) + } + hydrateBrowserDrivers([]) + hydrateBrowserRemoteViewerPages([]) + }) + expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(0) + redraw(view) + expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(1) + expect(view.container.querySelector('[data-page-id="b-1"]')).not.toBeNull() + } finally { + if (token) { + releaseBrowserAutomationVisibility(token) + } + } + } + ) + + it.each(['url', 'document'])( + 'retains %s content across page and tab switches within a visible worktree', + (kind) => { + if (kind === 'document') { + mocks.state!.browserPagesByWorkspace.a[0].docLocation = { + kind: 'workspace-doc', + worktreeId: 'wt-1', + filePath: '/workspace/report.html' + } + } + const view = render(surface()) + const page = view.container.querySelector('[data-page-id="a-1"]')! + page.value = 'unsaved state' + page.scrollTop = 80 + expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(1) + selectPage('a-2') + redraw(view) + expect(page.isConnected).toBe(true) + expect(page.dataset.active).toBe('false') + selectTab('b') + redraw(view) + expect(page.isConnected).toBe(true) + selectPage('a-1') + selectTab('a') + redraw(view) + expect(view.container.querySelector('[data-page-id="a-1"]')).toBe(page) + expect(page.value).toBe('unsaved state') + expect(page.scrollTop).toBe(80) + expect(page.dataset.active).toBe('true') + expect(view.container.querySelector('[data-page-id="b-2"]')).toBeNull() + + redraw(view, false) + expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(0) + redraw(view) + const restored = view.container.querySelector('[data-page-id="a-1"]')! + expect(restored).not.toBe(page) + expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(1) + + mocks.state!.browserPagesByWorkspace.a = mocks.state!.browserPagesByWorkspace.a.slice(1) + mocks.state!.browserTabsByWorktree['wt-1'] = [...mocks.state!.browserTabsByWorktree['wt-1']] + redraw(view) + expect(page.isConnected).toBe(false) + expect(restored.isConnected).toBe(false) + } + ) + + it('keeps a prepared SSH gate and its opened pages alive when switching tabs', async () => { + mocks.executionHostId = 'ssh:target-a' + const view = render(surface()) + await settle() + const page = view.container.querySelector('[data-page-id="a-1"]') + expect(page).not.toBeNull() + mocks.destroy.mockClear() + selectTab('b') + redraw(view) + await settle() + expect(mocks.destroy.mock.calls.flat()).not.toContain('a-1') + mocks.destroy.mockClear() + mocks.prepare.mockClear() + selectTab('a') + redraw(view) + await settle() + expect(mocks.destroy).not.toHaveBeenCalled() + expect(mocks.prepare).not.toHaveBeenCalled() + expect(view.container.querySelector('[data-page-id="a-1"]')).toBe(page) + redraw(view, false) + expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(0) + expect(mocks.destroy).not.toHaveBeenCalled() + redraw(view) + await settle() + expect(mocks.prepare).toHaveBeenCalledOnce() + expect(view.container.querySelector('[data-page-id="a-1"]')).not.toBe(page) + expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(1) + }) + + it('guards all pages, including inactive tabs, when SSH routing is enabled', async () => { + mocks.executionHostId = 'ssh:target-a' + mocks.state!.settings.browserSshWorkspaceRoutingEnabled = false + const view = render(surface()) + selectPage('a-2') + redraw(view) + selectTab('b') + redraw(view) + selectTab('a') + redraw(view) + mocks.destroy.mockClear() + mocks.prepare.mockImplementation(() => new Promise(() => {})) + mocks.state!.settings = { browserSshWorkspaceRoutingEnabled: true } + mocks.state!.browserTabsByWorktree['wt-1'] = mocks.state!.browserTabsByWorktree['wt-1'].map( + (browser) => ({ ...browser }) + ) + redraw(view) + expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(0) + expect(new Set(mocks.destroy.mock.calls.flat())).toEqual(new Set(['a-1', 'a-2', 'b-1', 'b-2'])) + }) +}) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx index 527a05f1460..3c329728846 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx @@ -149,14 +149,49 @@ describe('browser workspace pane retention props', () => { hydrateBrowserRemoteViewerPages([]) }) + it('defers unopened pages out of 200 and retains opened pages until unmount', () => { + const pages = Array.from({ length: 200 }, (_, index) => createPage(`page-${index}`)) + mocks.state!.browserPagesByWorkspace[WORKSPACE_ID] = pages + const workspace = { ...createWorkspace(), activePageId: pages[0].id } + const view = render() + const renderedIds = (): (string | null)[] => + [...view.container.querySelectorAll('[data-browser-page-id]')].map((node) => + node.getAttribute('data-browser-page-id') + ) + expect(renderedIds()).toEqual(['page-0']) + + view.rerender() + expect(renderedIds()).toEqual(['page-0', 'page-199']) + view.rerender() + expect(renderedIds()).toEqual(['page-0', 'page-199']) + view.rerender() + expect(renderedIds()).toEqual(['page-0']) + }) + + it.each(['automation', 'mobile', 'viewer'])('loads an inactive page for %s only', (consumer) => { + const token = consumer === 'automation' ? acquireBrowserAutomationVisibility('page-b') : null + if (consumer === 'mobile') { + hydrateBrowserDrivers([ + { browserPageId: 'page-b', driver: { kind: 'mobile', clientId: 'phone-1' } } + ]) + } + if (consumer === 'viewer') { + hydrateBrowserRemoteViewerPages(['page-b']) + } + try { + const view = render() + expect(view.container.querySelector('[data-browser-page-id="page-a"]')).toBeNull() + expect(view.container.querySelector('[data-browser-page-id="page-b"]')).not.toBeNull() + } finally { + if (token) { + releaseBrowserAutomationVisibility(token) + } + } + }) + it('threads all three retention terms to the page that owns them', () => { renderWorkspacePane() - expect(propsFor('page-b')).toEqual({ - id: 'page-b', - isAutomationVisible: false, - isMobileDriven: false, - isRemotelyViewed: false - }) + expect(mocks.pageProps.some((props) => props.id === 'page-b')).toBe(false) cleanup() const token = acquireBrowserAutomationVisibility('page-b') diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.tsx index e5c2cc28240..b50a6aa1692 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.tsx @@ -19,15 +19,19 @@ import { RemoteBrowserPagePane } from '../stream-remote/remote-browser-page-pane import { ClientHostedBrowserPagePane } from '../ClientHostedBrowserPagePane' import { BrowserPagePane } from './browser-page-pane' import { WorkspaceDocPagePane } from '../workspace-doc/workspace-doc-page-pane' +import { DeferredBrowserContent } from './DeferredBrowserContent' +import { isBrowserPagePanePaintable } from '../host-guest/browser-page-paintability' import { SshRoutedBrowserPageGate } from './ssh-routed-browser-page-gate' export default function BrowserPane({ browserTab, isActive, + isWorktreeActive = true, chromeShortcutScope }: { browserTab: BrowserWorkspaceState isActive: boolean + isWorktreeActive?: boolean chromeShortcutScope?: BrowserChromeShortcutScope }): React.JSX.Element { const resolvedChromeShortcutScope = chromeShortcutScope ?? (isActive ? 'focused' : 'inactive') @@ -55,17 +59,17 @@ export default function BrowserPane({ const automationVisiblePageIds = useBrowserAutomationVisiblePageIds(browserPageIds) const mobileDrivenPageIds = useBrowserMobileDrivenPageIds(browserPageIds) const remotelyViewedPageIds = useBrowserRemotelyViewedPageIds(browserPageIds) - // Why: inactive webviews must stay mounted in their original DOM parent; unmounting/reparenting loses form text and SPA state. - const renderedBrowserPages = useMemo( + const localBrowserPages = useMemo( () => browserPages.filter( (page) => !getBrowserPageRuntimeEnvironmentId(page, activeRuntimeEnvironmentId) ), [browserPages, activeRuntimeEnvironmentId] ) - const renderedBrowserPageIds = useMemo( - () => renderedBrowserPages.map((page) => page.id), - [renderedBrowserPages] + // Routing guards every local guest, including pages hidden after their first activation. + const localBrowserPageIds = useMemo( + () => localBrowserPages.map((page) => page.id), + [localBrowserPages] ) const pageDriver = useBrowserDriverForPage(activeBrowserPageId) // Why: a runtime-backed page is streamed, never locally driven, so its driver must read idle. @@ -149,42 +153,51 @@ export default function BrowserPane({ return (
- {renderedBrowserPages.length > 0 ? ( + {localBrowserPages.length > 0 ? ( {(routedPartition) => (
- {renderedBrowserPages.map((page) => - page.docLocation ? ( - - ) : ( - - ) - )} + {localBrowserPages.map((page) => ( + + {page.docLocation ? ( + + ) : ( + + )} + + ))} ({ + replace: vi.fn(async () => {}), + registeredIds: new Map(), + isRegistered: vi.fn(async () => true) +})) +vi.mock('./webview-registry', () => ({ + registeredWebContentsIds: mocks.registeredIds, + replacePersistentWebview: mocks.replace +})) +vi.mock('../describe-page/browser-page-load-error', () => ({ browserPageExists: () => true })) + +function createPage(id: string) { + const webview = document.createElement('webview') as Electron.WebviewTag + webview.getWebContentsId = vi.fn(() => { + if (!webview.isConnected) { + throw new Error('guest destroyed') + } + return 1 + }) + document.body.appendChild(webview) + const paintable = { current: false } + const setGeneration = vi.fn() + const ref = (current: T) => ({ current }) + const session = createBrowserPageWebviewGuestSession({ + webview, + browserTabId: id, + workspaceId: 'browser-1', + worktreeId: 'wt-1', + sessionProfileId: null, + webviewRef: ref(webview), + isPaintableRef: paintable, + guestRecoveryPendingRef: ref(false), + browserTabUrlRef: ref('https://example.test'), + addressBarValueRef: ref('https://example.test'), + activeLoadFailureRef: ref(null), + recoveryNavigationValidationRef: ref(null), + keepAddressBarFocusRef: ref(false), + paneZoomLevelRef: ref(0), + viewportPresetIdRef: ref(null), + onUpdatePageStateRef: ref(vi.fn()), + setGuestRecoveryGeneration: setGeneration, + setBrowserZoomPercent: vi.fn(), + focusAddressBarNow: () => false, + syncNavigationState: vi.fn(), + syncBrowserAnnotationViewportBridge: vi.fn() + }) + return { webview, paintable, setGeneration, recovery: session.guestRecovery } +} + +describe('retained browser panes after guest eviction', () => { + const pages: ReturnType[] = [] + beforeEach(() => { + mocks.replace.mockClear() + mocks.isRegistered.mockClear() + mocks.registeredIds.clear() + Object.defineProperty(window, 'api', { + configurable: true, + value: { browser: { isGuestRegistered: mocks.isRegistered } } + }) + }) + afterEach(() => { + for (const page of pages.splice(0)) { + page.recovery.dispose() + page.webview.remove() + } + }) + + it('rebuilds only the selected page after evicting 200 hidden guests', async () => { + for (let index = 0; index < 200; index++) { + const page = createPage(`page-${index}`) + pages.push(page) + page.webview.remove() + page.recovery.validateAfterResume() + } + expect(mocks.replace).not.toHaveBeenCalled() + pages[199].paintable.current = true + pages[199].recovery.validateAfterResume() + await vi.waitFor(() => expect(pages[199].setGeneration).toHaveBeenCalledOnce()) + expect(mocks.replace).toHaveBeenCalledExactlyOnceWith('page-199') + expect(pages.slice(0, 199).every((page) => page.setGeneration.mock.calls.length === 0)).toBe( + true + ) + expect(mocks.isRegistered).not.toHaveBeenCalled() + }) + + it('reuses a connected registered guest on reactivation', async () => { + const page = createPage('page-1') + pages.push(page) + mocks.registeredIds.set('page-1', 1) + page.paintable.current = true + page.recovery.validateAfterResume() + await vi.waitFor(() => expect(mocks.isRegistered).toHaveBeenCalledOnce()) + expect(mocks.replace).not.toHaveBeenCalled() + expect(page.setGeneration).not.toHaveBeenCalled() + }) + + it('does not mistake a connected guest awaiting dom-ready for an evicted guest', async () => { + const page = createPage('page-1') + pages.push(page) + vi.mocked(page.webview.getWebContentsId).mockImplementation(() => { + throw new Error('not ready') + }) + page.paintable.current = true + page.recovery.validateAfterResume() + await vi.waitFor(() => expect(page.webview.getWebContentsId).toHaveBeenCalledOnce()) + expect(mocks.replace).not.toHaveBeenCalled() + expect(page.setGeneration).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts index bc767017b70..4623b817b2a 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts @@ -134,6 +134,10 @@ export function createBrowserPageWebviewGuestSession({ guestRecoveryPendingRef.current = pending }, validateRegistration: async () => { + // Budget eviction can remove a hidden guest while its pane stays mounted. + if (!webview.isConnected) { + return false + } let webContentsId: number try { webContentsId = webview.getWebContentsId() diff --git a/src/renderer/src/components/browser-pane/host-guest/use-guest-drag-passthrough.ts b/src/renderer/src/components/browser-pane/host-guest/use-guest-drag-passthrough.ts deleted file mode 100644 index d77a526529a..00000000000 --- a/src/renderer/src/components/browser-pane/host-guest/use-guest-drag-passthrough.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useEffect, type MutableRefObject } from 'react' -import { useWebviewDragPassthroughActive } from './use-webview-drag-passthrough-active' - -/** - * Enrols a single component-owned guest in the renderer's drag passthrough. - * - * Why it matters: a `` swallows the pointer stream the document never sees, so a - * dnd-kit drag stops receiving `pointermove` the instant the cursor crosses one — the dragged - * tab stops following the cursor and the drop it was aiming for cannot be made. The browser - * pane's guests are held click-through through their registry; a guest that belongs to one - * component instead (the document preview) has no registry to be walked by, so it enrols here. - */ -export function useGuestDragPassthrough( - webviewRef: MutableRefObject, - /** Changes when the ref is pointed at a new guest, so one attached mid-drag is settled too. */ - guestKey: string | null -): void { - const passthroughActive = useWebviewDragPassthroughActive() - - useEffect(() => { - const webview = webviewRef.current - if (!webview) { - return - } - webview.style.pointerEvents = passthroughActive ? 'none' : '' - return () => { - // Why reset rather than restore: the guest outlives this state, and leaving it transparent - // would cost the reader every click on the document. - webview.style.pointerEvents = '' - } - }, [guestKey, passthroughActive, webviewRef]) -} diff --git a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx index 8fbc26b447c..8c100a3994a 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx @@ -9,6 +9,7 @@ // read error or a revoked grant); 'unsupported-asset' comes from a subresource whose format the // host declined to send — a font, say — and never from the document itself. import { act } from 'react' +import type * as WebviewRegistryModule from '../host-guest/webview-registry' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' @@ -47,7 +48,8 @@ vi.mock('@/lib/doc-preview-grants', () => ({ } })) -vi.mock('@/components/browser-pane/host-guest/webview-registry', () => ({ +vi.mock('@/components/browser-pane/host-guest/webview-registry', async (importOriginal) => ({ + ...(await importOriginal()), moveFocusToRendererBeforeWebviewDetach: () => undefined })) diff --git a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx index 32ddf0a6d2b..aaee9c6243f 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx @@ -5,6 +5,8 @@ // showing the internal preview scheme, Back/Forward really drive the guest's history, and the chip // hands over the path the owner spells rather than the one the grant was minted with. import { act } from 'react' +import type { BrowserPage, BrowserWorkspace } from '../../../../../shared/browser-workspace-types' +import type * as WebviewRegistryModule from '../host-guest/webview-registry' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' @@ -39,7 +41,8 @@ vi.mock('@/lib/doc-preview-grants', () => ({ releaseDocPreviewGrant: () => undefined })) -vi.mock('@/components/browser-pane/host-guest/webview-registry', () => ({ +vi.mock('@/components/browser-pane/host-guest/webview-registry', async (importOriginal) => ({ + ...(await importOriginal()), moveFocusToRendererBeforeWebviewDetach: () => undefined })) @@ -126,13 +129,14 @@ type StubWebview = Element & { async function renderPreview( container: HTMLDivElement, root: Root, - options: { holdsGuestFocus?: boolean } = {} + options: { holdsGuestFocus?: boolean; isActive?: boolean } = {} ): Promise { const { HtmlDocPreview } = await import('./HtmlDocPreview') await act(async () => { root.render( { } }, browser: { + unregisterGuest: () => Promise.resolve(), setGrabMode: (args: { browserPageId: string; enabled: boolean }) => { grabCalls.push(args) return Promise.resolve({ ok: true }) @@ -222,6 +227,55 @@ describe('HtmlDocPreview browser chrome', () => { container.remove() }) + it('counts document guests in the workspace budget and restores only on activation', async () => { + const { hasLiveBrowserGuest, webviewRegistry } = await import('../host-guest/webview-registry') + const { worktreeHoldsLiveBrowserGuests, selectBrowserGuestEvictionWorktreeIds } = + await import('../host-guest/browser-guest-worktree-retention') + const { destroyWorktreeBrowserGuests } = await import('@/store/slices/browser-webview-cleanup') + const guest = await renderPreview(container, root) + expect(hasLiveBrowserGuest('preview-1')).toBe(true) + expect(await renderPreview(container, root, { isActive: false })).toBe(guest) + const page: BrowserPage = { + id: 'preview-1', + workspaceId: 'browser-1', + worktreeId: 'wt-1', + url: 'about:blank', + title: 'Report', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1, + docLocation: { kind: 'workspace-doc', worktreeId: 'wt-1', filePath: ABSOLUTE_PATH } + } + const browsers: BrowserWorkspace[] = [{ ...page, id: 'browser-1', pageIds: [page.id] }] + const pages: Record = { 'browser-1': [page] } + const evicted = selectBrowserGuestEvictionWorktreeIds({ + orderedWorktreeIds: ['wt-1'], + activeWorktreeId: 'wt-2', + limit: 0, + isRetained: () => true, + isEvictable: () => true, + holdsLiveGuests: () => worktreeHoldsLiveBrowserGuests(browsers, pages, hasLiveBrowserGuest) + }) + expect(evicted).toEqual(['wt-1']) + await act(async () => { + destroyWorktreeBrowserGuests({ 'wt-1': browsers }, pages, 'wt-1') + }) + expect(guest.isConnected).toBe(false) + expect(hasLiveBrowserGuest('preview-1')).toBe(false) + expect(container.querySelector('webview')).toBeNull() + const restored = await renderPreview(container, root) + expect(restored).not.toBe(guest) + expect(webviewRegistry.get('preview-1')).toBe(restored) + expect(await renderPreview(container, root, { isActive: false })).toBe(restored) + expect(await renderPreview(container, root)).toBe(restored) + await act(async () => root.unmount()) + mounted = false + expect(hasLiveBrowserGuest('preview-1')).toBe(false) + }) + it('identifies the document by its workspace path and owning machine', async () => { await renderPreview(container, root) diff --git a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx index 7e790df561a..066251cecdd 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx @@ -10,7 +10,6 @@ import { returnAcrossBrowserPageConversion } from '@/lib/browser-page-conversion-history' import { BrowserGuestAnnotateOverlays } from '@/components/browser-pane/annotate/browser-guest-annotate-overlays' -import { useGuestDragPassthrough } from '@/components/browser-pane/host-guest/use-guest-drag-passthrough' import { attachDocPreviewWebview } from './doc-preview-webview-attach' import { buildDocPreviewGrantRequest, @@ -47,6 +46,7 @@ export function HtmlDocPreview({ relativePath, worktreeId, holdsGuestFocus = false, + isActive = true, runtimeEnvironmentId = null, externalSshTargetId = null, convertedFrom = null, @@ -58,6 +58,7 @@ export function HtmlDocPreview({ worktreeId: string /** Whether this preview is the surface the reader is in, and so may hold the keyboard. */ holdsGuestFocus?: boolean + isActive?: boolean runtimeEnvironmentId?: string | null externalSshTargetId?: string | null /** Set when the address bar converted this page; Back returns across it once guest history runs out. */ @@ -125,7 +126,6 @@ export function HtmlDocPreview({ [filePath, hostLabel, worktreeRoot] ) const isUnavailable = state === 'unavailable' || failureReason !== null - useGuestDragPassthrough(webviewRef, grantId) const { grab, markup, annotationSend, grabAnnotations, browserOverlayViewport, elementTools } = useDocPreviewGuestTools({ previewId, @@ -217,6 +217,7 @@ export function HtmlDocPreview({ return } const attached = attachDocPreviewWebview({ + previewId, container: containerRef.current, url: handle.url, ariaLabel: translate( @@ -270,6 +271,13 @@ export function HtmlDocPreview({ worktreeId ]) + useEffect(() => { + // Eviction removes the guest, not the retained pane; only the selected preview restores it. + if (isActive && webviewRef.current && !webviewRef.current.isConnected) { + setRemintCount((count) => count + 1) + } + }, [isActive, previewId]) + // The dropdown's doc-history source: opening a document is a visit, once per document per mount // (a hard reload re-mints the grant but is not a new visit). useEffect(() => { diff --git a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-webview-attach.test.ts b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-webview-attach.test.ts new file mode 100644 index 00000000000..e057e0a7d3c --- /dev/null +++ b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-webview-attach.test.ts @@ -0,0 +1,40 @@ +// @vitest-environment happy-dom +import { expect, it, vi } from 'vitest' +import { acquireWebviewsDragPassthrough } from '../host-guest/webview-drag-passthrough' +import { webviewRegistry } from '../host-guest/webview-registry' +import { attachDocPreviewWebview } from './doc-preview-webview-attach' + +it('restores pointer input when a drag ends after attaching a document preview', () => { + const container = document.createElement('div') + document.body.appendChild(container) + const append = vi.spyOn(container, 'appendChild') + append.mockImplementation((node) => { + expect((node as HTMLElement).style.pointerEvents).toBe('none') + return Node.prototype.appendChild.call(container, node) + }) + const release = acquireWebviewsDragPassthrough() + const attached = attachDocPreviewWebview({ + previewId: 'preview-drag', + container, + url: 'orca-preview://grant/index.html', + ariaLabel: 'HTML preview', + onLoadStarted: vi.fn(), + onLoadStopped: vi.fn(), + onLoadFailed: vi.fn(), + onNavigated: vi.fn(), + onTitleUpdated: vi.fn() + }) + + try { + expect(webviewRegistry.get('preview-drag')).toBe(attached.webview) + expect(attached.webview.style.pointerEvents).toBe('none') + release() + expect(attached.webview.style.pointerEvents).toBe('') + } finally { + release() + attached.detach() + container.remove() + append.mockRestore() + } + expect(webviewRegistry.has('preview-drag')).toBe(false) +}) diff --git a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-webview-attach.ts b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-webview-attach.ts index e5020ea3d1d..19989f0a709 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-webview-attach.ts +++ b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-webview-attach.ts @@ -1,9 +1,14 @@ import { DOC_PREVIEW_PARTITION } from '../../../../../shared/doc-preview-scheme' import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../../shared/browser-guest-web-preferences' -import { isWebviewDragPassthroughActive } from '@/components/browser-pane/host-guest/webview-drag-passthrough' -import { moveFocusToRendererBeforeWebviewDetach } from '@/components/browser-pane/host-guest/webview-registry' +import { + moveFocusToRendererBeforeWebviewDetach, + registerPersistentWebview, + unregisterPersistentWebview, + webviewRegistry +} from '@/components/browser-pane/host-guest/webview-registry' export function attachDocPreviewWebview({ + previewId, container, url, ariaLabel, @@ -13,6 +18,7 @@ export function attachDocPreviewWebview({ onNavigated, onTitleUpdated }: { + previewId: string container: HTMLDivElement url: string ariaLabel: string @@ -45,13 +51,8 @@ export function attachDocPreviewWebview({ // Why the document names its own tab: a preview is a browser tab, and this is how every other // one is named. What the document cannot do is name it the grant it is served over. webview.addEventListener('page-title-updated', onTitleUpdated) - // Why here and not in the enrolling hook: appending is what makes this guest hittable, and the - // registry's contract is that the path doing so settles it. Dragging the preview's own tab - // remounts this component mid-drag, and a hook effect lands a turn too late — for the rest of - // that turn the fresh guest eats the pointer stream and the drag freezes. - if (isWebviewDragPassthroughActive()) { - webview.style.pointerEvents = 'none' - } + // Register before append so a guest attached mid-drag cannot swallow the pointer stream. + registerPersistentWebview(previewId, webview) container.appendChild(webview) webview.setAttribute('src', url) @@ -66,6 +67,9 @@ export function attachDocPreviewWebview({ webview.removeEventListener('page-title-updated', onTitleUpdated) moveFocusToRendererBeforeWebviewDetach(webview) webview.remove() + if (webviewRegistry.get(previewId) === webview) { + unregisterPersistentWebview(previewId) + } }, // Why: the protocol handler answers with no-store, so a reload re-reads the workspace disk. reload: () => { diff --git a/src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.tsx b/src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.tsx index 59f52ea6f23..6ec243c34f0 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.tsx @@ -48,6 +48,7 @@ export function WorkspaceDocPagePane({ // grab in flight, exactly as a URL page's pane does.