diff --git a/src/main/daemon/daemon-pty-adapter-cold-restore-reanchor.test.ts b/src/main/daemon/daemon-pty-adapter-cold-restore-reanchor.test.ts index 422d1d1d6f0..ba743a25ac1 100644 --- a/src/main/daemon/daemon-pty-adapter-cold-restore-reanchor.test.ts +++ b/src/main/daemon/daemon-pty-adapter-cold-restore-reanchor.test.ts @@ -700,6 +700,50 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) + // Review of #33425: Sleep's adoption of an unattached session must not re-adopt one this + // client suspended after a seed failure, or Wake loses everything before the restore. + it('keeps a seed-failed session suspended when it is slept after an adapter restart', async () => { + const sessionId = 'cold-restore-seed-failure-sleep' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/tmp', + cols: 80, + rows: 24, + startedAt: '2026-07-10T08:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync(join(sessionDir, 'scrollback.bin'), 'recovered marker\r\n') + + const first = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const originalWriteSync = HeadlessEmulator.prototype.writeSync + const writeSyncSpy = vi + .spyOn(HeadlessEmulator.prototype, 'writeSync') + .mockImplementation(function (this: HeadlessEmulator, data) { + return data.includes('recovered marker') ? false : originalWriteSync.call(this, data) + }) + try { + await first.spawn({ cols: 80, rows: 24, sessionId }) + lastSubprocess._simulateData('fresh-only output\r\n') + await first.disconnectOnly() + + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + await historyAdapter.shutdown(sessionId, { immediate: true, keepHistory: true }) + + expect(existsSync(join(sessionDir, 'checkpoint.json'))).toBe(false) + expect(readFileSync(join(sessionDir, 'scrollback.bin'), 'utf8')).toContain( + 'recovered marker' + ) + } finally { + writeSyncSpy.mockRestore() + } + }) + it('does not cold-restore for clean shutdown (endedAt set)', async () => { const sessionId = 'clean-exit' const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) diff --git a/src/main/daemon/daemon-pty-adapter-wsl-sleep-cwd.test.ts b/src/main/daemon/daemon-pty-adapter-wsl-sleep-cwd.test.ts index e22a7061b26..613cf816ef3 100644 --- a/src/main/daemon/daemon-pty-adapter-wsl-sleep-cwd.test.ts +++ b/src/main/daemon/daemon-pty-adapter-wsl-sleep-cwd.test.ts @@ -162,6 +162,28 @@ describe('a sleeping bridge WSL pane keeps its shell folder', () => { expect(woken.coldRestore).toBeTruthy() }) + // awin verify-10, X2: production never calls reconcileOnStartup, so after a relaunch a pane the + // window has not reopened is known only from the inventory; its Sleep wrote no checkpoint at all. + it('reads it for a pane a relaunched app only listed, never adopted or reattached', async () => { + const before = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir() }) + const { id } = await before.spawn({ + cols: 80, + rows: 24, + cwd: WSL_CWD, + env: { ORCA_PANE_KEY: PANE_KEY }, + sessionId: 'repo::/repo@@listed' + }) + lastSubprocess._simulateData('$ cd deep/er\r\n') + await before.disconnectOnly() + adapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir() }) + expect((await adapter.listProcesses()).map((process) => process.id)).toContain(id) + + await adapter.shutdown(id, { immediate: true, keepHistory: true }) + + expect(readRows).toHaveBeenCalledWith('Ubuntu', expect.any(Number), { running: true }) + expect(checkpointCwd(id)).toBe(`${WSL_CWD}\\deep\\er`) + }) + it('keeps the spawn folder when the distro cannot be read', async () => { readRows.mockResolvedValue(null) adapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir() }) diff --git a/src/main/daemon/daemon-pty-process-inspection.ts b/src/main/daemon/daemon-pty-process-inspection.ts index 46c1b23f2ed..a1f9caeca23 100644 --- a/src/main/daemon/daemon-pty-process-inspection.ts +++ b/src/main/daemon/daemon-pty-process-inspection.ts @@ -12,6 +12,7 @@ import type { PtyProcessInspectionOptions } from '../providers/pty-process-inspection' import { clientOnlyUnverifiableInspection } from '../../shared/terminal-process-inspection' +import { remainingDaemonRequestTimeoutMs } from './daemon-request-deadline' export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshots { // Detached daemon PTYs can host agents; cleanup must not treat every foreground process as an idle shell. @@ -129,25 +130,65 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot killed.push(session.sessionId) } else { alive.push(session.sessionId) - // Include background sessions in the final disconnect checkpoint. - this.activeSessionIds.add(session.sessionId) - // Why: after a relaunch a pane the window has not reopened still sleeps, and reading its - // shell's folder then needs what only the daemon still knows about it. - if (session.wslDistro) { - this.wslDistrosBySessionId.set(session.sessionId, session.wslDistro) - } - if (session.wslDistro && session.paneKey) { - this.wslPaneKeysBySessionId.set(session.sessionId, session.paneKey) - } - await this.reconcileLiveSessionHistory(session).catch((err) => - console.warn('[history] live-session reconciliation failed:', session.sessionId, err) - ) + await this.adoptLiveSession(session) } } return { alive, killed } } + /** + * A session this client never spawned or reattached, such as one a relaunched app's window has + * not reopened: adopted before its Sleep, or that Sleep writes no checkpoint and keeps no folder. + */ + protected async adoptListedSessionForSleep( + sessionId: string, + deadlineMs?: number + ): Promise { + // Why active too: a session this client suspended after a seed failure must stay suspended, + // or its fresh-only screen would replace the history it was restored from. + if ( + !this.historyManager || + this.historyManager.hasWriter(sessionId) || + this.activeSessionIds.has(sessionId) + ) { + return + } + const result = await this.client.request( + 'listSessions', + undefined, + remainingDaemonRequestTimeoutMs(deadlineMs) + ) + const session = result.sessions.find((entry) => entry.sessionId === sessionId && entry.isAlive) + if (session) { + await this.adoptLiveSession(session, { activeOnlyIfReconciled: true }) + } + } + + private async adoptLiveSession( + session: SessionInfo, + opts: { activeOnlyIfReconciled?: boolean } = {} + ): Promise { + // Include background sessions in the final disconnect checkpoint. + if (!opts.activeOnlyIfReconciled) { + this.activeSessionIds.add(session.sessionId) + } + // Sleep reads a WSL shell's folder by what only the daemon still knows about the pane. + if (session.wslDistro) { + this.wslDistrosBySessionId.set(session.sessionId, session.wslDistro) + if (session.paneKey) { + this.wslPaneKeysBySessionId.set(session.sessionId, session.paneKey) + } + } + await this.reconcileLiveSessionHistory(session).catch((err) => + console.warn('[history] live-session reconciliation failed:', session.sessionId, err) + ) + // Why after: an exit seen during the reconcile closed the writer; that session is not live. + if (opts.activeOnlyIfReconciled && this.historyManager?.hasWriter(session.sessionId)) { + this.activeSessionIds.add(session.sessionId) + } + } + protected async reconcileLiveSessionHistory(session: SessionInfo): Promise { const historyManager = this.historyManager const historyReader = this.historyReader diff --git a/src/main/daemon/daemon-pty-session-control.ts b/src/main/daemon/daemon-pty-session-control.ts index e4451bab658..13fb079a709 100644 --- a/src/main/daemon/daemon-pty-session-control.ts +++ b/src/main/daemon/daemon-pty-session-control.ts @@ -17,6 +17,11 @@ export const LIVENESS_PROBE_TIMEOUT_MS = 2_000 const MAX_TOMBSTONES = 1000 export abstract class DaemonPtySessionControl extends DaemonPtySessionInput { + protected abstract adoptListedSessionForSleep( + sessionId: string, + deadlineMs?: number + ): Promise + async attach(id: string): Promise | void> { await this.ensureConnected() if (!this.canDelegateBackgroundToDaemon) { @@ -93,6 +98,11 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput { if (opts.keepHistory && this.disconnectOnlyPromise) { throw new Error('Cannot keep history after daemon disconnect has started') } + if (opts.keepHistory) { + // Outside the spawn lock, which adopting the session's history takes itself. + await this.ensureConnected(opts.deadlineMs) + await this.adoptListedSessionForSleep(id, opts.deadlineMs).catch(() => {}) + } const shutdown = this.withHistorySpawnLock(id, () => this.shutdownWithHistoryLock(id, opts)) if (!opts.keepHistory) { await shutdown