Restore agent terminal screen on hibernation wake (#6833)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-30 01:02:11 -07:00 committed by GitHub
parent 5b7611ddcb
commit 7df09f2f4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 278 additions and 11 deletions

View File

@ -932,6 +932,103 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(result.coldRestore?.oscLinks).toEqual(oscLinks)
})
it('cold-restores an alt-screen agent snapshot as scrollback on wake (hibernation)', async () => {
// Why: agent hibernation force-kills Claude/Codex while still in their
// alt-screen TUI, so scrollbackAnsi is empty. The fix falls back to the
// saved snapshot so the pane repaints the agent's last frame instead of
// coming back blank. (The payload is snapshotAnsi alone — no
// rehydrateSequences — so it never re-enters alt-screen.)
const sessionId = 'cold-restore-alt-screen'
const sessionDir = join(historyDir, getHistorySessionDirName(sessionId))
mkdirSync(sessionDir, { recursive: true })
writeFileSync(
join(sessionDir, 'meta.json'),
JSON.stringify({
cwd: '/projects/myapp',
cols: 80,
rows: 24,
startedAt: '2026-04-15T10:00:00Z',
endedAt: null,
exitCode: null
})
)
writeFileSync(
join(sessionDir, 'checkpoint.json'),
JSON.stringify({
snapshotAnsi: '\x1b[H Claude Code — Opus 4.8\r\n > ',
scrollbackAnsi: '',
oscLinks: [],
rehydrateSequences: '\x1b[?1049h',
cwd: '/projects/myapp',
cols: 80,
rows: 24,
modes: {
bracketedPaste: false,
mouseTracking: false,
applicationCursor: false,
alternateScreen: true
},
scrollbackLines: 0,
generation: 0,
checkpointedAt: '2026-04-15T11:00:00Z'
})
)
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
expect(result.coldRestore).toBeDefined()
expect(result.coldRestore!.scrollback).toContain('Claude Code')
// The payload must NOT re-enter alt-screen — that would fight the
// relaunched agent's repaint and the renderer's POST_REPLAY_MODE_RESET.
expect(result.coldRestore!.scrollback).not.toContain('\x1b[?1049h')
})
it('skips cold restore for an alt-screen session with an empty snapshot', async () => {
// Why: alt-screen entered before any content → nothing to show. Keep the
// no-op (blank) rather than fabricate a payload.
const sessionId = 'cold-restore-alt-screen-empty'
const sessionDir = join(historyDir, getHistorySessionDirName(sessionId))
mkdirSync(sessionDir, { recursive: true })
writeFileSync(
join(sessionDir, 'meta.json'),
JSON.stringify({
cwd: '/projects/myapp',
cols: 80,
rows: 24,
startedAt: '2026-04-15T10:00:00Z',
endedAt: null,
exitCode: null
})
)
writeFileSync(
join(sessionDir, 'checkpoint.json'),
JSON.stringify({
snapshotAnsi: '',
scrollbackAnsi: '',
oscLinks: [],
rehydrateSequences: '\x1b[?1049h',
cwd: '/projects/myapp',
cols: 80,
rows: 24,
modes: {
bracketedPaste: false,
mouseTracking: false,
applicationCursor: false,
alternateScreen: true
},
scrollbackLines: 0,
generation: 0,
checkpointedAt: '2026-04-15T11:00:00Z'
})
)
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
expect(result.coldRestore).toBeUndefined()
})
it('re-anchors a cold-restored session with a full checkpoint on the first tick', async () => {
const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number }
const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS

View File

@ -206,19 +206,16 @@ export class DaemonPtyAdapter implements IPtyProvider {
// an unclean shutdown → return saved scrollback so the renderer can
// display the previous terminal content.
if (result.isNew && restoreInfo) {
// Why: if the checkpoint was captured while an alternate-screen app
// (vim, less, htop) was active, snapshotAnsi is the alt buffer content.
// Replaying that into a fresh shell would show stale TUI content. Use
// scrollbackAnsi (rows above the viewport only) which excludes the alt
// buffer. For normal sessions, use the full snapshot with rehydrate
// sequences to restore terminal modes (colors, cursor position, etc).
// Why: scrollbackAnsi may be empty if the emulator hadn't accumulated
// scrollback before the alt-screen app launched. In that case, skip
// cold restore entirely rather than showing a blank terminal — no
// content is better than confusing the user with an empty restore.
// Why prefer scrollbackAnsi for alt-screen: snapshotAnsi is the alt buffer
// (vim/less/htop); normal sessions use the full snapshot + rehydrate.
// Why the snapshotAnsi fallback: a hibernated TUI agent (empty scrollback)
// would otherwise get `|| null` → blank pane on wake. snapshotAnsi *alone*
// (no rehydrateSequences — they start with \x1b[?1049h, which the
// renderer's POST_REPLAY_MODE_RESET does NOT undo) lands the last frame as
// normal scrollback. An empty snapshot still yields null → no-op.
const isAltScreen = restoreInfo.modes.alternateScreen
const scrollback = isAltScreen
? restoreInfo.scrollbackAnsi || null
? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null
: restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi
// Why: use registerWriter (not openSession) to avoid deleting the
// existing checkpoint.json. If the revived daemon crashes again before

View File

@ -0,0 +1,173 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { mkdtempSync, rmSync } from 'node:fs'
import { HistoryManager } from './history-manager'
import { HistoryReader } from './history-reader'
import { HeadlessEmulator } from './headless-emulator'
// Reproduction for the "blank pane after agent hibernation" bug.
//
// Verified root cause (NOT the meta.endedAt gate — see below):
// Agent hibernation force-kills the agent PTY via the immediate path
// (pty:kill -> shutdown({ immediate: true, keepHistory: true }) ->
// TerminalHost.kill(immediate) -> forceKillAndDisposeSubprocess), which reaps
// synchronously WITHOUT firing onExit. So closeSession never runs and
// meta.endedAt stays null — detectColdRestore does NOT reject the session.
//
// The actual blank comes from cold-restore CONTENT, not eligibility:
// Claude/Codex TUIs run in terminal alternate-screen mode. For an alt-screen
// snapshot, HistoryReader.coldRestoreInfoFromSnapshot returns scrollbackAnsi=''
// (history-reader.ts:190-191), and DaemonPtyAdapter then skips the cold-restore
// payload entirely on `if (scrollback)` (daemon-pty-adapter.ts:230) — "no
// content is better than a confusing empty restore." Result: the daemon sends
// nothing back on wake and the preserved pane repaints blank, even though a
// full snapshotAnsi of the agent's last screen is intact on disk.
//
// This test drives a real HeadlessEmulator into alt-screen mode, checkpoints it
// through the real HistoryManager, and asserts the empty-scrollback outcome the
// adapter treats as "no cold restore".
//
// Scope note: this file documents the bug MECHANISM at the emulator/reader layer
// and replicates the adapter's payload decision inline, so its post-fix
// assertions would still pass if the production line were reverted. The actual
// regression guard that exercises DaemonPtyAdapter.spawn() end-to-end lives in
// daemon-pty-adapter.test.ts ("cold-restores an alt-screen agent snapshot…").
const ALT_SCREEN_ON = '\x1b[?1049h'
// Note: HistoryManager.checkpoint() takes the emulator's TerminalSnapshot directly
// and stamps generation / checkpointedAt itself, so em.getSnapshot() is passed as-is.
describe('agent hibernation cold-restore (alt-screen TUI)', () => {
let dir: string
const sessionId = 'repo-1::/Users/dev/pr-review-6321'
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'hibernation-repro-'))
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
it('a NORMAL-screen shell cold-restores its snapshot as scrollback (control)', async () => {
const manager = new HistoryManager(dir)
const reader = new HistoryReader(dir)
const em = new HeadlessEmulator({ cols: 80, rows: 24 })
em.writeSync('thebr@host:~/project$ echo hello\r\nhello\r\n')
await manager.openSession(sessionId, { cwd: '/home/user/project', cols: 80, rows: 24 })
await manager.checkpoint(sessionId, em.getSnapshot())
em.dispose()
const info = reader.detectColdRestore(sessionId)
expect(info).not.toBeNull()
// Adapter uses rehydrateSequences + snapshotAnsi for non-alt-screen → non-empty.
expect(info!.modes.alternateScreen).toBe(false)
// Normal-screen restores carry their buffer as scrollback; assert both so a
// regression that empties scrollbackAnsi can't slip past this control.
expect(info!.scrollbackAnsi).toContain('hello')
expect(info!.snapshotAnsi).toContain('hello')
})
it('post-fix: an ALT-SCREEN TUI agent with empty scrollback now cold-restores its snapshot', async () => {
const manager = new HistoryManager(dir)
const reader = new HistoryReader(dir)
const em = new HeadlessEmulator({ cols: 80, rows: 24 })
// Why: Claude/Codex enter the alternate screen. Once in alt-screen, the
// serialized snapshot is the TUI buffer and scrollbackAnsi is empty.
em.writeSync(ALT_SCREEN_ON)
em.writeSync('\x1b[2J\x1b[H Claude Code — Opus 4.8\r\n > ')
expect(em.isAlternateScreen).toBe(true)
await manager.openSession(sessionId, { cwd: '/home/user/project', cols: 80, rows: 24 })
await manager.checkpoint(sessionId, em.getSnapshot())
em.dispose()
const info = reader.detectColdRestore(sessionId)
// The session IS eligible (endedAt is null — hibernation's immediate kill
// never stamps it), and the snapshot of the agent's screen is intact...
expect(info).not.toBeNull()
expect(info!.modes.alternateScreen).toBe(true)
expect(info!.snapshotAnsi.length).toBeGreaterThan(0)
// scrollbackAnsi is empty for alt-screen (the bug's trigger). Pre-fix the
// adapter's `isAltScreen ? scrollbackAnsi || null : ...` dropped the
// payload here, leaving the pane blank.
expect(info!.scrollbackAnsi).toBe('')
// Replicate the adapter's POST-FIX payload decision: alt-screen falls
// back to snapshotAnsi (the agent's last frame) when scrollbackAnsi is
// empty, so the pane is no longer blank on wake.
const isAltScreen = info!.modes.alternateScreen
const adapterScrollback = isAltScreen
? info!.scrollbackAnsi || info!.snapshotAnsi || null
: info!.rehydrateSequences + info!.snapshotAnsi
expect(adapterScrollback).not.toBeNull() // → adapter sends a coldRestore payload
expect(adapterScrollback).toContain('Claude Code')
})
it('post-fix: the restored alt-screen snapshot lands at a NORMAL screen, not alt-screen', async () => {
const manager = new HistoryManager(dir)
const reader = new HistoryReader(dir)
const em = new HeadlessEmulator({ cols: 80, rows: 24 })
em.writeSync(ALT_SCREEN_ON)
em.writeSync('\x1b[2J\x1b[H Claude Code — Opus 4.8\r\n > do something\r\n')
await manager.openSession(sessionId, { cwd: '/home/user/project', cols: 80, rows: 24 })
await manager.checkpoint(sessionId, em.getSnapshot())
em.dispose()
const info = reader.detectColdRestore(sessionId)
const adapterScrollback = info!.modes.alternateScreen
? info!.scrollbackAnsi || info!.snapshotAnsi || null
: info!.rehydrateSequences + info!.snapshotAnsi
expect(adapterScrollback).not.toBeNull()
// Drive the renderer's cold-restore branch into a FRESH shell emulator:
// clear, write the payload, then POST_REPLAY_MODE_RESET. The pane must end
// in the normal buffer (no \x1b[?1049h fed) so it won't fight the agent's
// own repaint when the resume command relaunches it.
// Keep in sync with POST_REPLAY_MODE_RESET in
// src/renderer/src/components/terminal-pane/layout-serialization.ts (copied
// as a literal because a main-process test must not import a renderer module).
const POST_REPLAY_MODE_RESET =
'\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1004l\x1b[?1006l\x1b[?2004l'
const fresh = new HeadlessEmulator({ cols: 80, rows: 24 })
fresh.writeSync('\x1b[2J\x1b[3J\x1b[H')
fresh.writeSync(adapterScrollback as string)
fresh.writeSync(POST_REPLAY_MODE_RESET)
expect(fresh.isAlternateScreen).toBe(false)
expect(fresh.getVisibleLines().some((l) => l.includes('Claude Code'))).toBe(true)
fresh.dispose()
})
it('an alt-screen snapshot with no drawn content restores harmlessly (no alt-screen re-entry)', async () => {
const manager = new HistoryManager(dir)
const reader = new HistoryReader(dir)
const em = new HeadlessEmulator({ cols: 80, rows: 24 })
// Alt-screen entered but nothing drawn. SerializeAddon still emits a bare
// cursor-home (\x1b[H), so the payload is non-null but visually empty —
// safe to write into the fresh shell and crucially never re-enters
// alt-screen (rehydrateSequences is omitted).
em.writeSync(ALT_SCREEN_ON)
await manager.openSession(sessionId, { cwd: '/home/user/project', cols: 80, rows: 24 })
await manager.checkpoint(sessionId, em.getSnapshot())
em.dispose()
const info = reader.detectColdRestore(sessionId)
expect(info!.modes.alternateScreen).toBe(true)
const adapterScrollback = info!.scrollbackAnsi || info!.snapshotAnsi || null
expect(adapterScrollback).not.toContain(ALT_SCREEN_ON)
const fresh = new HeadlessEmulator({ cols: 80, rows: 24 })
fresh.writeSync('\x1b[2J\x1b[3J\x1b[H')
if (adapterScrollback) {
fresh.writeSync(adapterScrollback)
}
expect(fresh.isAlternateScreen).toBe(false)
fresh.dispose()
})
})