diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 337325d76..9e16232cf 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -150,6 +150,13 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { await adapter.shutdown(id, { immediate: false }) expect(lastSubprocess.kill).toHaveBeenCalled() }) + + it('force-kills immediately when requested', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + await adapter.shutdown(id, { immediate: true }) + expect(lastSubprocess.kill).not.toHaveBeenCalled() + expect(lastSubprocess.forceKill).toHaveBeenCalled() + }) }) describe('sendSignal', () => { @@ -715,6 +722,27 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(false) }) + it('writes a final checkpoint before keepHistory shutdown', async () => { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + + const { id } = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: 'sleep-checkpoint' + }) + const checkpointSpy = vi.spyOn(historyAdapter.getHistoryManager()!, 'checkpoint') + + lastSubprocess._simulateData('fresh output before sleep\r\n') + await historyAdapter.shutdown(id, { immediate: true, keepHistory: true }) + + expect(checkpointSpy).toHaveBeenCalledWith( + id, + expect.objectContaining({ snapshotAnsi: expect.stringContaining('fresh output') }) + ) + expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(true) + }) + it('returns cold restore data when disk history has unclean shutdown', async () => { // Simulate a previous daemon crash: write history files without endedAt const sessionId = 'cold-restore-test' diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index ed82e978e..18ded8ec5 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -268,7 +268,12 @@ export class DaemonPtyAdapter implements IPtyProvider { } async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { - await this.client.request('kill', { sessionId: id }) + // Why: sleep/exact-stop must preserve restorable terminal history, + // so force a final checkpoint before killing the daemon session. + if (opts.keepHistory) { + await this.checkpointSessions([id], { final: true }) + } + await this.client.request('kill', { sessionId: id, immediate: opts.immediate ?? false }) this.activeSessionIds.delete(id) this.dirtySessionVersions.delete(id) this.coldRestoreCache.delete(id) diff --git a/src/main/daemon/daemon-pty-provider.ts b/src/main/daemon/daemon-pty-provider.ts index fa8252488..7c569f755 100644 --- a/src/main/daemon/daemon-pty-provider.ts +++ b/src/main/daemon/daemon-pty-provider.ts @@ -64,8 +64,8 @@ export class DaemonPtyProvider { this.client.notify('resize', { sessionId: id, cols, rows }) } - async shutdown(id: string, _opts: { immediate?: boolean; keepHistory?: boolean }): Promise { - await this.client.request('kill', { sessionId: id }) + async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { + await this.client.request('kill', { sessionId: id, immediate: opts.immediate ?? false }) } onData(callback: (payload: { id: string; data: string }) => void): () => void { diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index fd99feb78..47d44aa80 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -145,6 +145,15 @@ describe('DaemonPtyRouter', () => { expect(current.hasPty).not.toHaveBeenCalledWith('legacy-session') }) + it('fails listProcesses closed when any routed adapter cannot list sessions', async () => { + const current = createAdapter('current', ['current-session']) + const legacy = createAdapter('legacy', ['legacy-session']) + vi.mocked(legacy.listProcesses).mockRejectedValueOnce(new Error('legacy unavailable')) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + + await expect(router.listProcesses()).rejects.toThrow('legacy unavailable') + }) + it('merges startup reconciliation and updates route mappings', async () => { const current = createAdapter('current', [], { alive: ['current-alive'], diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 49274862f..14fe2d3dd 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -121,10 +121,10 @@ export class DaemonPtyRouter implements IPtyProvider { } async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { - const results = await Promise.allSettled( - this.allAdapters().map((adapter) => adapter.listProcesses()) - ) - return results.flatMap((result) => (result.status === 'fulfilled' ? result.value : [])) + // Why: runtime exact-stop/liveness flows must fail closed if any adapter + // cannot provide a trustworthy process list. + const results = await Promise.all(this.allAdapters().map((adapter) => adapter.listProcesses())) + return results.flat() } async getDefaultShell(): Promise { diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 8f76b0e39..b69d0464e 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -345,7 +345,7 @@ export class DaemonServer { case 'kill': this.lastInputAtBySessionId.delete(request.payload.sessionId) - this.host.kill(request.payload.sessionId) + this.host.kill(request.payload.sessionId, { immediate: request.payload.immediate }) return {} case 'signal': diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index 4f998e460..4a37d4a79 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -235,6 +235,22 @@ describe('TerminalHost', () => { expect(host.isKilled('session-1')).toBe(true) }) + it('force-kills immediately when requested', async () => { + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + host.kill('session-1', { immediate: true }) + + expect(lastSubprocess.kill).not.toHaveBeenCalled() + expect(lastSubprocess.forceKill).toHaveBeenCalled() + expect(lastSubprocess.dispose).toHaveBeenCalled() + expect(host.isKilled('session-1')).toBe(true) + }) + it('throws for non-existent session', () => { expect(() => host.kill('missing')).toThrow('Session not found') }) diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 2329c5fee..8ba3ea910 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -160,9 +160,13 @@ export class TerminalHost { this.getAliveSession(sessionId).resize(cols, rows) } - kill(sessionId: string): void { + kill(sessionId: string, opts: { immediate?: boolean } = {}): void { const session = this.getAliveSession(sessionId) this.recordTombstone(sessionId) + if (opts.immediate) { + session.forceKillAndDisposeSubprocess() + return + } session.kill() } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index a64e7cf2c..cea72e284 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -3,8 +3,10 @@ // when daemon-baked behavior cannot be delivered by on-disk wrapper refresh. // Why: bump when adding daemon wire behavior so same-version old daemons do // not silently accept the handshake and then reject new RPCs. -export const PROTOCOL_VERSION = 13 -export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as const +export const PROTOCOL_VERSION = 14 +export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13 +] as const // ─── Session State Machine ────────────────────────────────────────── export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited' @@ -134,6 +136,7 @@ export type KillRequest = { type: 'kill' payload: { sessionId: string + immediate?: boolean } } diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 675f18368..920ecedaf 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -298,6 +298,7 @@ describe('registerPtyHandlers', () => { }) afterEach(() => { + vi.useRealTimers() unregisterSshPtyProvider('ssh-1') setLocalPtyProvider(new LocalPtyProvider()) if (savedOpenCodeConfigDir !== undefined) { @@ -1981,6 +1982,164 @@ describe('registerPtyHandlers', () => { expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1) }) + it('passes keepHistory through runtime controller stopAndWait', async () => { + vi.useFakeTimers() + const shutdown = vi.fn(async () => undefined) + const store = { + markSshRemotePtyLease: vi.fn() + } + const runtime = { + setPtyController: vi.fn(), + onPtyExit: vi.fn() + } + registerSshPtyProvider('ssh-1', { + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown, + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + setPtyOwnership('remote-pty', 'ssh-1') + handlers.clear() + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise + } + + const stopPromise = controller.stopAndWait('remote-pty', { keepHistory: true }) + await vi.advanceTimersByTimeAsync(1_200) + await expect(stopPromise).resolves.toBe(true) + + expect(shutdown).toHaveBeenCalledWith('remote-pty', { + immediate: true, + keepHistory: true + }) + expect(store.markSshRemotePtyLease).toHaveBeenCalledWith( + 'ssh-1', + 'remote-pty', + 'terminated' + ) + expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1) + }) + + it('runtime controller stopAndWait fails when keepHistory allows the PTY to revive', async () => { + vi.useFakeTimers() + const shutdown = vi.fn(async () => undefined) + const listProcesses = vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 'local-pty', cwd: '/tmp/demo', title: 'shell' }]) + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown, + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses, + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + const runtime = { + setPtyController: vi.fn(), + onPtyExit: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise + } + + const stopPromise = controller.stopAndWait('local-pty', { keepHistory: true }) + await vi.advanceTimersByTimeAsync(200) + + await expect(stopPromise).resolves.toBe(false) + expect(shutdown).toHaveBeenCalledWith('local-pty', { + immediate: true, + keepHistory: true + }) + expect(runtime.onPtyExit).not.toHaveBeenCalled() + }) + + it('runtime controller stopAndWait preserves ownership when proof fails after shutdown', async () => { + const shutdown = vi.fn(async () => undefined) + const listProcesses = vi.fn().mockRejectedValue(new Error('legacy unavailable')) + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown, + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses, + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + const runtime = { + setPtyController: vi.fn(), + onPtyExit: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise + } + + await expect(controller.stopAndWait('local-pty', { keepHistory: true })).resolves.toBe( + false + ) + + expect(shutdown).toHaveBeenCalledWith('local-pty', { + immediate: true, + keepHistory: true + }) + expect(runtime.onPtyExit).not.toHaveBeenCalled() + }) + it('runtime controller kill routes app-scoped SSH ids through the parsed provider when ownership is absent', async () => { const localShutdown = vi.fn() setLocalPtyProvider({ @@ -2354,6 +2513,46 @@ describe('registerPtyHandlers', () => { ) }) + it('synthesizes runtime exit after ordinary daemon-backed pty kill', async () => { + const shutdown = vi.fn(async () => undefined) + const runtime = { + setPtyController: vi.fn(), + onPtyExit: vi.fn() + } + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown, + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + + await handlers.get('pty:kill')!(null, { id: 'local-pty', keepHistory: true }) + + expect(shutdown).toHaveBeenCalledWith('local-pty', { + immediate: true, + keepHistory: true + }) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', -1) + }) + it('waits for the desktop startup barrier before renderer local spawns resolve the provider', async () => { const barrier = makeDeferred() registerPtyHandlers( diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 71bdc953f..894e93ac2 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -91,6 +91,8 @@ const ptySizes = new Map() const lastInputAtByPty = new Map() const interactiveOutputCharsByPty = new Map() const activeRendererPtys = new Set() +const KEEP_HISTORY_STOP_SETTLE_MS = 1_000 +const KEEP_HISTORY_STOP_POLL_MS = 100 // Why: the agent-hooks server caches per-paneKey state (last prompt, last // tool) that otherwise grows unbounded as panes come and go. Track the // spawn-time paneKey so clearProviderPtyState can clear that cache on PTY @@ -289,6 +291,40 @@ function isPtyAlreadyGoneError(err: unknown): boolean { return isSshPtyNotFoundError(err) || /Session not found/i.test(message) } +function delay(ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms) + if (typeof timer.unref === 'function') { + timer.unref() + } + }) +} + +async function isProviderPtyLive(provider: IPtyProvider, ptyId: string): Promise { + return (await provider.listProcesses()).some((session) => session.id === ptyId) +} + +async function verifyPtyStopped( + provider: IPtyProvider, + ptyId: string, + opts: { keepHistory?: boolean } | undefined +): Promise { + if (await isProviderPtyLive(provider, ptyId)) { + return false + } + if (!opts?.keepHistory) { + return true + } + const deadline = Date.now() + KEEP_HISTORY_STOP_SETTLE_MS + while (Date.now() < deadline) { + await delay(KEEP_HISTORY_STOP_POLL_MS) + if (await isProviderPtyLive(provider, ptyId)) { + return false + } + } + return true +} + function finishPtyShutdown( id: string, connectionId: string | null | undefined, @@ -1916,6 +1952,52 @@ export function registerPtyHandlers( }) return true }, + stopAndWait: async (ptyId, opts) => { + let provider: IPtyProvider + let connectionId: string | null | undefined = ptyOwnership.get(ptyId) + const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null + connectionId ??= parsedSshId?.connectionId + try { + provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId) + } catch { + if (connectionId) { + // Why: an absent SSH provider means there is no live target left to + // await, but the relay lease must still be tombstoned. + finishPtyShutdown(ptyId, connectionId, store) + runtime?.onPtyExit(ptyId, -1) + return true + } + return false + } + try { + await provider.shutdown(ptyId, { + immediate: true, + keepHistory: opts?.keepHistory ?? false + }) + } catch (err) { + if (!isPtyAlreadyGoneError(err)) { + console.warn( + `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` + ) + return false + } + } + try { + if (!(await verifyPtyStopped(provider, ptyId, opts))) { + return false + } + } catch (err) { + console.warn( + `[pty] Failed to verify PTY ${ptyId} stopped: ${ + err instanceof Error ? err.message : String(err) + }` + ) + return false + } + finishPtyShutdown(ptyId, connectionId, store) + runtime?.onPtyExit(ptyId, -1) + return true + }, getForegroundProcess: async (ptyId) => { try { return await getProviderForPty(ptyId).getForegroundProcess(ptyId) @@ -1944,7 +2026,7 @@ export function registerPtyHandlers( listProcesses: async () => { const providerSessions = await Promise.all([ localProvider.listProcesses(), - ...Array.from(sshProviders.values(), (provider) => provider.listProcesses().catch(() => [])) + ...Array.from(sshProviders.values(), (provider) => provider.listProcesses()) ]) return providerSessions.flat() }, @@ -2748,6 +2830,7 @@ export function registerPtyHandlers( // provider is unregistered; hydrated app-scoped ids can also arrive // before ownership is rebuilt. Tombstone instead of falling back local. finishPtyShutdown(args.id, connectionId, store) + runtime?.onPtyExit(args.id, -1) return } try { @@ -2768,6 +2851,7 @@ export function registerPtyHandlers( // and daemon shutdown paths do not emit onExit through the local provider's // listener. Explicit cleanup is idempotent and covers already-dead PTYs. finishPtyShutdown(args.id, connectionId, store) + runtime?.onPtyExit(args.id, -1) }) ipcMain.handle( diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index b101adffd..cfa288264 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1075,6 +1075,7 @@ describe('OrcaRuntimeService', () => { expect(terminals.terminals[0]).toMatchObject({ worktreeId: 'repo-1::/tmp/worktree-a', branch: 'feature/foo', + ptyId: 'pty-1', title: 'Claude', preview: 'hello from terminal' }) @@ -11546,6 +11547,384 @@ describe('OrcaRuntimeService', () => { expect(killed).toBe(false) }) + it('stops exactly the expected live PTYs for a worktree', async () => { + const runtime = new OrcaRuntimeService(store) + const stopped: string[] = [] + const processLists = [[{ id: 'pty-1', cwd: '/tmp/worktree-a', title: 'Claude' }], []] + runtime.setPtyController({ + write: () => true, + kill: () => false, + stopAndWait: async (ptyId, opts) => { + stopped.push(ptyId) + expect(opts).toEqual({ keepHistory: true }) + runtime.onPtyExit(ptyId, -1) + return true + }, + getForegroundProcess: async () => null, + listProcesses: async () => processLists.shift() ?? [] + }) + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Claude', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + + await expect( + runtime.stopExactTerminalsForWorktree('id:repo-1::/tmp/worktree-a', ['pty-1'], { + keepHistory: true + }) + ).resolves.toEqual({ + stopped: 1, + stoppedPtyIds: ['pty-1'], + livePtyIds: ['pty-1'], + postStopVerified: true + }) + expect(stopped).toEqual(['pty-1']) + }) + + it('reports recoverable post-stop liveness failure after exact terminal stop', async () => { + const runtime = new OrcaRuntimeService(store) + const stopped: string[] = [] + const processLists = [ + [{ id: 'pty-1', cwd: '/tmp/worktree-a', title: 'Claude' }], + new Error('daemon unavailable') + ] + runtime.setPtyController({ + write: () => true, + kill: () => false, + stopAndWait: async (ptyId) => { + stopped.push(ptyId) + runtime.onPtyExit(ptyId, -1) + return true + }, + getForegroundProcess: async () => null, + listProcesses: async () => { + const next = processLists.shift() + if (next instanceof Error) { + throw next + } + return next ?? [] + } + }) + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Claude', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + + await expect( + runtime.stopExactTerminalsForWorktree('id:repo-1::/tmp/worktree-a', ['pty-1']) + ).resolves.toEqual({ + stopped: 1, + stoppedPtyIds: ['pty-1'], + livePtyIds: ['pty-1'], + postStopVerified: false, + postStopFailure: 'terminal_liveness_unavailable' + }) + expect(stopped).toEqual(['pty-1']) + }) + + it('rejects exact terminal stop when async PTY stop fails', async () => { + const runtime = new OrcaRuntimeService(store) + const stopped: string[] = [] + runtime.setPtyController({ + write: () => true, + kill: () => false, + stopAndWait: async (ptyId, opts) => { + stopped.push(ptyId) + expect(opts).toEqual({ keepHistory: true }) + return false + }, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: 'pty-1', cwd: '/tmp/worktree-a', title: 'Claude' }] + }) + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Claude', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + + await expect( + runtime.stopExactTerminalsForWorktree('id:repo-1::/tmp/worktree-a', ['pty-1'], { + keepHistory: true + }) + ).rejects.toThrow('terminal_exact_stop_failed') + expect(stopped).toEqual(['pty-1']) + }) + + it('rejects exact terminal stop when the live PTY set has extras', async () => { + const runtime = new OrcaRuntimeService(store) + const stopped: string[] = [] + runtime.setPtyController({ + write: () => true, + kill: () => false, + stopAndWait: async (ptyId) => { + stopped.push(ptyId) + runtime.onPtyExit(ptyId, -1) + return true + }, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { id: 'pty-1', cwd: '/tmp/worktree-a', title: 'Claude' }, + { id: 'pty-shell', cwd: '/tmp/worktree-a', title: 'Shell' } + ] + }) + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Claude', + activeLeafId: 'pane:1', + layout: null + }, + { + tabId: 'tab-2', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Shell', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + }, + { + tabId: 'tab-2', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 2, + ptyId: 'pty-shell' + } + ] + }) + + await expect( + runtime.stopExactTerminalsForWorktree('id:repo-1::/tmp/worktree-a', ['pty-1']) + ).rejects.toThrow('terminal_stop_pty_set_mismatch') + expect(stopped).toEqual([]) + }) + + it('rejects exact terminal stop for multiple expected PTYs before stopping anything', async () => { + const runtime = new OrcaRuntimeService(store) + const stopped: string[] = [] + runtime.setPtyController({ + write: () => true, + kill: () => false, + stopAndWait: async (ptyId) => { + stopped.push(ptyId) + runtime.onPtyExit(ptyId, -1) + return true + }, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { id: 'pty-1', cwd: '/tmp/worktree-a', title: 'Claude' }, + { id: 'pty-2', cwd: '/tmp/worktree-a', title: 'Codex' } + ] + }) + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Claude', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + }, + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:2', + paneRuntimeId: 2, + ptyId: 'pty-2' + } + ] + }) + + await expect( + runtime.stopExactTerminalsForWorktree('id:repo-1::/tmp/worktree-a', ['pty-1', 'pty-2']) + ).rejects.toThrow('terminal_exact_stop_requires_single_pty') + expect(stopped).toEqual([]) + }) + + it('uses fresh post-stop liveness instead of stale renderer leaves', async () => { + const runtime = new OrcaRuntimeService(store) + const stopped: string[] = [] + const processLists = [[{ id: 'pty-1', cwd: '/tmp/worktree-a', title: 'Claude' }], []] + runtime.setPtyController({ + write: () => true, + kill: () => false, + stopAndWait: async (ptyId) => { + stopped.push(ptyId) + runtime.onPtyExit(ptyId, -1) + return true + }, + getForegroundProcess: async () => null, + listProcesses: async () => processLists.shift() ?? [] + }) + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Claude', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + }, + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:2', + paneRuntimeId: 2, + ptyId: 'stale-pty' + } + ] + }) + + await expect( + runtime.stopExactTerminalsForWorktree('id:repo-1::/tmp/worktree-a', ['pty-1']) + ).resolves.toMatchObject({ + stoppedPtyIds: ['pty-1'] + }) + expect(stopped).toEqual(['pty-1']) + }) + + it('omits stale renderer leaves when fresh PTY liveness is required', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Stale', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'stale-pty' + } + ] + }) + + const terminals = await runtime.listTerminals('id:repo-1::/tmp/worktree-a', undefined, { + requireFreshPtyLiveness: true + }) + + expect(terminals.terminals).toEqual([]) + }) + + it('fails terminal listing closed when fresh PTY liveness is required and unavailable', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => { + throw new Error('provider unavailable') + } + }) + + await expect( + runtime.listTerminals('id:repo-1::/tmp/worktree-a', undefined, { + requireFreshPtyLiveness: true + }) + ).rejects.toThrow('terminal_liveness_unavailable') + }) + it('rejects invalid positive limits for bounded list commands', async () => { const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 9cc5611b2..9f5b4726e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -895,6 +895,7 @@ type RuntimePtyController = { }): Promise<{ id: string }> write(ptyId: string, data: string): boolean kill(ptyId: string): boolean + stopAndWait?(ptyId: string, opts?: { keepHistory?: boolean }): Promise getForegroundProcess(ptyId: string): Promise hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise @@ -6385,7 +6386,8 @@ export class OrcaRuntimeService { async listTerminals( worktreeSelector?: string, - limit = DEFAULT_TERMINAL_LIST_LIMIT + limit = DEFAULT_TERMINAL_LIST_LIMIT, + opts: { requireFreshPtyLiveness?: boolean } = {} ): Promise { if (!Number.isInteger(limit) || limit <= 0) { throw new Error('invalid_limit') @@ -6443,7 +6445,11 @@ export class OrcaRuntimeService { : targetWorktreeId ? [] : [...worktreesById.values()] - await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees, targetWorktreeId) + const refreshedPtyLiveness = + await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees, targetWorktreeId) + if (opts.requireFreshPtyLiveness && !refreshedPtyLiveness) { + throw new Error('terminal_liveness_unavailable') + } const livePtyWorktreeIds = new Set() for (const pty of this.ptysById.values()) { @@ -6459,6 +6465,9 @@ export class OrcaRuntimeService { if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) { continue } + if (opts.requireFreshPtyLiveness && leaf.ptyId && !refreshedPtyLiveness?.has(leaf.ptyId)) { + continue + } if (!leaf.ptyId && livePtyWorktreeIds.has(leaf.worktreeId)) { continue } @@ -6476,6 +6485,9 @@ export class OrcaRuntimeService { if (!pty.connected || ptyIdsFromLeaves.has(pty.ptyId)) { continue } + if (opts.requireFreshPtyLiveness && !refreshedPtyLiveness?.has(pty.ptyId)) { + continue + } if (targetWorktreeId && pty.worktreeId !== targetWorktreeId) { continue } @@ -13033,6 +13045,109 @@ export class OrcaRuntimeService { return { stopped } } + async stopExactTerminalsForWorktree( + worktreeSelector: string, + expectedPtyIds: readonly string[], + opts: { keepHistory?: boolean } = {} + ): Promise<{ + stopped: number + stoppedPtyIds: string[] + livePtyIds: string[] + postStopVerified: boolean + postStopFailure?: string + remainingLivePtyIds?: string[] + }> { + // Why: hibernation may commit sleeping state only after the runtime proves + // the selected PTYs are still the complete live set for this worktree. + const graphEpoch = this.captureReadyGraphEpoch() + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + this.assertStableReadyGraph(graphEpoch) + const expected = new Set(expectedPtyIds.filter((ptyId) => ptyId.length > 0)) + if (expected.size !== 1) { + throw new Error('terminal_exact_stop_requires_single_pty') + } + const resolvedWorktrees = [...(await this.getResolvedWorktreeMap()).values()] + const refreshedPtyLiveness = + await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) + if (!refreshedPtyLiveness) { + throw new Error('terminal_liveness_unavailable') + } + const livePtyIds = this.getLivePtyIdsForWorktree(worktree.id, refreshedPtyLiveness) + if (!setsEqual(livePtyIds, expected)) { + const error = Object.assign(new Error('terminal_stop_pty_set_mismatch'), { + livePtyIds: [...livePtyIds].sort(), + expectedPtyIds: [...expected].sort() + }) + throw error + } + + if (!this.ptyController?.stopAndWait) { + throw new Error('terminal_exact_stop_unavailable') + } + + const stoppedPtyIds: string[] = [] + for (const ptyId of [...expected].sort()) { + if (!(await this.ptyController.stopAndWait(ptyId, { keepHistory: opts.keepHistory }))) { + throw Object.assign(new Error('terminal_exact_stop_failed'), { ptyId }) + } + stoppedPtyIds.push(ptyId) + } + const postStopLiveness = await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) + if (!postStopLiveness) { + return { + stopped: stoppedPtyIds.length, + stoppedPtyIds, + livePtyIds: [...livePtyIds].sort(), + postStopVerified: false, + postStopFailure: 'terminal_liveness_unavailable' + } + } + const remainingLivePtyIds = this.getLivePtyIdsForWorktree(worktree.id, postStopLiveness) + if (remainingLivePtyIds.size > 0) { + return { + stopped: stoppedPtyIds.length, + stoppedPtyIds, + livePtyIds: [...livePtyIds].sort(), + postStopVerified: false, + postStopFailure: 'terminal_exact_stop_still_live', + remainingLivePtyIds: [...remainingLivePtyIds].sort() + } + } + return { + stopped: stoppedPtyIds.length, + stoppedPtyIds, + livePtyIds: [...livePtyIds].sort(), + postStopVerified: true + } + } + + private getLivePtyIdsForWorktree( + worktreeId: string, + freshPtyIds?: ReadonlySet + ): Set { + const ptyIds = new Set() + for (const leaf of this.leaves.values()) { + if ( + leaf.worktreeId === worktreeId && + leaf.connected && + leaf.ptyId && + (!freshPtyIds || freshPtyIds.has(leaf.ptyId)) + ) { + ptyIds.add(leaf.ptyId) + } + } + for (const pty of this.ptysById.values()) { + if ( + pty.worktreeId === worktreeId && + pty.connected && + (!freshPtyIds || freshPtyIds.has(pty.ptyId)) + ) { + ptyIds.add(pty.ptyId) + } + } + return ptyIds + } + async hasTerminalsForWorktree(worktreeSelector: string): Promise { const graphEpoch = this.captureReadyGraphEpoch() const worktree = await this.resolveWorktreeSelector(worktreeSelector) @@ -14141,9 +14256,9 @@ export class OrcaRuntimeService { private async refreshPtyWorktreeRecordsFromController( resolvedWorktrees: ResolvedWorktree[], targetWorktreeId: string | null = null - ): Promise { + ): Promise | null> { if (!this.ptyController?.listProcesses) { - return + return null } const sessionsResult = await withTimeoutResult( this.ptyController.listProcesses(), @@ -14151,7 +14266,7 @@ export class OrcaRuntimeService { ) if (!sessionsResult.ok) { // Why: a transient controller failure is not evidence that retained PTYs exited. - return + return null } const sessions = sessionsResult.value const livePtyIds = new Set(sessions.map((session) => session.id)) @@ -14175,6 +14290,7 @@ export class OrcaRuntimeService { } } this.pruneDisconnectedPtyRecords() + return livePtyIds } private pruneDisconnectedPtyTranscript(pty: RuntimePtyWorktreeRecord): void { @@ -14273,6 +14389,7 @@ export class OrcaRuntimeService { return { handle: this.issueHandle(leaf), + ptyId: leaf.ptyId, worktreeId: leaf.worktreeId, worktreePath: worktree?.path ?? '', branch: worktree?.branch ?? '', @@ -15299,6 +15416,7 @@ export class OrcaRuntimeService { return { handle: this.issuePtyHandle(pty), + ptyId: pty.ptyId, worktreeId: pty.worktreeId, worktreePath: worktree?.path ?? '', branch: worktree?.branch ?? '', @@ -19218,6 +19336,18 @@ function inferWorktreeIdFromPtyId(ptyId: string): string | null { return parsePtySessionId(ptyId).worktreeId } +function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { + if (a.size !== b.size) { + return false + } + for (const value of a) { + if (!b.has(value)) { + return false + } + } + return true +} + function parseRuntimeWorktreeId( worktreeId: string ): { repoId: string; worktreePath: string } | null { diff --git a/src/main/runtime/orchestration/groups.test.ts b/src/main/runtime/orchestration/groups.test.ts index 7371e0853..04799b7ec 100644 --- a/src/main/runtime/orchestration/groups.test.ts +++ b/src/main/runtime/orchestration/groups.test.ts @@ -8,6 +8,7 @@ function makeSummary( ): RuntimeTerminalSummary { return { handle, + ptyId: opts.ptyId ?? handle, worktreeId: opts.worktreeId ?? 'wt_default', worktreePath: opts.worktreePath ?? '/tmp/wt', branch: opts.branch ?? 'main', diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 7c858ad32..5e3df7475 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -176,6 +176,7 @@ describe('orchestration RPC methods', () => { ): RuntimeTerminalSummary { return { handle, + ptyId: opts.ptyId ?? handle, worktreeId: opts.worktreeId ?? 'wt_default', worktreePath: opts.worktreePath ?? '/tmp/wt', branch: opts.branch ?? 'main', diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 687f5f11e..ebdc5078f 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -389,7 +389,8 @@ const TerminalHandle = z.object({ const TerminalListParams = z.object({ worktree: OptionalString, - limit: OptionalFiniteNumber + limit: OptionalFiniteNumber, + requireFreshPtyLiveness: z.boolean().optional() }) const TerminalResolveActive = z.object({ @@ -485,6 +486,11 @@ const TerminalStop = z.object({ worktree: requiredString('Missing worktree selector') }) +const TerminalStopExact = TerminalStop.extend({ + expectedPtyIds: z.array(requiredString('Missing PTY ID')).min(1), + keepHistory: z.boolean().optional() +}) + const AgentTeamsTmuxCompat = z.object({ teamId: requiredString('Missing agent team ID'), token: requiredString('Missing agent team token'), @@ -617,7 +623,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'terminal.list', params: TerminalListParams, - handler: async (params, { runtime }) => runtime.listTerminals(params.worktree, params.limit) + handler: async (params, { runtime }) => + runtime.listTerminals(params.worktree, params.limit, { + requireFreshPtyLiveness: params.requireFreshPtyLiveness + }) }), defineMethod({ name: 'terminal.resolveActive', @@ -747,6 +756,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ params: TerminalStop, handler: async (params, { runtime }) => runtime.stopTerminalsForWorktree(params.worktree) }), + defineMethod({ + name: 'terminal.stopExact', + params: TerminalStopExact, + handler: async (params, { runtime }) => + runtime.stopExactTerminalsForWorktree(params.worktree, params.expectedPtyIds, { + keepHistory: params.keepHistory + }) + }), defineMethod({ name: 'terminal.resizeForClient', params: TerminalResizeForClient, diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 61f905a7e..21b157217 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -2147,7 +2147,10 @@ describe('OrcaRuntimeRpcServer', () => { }) expect(listResponse).toMatchObject({ id: 'req_list', - ok: true + ok: true, + result: { + terminals: [expect.objectContaining({ ptyId: 'pty-1' })] + } }) const handle = ( diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 8b18c4dcf..ef23d835a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -5375,6 +5375,83 @@ describe('connectPanePty', () => { expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:terminal-1') }) + it('cold-spawns slept remote runtime PTYs instead of reattaching the preserved handle', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment('env-1') + const restoredPtyId = 'remote:env-1@@terminal-1' + const freshPtyId = 'remote:env-1@@terminal-2' + const transport = createMockTransport(freshPtyId) + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks, sessionId }: Record) => { + capturedDataCallback.current = (callbacks as ConnectCallbacks | undefined)?.onData ?? null + if (sessionId) { + throw new Error('slept remote runtime PTYs must not reattach by sessionId') + } + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + onPtySpawn?.(freshPtyId) + return freshPtyId + } + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: restoredPtyId }] + }, + ptyIdsByTabId: { + 'tab-1': [] + }, + settings: { + ...mockStoreState.settings, + activeRuntimeEnvironmentId: 'env-1', + agentCmdOverrides: {} + }, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: restoredPtyId } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + capturedDataCallback.current?.('shell ready\r\n') + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(transport.attach).not.toHaveBeenCalled() + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(transport.connect).toHaveBeenCalledWith( + expect.not.objectContaining({ sessionId: expect.any(String) }) + ) + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, null) + expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', restoredPtyId) + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, freshPtyId) + expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', freshPtyId) + expect(transport.sendInput).toHaveBeenCalledWith( + "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'\r" + ) + expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) + }) + it('constructs restored encoded remote PTYs with their owning runtime environment', async () => { const { connectPanePty } = await import('./pty-connection') const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 49c85bfa7..3a131eeb1 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -3378,10 +3378,15 @@ export function connectPanePty( const existingPtyId = storeSnapshot.tabsByWorktree[deps.worktreeId]?.find( (t) => t.id === deps.tabId )?.ptyId + const hasSleepingAgentSession = Boolean(storeSnapshot.sleepingAgentSessionsByPaneKey[cacheKey]) const restoredSessionId = restoredPtyId ?? null + const sleptRemoteRuntimeSessionId = + restoredSessionId && isRemoteRuntimePtyId(restoredSessionId) && hasSleepingAgentSession + ? restoredSessionId + : null const detachedLivePtyId = - existingPtyId && !hadExistingPaneTransportAtConnect + existingPtyId && !hadExistingPaneTransportAtConnect && !sleptRemoteRuntimeSessionId ? restoredSessionId ? restoredSessionId === existingPtyId ? restoredSessionId @@ -3389,11 +3394,18 @@ export function connectPanePty( : existingPtyId : null const detachedRemoteLeafPtyId = - restoredSessionId && isRemoteRuntimePtyId(restoredSessionId) ? restoredSessionId : null + restoredSessionId && isRemoteRuntimePtyId(restoredSessionId) && !hasSleepingAgentSession + ? restoredSessionId + : null const candidateReattachSessionId = restoredSessionId && restoredSessionId !== detachedLivePtyId ? restoredSessionId : detachedLivePtyId + if (sleptRemoteRuntimeSessionId) { + deps.syncPanePtyLayoutBinding(pane.id, null) + deps.clearTabPtyId(deps.tabId, sleptRemoteRuntimeSessionId) + prepareColdRestoreAgentResumeCommand() + } const currentTabLivePtyIds = storeSnapshot.ptyIdsByTabId[deps.tabId] ?? [] const candidateHasEagerBuffer = Boolean( candidateReattachSessionId && diff --git a/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts b/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts index 1b9c3859c..9835a9124 100644 --- a/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts @@ -459,6 +459,42 @@ describe('dispatchTerminalNotification', () => { expect(mockState.markTerminalPaneUnread).not.toHaveBeenCalled() }) + it('drops final-flush notifications for suppressed live ptys', () => { + mockState.suppressedPtyExitIds = { 'pty-1': true } + + dispatchTerminalNotification('wt-primary', { + source: 'terminal-bell', + terminalTitle: 'codex', + paneKey + }) + + expect(window.api.notifications.dispatch).not.toHaveBeenCalled() + expect(mockState.markWorktreeUnread).not.toHaveBeenCalled() + expect(mockState.markTerminalTabUnread).not.toHaveBeenCalled() + expect(mockState.markTerminalPaneUnread).not.toHaveBeenCalled() + }) + + it('drops layout-fallback notifications when all tab PTYs are suppressed', () => { + mockState.suppressedPtyExitIds = { 'pty-1': true } + mockState.terminalLayoutsByTabId['tab-1'] = { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + ptyIdsByLeafId: {} + } + + dispatchTerminalNotification('wt-primary', { + source: 'terminal-bell', + terminalTitle: 'codex', + paneKey + }) + + expect(window.api.notifications.dispatch).not.toHaveBeenCalled() + expect(mockState.markWorktreeUnread).not.toHaveBeenCalled() + expect(mockState.markTerminalTabUnread).not.toHaveBeenCalled() + expect(mockState.markTerminalPaneUnread).not.toHaveBeenCalled() + }) + it('still drops stale notifications when neither pty liveness nor fresh hook status exists', () => { mockState.ptyIdsByTabId = {} mockState.agentStatusByPaneKey[paneKey] = { diff --git a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts index a0810258f..ee2dd0c1c 100644 --- a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts @@ -27,7 +27,9 @@ export type TerminalNotificationEvent = { function hasLivePtyForWorktree(state: StoreSnapshot, candidateWorktreeId: string): boolean { const tabs = state.tabsByWorktree[candidateWorktreeId] ?? [] - return tabs.some((tab) => (state.ptyIdsByTabId[tab.id] ?? []).length > 0) + return tabs.some((tab) => + (state.ptyIdsByTabId[tab.id] ?? []).some((ptyId) => !isSuppressedPtyHint(state, ptyId)) + ) } function hasLivePtyForPaneKey(state: StoreSnapshot, paneKey: string | undefined): boolean { @@ -35,7 +37,10 @@ function hasLivePtyForPaneKey(state: StoreSnapshot, paneKey: string | undefined) return false } const tabId = getPaneKeyTabId(paneKey) - return tabId !== null && (state.ptyIdsByTabId[tabId] ?? []).length > 0 + return ( + tabId !== null && + (state.ptyIdsByTabId[tabId] ?? []).some((ptyId) => !isSuppressedPtyHint(state, ptyId)) + ) } function hasLivePtyForNotification( @@ -76,7 +81,9 @@ function isCurrentLivePaneKey(state: StoreSnapshot, worktreeId: string, paneKey: return false } - const livePtyIds = state.ptyIdsByTabId[parsed.tabId] ?? [] + const livePtyIds = (state.ptyIdsByTabId[parsed.tabId] ?? []).filter( + (ptyId) => !isSuppressedPtyHint(state, ptyId) + ) if (livePtyIds.length === 0) { return false } diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts index 84af2e585..c1eac14c3 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts @@ -16,10 +16,23 @@ import { recordAgentHibernationPaneOutput, resetAgentHibernationOutputActivityForTests } from './agent-hibernation-output-activity' +import { createCompatibleRuntimeStatusResponseIfNeeded } from '../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../runtime/runtime-rpc-client' +import type { AppState } from '@/store/types' const NOW = 10_000_000 const LEAF = '11111111-1111-4111-8111-111111111111' +const mockRuntimeEnvironmentCall = vi.fn() + +vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: mockRuntimeEnvironmentCall + } + } +}) + function tab(): TerminalTab { return { id: 'tab-1', @@ -58,7 +71,8 @@ function entry(): AgentStatusEntry { } function installEligibleState( - shutdownWorktreeTerminals = vi.fn() + shutdownWorktreeTerminals = vi.fn(), + overrides: Partial = {} ): typeof shutdownWorktreeTerminals { const e = entry() useAppStore.setState({ @@ -73,16 +87,84 @@ function installEligibleState( agentStatusByPaneKey: { [e.paneKey]: e }, sleepingAgentSessionsByPaneKey: {}, lastTerminalInputAtByPaneKey: {}, - shutdownWorktreeTerminals: shutdownWorktreeTerminals as never + shutdownWorktreeTerminals: shutdownWorktreeTerminals as never, + ...overrides }) return shutdownWorktreeTerminals } +function runtimeListResult(ptyIds: string[], truncated = false) { + return { + terminals: ptyIds.map((ptyId) => ({ + handle: `handle-${ptyId}`, + ptyId, + worktreeId: 'wt-bg', + worktreePath: '/tmp/wt-bg', + branch: 'feature', + tabId: `pty:${ptyId}`, + leafId: `pty:${ptyId}`, + title: 'Agent', + connected: true, + writable: true, + lastOutputAt: null, + preview: '' + })), + totalCount: ptyIds.length, + truncated + } +} + +function installRuntimeListResponses( + ...responses: (ReturnType | Error)[] +): void { + const queue = [...responses] + mockRuntimeEnvironmentCall.mockImplementation((args: { method: string }) => { + const compatible = createCompatibleRuntimeStatusResponseIfNeeded(args) + if (compatible) { + return Promise.resolve(compatible) + } + if (args.method === 'terminal.list') { + const response = queue.shift() ?? runtimeListResult(['pty-1']) + if (response instanceof Error) { + return Promise.reject(response) + } + return Promise.resolve({ + id: 'terminal-list', + ok: true, + result: response, + _meta: { runtimeId: 'runtime-1' } + }) + } + return Promise.resolve({ + id: 'default', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + }) + }) +} + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (error: Error) => void +} { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + afterEach(() => { resetAgentHibernationCoordinatorForTests() + clearRuntimeCompatibilityCacheForTests() resetForegroundTerminalWorktreeIdsForTests() resetAgentHibernationOutputActivityForTests() hydrateDrivers([]) + mockRuntimeEnvironmentCall.mockReset() vi.useRealTimers() }) @@ -213,4 +295,190 @@ describe('agent hibernation coordinator', () => { expect(shutdown).not.toHaveBeenCalled() }) + + it('hibernates a runtime-backed candidate with fresh liveness and exact PTYs', async () => { + vi.useFakeTimers() + installRuntimeListResponses( + runtimeListResult(['pty-1']), + runtimeListResult(['pty-1']), + runtimeListResult(['pty-1']) + ) + const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), { + settings: { + experimentalAgentHibernation: true, + agentHibernationIdleMs: DEFAULT_AGENT_HIBERNATION_IDLE_MS, + activeRuntimeEnvironmentId: 'runtime-1' + } as never, + ptyIdsByTabId: { 'tab-1': [] } + }) + startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW }) + + await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(1000) + + expect(shutdown).toHaveBeenCalledWith('wt-bg', { + keepIdentifiers: true, + sleepingPaneKeys: [`tab-1:${LEAF}`], + expectedRuntimePtyIds: ['pty-1'] + }) + expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'terminal.list', + params: expect.objectContaining({ requireFreshPtyLiveness: true }) + }) + ) + }) + + it('requires fresh runtime liveness for confirmation and pre-shutdown recheck', async () => { + vi.useFakeTimers() + installRuntimeListResponses( + runtimeListResult(['pty-1']), + runtimeListResult(['pty-1']), + runtimeListResult(['pty-1', 'pty-shell']) + ) + const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), { + settings: { + experimentalAgentHibernation: true, + agentHibernationIdleMs: DEFAULT_AGENT_HIBERNATION_IDLE_MS, + activeRuntimeEnvironmentId: 'runtime-1' + } as never, + ptyIdsByTabId: { 'tab-1': [] } + }) + startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW }) + + await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(1000) + + expect(shutdown).not.toHaveBeenCalled() + expect( + mockRuntimeEnvironmentCall.mock.calls.filter(([args]) => args.method === 'terminal.list') + ).toHaveLength(3) + }) + + it('uses fresh store state after awaiting runtime liveness before shutdown', async () => { + vi.useFakeTimers() + const delayed = deferred>() + const responses: ( + | ReturnType + | Promise> + )[] = [runtimeListResult(['pty-1']), runtimeListResult(['pty-1']), delayed.promise] + mockRuntimeEnvironmentCall.mockImplementation((args: { method: string }) => { + const compatible = createCompatibleRuntimeStatusResponseIfNeeded(args) + if (compatible) { + return Promise.resolve(compatible) + } + if (args.method === 'terminal.list') { + return Promise.resolve(responses.shift() ?? runtimeListResult(['pty-1'])).then( + (result) => ({ + id: 'terminal-list', + ok: true, + result, + _meta: { runtimeId: 'runtime-1' } + }) + ) + } + return Promise.resolve({ + id: 'default', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + }) + }) + const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), { + settings: { + experimentalAgentHibernation: true, + agentHibernationIdleMs: DEFAULT_AGENT_HIBERNATION_IDLE_MS, + activeRuntimeEnvironmentId: 'runtime-1' + } as never, + ptyIdsByTabId: { 'tab-1': [] } + }) + startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW }) + + await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(1000) + useAppStore.setState({ activeWorktreeId: 'wt-bg' }) + delayed.resolve(runtimeListResult(['pty-1'])) + await Promise.resolve() + + expect(shutdown).not.toHaveBeenCalled() + }) + + it('skips runtime-backed candidates with multiple live PTYs', async () => { + vi.useFakeTimers() + installRuntimeListResponses( + runtimeListResult(['pty-1', 'pty-2']), + runtimeListResult(['pty-1', 'pty-2']) + ) + const secondLeaf = '22222222-2222-4222-8222-222222222222' + const e = { + ...entry(), + paneKey: `tab-1:${secondLeaf}`, + providerSession: { key: 'session_id' as const, id: 'session-2' } + } + const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), { + settings: { + experimentalAgentHibernation: true, + agentHibernationIdleMs: DEFAULT_AGENT_HIBERNATION_IDLE_MS, + activeRuntimeEnvironmentId: 'runtime-1' + } as never, + ptyIdsByTabId: { 'tab-1': [] }, + terminalLayoutsByTabId: { + 'tab-1': { + ...layout(), + ptyIdsByLeafId: { [LEAF]: 'pty-1', [secondLeaf]: 'pty-2' } + } + }, + agentStatusByPaneKey: { + [`tab-1:${LEAF}`]: entry(), + [e.paneKey]: e + } + }) + startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW }) + + await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(1000) + + expect(shutdown).not.toHaveBeenCalled() + }) + + it('fails closed on truncated runtime liveness samples', async () => { + vi.useFakeTimers() + installRuntimeListResponses(runtimeListResult(['pty-1'], true), runtimeListResult(['pty-1'])) + const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), { + settings: { + experimentalAgentHibernation: true, + agentHibernationIdleMs: DEFAULT_AGENT_HIBERNATION_IDLE_MS, + activeRuntimeEnvironmentId: 'runtime-1' + } as never, + ptyIdsByTabId: { 'tab-1': [] } + }) + startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW }) + + await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(1000) + + expect(shutdown).not.toHaveBeenCalled() + }) + + it('fails closed when fresh runtime liveness rejects after an earlier good sample', async () => { + vi.useFakeTimers() + installRuntimeListResponses(runtimeListResult(['pty-1']), new Error('runtime unavailable')) + const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), { + settings: { + experimentalAgentHibernation: true, + agentHibernationIdleMs: DEFAULT_AGENT_HIBERNATION_IDLE_MS, + activeRuntimeEnvironmentId: 'runtime-1' + } as never, + ptyIdsByTabId: { 'tab-1': [] } + }) + startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW }) + + await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(1000) + + expect(shutdown).not.toHaveBeenCalled() + expect( + mockRuntimeEnvironmentCall.mock.calls.filter(([args]) => args.method === 'terminal.list') + ).toHaveLength(2) + }) }) diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.ts b/src/renderer/src/lib/agent-hibernation-coordinator.ts index 37923850f..80d402f0e 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.ts @@ -10,6 +10,13 @@ import type { AppState } from '@/store/types' import { getAllDrivers } from './pane-manager/mobile-driver-state' import { getForegroundTerminalWorktreeIds } from './foreground-terminal-worktrees' import { getAgentHibernationOutputSignature } from './agent-hibernation-output-activity' +import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import type { + RuntimeTerminalListResult, + RuntimeTerminalSummary +} from '../../../shared/runtime-types' export const AGENT_HIBERNATION_TICK_MS = 60 * 1000 @@ -23,6 +30,7 @@ type AgentHibernationCoordinatorOptions = { type AgentHibernationCoordinatorState = { interval: IntervalHandle | null confirmationState: AgentHibernationConfirmationState + tickInFlight: boolean shuttingDownWorktreeIds: Set now: () => number } @@ -30,11 +38,21 @@ type AgentHibernationCoordinatorState = { const coordinator: AgentHibernationCoordinatorState = { interval: null, confirmationState: {}, + tickInFlight: false, shuttingDownWorktreeIds: new Set(), now: () => Date.now() } -function snapshotFromState(state: AppState, now: number): AgentHibernationPlannerSnapshot { +type RuntimePtyLivenessSample = { + runtimeLivePtyIdsByWorktreeId: Record + runtimeLivenessRequiredWorktreeIds: string[] +} + +function snapshotFromState( + state: AppState, + now: number, + runtimeLiveness: RuntimePtyLivenessSample +): AgentHibernationPlannerSnapshot { return { settings: state.settings, activeWorktreeId: state.activeWorktreeId, @@ -42,6 +60,8 @@ function snapshotFromState(state: AppState, now: number): AgentHibernationPlanne tabsByWorktree: state.tabsByWorktree, terminalLayoutsByTabId: state.terminalLayoutsByTabId, ptyIdsByTabId: state.ptyIdsByTabId, + runtimeLivePtyIdsByWorktreeId: runtimeLiveness.runtimeLivePtyIdsByWorktreeId, + runtimeLivenessRequiredWorktreeIds: runtimeLiveness.runtimeLivenessRequiredWorktreeIds, mobileLockedPtyIds: [...getAllDrivers()] .filter(([, driver]) => driver.kind === 'mobile') .map(([ptyId]) => ptyId), @@ -52,15 +72,84 @@ function snapshotFromState(state: AppState, now: number): AgentHibernationPlanne } } -function currentCandidates(now: number) { - return planAgentHibernationCandidates(snapshotFromState(useAppStore.getState(), now)).map( - (candidate) => ({ +function getRuntimeLivenessTargetWorktrees(state: AppState): Map { + const targets = new Map() + for (const worktreeId of Object.keys(state.tabsByWorktree)) { + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + if (runtimeEnvironmentId) { + targets.set(worktreeId, runtimeEnvironmentId) + } + } + return targets +} + +function getTypedRuntimePtyId(terminal: RuntimeTerminalSummary): string | null { + if (terminal.ptyId) { + return terminal.ptyId + } + if (terminal.tabId.startsWith('pty:') && terminal.tabId === terminal.leafId) { + return terminal.tabId.slice('pty:'.length) || null + } + return null +} + +async function collectRuntimePtyLiveness(state: AppState): Promise { + const targets = getRuntimeLivenessTargetWorktrees(state) + const runtimeLivePtyIdsByWorktreeId: Record = {} + const runtimeLivenessRequiredWorktreeIds = [...targets.keys()] + await Promise.all( + [...targets].map(async ([worktreeId, runtimeEnvironmentId]) => { + try { + const result = await callRuntimeRpc( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'terminal.list', + { + worktree: toRuntimeWorktreeSelector(worktreeId), + limit: 10_000, + requireFreshPtyLiveness: true + }, + { timeoutMs: 10_000 } + ) + if (result.truncated) { + return + } + const ptyIds = new Set() + for (const terminal of result.terminals) { + if (!terminal.connected || terminal.worktreeId !== worktreeId) { + continue + } + const ptyId = getTypedRuntimePtyId(terminal) + if (ptyId) { + ptyIds.add(ptyId) + } + } + runtimeLivePtyIdsByWorktreeId[worktreeId] = [...ptyIds].sort() + } catch { + // Why: stale runtime liveness is unsafe for all-or-nothing hibernation; + // omitting the worktree makes the planner fail closed for this pass. + } + }) + ) + return { runtimeLivePtyIdsByWorktreeId, runtimeLivenessRequiredWorktreeIds } +} + +async function currentCandidates(now: number) { + const runtimeLiveness = await collectRuntimePtyLiveness(useAppStore.getState()) + const freshState = useAppStore.getState() + return planAgentHibernationCandidates(snapshotFromState(freshState, now, runtimeLiveness)) + .filter((candidate) => { + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree( + freshState, + candidate.worktreeId + ) + return !runtimeEnvironmentId || candidate.expectedRuntimePtyIds.length === 1 + }) + .map((candidate) => ({ ...candidate, // Why: terminal output after the first stable tick can mean the session // is still alive even when agent status remains done; require it to stay quiet. signature: `${candidate.signature}|output:${getAgentHibernationOutputSignature(candidate.paneKeys)}` - }) - ) + })) } async function hibernateWorktreeIfStillEligible( @@ -70,7 +159,7 @@ async function hibernateWorktreeIfStillEligible( if (coordinator.shuttingDownWorktreeIds.has(worktreeId)) { return } - const candidates = currentCandidates(coordinator.now()) + const candidates = await currentCandidates(coordinator.now()) const stillEligible = candidates.some( (candidate) => candidate.worktreeId === worktreeId && candidate.signature === confirmedCandidate.signature @@ -80,9 +169,14 @@ async function hibernateWorktreeIfStillEligible( } coordinator.shuttingDownWorktreeIds.add(worktreeId) try { - await useAppStore.getState().shutdownWorktreeTerminals(worktreeId, { + const state = useAppStore.getState() + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + await state.shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true, - sleepingPaneKeys: confirmedCandidate.paneKeys + sleepingPaneKeys: confirmedCandidate.paneKeys, + ...(runtimeEnvironmentId + ? { expectedRuntimePtyIds: confirmedCandidate.expectedRuntimePtyIds } + : {}) }) } catch (err) { console.warn('[agent-hibernation] failed to hibernate worktree:', worktreeId, err) @@ -91,14 +185,22 @@ async function hibernateWorktreeIfStillEligible( } } -export function runAgentHibernationTick(): void { - const plan = confirmAgentHibernationCandidates( - coordinator.confirmationState, - currentCandidates(coordinator.now()) - ) - coordinator.confirmationState = plan.confirmationState - for (const candidate of plan.candidates) { - void hibernateWorktreeIfStillEligible(candidate) +export async function runAgentHibernationTick(): Promise { + if (coordinator.tickInFlight) { + return + } + coordinator.tickInFlight = true + try { + const plan = confirmAgentHibernationCandidates( + coordinator.confirmationState, + await currentCandidates(coordinator.now()) + ) + coordinator.confirmationState = plan.confirmationState + for (const candidate of plan.candidates) { + void hibernateWorktreeIfStillEligible(candidate) + } + } finally { + coordinator.tickInFlight = false } } @@ -110,7 +212,7 @@ export function startAgentHibernationCoordinator( } coordinator.now = options.now ?? (() => Date.now()) const intervalMs = options.intervalMs ?? AGENT_HIBERNATION_TICK_MS - coordinator.interval = setInterval(runAgentHibernationTick, intervalMs) + coordinator.interval = setInterval(() => void runAgentHibernationTick(), intervalMs) return stopAgentHibernationCoordinator } @@ -129,5 +231,6 @@ export function isAgentHibernationCoordinatorRunning(): boolean { export function resetAgentHibernationCoordinatorForTests(): void { stopAgentHibernationCoordinator() coordinator.shuttingDownWorktreeIds.clear() + coordinator.tickInFlight = false coordinator.now = () => Date.now() } diff --git a/src/renderer/src/lib/agent-hibernation-planner.test.ts b/src/renderer/src/lib/agent-hibernation-planner.test.ts index fdc05516d..11dd00632 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.test.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.test.ts @@ -144,6 +144,107 @@ describe('agent hibernation planner', () => { expect(plannedWorktrees(snapshot({ mobileLockedPtyIds: ['pty-1'] }))).toEqual([]) }) + it('selects runtime-backed live PTYs when the renderer live map is empty', () => { + const [candidate] = planAgentHibernationCandidates( + snapshot({ + ptyIdsByTabId: { 'tab-1': [] }, + runtimeLivePtyIdsByWorktreeId: { 'wt-bg': ['pty-1'] }, + runtimeLivenessRequiredWorktreeIds: ['wt-bg'] + }) + ) + + expect(candidate).toMatchObject({ + worktreeId: 'wt-bg', + paneKeys: [`tab-1:${LEAF}`], + expectedRuntimePtyIds: ['pty-1'] + }) + }) + + it('matches wrapped remote renderer PTY IDs to raw runtime PTY IDs', () => { + const [candidate] = planAgentHibernationCandidates( + snapshot({ + terminalLayoutsByTabId: { 'tab-1': layout(LEAF, 'remote:env-1@@terminal-1') }, + ptyIdsByTabId: { 'tab-1': ['remote:env-1@@terminal-1'] }, + runtimeLivePtyIdsByWorktreeId: { 'wt-bg': ['terminal-1'] }, + runtimeLivenessRequiredWorktreeIds: ['wt-bg'] + }) + ) + + expect(candidate).toMatchObject({ + worktreeId: 'wt-bg', + paneKeys: [`tab-1:${LEAF}`], + expectedRuntimePtyIds: ['terminal-1'] + }) + }) + + it('does not select layout-only stale PTYs without runtime liveness', () => { + expect( + plannedWorktrees( + snapshot({ + ptyIdsByTabId: { 'tab-1': [] }, + runtimeLivePtyIdsByWorktreeId: { 'wt-bg': [] }, + runtimeLivenessRequiredWorktreeIds: ['wt-bg'] + }) + ) + ).toEqual([]) + expect( + plannedWorktrees( + snapshot({ + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + runtimeLivePtyIdsByWorktreeId: { 'wt-bg': [] }, + runtimeLivenessRequiredWorktreeIds: ['wt-bg'] + }) + ) + ).toEqual([]) + expect( + plannedWorktrees( + snapshot({ + ptyIdsByTabId: { 'tab-1': [] }, + runtimeLivenessRequiredWorktreeIds: ['wt-bg'] + }) + ) + ).toEqual([]) + }) + + it('rejects runtime-backed worktrees with extra unknown live PTYs', () => { + expect( + plannedWorktrees( + snapshot({ + ptyIdsByTabId: { 'tab-1': [] }, + runtimeLivePtyIdsByWorktreeId: { 'wt-bg': ['pty-1', 'pty-shell'] }, + runtimeLivenessRequiredWorktreeIds: ['wt-bg'] + }) + ) + ).toEqual([]) + }) + + it('applies mobile locks to runtime-backed PTYs', () => { + expect( + plannedWorktrees( + snapshot({ + ptyIdsByTabId: { 'tab-1': [] }, + runtimeLivePtyIdsByWorktreeId: { 'wt-bg': ['pty-1'] }, + runtimeLivenessRequiredWorktreeIds: ['wt-bg'], + mobileLockedPtyIds: ['pty-1'] + }) + ) + ).toEqual([]) + }) + + it('applies mobile locks across wrapped remote and raw runtime PTY IDs', () => { + expect( + plannedWorktrees( + snapshot({ + terminalLayoutsByTabId: { 'tab-1': layout(LEAF, 'remote:env-1@@terminal-1') }, + ptyIdsByTabId: { 'tab-1': ['remote:env-1@@terminal-1'] }, + runtimeLivePtyIdsByWorktreeId: { 'wt-bg': ['terminal-1'] }, + runtimeLivenessRequiredWorktreeIds: ['wt-bg'], + mobileLockedPtyIds: ['remote:env-1@@terminal-1'] + }) + ) + ).toEqual([]) + }) + it('selects a worktree when all live PTYs are eligible done agents', () => { expect(plannedWorktrees(snapshot())).toEqual(['wt-bg']) const second = entry({ diff --git a/src/renderer/src/lib/agent-hibernation-planner.ts b/src/renderer/src/lib/agent-hibernation-planner.ts index 6e86c4c71..185798b66 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.ts @@ -6,6 +6,7 @@ import { } from '../../../shared/agent-session-resume' import { parsePaneKey } from '../../../shared/stable-pane-id' import type { GlobalSettings, TerminalLayoutSnapshot, TerminalTab } from '../../../shared/types' +import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' export const DEFAULT_AGENT_HIBERNATION_IDLE_MS = 30 * 60 * 1000 export const MIN_AGENT_HIBERNATION_IDLE_MS = 60 * 1000 @@ -18,6 +19,8 @@ export type AgentHibernationPlannerSnapshot = { tabsByWorktree: Record terminalLayoutsByTabId: Record ptyIdsByTabId: Record + runtimeLivePtyIdsByWorktreeId?: Record + runtimeLivenessRequiredWorktreeIds?: string[] mobileLockedPtyIds: string[] agentStatusByPaneKey: Record sleepingAgentSessionsByPaneKey: Record @@ -28,6 +31,7 @@ export type AgentHibernationPlannerSnapshot = { export type AgentHibernationCandidate = { worktreeId: string paneKeys: string[] + expectedRuntimePtyIds: string[] signature: string } @@ -41,12 +45,17 @@ export type AgentHibernationPlan = { type EligiblePane = { paneKey: string ptyId: string + runtimePtyId: string providerSessionId: string state: AgentStatusEntry['state'] updatedAt: number inputAt: number } +function toRuntimePtyId(ptyId: string): string { + return parseRemoteRuntimePtyId(ptyId)?.handle ?? ptyId +} + export function getEffectiveAgentHibernationIdleMs(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) && @@ -58,10 +67,24 @@ export function getEffectiveAgentHibernationIdleMs(value: unknown): number { function getLivePtyIdsForTab( tab: TerminalTab, - ptyIdsByTabId: Record + ptyIdsByTabId: Record, + runtimeLivePtyIdsByWorktreeId: Record | undefined, + runtimeLivenessRequired: boolean ): string[] { - const ids = ptyIdsByTabId[tab.id] ?? [] - return ids.filter((id): id is string => typeof id === 'string' && id.length > 0) + const ids = new Set() + for (const id of runtimeLivePtyIdsByWorktreeId?.[tab.worktreeId] ?? []) { + if (typeof id === 'string' && id.length > 0) { + ids.add(toRuntimePtyId(id)) + } + } + if (!runtimeLivenessRequired) { + for (const id of ptyIdsByTabId[tab.id] ?? []) { + if (typeof id === 'string' && id.length > 0) { + ids.add(toRuntimePtyId(id)) + } + } + } + return [...ids] } function getPaneLivePtyId( @@ -123,12 +146,17 @@ function getEligiblePane(args: { return null } const ptyId = getPaneLivePtyId(entry, layout) - if (!ptyId || !livePtyIds.has(ptyId)) { + if (!ptyId) { + return null + } + const runtimePtyId = toRuntimePtyId(ptyId) + if (!livePtyIds.has(runtimePtyId)) { return null } return { paneKey: entry.paneKey, ptyId, + runtimePtyId, providerSessionId: entry.providerSession.id, state: entry.state, updatedAt: entry.updatedAt, @@ -142,7 +170,7 @@ function signatureFor(worktreeId: string, panes: EligiblePane[]): string { .sort((a, b) => a.paneKey.localeCompare(b.paneKey)) .map( (pane) => - `${pane.paneKey}:${pane.ptyId}:${pane.providerSessionId}:${pane.state}:${pane.updatedAt}:${pane.inputAt}` + `${pane.paneKey}:${pane.ptyId}:${pane.runtimePtyId}:${pane.providerSessionId}:${pane.state}:${pane.updatedAt}:${pane.inputAt}` ) return `${worktreeId}|${parts.join('|')}` } @@ -176,8 +204,11 @@ export function planAgentHibernationCandidates( return [] } const idleMs = getEffectiveAgentHibernationIdleMs(snapshot.settings.agentHibernationIdleMs) - const mobileLockedPtyIds = new Set(snapshot.mobileLockedPtyIds) + const mobileLockedPtyIds = new Set(snapshot.mobileLockedPtyIds.map(toRuntimePtyId)) const foregroundWorktreeIds = new Set(snapshot.foregroundWorktreeIds) + const runtimeLivenessRequiredWorktreeIds = new Set( + snapshot.runtimeLivenessRequiredWorktreeIds ?? [] + ) const agentEntriesByTabId = getAgentEntriesByTabId(snapshot.agentStatusByPaneKey) const candidates: AgentHibernationCandidate[] = [] for (const [worktreeId, tabs] of Object.entries(snapshot.tabsByWorktree)) { @@ -189,11 +220,25 @@ export function planAgentHibernationCandidates( ) { continue } + if ( + runtimeLivenessRequiredWorktreeIds.has(worktreeId) && + !Object.prototype.hasOwnProperty.call( + snapshot.runtimeLivePtyIdsByWorktreeId ?? {}, + worktreeId + ) + ) { + continue + } const livePtyIds = new Set() const eligibleByPtyId = new Map() let rejected = false for (const tab of tabs) { - const tabLivePtyIds = getLivePtyIdsForTab(tab, snapshot.ptyIdsByTabId) + const tabLivePtyIds = getLivePtyIdsForTab( + tab, + snapshot.ptyIdsByTabId, + snapshot.runtimeLivePtyIdsByWorktreeId, + runtimeLivenessRequiredWorktreeIds.has(worktreeId) + ) for (const ptyId of tabLivePtyIds) { livePtyIds.add(ptyId) } @@ -216,7 +261,7 @@ export function planAgentHibernationCandidates( idleMs }) if (eligible) { - eligibleByPtyId.set(eligible.ptyId, eligible) + eligibleByPtyId.set(eligible.runtimePtyId, eligible) } else if (entry.state !== 'done' || getPaneLivePtyId(entry, layout)) { rejected = true } @@ -229,6 +274,7 @@ export function planAgentHibernationCandidates( candidates.push({ worktreeId, paneKeys: panes.map((pane) => pane.paneKey).sort(), + expectedRuntimePtyIds: [...livePtyIds].sort(), signature: signatureFor(worktreeId, panes) }) } diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 8d21ec964..c484bcb2c 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -222,6 +222,58 @@ function sleepingRecordFromEntry(args: { } } +export function collectSleepingAgentSessionRecordsForWorktree( + state: AppState, + worktreeId: string, + paneKeys?: string[] +): Record { + const capturedAt = Date.now() + const allowedPaneKeys = paneKeys ? new Set(paneKeys) : null + const tabPrefixes = (state.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) + const records: Record = {} + + for (const retained of Object.values(state.retainedAgentsByPaneKey)) { + if (allowedPaneKeys && !allowedPaneKeys.has(retained.entry.paneKey)) { + continue + } + if (retained.worktreeId !== worktreeId) { + continue + } + const record = sleepingRecordFromEntry({ + state, + entry: retained.entry, + worktreeId, + tab: retained.tab, + capturedAt + }) + if (record) { + records[record.paneKey] = record + } + } + + for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { + if (allowedPaneKeys && !allowedPaneKeys.has(paneKey)) { + continue + } + const belongsToWorktree = + entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) + if (!belongsToWorktree) { + continue + } + const record = sleepingRecordFromEntry({ + state, + entry, + worktreeId, + capturedAt + }) + if (record) { + records[record.paneKey] = record + } + } + + return records +} + function pruneMigrationUnsupportedEntries( entries: Record, predicate: (entry: MigrationUnsupportedPtyEntry) => boolean @@ -1017,50 +1069,14 @@ export const createAgentStatusSlice: StateCreator { set((s) => { - const capturedAt = Date.now() - const allowedPaneKeys = paneKeys ? new Set(paneKeys) : null - const tabPrefixes = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) + const records = collectSleepingAgentSessionRecordsForWorktree(s, worktreeId, paneKeys) const next: Record = { ...s.sleepingAgentSessionsByPaneKey } let changed = false - for (const retained of Object.values(s.retainedAgentsByPaneKey)) { - if (allowedPaneKeys && !allowedPaneKeys.has(retained.entry.paneKey)) { - continue - } - if (retained.worktreeId !== worktreeId) { - continue - } - const record = sleepingRecordFromEntry({ - state: s, - entry: retained.entry, - worktreeId, - tab: retained.tab, - capturedAt - }) - if (record && next[record.paneKey] !== record) { - next[record.paneKey] = record - changed = true - } - } - - for (const [paneKey, entry] of Object.entries(s.agentStatusByPaneKey)) { - if (allowedPaneKeys && !allowedPaneKeys.has(paneKey)) { - continue - } - const belongsToWorktree = - entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) - if (!belongsToWorktree) { - continue - } - const record = sleepingRecordFromEntry({ - state: s, - entry, - worktreeId, - capturedAt - }) - if (record && next[record.paneKey] !== record) { + for (const record of Object.values(records)) { + if (next[record.paneKey] !== record) { next[record.paneKey] = record changed = true } diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index f89169580..f4878d0e8 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -7,11 +7,17 @@ import { createCompatibleRuntimeStatusResponseIfNeeded } from '../../runtime/run import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' import { toast } from 'sonner' +const mockUnregisterPtyDataHandlers = vi.hoisted(() => vi.fn()) + // Mock sonner (imported by repos.ts) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } })) +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + unregisterPtyDataHandlers: mockUnregisterPtyDataHandlers +})) + // Mock agent-status (imported by terminal-helpers) vi.mock('@/lib/agent-status', async (importOriginal) => { const actual = await importOriginal() @@ -2655,6 +2661,483 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { ) }) + it('commits sleep state after exact runtime stop for runtime-backed PTYs', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => + Promise.resolve( + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? { + id: 'rpc-default', + ok: true, + result: + args.method === 'terminal.stopExact' + ? { stoppedPtyIds: ['pty-1'], livePtyIds: ['pty-1'], postStopVerified: true } + : {}, + _meta: { runtimeId: 'remote-runtime' } + } + ) + ) + + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' }, + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })] + }, + ptyIdsByTabId: { 'tab-1': [] } + }) + store.getState().setAgentStatus( + 'tab-1:live', + { + state: 'done', + prompt: 'resume live', + agentType: 'codex' + }, + 'Codex', + { updatedAt: 1000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'live-session' } } + ) + + await store.getState().shutdownWorktreeTerminals(wt, { + keepIdentifiers: true, + sleepingPaneKeys: ['tab-1:live'], + expectedRuntimePtyIds: ['pty-1'] + }) + + expect(mockApi.runtimeEnvironments.call).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'runtime-1', + method: 'terminal.stopExact', + params: expect.objectContaining({ expectedPtyIds: ['pty-1'], keepHistory: true }) + }) + ) + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toMatchObject({ + providerSession: { key: 'session_id', id: 'live-session' } + }) + expect(store.getState().agentStatusByPaneKey['tab-1:live']).toBeUndefined() + expect(mockApi.pty.kill).not.toHaveBeenCalled() + }) + + it('does not commit sleep state when exact runtime stop post-check is inconclusive', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => + Promise.resolve( + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? { + id: 'rpc-default', + ok: true, + result: + args.method === 'terminal.stopExact' + ? { + stoppedPtyIds: ['pty-1'], + livePtyIds: ['pty-1'], + postStopVerified: false, + postStopFailure: 'terminal_liveness_unavailable' + } + : {}, + _meta: { runtimeId: 'remote-runtime' } + } + ) + ) + + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' }, + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })] + }, + ptyIdsByTabId: { 'tab-1': [] } + }) + store.getState().setAgentStatus( + 'tab-1:live', + { + state: 'done', + prompt: 'resume live', + agentType: 'codex' + }, + 'Codex', + { updatedAt: 1000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'live-session' } } + ) + + await expect( + store.getState().shutdownWorktreeTerminals(wt, { + keepIdentifiers: true, + sleepingPaneKeys: ['tab-1:live'], + expectedRuntimePtyIds: ['pty-1'] + }) + ).rejects.toThrow('terminal_liveness_unavailable') + + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toBeUndefined() + expect(store.getState().agentStatusByPaneKey['tab-1:live']).toBeDefined() + expect(store.getState().suppressedPtyExitIds['pty-1']).toBeUndefined() + expect(mockApi.pty.kill).not.toHaveBeenCalled() + }) + + it('does not commit sleep state when exact runtime stop omits post-check proof', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => + Promise.resolve( + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? { + id: 'rpc-default', + ok: true, + result: + args.method === 'terminal.stopExact' + ? { stoppedPtyIds: ['pty-1'], livePtyIds: ['pty-1'] } + : {}, + _meta: { runtimeId: 'remote-runtime' } + } + ) + ) + + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' }, + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })] + }, + ptyIdsByTabId: { 'tab-1': [] } + }) + store.getState().setAgentStatus( + 'tab-1:live', + { + state: 'done', + prompt: 'resume live', + agentType: 'codex' + }, + 'Codex', + { updatedAt: 1000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'live-session' } } + ) + + await expect( + store.getState().shutdownWorktreeTerminals(wt, { + keepIdentifiers: true, + sleepingPaneKeys: ['tab-1:live'], + expectedRuntimePtyIds: ['pty-1'] + }) + ).rejects.toThrow('exact_terminal_stop_unverified') + + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toBeUndefined() + expect(store.getState().agentStatusByPaneKey['tab-1:live']).toBeDefined() + expect(store.getState().suppressedPtyExitIds['pty-1']).toBeUndefined() + expect(mockApi.pty.kill).not.toHaveBeenCalled() + }) + + it('clears exact-stop exit suppression when a slept PTY ID wakes live again', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => + Promise.resolve( + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? { + id: 'rpc-default', + ok: true, + result: + args.method === 'terminal.stopExact' + ? { stoppedPtyIds: ['pty-1'], livePtyIds: ['pty-1'], postStopVerified: true } + : {}, + _meta: { runtimeId: 'remote-runtime' } + } + ) + ) + + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' }, + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })] + }, + ptyIdsByTabId: { 'tab-1': [] } + }) + store.getState().setAgentStatus( + 'tab-1:live', + { + state: 'done', + prompt: 'resume live', + agentType: 'codex' + }, + 'Codex', + { updatedAt: 1000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'live-session' } } + ) + + await store.getState().shutdownWorktreeTerminals(wt, { + keepIdentifiers: true, + sleepingPaneKeys: ['tab-1:live'], + expectedRuntimePtyIds: ['pty-1'] + }) + expect(store.getState().suppressedPtyExitIds['pty-1']).toBe(true) + + store.getState().updateTabPtyId('tab-1', 'pty-1') + + expect(store.getState().suppressedPtyExitIds['pty-1']).toBeUndefined() + }) + + it('suppresses wrapped remote PTY exits before exact runtime stop resolves', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + let sawWrappedSuppressedDuringStop = false + let sawRawSuppressedDuringStop = false + mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => { + const compatible = createCompatibleRuntimeStatusResponseIfNeeded(args) + if (compatible) { + return Promise.resolve(compatible) + } + if (args.method === 'terminal.stopExact') { + sawWrappedSuppressedDuringStop = store + .getState() + .consumeSuppressedPtyExit('remote:env-1@@terminal-1') + sawRawSuppressedDuringStop = store.getState().consumeSuppressedPtyExit('terminal-1') + return Promise.resolve({ + id: 'rpc-default', + ok: true, + result: { + stoppedPtyIds: ['terminal-1'], + livePtyIds: ['terminal-1'], + postStopVerified: true + }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + return Promise.resolve({ + id: 'rpc-default', + ok: true, + result: {}, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' }, + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })] + }, + ptyIdsByTabId: { 'tab-1': ['remote:env-1@@terminal-1'] } + }) + store.getState().setAgentStatus( + 'tab-1:live', + { + state: 'done', + prompt: 'resume live', + agentType: 'codex' + }, + 'Codex', + { updatedAt: 1000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'live-session' } } + ) + + await store.getState().shutdownWorktreeTerminals(wt, { + keepIdentifiers: true, + sleepingPaneKeys: ['tab-1:live'], + expectedRuntimePtyIds: ['terminal-1'] + }) + + expect(sawWrappedSuppressedDuringStop).toBe(true) + expect(sawRawSuppressedDuringStop).toBe(true) + }) + + it('clears raw and wrapped remote exit suppression when a remote PTY wakes live again', () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })] + }, + ptyIdsByTabId: { 'tab-1': [] } + }) + store.getState().suppressPtyExit('remote:env-1@@terminal-1') + store.getState().suppressPtyExit('terminal-1') + + store.getState().updateTabPtyId('tab-1', 'remote:env-1@@terminal-1') + + expect(store.getState().suppressedPtyExitIds['remote:env-1@@terminal-1']).toBeUndefined() + expect(store.getState().suppressedPtyExitIds['terminal-1']).toBeUndefined() + }) + + it('commits the pre-stop sleeping record when exact-stop exit clears live status', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => { + if (args.method === 'terminal.stopExact') { + store.getState().removeAgentStatus('tab-1:live') + return Promise.resolve({ + id: 'rpc-default', + ok: true, + result: { stoppedPtyIds: ['pty-1'], livePtyIds: ['pty-1'], postStopVerified: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + return Promise.resolve( + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? { + id: 'rpc-default', + ok: true, + result: {}, + _meta: { runtimeId: 'remote-runtime' } + } + ) + }) + + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' }, + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })] + }, + ptyIdsByTabId: { 'tab-1': [] } + }) + store.getState().setAgentStatus( + 'tab-1:live', + { + state: 'done', + prompt: 'resume live', + agentType: 'codex' + }, + 'Codex', + { updatedAt: 1000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'live-session' } } + ) + + await store.getState().shutdownWorktreeTerminals(wt, { + keepIdentifiers: true, + sleepingPaneKeys: ['tab-1:live'], + expectedRuntimePtyIds: ['pty-1'] + }) + + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toMatchObject({ + providerSession: { key: 'session_id', id: 'live-session' } + }) + }) + + it('does not commit sleep state when exact runtime stop fails', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => { + const compatible = createCompatibleRuntimeStatusResponseIfNeeded(args) + if (compatible) { + return Promise.resolve(compatible) + } + if (args.method === 'terminal.stopExact') { + return Promise.reject(new Error('stop failed')) + } + return Promise.resolve({ + id: 'rpc-default', + ok: true, + result: {}, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' }, + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })] + }, + ptyIdsByTabId: { 'tab-1': [] } + }) + store.getState().setAgentStatus( + 'tab-1:live', + { + state: 'done', + prompt: 'resume live', + agentType: 'codex' + }, + 'Codex', + { updatedAt: 1000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'live-session' } } + ) + + await expect( + store.getState().shutdownWorktreeTerminals(wt, { + keepIdentifiers: true, + sleepingPaneKeys: ['tab-1:live'], + expectedRuntimePtyIds: ['pty-1'] + }) + ).rejects.toThrow('stop failed') + + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toBeUndefined() + expect(store.getState().agentStatusByPaneKey['tab-1:live']).toBeDefined() + expect(mockUnregisterPtyDataHandlers).not.toHaveBeenCalledWith(['pty-1']) + expect(mockApi.pty.kill).not.toHaveBeenCalled() + }) + + it('does not commit sleep state when exact runtime stop returns the wrong set', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => + Promise.resolve( + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? { + id: 'rpc-default', + ok: true, + result: + args.method === 'terminal.stopExact' + ? { + stoppedPtyIds: ['pty-1'], + livePtyIds: ['pty-1', 'pty-shell'], + postStopVerified: true + } + : {}, + _meta: { runtimeId: 'remote-runtime' } + } + ) + ) + + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' }, + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })] + }, + ptyIdsByTabId: { 'tab-1': [] } + }) + store.getState().setAgentStatus('tab-1:live', { + state: 'done', + prompt: 'resume live', + agentType: 'codex' + }) + + await expect( + store.getState().shutdownWorktreeTerminals(wt, { + keepIdentifiers: true, + sleepingPaneKeys: ['tab-1:live'], + expectedRuntimePtyIds: ['pty-1'] + }) + ).rejects.toThrow('exact_terminal_stop_mismatch') + + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toBeUndefined() + expect(store.getState().agentStatusByPaneKey['tab-1:live']).toBeDefined() + expect(mockApi.pty.kill).not.toHaveBeenCalled() + }) + it('drops live agentStatusByPaneKey entries on sleep so the working row disappears', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index e6ca708e1..0c0eb0c91 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -50,6 +50,7 @@ import { hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { collectSleepingAgentSessionRecordsForWorktree } from './agent-status' function getNextTerminalOrdinal(tabs: TerminalTab[]): number { const usedOrdinals = new Set() @@ -241,6 +242,18 @@ function resolveTerminalStopRuntimeEnvironmentId( return getRuntimeEnvironmentIdForWorktree(state, worktreeId) } +function sortedUniquePtyIds(ptyIds: readonly string[] | undefined): string[] { + return [...new Set((ptyIds ?? []).filter((ptyId) => ptyId.length > 0))].sort() +} + +function equalStringSets(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) { + return false + } + const bSet = new Set(b) + return a.every((value) => bSet.has(value)) +} + export type TerminalSlice = { tabsByWorktree: Record activeTabId: string | null @@ -401,7 +414,11 @@ export type TerminalSlice = { clearTabPtyId: (tabId: string, ptyId?: string) => void shutdownWorktreeTerminals: ( worktreeId: string, - opts?: { keepIdentifiers?: boolean; sleepingPaneKeys?: string[] } + opts?: { + keepIdentifiers?: boolean + sleepingPaneKeys?: string[] + expectedRuntimePtyIds?: string[] + } ) => Promise suppressPtyExit: (ptyId: string) => void consumeSuppressedPtyExit: (ptyId: string) => boolean @@ -1518,6 +1535,12 @@ export const createTerminalSlice: StateCreator const isFirstPty = existingPtyIds.length === 0 const isActiveWorktree = worktreeId != null && s.activeWorktreeId === worktreeId const shouldBumpSortEpoch = isFirstPty && isActiveWorktree && !wasActivationSpawn + const nextSuppressedPtyExitIds = { ...s.suppressedPtyExitIds } + delete nextSuppressedPtyExitIds[ptyId] + const remoteRuntimePtyHandle = parseRemoteRuntimePtyId(ptyId)?.handle + if (remoteRuntimePtyHandle) { + delete nextSuppressedPtyExitIds[remoteRuntimePtyHandle] + } return { ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}), ptyIdsByTabId: { @@ -1528,6 +1551,7 @@ export const createTerminalSlice: StateCreator ...s.lastKnownRelayPtyIdByTabId, [tabId]: ptyId }, + suppressedPtyExitIds: nextSuppressedPtyExitIds, ...(shouldBumpSortEpoch ? { sortEpoch: s.sortEpoch + 1 } : {}) } }) @@ -1639,6 +1663,11 @@ export const createTerminalSlice: StateCreator const keepIdentifiers = opts?.keepIdentifiers ?? false const tabs = get().tabsByWorktree[worktreeId] ?? [] const ptyIds = tabs.flatMap((tab) => get().ptyIdsByTabId[tab.id] ?? []) + const expectedRuntimePtyIds = sortedUniquePtyIds(opts?.expectedRuntimePtyIds) + const shutdownPtyIds = sortedUniquePtyIds([...ptyIds, ...expectedRuntimePtyIds]) + const sleepingAgentSessionRecords = keepIdentifiers + ? collectSleepingAgentSessionRecordsForWorktree(get(), worktreeId, opts?.sleepingPaneKeys) + : {} // Why: the main process flushes any remaining batched PTY data before // sending the exit event (pty.ts onExit handler). Without this, that @@ -1647,7 +1676,9 @@ export const createTerminalSlice: StateCreator // notifications for a worktree that is already being torn down — // the "phantom alerts" users see after shutting down worktrees. // Removing the data handlers first ensures the final flush is a no-op. - unregisterPtyDataHandlers(ptyIds) + if (expectedRuntimePtyIds.length === 0) { + unregisterPtyDataHandlers(shutdownPtyIds) + } // Why (ordering invariant — DESIGN_DOC §3.3.c): on sleep, capture every // pane's serializer buffer into terminalLayoutsByTabId[tab].buffersByLeafId @@ -1673,6 +1704,76 @@ export const createTerminalSlice: StateCreator } } + const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(get(), worktreeId) + if (expectedRuntimePtyIds.length > 0) { + if (!runtimeEnvironmentId) { + throw new Error('missing_runtime_for_exact_terminal_stop') + } + set((s) => ({ + suppressedPtyExitIds: { + ...s.suppressedPtyExitIds, + ...Object.fromEntries(shutdownPtyIds.map((ptyId) => [ptyId, true] as const)) + } + })) + let stopResult: { + stoppedPtyIds?: string[] + livePtyIds?: string[] + postStopVerified?: boolean + postStopFailure?: string + remainingLivePtyIds?: string[] + } + try { + stopResult = await callRuntimeRpc<{ + stoppedPtyIds?: string[] + livePtyIds?: string[] + }>( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'terminal.stopExact', + { + worktree: toRuntimeWorktreeSelector(worktreeId), + expectedPtyIds: expectedRuntimePtyIds, + keepHistory: keepIdentifiers + }, + { timeoutMs: 15_000 } + ) + } catch (err) { + set((s) => { + const next = { ...s.suppressedPtyExitIds } + for (const ptyId of shutdownPtyIds) { + delete next[ptyId] + } + return { suppressedPtyExitIds: next } + }) + throw err + } + const stoppedPtyIds = sortedUniquePtyIds(stopResult.stoppedPtyIds) + const livePtyIds = sortedUniquePtyIds(stopResult.livePtyIds) + if ( + !equalStringSets(stoppedPtyIds, expectedRuntimePtyIds) || + !equalStringSets(livePtyIds, expectedRuntimePtyIds) + ) { + set((s) => { + const next = { ...s.suppressedPtyExitIds } + for (const ptyId of shutdownPtyIds) { + delete next[ptyId] + } + return { suppressedPtyExitIds: next } + }) + throw new Error('exact_terminal_stop_mismatch') + } + if (stopResult.postStopVerified !== true) { + set((s) => { + const next = { ...s.suppressedPtyExitIds } + for (const ptyId of shutdownPtyIds) { + delete next[ptyId] + } + return { suppressedPtyExitIds: next } + }) + throw new Error(stopResult.postStopFailure ?? 'exact_terminal_stop_unverified') + } + unregisterPtyDataHandlers(shutdownPtyIds) + } + set((s) => { const nextTabsByWorktree = keepIdentifiers ? s.tabsByWorktree @@ -1691,7 +1792,7 @@ export const createTerminalSlice: StateCreator : { ...s.runtimePaneTitlesByTabId } const nextSuppressedPtyExitIds = { ...s.suppressedPtyExitIds, - ...Object.fromEntries(ptyIds.map((ptyId) => [ptyId, true] as const)) + ...Object.fromEntries(shutdownPtyIds.map((ptyId) => [ptyId, true] as const)) } // Why: pendingCodexPaneRestartIds is keyed by ptyId — under sleep we // preserve it so a mid-restart marker survives wake against the same @@ -1702,7 +1803,7 @@ export const createTerminalSlice: StateCreator ? s.pendingCodexPaneRestartIds : { ...s.pendingCodexPaneRestartIds } const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId } - for (const ptyId of ptyIds) { + for (const ptyId of shutdownPtyIds) { if (!keepIdentifiers) { delete nextPendingCodexPaneRestartIds[ptyId] } @@ -1827,7 +1928,12 @@ export const createTerminalSlice: StateCreator }) if (keepIdentifiers) { - get().captureSleepingAgentSessionsByWorktree(worktreeId, opts?.sleepingPaneKeys) + set((s) => ({ + sleepingAgentSessionsByPaneKey: { + ...s.sleepingAgentSessionsByPaneKey, + ...sleepingAgentSessionRecords + } + })) } else { get().clearSleepingAgentSessionsByWorktree(worktreeId) } @@ -1839,12 +1945,11 @@ export const createTerminalSlice: StateCreator // their original tab. get().dropAgentStatusByWorktree(worktreeId) - if (ptyIds.length === 0) { + if (ptyIds.length === 0 && expectedRuntimePtyIds.length === 0) { return } - const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(get(), worktreeId) - if (runtimeEnvironmentId) { + if (runtimeEnvironmentId && expectedRuntimePtyIds.length === 0) { await callRuntimeRpc( { kind: 'environment', environmentId: runtimeEnvironmentId }, 'terminal.stop', @@ -1855,6 +1960,7 @@ export const createTerminalSlice: StateCreator await Promise.allSettled( ptyIds + .filter((ptyId) => !expectedRuntimePtyIds.includes(ptyId)) .filter((ptyId) => !ptyId.startsWith('remote:')) .map((ptyId) => window.api.pty.kill(ptyId, { keepHistory: keepIdentifiers })) ) diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 1880c0d57..181885f73 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -318,6 +318,7 @@ export type RuntimeFilePreviewResult = { export type RuntimeTerminalSummary = { handle: string + ptyId: string | null worktreeId: string worktreePath: string branch: string