mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
fix(daemon): hand off slept v29 history to v30 (#11423)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user