From adf85f0f225604cb1e13141ca709ce814661c105 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:15:01 -0700 Subject: [PATCH] fix(terminal): retain live history after restore loss --- src/main/daemon/daemon-pty-adapter.ts | 19 ++++++--- .../daemon-restore-scrollback-depth.test.ts | 39 +++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 20af546e06c..d605afd3a59 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -2217,9 +2217,12 @@ export class DaemonPtyAdapter implements IPtyProvider { sessionId, take.snapshot, [...take.drainedRecords, ...take.records], - opts.requireContinuityProof === true - ? { requiredPreviousPendingOutputSeq: take.seq - 1 } - : undefined + { + pendingRecordsAreComplete: take.seq === 1, + ...(opts.requireContinuityProof === true + ? { requiredPreviousPendingOutputSeq: take.seq - 1 } + : {}) + } ) const checkpoint = await this.historyManager.checkpoint(sessionId, snapshot, { pendingOutputSeq: take.seq @@ -2259,7 +2262,10 @@ export class DaemonPtyAdapter implements IPtyProvider { sessionId: string, liveSnapshot: NonNullable, pendingRecords: TakePendingOutputResult['records'], - opts?: { requiredPreviousPendingOutputSeq?: number } + opts: { + pendingRecordsAreComplete: boolean + requiredPreviousPendingOutputSeq?: number + } ): Promise> { if (!this.historyReader) { return liveSnapshot @@ -2270,8 +2276,9 @@ export class DaemonPtyAdapter implements IPtyProvider { wslDistro: this.wslDistrosBySessionId.get(sessionId) }) if ( - opts?.requiredPreviousPendingOutputSeq !== undefined && - restoreInfo?.pendingOutputSeq !== opts.requiredPreviousPendingOutputSeq + (!restoreInfo && !opts.pendingRecordsAreComplete) || + (opts.requiredPreviousPendingOutputSeq !== undefined && + restoreInfo?.pendingOutputSeq !== opts.requiredPreviousPendingOutputSeq) ) { console.warn('[history] durable continuity unproven; using live snapshot:', sessionId) return liveSnapshot diff --git a/src/main/daemon/daemon-restore-scrollback-depth.test.ts b/src/main/daemon/daemon-restore-scrollback-depth.test.ts index ad1d4300eaf..3cc1d5d7ce2 100644 --- a/src/main/daemon/daemon-restore-scrollback-depth.test.ts +++ b/src/main/daemon/daemon-restore-scrollback-depth.test.ts @@ -8,6 +8,7 @@ import { DaemonServer } from './daemon-server' import { buildDurableCheckpointSnapshot } from './daemon-durable-history-snapshot' import { DAEMON_RESTORE_SCROLLBACK_ROWS } from './daemon-restore-scrollback-depth' import { DAEMON_SESSION_SCROLLBACK_ROWS } from './daemon-session-scrollback-window' +import { getHistorySessionDirName } from './history-paths' import { getDaemonSocketPath } from './daemon-spawner' import { HeadlessEmulator } from './headless-emulator' import { HistoryManager } from './history-manager' @@ -372,6 +373,44 @@ describe('STA-4091 previously recoverable restore depth', () => { expect(snapshotText(restore ?? {})).toContain(OLDEST_WRITTEN_LINE) }) + it('uses the live window when durable history disappears after a prior drain', async () => { + const { id } = await adapter.spawn({ + cols: 80, + rows: 24, + sessionId: 'missing-history-after-drain', + cwd: '/tmp' + }) + lastSubprocess.emitData(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT)) + await adapter.getBufferSnapshot(id) + lastSubprocess.emitData(`${FRESH_AFTER_CHECKPOINT}\r\n`) + + const internals = adapter as unknown as { checkpointDirtySessions: () => Promise } + await internals.checkpointDirtySessions() + rmSync(join(historyDir, getHistorySessionDirName(id), 'checkpoint.json')) + lastSubprocess.emitData('TAIL_AFTER_HISTORY_LOSS\r\n') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + const snapshot = await adapter.getBufferSnapshot(id) + const text = `${snapshot?.scrollbackAnsi ?? ''}${snapshot?.data ?? ''}` + expect(text).toContain(NEWEST_WRITTEN_LINE) + expect(text).toContain('TAIL_AFTER_HISTORY_LOSS') + expect(text).not.toContain(OLDEST_WRITTEN_LINE) + const restored = await new HistoryReader(historyDir).detectColdRestore(id, { + ignoreCleanEnd: true + }) + expect(snapshotText(restored ?? {})).toContain(NEWEST_WRITTEN_LINE) + expect(snapshotText(restored ?? {})).toContain('TAIL_AFTER_HISTORY_LOSS') + expect(restored?.scrollbackLines).toBe(DAEMON_SESSION_SCROLLBACK_ROWS) + expect(warn).toHaveBeenCalledWith( + '[history] durable continuity unproven; using live snapshot:', + id + ) + } finally { + warn.mockRestore() + } + }) + it('preserves durable depth after an incremental append and adapter crash', async () => { const { id } = await adapter.spawn({ cols: 80,