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 ed9ba6526b9..4ef1beb8d8a 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 @@ -60,6 +60,13 @@ import { adoptStructuredTuiOwner, type StructuredTuiAdoptionRequest } from './structured-agent-session-tui-adoption' +import { + releaseStructuredTuiAdoptionReservation, + reserveStructuredTuiAdoption, + type StructuredTuiAdoptionReservationRelease, + type StructuredTuiAdoptionReservationRequest, + type StructuredTuiAdoptionReservationResult +} from './structured-agent-session-tui-adoption-reservation' import { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result' export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' @@ -208,6 +215,20 @@ export class StructuredAgentSessionHost { return this.tasks.trackAttach(attaching) } + /** Reserve the lease an adopted TUI must hold before the write gate will admit its proof. */ + reserveAdoptedTuiOwner = ( + input: StructuredTuiAdoptionReservationRequest + ): Promise => + this.serialize(input.sessionId, () => + reserveStructuredTuiAdoption({ ...input, deps: this.deps, now: this.now }) + ) + + /** Hand back a reservation whose proof never landed, so the next attempt is not refused by it. */ + releaseAdoptedTuiReservation = (input: StructuredTuiAdoptionReservationRelease): Promise => + this.serialize(input.sessionId, () => + releaseStructuredTuiAdoptionReservation({ ...input, deps: this.deps, now: this.now }) + ) + adoptTuiOwner = ( input: StructuredTuiAdoptionRequest ): Promise> => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption-reservation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption-reservation.ts new file mode 100644 index 00000000000..cfdef2bbb45 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption-reservation.ts @@ -0,0 +1,164 @@ +/** + * The reservation an adopted Codex TUI needs BEFORE it may talk to the provider. + * + * Adoption proves the running Codex by typing `/status` into its own pane, and + * `agentSessionPtyWriteGate.admitProof` admits that probe only against a durable reservation that + * already names this session, this runtime kind, and this spawn token. Reserving after the proof — + * the shape this replaces — meant the probe was never admitted, so no adoption could ever succeed. + * + * The reservation is therefore created first and released again when the proof does not land, so a + * failed attempt leaves nothing behind that would refuse the next one. + */ + +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { + AgentSessionAccountHome, + AgentSessionExecutionLocation, + AgentSessionLaunchEnv, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { classifyStoreFailure } from './structured-agent-session-attach' +import type { + StructuredAgentSessionCaller, + StructuredAgentSessionHostDeps +} from './structured-agent-session-host-types' + +export type StructuredTuiAdoptionReservationRequest = { + caller: StructuredAgentSessionCaller + sessionId: string + clientOperationId: string + /** The adoption call's own payload fingerprint; the durable operation row is keyed by it. */ + fingerprint: string + location: AgentSessionExecutionLocation + provider: AgentSessionHandleProvider + accountHome: AgentSessionAccountHome + spawnToken: string + claimKeyId: string + launchEnv?: AgentSessionLaunchEnv +} + +export type StructuredTuiAdoptionReservationResult = + | { + ok: true + fence: number + /** The token the proof must carry: a replayed call keeps the one already on the record. */ + spawnToken: string + } + | { ok: false; refusal: AgentSessionWireRefusal } + +export type StructuredTuiAdoptionReservationRelease = { + sessionId: string + fence: number + spawnToken: string +} + +/** The exact lease shape `admitProof` accepts as `provingReservation`. */ +export function isStructuredTuiAdoptionReservation( + record: AgentSessionRecord, + spawnToken: string +): boolean { + const { lease } = record + return ( + lease.runtimeKind === 'tui' && + lease.claimStatus === 'reserved' && + lease.handoffStage === 'new-owner-proving' && + lease.ownerProcess === null && + lease.reservedSpawnToken === spawnToken && + !lease.unreconciled + ) +} + +export async function reserveStructuredTuiAdoption( + input: StructuredTuiAdoptionReservationRequest & { + deps: Pick + now: () => number + } +): Promise { + const { deps, sessionId } = input + const existing = deps.store.getRecord(sessionId) + try { + const reserved = await deps.store.reserveOwner({ + sessionId, + location: input.location, + provider: input.provider, + accountHome: input.accountHome, + ...(input.launchEnv ? { launchEnv: input.launchEnv } : {}), + runtimeKind: 'tui', + // An adoption never declares a fence, so the compare-and-swap runs against what the store + // holds right now and the probe below is what refuses when that record still has an owner. + expectedFence: existing?.lease.runtimeFence ?? null, + spawnToken: input.spawnToken, + claimKeyId: input.claimKeyId, + // Why null: a retry mints a fresh operation id, and a non-null one here would make the + // reservation left behind by the previous attempt refuse it as a different operation. + handoffOperationId: null, + probe: await previousOwnerProbe(input, existing), + operation: { + callerKey: input.caller.callerKey, + operationId: input.clientOperationId, + fingerprint: input.fingerprint + }, + now: input.now() + }) + const spawnToken = reserved.record.lease.reservedSpawnToken + if (!spawnToken) { + throw new Error('agent_session_ownership_unknown') + } + return { ok: true, fence: reserved.record.lease.runtimeFence, spawnToken } + } catch (error) { + const current = deps.store.getRecord(sessionId) + return { + ok: false, + refusal: classifyStoreFailure(error, current?.lease.runtimeFence ?? null, current) + } + } +} + +/** + * Undo a reservation whose proof never landed. `processlessAt` is the durable "nothing ever + * spawned under this token" proof, which is what lets the next attempt supersede the record + * instead of being refused by it forever — and what stops a restart from latching it into + * recovery. + */ +export async function releaseStructuredTuiAdoptionReservation( + input: StructuredTuiAdoptionReservationRelease & { + deps: Pick + now: () => number + } +): Promise { + const record = input.deps.store.getRecord(input.sessionId) + if ( + !record || + record.lease.runtimeFence !== input.fence || + !isStructuredTuiAdoptionReservation(record, input.spawnToken) + ) { + // Superseded, already proven, or gone: whatever holds the record now is not this attempt's. + return + } + const now = input.now() + await input.deps.store.setReservationProcesslessProof({ + sessionId: input.sessionId, + fence: input.fence, + spawnToken: input.spawnToken, + processlessAt: now, + now + }) +} + +function previousOwnerProbe( + input: { deps: Pick }, + existing: AgentSessionRecord | null +): Promise | AgentSessionOwnerProbe { + if (!existing) { + return { outcome: 'reservation-unused' } + } + // Fails closed: without a prober the store must not be told the previous owner is gone. + return ( + input.deps.probeOwner?.(existing) ?? { + outcome: 'indeterminate', + reason: 'this host cannot probe the recorded owner' + } + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption.ts index a450d13dc9e..ed084af4283 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption.ts @@ -1,4 +1,5 @@ import { isDeepStrictEqual } from 'node:util' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionAttachResult, AgentSessionMutationResult @@ -15,6 +16,7 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import type { StructuredTuiOwner } from './structured-agent-session-handoff-types' +import { isStructuredTuiAdoptionReservation } from './structured-agent-session-tui-adoption-reservation' export type StructuredTuiAdoptionRequest = { caller: StructuredAgentSessionCaller @@ -47,7 +49,13 @@ async function adopt( } let record = input.deps.store.getRecord(sessionId) let replayed = record !== null - if (!record) { + if (record && isStructuredTuiAdoptionReservation(record, owner.process.spawnToken)) { + // This adoption's own pre-proof reservation: turn it into an owner rather than reserve again. + replayed = false + record = await proveAdoptedTuiOwner(input, record) + } else if (record) { + assertMatchingTuiOwner(record, owner) + } else { const reserved = await input.deps.store.reserveOwner( reserveRequestFor({ sessionId, @@ -65,21 +73,7 @@ async function adopt( }) ) replayed = reserved.disposition === 'replayed' - record = reserved.record - await input.deps.store.commitProcessIdentity({ - sessionId, - fence: record.lease.runtimeFence, - process: owner.process, - now: input.now() - }) - record = await input.deps.store.proveOwner({ - sessionId, - fence: record.lease.runtimeFence, - link: owner.link, - now: input.now() - }) - } else { - assertMatchingTuiOwner(record, owner) + record = await proveAdoptedTuiOwner(input, reserved.record) } const attached = await attachJournal({ record, @@ -116,6 +110,27 @@ async function adopt( } } +/** Steps 4 and 5 of acquisition against a reservation that already exists at this fence. */ +async function proveAdoptedTuiOwner( + input: Parameters[0], + record: AgentSessionRecord +): Promise { + const { sessionId } = record + const fence = record.lease.runtimeFence + await input.deps.store.commitProcessIdentity({ + sessionId, + fence, + process: input.owner.process, + now: input.now() + }) + return input.deps.store.proveOwner({ + sessionId, + fence, + link: input.owner.link, + now: input.now() + }) +} + function assertMatchingTuiOwner( record: NonNullable>, owner: StructuredTuiOwner diff --git a/src/main/runtime/orca-runtime-structured-tui-adoption-write-gate.test.ts b/src/main/runtime/orca-runtime-structured-tui-adoption-write-gate.test.ts new file mode 100644 index 00000000000..efb231c8818 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-tui-adoption-write-gate.test.ts @@ -0,0 +1,267 @@ +/** + * Adoption against the REAL `agentSessionPtyWriteGate`. + * + * Every other adoption test mocks the PTY controller, so `writeAgentSessionProof` answers whatever + * the mock says and `admitProof` never runs — which is precisely why they all passed while no user + * could ever switch a Codex tab to structured chat. Here the controller delegates to the real gate + * over a real record store, so the probe is admitted only when adoption has actually reserved the + * lease and bound the pane first. + */ + +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createStructuredAgentSessionOperationId } from '../../shared/structured-agent-session-mutation' +import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' +import { createStructuredAgentSessionOwnerProbe } from './structured-agent-session-runtime' +import { OrcaRuntimeService } from './orca-runtime' + +const { readStructuredTuiProcessIdentity } = vi.hoisted(() => ({ + readStructuredTuiProcessIdentity: vi.fn() +})) +// The only OS-dependent step in the flow; the rollout proof and the write gate both stay real. +vi.mock('./structured-tui-process-identity', () => ({ readStructuredTuiProcessIdentity })) + +const WORKTREE_ID = 'repo-1::/tmp/structured-adoption' +const PTY_ID = 'pty-adopt' +const PANE_KEY = 'tab-adopt:leaf-adopt' +const THREAD_ID = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' +const SESSION_ID = `codex_${THREAD_ID.replaceAll('-', '_')}` + +type ProofRig = { + runtime: OrcaRuntimeService + store: AgentSessionRecordStore + host: StructuredAgentSessionHost + proofWrites: string[] + refusedWrites: string[] + codexHome: string +} + +let root: string +let rig: ProofRig + +async function writeRolloutFixture(codexHome: string, threadId: string): Promise { + const dir = join(codexHome, 'sessions', '2026', '08', '19') + await mkdir(dir, { recursive: true }) + await writeFile( + join(dir, `rollout-2026-08-19T10-00-00-${threadId}.jsonl`), + `${JSON.stringify({ type: 'session_meta', payload: { id: threadId } })}\n`, + 'utf8' + ) +} + +/** + * A PTY that answers `/status` with a Codex session banner — but only for bytes the real gate + * admitted. This mirrors `writeAgentSessionProof` in src/main/ipc/pty.ts verbatim. + */ +function installProofPty( + rigState: ProofRig, + threadId: string, + ptyRecord: { lastOutputAt: number } +) { + return (ptyId: string, data: string, authority: { sessionId: string; spawnToken: string }) => { + if (!agentSessionPtyWriteGate.admitProof(ptyId, authority)) { + rigState.refusedWrites.push(data) + return false + } + rigState.proofWrites.push(data) + if (data === '\r') { + const record = rigState.runtime as unknown as { + ptysById: Map + } + const pty = record.ptysById.get(PTY_ID)! + pty.tailBuffer = [`Session ID: ${threadId}`] + pty.lastOutputAt = ptyRecord.lastOutputAt + 1_000 + } + return true + } +} + +async function buildRig(): Promise { + const codexHome = join(root, 'codex-home') + await writeRolloutFixture(codexHome, THREAD_ID) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'agent-sessions'), + hostId: 'local' + }) + agentSessionPtyWriteGate.attachRecordLookup((sessionId) => store.getRecord(sessionId)) + const host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire: vi.fn(), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + }, + journalRoot: join(root, 'journals'), + claimKeyId: 'key-1', + probeOwner: createStructuredAgentSessionOwnerProbe( + 'local', + async () => ({ outcome: 'identity-matched', matchedOn: ['process-start-time'] }), + async () => [] + ) + }) + setStructuredAgentSessionHost(host) + + const runtime = new OrcaRuntimeService({ getSettings: () => ({ agentDefaultEnv: {} }) } as never) + const state: ProofRig = { runtime, store, host, proofWrites: [], refusedWrites: [], codexHome } + const ptyRecord = { + ptyId: PTY_ID, + connected: true, + connectionId: null, + wslDistro: null, + tabId: 'tab-adopt', + paneKey: PANE_KEY, + worktreeId: WORKTREE_ID, + incarnationId: 'inc-adopt', + tailBuffer: [] as string[], + tailPartialLine: '', + preview: '', + lastOutputAt: 1_000 + } + const internal = runtime as unknown as { + ptysById: Map + resolveRuntimeFileTarget(): Promise + resolveStructuredAgentSessionCreateIntent(input: { envelope: unknown }): Promise + issueStructuredTuiPtyHandle(): string + } + internal.ptysById.set(PTY_ID, ptyRecord) + internal.resolveRuntimeFileTarget = vi.fn(async () => ({ + connectionId: null, + worktree: { id: WORKTREE_ID } + })) + internal.resolveStructuredAgentSessionCreateIntent = vi.fn(async ({ envelope }) => ({ + envelope, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKTREE_ID, + workspaceKind: 'git-worktree' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: codexHome }, + runtimeKind: 'native' + })) + internal.issueStructuredTuiPtyHandle = vi.fn(() => 'term-adopt') + runtime.setPtyController({ + listProcesses: async () => [{ id: PTY_ID, incarnationId: 'inc-adopt', rootProcessId: 31337 }], + writeAgentSessionProof: installProofPty(state, THREAD_ID, ptyRecord) + } as never) + return state +} + +function adoptInput(overrides: { threadId?: string } = {}) { + return { + envelope: { + sessionId: SESSION_ID, + clientOperationId: createStructuredAgentSessionOperationId(() => randomUUID()), + expectedRuntimeFence: null as null, + payloadFingerprint: 'a'.repeat(64) + }, + worktree: `id:${WORKTREE_ID}`, + tabId: 'tab-adopt', + paneKey: PANE_KEY, + ptyId: PTY_ID, + ...overrides + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-adopt-gate-')) + readStructuredTuiProcessIdentity.mockImplementation( + async (input: { hostId: string; spawnToken: string }) => ({ + hostId: input.hostId, + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: input.spawnToken + }) + ) + rig = await buildRig() +}) + +afterEach(async () => { + agentSessionPtyWriteGate.unbindPty(PTY_ID) + agentSessionPtyWriteGate.detachRecordLookup() + setStructuredAgentSessionHost(null) + await rig.host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('structured Codex adoption against the real PTY write gate', () => { + it('authorizes the provider probe, so switching a Codex tab to chat succeeds', async () => { + const result = await rig.runtime.adoptStructuredAgentSessionTerminal( + { ...adoptInput(), threadId: THREAD_ID }, + { callerKey: 'renderer-1' } + ) + + expect(result).toMatchObject({ ok: true }) + // The probe reached the pane: `/status`, submit, escape — none of them refused by the gate. + expect(rig.refusedWrites).toEqual([]) + expect(rig.proofWrites).toHaveLength(3) + expect(agentSessionPtyWriteGate.boundSessionId(PTY_ID)).toBe(SESSION_ID) + expect(rig.store.getRecord(SESSION_ID)).toMatchObject({ + lease: { runtimeKind: 'tui', claimStatus: 'live', handoffStage: null } + }) + }, 20_000) + + it('authorizes the probe when no thread id has been published yet', async () => { + const result = await rig.runtime.adoptStructuredAgentSessionTerminal(adoptInput(), { + callerKey: 'renderer-1' + }) + + expect(result).toMatchObject({ ok: true }) + expect(rig.refusedWrites).toEqual([]) + expect(rig.store.getRecord(SESSION_ID)).toMatchObject({ + providerHandleChain: [{ origin: 'adopted', handle: { threadId: THREAD_ID } }] + }) + }, 20_000) + + it('leaves nothing latched when the proof fails, so the next attempt still succeeds', async () => { + await rm(join(rig.codexHome, 'sessions'), { recursive: true, force: true }) + + await expect( + rig.runtime.adoptStructuredAgentSessionTerminal( + { ...adoptInput(), threadId: THREAD_ID }, + { callerKey: 'renderer-1' } + ) + ).rejects.toThrow('did not prove the expected Codex rollout') + // The reservation is durably marked as never having spawned, and the pane is released. + expect(agentSessionPtyWriteGate.boundSessionId(PTY_ID)).toBeNull() + expect(rig.store.getRecord(SESSION_ID)?.lease).toMatchObject({ + claimStatus: 'reserved', + ownerProcess: null + }) + expect(rig.store.getRecord(SESSION_ID)?.lease.processlessAt).toEqual(expect.any(Number)) + + await writeRolloutFixture(rig.codexHome, THREAD_ID) + const retried = await rig.runtime.adoptStructuredAgentSessionTerminal( + { ...adoptInput(), threadId: THREAD_ID }, + { callerKey: 'renderer-1' } + ) + + expect(retried).toMatchObject({ ok: true }) + expect(rig.store.getRecord(SESSION_ID)).toMatchObject({ + lease: { claimStatus: 'live', runtimeKind: 'tui' } + }) + }, 30_000) + + it('refuses a pane another structured session already owns', async () => { + agentSessionPtyWriteGate.bindPty(PTY_ID, 'someone-elses-session') + + await expect( + rig.runtime.adoptStructuredAgentSessionTerminal( + { ...adoptInput(), threadId: THREAD_ID }, + { callerKey: 'renderer-1' } + ) + ).rejects.toThrow('already belongs to another structured Codex session') + expect(agentSessionPtyWriteGate.boundSessionId(PTY_ID)).toBe('someone-elses-session') + expect(rig.store.getRecord(SESSION_ID)).toBeNull() + }) +}) diff --git a/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts index d32752d83a2..6caf48a5f07 100644 --- a/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts +++ b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts @@ -77,6 +77,20 @@ function terminalAdoptionHarness() { return runtime } +/** The host half adoption now needs: the lease is reserved before the provider probe runs. */ +function structuredHostDouble(overrides: Record = {}) { + return { + reserveAdoptedTuiOwner: vi.fn(async (input: { spawnToken: string }) => ({ + ok: true, + fence: 1, + spawnToken: input.spawnToken + })), + releaseAdoptedTuiReservation: vi.fn(async () => undefined), + adoptTuiOwner: vi.fn(async () => ({ ok: true, replayed: false, fence: 1 })), + ...overrides + } +} + function notifier(revealTerminalSession: ReturnType) { return { worktreesChanged: vi.fn(), @@ -105,7 +119,7 @@ describe('structured TUI launch tab binding', () => { it('adopts the proven local Codex pane as a durable TUI owner', async () => { const runtime = terminalAdoptionHarness() const adoptTuiOwner = vi.fn(async () => ({ ok: true, replayed: false, fence: 1 })) - setStructuredAgentSessionHost({ adoptTuiOwner } as never) + setStructuredAgentSessionHost(structuredHostDouble({ adoptTuiOwner }) as never) proveCodexTuiRollout.mockResolvedValueOnce({ transcriptPath: '/tmp/codex-home/rollout.jsonl' }) @@ -162,6 +176,8 @@ describe('structured TUI launch tab binding', () => { it('refuses adoption when the provider rollout cannot be proved', async () => { const runtime = terminalAdoptionHarness() + const host = structuredHostDouble() + setStructuredAgentSessionHost(host as never) proveCodexTuiRollout.mockRejectedValueOnce( new Error('The agent terminal did not prove the expected Codex rollout.') ) @@ -172,29 +188,37 @@ describe('structured TUI launch tab binding', () => { spawnToken: 'spawn-adopt' }) - await expect( - runtime.adoptStructuredAgentSessionTerminal( - { - envelope: { - sessionId: 'session-adopt', - clientOperationId: 'operation-adopt', - expectedRuntimeFence: null, - payloadFingerprint: 'f'.repeat(64) + try { + await expect( + runtime.adoptStructuredAgentSessionTerminal( + { + envelope: { + sessionId: 'session-adopt', + clientOperationId: 'operation-adopt', + expectedRuntimeFence: null, + payloadFingerprint: 'f'.repeat(64) + }, + worktree: `id:${WORKTREE_ID}`, + tabId: 'tab-adopt', + paneKey: 'tab-adopt:leaf-adopt', + ptyId: 'pty-adopt', + threadId: 'thread-adopt' }, - worktree: `id:${WORKTREE_ID}`, - tabId: 'tab-adopt', - paneKey: 'tab-adopt:leaf-adopt', - ptyId: 'pty-adopt', - threadId: 'thread-adopt' - }, - { callerKey: 'renderer-1' } - ) - ).rejects.toThrow('did not prove the expected Codex rollout') - expect(agentSessionPtyWriteGate.boundSessionId('pty-adopt')).toBeNull() + { callerKey: 'renderer-1' } + ) + ).rejects.toThrow('did not prove the expected Codex rollout') + expect(agentSessionPtyWriteGate.boundSessionId('pty-adopt')).toBeNull() + // A proof that never lands hands the reservation back; nothing latches the next attempt. + expect(host.releaseAdoptedTuiReservation).toHaveBeenCalledTimes(1) + } finally { + setStructuredAgentSessionHost(null) + } }) it('refuses a thread proof when the Codex process changes during it', async () => { const runtime = terminalAdoptionHarness() + const host = structuredHostDouble() + setStructuredAgentSessionHost(host as never) proveCodexTuiRollout.mockResolvedValueOnce({ transcriptPath: '/tmp/codex-home/rollout.jsonl' }) @@ -231,12 +255,14 @@ describe('structured TUI launch tab binding', () => { ) ).rejects.toThrow('process changed during conversation proof') expect(agentSessionPtyWriteGate.boundSessionId('pty-adopt')).toBeNull() + expect(host.releaseAdoptedTuiReservation).toHaveBeenCalledTimes(1) + setStructuredAgentSessionHost(null) }) it('discovers the provider thread when hook status has not published it yet', async () => { const runtime = terminalAdoptionHarness() const adoptTuiOwner = vi.fn(async () => ({ ok: true, replayed: false, fence: 1 })) - setStructuredAgentSessionHost({ adoptTuiOwner } as never) + setStructuredAgentSessionHost(structuredHostDouble({ adoptTuiOwner }) as never) resolveLiveCodexTuiRollout.mockResolvedValueOnce({ threadId: 'thread-discovered', transcriptPath: '/tmp/codex-home/discovered.jsonl' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ec923855a56..4561b14bbd4 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -11437,7 +11437,82 @@ export class OrcaRuntimeService { throw new Error('Could not prove the Codex terminal process.') } const rootProcessId = listing.rootProcessId - const spawnToken = randomUUID() + const sessionId = input.envelope.sessionId + const priorBoundSessionId = agentSessionPtyWriteGate.boundSessionId(input.ptyId) + if (priorBoundSessionId !== null && priorBoundSessionId !== sessionId) { + throw new Error('This terminal pane already belongs to another structured Codex session.') + } + const host = getStructuredAgentSessionHost() + if (!host) { + throw new Error('structured_agent_session_unsupported') + } + const launchEnv = resolveTuiAgentLaunchEnv( + 'codex', + this.requireStore().getSettings().agentDefaultEnv + ) + // Why the reservation comes before the proof: `admitProof` authorizes the `/status` probe only + // for a reservation that already names this session, `tui`, and this spawn token. Reserving + // afterwards left the probe unauthorized, so every adoption failed at its first keystroke. + const reserved = await host.reserveAdoptedTuiOwner({ + caller, + sessionId, + clientOperationId: input.envelope.clientOperationId, + fingerprint: input.envelope.payloadFingerprint, + location: intent.location, + provider: intent.provider, + accountHome: intent.accountHome, + spawnToken: randomUUID(), + claimKeyId: this.agentSessionClaimSigner.keyId, + launchEnv + }) + if (!reserved.ok) { + return reserved + } + const { fence, spawnToken } = reserved + agentSessionPtyWriteGate.bindPty(input.ptyId, sessionId) + let owned = false + try { + return await this.attachAdoptedStructuredTui({ + input, + caller, + host, + intent, + launchEnv, + fence, + spawnToken, + rootProcessId, + pty, + onOwned: () => { + owned = true + } + }) + } finally { + if (!owned) { + // A failed proof must leave nothing behind: an unreleased reservation is exactly the + // latched lease that makes the next attempt refuse itself. + if (priorBoundSessionId === null) { + agentSessionPtyWriteGate.unbindPty(input.ptyId) + } + await host + .releaseAdoptedTuiReservation({ sessionId, fence, spawnToken }) + .catch(() => undefined) + } + } + } + + private async attachAdoptedStructuredTui(args: { + input: Parameters[0] + caller: StructuredAgentSessionCaller + host: NonNullable> + intent: AgentSessionAttachParams + launchEnv: Record + fence: number + spawnToken: string + rootProcessId: number + pty: RuntimePtyWorktreeRecord + onOwned: () => void + }): Promise> { + const { input, caller, host, intent, launchEnv, fence, spawnToken, rootProcessId, pty } = args const readPty = (): RuntimePtyWorktreeRecord => { const current = this.ptysById.get(input.ptyId) if (!current?.connected || current.paneKey !== input.paneKey) { @@ -11500,7 +11575,9 @@ export class OrcaRuntimeService { threadId, resumed: false, origin: 'adopted', - fence: 1, + // Why not 1: a retry supersedes the previous attempt's reservation, and `proveOwner` + // refuses a handle link minted at any fence but the reservation's own. + fence, observedAt: Date.now() }), transcriptPath, @@ -11528,14 +11605,6 @@ export class OrcaRuntimeService { runtimeKind: 'tui', providerHandle: { kind: 'codex', threadId } } - const launchEnv = resolveTuiAgentLaunchEnv( - 'codex', - this.requireStore().getSettings().agentDefaultEnv - ) - const host = getStructuredAgentSessionHost() - if (!host) { - throw new Error('structured_agent_session_unsupported') - } const adopted = await host.adoptTuiOwner({ caller, params, @@ -11544,8 +11613,9 @@ export class OrcaRuntimeService { launchEnv }) if (adopted.ok) { + // The pane stays bound to the reservation this call already made; only the owner is new. this.adoptedStructuredTuiOwners.set(input.envelope.sessionId, owner) - agentSessionPtyWriteGate.bindPty(input.ptyId, input.envelope.sessionId) + args.onOwned() } return adopted } diff --git a/src/renderer/src/store/slices/tab-view-mode-structured-route.test.ts b/src/renderer/src/store/slices/tab-view-mode-structured-route.test.ts new file mode 100644 index 00000000000..a6383bde7f4 --- /dev/null +++ b/src/renderer/src/store/slices/tab-view-mode-structured-route.test.ts @@ -0,0 +1,75 @@ +/** + * Both ways into structured chat go through one door. + * + * Right-click → "Switch to chat" and launching a Codex tab with open-in-chat both end at + * `setTabViewMode(tabId, 'chat')`, which hands a terminal tab to `setTerminalNativeChatMode` and + * from there to `agentSession.adoptTerminal`. Pinning that here is what makes "launch-at-chat + * shares the adoption defect" a checked claim rather than an inspection of the call graph. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '../types' +import { createTestStore, makeUnifiedTab, makeTabGroup } from './store-test-helpers' +import type * as StructuredNativeChatToggle from './structured-native-chat-toggle' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) + +const { mockSetTerminalNativeChatMode } = vi.hoisted(() => ({ + mockSetTerminalNativeChatMode: vi.fn() +})) + +vi.mock('./structured-native-chat-toggle', async (importOriginal) => ({ + ...(await importOriginal()), + setTerminalNativeChatMode: mockSetTerminalNativeChatMode +})) + +const mockApi = { + ui: { set: vi.fn().mockResolvedValue(undefined) }, + settings: { get: vi.fn().mockResolvedValue({}), set: vi.fn().mockResolvedValue(undefined) } +} + +// @ts-expect-error -- partial window stub is sufficient for these store-only tests +globalThis.window = { api: mockApi } + +const WT = 'repo1::/tmp/feature' + +describe('structured chat route out of setTabViewMode', () => { + let store: ReturnType + + beforeEach(() => { + mockSetTerminalNativeChatMode.mockReset() + mockSetTerminalNativeChatMode.mockResolvedValue('structured') + store = createTestStore() + store.setState({ + unifiedTabsByWorktree: { + [WT]: [makeUnifiedTab({ id: 'codex-tab', worktreeId: WT, groupId: 'g-1' })] + }, + groupsByWorktree: { + [WT]: [ + makeTabGroup({ + id: 'g-1', + worktreeId: WT, + activeTabId: 'codex-tab', + tabOrder: ['codex-tab'] + }) + ] + } + } as Partial) + }) + + it('hands an explicit chat mode to the structured adoption path', () => { + store.getState().setTabViewMode('codex-tab', 'chat') + + expect(mockSetTerminalNativeChatMode).toHaveBeenCalledWith( + expect.objectContaining({ tabId: 'codex-tab', mode: 'chat' }) + ) + }) + + it('hands a toggled chat mode to the same path', () => { + store.getState().toggleTabViewMode('codex-tab') + + expect(mockSetTerminalNativeChatMode).toHaveBeenCalledWith( + expect.objectContaining({ tabId: 'codex-tab', mode: 'chat' }) + ) + }) +})