fix(daemon): hand off slept v29 history to v30 (#11423)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-29 20:27:20 -07:00
co-authored by Orca
parent b808a14369
commit f65afb045f
4 changed files with 202 additions and 9 deletions
+15
View File
@@ -0,0 +1,15 @@
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version'
export function shouldHandoffDaemonHistory(
keepHistory: boolean | undefined,
owner: DaemonPtyAdapter,
current: DaemonPtyAdapter
): boolean {
return (
keepHistory === true &&
owner !== current &&
owner.protocolVersion < HISTORY_SEED_TRANSFER_PROTOCOL_VERSION &&
current.protocolVersion >= HISTORY_SEED_TRANSFER_PROTOCOL_VERSION
)
}
@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonPtyRouter } from './daemon-pty-router'
import { DaemonServer } from './daemon-server'
import { getDaemonSocketPath } from './daemon-spawner'
import { TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS } from './terminal-history-seed-chunks'
import type { DaemonFileLog } from './daemon-file-log'
import type { SubprocessHandle } from './session'
function createMockSubprocess(): SubprocessHandle & {
emitData: (data: string) => void
} {
let onData: ((data: string) => void) | undefined
let onExit: ((code: number) => void) | undefined
return {
pid: 999_999_999,
getForegroundProcess: () => null,
write: vi.fn(),
resize: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
kill: vi.fn(() => setTimeout(() => onExit?.(0), 0)),
forceKill: vi.fn(() => setTimeout(() => onExit?.(137), 0)),
signal: vi.fn(),
onData(callback) {
onData = callback
},
onExit(callback) {
onExit = callback
},
dispose: vi.fn(),
emitData(data: string) {
onData?.(data)
}
}
}
describe('DaemonPtyRouter history handoff', () => {
let testDir: string
let historyPath: string
let legacyServer: DaemonServer
let currentServer: DaemonServer
let legacyAdapter: DaemonPtyAdapter
let currentAdapter: DaemonPtyAdapter
let router: DaemonPtyRouter
let legacySubprocess: ReturnType<typeof createMockSubprocess>
beforeEach(async () => {
testDir = mkdtempSync(join(tmpdir(), 'daemon-router-history-handoff-'))
historyPath = join(testDir, 'history')
const log: DaemonFileLog = { log: () => {}, close: () => {} }
legacyServer = new DaemonServer({
socketPath: getDaemonSocketPath(testDir, 29),
tokenPath: join(testDir, 'legacy.token'),
protocolVersion: 29,
log,
spawnSubprocess: () => {
legacySubprocess = createMockSubprocess()
return legacySubprocess
}
})
currentServer = new DaemonServer({
socketPath: getDaemonSocketPath(testDir, 30),
tokenPath: join(testDir, 'current.token'),
protocolVersion: 30,
log,
spawnSubprocess: createMockSubprocess
})
await Promise.all([legacyServer.start(), currentServer.start()])
legacyAdapter = new DaemonPtyAdapter({
socketPath: getDaemonSocketPath(testDir, 29),
tokenPath: join(testDir, 'legacy.token'),
protocolVersion: 29,
historyPath
})
currentAdapter = new DaemonPtyAdapter({
socketPath: getDaemonSocketPath(testDir, 30),
tokenPath: join(testDir, 'current.token'),
protocolVersion: 30,
historyPath
})
})
afterEach(async () => {
if (router) {
router.dispose()
} else {
legacyAdapter?.dispose()
currentAdapter?.dispose()
}
await Promise.all([legacyServer?.shutdown(), currentServer?.shutdown()])
rmSync(testDir, { recursive: true, force: true })
vi.restoreAllMocks()
})
it('moves a slept v29 session to v30 with its full large history', async () => {
const sessionId = 'large-legacy-session'
const marker = 'V29-HISTORY-HANDOFF-MARKER'
await legacyAdapter.spawn({ sessionId, cols: 400, rows: 24 })
legacySubprocess.emitData(`${'x'.repeat(TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS + 1)}${marker}`)
router = new DaemonPtyRouter({ current: currentAdapter, legacy: [legacyAdapter] })
await router.discoverLegacySessions()
const client = (
currentAdapter as unknown as {
client: { request: (type: string, payload?: unknown) => Promise<unknown> }
}
).client
const request = vi.spyOn(client, 'request')
await router.shutdown(sessionId, { immediate: true, keepHistory: true })
const restored = await router.spawn({ sessionId, cols: 400, rows: 24 })
expect(restored.coldRestore?.scrollback).toContain(marker)
expect(request.mock.calls.map(([type]) => type)).toEqual(
expect.arrayContaining([
'startHistorySeedTransfer',
'appendHistorySeedTransfer',
'finishHistorySeedTransfer'
])
)
await expect(router.getBufferSnapshot(sessionId)).resolves.toMatchObject({
data: expect.stringContaining(marker)
})
expect(currentAdapter.getHistoryManager()?.hasWriter(sessionId)).toBe(true)
})
})
+47
View File
@@ -7,6 +7,7 @@ import {
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION
} from './types'
import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version'
type AdapterMock = DaemonPtyAdapter & {
emitData: (id: string, data: string, sequenceChars?: number) => void
@@ -470,6 +471,52 @@ describe('DaemonPtyRouter', () => {
expect(current.hasPty).not.toHaveBeenCalledWith('legacy-session')
})
it('hands a checkpointed pre-v30 session to the current daemon on wake', async () => {
const current = createAdapter('current', [], undefined, HISTORY_SEED_TRANSFER_PROTOCOL_VERSION)
const legacy = createAdapter(
'legacy',
['legacy-session'],
undefined,
HISTORY_SEED_TRANSFER_PROTOCOL_VERSION - 1
)
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await router.discoverLegacySessions()
await router.shutdown('legacy-session', { keepHistory: true })
await router.spawn({ sessionId: 'legacy-session', cols: 80, rows: 24 })
expect(legacy.shutdown).toHaveBeenCalledWith('legacy-session', { keepHistory: true })
expect(legacy.ackColdRestore).toHaveBeenCalledWith('legacy-session')
expect(current.spawn).toHaveBeenCalledWith({
sessionId: 'legacy-session',
cols: 80,
rows: 24
})
expect(legacy.spawn).not.toHaveBeenCalled()
})
it('keeps the legacy route when checkpointed shutdown fails', async () => {
const current = createAdapter('current', [], undefined, HISTORY_SEED_TRANSFER_PROTOCOL_VERSION)
const legacy = createAdapter(
'legacy',
['legacy-session'],
undefined,
HISTORY_SEED_TRANSFER_PROTOCOL_VERSION - 1
)
vi.mocked(legacy.shutdown).mockRejectedValueOnce(new Error('checkpoint failed'))
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await router.discoverLegacySessions()
await expect(router.shutdown('legacy-session', { keepHistory: true })).rejects.toThrow(
'checkpoint failed'
)
await router.spawn({ sessionId: 'legacy-session', cols: 80, rows: 24 })
expect(legacy.spawn).toHaveBeenCalled()
expect(legacy.ackColdRestore).not.toHaveBeenCalled()
expect(current.spawn).not.toHaveBeenCalled()
})
it('fails listProcesses closed when any routed adapter cannot list sessions', async () => {
const current = createAdapter('current', ['current-session'])
const legacy = createAdapter('legacy', ['legacy-session'])
+11 -9
View File
@@ -10,6 +10,7 @@ import type {
} from '../providers/types'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
import { shouldHandoffDaemonHistory } from './daemon-history-handoff'
export class DaemonPtyRouter implements IPtyProvider {
private current: DaemonPtyAdapter
@@ -132,15 +133,16 @@ export class DaemonPtyRouter implements IPtyProvider {
id: string,
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
): Promise<void> {
await this.adapterFor(id).shutdown(id, opts)
// Why: sleep passes keepHistory=true and re-spawns against the same
// sessionId on wake. If we delete the routing entry here, adapterFor()
// falls back to `this.current` on wake — for a session that originally
// lived on a legacy adapter (different protocolVersion), the wake-side
// createOrAttach lands on the wrong adapter and creates a fresh session,
// losing the cold-restore from the legacy adapter's history dir.
if (!opts.keepHistory) {
this.sessionAdapters.delete(id)
const adapter = this.adapterFor(id)
const migrateHistory = shouldHandoffDaemonHistory(opts.keepHistory, adapter, this.current)
await adapter.shutdown(id, opts)
if (!opts.keepHistory || migrateHistory) {
if (migrateHistory) {
adapter.ackColdRestore(id)
}
if (this.sessionAdapters.get(id) === adapter) {
this.sessionAdapters.delete(id)
}
}
}