diff --git a/src/main/daemon/daemon-restore-scrollback-depth.test.ts b/src/main/daemon/daemon-restore-scrollback-depth.test.ts index 3cc1d5d7ce2..a989ee0b603 100644 --- a/src/main/daemon/daemon-restore-scrollback-depth.test.ts +++ b/src/main/daemon/daemon-restore-scrollback-depth.test.ts @@ -486,6 +486,55 @@ describe('STA-4091 previously recoverable restore depth', () => { ) }) + // Assert durable depth, not the sequence that merely enables it. + it('preserves durable depth when an empty incremental take precedes a warm reattach', async () => { + const { id } = await adapter.spawn({ + cols: 80, + rows: 24, + sessionId: 'empty-take-depth', + cwd: '/tmp' + }) + lastSubprocess.emitData(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT)) + await adapter.getBufferSnapshot(id) + lastSubprocess.emitData(`${FRESH_AFTER_CHECKPOINT}\r\n`) + + const oldInternals = adapter as unknown as { checkpointDirtySessions: () => Promise } + await oldInternals.checkpointDirtySessions() + const beforeReattach = await new HistoryReader(historyDir).detectColdRestore(id, { + ignoreCleanEnd: true + }) + expect(snapshotText(beforeReattach ?? {})).toContain(OLDEST_WRITTEN_LINE) + + // The trigger: a dirty mark with no new PTY records (the mock swallows the write, nothing echoes back). + adapter.write(id, 'noop') + await oldInternals.checkpointDirtySessions() + + simulateAdapterCrash(adapter) + adapter = new DaemonPtyAdapter({ + socketPath: getDaemonSocketPath(dir), + tokenPath: join(dir, 'test.token'), + historyPath: historyDir + }) + + const reattach = await adapter.spawn({ cols: 80, rows: 24, sessionId: id, cwd: '/tmp' }) + expect(reattach.snapshot).toContain(FRESH_AFTER_CHECKPOINT) + expect(reattach.snapshot).toContain(OLDEST_WRITTEN_LINE) + + // First post-reattach compact runs the continuity proof; it must not flatten the deep checkpoint. + const newInternals = adapter as unknown as { + checkpointDirtySessions: () => Promise + } + adapter.write(id, 'noop') + await newInternals.checkpointDirtySessions() + + const restored = await new HistoryReader(historyDir).detectColdRestore(id, { + ignoreCleanEnd: true + }) + expect(snapshotText(restored ?? {})).toContain(OLDEST_WRITTEN_LINE) + expect(snapshotText(restored ?? {})).toContain(PREVIOUSLY_RECOVERABLE_LINE) + expect(restored?.scrollbackLines).toBeGreaterThan(DAEMON_SESSION_SCROLLBACK_ROWS) + }) + it('falls back to the live window when durable history cannot be read', async () => { const { id } = await adapter.spawn({ cols: 80, diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 84eb7fef15d..f25a6fc5377 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -494,7 +494,10 @@ export class Session { this.pendingOutputRecords = [] this.pendingOutputBytes = 0 this.pendingOutputOverflowed = false - this.pendingOutputSeq += 1 + // Empty incremental takes are not persisted; advancing them would create a false reattach gap. + if (includeSnapshot || records.length > 0 || overflowed) { + this.pendingOutputSeq += 1 + } return { records: includeSnapshot ? releasedHeldBytes diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 09e96f6d1c7..0a30b3c472f 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -285,9 +285,10 @@ export type TakePendingOutputResult = { /** Drained pending queue. Absent on older daemons. includeSnapshot still * keeps `records` as held-only so mixed-version adapters do not double-replay. */ drainedRecords?: PendingOutputRecord[] - /** Monotonic per-session batch sequence. The history log stores it so the + /** Non-decreasing per-session batch sequence. The history log stores it so the * cold-restore reader can detect a lost batch (gap) and discard the log - * instead of replaying a stream with missing bytes. */ + * instead of replaying a stream with missing bytes. Snapshot, record, and + * overflow takes advance it; empty incremental takes repeat the prior value. */ seq: number /** True when the session's pending buffer exceeded its cap and records were * dropped. The caller must fall back to a full snapshot checkpoint. */