diff --git a/config/scripts/check-layout-writer-ratchets.mjs b/config/scripts/check-layout-writer-ratchets.mjs index c2b611cbead3..b47bc51145ce 100644 --- a/config/scripts/check-layout-writer-ratchets.mjs +++ b/config/scripts/check-layout-writer-ratchets.mjs @@ -381,6 +381,17 @@ export function countLayoutWrites(sourceText, ratchet, fileName = 'file.tsx') { !(ts.isPropertyAssignment(node.parent) && node.parent.name === node) && !ts.isComputedPropertyName(node.parent) && !(ts.isElementAccessExpression(node.parent) && node.parent.argumentExpression === node) && + !( + ts.isCallExpression(node.parent) && + !node.parent.questionDotToken && + node.parent.arguments.length === 2 && + node.parent.arguments[1] === node && + ts.isPropertyAccessExpression(node.parent.expression) && + !node.parent.expression.questionDotToken && + ts.isIdentifier(node.parent.expression.expression) && + node.parent.expression.expression.text === 'Object' && + node.parent.expression.name.text === 'hasOwn' + ) && !ts.isLiteralTypeNode(node.parent) ) { count += 1 diff --git a/config/scripts/check-layout-writer-ratchets.test.mjs b/config/scripts/check-layout-writer-ratchets.test.mjs index 126e33260120..e697e8409834 100644 --- a/config/scripts/check-layout-writer-ratchets.test.mjs +++ b/config/scripts/check-layout-writer-ratchets.test.mjs @@ -36,6 +36,7 @@ describe('renderer layout writes', () => { 'const useTabs = () => ({ activeTabId, tabsByWorktree })', "type Field = Pick", "const read = state['unifiedTabsByWorktree']", + "Object.hasOwn(requested, 'terminalLayoutsByTabId')", 'const { tabsByWorktree } = state' ].join('\n') expect(countLayoutWrites(src, rendererRatchet, 'a.tsx')).toBe(5) diff --git a/src/main/daemon/daemon-pty-adapter-wsl-sleep-cwd.test.ts b/src/main/daemon/daemon-pty-adapter-wsl-sleep-cwd.test.ts index e61437041432..54172da8af14 100644 --- a/src/main/daemon/daemon-pty-adapter-wsl-sleep-cwd.test.ts +++ b/src/main/daemon/daemon-pty-adapter-wsl-sleep-cwd.test.ts @@ -56,6 +56,7 @@ describe('a sleeping bridge WSL pane keeps its shell folder', () => { const harness = await startDaemonAdapterHarness((opts) => { spawnCwds.push(opts.cwd) if (opts.cwd && deletedCwds.has(opts.cwd)) { + opts.onCreationDisposition?.({ kind: 'no-fork' }) throw new Error(`Working directory "${opts.cwd}" does not exist. It may have been deleted.`) } lastSubprocess = createMockSubprocess() diff --git a/src/main/daemon/daemon-server-async-spawn-cancellation.test.ts b/src/main/daemon/daemon-server-async-spawn-cancellation.test.ts index 3ea304bc1b9d..3a5a1377ddd4 100644 --- a/src/main/daemon/daemon-server-async-spawn-cancellation.test.ts +++ b/src/main/daemon/daemon-server-async-spawn-cancellation.test.ts @@ -1,3 +1,4 @@ +import { registerDaemonCreationFailureTests } from './daemon-server-creation-failure.test-fixture' import './mock-descendant-sweep' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -15,6 +16,7 @@ type DaemonServerInternals = { } describe('daemon async spawn cancellation', () => { + let spawned = 0 let directory: string let server: DaemonServer let client: DaemonClient @@ -23,6 +25,7 @@ describe('daemon async spawn cancellation', () => { let subprocess: ReturnType beforeEach(async () => { + spawned = 0 directory = mkdtempSync(join(tmpdir(), 'daemon-async-spawn-cancel-')) let markSpawnStarted: () => void = () => {} spawnStarted = new Promise((resolve) => { @@ -35,6 +38,7 @@ describe('daemon async spawn cancellation', () => { socketPath: getDaemonSocketPath(directory), tokenPath: join(directory, 'daemon.token'), spawnSubprocess: async () => { + spawned++ subprocess = createMockSubprocess() markSpawnStarted() await spawnGate @@ -53,9 +57,29 @@ describe('daemon async spawn cancellation', () => { releaseSpawn?.() client?.disconnect() await server?.shutdown() + vi.restoreAllMocks() rmSync(directory, { recursive: true, force: true }) }) + registerDaemonCreationFailureTests({ + get server() { + return server + }, + get client() { + return client + }, + get subprocess() { + return subprocess + }, + get spawnStarted() { + return spawnStarted + }, + get spawned() { + return spawned + }, + releaseSpawn: () => releaseSpawn() + }) + it.each(['cancelCreateOrAttach', 'kill'] as const)( 'reaps a subprocess canceled by %s during async spawn', async (requestType) => { diff --git a/src/main/daemon/daemon-server-creation-failure.test-fixture.ts b/src/main/daemon/daemon-server-creation-failure.test-fixture.ts new file mode 100644 index 000000000000..6433a5564a44 --- /dev/null +++ b/src/main/daemon/daemon-server-creation-failure.test-fixture.ts @@ -0,0 +1,92 @@ +import './mock-descendant-sweep' +import { expect, it, vi } from 'vitest' +import type { DaemonClient } from './client' +import type { createMockSubprocess } from './daemon-pty-adapter-test-harness' +import type { DaemonServer } from './daemon-server' +export type DaemonCreationFailureFixture = { + server: DaemonServer + client: DaemonClient + subprocess: ReturnType + releaseSpawn: () => void + spawnStarted: Promise + spawned: number +} +export function registerDaemonCreationFailureTests(fixture: DaemonCreationFailureFixture) { + it.each([ + ['observer', 'retry'], + ['constructor', 'retry'], + ['publication', 'disposal'] + ] as const)( + 'ordinary authenticated create drives failed unpublished cleanup: %s/%s', + async (failure, wake) => { + const options = { + sessionId: 'ordinary-failed', + cols: 80, + rows: 24, + agentSessionEnsure: { + claim: { + digestVersion: 1 as const, + keyId: 'key', + identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + agent: 'codex' as const + }, + surface: { + worktreeId: 'worktree', + tabId: 'ordinary-tab', + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: 'term_ordinary' + } + } + } + const creating = fixture.client.request('createOrAttach', options) + const observed = expect(creating).rejects.toThrow('fixture subscription failed') + await fixture.spawnStarted + const failed = fixture.subprocess + if (failure === 'observer') { + failed.onExit = vi.fn(failed.onExit).mockImplementationOnce(() => { + throw new Error('fixture subscription failed') + }) + } else if (failure === 'constructor') { + failed.onData = () => { + throw new Error('fixture subscription failed') + } + } else { + Object.assign(failed, { + onInputClosed: () => { + throw new Error('fixture subscription failed') + } + }) + } + vi.mocked(failed.forceKill).mockImplementation(() => { + throw new Error('fixture close unavailable') + }) + fixture.releaseSpawn() + await observed + await vi.waitFor(() => expect(failed.forceKill).toHaveBeenCalled()) + expect(fixture.server.host.listSessions()).toEqual([]) + expect(fixture.server.automaticWork.gate.snapshot().terminals).toBe(1) + const attempts = vi.mocked(failed.forceKill).mock.calls.length + // The ordinary next authenticated request drives the retained resource, not a test callback. + vi.mocked(failed.forceKill).mockImplementation(() => { + setTimeout(() => failed._simulateExit(137), 5) + }) + if (wake === 'disposal') { + await fixture.server.host.dispose() + expect(failed.dispose).toHaveBeenCalledOnce() + expect(fixture.spawned).toBe(1) + expect(fixture.server.automaticWork.gate.snapshot().terminals).toBe(0) + return + } + expect(await fixture.client.request('createOrAttach', options)).toMatchObject({ isNew: true }) + expect(vi.mocked(failed.forceKill).mock.calls.length).toBeGreaterThan(attempts) + expect(failed.dispose).toHaveBeenCalledOnce() + expect(fixture.spawned).toBe(2) + expect(fixture.server.automaticWork.gate.snapshot().terminals).toBe(1) + await fixture.client.request('write', { sessionId: options.sessionId, data: 'new input' }) + expect(fixture.subprocess.write).toHaveBeenCalledWith('new input') + await fixture.server.host.dispose() + expect(fixture.server.automaticWork.gate.snapshot().terminals).toBe(0) + } + ) +} diff --git a/src/main/daemon/daemon-server-options.ts b/src/main/daemon/daemon-server-options.ts index f07f00ce2556..1a2f24b84689 100644 --- a/src/main/daemon/daemon-server-options.ts +++ b/src/main/daemon/daemon-server-options.ts @@ -1,5 +1,5 @@ import type { DaemonFileLog } from './daemon-file-log' -import type { SubprocessHandle } from './session-subprocess-handle' +import type { TerminalHostOptions } from './terminal-host-options' import type { TerminalSpawnAdmissionMode } from './terminal-spawn-gate' import type { HostTerminalPolicy } from './host-terminal-policy' @@ -37,14 +37,5 @@ export type DaemonServerOptions = { /** Shared terminal counts for the host-wide cap. */ hostTerminalLedgerDir?: string terminalLimitPolicy?: HostTerminalPolicy - spawnSubprocess: (opts: { - sessionId: string - cols: number - rows: number - cwd?: string - env?: Record - command?: string - shellOverride?: string - isCanceled?: () => boolean - }) => SubprocessHandle | Promise + spawnSubprocess: TerminalHostOptions['spawnSubprocess'] } diff --git a/src/main/daemon/pty-subprocess-handle-lifecycle.test.ts b/src/main/daemon/pty-subprocess-handle-lifecycle.test.ts index 2a1ca54781b9..cdf42214b874 100644 --- a/src/main/daemon/pty-subprocess-handle-lifecycle.test.ts +++ b/src/main/daemon/pty-subprocess-handle-lifecycle.test.ts @@ -80,6 +80,7 @@ vi.mock('../providers/windows-pty-job-membership', () => ({ import { createPtySubprocess } from './pty-subprocess' import { mockPtyProcess, useDaemonPtySubprocessEnv } from './pty-subprocess-test-harness' import { __setConptyJobNativeForTests } from '../windows/windows-pty-job' +import * as ptyJob from '../windows/windows-pty-job' describe('createPtySubprocess', () => { beforeEach(() => { @@ -453,16 +454,20 @@ describe('createPtySubprocess', () => { const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { throw new Error('already gone') }) + const terminateJob = vi.spyOn(ptyJob, 'terminatePtyJob').mockReturnValue('unavailable') const origPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { value: 'win32' }) try { const handle = await createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) handle.forceKill() handle.dispose() - expect(killSpy).toHaveBeenCalledWith(123456, 'SIGKILL') + // An unverified PID can be reused; the per-PTY job/native handle owns this process tree. + expect(killSpy).not.toHaveBeenCalled() + expect(terminateJob).toHaveBeenCalledExactlyOnceWith(proc) expect(proc.kill).toHaveBeenCalledOnce() expect(proc.destroy).not.toHaveBeenCalled() } finally { + terminateJob.mockRestore() killSpy.mockRestore() restorePlatform(origPlatform) } diff --git a/src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts b/src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts index ec96ec8f50f7..bcd672ad0d72 100644 --- a/src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts +++ b/src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts @@ -145,6 +145,7 @@ describe.each(['darwin', 'linux', 'win32'] as const)('%s native-handle contract' it('keeps graceful and forced termination available until physical exit', () => { const fixture = createFixture() const originalKill = fixture.proc.kill + const terminateJob = vi.spyOn(ptyJob, 'terminatePtyJob').mockReturnValue('unavailable') const signal = vi.spyOn(process, 'kill').mockReturnValue(true) failIo(fixture, operation) @@ -154,7 +155,14 @@ describe.each(['darwin', 'linux', 'win32'] as const)('%s native-handle contract' const forced = createFixture() failIo(forced, operation) forced.handle.forceKill() - expect(signal).toHaveBeenCalledWith(4242, 'SIGKILL') + if (platform === 'win32') { + // An unverified PID can be reused; the per-PTY job/native handle owns this process tree. + expect(signal).not.toHaveBeenCalled() + expect(terminateJob).toHaveBeenCalledExactlyOnceWith(forced.proc) + expect(forced.proc.kill).toHaveBeenCalledOnce() + } else { + expect(signal).toHaveBeenCalledWith(4242, 'SIGKILL') + } forced.proc._simulateExit(137) signal.mockClear() @@ -168,6 +176,7 @@ describe.each(['darwin', 'linux', 'win32'] as const)('%s native-handle contract' it('reaps every session and native handle across 32 failed-I/O create/close cycles', async () => { const emulatorDispose = vi.spyOn(HeadlessEmulator.prototype, 'dispose') + const terminateJob = vi.spyOn(ptyJob, 'terminatePtyJob').mockReturnValue('unavailable') const signal = vi.spyOn(process, 'kill').mockReturnValue(true) let fixture = createFixture() const host = new TerminalHost({ spawnSubprocess: () => fixture.handle }) @@ -195,9 +204,18 @@ describe.each(['darwin', 'linux', 'win32'] as const)('%s native-handle contract' expect(host.listSessions()).toHaveLength(1) expect(fixture.proc.destroy).not.toHaveBeenCalled() expect(onExit).not.toHaveBeenCalled() - await vi.waitFor(() => expect(signal).toHaveBeenCalledWith(4242, 'SIGKILL'), { - interval: 1 - }) + if (platform === 'win32') { + await vi.waitFor(() => expect(fixture.proc.kill).toHaveBeenCalledOnce(), { + interval: 1 + }) + expect(terminateJob).toHaveBeenCalledWith(fixture.proc) + // An unverified PID can be reused; the per-PTY job/native handle owns this process tree. + expect(signal).not.toHaveBeenCalled() + } else { + await vi.waitFor(() => expect(signal).toHaveBeenCalledWith(4242, 'SIGKILL'), { + interval: 1 + }) + } } finally { fixture.proc._simulateExit(137) killFailure = await settled @@ -205,7 +223,13 @@ describe.each(['darwin', 'linux', 'win32'] as const)('%s native-handle contract' expect(killFailure).toBeNull() expect(host.listSessions()).toHaveLength(0) expect(onExit).toHaveBeenCalledOnce() - expect(fixture.proc.destroy).toHaveBeenCalledOnce() + if (platform === 'win32') { + // An unverified PID can be reused; job/native close owns the tree and must not be repeated by destroy. + expect(fixture.proc.destroy).not.toHaveBeenCalled() + expect(fixture.proc.kill).toHaveBeenCalledOnce() + } else { + expect(fixture.proc.destroy).toHaveBeenCalledOnce() + } expect(emulatorDispose).toHaveBeenCalledTimes(index + 1) } } finally { diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 1594fda4713d..41f944b53cd7 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -1,3 +1,5 @@ +import type { PtyCreationDisposition } from '../../shared/claimed-agent-pty-owner' +import { TerminalCreationWork } from './terminal-creation-work' import type { SubprocessHandle } from './session-subprocess-handle' import { normalizePtySize } from './daemon-pty-size' import { TerminalAttachCanceledError } from './daemon-errors' @@ -31,6 +33,8 @@ export type PtySubprocessOptions = { terminalShellArgs?: string[] terminalWindowsWslDistro?: string | null terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' + onCreationDisposition?: (disposition: PtyCreationDisposition) => void + assertCreateAllowed?: () => void isCanceled?: () => boolean /** Aborts in-progress cwd validation; `isCanceled` is only polled between steps. */ cancelSignal?: AbortSignal @@ -61,59 +65,68 @@ export async function checkPtySpawnHealth(): Promise { /** Plan first so validation uses the host or WSL cwd that spawn will actually use. */ export async function createPtySubprocess(opts: PtySubprocessOptions): Promise { - const size = normalizePtySize(opts.cols, opts.rows) - const env = createDaemonPtyEnvironment(opts) - const launch = createPtyShellLaunchPlan(opts, env) - - await preflightPtySpawn({ - validationCwd: launch.validationCwd, - cwdWasExplicit: opts.cwd !== undefined, - sessionId: opts.sessionId, - ...(opts.cancelSignal ? { signal: opts.cancelSignal } : {}) - }) - if (opts.isCanceled?.()) { - throw new TerminalAttachCanceledError(opts.sessionId) - } - - let spawned: SpawnedDaemonPty + const work = new TerminalCreationWork() try { - spawned = await spawnNativeDaemonPty({ - shellPath: launch.shellPath, - shellArgs: launch.shellArgs, - spawnCwd: launch.spawnCwd, - env, - cols: size.cols, - rows: size.rows, - windowsFallbackAttempts: launch.windowsFallbackAttempts, - // User-configured shell args can carry a command (`pwsh -Command ...`); never respawn those. - primaryArgsRespawnSafe: - !launch.startupCommandDeliveredInShellArgs && !opts.terminalShellArgs?.length, - onMacosTccSpawnStrategy: opts.onMacosTccSpawnStrategy, + const size = normalizePtySize(opts.cols, opts.rows) + const env = createDaemonPtyEnvironment(opts) + const launch = createPtyShellLaunchPlan(opts, env) + + await preflightPtySpawn({ + validationCwd: launch.validationCwd, + cwdWasExplicit: opts.cwd !== undefined, + sessionId: opts.sessionId, ...(opts.cancelSignal ? { signal: opts.cancelSignal } : {}) }) - } catch (error) { - // A cancel that lands while ConPTY's startup is awaited is a cancel, not a spawn failure. - if (opts.cancelSignal?.aborted) { + if (opts.isCanceled?.()) { throw new TerminalAttachCanceledError(opts.sessionId) } - if (process.platform === 'win32') { - throw formatPtySpawnError(error, launch.shellPath, launch.spawnCwd) + + let spawned: SpawnedDaemonPty + try { + spawned = await spawnNativeDaemonPty({ + creationWork: work, + assertCreateAllowed: opts.assertCreateAllowed, + shellPath: launch.shellPath, + shellArgs: launch.shellArgs, + spawnCwd: launch.spawnCwd, + env, + cols: size.cols, + rows: size.rows, + windowsFallbackAttempts: launch.windowsFallbackAttempts, + // User-configured shell args can carry a command (`pwsh -Command ...`); never respawn those. + primaryArgsRespawnSafe: + !launch.startupCommandDeliveredInShellArgs && !opts.terminalShellArgs?.length, + onMacosTccSpawnStrategy: opts.onMacosTccSpawnStrategy, + ...(opts.cancelSignal ? { signal: opts.cancelSignal } : {}) + }) + } catch (error) { + // A cancel that lands while ConPTY's startup is awaited is a cancel, not a spawn failure. + if (opts.cancelSignal?.aborted) { + throw new TerminalAttachCanceledError(opts.sessionId) + } + if (process.platform === 'win32') { + throw formatPtySpawnError(error, launch.shellPath, launch.spawnCwd) + } + throw error } - throw error - } - return createDaemonPtySubprocessHandle({ - process: spawned.process, - shellPath: spawned.shellPath, - spawnCwd: spawned.spawnCwd, - env, - startupCommandDeliveredInShellArgs: - spawned.startupCommandDeliveredInShellArgs ?? launch.startupCommandDeliveredInShellArgs, - reportsChildExitStatus: spawned.reportsChildExitStatus, - requestedCwd: opts.cwd, - sessionId: opts.sessionId, - startupAgentRecognition: launch.startupAgentRecognition, - // Claimed here, synchronously with the handle's own subscription, so no event falls between. - ...(spawned.claimStartupEvents ? { startupEvents: spawned.claimStartupEvents() } : {}) - }) + return createDaemonPtySubprocessHandle({ + process: spawned.process, + physicalOwner: spawned.physicalOwner, + shellPath: spawned.shellPath, + spawnCwd: spawned.spawnCwd, + env, + startupCommandDeliveredInShellArgs: + spawned.startupCommandDeliveredInShellArgs ?? launch.startupCommandDeliveredInShellArgs, + reportsChildExitStatus: spawned.reportsChildExitStatus, + requestedCwd: opts.cwd, + sessionId: opts.sessionId, + startupAgentRecognition: launch.startupAgentRecognition, + // Claimed here, synchronously with the handle's own subscription, so no event falls between. + ...(spawned.claimStartupEvents ? { startupEvents: spawned.claimStartupEvents() } : {}) + }) + } finally { + await work.finish() + opts.onCreationDisposition?.(await work.completion) + } } diff --git a/src/main/daemon/pty-subprocess/conpty-startup-input-reader.ts b/src/main/daemon/pty-subprocess/conpty-startup-input-reader.ts index 862e03458949..a9b4092cc1cb 100644 --- a/src/main/daemon/pty-subprocess/conpty-startup-input-reader.ts +++ b/src/main/daemon/pty-subprocess/conpty-startup-input-reader.ts @@ -1,5 +1,5 @@ +import type { DaemonPtyPhysicalOwner } from './subprocess-handle' import type * as pty from 'node-pty' -import { killConptyNow, terminatePtyJob } from '../../windows/windows-pty-job' import { CONPTY_DA1_RESPONSE } from '../../../shared/conpty-device-attributes' // Bundled ConPTY queries DA1 (or CPR when inheriting the cursor) right after it starts reading @@ -50,13 +50,21 @@ export type ConptyStartupEvents = { export type ConptyStartupWatch = { /** Settles on the first output that decides, or on exit before any did. */ verdict: Promise - /** Hands buffered output and exit to the caller and stops buffering. */ + /** Hands buffered output to the caller and stops buffering. */ claim(): ConptyStartupEvents + exited: Promise } /** Buffers a fresh ConPTY's output until its owner claims it, so no startup byte is lost. */ -export function watchConptyStartup(proc: pty.IPty): ConptyStartupWatch { +export function watchConptyStartup( + proc: pty.IPty, + exitSource: Pick = proc +): ConptyStartupWatch { const events: ConptyStartupEvents = { data: [], exit: null } + let ended!: () => void + const exited = new Promise((resolve) => { + ended = resolve + }) let output = '' let settle: (outcome: ConptyStartupOutcome) => void = () => {} const verdict = new Promise((resolve) => { @@ -84,13 +92,15 @@ export function watchConptyStartup(proc: pty.IPty): ConptyStartupWatch { settle(classified) } }) - const exitSubscription = proc.onExit(({ exitCode, signal }) => { + const exitSubscription = exitSource.onExit(({ exitCode, signal }) => { events.exit = { exitCode, ...(signal !== undefined ? { signal } : {}) } + ended() decided = true settle('exited') }) return { verdict, + exited, claim: () => { dataSubscription.dispose() exitSubscription.dispose() @@ -101,9 +111,14 @@ export function watchConptyStartup(proc: pty.IPty): ConptyStartupWatch { async function decideConptyStartup( proc: pty.IPty, - signal: AbortSignal | undefined -): Promise<{ outcome: ConptyStartupOutcome; claim: () => ConptyStartupEvents }> { - const watch = watchConptyStartup(proc) + signal: AbortSignal | undefined, + physicalOwner: DaemonPtyPhysicalOwner +): Promise<{ + outcome: ConptyStartupOutcome + claim: () => ConptyStartupEvents + exited: Promise +}> { + const watch = watchConptyStartup(proc, physicalOwner) let limit: ReturnType | undefined let onAbort = (): void => {} try { @@ -115,10 +130,10 @@ async function decideConptyStartup( limit.unref?.() void watch.verdict.then(resolve) }) - return { outcome, claim: watch.claim } + return { outcome, claim: watch.claim, exited: watch.exited } } catch (error) { // A canceled spawn must not leave the half-started shell running. - discardConpty(proc, watch) + discardConpty(physicalOwner, watch) throw error } finally { clearTimeout(limit) @@ -126,12 +141,12 @@ async function decideConptyStartup( } } -function discardConpty(proc: pty.IPty, watch: Pick): void { +function discardConpty( + physicalOwner: DaemonPtyPhysicalOwner, + watch: Pick +): void { watch.claim() - // Handle-based only: a pid may already be recycled if the shell exited during startup. - terminatePtyJob(proc) - killConptyNow(proc) - proc.kill() + physicalOwner.forceKill() } // Why process-wide: the announcement is a fact of the bundled ConPTY build. Until one pane shows @@ -157,50 +172,74 @@ function withNotice(claim: () => ConptyStartupEvents): () => ConptyStartupEvents // Verify ConPTY input before exposing the pane, so respawning cannot repeat user input. export async function spawnConptyWithInputReader( - spawn: () => Promise, + spawn: () => Promise<{ proc: pty.IPty; physicalOwner: DaemonPtyPhysicalOwner }>, opts: { label: string signal?: AbortSignal // Only Orca's default interactive argv is safe to respawn; custom argv may repeat a command. respawnSafe: boolean } -): Promise<{ proc: pty.IPty; claimStartupEvents: () => ConptyStartupEvents }> { - const first = await spawn() +): Promise<{ + proc: pty.IPty + physicalOwner: DaemonPtyPhysicalOwner + claimStartupEvents: () => ConptyStartupEvents +}> { + const firstSpawn = await spawn() + const { proc: first, physicalOwner: firstOwner } = firstSpawn if (announcementUntrusted) { - return { proc: first, claimStartupEvents: () => ({ data: [], exit: null }) } + return { ...firstSpawn, claimStartupEvents: () => ({ data: [], exit: null }) } } - const firstStartup = await decideConptyStartup(first, opts.signal) + const firstStartup = await decideConptyStartup(first, opts.signal, firstOwner) if (firstStartup.outcome === 'input-reader') { announcementObserved = true } if (firstStartup.outcome !== 'no-input-reader') { - return { proc: first, claimStartupEvents: firstStartup.claim } + return { ...firstSpawn, claimStartupEvents: firstStartup.claim } } if (!opts.respawnSafe) { console.warn(`[daemon/pty] ConPTY for "${opts.label}" started without an input reader`) const claim = firstStartup.claim - return { proc: first, claimStartupEvents: announcementObserved ? withNotice(claim) : claim } + return { ...firstSpawn, claimStartupEvents: announcementObserved ? withNotice(claim) : claim } } respawnCount += 1 console.warn( `[daemon/pty] ConPTY for "${opts.label}" started without an input reader; respawning (${respawnCount})` ) - discardConpty(first, firstStartup) - const retry = await spawn() - const retryStartup = await decideConptyStartup(retry, opts.signal) + let limit: ReturnType | undefined + try { + firstOwner.forceKill() + const exited = await Promise.race([ + firstOwner.completion.then(() => true), + new Promise((resolve) => { + limit = setTimeout(() => resolve(false), CONPTY_STARTUP_DECISION_LIMIT_MS) + limit.unref?.() + }) + ]) + if (!exited) { + return { ...firstSpawn, claimStartupEvents: withNotice(firstStartup.claim) } + } + firstStartup.claim() + } catch { + return { ...firstSpawn, claimStartupEvents: withNotice(firstStartup.claim) } + } finally { + clearTimeout(limit) + } + const retrySpawn = await spawn() + const { proc: retry, physicalOwner: retryOwner } = retrySpawn + const retryStartup = await decideConptyStartup(retry, opts.signal, retryOwner) if (retryStartup.outcome === 'input-reader') { announcementObserved = true } if (retryStartup.outcome !== 'no-input-reader') { - return { proc: retry, claimStartupEvents: retryStartup.claim } + return { ...retrySpawn, claimStartupEvents: retryStartup.claim } } if (!announcementObserved) { // Two misses and no pane ever announced: distrust the check rather than respawn every pane. announcementUntrusted = true console.warn('[daemon/pty] ConPTY never announced an input reader; disabling the startup check') - return { proc: retry, claimStartupEvents: retryStartup.claim } + return { ...retrySpawn, claimStartupEvents: retryStartup.claim } } console.warn(`[daemon/pty] ConPTY for "${opts.label}" again started without an input reader`) // After one respawn, show the failure on screen instead of silently ignoring keystrokes. - return { proc: retry, claimStartupEvents: withNotice(retryStartup.claim) } + return { ...retrySpawn, claimStartupEvents: withNotice(retryStartup.claim) } } diff --git a/src/main/daemon/pty-subprocess/daemon-pty-physical-owner.ts b/src/main/daemon/pty-subprocess/daemon-pty-physical-owner.ts new file mode 100644 index 000000000000..01c3390cf61e --- /dev/null +++ b/src/main/daemon/pty-subprocess/daemon-pty-physical-owner.ts @@ -0,0 +1,175 @@ +import type { DisposableNativePty } from './native-pty-spawn' +import type * as pty from 'node-pty' +import { forceKillPosixPtyProcessGroups } from '../../pty/posix-pty-process-groups' +import { PendingPtyGroupSweep } from '../../pty/pending-pty-group-sweep' +import { killConptyNow, terminatePtyJob } from '../../windows/windows-pty-job' +import { SessionTerminationController } from '../session-termination-controller' +import { shutdownOwnedTerminalProcess } from '../terminal-host-session-shutdown' +export function createDaemonPtyPhysicalOwner(proc: pty.IPty) { + const nativeProc: DisposableNativePty = proc + let exit: { exitCode: number; signal?: number } | null = null + const exitListeners = new Set[0]>() + let disposed = false + let nodePtyKillIssued = false + const groupSweep = new PendingPtyGroupSweep() + const killRoot = (): void => { + process.kill(proc.pid, 'SIGKILL') + } + const fallBackToNodePtyKill = (signalError: unknown): void => { + try { + proc.kill() + nodePtyKillIssued = true + } catch { + nodePtyKillIssued = false + throw signalError + } + } + const forceKillPosix = async (): Promise => { + if (exit) { + return + } + // A reaped root frees its pid and TTY for reuse, so a table read that outlives it signals nothing. + await groupSweep.track( + forceKillPosixPtyProcessGroups(proc.pid, killRoot, { ownsRoot: () => exit === null }).catch( + fallBackToNodePtyKill + ) + ) + } + const kill = (): void => { + if (exit || (process.platform === 'win32' && nodePtyKillIssued)) { + return + } + if (process.platform === 'win32' && killConptyNow(proc)) { + nodePtyKillIssued = true + return + } + nodePtyKillIssued = true + try { + proc.kill() + } catch (error) { + nodePtyKillIssued = false + throw error + } + } + const forceKillConpty = (): void => { + if (exit) { + return + } + // Re-request the owned job even after a native close; neither request proves exit. + terminatePtyJob(proc) + kill() + } + + const forceKill = () => (process.platform === 'win32' ? forceKillConpty() : forceKillPosix()) + const dispose = (): void => { + if (disposed) { + return + } + disposed = true + const destroy = (): void => { + try { + nativeProc.destroy?.() + } catch {} + } + if (process.platform !== 'win32') { + nativeProc.kill = () => {} + } else if (nodePtyKillIssued) { + return + } + groupSweep.afterSettled(destroy) + } + const termination = new SessionTerminationController({ + sessionId: String(proc.pid), + subprocess: { kill, forceKill, signal: (signal) => proc.kill(signal), dispose }, + isExited: () => exit !== null, + releaseProducerPause: ({ resume }) => { + if (resume) { + proc.resume() + } + } + }) + let nativeExitSubscription: pty.IDisposable | undefined + const retireNativeExitSubscription = (): void => { + const subscription = nativeExitSubscription + nativeExitSubscription = undefined + subscription?.dispose() + } + const observeExit = (): void => { + if (exit || nativeExitSubscription) { + return + } + nativeExitSubscription = proc.onExit((event) => { + if (exit) { + return + } + exit = { + exitCode: event.exitCode, + ...(event.signal !== undefined ? { signal: event.signal } : {}) + } + termination.markPhysicalExit() + const errors: unknown[] = [] + try { + retireNativeExitSubscription() + } catch (error) { + errors.push(error) + } + try { + const listeners = [...exitListeners] + for (const listener of listeners) { + if (exitListeners.delete(listener)) { + try { + listener(exit) + } catch (error) { + errors.push(error) + } + } + } + } finally { + exitListeners.clear() + } + if (errors.length > 0) { + throw errors[0] + } + }) + if (exit) { + retireNativeExitSubscription() + } + } + return { + observeExit, + completion: termination.physicalCompletion, + exitCode: () => exit?.exitCode ?? null, + onExit: (listener: Parameters[0]): pty.IDisposable => { + if (exit) { + listener(exit) + } else { + exitListeners.add(listener) + } + return { + dispose: () => { + exitListeners.delete(listener) + } + } + }, + hasExited: () => exit !== null, + kill, + forceKill, + dispose, + recover: () => { + try { + observeExit() + } catch { + // Observation failure must not revoke the returned process's cleanup authority. + } + return shutdownOwnedTerminalProcess({ + pid: proc.pid, + isAlive: () => exit === null, + terminateOwnedTree: () => terminatePtyJob(proc), + close: async () => { + await termination.forceKillAndWaitForExit() + dispose() + } + }) + } + } +} diff --git a/src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts b/src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts index fd539e043800..b5574e20dd37 100644 --- a/src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts +++ b/src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts @@ -9,6 +9,12 @@ const ptyJob = vi.hoisted(() => ({ })) vi.mock('../../windows/windows-pty-job', () => ptyJob) +import '../mock-descendant-sweep' +import { TerminalHost } from '../terminal-host' +import { TerminalSpawnGate } from '../terminal-spawn-gate' +import { TerminalCreationWork } from '../terminal-creation-work' +import { IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS } from '../immediate-kill-reply-budget' +import { createDaemonPtySubprocessHandle } from './subprocess-handle' import { spawnNativeDaemonPty } from './native-pty-spawn' import { _resetConptyStartupTrustForTest, @@ -42,8 +48,26 @@ function fakePane(pid: number, chunks: string[], exitCode?: number) { const exitListeners = new Set<(event: { exitCode: number }) => void>() const pane = { pid, - kill: vi.fn(), + kill: vi.fn(() => { + for (const listener of exitListeners) { + listener({ exitCode: 137 }) + } + }), + destroy: vi.fn(), write: vi.fn(), + resize: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + emitData: (data: string) => { + for (const listener of dataListeners) { + listener(data) + } + }, + emitExit: (exitCode: number) => { + for (const listener of exitListeners) { + listener({ exitCode }) + } + }, onData: (listener: (data: string) => void) => { dataListeners.add(listener) return { dispose: () => dataListeners.delete(listener) } @@ -79,11 +103,13 @@ describe('Windows shell fallback over node-pty ConPTY', () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) vi.spyOn(console, 'warn').mockImplementation(() => {}) nodePty.spawn.mockReset() - ptyJob.terminatePtyJob.mockReset() + ptyJob.terminatePtyJob.mockReset().mockReturnValue('unavailable') + ptyJob.killConptyNow.mockReset().mockReturnValue(false) _resetConptyStartupTrustForTest() }) afterEach(() => { Object.defineProperty(process, 'platform', platform) + vi.useRealTimers() vi.restoreAllMocks() }) @@ -160,6 +186,145 @@ describe('Windows shell fallback over node-pty ConPTY', () => { expect(result.claimStartupEvents?.().data).toEqual(['\x1b[1t\x1b[?1004h']) }) + it.each(['physical-exit', 'deadline', 'observation-retry'] as const)( + 'ordinary create keeps the startup predecessor owned through %s and host disposal', + async (outcome) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const broken = deadPane(990010) + broken.kill.mockImplementation(() => {}) + if (outcome === 'observation-retry') { + vi.spyOn(broken, 'onExit').mockImplementationOnce(() => { + throw new Error('initial exit subscription failed') + }) + // The native close request returns but cannot establish exit; the job is unavailable. + ptyJob.killConptyNow.mockReturnValue(true) + } + const replacement = fakePane(990011, []) + nodePty.spawn.mockReturnValueOnce(broken).mockReturnValueOnce(replacement) + vi.spyOn(process, 'kill').mockImplementation(() => { + throw new Error('Windows cleanup must not signal an unchecked PID') + }) + ptyJob.terminatePtyJob.mockImplementation((proc) => { + if (proc === replacement) { + queueMicrotask(() => replacement.emitExit(137)) + } + return outcome === 'observation-retry' ? 'unavailable' : 'terminated' + }) + const gate = new TerminalSpawnGate({ log: () => {} }) + const host = new TerminalHost({ + spawnGate: gate, + spawnSubprocess: async (options) => { + const work = new TerminalCreationWork() + try { + const spawned = await spawnNativeDaemonPty({ ...args, creationWork: work }) + return createDaemonPtySubprocessHandle({ + ...spawned, + env: {}, + sessionId: options.sessionId, + startupCommandDeliveredInShellArgs: false, + startupAgentRecognition: null, + startupEvents: spawned.claimStartupEvents?.() + }) + } finally { + await work.finish() + options.onCreationDisposition?.(await work.completion) + } + } + }) + const creating = host.createOrAttach({ + sessionId: 'ordinary-conpty', + cols: 80, + rows: 24, + streamClient: { onData() {}, onExit() {} } + }) + const creationSettled = creating.then( + () => null, + (error: unknown) => error + ) + try { + if (outcome === 'observation-retry') { + await vi.waitFor(() => expect(ptyJob.killConptyNow).toHaveBeenCalledOnce()) + await vi.advanceTimersByTimeAsync(IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS) + expect(await creationSettled).toBeInstanceOf(Error) + expect(host.listSessions()).toEqual([]) + expect(gate.snapshot().terminals).toBe(1) + // Let the automatic failed-create recovery also exhaust its bounded exit wait. + await vi.advanceTimersByTimeAsync(IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS) + const requests = ptyJob.terminatePtyJob.mock.calls.length + ptyJob.terminatePtyJob.mockImplementation(() => { + queueMicrotask(() => broken.emitExit(137)) + return 'terminated' + }) + await host.dispose() + expect(ptyJob.terminatePtyJob.mock.calls.length).toBeGreaterThan(requests) + expect(ptyJob.killConptyNow).toHaveBeenCalledOnce() + expect(broken.kill).not.toHaveBeenCalled() + expect(broken.destroy).not.toHaveBeenCalled() + expect(process.kill).not.toHaveBeenCalled() + expect(nodePty.spawn).toHaveBeenCalledOnce() + expect(gate.snapshot().terminals).toBe(0) + expect(host.listSessions()).toEqual([]) + return + } + await vi.waitFor(() => expect(broken.kill).toHaveBeenCalledOnce()) + expect(nodePty.spawn).toHaveBeenCalledOnce() + expect(host.listSessions()).toEqual([]) + if (outcome === 'physical-exit') { + broken.emitExit(137) + await vi.waitFor(() => expect(nodePty.spawn).toHaveBeenCalledTimes(2)) + replacement.emitData(CONPTY_PRELUDE) + } else { + await vi.advanceTimersByTimeAsync(CONPTY_STARTUP_DECISION_LIMIT_MS) + } + const created = await creating + expect(created.isNew).toBe(true) + expect(created.pid).toBe(outcome === 'physical-exit' ? replacement.pid : broken.pid) + expect(nodePty.spawn).toHaveBeenCalledTimes(outcome === 'physical-exit' ? 2 : 1) + expect(gate.snapshot().terminals).toBe(1) + if (outcome === 'deadline') { + ptyJob.terminatePtyJob.mockReturnValue('unavailable') + const disposing = host.dispose().then( + () => null, + (error: unknown) => error + ) + await vi.waitFor(() => + expect( + ptyJob.terminatePtyJob.mock.calls.filter(([proc]) => proc === broken).length + ).toBeGreaterThan(1) + ) + expect(gate.snapshot().terminals).toBe(1) + expect(host.listSessions()).toHaveLength(1) + await vi.advanceTimersByTimeAsync(IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS) + expect(await disposing).toBeInstanceOf(Error) + expect(gate.snapshot().terminals).toBe(1) + expect(host.listSessions()).toHaveLength(1) + // The same owner's force request must reach the now-available job after the timeout. + const requests = ptyJob.terminatePtyJob.mock.calls.length + ptyJob.terminatePtyJob.mockImplementation(() => { + queueMicrotask(() => broken.emitExit(137)) + return 'terminated' + }) + const retrying = host.dispose() + await vi.waitFor(() => + expect(ptyJob.terminatePtyJob.mock.calls.length).toBeGreaterThan(requests) + ) + await retrying + expect(broken.kill).toHaveBeenCalledOnce() + expect(broken.destroy).not.toHaveBeenCalled() + } else { + await host.dispose() + } + expect(gate.snapshot().terminals).toBe(0) + expect(host.listSessions()).toEqual([]) + expect(process.kill).not.toHaveBeenCalled() + } finally { + broken.emitExit(137) + replacement.emitExit(137) + await host.dispose() + } + } + ) + it('respawns only once, then says so in the pane', async () => { nodePty.spawn.mockReturnValueOnce(healthyPane(990001)) await spawnNativeDaemonPty(args) @@ -215,12 +380,26 @@ describe('Windows shell fallback over node-pty ConPTY', () => { it('kills the pane tree when the spawn is canceled while startup is awaited', async () => { const controller = new AbortController() const silent = fakePane(990001, []) + silent.kill.mockImplementation(() => {}) + vi.spyOn(process, 'kill').mockImplementation(() => { + throw new Error('unchecked Windows PID') + }) nodePty.spawn.mockReturnValueOnce(silent) const spawned = spawnNativeDaemonPty({ ...args, signal: controller.signal }) await new Promise((resolve) => setImmediate(resolve)) + let settled = false + const rejected = expect(spawned) + .rejects.toThrow('spawn canceled') + .then(() => { + settled = true + }) controller.abort(new Error('spawn canceled')) - - await expect(spawned).rejects.toThrow('spawn canceled') + await vi.waitFor(() => expect(silent.kill).toHaveBeenCalledOnce()) + expect(settled).toBe(false) + expect(nodePty.spawn).toHaveBeenCalledOnce() + silent.emitExit(137) + await rejected + expect(process.kill).not.toHaveBeenCalled() expect(ptyJob.terminatePtyJob).toHaveBeenCalledWith(silent) // node-pty defers kill() until first output, which a silent pane never produces. expect(ptyJob.killConptyNow).toHaveBeenCalledWith(silent) diff --git a/src/main/daemon/pty-subprocess/native-pty-spawn.ts b/src/main/daemon/pty-subprocess/native-pty-spawn.ts index 8dd1a6ecfc63..ea368ba67c4f 100644 --- a/src/main/daemon/pty-subprocess/native-pty-spawn.ts +++ b/src/main/daemon/pty-subprocess/native-pty-spawn.ts @@ -1,3 +1,6 @@ +import type { PtyCreationDisposition } from '../../../shared/claimed-agent-pty-owner' +import type { TerminalCreationWork } from '../terminal-creation-work' +import { createDaemonPtyPhysicalOwner, type DaemonPtyPhysicalOwner } from './subprocess-handle' import { assertWslDistroAllowed } from '@orca/process-host/wsl-distro-allowlist' import type * as pty from 'node-pty' import { @@ -12,20 +15,36 @@ import { spawnConptyWithInputReader, type ConptyStartupEvents } from './conpty-s async function loadNodePty(): Promise { return import('node-pty') } + +export type DisposableNativePty = pty.IPty & { + destroy?: () => void + signalProcess?: (signal: string) => void + // node-pty emits these when its master stream retires; writes then become silent no-ops. + on?: (event: 'close' | 'error', listener: (error?: unknown) => void) => void + // False once node-pty's _close() retired the master; Windows never emits 'close' for that. + _writable?: boolean +} + export type SpawnedDaemonPty = { process: pty.IPty + physicalOwner?: DaemonPtyPhysicalOwner shellPath: string spawnCwd: string startupCommandDeliveredInShellArgs?: boolean /** False when a wrapper owns the reported status, so no exit code may be read from it. */ reportsChildExitStatus: boolean - /** Windows: output and exit seen while confirming ConPTY reads input; claim before subscribing. */ + /** Windows: output seen while confirming ConPTY reads input; claim before subscribing. */ claimStartupEvents?: () => ConptyStartupEvents } -type SpawnedNativePty = { proc: pty.IPty; claimStartupEvents?: () => ConptyStartupEvents } +type SpawnedNativePty = { + proc: pty.IPty + physicalOwner: DaemonPtyPhysicalOwner + claimStartupEvents?: () => ConptyStartupEvents +} export async function spawnNativeDaemonPty(args: { + creationWork?: TerminalCreationWork shellPath: string shellArgs: string[] spawnCwd: string @@ -36,14 +55,16 @@ export async function spawnNativeDaemonPty(args: { /** The primary shell's argv is Orca's interactive default, so a respawn cannot repeat a command. */ primaryArgsRespawnSafe?: boolean signal?: AbortSignal + assertCreateAllowed?: () => void onMacosTccSpawnStrategy?: (strategy: 'wrapped' | 'direct') => void }): Promise { let reportsChildExitStatus = true + let unpublishedOwner: DaemonPtyPhysicalOwner | undefined const spawnOnce = async ( shellPath: string, shellArgs: string[], cwd: string - ): Promise => { + ): Promise<{ proc: pty.IPty; physicalOwner: DaemonPtyPhysicalOwner }> => { args.signal?.throwIfAborted() assertWslDistroAllowed(shellPath, shellArgs) const tcc = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, args.env) @@ -53,18 +74,41 @@ export async function spawnNativeDaemonPty(args: { if (process.platform === 'win32') { assignHostProcessToKillOnCloseJob() } - const proc = nodePty.spawn(wrapped.file, wrapped.args, { - name: args.env.TERM ?? 'xterm-256color', - cols: args.cols, - rows: args.rows, - cwd, - env: wrapped.env, - // Bundled ConPTY provides the wrap-marker behavior xterm expects. - ...(process.platform === 'win32' ? { useConptyDll: true } : {}) + args.signal?.throwIfAborted() + args.assertCreateAllowed?.() + const attempt = Promise.withResolvers() + args.creationWork?.join(attempt.promise) + let proc: pty.IPty + try { + proc = nodePty.spawn(wrapped.file, wrapped.args, { + name: args.env.TERM ?? 'xterm-256color', + cols: args.cols, + rows: args.rows, + cwd, + env: wrapped.env, + // Bundled ConPTY provides the wrap-marker behavior xterm expects. + ...(process.platform === 'win32' ? { useConptyDll: true } : {}) + }) + } catch (error) { + attempt.resolve({ kind: 'failed', completion: Promise.resolve() }) + throw error + } + const physical = createDaemonPtyPhysicalOwner(proc) + unpublishedOwner = physical + attempt.resolve({ + kind: 'physical', + completion: physical.completion, + exitCode: physical.exitCode, + recover: physical.recover }) + physical.observeExit() reportsChildExitStatus = hostReportsChildExitStatus(tcc.file) - args.onMacosTccSpawnStrategy?.(tcc.file === shellPath ? 'direct' : 'wrapped') - return proc + try { + args.onMacosTccSpawnStrategy?.(tcc.file === shellPath ? 'direct' : 'wrapped') + } catch { + /* Diagnostics cannot revoke an owned child. */ + } + return { proc, physicalOwner: physical } } const spawnAt = async ( shellPath: string, @@ -73,7 +117,7 @@ export async function spawnNativeDaemonPty(args: { respawnSafe: boolean ): Promise => { if (process.platform !== 'win32') { - return { proc: await spawnOnce(shellPath, shellArgs, cwd) } + return await spawnOnce(shellPath, shellArgs, cwd) } return spawnConptyWithInputReader(() => spawnOnce(shellPath, shellArgs, cwd), { label: shellPath, @@ -91,12 +135,15 @@ export async function spawnNativeDaemonPty(args: { ) return { process: spawned.proc, + physicalOwner: spawned.physicalOwner, shellPath: args.shellPath, spawnCwd: args.spawnCwd, reportsChildExitStatus, ...(spawned.claimStartupEvents ? { claimStartupEvents: spawned.claimStartupEvents } : {}) } } catch (primaryErr) { + // A returned predecessor stays owned until exit, including failed observer/startup setup. + await unpublishedOwner?.recover() args.signal?.throwIfAborted() if (process.platform !== 'win32') { throw primaryErr @@ -115,6 +162,7 @@ export async function spawnNativeDaemonPty(args: { ) return { process: spawned.proc, + physicalOwner: spawned.physicalOwner, shellPath: attempt.shellPath, spawnCwd: attempt.effectiveCwd, startupCommandDeliveredInShellArgs: attempt.startupCommandDeliveredInShellArgs, @@ -122,6 +170,7 @@ export async function spawnNativeDaemonPty(args: { ...(spawned.claimStartupEvents ? { claimStartupEvents: spawned.claimStartupEvents } : {}) } } catch { + await unpublishedOwner?.recover() args.signal?.throwIfAborted() } } diff --git a/src/main/daemon/pty-subprocess/subprocess-handle.ts b/src/main/daemon/pty-subprocess/subprocess-handle.ts index 1ce9cbfbc5cd..a1505b435f26 100644 --- a/src/main/daemon/pty-subprocess/subprocess-handle.ts +++ b/src/main/daemon/pty-subprocess/subprocess-handle.ts @@ -1,9 +1,9 @@ +import { createDaemonPtyPhysicalOwner } from './daemon-pty-physical-owner' +import type { DisposableNativePty } from './native-pty-spawn' import type * as pty from 'node-pty' import type { RecognizedAgentProcess } from '../../../shared/agent-process-recognition' import { readPtySlavePath } from '../../../shared/pty-slave-line-discipline-echo' -import { forceKillPosixPtyProcessGroups } from '../../pty/posix-pty-process-groups' import { signalPosixPtyForegroundGroup } from '../../pty/posix-pty-foreground-group' -import { PendingPtyGroupSweep } from '../../pty/pending-pty-group-sweep' import { readPtsName } from '../../pty/node-pty-pts-name' import { terminatePtyJob } from '../../windows/windows-pty-job' import { isValidPtySize } from '../daemon-pty-size' @@ -14,20 +14,15 @@ import { ptyProcessNameIsSpawnFile } from './spawn-file-foreground-process' import { inspectSpawnFileWindowsChildProcesses } from './spawn-file-child-processes' import type { ConptyStartupEvents } from './conpty-startup-input-reader' -type DisposableNativePty = pty.IPty & { - destroy?: () => void - signalProcess?: (signal: string) => void - // node-pty emits these when its master stream retires; writes then become silent no-ops. - on?: (event: 'close' | 'error', listener: (error?: unknown) => void) => void - // False once node-pty's _close() retired the master; Windows never emits 'close' for that. - _writable?: boolean -} +export { createDaemonPtyPhysicalOwner } from './daemon-pty-physical-owner' +export type DaemonPtyPhysicalOwner = ReturnType // Normal exit also retires the master first; let the exit report win before calling input closed. export const MASTER_CLOSE_EXIT_GRACE_MS = 1_000 export function createDaemonPtySubprocessHandle(args: { process: pty.IPty + physicalOwner?: DaemonPtyPhysicalOwner shellPath: string spawnCwd: string env: Record @@ -36,7 +31,7 @@ export function createDaemonPtySubprocessHandle(args: { requestedCwd?: string sessionId: string startupAgentRecognition: RecognizedAgentProcess | null - /** Output and exit buffered before this handle subscribed; replayed ahead of live events. */ + /** Output buffered before this handle subscribed; replayed ahead of live events. */ startupEvents?: ConptyStartupEvents }): SubprocessHandle { const reportsChildExitStatus = args.reportsChildExitStatus @@ -44,15 +39,13 @@ export function createDaemonPtySubprocessHandle(args: { const proc = args.process // node-pty exposes destroy at runtime but omits it from IPty. const nativeProc: DisposableNativePty = proc + const physical = args.physicalOwner ?? createDaemonPtyPhysicalOwner(proc) + physical.observeExit() const events = new PtyPreListenerEvents() let dead = false - // Unlike dead, disposal keeps this false so an in-flight force-kill still lands. - let exited = false - const groupSweep = new PendingPtyGroupSweep() // I/O failure is not exit evidence; keep termination and producer flow control available. let ioFailed = false let disposed = false - let nodePtyKillIssued = false let masterRetired = false let masterError: string | null = null let masterRetiredTimer: ReturnType | null = null @@ -116,7 +109,6 @@ export function createDaemonPtySubprocessHandle(args: { const acceptExit = ({ exitCode, signal }: { exitCode: number; signal?: number }): void => { // Exit listeners may re-enter cleanup; retire signal authority before notifying them. dead = true - exited = true clearMasterRetiredTimer() foreground.markDead() // Neutralize kill before an async socket-close SIGHUP can target a recycled PID. @@ -129,51 +121,7 @@ export function createDaemonPtySubprocessHandle(args: { hostReportsChildExitStatus: reportsChildExitStatus }) } - if (args.startupEvents?.exit) { - acceptExit(args.startupEvents.exit) - } else { - proc.onExit(acceptExit) - } - - const killRoot = (): void => { - process.kill(proc.pid, 'SIGKILL') - } - const fallBackToNodePtyKill = (signalError: unknown): void => { - try { - proc.kill() - nodePtyKillIssued = true - } catch { - nodePtyKillIssued = false - throw signalError - } - } - const forceKillPosix = async (): Promise => { - if (dead) { - return - } - // A reaped root frees its pid and TTY for reuse, so a table read that outlives it signals nothing. - await groupSweep.track( - forceKillPosixPtyProcessGroups(proc.pid, killRoot, { ownsRoot: () => !exited }).catch( - fallBackToNodePtyKill - ) - ) - } - // Stays synchronous: dispose() reads nodePtyKillIssued right after to avoid double-closing ConPTY. - const forceKillConpty = (): void => { - if (dead) { - return - } - // Escalate a ConPTY kill through the job without double-closing node-pty's shell handle. - if (nodePtyKillIssued) { - terminatePtyJob(proc) - return - } - try { - killRoot() - } catch (signalError) { - fallBackToNodePtyKill(signalError) - } - } + const exitSubscription = physical.onExit(acceptExit) const slavePath = readPtySlavePath(proc) return { @@ -247,20 +195,16 @@ export function createDaemonPtySubprocessHandle(args: { } catch {} }, kill: () => { - if (dead) { - return - } - nodePtyKillIssued = true - try { - proc.kill() - } catch (error) { - // A rejected native kill is not proof of exit; keep the wrapper live for a retry. - nodePtyKillIssued = false - throw error + if (!dead) { + physical.kill() } }, terminateOwnedTree: () => terminatePtyJob(proc), - forceKill: () => (process.platform === 'win32' ? forceKillConpty() : forceKillPosix()), + forceKill: () => { + if (!dead) { + return physical.forceKill() + } + }, signal: (sig) => { if (dead) { return @@ -279,7 +223,7 @@ export function createDaemonPtySubprocessHandle(args: { // SIGWINCH belongs to the tty foreground group; destructive signals keep the root-pid target. if (sig === 'SIGWINCH') { void signalPosixPtyForegroundGroup(proc.pid, readPtsName(proc), sig, signalRootPid, { - ownsRoot: () => !exited + ownsRoot: () => !physical.hasExited() }) return } @@ -295,19 +239,9 @@ export function createDaemonPtySubprocessHandle(args: { dead = true clearMasterRetiredTimer() inputClosedListener = null + exitSubscription.dispose() events.clear() - const destroy = (): void => { - try { - nativeProc.destroy?.() - } catch {} - } - // POSIX destroy() can asynchronously signal a recycled pid; Windows needs kill() to close ConPTY. - if (process.platform !== 'win32') { - nativeProc.kill = () => {} - } else if (nodePtyKillIssued) { - return - } - groupSweep.afterSettled(destroy) + physical.dispose() } } } diff --git a/src/main/daemon/session-output-pipeline.ts b/src/main/daemon/session-output-pipeline.ts index fd4291b4df74..c1bb107b20cc 100644 --- a/src/main/daemon/session-output-pipeline.ts +++ b/src/main/daemon/session-output-pipeline.ts @@ -1,22 +1,15 @@ import { SessionOutputPlane } from './session-output-plane' import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' -import type { SubprocessHandle } from './session-subprocess-handle' import { release } from 'node:os' -import type { PtyOwnerBackend } from '../../shared/pty-owner-backend' +import type { SessionOptions } from './session-options' import { windowsPtyForOwnerBackend } from '../../shared/xterm-conpty-options' /** Couple barrier emissions and snapshot ownership so neither can lag the output plane. */ -export function createSessionOutputPipeline(opts: { - cols: number - rows: number - scrollback?: number | undefined - wslDistro?: string | undefined - historySeedChunks?: readonly string[] | undefined - ownerBackend?: PtyOwnerBackend | undefined - subprocess: SubprocessHandle +export function createSessionOutputPipeline( + opts: SessionOptions, isAlive: () => boolean -}): { output: SessionOutputPlane; recoveryBarrier: TerminalShellRecoveryBarrier } { - const { subprocess, isAlive } = opts +): { output: SessionOutputPlane; recoveryBarrier: TerminalShellRecoveryBarrier } { + const { subprocess } = opts let barrier: TerminalShellRecoveryBarrier | null = null const output = new SessionOutputPlane({ cols: opts.cols, diff --git a/src/main/daemon/session-termination-controller.ts b/src/main/daemon/session-termination-controller.ts index e13778a75871..30a17c4291a2 100644 --- a/src/main/daemon/session-termination-controller.ts +++ b/src/main/daemon/session-termination-controller.ts @@ -11,7 +11,7 @@ const SESSION_FORCE_KILL_MAX_ATTEMPTS = 2 export type SessionTerminationControllerDeps = { sessionId: string - subprocess: SubprocessHandle + subprocess: Pick isExited(): boolean releaseProducerPause(opts: { resume: boolean }): void } @@ -25,6 +25,10 @@ export class SessionTerminationController { constructor(private readonly deps: SessionTerminationControllerDeps) {} + get physicalCompletion(): Promise { + return this.physicalExit.exitedPromise + } + get isTerminating(): boolean { return this._isTerminating } @@ -88,7 +92,13 @@ export class SessionTerminationController { } // Escalate now rather than spend the physical-exit budget waiting for graceful shutdown. await this.requestForceKillWithRetry() - await this.waitForPhysicalExit(timeoutMs) + try { + await this.waitForPhysicalExit(timeoutMs) + } catch (error) { + // A fulfilled request without exit cannot suppress the next explicit cleanup attempt. + this.forceKillRequest = null + throw error + } } signal(sig: string): void { diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 187df3b54cb0..fbd8e46e3596 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -49,16 +49,8 @@ export class Session { this.processNameIsSpawnFile = opts.subprocess.processNameIsSpawnFile === true this.shellIsRootChild = opts.subprocess.shellIsRootChild === true this.onSessionExit = opts.onExit - const pipeline = createSessionOutputPipeline({ - cols: opts.cols, - rows: opts.rows, - scrollback: opts.scrollback, - wslDistro: opts.wslDistro, - historySeedChunks: opts.historySeedChunks, - ownerBackend: opts.ownerBackend, - subprocess: this.subprocess, - isAlive: () => !this._disposed && this._state !== 'exited' - }) + const isAlive = () => !this._disposed && this._state !== 'exited' + const pipeline = createSessionOutputPipeline(opts, isAlive) this.output = pipeline.output this.recoveryBarrier = pipeline.recoveryBarrier this.producerPause = new SessionProducerPause(this.subprocess) @@ -87,12 +79,20 @@ export class Session { write: (data) => this.subprocess.write(data), onEmission: (emission) => this.recoveryBarrier.accept(emission) }) - this.subprocess.onData((data) => { - if (!this._disposed) { - this.shellReady.ingestSubprocessData(data) - } - }) - this.subprocess.onExit((code, cause) => this.handleSubprocessExit(code, cause)) + try { + this.subprocess.onData((data) => { + if (!this._disposed) { + this.shellReady.ingestSubprocessData(data) + } + }) + this.subprocess.onExit((code, cause) => this.handleSubprocessExit(code, cause)) + } catch (error) { + this._disposed = true + this.shellReady.dispose() + this.recoveryBarrier.dispose() + this.output.disposeEmulator() + throw error + } } get state(): SessionState { @@ -119,6 +119,10 @@ export class Session { return this.output.hasAttachedClients } + get physicalCompletion(): Promise { + return this.termination.physicalCompletion + } + get isTerminating(): boolean { return this.termination.isTerminating } diff --git a/src/main/daemon/terminal-creation-work.ts b/src/main/daemon/terminal-creation-work.ts new file mode 100644 index 000000000000..8129e8ca1e74 --- /dev/null +++ b/src/main/daemon/terminal-creation-work.ts @@ -0,0 +1,229 @@ +import type { InternalCreateOrAttachOptions } from './terminal-host-agent-session-claim' +import { waitForTerminalAttachOperation } from './terminal-attach-cancellation' +import type { PtyCreationDisposition } from '../../shared/claimed-agent-pty-owner' +import type { TerminalSpawnGate, TerminalSpawnLease } from './terminal-spawn-gate' + +export type PendingTerminalCreation = { + completion: Promise + recover: () => Promise +} + +/** The complete ordinary call owns every lower attempt and acquired dependency. */ +export class TerminalCreationWork { + private readonly result = Promise.withResolvers() + readonly completion = this.result.promise + private readonly dependencies: Promise[] = [] + private readonly owners: { + completion: Promise + exitCode: () => number | null + recover?: () => Promise + }[] = [] + private uncertain = false + + physicalObserver( + report: ((completion: Promise, exitCode: () => number | null) => void) | undefined + ): (completion: Promise, exitCode: () => number | null) => void { + return (completion, exitCode) => { + this.owner(completion, exitCode) + report?.(completion, exitCode) + } + } + + join(disposition: Promise): void { + this.dependencies.push(disposition) + } + + dispatched(): void { + this.uncertain = true + } + + owner( + completion: Promise, + exitCode: () => number | null, + recover?: () => Promise + ): void { + this.owners.push({ completion, exitCode, recover }) + } + + async finish(reconcile?: (release: () => Promise) => Promise): Promise { + const joined = await Promise.all(this.dependencies) + const physical = [ + ...this.owners, + ...joined.flatMap((entry) => (entry.kind === 'physical' ? [entry] : [])) + ] + const release = async () => { + await Promise.all( + [...this.owners, ...joined.filter((entry) => entry.kind !== 'no-fork')].map(async (entry) => + entry.recover?.() + ) + ) + } + let recovery: Promise | undefined + const recover = () => { + recovery ??= (reconcile ? reconcile(release) : release()).finally(() => { + recovery = undefined + }) + return recovery + } + if (this.uncertain || joined.some((entry) => entry.kind === 'unknown')) { + this.result.resolve({ kind: 'unknown', recover }) + } else if (joined.some((entry) => entry.kind === 'failed')) { + this.result.resolve({ + kind: 'failed', + completion: Promise.all([ + ...physical.map((entry) => entry.completion), + ...joined.flatMap((entry) => (entry.kind === 'failed' ? [entry.completion] : [])) + ]).then(() => {}), + recover + }) + } else if (physical.length) { + this.result.resolve({ + kind: 'physical', + completion: Promise.all(physical.map((entry) => entry.completion)).then(() => {}), + exitCode: () => physical.at(-1)?.exitCode() ?? null, + recover + }) + } else { + this.result.resolve({ kind: 'no-fork' }) + } + } +} + +export function reconcileUnpublishedTerminalCreation( + sessions: ReadonlyMap, + sessionId: string +): (release: () => Promise) => Promise { + return async (release) => { + // Registered Sessions retain their ordinary current/use-fenced close authority. + if (!sessions.has(sessionId)) { + await release() + } + } +} + +/** Keep pending recovery and exact-entry retirement outside the completed request's scope. */ +export function registerPendingTerminalCreation( + pendingCreations: Map, + sessionId: string, + creation: TerminalCreationWork +): (published: boolean) => void { + const settled = Promise.withResolvers() + const pending: PendingTerminalCreation = { + completion: settled.promise, + recover: async () => { + const disposition = await creation.completion + if (disposition.kind !== 'no-fork') { + await disposition.recover?.() + } + } + } + pendingCreations.set(sessionId, pending) + const finish = () => { + if (pendingCreations.get(sessionId) === pending) { + pendingCreations.delete(sessionId) + } + settled.resolve() + } + return (published) => { + if (published) { + finish() + } else { + void creation.completion.then(async (disposition) => { + if (disposition.kind === 'unknown') { + return + } + if (disposition.kind === 'physical' || disposition.kind === 'failed') { + await disposition.completion + } + finish() + }) + } + } +} + +/** An unpublished attempt keeps its existing lease until its actual disposition permits release. */ +export function settleUnpublishedTerminalSpawnLease( + completion: Promise, + admitted: TerminalSpawnLease | undefined, + sessionId: string, + spawnGate: TerminalSpawnGate | undefined +): void { + void completion.then(async (disposition) => { + if (disposition.kind === 'no-fork') { + admitted?.abandon() + } else if (disposition.kind === 'failed') { + await disposition.completion + admitted?.abandon() + } else if (disposition.kind === 'physical') { + admitted?.commit(sessionId, true) + await disposition.completion + spawnGate?.onSessionExit(sessionId) + } + }) +} + +/** Recovery may outlive the observer; its rejection observer needs only the actual outcome. */ +export function recoverUnpublishedTerminalCreation(disposition: PtyCreationDisposition): void { + if (disposition.kind !== 'no-fork') { + void disposition.recover?.().catch(() => {}) + } +} + +export async function runTerminalCreation( + opts: InternalCreateOrAttachOptions, + sessions: ReadonlyMap, + pendingCreations: Map, + assertCreateAllowed: (options: InternalCreateOrAttachOptions) => void, + operation: ( + options: InternalCreateOrAttachOptions, + creation: TerminalCreationWork + ) => Promise +): Promise { + const creation = new TerminalCreationWork() + const requestedOptions = opts + opts = { + ...opts, + creationWork: creation, + onPhysicalOwner: creation.physicalObserver(opts.onPhysicalOwner) + } + try { + assertCreateAllowed(opts) + for ( + let inFlight = pendingCreations.get(opts.sessionId); + inFlight !== undefined; + inFlight = pendingCreations.get(opts.sessionId) + ) { + // Let canceled callers leave rather than wait behind a cwd probe stuck on an unreachable share. + const pending = inFlight + await waitForTerminalAttachOperation( + pending.recover().then(() => pending.completion), + opts.cancelSignal, + opts.sessionId + ) + assertCreateAllowed(opts) + } + assertCreateAllowed(opts) + + const finishPending = registerPendingTerminalCreation( + pendingCreations, + opts.sessionId, + creation + ) + let published = false + try { + const result = await operation(opts, creation) + published = true + return result + } finally { + finishPending(published || sessions.has(opts.sessionId)) + } + } finally { + await creation.finish(reconcileUnpublishedTerminalCreation(sessions, opts.sessionId)) + const disposition = await creation.completion + requestedOptions.onCreationDisposition?.(disposition) + if (!sessions.has(opts.sessionId) && disposition.kind !== 'no-fork') { + // Failed unpublished resources keep the same ordinary cleanup owner across Retry/disposal. + recoverUnpublishedTerminalCreation(disposition) + } + } +} diff --git a/src/main/daemon/terminal-host-agent-session-claim.ts b/src/main/daemon/terminal-host-agent-session-claim.ts index 84ee3830d9bb..b514418cda33 100644 --- a/src/main/daemon/terminal-host-agent-session-claim.ts +++ b/src/main/daemon/terminal-host-agent-session-claim.ts @@ -1,4 +1,8 @@ -import type { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner' +import type { TerminalCreationWork } from './terminal-creation-work' +import type { + ClaimedAgentPtyOwnerRegistry, + PtyCreationDisposition +} from '../../shared/claimed-agent-pty-owner' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' import type { SpawnIntent } from '../../shared/host-spawn-admission' import type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract' @@ -7,6 +11,9 @@ export type InternalCreateOrAttachOptions = CreateOrAttachOptions & { agentSessionGeneration?: string /** Request identity for the fork gate; absent for in-process callers. */ spawnAdmission?: { clientId: string; declaredIntent: SpawnIntent | null; deadlineAt: number } + creationWork?: TerminalCreationWork + onCreationDisposition?: (disposition: PtyCreationDisposition) => void + onPhysicalOwner?: (completion: Promise, exitCode: () => number | null) => void isCanceled?: () => boolean cancelSignal?: AbortSignal } @@ -23,6 +30,14 @@ export async function createOrAttachClaimedAgentSession(args: { } let created: CreateOrAttachResult | null = null const ensured = await args.owners.ensure({ + creation: args.options.creationWork?.completion, + onJoin: (creation) => { + if (creation) { + args.options.creationWork?.join(creation) + } else { + args.options.creationWork?.dispatched() + } + }, claim: ensureRequest.claim, surface: ensureRequest.surface, spawn: async ({ generation }) => { diff --git a/src/main/daemon/terminal-host-concurrent-create.test.ts b/src/main/daemon/terminal-host-concurrent-create.test.ts index 85dd89799295..1cf8ca1537cc 100644 --- a/src/main/daemon/terminal-host-concurrent-create.test.ts +++ b/src/main/daemon/terminal-host-concurrent-create.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { SubprocessHandle } from './session-subprocess-handle' +import type { TerminalHostOptions } from './terminal-host-options' import { TerminalHost } from './terminal-host' vi.mock('../pty-descendant-termination', () => ({ killWithDescendantSweep: vi.fn() })) @@ -95,8 +96,11 @@ describe('concurrent createOrAttach across the async spawn', () => { it('lets the next caller spawn after a failed spawn releases the gate', async () => { const spawnSubprocess = vi - .fn<() => Promise>() - .mockRejectedValueOnce(new Error('Working directory "X" does not exist.')) + .fn() + .mockImplementationOnce((opts) => { + opts.onCreationDisposition?.({ kind: 'no-fork' }) + throw new Error('Working directory "X" does not exist.') + }) .mockImplementation(async () => mockSubprocess()) const host = new TerminalHost({ spawnSubprocess }) @@ -197,9 +201,14 @@ describe('concurrent createOrAttach across the async spawn', () => { rejectSpawn = () => reject(new Error('spawn failed')) }) const spawnSubprocess = vi - .fn<() => Promise>() - .mockImplementationOnce(async () => { - await spawnGate + .fn() + .mockImplementationOnce(async (opts) => { + try { + await spawnGate + } catch (error) { + opts.onCreationDisposition?.({ kind: 'no-fork' }) + throw error + } return mockSubprocess() }) .mockImplementation(async () => mockSubprocess()) diff --git a/src/main/daemon/terminal-host-cwd-readability.test.ts b/src/main/daemon/terminal-host-cwd-readability.test.ts index 771e37e229ec..2bb707641c9e 100644 --- a/src/main/daemon/terminal-host-cwd-readability.test.ts +++ b/src/main/daemon/terminal-host-cwd-readability.test.ts @@ -188,7 +188,12 @@ describe('TerminalHost cwd readability verdict', () => { host = new TerminalHost({ spawnSubprocess: async (opts) => { if (opts.cwd) { - await validateWorkingDirectoryAsync(opts.cwd, { signal: opts.cancelSignal }) + try { + await validateWorkingDirectoryAsync(opts.cwd, { signal: opts.cancelSignal }) + } catch (error) { + opts.onCreationDisposition?.({ kind: 'no-fork' }) + throw error + } } return createMockSubprocess() } diff --git a/src/main/daemon/terminal-host-disposal.ts b/src/main/daemon/terminal-host-disposal.ts index 0a733db4e343..741b4fc43bfa 100644 --- a/src/main/daemon/terminal-host-disposal.ts +++ b/src/main/daemon/terminal-host-disposal.ts @@ -1,3 +1,4 @@ +import type { PendingTerminalCreation } from './terminal-creation-work' import { shutdownTerminalHostSessions } from './terminal-host-session-shutdown' import type { TerminalSessionTeardown } from './terminal-session-teardown' import type { Session } from './session' @@ -10,7 +11,7 @@ type FinalCheckpoint = ( ) => void type TerminalHostDisposalOptions = { - pendingCreations: ReadonlyMap> + pendingCreations: ReadonlyMap sessionTeardown: TerminalSessionTeardown sessions: Map onFinalCheckpoint?: FinalCheckpoint @@ -26,7 +27,12 @@ export async function disposeTerminalHostSessions({ }: TerminalHostDisposalOptions): Promise { if (pendingCreations.size > 0) { // No spawn may publish a session after teardown completes. - await Promise.all(pendingCreations.values()) + await Promise.all( + [...pendingCreations.values()].map(async (creation) => { + await creation.recover() + await creation.completion + }) + ) } const existingTeardowns = sessionTeardown.requestImmediateAll() await Promise.all([ diff --git a/src/main/daemon/terminal-host-fenced-termination.ts b/src/main/daemon/terminal-host-fenced-termination.ts index 45869c858ea6..97f93c7ee257 100644 --- a/src/main/daemon/terminal-host-fenced-termination.ts +++ b/src/main/daemon/terminal-host-fenced-termination.ts @@ -13,7 +13,7 @@ export function beginTerminalHostFencedTermination( request: PtyFencedTerminationRequest, deps: { sessions: ReadonlyMap - pendingCreations: ReadonlyMap> + pendingCreations: ReadonlyMap owners: ClaimedAgentPtyOwnerRegistry kill: (id: string, options: { immediate: true }) => Promise } diff --git a/src/main/daemon/terminal-host-options.ts b/src/main/daemon/terminal-host-options.ts index 0b21fbc8b3fc..8c70339eec0a 100644 --- a/src/main/daemon/terminal-host-options.ts +++ b/src/main/daemon/terminal-host-options.ts @@ -1,3 +1,4 @@ +import type { PtyCreationDisposition } from '../../shared/claimed-agent-pty-owner' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { TuiAgent } from '../../shared/tui-agent' import type { SubprocessHandle } from './session-subprocess-handle' @@ -19,6 +20,8 @@ export type TerminalHostOptions = { terminalShellArgs?: string[] terminalWindowsWslDistro?: string | null terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' + onCreationDisposition?: (disposition: PtyCreationDisposition) => void + assertCreateAllowed?: () => void isCanceled?: () => boolean cancelSignal?: AbortSignal }) => SubprocessHandle | Promise diff --git a/src/main/daemon/terminal-host-returned-process.ts b/src/main/daemon/terminal-host-returned-process.ts new file mode 100644 index 000000000000..d2c246b2ee3c --- /dev/null +++ b/src/main/daemon/terminal-host-returned-process.ts @@ -0,0 +1,100 @@ +import type { Session } from './session' +import type { SubprocessHandle } from './session-subprocess-handle' +import type { TerminalExitCause } from '../../shared/terminal-exit-cause' +import type { TerminalSpawnGate } from './terminal-spawn-gate' +import { SessionTerminationController } from './session-termination-controller' +import { shutdownOwnedTerminalProcess } from './terminal-host-session-shutdown' + +/** Retain the returned resource, not the completed create request or publication dependencies. */ +export function observeReturnedTerminalProcess( + subprocess: SubprocessHandle, + sessionId: string, + spawnGate: TerminalSpawnGate | undefined +) { + const exited = Promise.withResolvers() + let code: number | null = null + let exitCause: TerminalExitCause | undefined + let sessionExit: Parameters[0] | undefined + const rawTermination = new SessionTerminationController({ + sessionId, + subprocess, + isExited: () => code !== null, + releaseProducerPause: ({ resume }) => { + if (resume) { + subprocess.resume?.() + } + } + }) + let recoverCreated = () => + shutdownOwnedTerminalProcess({ + pid: subprocess.pid, + isAlive: () => code === null, + terminateOwnedTree: () => subprocess.terminateOwnedTree(), + close: async () => { + await rawTermination.forceKillAndWaitForExit() + rawTermination.disposeSubprocessHandle() + } + }) + let observing = false + const observeExit = (): void => { + if (observing || code !== null) { + return + } + subprocess.onExit((value, cause) => { + if (code !== null) { + return + } + code = value + exitCause = cause + rawTermination.markPhysicalExit() + spawnGate?.onSessionExit(sessionId) + exited.resolve() + sessionExit?.(value, cause) + }) + observing = true + subprocess.onExit = (callback) => { + sessionExit = callback + if (code !== null) { + callback(code, exitCause) + } + } + } + return { + observeExit, + completion: exited.promise, + exitCode: () => code, + recover: async () => { + try { + observeExit() + } catch { + // Recovery still owns signaling when the adapter cannot yet observe exit. + } + await recoverCreated() + }, + useRecovery: (recover: () => Promise) => { + recoverCreated = recover + } + } +} + +export function recoverUnpublishedTerminalSession( + session: Pick< + Session, + 'pid' | 'isAlive' | 'terminateOwnedTree' | 'forceKillAndWaitForExit' | 'dispose' + > +): () => Promise { + return () => + shutdownOwnedTerminalProcess({ + pid: session.pid, + isAlive: () => session.isAlive, + terminateOwnedTree: () => session.terminateOwnedTree(), + close: async () => { + await session.forceKillAndWaitForExit() + session.dispose() + } + }) +} + +export function terminalSessionExitCode(session: Pick): () => number | null { + return () => session.exitCode +} diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index 5a2852d0d3bb..600ad2e78fc0 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -1,37 +1,22 @@ -import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' -import { - discardStagedStartupCommand, - stageStartupCommand, - startupStagingFailureNotice, - type StartupCommandStaging -} from '../../shared/startup-command-staging' -import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' +import { terminalSessionExitCode } from './terminal-host-returned-process' +import { settleUnpublishedTerminalSpawnLease } from './terminal-creation-work' +import { spawnAndPublishSession } from './terminal-host-session-publish' import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result' -import { enumerateDirectoryOnce } from './directory-enumeration-probe' import { normalizePtySize } from './daemon-pty-size' -import { Session } from './session' -import { shellPathSupportsPtyStartupBarrier } from './shell-ready' +import type { Session } from './session' import type { InternalCreateOrAttachOptions } from './terminal-host-agent-session-claim' import type { CreateOrAttachResult } from './terminal-host-create-contract' import type { TerminalHostOptions } from './terminal-host-options' import type { TerminalHostTombstones } from './terminal-host-tombstones' import type { TerminalSessionTeardown } from './terminal-session-teardown' import type { TerminalSpawnGate } from './terminal-spawn-gate' -import type { LegacyRestoreDetails } from './legacy-restore-records' -import { resolveDaemonSessionScrollbackRows } from './daemon-session-scrollback-window' import { TerminalAttachCanceledError } from './daemon-errors' import { waitForTerminalAttachOperation } from './terminal-attach-cancellation' -import { sharedPromiseSettlementWaiters } from '../../shared/promise-settlement-waiters' import { SessionNotFoundError } from './types' import { resolveWslSessionContext } from './wsl-session-context' -import { recordSessionSpawnPaneKey } from './terminal-host-session-listing' -import { - attachSessionInputViewer, - isSessionInputClosed, - trackSessionInput -} from './session-input-path' - -type TerminalHostSessionCreateDependencies = { +import { attachSessionInputViewer, isSessionInputClosed } from './session-input-path' +import { describeAgentRestore } from './terminal-host-session-process' +export type TerminalHostSessionCreateDependencies = { sessions: Map /** Re-checks the host's shutdown fence and this request's cancellation after any await. */ assertCreateAllowed: () => void @@ -74,6 +59,12 @@ export async function createOrAttachTerminalSession( // Attach synchronously: in-flight recovery resets arrive after the snapshot in stream order. // Checkpoint readers that require settled ownership use getSettledSnapshot. if (existing && existing.isAlive && !existing.isTerminating) { + deps.assertCreateAllowed() + const attachedSession = existing + opts.onPhysicalOwner?.( + attachedSession.physicalCompletion, + terminalSessionExitCode(attachedSession) + ) const snapshot = existing.getSnapshot() existing.detachAllClients() const token = existing.attachClient(opts.streamClient) @@ -109,6 +100,7 @@ export async function createOrAttachTerminalSession( const size = normalizePtySize(opts.cols, opts.rows) const wslDistro = resolveWslSessionContext(opts)?.distro const sessionId = opts.sessionId + const spawnGate = deps.spawnGate let lease = deps.spawnGate?.admit( { clientId: opts.spawnAdmission?.clientId ?? 'in-process', @@ -126,210 +118,31 @@ export async function createOrAttachTerminalSession( if (lease instanceof Promise) { lease = await lease } + let published = false try { if (paced) { // Pacing can outlast shutdown or cancellation; recheck before spawning. deps.assertCreateAllowed() } - return await spawnAndPublishSession(opts, deps, { size, wslDistro }, (publishedId, isAlive) => - lease?.commit(publishedId, isAlive) + const result = await spawnAndPublishSession( + opts, + deps, + { size, wslDistro }, + (publishedId, isAlive) => lease?.commit(publishedId, isAlive) ) + published = true + return result } finally { - lease?.abandon() - } -} - -async function spawnAndPublishSession( - opts: InternalCreateOrAttachOptions, - deps: TerminalHostSessionCreateDependencies, - ctx: { size: { cols: number; rows: number }; wslDistro: string | undefined }, - onPublished: (sessionId: string, isAlive: boolean) => void -): Promise { - const { size, wslDistro } = ctx - // Only macOS needs the TCC probe; other hosts must not pin extra filesystem workers. - const cwdReadableByDaemon = - process.platform === 'darwin' && opts.cwd && !wslDistro - ? await isCwdReadableByThisProcess(opts.cwd, opts) - : null - deps.assertCreateAllowed() - const subprocess = await deps.spawnSubprocess({ - sessionId: opts.sessionId, - cols: size.cols, - rows: size.rows, - cwd: opts.cwd, - env: opts.env, - envToDelete: opts.envToDelete, - command: opts.command, - startupCommandDelivery: opts.startupCommandDelivery, - ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), - shellOverride: opts.shellOverride, - terminalShellArgs: opts.terminalShellArgs, - terminalWindowsWslDistro: opts.terminalWindowsWslDistro, - terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation, - isCanceled: opts.isCanceled, - ...(opts.cancelSignal ? { cancelSignal: opts.cancelSignal } : {}) - }) - - let staging: StartupCommandStaging | undefined - // Reconcile fallback-shell capability or its first command could remain queued until timeout. - const shellReadySupported = - (opts.shellReadySupported ?? false) && - (subprocess.shellPath === undefined || shellPathSupportsPtyStartupBarrier(subprocess.shellPath)) - const session = new Session({ - sessionId: opts.sessionId, - cols: size.cols, - rows: size.rows, - terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, - launchAgent: opts.launchAgent, - subprocess, - ownerBackend: resolvePtyOwnerBackend({ - platform: process.platform, - shellPath: subprocess.shellPath, - wslDistro - }), - shellReadySupported, - scrollback: resolveDaemonSessionScrollbackRows(), - historySeedChunks: opts.historySeedChunks, - ...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}), - wslDistro, - onExit: createSessionExitHandler( - deps.onSessionExit, - opts.sessionId, - opts.agentSessionGeneration, - () => discardStagedStartupCommand(staging), - deps.spawnGate - ), - ...(deps.reportReadinessEvent ? { reportReadinessEvent: deps.reportReadinessEvent } : {}), - ...(opts.shellReadyTimeoutMs !== undefined - ? { shellReadyTimeoutMs: opts.shellReadyTimeoutMs } - : {}) - }) - recordSessionSpawnPaneKey(session, opts.env?.ORCA_PANE_KEY) - - trackSessionInput(session, subprocess, deps.reportReadinessEvent) - - if (opts.isCanceled?.()) { - // Retain cleanup ownership if the native child refuses to exit. - deps.sessions.set(opts.sessionId, session) - // Retain the live-cap charge until physical exit. - onPublished(opts.sessionId, session.isAlive) - await session.forceKillAndDisposeSubprocess() - if (deps.sessions.get(opts.sessionId) === session) { - session.dispose() - deps.sessions.delete(opts.sessionId) - deps.onDeadSessionRemoved(opts.sessionId) - deps.spawnGate?.onSessionExit(opts.sessionId) - } - throw new TerminalAttachCanceledError(opts.sessionId) - } - - deps.sessions.set(opts.sessionId, session) - deps.onSessionCreated(opts.sessionId, opts.agentSessionGeneration, session.isAlive) - onPublished(opts.sessionId, session.isAlive) - const token = session.attachClient(opts.streamClient) - attachSessionInputViewer(session, opts.streamClient) - - const startupCommandWritten = - Boolean(opts.command) && !subprocess.startupCommandDeliveredInShellArgs - // Distinguish absent from undelivered commands; log only length because command text may contain credentials. - try { - deps.reportReadinessEvent?.('startup-command-delivery', { - sessionId: opts.sessionId, - written: startupCommandWritten, - hasCommand: Boolean(opts.command), - commandLength: opts.command?.length ?? 0, - viaShellArgs: subprocess.startupCommandDeliveredInShellArgs === true, - queuedByShellReadyBarrier: shellReadySupported - }) - } catch { - // Diagnostics must never turn a live PTY into a failed create. - } - if (startupCommandWritten && opts.command) { - staging = stageStartupCommand({ - command: opts.command, - shellPath: subprocess.shellPath, - orcaBuiltLine: opts.launchAgent !== undefined - }) - const notice = startupStagingFailureNotice(staging) - if (notice) { - session.startupIngress.accept(notice) - try { - deps.reportReadinessEvent?.('startup-command-stage-failed', { - sessionId: opts.sessionId, - reason: staging.failure - }) - } catch { - // Diagnostics must never turn a live PTY into a failed create. - } + const admitted = lease + if (published || !opts.creationWork) { + admitted?.abandon() + } else { + settleUnpublishedTerminalSpawnLease( + opts.creationWork.completion, + admitted, + sessionId, + spawnGate + ) } - // Why: only Orca-wrapped shells advertise the paste-safe startup barrier. - session.writeStartupCommand( - buildStartupCommandSubmission(staging.command, { - bracketedPasteSafe: shellReadySupported - }) - ) - } - - return { - isNew: true, - snapshot: null, - pid: subprocess.pid, - shellState: session.shellState, - incarnationId: session.incarnationId, - ...getDaemonSessionResultMetadata(session), - ...(isSessionInputClosed(session) ? { inputClosed: true as const } : {}), - ...(cwdReadableByDaemon !== null ? { cwdReadableByDaemon } : {}), - attachToken: token } } - -function createSessionExitHandler( - onSessionExit: TerminalHostSessionCreateDependencies['onSessionExit'], - sessionId: string, - generation: string | undefined, - discardStagedCommand: () => void, - spawnGate: TerminalSpawnGate | undefined -): () => void { - return () => { - discardStagedCommand() - spawnGate?.onSessionExit(sessionId) - onSessionExit(sessionId, generation) - } -} - -/** Describe restore identity without including environment values that may contain credentials. */ -function describeAgentRestore( - opts: InternalCreateOrAttachOptions -): LegacyRestoreDetails | undefined { - if (!opts.command || (!opts.launchAgent && !opts.agentSessionEnsure)) { - return undefined - } - return { - sessionId: opts.sessionId, - agent: opts.launchAgent ?? opts.agentSessionEnsure?.claim.agent ?? null, - cwd: opts.cwd ?? null, - worktreeId: opts.agentSessionEnsure?.surface.worktreeId ?? null, - command: opts.command, - identityDigest: opts.agentSessionEnsure?.claim.identityDigest ?? null - } -} - -// TCC may reject directory enumeration despite successful access(); only explicit permission denial counts. -async function isCwdReadableByThisProcess( - cwd: string, - opts: Pick -): Promise { - const outcome = await sharedPromiseSettlementWaiters(enumerateDirectoryOnce(cwd)) - .wait({ - timeoutMs: 1_000, - signal: opts.cancelSignal, - createAbortError: () => new TerminalAttachCanceledError(opts.sessionId) - }) - .catch((error: unknown) => { - if (error instanceof TerminalAttachCanceledError) { - throw error - } - return null - }) - return outcome === null ? null : outcome !== 'denied' -} diff --git a/src/main/daemon/terminal-host-session-process.ts b/src/main/daemon/terminal-host-session-process.ts new file mode 100644 index 000000000000..1a0357eb34e3 --- /dev/null +++ b/src/main/daemon/terminal-host-session-process.ts @@ -0,0 +1,60 @@ +import { + discardStagedStartupCommand, + type StartupCommandStaging +} from '../../shared/startup-command-staging' +import type { TerminalHostSessionCreateDependencies } from './terminal-host-session-create' +import { enumerateDirectoryOnce } from './directory-enumeration-probe' +import type { InternalCreateOrAttachOptions } from './terminal-host-agent-session-claim' +import type { TerminalSpawnGate } from './terminal-spawn-gate' +import type { LegacyRestoreDetails } from './legacy-restore-records' +import { TerminalAttachCanceledError } from './daemon-errors' +import { sharedPromiseSettlementWaiters } from '../../shared/promise-settlement-waiters' + +export function createSessionExitHandler( + onSessionExit: TerminalHostSessionCreateDependencies['onSessionExit'], + sessionId: string, + generation: string | undefined, + staging: { current?: StartupCommandStaging }, + spawnGate: TerminalSpawnGate | undefined +): () => void { + return () => { + discardStagedStartupCommand(staging.current) + spawnGate?.onSessionExit(sessionId) + onSessionExit(sessionId, generation) + } +} + +export function describeAgentRestore( + opts: InternalCreateOrAttachOptions +): LegacyRestoreDetails | undefined { + if (!opts.command || (!opts.launchAgent && !opts.agentSessionEnsure)) { + return undefined + } + return { + sessionId: opts.sessionId, + agent: opts.launchAgent ?? opts.agentSessionEnsure?.claim.agent ?? null, + cwd: opts.cwd ?? null, + worktreeId: opts.agentSessionEnsure?.surface.worktreeId ?? null, + command: opts.command, + identityDigest: opts.agentSessionEnsure?.claim.identityDigest ?? null + } +} + +export async function isCwdReadableByThisProcess( + cwd: string, + opts: Pick +): Promise { + const outcome = await sharedPromiseSettlementWaiters(enumerateDirectoryOnce(cwd)) + .wait({ + timeoutMs: 1_000, + signal: opts.cancelSignal, + createAbortError: () => new TerminalAttachCanceledError(opts.sessionId) + }) + .catch((error: unknown) => { + if (error instanceof TerminalAttachCanceledError) { + throw error + } + return null + }) + return outcome === null ? null : outcome !== 'denied' +} diff --git a/src/main/daemon/terminal-host-session-publish.ts b/src/main/daemon/terminal-host-session-publish.ts new file mode 100644 index 000000000000..0ad776c08a4c --- /dev/null +++ b/src/main/daemon/terminal-host-session-publish.ts @@ -0,0 +1,212 @@ +import type { PtyCreationDisposition } from '../../shared/claimed-agent-pty-owner' +import type { SubprocessHandle } from './session-subprocess-handle' +import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' +import { + stageStartupCommand, + startupStagingFailureNotice, + type StartupCommandStaging +} from '../../shared/startup-command-staging' +import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' +import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result' +import { Session } from './session' +import { + observeReturnedTerminalProcess, + recoverUnpublishedTerminalSession, + terminalSessionExitCode +} from './terminal-host-returned-process' +import { shellPathSupportsPtyStartupBarrier } from './shell-ready' +import type { InternalCreateOrAttachOptions } from './terminal-host-agent-session-claim' +import type { CreateOrAttachResult } from './terminal-host-create-contract' +import { resolveDaemonSessionScrollbackRows } from './daemon-session-scrollback-window' +import { TerminalAttachCanceledError } from './daemon-errors' +import { recordSessionSpawnPaneKey } from './terminal-host-session-listing' +import { + attachSessionInputViewer, + isSessionInputClosed, + trackSessionInput +} from './session-input-path' +import { + createSessionExitHandler, + isCwdReadableByThisProcess +} from './terminal-host-session-process' +import type { TerminalHostSessionCreateDependencies } from './terminal-host-session-create' +export async function spawnAndPublishSession( + opts: InternalCreateOrAttachOptions, + deps: TerminalHostSessionCreateDependencies, + ctx: { size: { cols: number; rows: number }; wslDistro: string | undefined }, + onPublished: (sessionId: string, isAlive: boolean) => void +): Promise { + const { size, wslDistro } = ctx + // Only macOS needs the TCC probe; other hosts must not pin extra filesystem workers. + const cwdReadableByDaemon = + process.platform === 'darwin' && opts.cwd && !wslDistro + ? await isCwdReadableByThisProcess(opts.cwd, opts) + : null + deps.assertCreateAllowed() + const lower = Promise.withResolvers() + opts.creationWork?.join(lower.promise) + let lowerReported = false + let physical: ReturnType + let subprocess: SubprocessHandle + try { + subprocess = await deps.spawnSubprocess({ + onCreationDisposition: (disposition) => { + lowerReported = true + lower.resolve(disposition) + }, + sessionId: opts.sessionId, + cols: size.cols, + rows: size.rows, + cwd: opts.cwd, + env: opts.env, + envToDelete: opts.envToDelete, + command: opts.command, + startupCommandDelivery: opts.startupCommandDelivery, + ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + shellOverride: opts.shellOverride, + terminalShellArgs: opts.terminalShellArgs, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro, + terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation, + isCanceled: opts.isCanceled, + assertCreateAllowed: deps.assertCreateAllowed, + ...(opts.cancelSignal ? { cancelSignal: opts.cancelSignal } : {}) + }) + // External implementations returning a handle have transferred an actual resource. + physical = observeReturnedTerminalProcess(subprocess, opts.sessionId, deps.spawnGate) + if (!lowerReported) { + lower.resolve({ + kind: 'physical', + completion: physical.completion, + exitCode: physical.exitCode, + recover: physical.recover + }) + } + physical.observeExit() + opts.onPhysicalOwner?.(physical.completion, physical.exitCode) + onPublished(opts.sessionId, physical.exitCode() === null) + } catch (error) { + if (!lowerReported) { + lower.resolve({ kind: 'unknown' }) + } + throw error + } + + const staging: { current?: StartupCommandStaging } = {} + // Reconcile fallback-shell capability or its first command could remain queued until timeout. + const shellReadySupported = + (opts.shellReadySupported ?? false) && + (subprocess.shellPath === undefined || shellPathSupportsPtyStartupBarrier(subprocess.shellPath)) + const session = new Session({ + sessionId: opts.sessionId, + cols: size.cols, + rows: size.rows, + terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, + launchAgent: opts.launchAgent, + subprocess, + ownerBackend: resolvePtyOwnerBackend({ + platform: process.platform, + shellPath: subprocess.shellPath, + wslDistro + }), + shellReadySupported, + scrollback: resolveDaemonSessionScrollbackRows(), + historySeedChunks: opts.historySeedChunks, + ...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}), + wslDistro, + onExit: createSessionExitHandler( + deps.onSessionExit, + opts.sessionId, + opts.agentSessionGeneration, + staging, + deps.spawnGate + ), + ...(deps.reportReadinessEvent ? { reportReadinessEvent: deps.reportReadinessEvent } : {}), + ...(opts.shellReadyTimeoutMs !== undefined + ? { shellReadyTimeoutMs: opts.shellReadyTimeoutMs } + : {}) + }) + physical.useRecovery(recoverUnpublishedTerminalSession(session)) + opts.onPhysicalOwner?.(session.physicalCompletion, terminalSessionExitCode(session)) + recordSessionSpawnPaneKey(session, opts.env?.ORCA_PANE_KEY) + + try { + trackSessionInput(session, subprocess, deps.reportReadinessEvent) + } catch (error) { + void session.physicalCompletion.then(session.dispose.bind(session)) + throw error + } + + if (opts.isCanceled?.()) { + // Retain cleanup ownership if the native child refuses to exit. + deps.sessions.set(opts.sessionId, session) + // Retain the live-cap charge until physical exit. + onPublished(opts.sessionId, session.isAlive) + await session.forceKillAndWaitForExit().catch(() => {}) + if (!session.isAlive && deps.sessions.get(opts.sessionId) === session) { + session.dispose() + deps.sessions.delete(opts.sessionId) + deps.onDeadSessionRemoved(opts.sessionId) + deps.spawnGate?.onSessionExit(opts.sessionId) + } + throw new TerminalAttachCanceledError(opts.sessionId) + } + + deps.sessions.set(opts.sessionId, session) + deps.onSessionCreated(opts.sessionId, opts.agentSessionGeneration, session.isAlive) + onPublished(opts.sessionId, session.isAlive) + const token = session.attachClient(opts.streamClient) + attachSessionInputViewer(session, opts.streamClient) + + const startupCommandWritten = + Boolean(opts.command) && !subprocess.startupCommandDeliveredInShellArgs + // Distinguish absent from undelivered commands; log only length because command text may contain credentials. + try { + deps.reportReadinessEvent?.('startup-command-delivery', { + sessionId: opts.sessionId, + written: startupCommandWritten, + hasCommand: Boolean(opts.command), + commandLength: opts.command?.length ?? 0, + viaShellArgs: subprocess.startupCommandDeliveredInShellArgs === true, + queuedByShellReadyBarrier: shellReadySupported + }) + } catch { + // Diagnostics must never turn a live PTY into a failed create. + } + if (startupCommandWritten && opts.command) { + staging.current = stageStartupCommand({ + command: opts.command, + shellPath: subprocess.shellPath, + orcaBuiltLine: opts.launchAgent !== undefined + }) + const notice = startupStagingFailureNotice(staging.current) + if (notice) { + session.startupIngress.accept(notice) + try { + deps.reportReadinessEvent?.('startup-command-stage-failed', { + sessionId: opts.sessionId, + reason: staging.current.failure + }) + } catch { + // Diagnostics must never turn a live PTY into a failed create. + } + } + // Why: only Orca-wrapped shells advertise the paste-safe startup barrier. + session.writeStartupCommand( + buildStartupCommandSubmission(staging.current.command, { + bracketedPasteSafe: shellReadySupported + }) + ) + } + + return { + isNew: true, + snapshot: null, + pid: subprocess.pid, + shellState: session.shellState, + incarnationId: session.incarnationId, + ...getDaemonSessionResultMetadata(session), + ...(isSessionInputClosed(session) ? { inputClosed: true as const } : {}), + ...(cwdReadableByDaemon !== null ? { cwdReadableByDaemon } : {}), + attachToken: token + } +} diff --git a/src/main/daemon/terminal-host-session-shutdown.ts b/src/main/daemon/terminal-host-session-shutdown.ts index d043ea1d7c5f..384739ee48e8 100644 --- a/src/main/daemon/terminal-host-session-shutdown.ts +++ b/src/main/daemon/terminal-host-session-shutdown.ts @@ -8,15 +8,29 @@ async function disposeLiveSession(session: Session): Promise { await session.forceKillAndDisposeSubprocess() return } + await shutdownOwnedTerminalProcess({ + pid: session.pid, + isAlive: () => session.isAlive, + terminateOwnedTree: () => session.terminateOwnedTree(), + close: () => session.forceKillAndDisposeSubprocess() + }) +} + +export async function shutdownOwnedTerminalProcess(owner: { + pid: number + isAlive: () => boolean + terminateOwnedTree: Session['terminateOwnedTree'] + close: () => Promise +}): Promise { try { - await killWithDescendantSweep(session.pid, () => {}, { - ownsRoot: () => session.isAlive, - terminateOwnedTree: () => session.terminateOwnedTree(), + await killWithDescendantSweep(owner.pid, () => {}, { + ownsRoot: owner.isAlive, + terminateOwnedTree: owner.terminateOwnedTree, terminateDescendants: terminateShutdownDescendants, awaitEscalation: true }) } finally { - await session.forceKillAndDisposeSubprocess() + await owner.close() } } diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index c5fbe6db155a..1243ff32de96 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -1,3 +1,5 @@ +import { terminalSessionExitCode } from './terminal-host-returned-process' +import { runTerminalCreation, type PendingTerminalCreation } from './terminal-creation-work' import { beginTerminalHostFencedTermination } from './terminal-host-fenced-termination' import type { PtyFencedTerminationRequest } from '../../shared/pty-fenced-termination' import type { Session } from './session' @@ -22,7 +24,6 @@ import { } from './terminal-host-session-listing' import { createOrAttachTerminalSession } from './terminal-host-session-create' import { TerminalAttachCanceledError } from './daemon-errors' -import { waitForTerminalAttachOperation } from './terminal-attach-cancellation' import { randomUUID } from 'node:crypto' import { inspectTerminalHostSession } from './terminal-host-process-inspection' import type { TerminalHostProcessInspection } from './terminal-host-process-inspection' @@ -45,7 +46,7 @@ const DEFAULT_MAX_TOMBSTONES = 1000 export class TerminalHost { private sessions = new Map() // Serializes creates for one id across async spawn validation. - private pendingCreations = new Map>() + private pendingCreations = new Map() private sessionTeardown = new TerminalSessionTeardown(this.sessions) private killedTombstones: TerminalHostTombstones private readonly spawnOptions: Pick @@ -71,64 +72,50 @@ export class TerminalHost { this.killedTombstones = new TerminalHostTombstones(opts.maxTombstones ?? DEFAULT_MAX_TOMBSTONES) } - async createOrAttach(opts: InternalCreateOrAttachOptions): Promise { - this.assertCreateOrAttachAllowed(opts) - for ( - let inFlight = this.pendingCreations.get(opts.sessionId); - inFlight !== undefined; - inFlight = this.pendingCreations.get(opts.sessionId) - ) { - // Let canceled callers leave rather than wait behind a cwd probe stuck on an unreachable share. - await waitForTerminalAttachOperation(inFlight, opts.cancelSignal, opts.sessionId) - this.assertCreateOrAttachAllowed(opts) - } - this.assertCreateOrAttachAllowed(opts) - - let settleCreation: () => void = () => {} - this.pendingCreations.set( - opts.sessionId, - new Promise((resolve) => { - settleCreation = resolve - }) - ) - try { - return await createOrAttachClaimedAgentSession({ - options: opts, - owners: this.agentSessionOwners, - isLive: (owner) => - this.agentSessionGenerations.isCurrent( - owner, - Boolean( - this.sessions.get(owner.ptyId)?.isAlive && - !this.sessions.get(owner.ptyId)?.isTerminating + createOrAttach(opts: InternalCreateOrAttachOptions): Promise { + return runTerminalCreation( + opts, + this.sessions, + this.pendingCreations, + (options) => this.assertCreateOrAttachAllowed(options), + (opts, creation) => + createOrAttachClaimedAgentSession({ + options: opts, + owners: this.agentSessionOwners, + isLive: (owner) => { + const session = this.sessions.get(owner.ptyId) + const live = this.agentSessionGenerations.isCurrent( + owner, + Boolean(session?.isAlive && !session.isTerminating) ) - ), - createOrAttach: async (options) => { - this.assertCreateOrAttachAllowed(options) - if (options.agentSessionGeneration && this.sessions.get(options.sessionId)?.isAlive) { - throw new Error('agent_session_claim_unavailable') + if (live && session) { + creation.owner(session.physicalCompletion, terminalSessionExitCode(session)) + } + return live + }, + createOrAttach: async (options) => { + this.assertCreateOrAttachAllowed(options) + if (options.agentSessionGeneration && this.sessions.get(options.sessionId)?.isAlive) { + throw new Error('agent_session_claim_unavailable') + } + return await createOrAttachTerminalSession(options, { + sessions: this.sessions, + assertCreateAllowed: () => this.assertCreateOrAttachAllowed(options), + sessionTeardown: this.sessionTeardown, + killedTombstones: this.killedTombstones, + spawnSubprocess: this.spawnOptions.spawnSubprocess, + spawnGate: this.spawnOptions.spawnGate, + onDeadSessionRemoved: (sessionId) => this.agentSessionGenerations.forget(sessionId), + onSessionCreated: (sessionId, generation, isAlive) => + this.agentSessionGenerations.remember(sessionId, generation, isAlive), + ...(this.reportReadinessEvent + ? { reportReadinessEvent: this.reportReadinessEvent } + : {}), + onSessionExit: this.handleSessionExit.bind(this) + }) } - return await createOrAttachTerminalSession(options, { - sessions: this.sessions, - assertCreateAllowed: () => this.assertCreateOrAttachAllowed(options), - sessionTeardown: this.sessionTeardown, - killedTombstones: this.killedTombstones, - spawnSubprocess: this.spawnOptions.spawnSubprocess, - spawnGate: this.spawnOptions.spawnGate, - onDeadSessionRemoved: (sessionId) => this.agentSessionGenerations.forget(sessionId), - onSessionCreated: (sessionId, generation, isAlive) => - this.agentSessionGenerations.remember(sessionId, generation, isAlive), - ...(this.reportReadinessEvent - ? { reportReadinessEvent: this.reportReadinessEvent } - : {}), - onSessionExit: this.handleSessionExit.bind(this) - }) - } - }) - } finally { - this.pendingCreations.delete(opts.sessionId) - settleCreation() - } + }) + ) } private handleSessionExit(sessionId: string, generation: string | undefined): void { diff --git a/src/main/daemon/terminal-spawn-gate.test.ts b/src/main/daemon/terminal-spawn-gate.test.ts index 3da9de6af159..f77c331746b2 100644 --- a/src/main/daemon/terminal-spawn-gate.test.ts +++ b/src/main/daemon/terminal-spawn-gate.test.ts @@ -153,7 +153,8 @@ describe('TerminalSpawnGate', () => { expect(gate.snapshot()).toMatchObject({ liveAutomatic: 0, inFlight: { interactive: 0 } }) expect(log).not.toHaveBeenCalled() - spawnSubprocess.mockImplementationOnce(() => { + spawnSubprocess.mockImplementationOnce((options) => { + options.onCreationDisposition?.({ kind: 'no-fork' }) throw new Error('spawn failed') }) await expect(restore('restore-fails')).rejects.toThrow('spawn failed') @@ -269,7 +270,8 @@ describe('TerminalSpawnGate', () => { it('frees the slot of a fork that failed', async () => { capAt(1) - spawnSubprocess.mockImplementationOnce(() => { + spawnSubprocess.mockImplementationOnce((options) => { + options.onCreationDisposition?.({ kind: 'no-fork' }) throw new Error('spawn failed') }) await expect(shell('a')).rejects.toThrow('spawn failed') diff --git a/src/main/native-chat/agent-session-journal/adopted-transcript-source.ts b/src/main/native-chat/agent-session-journal/adopted-transcript-source.ts index bd80e4d520e3..4a62879c6518 100644 --- a/src/main/native-chat/agent-session-journal/adopted-transcript-source.ts +++ b/src/main/native-chat/agent-session-journal/adopted-transcript-source.ts @@ -9,7 +9,12 @@ import { samePiDirectory } from '../../pi/rpc-launch-resolution' import { admitTranscriptPath } from '../transcript-path-admission' import { adoptedDirectoryProbes } from './adopted-directory-probe' -export type AdoptedTranscriptSource = { providerSessionId: string; workspaceCwd: string } +export type AdoptedTranscriptSource = { + providerSessionId: string + workspaceCwd: string + /** Host-selected account store, never supplied by a wire caller or persisted. */ + conversationStore?: string +} export async function assertAdoptedSourceIdentity( source: AdoptedTranscriptSource, diff --git a/src/main/native-chat/agent-session-journal/codex-history-test-fixture.ts b/src/main/native-chat/agent-session-journal/codex-history-test-fixture.ts new file mode 100644 index 000000000000..783daff93704 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/codex-history-test-fixture.ts @@ -0,0 +1,113 @@ +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +export type CodexHistoryPositionFixture = { + thread_id: string + end_ordinal_exclusive: number + end_byte_offset: number +} + +export async function writeCodexHistoryFixture( + root: string, + id: string, + prompts: string[], + base?: CodexHistoryPositionFixture, + metadataId = id +) { + const records = [ + { + type: 'session_meta', + payload: { + id: metadataId, + history_mode: 'paginated', + ...(base ? { history_base: base } : {}) + } + }, + ...prompts.flatMap((text, turn) => [ + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + id: `response-user-${turn}`, + content: [{ type: 'input_text', text }] + } + }, + { + type: 'event_msg', + payload: { + type: 'item_completed', + thread_id: id, + turn_id: `turn-${turn}`, + completed_at_ms: 1, + item: { type: 'UserMessage', id: `user-${turn}`, content: [{ type: 'text', text }] } + } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + id: `answer-${turn}`, + content: [{ type: 'output_text', text: `${text} answer` }] + } + }, + { + type: 'event_msg', + payload: { + type: 'item_completed', + thread_id: id, + turn_id: `turn-${turn}`, + completed_at_ms: 1, + item: { + type: 'AgentMessage', + id: `answer-${turn}`, + content: [{ type: 'Text', text: `${text} answer` }] + } + } + } + ]) + ].map((record, index) => ({ + ...record, + timestamp: '2026-10-27T00:00:00.000Z', + ordinal: (base?.end_ordinal_exclusive ?? 0) + index + })) + const serialized = `${records.map((record) => JSON.stringify(record)).join('\n')}\n` + const ids = metadataId === id ? id : `${metadataId}_${id}` + const filePath = join(root, `rollout-2026-10-27T00-00-00-${ids}.jsonl`) + await writeFile(filePath, serialized) + return { + filePath, + records, + position: { + thread_id: id, + end_ordinal_exclusive: (base?.end_ordinal_exclusive ?? 0) + records.length, + end_byte_offset: Buffer.byteLength(serialized) + } + } +} + +export function codexLegacyMirrorRecords() { + return [ + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'Prompt' }] + } + }, + { type: 'event_msg', payload: { type: 'user_message', message: 'Prompt' } }, + { type: 'compacted', payload: { message: 'summary', replacement_history: [] } }, + { type: 'event_msg', payload: { type: 'context_compacted' } }, + { type: 'event_msg', payload: { type: 'agent_message', message: 'Response' } }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'Response' }] + } + } + ] +} diff --git a/src/main/native-chat/agent-session-journal/journal-codex-history.test.ts b/src/main/native-chat/agent-session-journal/journal-codex-history.test.ts new file mode 100644 index 000000000000..fac0bc529b64 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-codex-history.test.ts @@ -0,0 +1,72 @@ +import { appendFile, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { zstdCompressSync } from 'node:zlib' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { writeCodexHistoryFixture } from './codex-history-test-fixture' +import { prepareLegacyTranscriptImport } from './journal-legacy-import' + +const PARENT = '01a17c7f-6f58-7a73-b87a-a8702ff2da17' +const CHILD = '01a17c81-30d1-72f0-b84e-002ddfe6fd47' +const MIDDLE = '01a17c80-30d1-72f0-b84e-002ddfe6fd47' +let root: string +beforeEach(async () => { + const account = await mkdtemp(join(tmpdir(), 'orca-codex-lineage-')) + root = join(account, 'sessions') + await mkdir(root) + await mkdir(join(account, 'archived_sessions')) +}) +afterEach(async () => { + await rm(dirname(root), { recursive: true, force: true }) +}) + +function request(filePath: string) { + return { + agent: 'codex' as const, + sessionId: CHILD, + options: { + filePath, + codexSessionsDirs: [root], + source: { providerSessionId: CHILD, workspaceCwd: '/workspace' } + } + } +} + +it.each(['active', 'archive', 'compressed'] as const)( + 'imports %s ancestor prefixes oldest first and excludes later turns', + async (location) => { + const parent = await writeCodexHistoryFixture(root, PARENT, ['sky'], undefined, MIDDLE) + const middle = await writeCodexHistoryFixture(root, MIDDLE, ['leaves'], parent.position) + const child = await writeCodexHistoryFixture(root, CHILD, ['ice'], middle.position) + const later = await writeCodexHistoryFixture(root, '01a17c82-30d1-72f0-b84e-002ddfe6fd47', [ + 'S1' + ]) + await appendFile( + parent.filePath, + `${later.records + .slice(1) + .map((record, index) => + JSON.stringify({ ...record, ordinal: parent.position.end_ordinal_exclusive + index }) + ) + .join('\n')}\n` + ) + if (location !== 'active') { + const archived = join(dirname(root), 'archived_sessions', basename(parent.filePath)) + if (location === 'compressed') { + await writeFile(`${archived}.zst`, zstdCompressSync(await readFile(parent.filePath))) + await rm(parent.filePath) + } else { + await rename(parent.filePath, archived) + } + } + const result = await prepareLegacyTranscriptImport(request(child.filePath)) + expect(result.ok && result.items.map((item) => item.body)).toMatchObject([ + { blocks: [{ text: 'sky' }] }, + { blocks: [{ text: 'sky answer' }] }, + { blocks: [{ text: 'leaves' }] }, + { blocks: [{ text: 'leaves answer' }] }, + { blocks: [{ text: 'ice' }] }, + { blocks: [{ text: 'ice answer' }] } + ]) + } +) diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-identity.ts b/src/main/native-chat/agent-session-journal/journal-legacy-identity.ts index 92b9aba86a80..dde940448701 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-identity.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-identity.ts @@ -23,7 +23,7 @@ export function createLegacyIdentityTracker(input: { provider: 'legacy', agent: input.agent, sessionId: input.sessionId, - recordId: legacyRecordId(line, index) + recordId: legacyRecordId(line, index, input.transcriptAgent === 'codex') }) } } @@ -44,10 +44,16 @@ function claudeIdentity( } /** Line position is stable only within a given import; prefer stored IDs when available. */ -function legacyRecordId(line: string, lineIndex: number): string { +function legacyRecordId(line: string, lineIndex: number, nestedItem: boolean): string { const record = parseRecord(line) const payload = record?.payload - const id = stringField(payload, 'id') ?? stringField(record, 'id') ?? stringField(record, 'uuid') + const item = + nestedItem && payload && typeof payload === 'object' && 'item' in payload ? payload.item : null + const id = + stringField(item, 'id') ?? + stringField(payload, 'id') ?? + stringField(record, 'id') ?? + stringField(record, 'uuid') return id ?? `#${lineIndex}` } diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import-record-bound.test.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import-record-bound.test.ts index 515429eea1e6..6142c8f41c3a 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import-record-bound.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import-record-bound.test.ts @@ -18,7 +18,7 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) -describe('legacy import record byte bound', () => { +describe.each(['claude', 'codex'] as const)('record byte bound: %s', (agent) => { it('refuses a single line past the limit and closes the stream', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-legacy-import-bound-')) roots.push(root) @@ -26,7 +26,7 @@ describe('legacy import record byte bound', () => { await writeFile(filePath, ' '.repeat(RECORD_LIMIT_BYTES + 1)) const result = await prepareLegacyTranscriptImport({ - agent: 'claude', + agent, sessionId: 'record-bound', options: { filePath } }) diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import-tool-pairing.test.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import-tool-pairing.test.ts index ac5eb4363fba..7bc0662e6039 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import-tool-pairing.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import-tool-pairing.test.ts @@ -286,6 +286,7 @@ describe('legacy import tool identity', () => { ])('pairs Codex singleton %s and %s import records', async (callType, outputType) => { const { pairs } = await importRows( [ + { type: 'session_meta', payload: { history_mode: 'paginated' } }, { type: 'response_item', payload: { diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts index 23df06f5e1c9..88805af9b955 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -5,7 +5,6 @@ import { validateAdoptedOpenCodeSource, type AdoptedTranscriptSource } from './adopted-transcript-source' -import { createReadStream } from 'node:fs' import type { AgentType } from '../../../shared/agent-status-types' import type { AgentJournalCursor, @@ -21,7 +20,7 @@ import { decodeGrokTranscriptLine, decodeOmpTranscriptLine } from '../transcript-line-decoders' -import { decodeTranscriptStream } from '../transcript-stream-lines' +import { readLegacyImportTranscript } from './journal-legacy-transcript-stream' import { readOpenCodeNativeChatTranscriptFull } from '../transcript-opencode' import { boundSubagentEntryId } from '../subagent-entry-id-bounds' import { createLegacyIdentityTracker } from './journal-legacy-identity' @@ -112,6 +111,9 @@ export async function prepareLegacyTranscriptImport(input: { agent: input.agent, sessionId: input.sessionId, source: options.source, + codexSessionsDirs: options.source?.conversationStore + ? [options.source.conversationStore] + : options.codexSessionsDirs, signal: options.signal, decodedMessageIdentities: options.decodedMessageIdentities, limits @@ -188,7 +190,8 @@ function replacementItem( const TRANSCRIPT_DECODERS = { claude: decodeClaudeTranscriptLine, - codex: decodeCodexTranscriptLine, + codex: (line: string, fallbackId: string) => + decodeCodexTranscriptLine(line, fallbackId, { historyImport: true }), grok: decodeGrokTranscriptLine, omp: decodeOmpTranscriptLine } as const @@ -201,6 +204,7 @@ async function decodeReplacementItems(input: { sessionId: string decodedMessageIdentities?: true source?: AdoptedTranscriptSource + codexSessionsDirs?: string[] signal?: AbortSignal limits: JournalPayloadLimits }): Promise<{ items: JournalReplacementItem[]; consumedBytes: number }> { @@ -218,7 +222,6 @@ async function decodeReplacementItems(input: { agent: input.agent, sessionId: input.sessionId }) - const decode = TRANSCRIPT_DECODERS[input.transcriptAgent] const toItem = (message: NativeChatMessage, identity: AgentJournalItemIdentity) => replacementItem(message, identity, input.limits) const retained = createNewestItemWindow(MAX_LEGACY_IMPORT_RETAINED_BYTES) @@ -228,38 +231,39 @@ async function decodeReplacementItems(input: { : null let lineIndex = 0 try { - const { consumedBytes } = await decodeTranscriptStream( - createReadStream(input.filePath), - input.filePath, - 0, - (line, fallbackId) => { - source?.observe(line) - const currentLineIndex = lineIndex - lineIndex += 1 + const { consumedBytes } = await readLegacyImportTranscript( + { + filePath: input.filePath, + codex: input.transcriptAgent === 'codex', + roots: input.codexSessionsDirs + }, + (line, fallbackId, recordId, selected = true) => { + if (selected) { + source?.observe(line) + } + const currentLineIndex = lineIndex++ if (pi) { pi.record(line, fallbackId, currentLineIndex) return null } - const message = decode(line, fallbackId) + const message = TRANSCRIPT_DECODERS[input.transcriptAgent](line, fallbackId) if (message) { retained.push( toItem( message, - input.decodedMessageIdentities + recordId || input.decodedMessageIdentities ? { provider: 'legacy', agent: input.agent, sessionId: input.sessionId, - recordId: message.id + recordId: recordId ?? message.id } : tracker.identify(line, currentLineIndex) ) ) } return null - }, - true, - MAX_LEGACY_IMPORT_RECORD_BYTES + } ) if (consumedBytes > 0) { await source?.validate() diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-transcript-stream.ts b/src/main/native-chat/agent-session-journal/journal-legacy-transcript-stream.ts new file mode 100644 index 000000000000..760a75df2383 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-legacy-transcript-stream.ts @@ -0,0 +1,25 @@ +import { createReadStream } from 'node:fs' +import { decodeTranscriptStream } from '../transcript-stream-lines' +import { readCodexLocalHistory } from '../codex-local-history' + +export async function readLegacyImportTranscript( + input: { filePath: string; codex: boolean; roots?: readonly string[] }, + visit: (line: string, fallbackId: string, recordId?: string, selected?: boolean) => null +): Promise<{ consumedBytes: number }> { + return input.codex + ? { + consumedBytes: await readCodexLocalHistory({ + ...input, + visit: (line, fallbackId, selected, recordId) => + visit(line, fallbackId, recordId, selected) + }) + } + : decodeTranscriptStream( + createReadStream(input.filePath), + input.filePath, + 0, + visit, + true, + 16 * 1024 * 1024 + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 57a7f56ba97b..6eca137c164d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -1,5 +1,4 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' - import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -16,6 +15,7 @@ import { openTestAttachConversation } from './structured-agent-session-attach-te import { performAttach, type AttachFlowInput } from './structured-agent-session-attach-flow' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import * as legacyImport from '../agent-session-journal/journal-legacy-import' +import { writeCodexHistoryFixture } from '../agent-session-journal/codex-history-test-fixture' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -97,6 +97,11 @@ function attachParams( runtimeKind: 'native', adopt: { providerHandle: codexProviderHandle(THREAD), + source: { + providerSessionId: THREAD, + workspaceCwd: '/workspace', + conversationStore: root ?? undefined + }, ...(transcriptPath ? { transcriptPath } : {}) }, ...overrides @@ -296,7 +301,7 @@ describe('adopting a provider conversation on create', () => { expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) }) - it.each(['missing', 'oversized', 'empty', 'invalid', 'source-less'] as const)( + it.each(['missing', 'oversized', 'empty', 'metadata-only', 'invalid', 'source-less'] as const)( 'refuses %s source before claiming a conversation', async (kind) => { root = await mkdtemp(join(tmpdir(), 'orca-adopt-preflight-')) @@ -304,6 +309,11 @@ describe('adopting a provider conversation on create', () => { if (kind === 'oversized') { await writeCodexRollout(transcriptPath, 'original turn') await appendFile(transcriptPath, 'x'.repeat(16 * 1024 * 1024 + 1)) + } else if (kind === 'metadata-only') { + await writeFile( + transcriptPath, + `${JSON.stringify({ type: 'session_meta', payload: { id: THREAD, cwd: '/workspace' } })}\n` + ) } else if (kind === 'empty' || kind === 'invalid') { await writeFile(transcriptPath, kind === 'empty' ? '' : 'not json\n') } @@ -388,14 +398,23 @@ describe('adopting a provider conversation on create', () => { it('prepares a valid source once before acquisition and imports those exact items', async () => { root = await mkdtemp(join(tmpdir(), 'orca-adopt-once-')) - const transcriptPath = join(root, 'rollout.jsonl') - await writeCodexRollout(transcriptPath, 'prepared before acquiring') + const parent = await writeCodexHistoryFixture(root, '01a17c7f-6f58-7a73-b87a-a8702ff2da17', [ + 'sky' + ]) + const { filePath: transcriptPath } = await writeCodexHistoryFixture( + root, + THREAD, + ['prepared before acquiring'], + parent.position + ) + await appendFile(transcriptPath, '{"ordinal":') const prepare = vi.spyOn(legacyImport, 'prepareLegacyTranscriptImport') const sessionAdapter = adapter() const acquire = sessionAdapter.acquire sessionAdapter.acquire = vi.fn(async (input) => { expect(prepare).toHaveBeenCalledTimes(1) await rm(transcriptPath) + await rm(parent.filePath) return acquire(input) }) const result = await attach(transcriptPath, sessionAdapter, async ({ journal }) => @@ -406,6 +425,9 @@ describe('adopting a provider conversation on create', () => { throw new Error('attach failed') } expect(JSON.stringify(result.value.page.items)).toContain('prepared before acquiring') + expect(JSON.stringify(result.value.page.items)).toContain('sky answer') + expect(result.value.page.items).toHaveLength(4) + expect(sessionAdapter.dispatch).not.toHaveBeenCalled() expect(prepare).toHaveBeenCalledTimes(1) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 79cb8c859c5c..8c868adf3119 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -36,6 +36,7 @@ import { } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { AdoptedTranscriptSource } from '../agent-session-journal/adopted-transcript-source' import { structuredAgentSessionRefusalMessage } from './structured-agent-session-refusal-message' /** Clients cannot assert spawn tokens, claim keys or owner death; only host observations grant authority. */ @@ -59,7 +60,7 @@ export type AgentSessionAttachParams = { providerHandle: AgentSessionProviderHandle /** Omit only for exact replay of an already-imported journal. */ transcriptPath?: string - source?: { providerSessionId: string; workspaceCwd: string } + source?: AdoptedTranscriptSource } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts index e0c010eb1795..bc074176eac7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts @@ -1,4 +1,4 @@ -import { writeFile, rm, mkdir, realpath, symlink, readFile } from 'node:fs/promises' +import { mkdir, realpath, rename, writeFile, rm, symlink, readFile } from 'node:fs/promises' import { join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { @@ -41,6 +41,17 @@ vi.mock('../../ai-vault/session-scanner-opencode-sqlite-worker-spawn', async (or } }) +import { + codexLegacyMirrorRecords, + writeCodexHistoryFixture +} from '../agent-session-journal/codex-history-test-fixture' +import { structuredAgentRuntimeMetadata } from '../../runtime/structured-agent-runtime-metadata' +import { + resolveStructuredAgentSessionAdoptionForCreate, + structuredAgentSessionAdoptAttach +} from '../../runtime/structured-agent-session-create-adoption' +import { prepareCodexAiVaultSessionResume } from '../../codex/codex-ai-vault-session-resume' + let rig: RestTestRig beforeEach(async () => { rig = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } }) @@ -48,6 +59,7 @@ beforeEach(async () => { afterEach(async () => { await rig.dispose() vi.restoreAllMocks() + vi.unstubAllEnvs() }) async function adoptedParams(history = 'adopted history', threadId = THREAD) { @@ -82,6 +94,132 @@ function anotherSession(params: AgentSessionAttachParams): AgentSessionAttachPar }) } +it.each([ + ['matching', THREAD], + ['legacy', THREAD], + ['mismatched', '019fd532-7c11-7a90-b6de-4e1a2c3d5f61'] +] as const)( + 'resolves a selected-account rollout with %s metadata before at-rest publication', + async (_kind, metadataId) => { + vi.stubEnv('ORCA_USER_DATA_PATH', join(rig.root, 'host-data')) + const accountHome = { variable: 'CODEX_HOME', path: join(rig.root, 'selected-account') } + const sessions = join(accountHome.path, 'sessions') + await mkdir(sessions, { recursive: true }) + const rollout = await writeCodexHistoryFixture( + sessions, + THREAD, + ['saved CLI turn'], + undefined, + metadataId + ) + if (_kind === 'legacy') { + await writeFile( + rollout.filePath, + [ + { type: 'session_meta', payload: { id: THREAD, cwd: rig.root } }, + ...codexLegacyMirrorRecords() + ] + .map((record) => JSON.stringify(record)) + .join('\n') + ) + } + if (metadataId !== THREAD) { + // Keep filename lookup valid so the imported metadata is what refuses the mismatched source. + await rename(rollout.filePath, join(sessions, `rollout-2026-10-27T00-00-00-${THREAD}.jsonl`)) + } + const metadata = structuredAgentRuntimeMetadata('codex') + if (!metadata?.transcriptImport) { + throw new Error('Codex transcript importer is not registered') + } + const adoption = await resolveStructuredAgentSessionAdoptionForCreate({ + settings: {}, + agent: 'codex', + transcriptImport: metadata.transcriptImport, + providerSessionId: THREAD, + selectedAccountHome: accountHome, + launchEnv: {}, + workspaceCwd: rig.root, + prepareCodexResume: (args) => + prepareCodexAiVaultSessionResume(args, { + runtimeHome: null, + systemCodexHomePath: join(rig.root, 'system-account'), + preparePinnedLaunchHome: async () => { + throw new Error('At-rest adoption must not ready a fork launch') + } + }) + }) + expect(adoption.accountHome).toEqual(accountHome) + expect(adoption.transcriptPath).toBe( + await realpath(join(sessions, `rollout-2026-10-27T00-00-00-${THREAD}.jsonl`)) + ) + const result = await rig.host.create( + CALLER, + createTestParams(undefined, { + accountHome: adoption.accountHome, + adopt: structuredAgentSessionAdoptAttach({ + definition: metadata.definition, + handleNamesTranscript: metadata.transcriptImport.handleNamesTranscript, + conversationStore: + metadata.transcriptImport.conversationStore({ + accountHome: adoption.accountHome, + launchEnv: {} + }) ?? undefined, + transcriptPath: adoption.transcriptPath, + providerSessionId: THREAD, + workspaceCwd: rig.root + }) + }) + ) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.dispatch).not.toHaveBeenCalled() + if (metadataId !== THREAD) { + expect(result).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_identity_required', + details: { reason: 'transcriptUnreadable' }, + createAttemptRefusedBeforeCommit: true + } + }) + expect(rig.store.listRecords()).toEqual([]) + expect(rig.store.listOperationRows()).toEqual([]) + expect(rig.store.listVisibleSessionIds()).toEqual([]) + return + } + expect(result.ok).toBe(true) + if (!result.ok) { + throw new Error('Real selected-account adoption failed') + } + const snapshot = await rig.host.journalSnapshot(SESSION) + expect(snapshot.items.map(({ body }) => body)).toEqual( + _kind === 'legacy' + ? [ + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Prompt' }] }, + { + kind: 'message', + role: 'system', + blocks: [{ type: 'text', text: 'Context compacted', presentation: 'compaction' }] + }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Response' }] } + ] + : [ + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'saved CLI turn' }] }, + { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'saved CLI turn answer' }] + } + ] + ) + expect(result.value.page.items).toEqual(snapshot.items) + expect(rig.store.getRecord(SESSION)?.accountHome).toEqual(accountHome) + expect(rig.store.getRecord(SESSION)?.providerHandleChain).toMatchObject([ + { origin: 'adopted', mintedAtFence: 1, handle: { nativeId: THREAD } } + ]) + expect(rig.store.listVisibleSessionIds()).toEqual([SESSION]) + } +) + it.each([100, 1000])( 'imports %s source rows into a linked workspace with one fresh probe and zero replay work', async (size) => { diff --git a/src/main/native-chat/codex-local-history.ts b/src/main/native-chat/codex-local-history.ts new file mode 100644 index 000000000000..567d56af7ba0 --- /dev/null +++ b/src/main/native-chat/codex-local-history.ts @@ -0,0 +1,154 @@ +import { createHash } from 'node:crypto' +import { asRecord, extractString, parseJsonObject } from '../ai-vault/session-scanner-values' +import { sessionIdFromFileName } from '../ai-vault/session-scanner-accumulator' +import { codexHistorySources, codexPhysicalRolloutId } from './codex-rollout-history-source' +import { readCodexRolloutBytes } from './codex-rollout-history-stream' +import { splitTranscriptStreamLines } from './transcript-stream-lines' +import { isCodexVisibleHistoryRecord } from './transcript-line-decoders-codex' +import { transcriptFallbackId } from './transcript-fallback-id' + +type HistoryPosition = { + thread_id: string + end_ordinal_exclusive: number + end_byte_offset: number +} + +type HistorySegment = { + path: string + rolloutId: string + headerDigest: string | null + paginated: boolean + start: number + end?: HistoryPosition +} + +/** Resolve physical prefixes only; logical fork parents can describe standalone copied files. */ +export async function readCodexLocalHistory(input: { + filePath: string + roots?: readonly string[] + visit: (line: string, fallbackId: string, selected: boolean, recordId?: string) => void +}): Promise { + const sources = await codexHistorySources(input.filePath, input.roots) + const seen = new Set() + const segments: HistorySegment[] = [] + let path = sources.path + let end: HistoryPosition | undefined + for (;;) { + const rolloutId = codexPhysicalRolloutId(path) ?? sessionIdFromFileName(path) + if (seen.has(path) || seen.has(rolloutId)) { + throw new Error('The conversation history lineage is invalid.') + } + seen.add(path) + seen.add(rolloutId) + if (end && rolloutId !== end.thread_id.toLowerCase()) { + throw new Error('The conversation history source identity is invalid.') + } + let header: string | null = null + for await (const raw of lines(path, end)) { + if (raw.line.trim()) { + header = raw.line + break + } + } + const record = header ? parseJsonObject(header) : null + const metadata = record?.type === 'session_meta' ? asRecord(record.payload) : null + const paginated = metadata?.history_mode === 'paginated' + const base = metadata?.history_base == null ? undefined : historyPosition(metadata.history_base) + if ((end || base) && !paginated) { + throw new Error('The conversation history source is not paginated.') + } + segments.push({ + path, + rolloutId, + headerDigest: header === null ? null : digestHeader(header), + paginated, + start: base?.end_ordinal_exclusive ?? 0, + end + }) + if (!base) { + break + } + path = await sources.resolve(base.thread_id) + end = base + } + let bytes = 0 + for (const segment of segments.toReversed()) { + let offset = 0 + let ordinal = segment.start + let first = true + const strictOrdinals = segment.end !== undefined + for await (const raw of lines(segment.path, segment.end)) { + offset += raw.byteLength + bytes += raw.byteLength + if (!raw.line.trim()) { + continue + } + if (first) { + first = false + if (digestHeader(raw.line) !== segment.headerDigest) { + throw new Error('The conversation history source changed during import.') + } + } + const record = parseJsonObject(raw.line) + if ( + strictOrdinals && + (record?.ordinal !== ordinal || + (segment.end && (!raw.terminated || ordinal >= segment.end.end_ordinal_exclusive))) + ) { + throw new Error('The conversation history ordinal or byte boundary is invalid.') + } + ordinal += 1 + if (isCodexVisibleHistoryRecord(record, segment.paginated)) { + const payload = asRecord(record?.payload) + const item = asRecord(payload?.item) + const id = + extractString(item?.id) ?? extractString(payload?.id) ?? extractString(record?.id) + input.visit( + raw.line, + transcriptFallbackId(segment.path, offset - raw.byteLength), + segment.end === undefined, + segment.paginated ? `${segment.rolloutId}:${id ?? `#${ordinal - 1}`}` : undefined + ) + } + } + if ( + (first && segment.headerDigest !== null) || + (segment.end && + (offset !== segment.end.end_byte_offset || ordinal !== segment.end.end_ordinal_exclusive)) + ) { + throw new Error('The conversation history prefix is truncated or mismatched.') + } + } + return bytes +} + +function lines(path: string, end?: HistoryPosition) { + return splitTranscriptStreamLines( + readCodexRolloutBytes(path, end?.end_byte_offset), + 16 * 1024 * 1024 + ) +} + +function digestHeader(line: string): string { + return createHash('sha256').update(line).digest('hex') +} + +function historyPosition(value: unknown): HistoryPosition { + const record = asRecord(value) + const thread_id = extractString(record?.thread_id) + const end_ordinal_exclusive = record?.end_ordinal_exclusive + const end_byte_offset = record?.end_byte_offset + if ( + !thread_id || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(thread_id) || + typeof end_ordinal_exclusive !== 'number' || + !Number.isSafeInteger(end_ordinal_exclusive) || + end_ordinal_exclusive < 1 || + typeof end_byte_offset !== 'number' || + !Number.isSafeInteger(end_byte_offset) || + end_byte_offset < 1 + ) { + throw new Error('The conversation history position is invalid.') + } + return { thread_id, end_ordinal_exclusive, end_byte_offset } +} diff --git a/src/main/native-chat/codex-rollout-history-source.ts b/src/main/native-chat/codex-rollout-history-source.ts new file mode 100644 index 000000000000..3932efc5c6b2 --- /dev/null +++ b/src/main/native-chat/codex-rollout-history-source.ts @@ -0,0 +1,82 @@ +import { basename, dirname, join } from 'node:path' +import { forEachSessionFile } from '../ai-vault/session-scanner-discovery' +import { admitTranscriptPath } from './transcript-path-admission' + +export function codexPhysicalRolloutId(path: string): string | null { + const match = basename(path).match( + /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))?\.jsonl(?:\.zst)?$/i + ) + return (match?.[2] ?? match?.[1])?.toLowerCase() ?? null +} + +export async function codexHistorySources(filePath: string, roots?: readonly string[]) { + let stores: string[] | undefined + let selected = filePath + for (const root of roots ?? []) { + const name = basename(root) + const candidates = + name === 'sessions' || name === 'archived_sessions' + ? [join(dirname(root), 'sessions'), join(dirname(root), 'archived_sessions')] + : [root] + for (const store of candidates) { + const admitted = await admitTranscriptPath(store, filePath) + if (admitted) { + stores = candidates + selected = admitted.path + break + } + } + if (stores) { + break + } + } + if (roots?.length && !stores) { + throw new Error('The conversation is outside its account storage.') + } + const paths = new Map() + let indexedStores = 0 + return { path: selected, resolve } + + async function resolve(id: string): Promise { + if (!stores) { + throw new Error('The conversation history account is unavailable.') + } + const key = id.toLowerCase() + while (!paths.has(key) && indexedStores < stores.length) { + const indexed = new Map() + await forEachSessionFile( + stores[indexedStores], + 'codex', + [], + { extensions: new Set(['.jsonl', '.zst']), strictErrors: true }, + (path) => { + const physicalId = codexPhysicalRolloutId(path) + if (!physicalId) { + return + } + const previous = indexed.get(physicalId) + // Prefer a plain file over its compressed sibling within this store. + if (!previous || `${path}.zst` === previous) { + indexed.set(physicalId, path) + } + } + ) + for (const [physicalId, path] of indexed) { + if (!paths.has(physicalId)) { + paths.set(physicalId, path) + } + } + indexedStores += 1 + } + const found = paths.get(key) + if (found) { + for (const store of stores) { + const admitted = await admitTranscriptPath(store, found) + if (admitted) { + return admitted.path + } + } + } + throw new Error('The conversation history source is unavailable.') + } +} diff --git a/src/main/native-chat/codex-rollout-history-stream.ts b/src/main/native-chat/codex-rollout-history-stream.ts new file mode 100644 index 000000000000..adaf53b409fe --- /dev/null +++ b/src/main/native-chat/codex-rollout-history-stream.ts @@ -0,0 +1,34 @@ +import { createReadStream } from 'node:fs' +import { finished } from 'node:stream/promises' +import { createZstdDecompress } from 'node:zlib' + +/** Bookmarks count decoded JSONL bytes, including newlines, never compressed file bytes. */ +export async function* readCodexRolloutBytes(path: string, end?: number): AsyncGenerator { + const decoder = path.endsWith('.zst') ? createZstdDecompress() : undefined + const file = createReadStream(path) + try { + if (decoder) { + file.on('error', (error) => decoder.destroy(error)) + file.pipe(decoder) + } + let remaining = end ?? Infinity + for await (const chunk of decoder ?? file) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + const prefix = buffer.subarray(0, Math.min(buffer.length, remaining)) + if (prefix.length) { + yield prefix + } + remaining -= prefix.length + if (remaining === 0) { + break + } + } + } finally { + decoder?.destroy() + file.destroy() + await Promise.allSettled([ + finished(file, { cleanup: true }), + ...(decoder ? [finished(decoder, { cleanup: true })] : []) + ]) + } +} diff --git a/src/main/native-chat/structured-agent-cli-source.ts b/src/main/native-chat/structured-agent-cli-source.ts index aab056651481..900fdc214a6a 100644 --- a/src/main/native-chat/structured-agent-cli-source.ts +++ b/src/main/native-chat/structured-agent-cli-source.ts @@ -11,15 +11,29 @@ import { isWslUncPath } from '../../shared/wsl-paths' import { needsWslHostResolution } from './host-readable-transcript-path' import { parseSshTranscriptPath } from './ssh-transcript-path' -export type StructuredAgentCliSourceEvidence = +type CliSource = NonNullable +export type StructuredAgentCliSourceRoute = 'native-local' | 'unsupported' | 'unknown' +export type StructuredAgentCliSourceLocator = + | { kind: 'none' } + | { kind: 'matched'; transcriptPath?: string } + | { kind: 'absent' } + +export type StructuredAgentCliSourceEvidence = ( | { readonly rows: readonly Readonly[] } | { readonly error: unknown } +) & { + readonly sourceRoute?: Readonly + readonly routeError?: unknown +} export function captureStructuredAgentCliSource( - readRows: () => readonly AgentStatusIpcPayload[] + readRows: () => readonly AgentStatusIpcPayload[], + source?: CliSource, + readRoute?: (source: CliSource) => StructuredAgentCliSourceRoute ): StructuredAgentCliSourceEvidence { + let rows: StructuredAgentCliSourceEvidence try { - return Object.freeze({ + rows = { rows: Object.freeze( readRows().map((row) => Object.freeze({ @@ -30,23 +44,34 @@ export function captureStructuredAgentCliSource( }) ) ) - }) + } } catch (error) { - return Object.freeze({ error }) + rows = { error } + } + if (!source || !readRoute) { + return Object.freeze(rows) + } + try { + return Object.freeze({ + ...rows, + sourceRoute: Object.freeze({ ...source, route: readRoute(source) }) + }) + } catch (routeError) { + return Object.freeze({ ...rows, routeError }) } } -export function structuredAgentCliSourcePathFromEvidence(input: { +export function structuredAgentCliSourceFromEvidence(input: { agent: StructuredAgentId resumeFrom: StructuredAgentSessionResumeSource workspaceId: string evidence?: StructuredAgentCliSourceEvidence -}): string | undefined { +}): StructuredAgentCliSourceLocator { const evidence = input.evidence if (input.resumeFrom.cliSource && evidence && 'error' in evidence) { throw evidence.error } - return structuredAgentCliSourcePath({ + return resolveSource({ ...input, rows: evidence && 'rows' in evidence ? evidence.rows : [] }) @@ -59,9 +84,20 @@ export function structuredAgentCliSourcePath(input: { workspaceId: string rows: readonly AgentStatusIpcPayload[] }): string | undefined { + const source = resolveSource(input) + return source.kind === 'matched' ? source.transcriptPath : undefined +} + +function resolveSource(input: { + agent: StructuredAgentId + resumeFrom: StructuredAgentSessionResumeSource + workspaceId: string + rows: readonly AgentStatusIpcPayload[] + evidence?: StructuredAgentCliSourceEvidence +}): StructuredAgentCliSourceLocator { const source = input.resumeFrom.cliSource if (!source) { - return undefined + return { kind: 'none' } } const row = input.rows.find( (candidate) => @@ -78,6 +114,26 @@ export function structuredAgentCliSourcePath(input: { candidate.providerSession?.id === input.resumeFrom.providerSessionId ) if (!row) { + if ( + source.workspaceId === input.workspaceId && + !input.rows.some((candidate) => candidate.paneKey === source.paneKey) + ) { + const evidence = input.evidence + if (evidence && 'routeError' in evidence) { + throw evidence.routeError + } + const route = evidence?.sourceRoute + if (route?.workspaceId === source.workspaceId && route.paneKey === source.paneKey) { + if (route.route === 'native-local') { + return { kind: 'absent' } + } + if (route.route === 'unsupported') { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) + } + } + } throw agentSessionRefusalError( 'agent_session_identity_required', { reason: 'transcriptUnreadable' }, @@ -93,5 +149,5 @@ export function structuredAgentCliSourcePath(input: { reason: 'hostUnsupported' }) } - return path || undefined + return { kind: 'matched', ...(path ? { transcriptPath: path } : {}) } } diff --git a/src/main/native-chat/structured-agent-session-history-adoption.ts b/src/main/native-chat/structured-agent-session-history-adoption.ts index f5764dc9e7fa..8b1c66c0c882 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.ts @@ -112,6 +112,7 @@ export function structuredAdoptionConflictError( export async function resolveStructuredAgentSessionAdoption(input: { providerSessionId: string candidateAccountHomes: readonly AgentSessionAccountHome[] + exhaustionReason?: 'transcriptNotFound' | 'transcriptUnreadable' resolveTranscript: (args: { providerSessionId: string accountHome: AgentSessionAccountHome @@ -139,6 +140,6 @@ export async function resolveStructuredAgentSessionAdoption(input: { } // Wrong-home fallback silently turns resume into a blank chat under the old name. throw agentSessionRefusalError('agent_session_identity_required', { - reason: 'transcriptNotFound' + reason: input.exhaustionReason ?? 'transcriptNotFound' }) } diff --git a/src/main/native-chat/structured-agent-session-source-admission.test.ts b/src/main/native-chat/structured-agent-session-source-admission.test.ts index 0f041b570138..ace61d29ec28 100644 --- a/src/main/native-chat/structured-agent-session-source-admission.test.ts +++ b/src/main/native-chat/structured-agent-session-source-admission.test.ts @@ -4,7 +4,12 @@ import { dirname, join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { CLAUDE_CLI_CONVERSATIONS } from '../claude/claude-cli-conversations' import { resolveStructuredAgentSessionAdoptionForCreate } from '../runtime/structured-agent-session-create-adoption' -import { structuredAgentCliSourcePath } from './structured-agent-cli-source' +import { + captureStructuredAgentCliSource, + structuredAgentCliSourcePath, + structuredAgentCliSourceFromEvidence, + type StructuredAgentCliSourceEvidence +} from './structured-agent-cli-source' import { prepareAdoptedTranscript } from './agent-session-wire/structured-agent-session-adopted-import' import type { AgentSessionAttachParams } from './agent-session-wire/structured-agent-session-attach' import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' @@ -13,6 +18,7 @@ import { prepareCodexAdoption } from './codex-adoption-preparation' import { codexAiVaultSessionResumePreparation } from '../codex/codex-ai-vault-session-resume' import * as codexHomes from '../codex/codex-home-paths' import { adoptedDirectoryProbes } from './agent-session-journal/adopted-directory-probe' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' let root: string beforeEach(async () => { @@ -169,11 +175,38 @@ it('creates from the host hook path when Claude filename differs, then checks ac selectedAccountHome: { variable: 'CLAUDE_CONFIG_DIR', path: root }, launchEnv: {}, workspaceCwd: root, - resolveTranscriptPath: sourcePath + resolveTranscriptSource: () => ({ kind: 'matched', transcriptPath: sourcePath() }) }) expect(adoption.transcriptPath).toBe(transcriptPath) expect(admission).toHaveBeenCalledOnce() expect(find).not.toHaveBeenCalled() + await expect( + resolveStructuredAgentSessionAdoptionForCreate({ + settings: {}, + agent: 'claude', + transcriptImport: importer, + providerSessionId: id, + selectedAccountHome, + launchEnv: {}, + workspaceCwd: root, + resolveTranscriptSource: () => + structuredAgentCliSourceFromEvidence({ + agent: 'claude', + workspaceId: 'folder', + resumeFrom: { + providerSessionId: id, + cliSource: { workspaceId: 'folder', paneKey: 'tab:leaf' } + }, + evidence: captureStructuredAgentCliSource( + () => [], + { workspaceId: 'folder', paneKey: 'tab:leaf' }, + () => 'native-local' + ) + }) + }) + ).rejects.toMatchObject({ refusal: { details: { reason: 'transcriptUnreadable' } } }) + expect(find).toHaveBeenCalled() + find.mockClear() expect(await prepareAdoptedTranscript(params('claude', adoption.transcriptPath))).toMatchObject({ ok: true }) @@ -200,7 +233,7 @@ it('creates from the host hook path when Claude filename differs, then checks ac selectedAccountHome: { variable: 'CLAUDE_CONFIG_DIR', path: root }, launchEnv: {}, workspaceCwd: root, - resolveTranscriptPath: () => escaped + resolveTranscriptSource: () => ({ kind: 'matched', transcriptPath: escaped }) }) ).rejects.toMatchObject({ refusal: { details: { reason: 'transcriptUnreadable' } } }) admission.mockRejectedValueOnce( @@ -215,12 +248,192 @@ it('creates from the host hook path when Claude filename differs, then checks ac selectedAccountHome, launchEnv: {}, workspaceCwd: root, - resolveTranscriptPath: sourcePath + resolveTranscriptSource: () => ({ kind: 'matched', transcriptPath: sourcePath() }) }) ).rejects.toMatchObject({ refusal: { details: { reason: 'transcriptUnreadable' } } }) expect(find).not.toHaveBeenCalled() }) +it('imports early and late Claude history with an absent locator and captured native source', async () => { + const transcriptPath = await file(join(root, 'projects', 'folder', `${id}.jsonl`), [ + { + type: 'user', + uuid: 'early', + parentUuid: null, + isSidechain: false, + timestamp: '2026-10-29T18:00:00.000Z', + sessionId: id, + cwd: root, + message: { role: 'user', content: 'early Claude request' } + }, + { + type: 'assistant', + uuid: 'late', + parentUuid: 'early', + isSidechain: false, + timestamp: '2026-10-29T18:10:00.000Z', + sessionId: id, + cwd: root, + message: { role: 'assistant', content: [{ type: 'text', text: 'late Claude reply' }] } + } + ]) + const resumeFrom = { + providerSessionId: id, + cliSource: { workspaceId: 'folder', paneKey: 'tab:leaf' } + } + const evidence = captureStructuredAgentCliSource( + () => [], + resumeFrom.cliSource, + () => 'native-local' + ) + const selectedAccountHome = { variable: 'CLAUDE_CONFIG_DIR', path: root } + const importer = CLAUDE_CLI_CONVERSATIONS.transcriptImport! + vi.spyOn(importer, 'accountHomeCandidates').mockReturnValue([selectedAccountHome]) + const store = vi.spyOn(importer, 'conversationStore') + const find = vi.spyOn(importer, 'findTranscript') + const lookup = (captured: StructuredAgentCliSourceEvidence | undefined = evidence) => + resolveStructuredAgentSessionAdoptionForCreate({ + settings: {}, + agent: 'claude', + transcriptImport: importer, + providerSessionId: id, + selectedAccountHome, + launchEnv: {}, + workspaceCwd: root, + resolveTranscriptSource: () => + structuredAgentCliSourceFromEvidence({ + agent: 'claude', + workspaceId: 'folder', + resumeFrom, + evidence: captured + }) + }) + for (const captured of [ + captureStructuredAgentCliSource(() => []), + captureStructuredAgentCliSource( + () => [], + resumeFrom.cliSource, + () => 'unknown' + ), + captureStructuredAgentCliSource( + () => [], + resumeFrom.cliSource, + () => { + throw new Error('route unreadable') + } + ), + captureStructuredAgentCliSource( + () => { + throw new Error('rows unreadable') + }, + resumeFrom.cliSource, + () => 'native-local' + ), + captureStructuredAgentCliSource( + () => [], + { ...resumeFrom.cliSource, workspaceId: 'other' }, + () => 'native-local' + ) + ]) { + await expect(lookup(captured)).rejects.toMatchObject({ + refusal: { details: { reason: 'transcriptUnreadable' } } + }) + } + await expect( + lookup( + captureStructuredAgentCliSource( + () => [], + resumeFrom.cliSource, + () => 'unsupported' + ) + ) + ).rejects.toMatchObject({ refusal: { details: { reason: 'hostUnsupported' } } }) + const row: AgentStatusIpcPayload = { + paneKey: 'tab:leaf', + worktreeId: 'folder', + agentType: 'claude', + state: 'waiting', + prompt: '', + connectionId: null, + receivedAt: 1, + stateStartedAt: 1, + providerSession: { key: 'session_id', id, transcriptPath } + } + for (const change of [ + { providerSession: undefined }, + { providerSession: { ...row.providerSession!, id: 'new-run' } }, + { worktreeId: 'other' }, + { agentType: 'codex' }, + { connectionId: 'ssh' }, + { structuredHost: 'owned' as const } + ]) { + await expect( + lookup( + captureStructuredAgentCliSource( + () => [{ ...row, ...change }], + resumeFrom.cliSource, + () => 'native-local' + ) + ) + ).rejects.toMatchObject({ refusal: { details: { reason: 'transcriptUnreadable' } } }) + } + const resolve = (captured: StructuredAgentCliSourceEvidence, workspaceId = 'folder') => + structuredAgentCliSourceFromEvidence({ + agent: 'claude', + workspaceId, + resumeFrom, + evidence: captured + }) + expect(() => resolve(evidence, 'other')).toThrow('agent_session_identity_required') + const routeFault = () => { + throw new Error('unused route failure') + } + expect( + resolve( + captureStructuredAgentCliSource( + () => [row, { ...row, worktreeId: 'other' }], + resumeFrom.cliSource, + routeFault + ) + ) + ).toEqual({ kind: 'matched', transcriptPath }) + expect( + resolve( + captureStructuredAgentCliSource( + () => [{ ...row, providerSession: { key: 'session_id', id } }], + resumeFrom.cliSource, + routeFault + ) + ) + ).toEqual({ kind: 'matched' }) + expect(find).not.toHaveBeenCalled() + const adoption = await lookup() + expect(resumeFrom.cliSource).toEqual({ workspaceId: 'folder', paneKey: 'tab:leaf' }) + expect(adoption).toEqual({ accountHome: selectedAccountHome, transcriptPath }) + expect(store).toHaveBeenCalledOnce() + const prepared = await prepareAdoptedTranscript(params('claude', adoption.transcriptPath)) + expect(prepared).toMatchObject({ ok: true }) + if (!prepared.ok) { + throw new Error('Claude history was not imported') + } + expect(prepared.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + body: expect.objectContaining({ + blocks: expect.arrayContaining([ + expect.objectContaining({ text: 'early Claude request' }) + ]) + }) + }), + expect.objectContaining({ + body: expect.objectContaining({ + blocks: expect.arrayContaining([expect.objectContaining({ text: 'late Claude reply' })]) + }) + }) + ]) + ) +}) + it('judges the lookup and its re-admission against one store resolved once per account', async () => { const transcriptPath = await file(join(root, 'projects', 'folder', `${id}.jsonl`), []) const importer = CLAUDE_CLI_CONVERSATIONS.transcriptImport! @@ -259,7 +472,10 @@ it.skipIf(process.platform === 'win32')( selectedAccountHome: { variable: 'CLAUDE_CONFIG_DIR', path: linked }, launchEnv: {}, workspaceCwd: root, - resolveTranscriptPath: () => join(linked, 'projects', 'folder', `${id}.jsonl`) + resolveTranscriptSource: () => ({ + kind: 'matched', + transcriptPath: join(linked, 'projects', 'folder', `${id}.jsonl`) + }) }) ).rejects.toMatchObject({ refusal: { details: { reason: 'transcriptNotFound' } } }) } diff --git a/src/main/native-chat/transcript-line-decoders-codex.ts b/src/main/native-chat/transcript-line-decoders-codex.ts index b856377f74d0..ec388e48df23 100644 --- a/src/main/native-chat/transcript-line-decoders-codex.ts +++ b/src/main/native-chat/transcript-line-decoders-codex.ts @@ -13,9 +13,27 @@ import { import { claudeContentBlocks, toolResultOutput } from './transcript-record-blocks' import { CODEX_EVENT_TURN_ABORTED } from './transcript-turn-markers' +/** Paginated completed items own visible messages; response tools retain their legacy format. */ +export function isCodexVisibleHistoryRecord( + record: Record | null, + paginated: boolean +): boolean { + const payload = asRecord(record?.payload) + return ( + !paginated || + !( + (record?.type === 'response_item' && + (payload?.type === 'message' || payload?.type === 'reasoning')) || + (record?.type === 'event_msg' && + (payload?.type === 'user_message' || payload?.type === 'agent_message')) + ) + ) +} + export function decodeCodexTranscriptLine( line: string, - fallbackId: string + fallbackId: string, + options?: { historyImport: true } ): NativeChatMessage | null { const record = parseJsonObject(line) if (!record) { @@ -33,7 +51,7 @@ export function decodeCodexTranscriptLine( return codexResponseItem(payload, baseId, timestamp) } if (record.type === 'event_msg') { - return codexEventMessage(payload, baseId, timestamp) + return codexEventMessage(payload, baseId, timestamp, options?.historyImport === true) } return null } @@ -122,7 +140,8 @@ function isSkillContext(block: NativeChatBlock): boolean { function codexEventMessage( payload: Record, id: string, - timestamp: number | null + timestamp: number | null, + historyImport: boolean ): NativeChatMessage | null { if (payload.type === CODEX_EVENT_TURN_ABORTED) { return { @@ -138,7 +157,7 @@ function codexEventMessage( return codexCompactionMarker(id, timestamp) } if (payload.type === 'item_completed') { - return codexCompletedTurnItem(payload, id, timestamp) + return codexCompletedTurnItem(payload, id, timestamp, historyImport) } if (payload.type === 'user_message') { const text = extractString(payload.message) @@ -158,13 +177,24 @@ function codexEventMessage( function codexCompletedTurnItem( payload: Record, fallbackId: string, - timestamp: number | null + timestamp: number | null, + historyImport: boolean ): NativeChatMessage | null { const item = asRecord(payload.item) if (!item) { return null } const id = extractString(item.id) ?? fallbackId + if (historyImport && item.type === 'Reasoning') { + return codexResponseItem( + { + type: 'reasoning', + text: codexSummaryText(item.summary_text) ?? codexSummaryText(item.raw_content) + }, + id, + timestamp + ) + } if (item.type === 'ContextCompaction') { return codexCompactionMarker(id, timestamp) } diff --git a/src/main/native-chat/transcript-reader-codex-history-mode.test.ts b/src/main/native-chat/transcript-reader-codex-history-mode.test.ts index 36e5748d6e77..6013dd838d0b 100644 --- a/src/main/native-chat/transcript-reader-codex-history-mode.test.ts +++ b/src/main/native-chat/transcript-reader-codex-history-mode.test.ts @@ -1,3 +1,6 @@ +import { codexLegacyMirrorRecords } from './agent-session-journal/codex-history-test-fixture' +import { prepareLegacyTranscriptImport } from './agent-session-journal/journal-legacy-import' +import { createNativeChatMerger, replaceList } from '../../shared/native-chat/native-chat-merge' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -135,6 +138,18 @@ describe('Codex transcript history modes', () => { '2026-08-09T10:00:01.000Z' ), completedItem({ type: 'ContextCompaction', id: 'compaction-1' }, '2026-08-09T10:00:01.500Z'), + { + type: 'response_item', + payload: { + type: 'reasoning', + id: 'reason', + summary: [{ type: 'summary_text', text: 'why' }] + } + }, + completedItem( + { type: 'Reasoning', id: 'reason', summary_text: ['why'], raw_content: [] }, + '2026-08-09T10:00:01.750Z' + ), { type: 'response_item', payload: { @@ -179,6 +194,7 @@ describe('Codex transcript history modes', () => { timestamp: Date.parse('2026-08-09T10:00:01.500Z'), blocks: [{ type: 'text', text: 'Context compacted', presentation: 'compaction' }] }, + { id: 'reason', role: 'reasoning', blocks: [{ type: 'text', text: 'why' }] }, { id: 'assistant-1', role: 'assistant', @@ -187,31 +203,24 @@ describe('Codex transcript history modes', () => { ] }) expect(tail).toMatchObject({ messages: 'messages' in result ? result.messages : [] }) + const snapshot = createNativeChatMerger() + replaceList(snapshot, 'messages' in result ? result.messages : []) + expect(snapshot.list.filter(({ role }) => role === 'reasoning')).toHaveLength(1) + const imported = await prepareLegacyTranscriptImport({ + agent: 'codex', + sessionId: 'session-1', + options: { filePath } + }) + expect( + imported.ok && + imported.items + .filter(({ body }) => body.kind === 'message' && body.role === 'reasoning') + .map(({ body }) => body) + ).toEqual([{ kind: 'message', role: 'reasoning', blocks: [{ type: 'text', text: 'why' }] }]) }) it('keeps legacy event messages without duplicating response copies', async () => { - const filePath = await writeCodexFixture([ - { - type: 'response_item', - payload: { - type: 'message', - role: 'user', - content: [{ type: 'input_text', text: 'Prompt' }] - } - }, - { type: 'event_msg', payload: { type: 'user_message', message: 'Prompt' } }, - { type: 'compacted', payload: { message: 'summary', replacement_history: [] } }, - { type: 'event_msg', payload: { type: 'context_compacted' } }, - { type: 'event_msg', payload: { type: 'agent_message', message: 'Response' } }, - { - type: 'response_item', - payload: { - type: 'message', - role: 'assistant', - content: [{ type: 'output_text', text: 'Response' }] - } - } - ]) + const filePath = await writeCodexFixture(codexLegacyMirrorRecords()) const result = await readNativeChatTranscript('codex', 'session-1', { filePath }) diff --git a/src/main/runtime/rpc/dispatcher-stream-options.ts b/src/main/runtime/rpc/dispatcher-stream-options.ts index 349c589acc01..f73bd6326623 100644 --- a/src/main/runtime/rpc/dispatcher-stream-options.ts +++ b/src/main/runtime/rpc/dispatcher-stream-options.ts @@ -36,7 +36,8 @@ export function rpcContextFromTransport( runtime: OrcaRuntimeService, request: RpcRequest, options: RpcDispatchStreamingOptions | undefined, - orchestrationCaller: OrchestrationSessionCaller | undefined + orchestrationCaller: OrchestrationSessionCaller | undefined, + params?: unknown ): RpcContext { return { runtime, @@ -50,7 +51,11 @@ export function rpcContextFromTransport( : undefined, structuredAgentCliSourceEvidence: request.method === 'agentSession.create' - ? captureStructuredAgentCliSource(() => runtime.getAgentProviderSessionRows()) + ? captureStructuredAgentCliSource( + () => runtime.getAgentProviderSessionRows(), + cliSourceCoordinates(params), + (source) => runtime.getStructuredAgentCliSourceRoute(source) + ) : undefined, clientId: options?.clientId, pairedDeviceId: options?.pairedDeviceId, @@ -71,3 +76,22 @@ export function rpcContextFromTransport( orchestrationCaller } } + +function cliSourceCoordinates(params: unknown) { + if (!params || typeof params !== 'object' || !('resumeFrom' in params)) { + return undefined + } + const resume = params.resumeFrom + if (!resume || typeof resume !== 'object' || !('cliSource' in resume)) { + return undefined + } + const source = resume.cliSource + return source && + typeof source === 'object' && + 'workspaceId' in source && + typeof source.workspaceId === 'string' && + 'paneKey' in source && + typeof source.paneKey === 'string' + ? { workspaceId: source.workspaceId, paneKey: source.paneKey } + : undefined +} diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index d6c08bacfde8..8bb2cacdc529 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -153,7 +153,7 @@ export class RpcDispatcher { try { const result = await method.handler( params, - rpcContextFromTransport(this.runtime, request, options, caller), + rpcContextFromTransport(this.runtime, request, options, caller, params), emit ) recordRuntimeFeatureInteraction( @@ -192,7 +192,7 @@ export class RpcDispatcher { options: RpcDispatchStreamingOptions | undefined ): Promise { const { runtime, orchestrationMutations, legacyOrchestration } = this - const context = rpcContextFromTransport(runtime, request, options, caller) + const context = rpcContextFromTransport(runtime, request, options, caller, params) // Runtime test fakes may omit client-hosted browser routing. const clientHostedBrowser = await (runtime.routeClientHostedBrowserRpc?.( request.method, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index ec0b159e9c83..cf557117e784 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -1,7 +1,7 @@ import type { AgentProcessVerdict } from '../../../../shared/agent-process-presence' import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities' import '../unused-default-rpc-methods.test-fixture' -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { appendFile, mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -22,6 +22,7 @@ import { openTestJournalHostDatabase } from '../../../native-chat/agent-session- import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../../../shared/agent-session-provider-handle-encoding' import { claudeAndCodexAgents } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' +import { prepareStructuredAgentSessionCreateForWorktree } from './structured-agent-session-create' const SESSION = 'session-adoption-replay' const THREAD = 'thread-adoption-replay' @@ -144,7 +145,11 @@ afterEach(async () => { }) describe('committed adopting create RPC replay', () => { - it.each([true, false])('captures source before routing (unary=%s)', async (unary) => { + it.each([ + [true, false], + [false, false], + [true, true] + ] as const)('captures source before routing (unary=%s, absent=%s)', async (unary, absent) => { const originalHome = join(root, 'account-original') const driftedHome = join(root, 'account-drifted') const transcriptPath = join( @@ -168,6 +173,16 @@ describe('committed adopting create RPC replay', () => { })}\n`, 'utf8' ) + if (absent) { + await appendFile( + transcriptPath, + `${JSON.stringify({ + type: 'response_item', + timestamp: '2026-09-06T18:10:01.000Z', + payload: { type: 'message', role: 'assistant', content: 'late adopted history' } + })}\n` + ) + } let selectedHome = originalHome const selectAccountHome = vi.fn(() => selectedHome) @@ -212,6 +227,7 @@ describe('committed adopting create RPC replay', () => { const sourceRows = vi .spyOn(runtime, 'getAgentProviderSessionRows') .mockImplementation(() => rows) + const sourceRoute = vi.spyOn(runtime, 'getStructuredAgentCliSourceRoute') // The structured surface is settings-gated for every caller, not just mobile; this test // probes durable-identity replay, which only runs once the gate admits the call. // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The RPC gate reads only this setting from the test double. @@ -306,8 +322,9 @@ describe('committed adopting create RPC replay', () => { writerVerdict = 'exited' const params = createParams() + // absent row with proven local history now imports (Continue after exit); invalid source still refuses rows = unary - ? [] + ? [{ ...sourceRow, providerSession: undefined }] : [{ ...sourceRow, providerSession: { ...sourceRow.providerSession, id: 'replaced' } }] const route = vi .spyOn(runtime, 'routeClientHostedBrowserRpc') @@ -321,7 +338,42 @@ describe('committed adopting create RPC replay', () => { }) expect(sessionAdapter.acquire).not.toHaveBeenCalled() expect(internal.publishStructuredAgentSessionTab).not.toHaveBeenCalled() + if (absent) { + rows = [] + const source = params.resumeFrom.cliSource + const binding = { tabId: 'tab-source', leafId: SOURCE_LEAF, incarnationId: 'i1' } + expect(runtime.getStructuredAgentCliSourceRoute(source)).toBe('native-local') + runtime.registerPty('source-pty', WORKSPACE, null, binding, true) + expect(runtime.getStructuredAgentCliSourceRoute(source)).toBe('unsupported') + runtime.registerPty('source-pty', WORKSPACE, 'ssh', binding, false) + expect(runtime.getStructuredAgentCliSourceRoute(source)).toBe('unsupported') + runtime.registerPty('source-pty', WORKSPACE, null, binding, false) + expect(runtime.getStructuredAgentCliSourceRoute({ ...source, paneKey: 'missing' })).toBe( + 'unknown' + ) + expect(() => + runtime.getStructuredAgentCliSourceRoute({ ...source, workspaceId: 'other' }) + ).toThrow('terminal_not_found') + const direct = { + runtime, + ensureHost: async () => host, + envelope: params.envelope, + worktree: params.worktree, + agent: params.agent, + caller: { callerKey: CLIENT.clientId }, + resumeFrom: params.resumeFrom + } + sourceRoute.mockClear() + const prepared = await prepareStructuredAgentSessionCreateForWorktree(direct) + expect(prepared.attachParams.adopt?.transcriptPath).toBe(await realpath(transcriptPath)) + expect(sourceRoute).toHaveBeenCalledOnce() + sourceRoute.mockClear() + const intent = await runtime.resolveStructuredAgentSessionCreateIntent(direct) + expect(intent.adopt?.transcriptPath).toBe(await realpath(transcriptPath)) + expect(sourceRoute).toHaveBeenCalledOnce() + } selectAccountHome.mockClear() + sourceRoute.mockClear() const entered = Promise.withResolvers() const release = Promise.withResolvers() route.mockImplementationOnce(async () => { @@ -331,6 +383,10 @@ describe('committed adopting create RPC replay', () => { }) const pending = call(dispatcher, params, CLIENT, unary) await entered.promise + expect(sourceRoute).toHaveBeenCalledOnce() + if (absent) { + runtime.onPtyExit('source-pty', 0) + } if (unary) { rows = [] } else { @@ -343,6 +399,7 @@ describe('committed adopting create RPC replay', () => { ok: true, result: { ok: false, refusal: { code: 'agent_session_operation_unknown' } } }) + expect(sourceRoute).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.accountHome).toMatchObject({ path: originalHome }) await rm(transcriptPath) selectedHome = driftedHome @@ -357,7 +414,8 @@ describe('committed adopting create RPC replay', () => { return [] }) - expect(await call(dispatcher, params)).toMatchObject({ + const replay = await call(dispatcher, params) + expect(replay).toMatchObject({ ok: true, result: { ok: true, @@ -377,6 +435,9 @@ describe('committed adopting create RPC replay', () => { } } }) + if (absent) { + expect(JSON.stringify(replay)).toContain('late adopted history') + } expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) expect(internal.publishStructuredAgentSessionTab).toHaveBeenCalledTimes(2) expect(selectAccountHome).toHaveBeenCalledTimes(1) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index 69394a2d4295..21dd8680ce76 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -92,7 +92,11 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { const structuredAgentCliSourceEvidence = args.structuredAgentCliSourceEvidence ?? (args.resumeFrom?.cliSource - ? captureStructuredAgentCliSource(() => args.runtime.getAgentProviderSessionRows()) + ? captureStructuredAgentCliSource( + () => args.runtime.getAgentProviderSessionRows(), + args.resumeFrom.cliSource, + (source) => args.runtime.getStructuredAgentCliSourceRoute(source) + ) : undefined) // Adoption replay may need the record loaded from disk before source discovery can be skipped. let host = args.resumeFrom ? await args.ensureHost() : null diff --git a/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts index 5fe9dd7b78df..9692e8912c7a 100644 --- a/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts +++ b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts @@ -24,8 +24,29 @@ import { import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { CLI_CONVERSATION_ADOPTING_AGENTS } from '../../../shared/structured-agent-cli-conversation-adoption' import { recordConversationKey } from '../../native-chat/structured-conversation-key' +import type { StructuredAgentCliSourceRoute } from '../../native-chat/structured-agent-cli-source' export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends OrcaRuntimeWithGetWorktreePs { + getStructuredAgentCliSourceRoute(source: { + workspaceId: string + paneKey: string + }): StructuredAgentCliSourceRoute { + const pty = this.getPtyRecordForPaneKey(source.paneKey) + if (!pty) { + return 'unknown' + } + const pane = this.resolveTerminalPane(source.paneKey, source.workspaceId) + if (pane.ptyId !== pty.ptyId || pty.worktreeId !== source.workspaceId) { + return 'unknown' + } + return pty.connectionId || + pty.isWsl || + pty.wslDistro || + pane.executionHostId !== LOCAL_EXECUTION_HOST_ID + ? 'unsupported' + : 'native-local' + } + rememberSavedTerminalLaunchResult(ptyId: string, result: TerminalLaunchResult): void { const pty = this.ptysById.get(ptyId) if (pty && result.savedResumeDecision) { diff --git a/src/main/runtime/session-tabs/structured-session-create-support.ts b/src/main/runtime/session-tabs/structured-session-create-support.ts index 9d8b667b0f01..d7ebdc6a274c 100644 --- a/src/main/runtime/session-tabs/structured-session-create-support.ts +++ b/src/main/runtime/session-tabs/structured-session-create-support.ts @@ -1,6 +1,6 @@ import { captureStructuredAgentCliSource, - structuredAgentCliSourcePathFromEvidence + structuredAgentCliSourceFromEvidence } from '../../native-chat/structured-agent-cli-source' import type { OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce as RestoreStructuredAgentSessionTabsOnceMethods } from './structured-session-tab-restoration' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' @@ -102,7 +102,11 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte const structuredAgentCliSourceEvidence = input.structuredAgentCliSourceEvidence ?? (input.resumeFrom?.cliSource - ? captureStructuredAgentCliSource(() => this.getAgentProviderSessionRows()) + ? captureStructuredAgentCliSource( + () => this.getAgentProviderSessionRows(), + input.resumeFrom.cliSource, + (source) => this.getStructuredAgentCliSourceRoute(source) + ) : undefined) const hostLaunchDirectory = isFloatingWorkspaceSelector(input.worktree) ? (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path @@ -198,8 +202,8 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte agent: input.agent, transcriptImport, providerSessionId: resumeFrom.providerSessionId, - resolveTranscriptPath: () => - structuredAgentCliSourcePathFromEvidence({ + resolveTranscriptSource: () => + structuredAgentCliSourceFromEvidence({ agent: input.agent, resumeFrom, workspaceId: location.workspaceId, @@ -228,6 +232,15 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte adopt: structuredAgentSessionAdoptAttach({ definition, handleNamesTranscript: transcriptImport!.handleNamesTranscript, + ...(input.agent === 'codex' + ? { + conversationStore: + transcriptImport!.conversationStore({ + accountHome: adoption.accountHome, + launchEnv + }) ?? undefined + } + : {}), transcriptPath: adoption.transcriptPath, providerSessionId: input.resumeFrom.providerSessionId, workspaceCwd diff --git a/src/main/runtime/structured-agent-session-create-adoption.ts b/src/main/runtime/structured-agent-session-create-adoption.ts index c0f6bb4d3970..bf7b01c101d4 100644 --- a/src/main/runtime/structured-agent-session-create-adoption.ts +++ b/src/main/runtime/structured-agent-session-create-adoption.ts @@ -6,7 +6,10 @@ import { import { prepareCodexAdoption } from '../native-chat/codex-adoption-preparation' import type { NativeCodexResumePreparation } from '../codex/codex-ai-vault-session-resume' import type { StructuredAgentSessionResumeSource } from '../../shared/structured-agent-session-create' -import type { StructuredAgentCliSourceEvidence } from '../native-chat/structured-agent-cli-source' +import type { + StructuredAgentCliSourceEvidence, + StructuredAgentCliSourceLocator +} from '../native-chat/structured-agent-cli-source' import type { AgentSessionAccountHome } from '../../shared/agent-session-account-home' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import type { @@ -107,11 +110,12 @@ export async function resolveStructuredAgentSessionAdoptionForCreate(input: { selectedAccountHome: AgentSessionAccountHome launchEnv: NodeJS.ProcessEnv workspaceCwd: string - resolveTranscriptPath?: () => string | undefined + resolveTranscriptSource?: () => StructuredAgentCliSourceLocator prepareCodexResume?: NativeCodexResumePreparation }) { try { - const transcriptPath = input.resolveTranscriptPath?.() + const source = input.resolveTranscriptSource?.() + const transcriptPath = source?.kind === 'matched' ? source.transcriptPath : undefined const candidates = input.transcriptImport.accountHomeCandidates(input) // Resolve each account store once so lookup, hook path and re-admission agree despite environment changes. const stores = new Map() @@ -145,6 +149,7 @@ export async function resolveStructuredAgentSessionAdoptionForCreate(input: { } const adoption = await resolveStructuredAgentSessionAdoption({ providerSessionId: input.providerSessionId, + ...(source?.kind === 'absent' ? { exhaustionReason: 'transcriptUnreadable' as const } : {}), candidateAccountHomes: admitted ? [admitted.accountHome] : candidates, resolveTranscript: async (args) => { const exact = admitted?.path @@ -218,6 +223,7 @@ export function structuredAgentSessionAdoptAttach(input: { agent: AgentSessionProviderHandle['agent'] } handleNamesTranscript: boolean | undefined + conversationStore?: string transcriptPath: string providerSessionId: string workspaceCwd: string @@ -229,6 +235,12 @@ export function structuredAgentSessionAdoptAttach(input: { nativeId: input.handleNamesTranscript ? input.transcriptPath : input.providerSessionId }, transcriptPath: input.transcriptPath, - source: { providerSessionId: input.providerSessionId, workspaceCwd: input.workspaceCwd } + source: { + providerSessionId: input.providerSessionId, + workspaceCwd: input.workspaceCwd, + ...(input.conversationStore !== undefined + ? { conversationStore: input.conversationStore } + : {}) + } } } diff --git a/src/main/windows/windows-pty-job.ts b/src/main/windows/windows-pty-job.ts index bc5bb536d806..ae1e382e9fdf 100644 --- a/src/main/windows/windows-pty-job.ts +++ b/src/main/windows/windows-pty-job.ts @@ -195,11 +195,15 @@ export function __setConptyJobNativeForTests(loader?: () => ConptyNative | null) type UnreadyWindowsPty = IPty & { _agent?: { kill?: () => void } } // Bypass node-pty's output-deferred kill to release silent ConPTY hosts, input pipes and output workers immediately. -export function killConptyNow(proc: IPty): void { +export function killConptyNow(proc: IPty): boolean { const pane: UnreadyWindowsPty = proc try { - pane._agent?.kill?.() + if (!pane._agent?.kill) { + return false + } + pane._agent.kill() + return true } catch { - // Best effort; the host job still reaps the console host with the daemon. + return false } } diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts index c435d6d5c26c..42e407e92b16 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts @@ -1,7 +1,7 @@ import type { AppState } from '@/store/types' import { collectAgentPaneConversations, - resolvePaneAgentConversation + resolvePaneLastAgentConversation } from '@/lib/ai-vault-tab-title-requests' import type { ExecutionHostId } from '../../../../shared/execution-host' import { @@ -26,13 +26,15 @@ export function resolveCliChatResumeSource( args: { tab: { id: string; worktreeId: string }; paneKey?: string } ): CliChatResumeSource | null { const conversation = args.paneKey - ? resolvePaneAgentConversation( + ? resolvePaneLastAgentConversation( state, { worktreeId: args.tab.worktreeId, paneKey: args.paneKey }, adoptsCliConversations ) : collectAgentPaneConversations(state, adoptsCliConversations, { - runningAgentsOnly: true + runningAgentsOnly: true, + includeLastReported: true, + tab: { worktreeId: args.tab.worktreeId, tabId: args.tab.id } }).find((candidate) => candidate.tabId === args.tab.id) const offer = conversation ? resolvePaneResumeInNewChatOffer(state, { diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.parity.test.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.parity.test.ts index e21f4129094c..0ef7cfa7b33f 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.parity.test.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.parity.test.ts @@ -1,4 +1,5 @@ // @vitest-environment happy-dom +import { makeTabSessionHistoryRow as row } from './tab-session-history-test-fixture' import { renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' @@ -36,32 +37,6 @@ vi.mock('@/lib/agent-session-launch-plan', async (importOriginal) => ({ const WORKTREE_ID = 'repo-1::/repo/wt' const SIBLING_ID = 'repo-1::/repo/sibling' -function row(overrides: Partial = {}): AiVaultSession { - return { - id: 'row-1', - executionHostId: 'local', - agent: 'claude', - sessionId: 'claude-session-1', - title: 'Fix the build', - cwd: '/repo/wt', - branch: 'main', - model: null, - filePath: '/home/.claude/projects/repo/claude-session-1.jsonl', - codexHome: null, - createdAt: null, - updatedAt: null, - modifiedAt: '2026-10-08T00:00:00.000Z', - messageCount: 2, - totalTokens: 10, - previewMessages: [{ role: 'user', text: 'Fix the build', timestamp: null }], - queuedMessageCount: 0, - subagentTranscriptCount: 0, - resumeCommand: 'claude --resume claude-session-1', - subagent: null, - ...overrides - } -} - function makeState(): AppState { useAppStore.setState(useAppStore.getInitialState(), true) useAppStore.setState({ diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts index 0dfd6677eed0..85423ccb7699 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts @@ -1,3 +1,5 @@ +import { makeTabSessionHistoryRow as row } from './tab-session-history-test-fixture' +import { applyProviderSessionOnlyUpdate } from '@/hooks/ipc-events/agent-status-partial-updates' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' @@ -69,32 +71,6 @@ vi.mock('../right-sidebar/ai-vault-session-resume-in-chat-workspace', () => ({ const WORKTREE_ID = 'repo-1::/repo/wt' -function row(overrides: Partial = {}): AiVaultSession { - return { - id: 'row-1', - executionHostId: 'local', - agent: 'claude', - sessionId: 'claude-session-1', - title: 'Fix the build', - cwd: '/repo/wt', - branch: 'main', - model: null, - filePath: '/home/.claude/projects/repo/claude-session-1.jsonl', - codexHome: null, - createdAt: null, - updatedAt: null, - modifiedAt: '2026-10-08T00:00:00.000Z', - messageCount: 2, - totalTokens: 10, - previewMessages: [{ role: 'user', text: 'Fix the build', timestamp: null }], - queuedMessageCount: 0, - subagentTranscriptCount: 0, - resumeCommand: 'claude --resume claude-session-1', - subagent: null, - ...overrides - } -} - const SIBLING_ID = 'repo-1::/repo/sibling' function makeState( @@ -572,6 +548,82 @@ describe('one pane of a terminal tab', () => { false ) }) + it('identifies a provider-only Continue source without a live turn or selecting its sibling', () => { + makeState() + const [L1, L2] = [ + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222' + ] + const pane = `term-1:${L1}` + const layout = (ptyIdsByLeafId: Record) => + useAppStore.setState({ + terminalLayoutsByTabId: { + 'term-1': { root: null, activeLeafId: L1, expandedLeafId: null, ptyIdsByLeafId } + } + }) + layout({ [L1]: 'pty-1', [L2]: 'pty-2' }) + applyProviderSessionOnlyUpdate({ + store: useAppStore.getState(), + batch: undefined, + paneKey: pane, + routing: { tabId: 'term-1', worktreeId: WORKTREE_ID }, + data: { + paneKey: pane, + connectionId: null, + receivedAt: 1, + stateStartedAt: 1, + state: 'done', + prompt: '', + agentType: 'codex', + providerSession: { key: 'session_id', id: 'thread-c' }, + providerSessionOnly: true + } + }) + const state = useAppStore.getState() + expect(state.agentStatusByPaneKey).toEqual({}) + expect( + resolveCliChatResumeSource(state, { + tab: { id: 'term-1', worktreeId: WORKTREE_ID }, + paneKey: pane + }) + ).toMatchObject({ agent: 'codex', providerSessionId: 'thread-c' }) + expect(paneGate(state, `term-1:${L2}`)).toBe(false) + useAppStore + .getState() + .setAgentStatus( + pane, + { state: 'done', prompt: 'first', agentType: 'codex' }, + undefined, + undefined, + { tabId: 'term-1', worktreeId: WORKTREE_ID }, + { providerSession: { key: 'session_id', id: 'thread-c' } } + ) + useAppStore + .getState() + .setAgentStatus(pane, { state: 'working', prompt: 'second', agentType: 'codex' }) + expect( + resolveCliChatResumeSource(useAppStore.getState(), { + tab: { id: 'term-1', worktreeId: WORKTREE_ID }, + paneKey: pane + }) + ).toBeNull() + expect(paneGate(useAppStore.getState(), pane)).toBe(false) + useAppStore + .getState() + .recordAgentProviderSession( + pane, + 'codex', + { key: 'session_id', id: 'thread-next' }, + undefined, + { tabId: 'term-1', worktreeId: WORKTREE_ID } + ) + expect( + resolveCliChatResumeSource(useAppStore.getState(), { + tab: { id: 'term-1', worktreeId: WORKTREE_ID }, + paneKey: pane + }) + ).toMatchObject({ providerSessionId: 'thread-next' }) + }) }) describe('findTabSessionHistoryRow', () => { diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.ts index c6af6894b09a..4c6a79d55e46 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.ts @@ -4,7 +4,8 @@ import type { AppState } from '@/store/types' import { getIndexedAllWorktrees } from '@/store/worktree-repo-index' import { collectAgentPaneConversations, - resolvePaneAgentConversation + resolvePaneAgentConversation, + resolvePaneLastAgentConversation } from '@/lib/ai-vault-tab-title-requests' import { isAiVaultSessionResumableContent, @@ -82,7 +83,7 @@ export function resolvePaneResumeInNewChatOffer( state: AppState, pane: { worktreeId: string; paneKey: string } ): PaneResumeInNewChatOffer { - const conversation = resolvePaneAgentConversation(state, pane, adoptsCliConversations) + const conversation = resolvePaneLastAgentConversation(state, pane, adoptsCliConversations) if (!conversation) { return null } diff --git a/src/renderer/src/components/tab-bar/tab-session-history-test-fixture.ts b/src/renderer/src/components/tab-bar/tab-session-history-test-fixture.ts new file mode 100644 index 000000000000..13c5f985a685 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-session-history-test-fixture.ts @@ -0,0 +1,27 @@ +import type { AiVaultSession } from '../../../../shared/ai-vault-types' + +export function makeTabSessionHistoryRow(overrides: Partial = {}): AiVaultSession { + return { + id: 'row-1', + executionHostId: 'local', + agent: 'claude', + sessionId: 'claude-session-1', + title: 'Fix the build', + cwd: '/repo/wt', + branch: 'main', + model: null, + filePath: '/home/.claude/projects/repo/claude-session-1.jsonl', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-10-08T00:00:00.000Z', + messageCount: 2, + totalTokens: 10, + previewMessages: [{ role: 'user', text: 'Fix the build', timestamp: null }], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: 'claude --resume claude-session-1', + subagent: null, + ...overrides + } +} diff --git a/src/renderer/src/hooks/ipc-events/agent-status-partial-updates.ts b/src/renderer/src/hooks/ipc-events/agent-status-partial-updates.ts index 7c265e49db22..14b108c7a413 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-partial-updates.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-partial-updates.ts @@ -1,3 +1,4 @@ +import { RESUMABLE_TUI_AGENTS } from '../../../../shared/agent-session-resume' import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' import type { ProgramStatusFacet } from '../../../../shared/program-status/facet' import type { AppState } from '@/store/types' @@ -48,13 +49,14 @@ export function applyProviderSessionOnlyUpdate(args: { routing: NonNullable }): AgentStatusApplyResult { const { store, batch, data, paneKey, routing } = args - if (!data.providerSession || data.agentType !== 'pi') { + const agent = RESUMABLE_TUI_AGENTS.find((candidate) => candidate === data.agentType) + if (!data.providerSession || !agent) { return 'dropped' } const update: AgentProviderSessionUpdate = { kind: 'providerSession', paneKey, - agent: 'pi', + agent, providerSession: data.providerSession, timing: { updatedAt: data.receivedAt }, routing, diff --git a/src/renderer/src/lib/ai-vault-tab-title-requests.ts b/src/renderer/src/lib/ai-vault-tab-title-requests.ts index 24722f26b90d..e2ed995b2d98 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-requests.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-requests.ts @@ -7,6 +7,10 @@ import { parsePaneKey } from '../../../shared/stable-pane-id' import type { TerminalTab } from '../../../shared/terminal-tab-types' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import type { AppState } from '@/store/types' +import type { AgentPaneConversationIdentity } from '@/store/slices/agent-pane-conversation' +import { collectLeafIdsInOrder } from '@/components/terminal-pane/terminal-layout-leaf-ids' +import { getIndexedRepoMap, getIndexedWorktreeMap } from '@/store/worktree-repo-index' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' export type AgentPaneConversation = { agent: Agent @@ -23,6 +27,8 @@ export type AiVaultTitleRequest = Omit< 'paneKey' > +type ConversationTab = TerminalTab & { worktreeId: string; leafIds?: ReadonlySet } + type RequestCandidate = AgentPaneConversation & { priority: number } @@ -38,7 +44,7 @@ function activePaneKey(state: AppState, tabId: string): string | null { function registerCandidate( state: AppState, - tabsById: ReadonlyMap, + tabsById: ReadonlyMap, candidates: Map>, isAgent: (agent: AgentType | null | undefined) => agent is Agent, args: { @@ -56,16 +62,24 @@ function registerCandidate( } const tabId = tabIdFromPaneKey(args.paneKey, args.tabId) const tab = tabId ? tabsById.get(tabId) : undefined - const worktreeId = args.worktreeId ?? tab?.worktreeId - if (!tabId || !tab || !worktreeId) { + const worktreeId = tab?.worktreeId + const pane = parsePaneKey(args.paneKey) + if ( + !tabId || + !tab || + !worktreeId || + (pane && (pane.tabId !== tabId || (tab.leafIds && !tab.leafIds.has(pane.leafId)))) || + (args.worktreeId !== undefined && args.worktreeId !== worktreeId) + ) { return } const priority = args.priority + (activePaneKey(state, tabId) === args.paneKey ? 100 : 0) - if ((candidates.get(tabId)?.priority ?? -1) >= priority) { + const key = tabId + if ((candidates.get(key)?.priority ?? -1) >= priority) { return } const executionHostId = getExecutionHostIdForWorktree(state, worktreeId) - candidates.set(tabId, { + candidates.set(key, { agent: args.agent, executionHostId, providerSession: args.providerSession, @@ -89,30 +103,96 @@ function liveCandidate(entry: AgentStatusEntry): Parameters +): Parameters[4] | null { + const pane = parsePaneKey(identity.paneKey) + const tab = pane ? tabsById.get(pane.tabId) : undefined + const worktree = getIndexedWorktreeMap(selectWorktreesByRepo(state)).get(identity.worktreeId) + const repo = worktree ? getIndexedRepoMap(state.repos).get(worktree.repoId) : undefined + if ( + !pane || + tab?.worktreeId !== identity.worktreeId || + (tab.leafIds && !tab.leafIds.has(pane.leafId)) || + identity.executionHostId !== getExecutionHostIdForWorktree(state, identity.worktreeId) || + (repo !== undefined && identity.connectionId !== (repo.connectionId ?? null)) + ) { + return null + } + return { ...identity, priority: 0, refresh: false } +} + +function conversationRecords(records: Record | undefined, paneKey?: string): T[] { + const addressed = paneKey ? records?.[paneKey] : undefined + return paneKey ? (addressed ? [addressed] : []) : Object.values(records ?? {}) +} + export function collectAiVaultTitleRequests(state: AppState): AiVaultTitleRequest[] { return collectAgentPaneConversations(state, isAiVaultTitleAgent).map( ({ paneKey: _paneKey, ...request }) => request ) } +/** Each tab's conversation: live, sleeping, retained, then its last reported identity. */ export function collectAgentPaneConversations( state: AppState, isAgent: (agent: AgentType | null | undefined) => agent is Agent, options: { /** Skip retained and sleeping records: those remember a conversation after its agent exited. */ runningAgentsOnly?: boolean + includeLastReported?: boolean + pane?: { worktreeId: string; paneKey: string } + tab?: { worktreeId: string; tabId: string } } = {} ): AgentPaneConversation[] { - const tabsById = new Map( - Object.values(state.tabsByWorktree) - .flat() - .map((tab) => [tab.id, tab] as const) - ) + const addressed = options.pane + ? { + worktreeId: options.pane.worktreeId, + tabId: tabIdFromPaneKey( + options.pane.paneKey, + state.agentStatusByPaneKey[options.pane.paneKey]?.tabId ?? + state.sleepingAgentSessionsByPaneKey[options.pane.paneKey]?.tabId ?? + state.retainedAgentsByPaneKey[options.pane.paneKey]?.entry.tabId + ) + } + : options.tab + const partitions = addressed + ? [ + [ + addressed.worktreeId, + (state.tabsByWorktree[addressed.worktreeId] ?? []).filter( + (tab) => tab.id === addressed.tabId + ) + ] as const + ] + : Object.entries(state.tabsByWorktree) + const tabsById = new Map() + for (const [worktreeId, tabs] of partitions) { + for (const tab of tabs) { + const root = state.terminalLayoutsByTabId[tab.id]?.root + tabsById.set(tab.id, { + ...tab, + worktreeId, + ...(root ? { leafIds: new Set(collectLeafIdsInOrder(root)) } : {}) + }) + } + } + const records = (record: Record | undefined) => + conversationRecords(record, options.pane?.paneKey) const candidates = new Map>() - const retained = options.runningAgentsOnly ? [] : Object.values(state.retainedAgentsByPaneKey) - const sleeping = options.runningAgentsOnly - ? [] - : Object.values(state.sleepingAgentSessionsByPaneKey) + const retained = options.runningAgentsOnly ? [] : records(state.retainedAgentsByPaneKey) + const sleeping = options.runningAgentsOnly ? [] : records(state.sleepingAgentSessionsByPaneKey) + + if (!options.runningAgentsOnly || options.includeLastReported) { + for (const identity of records(state.agentConversationsByPaneKey)) { + const candidate = lastReportedCandidate(state, identity, tabsById) + if (candidate) { + registerCandidate(state, tabsById, candidates, isAgent, candidate) + } + } + } for (const entry of retained) { registerCandidate(state, tabsById, candidates, isAgent, { @@ -136,11 +216,13 @@ export function collectAgentPaneConversations( worktreeId: record.worktreeId }) } - for (const entry of Object.values(state.agentStatusByPaneKey)) { + for (const entry of records(state.agentStatusByPaneKey)) { registerCandidate(state, tabsById, candidates, isAgent, liveCandidate(entry)) } - return [...candidates.values()].map(({ priority: _priority, ...request }) => request) + return [...candidates.values()] + .filter((candidate) => !options.pane || candidate.paneKey === options.pane.paneKey) + .map(({ priority: _priority, ...request }) => request) } export function resolvePaneAgentConversation( @@ -148,19 +230,20 @@ export function resolvePaneAgentConversation( pane: { worktreeId: string; paneKey: string }, isAgent: (agent: AgentType | null | undefined) => agent is Agent ): AgentPaneConversation | null { - const live = state.agentStatusByPaneKey[pane.paneKey] - if (live?.paneKey !== pane.paneKey) { - return null - } - const tabsById = new Map( - (state.tabsByWorktree[pane.worktreeId] ?? []).map((tab) => [tab.id, tab] as const) + return collectAgentPaneConversations(state, isAgent, { pane, runningAgentsOnly: true })[0] ?? null +} + +/** One pane's conversation survives occupancy cleanup after its agent exits. */ +export function resolvePaneLastAgentConversation( + state: AppState, + pane: { worktreeId: string; paneKey: string }, + isAgent: (agent: AgentType | null | undefined) => agent is Agent +): AgentPaneConversation | null { + return ( + collectAgentPaneConversations(state, isAgent, { + pane, + runningAgentsOnly: true, + includeLastReported: true + })[0] ?? null ) - const candidates = new Map>() - registerCandidate(state, tabsById, candidates, isAgent, liveCandidate(live)) - const [candidate] = candidates.values() - if (!candidate) { - return null - } - const { priority: _priority, ...request } = candidate - return request } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts index e72b33217b23..261c036aec83 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts @@ -188,6 +188,7 @@ export function buildRetractedMirroredTabSweepPatch( let sweepState: RetiredTerminalTabSweepState = { acknowledgedAgentsByPaneKey: state.acknowledgedAgentsByPaneKey ?? {}, activityClearedAtByPaneKey: state.activityClearedAtByPaneKey ?? {}, + agentConversationsByPaneKey: state.agentConversationsByPaneKey ?? {}, agentLaunchConfigByPaneKey: state.agentLaunchConfigByPaneKey ?? {}, agentStatusByPaneKey: agentStatusPatch?.agentStatusByPaneKey ?? state.agentStatusByPaneKey, agentStatusEpoch: agentStatusPatch?.agentStatusEpoch ?? state.agentStatusEpoch, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/retraction-pane-ownership.test.ts b/src/renderer/src/runtime/web-session-tabs-sync/retraction-pane-ownership.test.ts index 6c1d7aa9e8e6..5ba8ad659015 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/retraction-pane-ownership.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/retraction-pane-ownership.test.ts @@ -10,7 +10,7 @@ import { } from '../web-session-tabs-sync-test-harness' import { collectCollidingRetractionPaneKeys } from './retraction-pane-ownership' import { buildRetractedMirroredTabSweepPatch } from './agent-status-primitives' -import type { WebSessionTabsBatchContext } from './state' +import type { WebSessionTabsBatchContext, WebSessionTabsSyncState } from './state' import { getForegroundTerminalTabLastSeenAtById, resetForegroundTerminalTabIdsForTests, @@ -28,7 +28,18 @@ it.each([true, false])('cleans unrelated tabs when a collision is mirrored=%s', const owned = makePaneKey(collision, LEAF_ID) const foreign = makePaneKey(collision, SECOND_LEAF_ID) const normal = makePaneKey(ordinary, LEAF_ID) + const conversations: WebSessionTabsSyncState['agentConversationsByPaneKey'] = { + [owned]: { + paneKey: owned, + worktreeId: 'folder:own', + executionHostId: 'local', + connectionId: 'host-a', + agent: 'codex', + providerSession: { key: 'session_id', id: '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' } + } + } const state = makeState({ + agentConversationsByPaneKey: conversations, acknowledgedAgentsByPaneKey: { [foreign]: 2 }, agentStatusByPaneKey: Object.fromEntries( [owned, normal].map((paneKey) => [ @@ -57,6 +68,8 @@ it.each([true, false])('cleans unrelated tabs when a collision is mirrored=%s', ) const patch = buildRetractedMirroredTabSweepPatch(state, {}, null, [collision, ordinary], scope) const after = { ...state, ...patch } + expect(after.agentConversationsByPaneKey).toBe(conversations) + expect(patch).not.toHaveProperty('agentConversationsByPaneKey') expect(after.acknowledgedAgentsByPaneKey?.[foreign]).toBe(2) expect(after.agentStatusByPaneKey[normal]).toBeUndefined() expect(after.recentlyClosedAgentStatusTabIds?.[ordinary]).toBeDefined() diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state.ts b/src/renderer/src/runtime/web-session-tabs-sync/state.ts index fc5ea00dfbc8..c9ff465fb67f 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state.ts @@ -1,4 +1,5 @@ import type { AppState } from '../../store' +import type { RetiredTerminalTabSweepState } from '../../store/slices/retired-terminal-tab-state-sweep' import type { RuntimeMobileSessionAgentTab, RuntimeMobileSessionBrowserTab, @@ -225,26 +226,17 @@ export type WebSessionTabsSyncState = Pick< | 'unreadTerminalTabs' | 'sortEpoch' > & + Partial & Partial< Pick< AppState, - | 'acknowledgedAgentsByPaneKey' - | 'activityClearedAtByPaneKey' - | 'agentLaunchConfigByPaneKey' | 'automaticAgentResumeClaimsByTabId' // Client draft proves dirty-flag ownership (#21392); without one, follow host save state. | 'editorDrafts' // Why: the live index carries sticky detachments; absent here, views resolve by identity. | 'editorBufferIdOf' | 'localOnlyScrollbackByTabId' - | 'migrationUnsupportedByPtyId' - | 'manuallyUnreadTurnsByPaneKey' - | 'paneForegroundAgentByPaneKey' | 'pendingStartupByTabId' - | 'recentlyClosedAgentStatusTabIds' - | 'recentlyRetiredAgentStatusPaneKeys' - | 'retainedAgentsByPaneKey' - | 'retentionSuppressedPaneKeys' | 'runtimePaneTitlesByTabId' > > diff --git a/src/renderer/src/store/agent-conversation-retention.ts b/src/renderer/src/store/agent-conversation-retention.ts new file mode 100644 index 000000000000..e132b536f325 --- /dev/null +++ b/src/renderer/src/store/agent-conversation-retention.ts @@ -0,0 +1,76 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from './types' +import type { StoreSetArgs } from './react-commit-cascade-write-probe' +import { + classifyPaneKeyLiveness, + MAX_LIVE_AGENT_STATUSES +} from './slices/agent-status/agent-status-capacity-eviction' + +type Conversations = AppState['agentConversationsByPaneKey'] + +function cappedConversations(state: AppState, previous: Conversations): Conversations { + const entries = state.agentConversationsByPaneKey + const incoming = Object.keys(entries) + const keys = [ + ...Object.keys(previous).filter((key) => Object.hasOwn(entries, key)), + ...incoming.filter((key) => !Object.hasOwn(previous, key)) + ] + let evicted: string[] = [] + if (keys.length > MAX_LIVE_AGENT_STATUSES) { + const classify = classifyPaneKeyLiveness(state) + const eligible = keys.filter((key) => classify(key) !== 'live') + evicted = eligible.slice(0, Math.max(0, eligible.length - MAX_LIVE_AGENT_STATUSES)) + } + if (evicted.length === 0 && keys.every((key, index) => key === incoming[index])) { + return entries + } + const removed = new Set(evicted) + return Object.fromEntries( + keys.filter((key) => !removed.has(key)).map((key) => [key, entries[key]]) + ) +} + +export function withAgentConversationRetention( + createState: StateCreator +): StateCreator { + return (set, get, api) => { + const wrap = + (write: typeof set): typeof set => + (...args: StoreSetArgs): void => { + if (args[1] === true) { + const [partial] = args + write((state) => { + const requested = typeof partial === 'function' ? partial(state) : partial + const entries = cappedConversations(requested, get().agentConversationsByPaneKey) + return entries === requested.agentConversationsByPaneKey + ? requested + : { ...requested, agentConversationsByPaneKey: entries } + }, true) + } else { + const [partial] = args + write((state) => { + const requested = typeof partial === 'function' ? partial(state) : partial + if ( + requested === state || + (!Object.hasOwn(requested, 'agentConversationsByPaneKey') && + !Object.hasOwn(requested, 'terminalLayoutsByTabId')) + ) { + return requested + } + const current = get() + const candidate = { ...current, ...requested } + const entries = cappedConversations(candidate, current.agentConversationsByPaneKey) + return entries === candidate.agentConversationsByPaneKey + ? requested + : { ...requested, agentConversationsByPaneKey: entries } + }) + } + } + api.setState = wrap(api.setState) + const initial = createState(wrap(set), get, api) + const entries = cappedConversations(initial, {}) + return entries === initial.agentConversationsByPaneKey + ? initial + : { ...initial, agentConversationsByPaneKey: entries } + } +} diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index b7c83ea7d4d9..1082f2ee6250 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -67,6 +67,7 @@ import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../shared/sidebar-inputs-swit import { withReactCommitCascadeWriteProbe } from './react-commit-cascade-write-probe' import { withStoreIdentityChurnProbe } from './store-identity-churn-probe' import { withEditorBufferRecords } from './editor-buffer-record-lifecycle' +import { withAgentConversationRetention } from './agent-conversation-retention' import { attachEditorBufferIdentityEvents } from './editor-buffer-identity-events' import { registerRendererMemoryProfileContributor, @@ -81,11 +82,14 @@ const withDevelopmentStoreProbes = (createState: StateCreator) ? withStoreIdentityChurnProbe(createState) : createState +const withAppStoreWritePolicies = (createState: StateCreator) => + withAgentConversationRetention(withDevelopmentStoreProbes(createState)) + // Why buffer records outermost: the probes must see each caller's own partial and frame. // Why e2e checks the buffer invariant too: a writer that skips the record must fail there, not drift. export const useAppStore = create()( withEditorBufferRecords( - withDevelopmentStoreProbes( + withAppStoreWritePolicies( withReactCommitCascadeWriteProbe((...a) => { // Why first: settling inside the bump's own notify means hook subscribers never see it unsettled. // Switch point: the stamp-sorted list reads no settled epoch. diff --git a/src/renderer/src/store/slices/agent-pane-conversation.ts b/src/renderer/src/store/slices/agent-pane-conversation.ts new file mode 100644 index 000000000000..de1c17921427 --- /dev/null +++ b/src/renderer/src/store/slices/agent-pane-conversation.ts @@ -0,0 +1,67 @@ +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' +import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import type { AppState } from '../types' +import { findAgentPaneWorktreeId } from './agent-status/agent-status-pane-key-tab-binding' + +export type AgentPaneConversationIdentity = { + paneKey: string + worktreeId: string + executionHostId: ExecutionHostId + connectionId: string | null + agent: AgentType + providerSession: AgentProviderSessionMetadata +} + +/** Conversation identity outlives turn occupancy; it never requests an automatic resume. */ +export function projectAgentPaneConversation( + state: AppState, + entry: Pick< + AgentStatusEntry, + 'paneKey' | 'agentType' | 'providerSession' | 'worktreeId' | 'connectionId' + >, + copy: ( + entries: AppState['agentConversationsByPaneKey'] + ) => AppState['agentConversationsByPaneKey'] = (entries) => ({ ...entries }) +): AppState['agentConversationsByPaneKey'] { + const current = state.agentConversationsByPaneKey + const previous = current[entry.paneKey] + const agent = entry.agentType + const worktreeId = entry.worktreeId ?? findAgentPaneWorktreeId(state, entry.paneKey) + if (!agent || agent === 'unknown' || !entry.providerSession?.id || !worktreeId) { + if (!previous) { + return current + } + const next = copy(current) + delete next[entry.paneKey] + return next + } + const executionHostId = getExecutionHostIdForWorktree(state, worktreeId) + const connectionId = entry.connectionId ?? null + if ( + previous?.agent === agent && + previous.worktreeId === worktreeId && + previous.executionHostId === executionHostId && + previous.connectionId === connectionId && + agentProviderSessionsEqual(agent, previous.providerSession, entry.providerSession) + ) { + return current + } + const next = copy(current) + Object.defineProperty(next, entry.paneKey, { + value: { + paneKey: entry.paneKey, + worktreeId, + executionHostId, + connectionId, + agent, + providerSession: entry.providerSession + }, + writable: true, + enumerable: true, + configurable: true + }) + return next +} diff --git a/src/renderer/src/store/slices/agent-status/agent-status-authority-actions.ts b/src/renderer/src/store/slices/agent-status/agent-status-authority-actions.ts index 41643ce2a95f..72dfc14f948e 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-authority-actions.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-authority-actions.ts @@ -77,6 +77,10 @@ export function createAgentStatusAuthorityActions( } return { agentStatusByPaneKey: removePaneKeys(s.agentStatusByPaneKey, retiredPaneKeySet), + agentConversationsByPaneKey: removePaneKeys( + s.agentConversationsByPaneKey, + retiredPaneKeySet + ), runtimeAgentOrchestrationByPaneKey: removePaneKeys( s.runtimeAgentOrchestrationByPaneKey, retiredPaneKeySet @@ -192,6 +196,12 @@ export function createAgentStatusAuthorityActions( paneKey: to, tabId: targetTabId })), + agentConversationsByPaneKey: movePaneKeyedRecord( + s.agentConversationsByPaneKey, + from, + to, + (identity) => ({ ...identity, paneKey: to }) + ), // Bump epoch on live pane-key moves so retention/sidebar consumers observe the remap. ...(from in s.agentStatusByPaneKey ? { agentStatusEpoch: s.agentStatusEpoch + 1, sortEpoch: s.sortEpoch + 1 } diff --git a/src/renderer/src/store/slices/agent-status/agent-status-batch-update.ts b/src/renderer/src/store/slices/agent-status/agent-status-batch-update.ts new file mode 100644 index 000000000000..69db959e7010 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status/agent-status-batch-update.ts @@ -0,0 +1,32 @@ +import type { AgentStatusBatchUpdate } from './agent-status-contract' +import type { AgentStatusSlice } from './agent-status-slice-contract' + +export function applyAgentStatusBatchUpdate( + actions: Pick< + AgentStatusSlice, + 'setAgentStatus' | 'recordAgentProviderSession' | 'setAgentProgramStatus' + >, + update: AgentStatusBatchUpdate +): void { + if (update.kind === 'providerSession') { + actions.recordAgentProviderSession( + update.paneKey, + update.agent, + update.providerSession, + update.timing, + update.routing, + update.metadata + ) + } else if (update.kind === 'programStatus') { + actions.setAgentProgramStatus(update.paneKey, update.programStatus) + } else { + actions.setAgentStatus( + update.paneKey, + update.payload, + update.terminalTitle, + update.timing, + update.routing, + update.metadata + ) + } +} diff --git a/src/renderer/src/store/slices/agent-status/agent-status-drop-actions.ts b/src/renderer/src/store/slices/agent-status/agent-status-drop-actions.ts index 87957222f6a0..5913c0821c5f 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-drop-actions.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-drop-actions.ts @@ -14,6 +14,7 @@ import { } from './agent-status-pane-key-tab-binding' import { retireAgentPaneAuthorityAliasesByOwnerTab } from '../agent-pane-authority' import { agentTurnEndedOnPurpose } from '../../../../../shared/agent-main-agent-verdict' +import { removePaneKeys } from './agent-status-pane-keyed-records' function removeAcknowledgement( acknowledgements: Record, @@ -53,6 +54,9 @@ export function createAgentStatusDropActions( const nextManualUnread = opts?.paneRemoved ? removeAcknowledgement(s.manuallyUnreadTurnsByPaneKey, paneKey) : s.manuallyUnreadTurnsByPaneKey + const nextConversations = opts?.paneRemoved + ? removePaneKeys(s.agentConversationsByPaneKey, new Set([paneKey])) + : s.agentConversationsByPaneKey const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey const nextLaunchConfigs = hasLaunchConfig ? { ...s.agentLaunchConfigByPaneKey } @@ -62,6 +66,9 @@ export function createAgentStatusDropActions( } if (!hasLive && !hasRetained && !migrationUnsupported.changed) { const cleanupPatch = { + ...(nextConversations !== s.agentConversationsByPaneKey + ? { agentConversationsByPaneKey: nextConversations } + : {}), ...(hasLaunchConfig ? { agentLaunchConfigByPaneKey: nextLaunchConfigs } : {}), ...(nextAck !== s.acknowledgedAgentsByPaneKey ? { acknowledgedAgentsByPaneKey: nextAck } @@ -89,6 +96,7 @@ export function createAgentStatusDropActions( const needsSuppressor = hasLive && !(paneKey in s.retentionSuppressedPaneKeys) return { agentStatusByPaneKey: nextLive, + agentConversationsByPaneKey: nextConversations, agentLaunchConfigByPaneKey: nextLaunchConfigs, retainedAgentsByPaneKey: nextRetained, migrationUnsupportedByPtyId: migrationUnsupported.next, diff --git a/src/renderer/src/store/slices/agent-status/agent-status-drop-reducer.ts b/src/renderer/src/store/slices/agent-status/agent-status-drop-reducer.ts index 96eda5a96f06..73a89376b97d 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-drop-reducer.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-drop-reducer.ts @@ -17,6 +17,7 @@ export type AgentStatusTabPrefixDropState = Pick< | 'acknowledgedAgentsByPaneKey' | 'activityClearedAtByPaneKey' | 'agentLaunchConfigByPaneKey' + | 'agentConversationsByPaneKey' | 'agentStatusByPaneKey' | 'agentStatusEpoch' | 'migrationUnsupportedByPtyId' @@ -75,6 +76,15 @@ export function buildAgentStatusTabPrefixDropPatch( const nextClosedTabs = opts?.paneKeys ? s.recentlyClosedAgentStatusTabIds : boundRecentlyClosedAgentStatusTabIds(s.recentlyClosedAgentStatusTabIds, tabIdPrefix) + const nextConversations = opts?.preserveActivityClearedState + ? s.agentConversationsByPaneKey + : opts?.paneKeys + ? removePaneKeys(s.agentConversationsByPaneKey, opts.paneKeys) + : removePaneKeysByTabPrefix( + s.agentConversationsByPaneKey, + tabIdPrefix, + new Set([...completedOrphanKeys, ...retiredAliasPaneKeys]) + ) const nextRetiredPaneKeys = boundRecentlyRetiredAgentStatusPaneKeys( s.recentlyRetiredAgentStatusPaneKeys, closedAgentStatusRetirementKeys( @@ -117,11 +127,13 @@ export function buildAgentStatusTabPrefixDropPatch( ...(nextManualUnread !== s.manuallyUnreadTurnsByPaneKey ? { manuallyUnreadTurnsByPaneKey: nextManualUnread } : {}), + agentConversationsByPaneKey: nextConversations, recentlyClosedAgentStatusTabIds: nextClosedTabs, recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys } } return { + agentConversationsByPaneKey: nextConversations, recentlyClosedAgentStatusTabIds: nextClosedTabs, recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys, ...(nextClearedAt !== s.activityClearedAtByPaneKey @@ -167,6 +179,7 @@ export function buildAgentStatusTabPrefixDropPatch( return { agentStatusByPaneKey: nextLive, + agentConversationsByPaneKey: nextConversations, agentLaunchConfigByPaneKey: nextLaunchConfigs, retainedAgentsByPaneKey: nextRetained, migrationUnsupportedByPtyId: migrationUnsupported.next, diff --git a/src/renderer/src/store/slices/agent-status/agent-status-live-actions.ts b/src/renderer/src/store/slices/agent-status/agent-status-live-actions.ts index 0ce74ccc64de..fc46e318e17c 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-live-actions.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-live-actions.ts @@ -32,6 +32,7 @@ export function createAgentStatusLiveActions( runtime: AgentStatusRuntime ): Pick { const { + projectConversation, get, set, applyGeneratedTabTitleUpdate, @@ -105,7 +106,7 @@ export function createAgentStatusLiveActions( return state } const previousEntries = state.agentStatusByPaneKey - const reduction = reduceAgentStatusLiveUpdate(state, built, updatedAt) + const reduction = reduceAgentStatusLiveUpdate(state, built, updatedAt, projectConversation) changedPaneKeys.add(built.entry.paneKey) for (const evicted of reduction.evictedEntries) { changedPaneKeys.add(evicted.paneKey) diff --git a/src/renderer/src/store/slices/agent-status/agent-status-live-reducer.ts b/src/renderer/src/store/slices/agent-status/agent-status-live-reducer.ts index d7f7a26c5b80..7c0d99279718 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-live-reducer.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-live-reducer.ts @@ -1,3 +1,4 @@ +import { projectAgentPaneConversation } from '../agent-pane-conversation' import type { AppState } from '../../types' import type { AgentStatusEntry } from '../../../../../shared/agent-status-types' import { @@ -20,7 +21,8 @@ export type AgentStatusLiveUpdateReduction = { export function reduceAgentStatusLiveUpdate( state: AppState, build: AgentStatusLiveEntryBuild, - updatedAt: number + updatedAt: number, + projectConversation = projectAgentPaneConversation ): AgentStatusLiveUpdateReduction { const { entry, @@ -107,8 +109,12 @@ export function reduceAgentStatusLiveUpdate( nextSleepingAgentSessions = removePaneKeys(nextSleepingAgentSessions, evicted) nextLaunchConfigs = removePaneKeys(nextLaunchConfigs, evicted) } + const nextConversations = projectConversation(state, entry) const patch: Partial = { agentStatusByPaneKey: nextLive, + ...(nextConversations !== state.agentConversationsByPaneKey + ? { agentConversationsByPaneKey: nextConversations } + : {}), retainedAgentsByPaneKey: nextRetainedAgents, sleepingAgentSessionsByPaneKey: nextSleepingAgentSessions, agentLaunchConfigByPaneKey: nextLaunchConfigs, diff --git a/src/renderer/src/store/slices/agent-status/agent-status-provider-session-actions.ts b/src/renderer/src/store/slices/agent-status/agent-status-provider-session-actions.ts index 2a56eee351b0..6ef89e795a1a 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-provider-session-actions.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-provider-session-actions.ts @@ -27,7 +27,7 @@ import { settleAttentionRemovedBy } from '../workspace-attention/attention-remov export function createAgentStatusProviderSessionActions( runtime: AgentStatusRuntime ): Pick { - const { get, set, requestFreshness } = runtime + const { get, set, requestFreshness, projectConversation } = runtime return { recordAgentProviderSession: ( paneKey: string, @@ -148,8 +148,12 @@ export function createAgentStatusProviderSessionActions( nextLaunchConfigs = { ...nextLaunchConfigs } delete nextLaunchConfigs[paneKey] } + const nextConversations = projectConversation(s, { ...record, agentType: agent }) return { agentStatusByPaneKey: nextLive, + ...(nextConversations !== s.agentConversationsByPaneKey + ? { agentConversationsByPaneKey: nextConversations } + : {}), retainedAgentsByPaneKey: nextRetained, sleepingAgentSessionsByPaneKey: { ...s.sleepingAgentSessionsByPaneKey, diff --git a/src/renderer/src/store/slices/agent-status/agent-status-provider-session.test.ts b/src/renderer/src/store/slices/agent-status/agent-status-provider-session.test.ts index e68a56ccff76..c32d82651d81 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-provider-session.test.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-provider-session.test.ts @@ -1,3 +1,6 @@ +import { normalizeHookPayload } from '../../../../../shared/agent-hook-listener' +import { createHookListenerState } from '../../../../../shared/agent-hook-listener/listener-state' +import { applyProviderSessionOnlyUpdate } from '../../../hooks/ipc-events/agent-status-partial-updates' import { afterEach, describe, expect, it, vi } from 'vitest' import type { SleepingAgentSessionRecord } from '../../../../../shared/agent-session-resume' import type { AppState } from '../../types' @@ -833,3 +836,39 @@ describe('sleepingAgentSessionsByPaneKey after reminted-hook settlement', () => ) }) }) + +it('retains recorded Jcode session-start identity without a status row', () => { + const store = createTestStore() + seedStore(store, { tabsByWorktree: { 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] } }) + const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111' + // Jcode 0.87.1 captured input; agent-hook-listener-jcode.test.ts pins provenance. + const payload = { + hook_event_name: 'session_start', + event: 'session_start', + session_id: 'session_jc_5', + model: 'claude-haiku-4-5', + source: 'create' + } + const event = normalizeHookPayload( + createHookListenerState(), + 'jcode', + { paneKey, payload }, + 'production' + ) + if (!event) { + throw new Error('Recorded session_start was rejected') + } + expect(event.providerSessionOnly).toBe(true) + applyProviderSessionOnlyUpdate({ + store: store.getState(), + batch: undefined, + paneKey, + routing: { tabId: 'tab-1', worktreeId: 'wt-1' }, + data: { ...event, ...event.payload, receivedAt: 1, stateStartedAt: 1 } + }) + expect(store.getState().agentStatusByPaneKey).toEqual({}) + expect(store.getState().agentConversationsByPaneKey[paneKey]).toMatchObject({ + agent: 'jcode', + providerSession: { key: 'session_id', id: 'session_jc_5' } + }) +}) diff --git a/src/renderer/src/store/slices/agent-status/agent-status-runtime.ts b/src/renderer/src/store/slices/agent-status/agent-status-runtime.ts index 43646194b00e..08dbdb0f6da5 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-runtime.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-runtime.ts @@ -1,3 +1,5 @@ +import { applyAgentStatusBatchUpdate } from './agent-status-batch-update' +import { projectAgentPaneConversation } from '../agent-pane-conversation' import { structuralValuesEqualIgnoringUndefined } from '../../../../../shared/structural-value-equality' import type { StateCreator } from 'zustand' import type { AppState } from '../../types' @@ -17,6 +19,7 @@ export type AgentStatusStateUpdate = | ((state: AppState, changedPaneKeys: Set) => AppState | Partial) export type AgentStatusRuntime = { + projectConversation: typeof projectAgentPaneConversation get: () => AppState subscribeAgentStatusChanges: (listener: AgentStatusChangeListener) => () => void set: (update: AgentStatusStateUpdate) => void @@ -114,6 +117,20 @@ export function createAgentStatusRuntime( let batchedAgentStatusFreshnessRequested = false const get = (): AppState => batchedAgentStatusState ?? storeGet() + let conversationDraft: AppState['agentConversationsByPaneKey'] | null = null + const projectConversation: typeof projectAgentPaneConversation = (state, entry) => + projectAgentPaneConversation(state, entry, (entries) => { + if (!batchedAgentStatusState) { + return { ...entries } + } + const next = + entries === conversationDraft + ? entries + : Object.setPrototypeOf(Object.assign(Object.create(null), entries), Object.prototype) + conversationDraft = next + return next + }) + // Omit replace from this write type so no slice call can request replacement the batch cannot express. const set = (update: AgentStatusStateUpdate): void => { const staged = batchedAgentStatusState @@ -171,28 +188,7 @@ export function createAgentStatusRuntime( return false } const revisionBeforeUpdate = batchedAgentStatusRevision - const actions = getActions() - if (update.kind === 'providerSession') { - actions.recordAgentProviderSession( - update.paneKey, - update.agent, - update.providerSession, - update.timing, - update.routing, - update.metadata - ) - } else if (update.kind === 'programStatus') { - actions.setAgentProgramStatus(update.paneKey, update.programStatus) - } else { - actions.setAgentStatus( - update.paneKey, - update.payload, - update.terminalTitle, - update.timing, - update.routing, - update.metadata - ) - } + applyAgentStatusBatchUpdate(getActions(), update) return batchedAgentStatusRevision !== revisionBeforeUpdate } @@ -224,6 +220,7 @@ export function createAgentStatusRuntime( const generatedTabTitleUpdates = batchedGeneratedTabTitleUpdates const freshnessRequested = batchedAgentStatusFreshnessRequested const hasStagedWrites = batchedAgentStatusRevision !== revisionAtStart + conversationDraft = null batchedPaneKeys = null batchedAgentStatusState = null batchedAgentStatusTouchedKeys = null @@ -248,6 +245,7 @@ export function createAgentStatusRuntime( } return result } finally { + conversationDraft = null batchedPaneKeys = null batchedAgentStatusState = null batchedAgentStatusTouchedKeys = null @@ -292,6 +290,7 @@ export function createAgentStatusRuntime( } return { + projectConversation, subscribeAgentStatusChanges, get, set, diff --git a/src/renderer/src/store/slices/agent-status/agent-status-slice-contract.ts b/src/renderer/src/store/slices/agent-status/agent-status-slice-contract.ts index 13c1787564df..eb75b409b592 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-slice-contract.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-slice-contract.ts @@ -29,12 +29,15 @@ import type { SleepingAgentLaunchConfig, SleepingAgentSessionRecord } from '../../../../../shared/agent-session-resume' +import type { AgentPaneConversationIdentity } from '../agent-pane-conversation' import type { AgentStatusStateUpdate } from './agent-status-runtime' import type { ProgramStatusFacet } from '../../../../../shared/program-status/facet' export type AgentStatusSlice = { /** Explicit agent status entries keyed by `${tabId}:${leafId}`; real-time only, not persisted. */ agentStatusByPaneKey: Record + /** Host-reported conversation identity only; transient and independent of agent occupancy. */ + agentConversationsByPaneKey: Record /** Store-local committed row changes, using writer-known keys rather than map discovery. */ subscribeAgentStatusChanges: (listener: AgentStatusChangeListener) => () => void /** Route external atomic status patches through the same commit boundary. */ diff --git a/src/renderer/src/store/slices/agent-status/agent-status.ts b/src/renderer/src/store/slices/agent-status/agent-status.ts index b52cc7b05a78..71ebea692c50 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status.ts @@ -61,6 +61,7 @@ export const createAgentStatusSlice: StateCreator, check: boolean) => + withEditorBufferRecords(withAgentConversationRetention(creator), check) + export function createTestStore() { // Why the record middleware: views of one file share a record only through it, as in the app. return create()( - withEditorBufferRecords( + withTestStorePolicies( (...a) => ({ ...createRepoSlice(...a), ...createSparsePresetsSlice(...a), diff --git a/src/shared/claimed-agent-pty-owner.ts b/src/shared/claimed-agent-pty-owner.ts index 93a9c63ea0af..dd5f233b5e09 100644 --- a/src/shared/claimed-agent-pty-owner.ts +++ b/src/shared/claimed-agent-pty-owner.ts @@ -1,3 +1,5 @@ +import type { PtyCreationDisposition } from './pty-creation-disposition' +export type { PtyCreationDisposition } from './pty-creation-disposition' import { randomUUID } from 'node:crypto' import type { AgentSessionClaimedSpawnResult, @@ -29,6 +31,7 @@ type ReservedOwner = { generation: string phase: 'reserved' promise: Promise + creation?: Promise } type LiveOwner = LiveAgentSessionOwner @@ -61,7 +64,8 @@ export class ClaimedAgentPtyOwnerRegistry { }> isLive?: (owner: LiveAgentSessionOwner) => boolean | Promise /** Called when this request waits on another request's start instead of starting its own. */ - onJoin?: () => void + onJoin?: (creation?: Promise) => void + creation?: Promise }): Promise { // Snapshot reused retry arguments before awaits so caller mutations cannot change registry keys. const requestedClaim = cloneClaim(args.claim) @@ -93,7 +97,7 @@ export class ClaimedAgentPtyOwnerRegistry { if (reserved.worktreeScopeDigest !== requestedClaim.worktreeScopeDigest) { throw new Error('agent_session_conflict') } - args.onJoin?.() + args.onJoin?.(reserved.creation) const result = await reserved.promise return { disposition: 'adopted', owner: cloneOwner(result.owner as LiveOwner) } } @@ -113,10 +117,11 @@ export class ClaimedAgentPtyOwnerRegistry { worktreeScopeDigest: requestedClaim.worktreeScopeDigest, generation, phase: 'reserved', + creation: args.creation, promise }) - let promotedOwner: LiveOwner | null = null + let published = false try { const spawned = await args.spawn({ generation }) const owner: LiveOwner = spawned.owner @@ -155,9 +160,9 @@ export class ClaimedAgentPtyOwnerRegistry { const keys = this.keysByPtyId.get(owner.ptyId) ?? new Set() keys.add(key) this.keysByPtyId.set(owner.ptyId, keys) - promotedOwner = owner // Index before awaited proof so an exit beating spawn completion can remove the matching generation. if (args.isLive && !(await args.isLive(cloneOwner(owner)))) { + this.release(owner.ptyId, owner.generation) throw new Error('agent_session_exited_during_start') } const current = this.live.get(key) @@ -169,17 +174,29 @@ export class ClaimedAgentPtyOwnerRegistry { owner: cloneOwner(owner) } resolveReservation(result) + published = true return result } catch (error) { - if (promotedOwner) { - this.release(promotedOwner.ptyId, promotedOwner.generation) - } rejectReservation(error) throw error } finally { - const current = this.reserved.get(key) - if (current?.generation === generation) { - this.reserved.delete(key) + const finish = () => { + if (this.reserved.get(key)?.generation === generation) { + this.reserved.delete(key) + } + } + if (published || !args.creation) { + finish() + } else { + void args.creation.then(async (disposition) => { + if (disposition.kind === 'unknown') { + return + } + if (disposition.kind === 'physical' || disposition.kind === 'failed') { + await disposition.completion + } + finish() + }) } } } diff --git a/src/shared/pty-creation-disposition.ts b/src/shared/pty-creation-disposition.ts new file mode 100644 index 000000000000..d1adbc8ae52e --- /dev/null +++ b/src/shared/pty-creation-disposition.ts @@ -0,0 +1,11 @@ +export type PtyCreationDisposition = + | { kind: 'no-fork' } + | { kind: 'unknown'; recover?: () => Promise } + // Exact native call threw without exporting a handle; physical outcome is unknown. + | { kind: 'failed'; completion: Promise; recover?: () => Promise } + | { + kind: 'physical' + completion: Promise + exitCode: () => number | null + recover?: () => Promise + }