fix(terminal): retain live history after restore loss

This commit is contained in:
Brennan Benson
2026-08-13 04:15:01 -07:00
parent 082aa4440c
commit adf85f0f22
2 changed files with 52 additions and 6 deletions
+13 -6
View File
@@ -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<TakePendingOutputResult['snapshot']>,
pendingRecords: TakePendingOutputResult['records'],
opts?: { requiredPreviousPendingOutputSeq?: number }
opts: {
pendingRecordsAreComplete: boolean
requiredPreviousPendingOutputSeq?: number
}
): Promise<NonNullable<TakePendingOutputResult['snapshot']>> {
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
@@ -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<void> }
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,