mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Stop relaunching creation-time agents on workspace activation (#10647)
* fix(activation): stop relaunching the creation-time agent on workspace activation Activating a workspace with zero renderable tabs launched the agent it was created with, unprompted and in approval-bypass mode. Navigation is not consent to start a process: the same fallback fired from post-delete focus handoff, the jump palette, keyboard cycling, CLI/relay activation, and notification clicks. The mechanism was superseded. #1814 added it when relaunching the created agent *was* the resume feature; #4706 later added real provider-session resume six lines above and left the fallback in place. What remained fired whenever a workspace had no renderable tabs -- including when nothing had ever slept -- and reported itself as `request_kind: 'resume'` while resuming nothing, discarding any resumable session a plain tab close had already purged. No caller depends on it. All seven intent-carrying callers pass an explicit `startup` on the branch where they intend a launch, and every no-startup branch either declined an agent, already has one running (host `didSpawnStartup`), or is this same defect arriving over IPC. Drops the now-orphaned imports, retargets the stale comment in launch-work-item-direct that cited reopen-relaunch as the reason to persist `createdWithAgent`, and moves the WSL default-args quoting assertion to launch-agent-in-new-tab, whose launch path still resolves those args. Regression tests are revert-sensitive -- all four fail if the fallback returns. * test(activation): name the relaunch regression tests after what they reach Three tests were named after scenarios they never invoked, which is the failure mode that lets a coverage gap read as closed. - The "host-originated" test's `notifyHostRuntime: false` is inert here: both gates resolve through `isWebRuntimeSessionActive`, false with no runtime environment seeded, so it was byte-identical to the plain reopen test. It no longer claims to cover the host `didSpawnStartup` leg, which lives in main and is unreachable from this layer. - The "post-delete focus handoff" test never deleted anything and never touched `prepareActiveWorktreeFocusAfterDelete`. That caller is asserted directly in active-worktree-focus-after-delete.test.ts, which locks out any opts. - The activate/close loop resets state instead of calling `closeTab`, so it does not exercise the sleeping-record purge its comment claimed. Also folds the primary reopen test onto `seedEmptyActivatableWorktree` — the fixture extracted for exactly that state, which its inline copy had drifted from by hardcoding a POSIX repo path. `preflight` is dropped from the launch-work-item-direct comment: the trust preflight reads the create-time argument (worktree-remote.ts), not the persisted meta. Removal safety and ownership do read the field and remain accurate. Renames the ported quoting test to what it pins. Under vitest's node environment `navigator.userAgent` carries no "Windows", so platform resolution bails before the WSL branch and the WSL preference is inert — the real coverage is single-quote escaping of user-configured agentDefaultArgs. * transfer large terminal history seeds across bounded protocol messages - Oversized cold-restore snapshots (>1MB) now upload via chunked startHistorySeedTransfer/appendHistorySeedTransfer protocol instead of inline, avoiding NDJSON line-size violations - Checkpoints automatically trim oldest rows to fit within configured byte limit (200MB) before commit - Protocol v30 required for chunked transfers; v29 daemons gracefully fall back to renderer-only recovery - NDJSON encodeNdjson() validates line size and rejects oversized payloads; notifications silently swallow encoding errors * fix(daemon): drop held output when teardown checkpoint fails to serializ When a final snapshot checkpoint fails to serialize (returns retryable), the pending output records must not be appended later—doing so would splice them over the seq gap left by the failed snapshot, defeating gap detection. Drop the records and retry the checkpoint instead. * Bump daemon protocol version to 30 * Bump daemon protocol version to 30
This commit is contained in:
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { DaemonClient } from './client'
|
||||
import { encodeNdjson } from './ndjson'
|
||||
import { encodeNdjson, NDJSON_MAX_LINE_BYTES, NdjsonLineTooLongError } from './ndjson'
|
||||
import type { HelloMessage, DaemonRequest, DaemonEvent } from './types'
|
||||
import { getDaemonSocketPath } from './daemon-spawner'
|
||||
|
||||
@@ -328,6 +328,30 @@ describe('DaemonClient', () => {
|
||||
})
|
||||
|
||||
describe('RPC', () => {
|
||||
it('rejects an oversized request before installing a timer or writing', async () => {
|
||||
await startMockDaemon()
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
const internals = client as unknown as {
|
||||
controlSocket: Socket
|
||||
pendingRequests: Map<string, unknown>
|
||||
}
|
||||
const writeSpy = vi.spyOn(internals.controlSocket, 'write')
|
||||
const timerSpy = vi.spyOn(globalThis, 'setTimeout')
|
||||
try {
|
||||
await expect(
|
||||
client.request('write', { data: 'x'.repeat(NDJSON_MAX_LINE_BYTES) })
|
||||
).rejects.toBeInstanceOf(NdjsonLineTooLongError)
|
||||
|
||||
expect(timerSpy).not.toHaveBeenCalled()
|
||||
expect(writeSpy).not.toHaveBeenCalled()
|
||||
expect(internals.pendingRequests.size).toBe(0)
|
||||
} finally {
|
||||
timerSpy.mockRestore()
|
||||
writeSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('sends request and receives response', async () => {
|
||||
await startMockDaemon({
|
||||
onControlMessage: (msg) => {
|
||||
@@ -562,5 +586,29 @@ describe('DaemonClient', () => {
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
expect(client.notify('write', { sessionId: 'session-1', data: 'hello' })).toBe(false)
|
||||
})
|
||||
|
||||
it('reports a dropped delivery for an oversized payload without writing', async () => {
|
||||
await startMockDaemon()
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
const internals = client as unknown as { controlSocket: Socket }
|
||||
const writeSpy = vi.spyOn(internals.controlSocket, 'write')
|
||||
|
||||
expect(client.notify('write', { data: 'x'.repeat(NDJSON_MAX_LINE_BYTES) })).toBe(false)
|
||||
expect(writeSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a dropped delivery when the socket write throws', async () => {
|
||||
await startMockDaemon()
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
const internals = client as unknown as { controlSocket: Socket }
|
||||
vi.spyOn(internals.controlSocket, 'write').mockImplementation(() => {
|
||||
throw new Error('EPIPE')
|
||||
})
|
||||
|
||||
// Swallowed, not rethrown: a dead socket must not tear down the caller.
|
||||
expect(client.notify('write', { sessionId: 'session-1', data: 'hello' })).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -199,6 +199,7 @@ export class DaemonClient {
|
||||
|
||||
const id = `req-${++this.requestCounter}`
|
||||
const msg = { id, type, ...(payload !== undefined ? { payload } : {}) }
|
||||
const encoded = encodeNdjson(msg)
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -212,7 +213,7 @@ export class DaemonClient {
|
||||
timer
|
||||
})
|
||||
|
||||
this.controlSocket!.write(encodeNdjson(msg))
|
||||
this.controlSocket!.write(encoded)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -224,8 +225,13 @@ export class DaemonClient {
|
||||
|
||||
const id = `${NOTIFY_PREFIX}${++this.requestCounter}`
|
||||
const msg = { id, type, ...(payload !== undefined ? { payload } : {}) }
|
||||
this.controlSocket.write(encodeNdjson(msg))
|
||||
return true
|
||||
try {
|
||||
this.controlSocket.write(encodeNdjson(msg))
|
||||
return true
|
||||
} catch {
|
||||
// Notifications are best-effort; an oversized payload must not tear down the caller.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
onEvent(listener: (event: unknown) => void): () => void {
|
||||
|
||||
@@ -10,11 +10,13 @@ export type TerminalCheckpointFile = {
|
||||
scrollbackAnsi: string
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
rehydrateSequences: string
|
||||
pendingEscapeTailAnsi?: string
|
||||
cwd: string | null
|
||||
cols: number
|
||||
rows: number
|
||||
modes: TerminalModes
|
||||
scrollbackLines: number
|
||||
lastTitle?: string
|
||||
/** Ties this checkpoint to the output.log whose header carries the same
|
||||
* generation. Absent on checkpoints written before incremental logs. */
|
||||
generation?: number
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
|
||||
|
||||
describe('foreground-confirmation daemon protocol', () => {
|
||||
it('rejects daemons from before the fresh-confirmation RPC', () => {
|
||||
expect(PROTOCOL_VERSION).toBe(29)
|
||||
expect(PROTOCOL_VERSION).toBe(30)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23)
|
||||
@@ -12,5 +12,6 @@ describe('foreground-confirmation daemon protocol', () => {
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(26)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(27)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(28)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(29)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
|
||||
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
|
||||
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
|
||||
HISTORY_SEED_TRANSFER_PROTOCOL_VERSION,
|
||||
MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION,
|
||||
PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
|
||||
PROTOCOL_VERSION,
|
||||
@@ -11,25 +12,27 @@ import {
|
||||
} from './daemon-protocol-version'
|
||||
|
||||
describe('daemon protocol version', () => {
|
||||
it('ships the 2031-unsubscribe fact after preflight-cache replacement', () => {
|
||||
expect(PROTOCOL_VERSION).toBe(29)
|
||||
it('ships bounded history transfer after the 2031-unsubscribe fact', () => {
|
||||
expect(PROTOCOL_VERSION).toBe(30)
|
||||
expect(HISTORY_SEED_TRANSFER_PROTOCOL_VERSION).toBe(30)
|
||||
expect(MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION).toBe(29)
|
||||
expect(COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION).toBe(27)
|
||||
expect(GET_FOREGROUND_PROCESS_PROTOCOL_VERSION).toBe(11)
|
||||
expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26)
|
||||
expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual(
|
||||
Array.from({ length: 28 }, (_, index) => index + 1)
|
||||
Array.from({ length: 29 }, (_, index) => index + 1)
|
||||
)
|
||||
})
|
||||
|
||||
it('withholds 2031-unsubscribe support from every preserved older daemon', () => {
|
||||
it('withholds 2031-unsubscribe support only before its v29 boundary', () => {
|
||||
// Why (#9993): v28 is what ships today, so a v28 daemon preserved across an app
|
||||
// update is the live hazard — it emits '2031-subscribe' with no way to retract it.
|
||||
// The boundary must sit at 29, not merely "recent enough".
|
||||
expect(supportsMode2031UnsubscribeFact(PROTOCOL_VERSION)).toBe(true)
|
||||
expect(supportsMode2031UnsubscribeFact(29)).toBe(true)
|
||||
expect(supportsMode2031UnsubscribeFact(28)).toBe(false)
|
||||
for (const version of PREVIOUS_DAEMON_PROTOCOL_VERSIONS) {
|
||||
for (const version of PREVIOUS_DAEMON_PROTOCOL_VERSIONS.filter((version) => version < 29)) {
|
||||
expect(supportsMode2031UnsubscribeFact(version)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Why: daemons survive app updates, so wire behavior must be version-gated.
|
||||
// v29 emits '2031-unsubscribe' transient facts; v20-28 emit only '2031-subscribe' (#9993).
|
||||
export const PROTOCOL_VERSION = 29
|
||||
// v30 transfers large cold-restore seeds across bounded NDJSON messages.
|
||||
export const PROTOCOL_VERSION = 30
|
||||
export const HISTORY_SEED_TRANSFER_PROTOCOL_VERSION = 30
|
||||
export const COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION = 27
|
||||
export const GET_FOREGROUND_PROCESS_PROTOCOL_VERSION = 11
|
||||
export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 25
|
||||
@@ -21,7 +22,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
|
||||
export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29
|
||||
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
|
||||
28
|
||||
28, 29
|
||||
] as const
|
||||
|
||||
export function supportsPtyStartupIngress(protocolVersion: number): boolean {
|
||||
|
||||
@@ -206,7 +206,7 @@ describe('DaemonPtyAdapter history recovery', () => {
|
||||
const originalCheckpoint = manager.checkpoint.bind(manager)
|
||||
let malformedLog!: Buffer
|
||||
vi.spyOn(manager, 'checkpoint').mockImplementation(async (...args) => {
|
||||
await originalCheckpoint(...args)
|
||||
const result = await originalCheckpoint(...args)
|
||||
const sessionDir = join(historyDir, getHistorySessionDirName(id))
|
||||
const checkpoint = JSON.parse(readFileSync(join(sessionDir, 'checkpoint.json'), 'utf-8'))
|
||||
malformedLog = Buffer.concat([
|
||||
@@ -217,6 +217,7 @@ describe('DaemonPtyAdapter history recovery', () => {
|
||||
])
|
||||
])
|
||||
writeFileSync(join(sessionDir, 'output.log'), malformedLog)
|
||||
return result
|
||||
})
|
||||
|
||||
await historyAdapter.shutdown(id, { immediate: true, keepHistory: true })
|
||||
@@ -243,13 +244,12 @@ describe('DaemonPtyAdapter history recovery', () => {
|
||||
let releaseCheckpoint!: () => void
|
||||
let checkpointCalls = 0
|
||||
vi.spyOn(manager, 'checkpoint').mockImplementation(async (...args) => {
|
||||
checkpointCalls++
|
||||
if (checkpointCalls === 1) {
|
||||
if (++checkpointCalls === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseCheckpoint = resolve
|
||||
})
|
||||
}
|
||||
await originalCheckpoint(...args)
|
||||
return originalCheckpoint(...args)
|
||||
})
|
||||
|
||||
const shuttingDown = historyAdapter.shutdown(id, {
|
||||
|
||||
@@ -16,10 +16,12 @@ import { HeadlessEmulator } from './headless-emulator'
|
||||
import { getHistorySessionDirName } from './history-paths'
|
||||
import type { HistoryReader } from './history-reader'
|
||||
import type { SubprocessHandle } from './session'
|
||||
import type { PendingOutputRecord } from './types'
|
||||
import type { DaemonFileLog } from './daemon-file-log'
|
||||
import type * as DaemonHealthModule from './daemon-health'
|
||||
import { getDaemonSocketPath } from './daemon-spawner'
|
||||
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
|
||||
import { TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS } from './terminal-history-seed-chunks'
|
||||
|
||||
const { getMacDaemonSystemResolverHealthMock } = vi.hoisted(() => ({
|
||||
getMacDaemonSystemResolverHealthMock: vi.fn(async () => 'unknown')
|
||||
@@ -1824,7 +1826,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
snapshot: null
|
||||
}
|
||||
})
|
||||
const checkpoint = vi.fn(async () => {})
|
||||
const checkpoint = vi.fn(async () => 'committed' as const)
|
||||
const appendIncrements = vi.fn(async () => 'ok' as const)
|
||||
const dispose = vi.fn(async () => {})
|
||||
const disconnect = vi.fn()
|
||||
@@ -1880,11 +1882,18 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
function makeCooldownHarness(takeResult: {
|
||||
overflowed: boolean
|
||||
appendResult?: 'ok' | 'needs-checkpoint'
|
||||
checkpointResult?: 'committed' | 'retryable' | 'unavailable'
|
||||
snapshotRecords?: PendingOutputRecord[]
|
||||
}): CooldownInternals {
|
||||
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
|
||||
const request = vi.fn(async (_type: string, payload: Record<string, unknown>) => {
|
||||
if (payload.includeSnapshot === true) {
|
||||
return { records: [], seq: 2, overflowed: false, snapshot: { cols: 80, rows: 24 } }
|
||||
return {
|
||||
records: takeResult.snapshotRecords ?? [],
|
||||
seq: 2,
|
||||
overflowed: false,
|
||||
snapshot: { cols: 80, rows: 24 }
|
||||
}
|
||||
}
|
||||
return {
|
||||
records: [{ kind: 'output', data: 'x' }],
|
||||
@@ -1896,7 +1905,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
const internals = historyAdapter as unknown as CooldownInternals
|
||||
internals.client = { request, disconnect: vi.fn() }
|
||||
internals.historyManager = {
|
||||
checkpoint: vi.fn(async () => {}),
|
||||
checkpoint: vi.fn(async () => takeResult.checkpointResult ?? 'committed'),
|
||||
appendIncrements: vi.fn(async () => takeResult.appendResult ?? 'ok'),
|
||||
dispose: vi.fn(async () => {})
|
||||
}
|
||||
@@ -1958,6 +1967,26 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
expect(internals.historyManager.checkpoint).toHaveBeenCalledTimes(1)
|
||||
expect(internals.sessionsNeedingFullCheckpoint.has('capped')).toBe(false)
|
||||
})
|
||||
|
||||
it('defers a teardown checkpoint that fails to serialize and drops its held tail', async () => {
|
||||
const internals = makeCooldownHarness({
|
||||
overflowed: false,
|
||||
checkpointResult: 'retryable',
|
||||
// Held shell-ready bytes ride out with the teardown snapshot (Session.prepareForFinalSnapshot).
|
||||
snapshotRecords: [{ kind: 'output', data: 'held tail' }]
|
||||
})
|
||||
|
||||
await expect(
|
||||
internals.checkpointSessions(['sleeping'], { final: true, teardown: true })
|
||||
).resolves.toEqual(new Set())
|
||||
|
||||
expect(internals.historyManager.checkpoint).toHaveBeenCalledTimes(1)
|
||||
expect(internals.sessionsNeedingFullCheckpoint.has('sleeping')).toBe(true)
|
||||
// Why the tail must not be appended: the output this take drained went into the failed snapshot, so the tail
|
||||
// would land at a contiguous seq over that hole and pass the log's gap detection.
|
||||
expect(internals.historyManager.appendIncrements).not.toHaveBeenCalled()
|
||||
expect(internals.lastFullCheckpointAt.has('sleeping')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not schedule a checkpoint timer until a session is dirty', async () => {
|
||||
@@ -2229,6 +2258,146 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uploads a large cold-restore seed in bounded protocol chunks', async () => {
|
||||
const sessionId = 'chunked-cold-restore'
|
||||
const sessionDir = join(historyDir, getHistorySessionDirName(sessionId))
|
||||
const snapshotAnsi = `${'x'.repeat(TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS + 1)}\r\nCHUNKED-SEED-MARKER`
|
||||
mkdirSync(sessionDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(sessionDir, 'meta.json'),
|
||||
JSON.stringify({
|
||||
cwd: '/projects/chunked',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
startedAt: '2026-07-25T10:00:00Z',
|
||||
endedAt: null,
|
||||
exitCode: null
|
||||
})
|
||||
)
|
||||
writeFileSync(
|
||||
join(sessionDir, 'checkpoint.json'),
|
||||
JSON.stringify({
|
||||
snapshotAnsi,
|
||||
scrollbackAnsi: '',
|
||||
rehydrateSequences: '',
|
||||
cwd: '/projects/chunked',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
modes: {
|
||||
bracketedPaste: false,
|
||||
mouseTracking: false,
|
||||
applicationCursor: false,
|
||||
alternateScreen: false
|
||||
},
|
||||
scrollbackLines: 0,
|
||||
generation: 0,
|
||||
checkpointedAt: '2026-07-25T10:00:00Z'
|
||||
})
|
||||
)
|
||||
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
|
||||
const client = (
|
||||
historyAdapter as unknown as {
|
||||
client: { request: (type: string, payload?: unknown) => Promise<unknown> }
|
||||
}
|
||||
).client
|
||||
const requestSpy = vi.spyOn(client, 'request')
|
||||
|
||||
const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
|
||||
|
||||
expect(result.coldRestore?.scrollback).toContain('CHUNKED-SEED-MARKER')
|
||||
expect(requestSpy.mock.calls.map(([type]) => type)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'startHistorySeedTransfer',
|
||||
'appendHistorySeedTransfer',
|
||||
'finishHistorySeedTransfer',
|
||||
'createOrAttach'
|
||||
])
|
||||
)
|
||||
const createPayload = requestSpy.mock.calls.find(([type]) => type === 'createOrAttach')?.[1]
|
||||
expect(createPayload).toMatchObject({
|
||||
historySeedTransferId: expect.any(String)
|
||||
})
|
||||
expect(createPayload).not.toHaveProperty('historySeed')
|
||||
await expect(historyAdapter.getBufferSnapshot(sessionId)).resolves.toMatchObject({
|
||||
data: expect.stringContaining('CHUNKED-SEED-MARKER')
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps large recovery renderer-only with a preserved legacy daemon', async () => {
|
||||
await server.shutdown()
|
||||
server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
protocolVersion: 29,
|
||||
spawnSubprocess: (opts) => {
|
||||
lastSpawnOpts = opts
|
||||
lastSubprocess = createMockSubprocess()
|
||||
return lastSubprocess
|
||||
}
|
||||
})
|
||||
await server.start()
|
||||
const sessionId = 'legacy-large-cold-restore'
|
||||
const sessionDir = join(historyDir, getHistorySessionDirName(sessionId))
|
||||
const checkpointPath = join(sessionDir, 'checkpoint.json')
|
||||
mkdirSync(sessionDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(sessionDir, 'meta.json'),
|
||||
JSON.stringify({
|
||||
cwd: '/projects/legacy',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
startedAt: '2026-07-25T10:00:00Z',
|
||||
endedAt: null,
|
||||
exitCode: null
|
||||
})
|
||||
)
|
||||
writeFileSync(
|
||||
checkpointPath,
|
||||
JSON.stringify({
|
||||
snapshotAnsi: `${'x'.repeat(TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS + 1)}LEGACY-MARKER`,
|
||||
scrollbackAnsi: '',
|
||||
rehydrateSequences: '',
|
||||
cwd: '/projects/legacy',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
modes: {
|
||||
bracketedPaste: false,
|
||||
mouseTracking: false,
|
||||
applicationCursor: false,
|
||||
alternateScreen: false
|
||||
},
|
||||
scrollbackLines: 0,
|
||||
generation: 0,
|
||||
checkpointedAt: '2026-07-25T10:00:00Z'
|
||||
})
|
||||
)
|
||||
historyAdapter = new DaemonPtyAdapter({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
protocolVersion: 29,
|
||||
historyPath: historyDir
|
||||
})
|
||||
const client = (
|
||||
historyAdapter as unknown as {
|
||||
client: { request: (type: string, payload?: unknown) => Promise<unknown> }
|
||||
}
|
||||
).client
|
||||
const requestSpy = vi.spyOn(client, 'request')
|
||||
|
||||
const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
|
||||
|
||||
expect(result.coldRestore?.scrollback).toContain('LEGACY-MARKER')
|
||||
expect(requestSpy.mock.calls.map(([type]) => type)).not.toContain('startHistorySeedTransfer')
|
||||
const createPayload = requestSpy.mock.calls.find(([type]) => type === 'createOrAttach')?.[1]
|
||||
expect(createPayload).not.toHaveProperty('historySeed')
|
||||
expect(createPayload).not.toHaveProperty('historySeedTransferId')
|
||||
expect(existsSync(checkpointPath)).toBe(true)
|
||||
const managerInternals = historyAdapter.getHistoryManager()! as unknown as {
|
||||
writers: Map<string, unknown>
|
||||
}
|
||||
expect(managerInternals.writers.has(sessionId)).toBe(false)
|
||||
})
|
||||
|
||||
it('repairs legacy hostname UNC cwd for WSL spawn and cold-restore metadata', async () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
@@ -3,8 +3,13 @@ import { basename } from 'node:path'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { DaemonClient } from './client'
|
||||
import { getMacDaemonSystemResolverHealth } from './daemon-health'
|
||||
import { HistoryManager, type HistoryRecoveryFreeze } from './history-manager'
|
||||
import {
|
||||
HistoryManager,
|
||||
type HistoryCheckpointResult,
|
||||
type HistoryRecoveryFreeze
|
||||
} from './history-manager'
|
||||
import { HistoryReader, type ColdRestoreInfo } from './history-reader'
|
||||
import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments'
|
||||
import { mintPtySessionId, parsePtySessionId } from './pty-session-id'
|
||||
import { supportsPtyStartupBarrier } from './shell-ready'
|
||||
import { CODEX_SHELL_READY_TIMEOUT_MS } from './session'
|
||||
@@ -25,6 +30,7 @@ import {
|
||||
type SessionInfo,
|
||||
type TakePendingOutputResult
|
||||
} from './types'
|
||||
import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version'
|
||||
import {
|
||||
isAgentSessionClaimedSpawnResult,
|
||||
isAgentSessionOwnerBinding,
|
||||
@@ -51,6 +57,12 @@ import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
|
||||
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
|
||||
import { ColdRestorePayloadCache, type ColdRestorePayload } from './cold-restore-payload-cache'
|
||||
import { PtyProcessListAdmission } from '../providers/pty-process-list-admission'
|
||||
import {
|
||||
iterateTerminalHistorySeedChunks,
|
||||
measureTerminalHistorySeed,
|
||||
TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS
|
||||
} from './terminal-history-seed-chunks'
|
||||
import { NdjsonLineTooLongError } from './ndjson'
|
||||
|
||||
type PendingDaemonSpawnOperation = {
|
||||
exitsBySessionId: Map<string, { incarnationId?: string }[]>
|
||||
@@ -74,14 +86,6 @@ function takeRecoveryFreeze(
|
||||
historyRecovery.freeze = null
|
||||
return freeze
|
||||
}
|
||||
|
||||
function getRecoveredHistorySeed(restoreInfo: ColdRestoreInfo): string | null {
|
||||
// Why: alt-screen snapshots are the TUI buffer; prefer its normal scrollback so a dead TUI isn't revived as the fresh shell's active screen.
|
||||
return restoreInfo.modes.alternateScreen
|
||||
? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null
|
||||
: restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi
|
||||
}
|
||||
|
||||
function providerSequenceForSpawn(
|
||||
result: CreateOrAttachResult
|
||||
): PtySpawnResult['providerSequence'] {
|
||||
@@ -412,7 +416,10 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
? CODEX_SHELL_READY_TIMEOUT_MS
|
||||
: undefined
|
||||
|
||||
const createOrAttach = (historySeed: string | null) => {
|
||||
const requestCreateOrAttach = (
|
||||
historySeed: string | undefined,
|
||||
historySeedTransferId: string | undefined
|
||||
) => {
|
||||
if (opts.signal?.aborted) {
|
||||
throw new Error('client_disconnected')
|
||||
}
|
||||
@@ -433,6 +440,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
shellReadySupported,
|
||||
...(shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs } : {}),
|
||||
...(historySeed ? { historySeed } : {}),
|
||||
...(historySeedTransferId ? { historySeedTransferId } : {}),
|
||||
...(this.supportsStartupIngress && opts.startupIngress
|
||||
? { startupIngress: opts.startupIngress }
|
||||
: {}),
|
||||
@@ -440,7 +448,65 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
})
|
||||
}
|
||||
|
||||
let scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null
|
||||
const createOrAttach = async (
|
||||
historySeedSegments: readonly string[] | null
|
||||
): Promise<CreateOrAttachResult> => {
|
||||
// Why scoped per call: the aliveness-probe retry re-runs this with its own seed, so a first-call
|
||||
// delivery failure must not force historySeeded=false on a retry that seeded successfully.
|
||||
let historySeedUnavailable = false
|
||||
const deliverSeedAndCreate = async (): Promise<CreateOrAttachResult> => {
|
||||
if (!historySeedSegments || historySeedSegments.length === 0) {
|
||||
return requestCreateOrAttach(undefined, undefined)
|
||||
}
|
||||
const metrics = measureTerminalHistorySeed(historySeedSegments)
|
||||
if (metrics.codeUnits <= TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS) {
|
||||
try {
|
||||
return await requestCreateOrAttach(historySeedSegments.join(''), undefined)
|
||||
} catch (error) {
|
||||
if (!(error instanceof NdjsonLineTooLongError)) {
|
||||
throw error
|
||||
}
|
||||
historySeedUnavailable = true
|
||||
return requestCreateOrAttach(undefined, undefined)
|
||||
}
|
||||
}
|
||||
if (this.protocolVersion < HISTORY_SEED_TRANSFER_PROTOCOL_VERSION) {
|
||||
historySeedUnavailable = true
|
||||
return requestCreateOrAttach(undefined, undefined)
|
||||
}
|
||||
|
||||
let transferId: string | undefined
|
||||
try {
|
||||
const started = await this.client.request<{ transferId: string }>(
|
||||
'startHistorySeedTransfer',
|
||||
metrics
|
||||
)
|
||||
transferId = started.transferId
|
||||
let index = 0
|
||||
for (const data of iterateTerminalHistorySeedChunks(historySeedSegments)) {
|
||||
await this.client.request('appendHistorySeedTransfer', { transferId, index, data })
|
||||
index += 1
|
||||
}
|
||||
await this.client.request('finishHistorySeedTransfer', { transferId })
|
||||
} catch (error) {
|
||||
if (transferId) {
|
||||
await this.client.request('abortHistorySeedTransfer', { transferId }).catch(() => {})
|
||||
}
|
||||
if (isDaemonGoneError(error)) {
|
||||
throw error
|
||||
}
|
||||
historySeedUnavailable = true
|
||||
return requestCreateOrAttach(undefined, undefined)
|
||||
}
|
||||
return requestCreateOrAttach(undefined, transferId)
|
||||
}
|
||||
const result = await deliverSeedAndCreate()
|
||||
return historySeedUnavailable && result.historySeeded === undefined
|
||||
? { ...result, historySeeded: false }
|
||||
: result
|
||||
}
|
||||
|
||||
let historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null
|
||||
const adoptSpawnResultSession = async (spawnResult: CreateOrAttachResult): Promise<void> => {
|
||||
const requestedSessionId = sessionId
|
||||
if (
|
||||
@@ -463,9 +529,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
historyRecovery.unreadableSessionId = null
|
||||
historyRecovery.identityChanged = true
|
||||
restoreInfo = null
|
||||
scrollback = null
|
||||
historySeedSegments = null
|
||||
}
|
||||
let result = await createOrAttach(scrollback)
|
||||
let result = await createOrAttach(historySeedSegments)
|
||||
await adoptSpawnResultSession(result)
|
||||
// Both ids: adoptSpawnResultSession may have rewritten sessionId to the claim owner.
|
||||
this.clearSessionAwaitingDaemonRecovery(requestedSessionId)
|
||||
@@ -528,8 +594,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
// Why ignoreCleanEnd: the raced exit event can write endedAt before the reply; nulling the restore here would delete the checkpoint instead of restoring it.
|
||||
if (!historyRecovery.identityChanged && result.isNew && restoreSkippedForLiveSession) {
|
||||
restoreInfo = await detectColdRestore({ ignoreCleanEnd: true })
|
||||
scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null
|
||||
if (restoreInfo && scrollback) {
|
||||
historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null
|
||||
if (restoreInfo && historySeedSegments && historySeedSegments.length > 0) {
|
||||
// Why: the aliveness probe raced with session death, so the first
|
||||
// create lacked recovery bytes. Replace it before exposing the PTY.
|
||||
if (result.incarnationId) {
|
||||
@@ -540,7 +606,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
effectiveCwd = restoreInfo.cwd
|
||||
effectiveCols = restoreInfo.cols
|
||||
effectiveRows = restoreInfo.rows
|
||||
result = await createOrAttach(scrollback)
|
||||
result = await createOrAttach(historySeedSegments)
|
||||
await adoptSpawnResultSession(result)
|
||||
const exitedRetryResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation)
|
||||
if (exitedRetryResult) {
|
||||
@@ -565,7 +631,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
result.historySeeded === false
|
||||
) {
|
||||
restoreInfo = await detectColdRestore()
|
||||
scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null
|
||||
historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null
|
||||
}
|
||||
|
||||
const wasAlreadyManaged = this.activeSessionIds.has(sessionId)
|
||||
@@ -575,7 +641,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
// Cold restore: daemon made a new session but disk history shows an unclean shutdown → return saved scrollback.
|
||||
if (restoreInfo && (result.isNew || result.historySeeded === false)) {
|
||||
const coldRestore = this.buildColdRestorePayload(restoreInfo)
|
||||
const canReanchorHistory = !scrollback || result.historySeeded === true
|
||||
const canReanchorHistory =
|
||||
!historySeedSegments || historySeedSegments.length === 0 || result.historySeeded === true
|
||||
// Why: registerWriter (not openSession) avoids deleting checkpoint.json — the only recovery data if the revived daemon crashes before the next tick.
|
||||
if (this.historyManager && !historyRecovery.identityChanged) {
|
||||
const recoveryFreeze = takeRecoveryFreeze(historyRecovery, sessionId)
|
||||
@@ -1649,7 +1716,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
if (!this.supportsIncrementalCheckpoints) {
|
||||
const result = await this.client.request<GetSnapshotResult>('getSnapshot', { sessionId })
|
||||
if (result.snapshot && this.historyManager) {
|
||||
await this.historyManager.checkpoint(sessionId, result.snapshot)
|
||||
const checkpoint = await this.historyManager.checkpoint(sessionId, result.snapshot)
|
||||
return checkpoint === 'retryable' ? 'deferred' : 'done'
|
||||
}
|
||||
return 'done'
|
||||
}
|
||||
@@ -1659,7 +1727,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
// Why take-with-snapshot not plain getSnapshot: it clears pending records in the same turn as the serialize,
|
||||
// so a warm reattach won't re-append records the checkpoint already contains (double-replay on cold restore).
|
||||
await this.takeSnapshotAndCheckpoint(sessionId, { teardown: opts.teardown })
|
||||
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, {
|
||||
teardown: opts.teardown
|
||||
})
|
||||
if (checkpoint === 'retryable') {
|
||||
this.sessionsNeedingFullCheckpoint.add(sessionId)
|
||||
return 'deferred'
|
||||
}
|
||||
this.sessionsNeedingFullCheckpoint.delete(sessionId)
|
||||
return 'done'
|
||||
}
|
||||
@@ -1675,7 +1749,11 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
this.sessionsNeedingFullCheckpoint.add(sessionId)
|
||||
return 'deferred'
|
||||
}
|
||||
await this.takeSnapshotAndCheckpoint(sessionId, { teardown: false })
|
||||
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { teardown: false })
|
||||
if (checkpoint === 'retryable') {
|
||||
this.sessionsNeedingFullCheckpoint.add(sessionId)
|
||||
return 'deferred'
|
||||
}
|
||||
return 'done'
|
||||
}
|
||||
if (take.records.length === 0) {
|
||||
@@ -1695,7 +1773,11 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
this.sessionsNeedingFullCheckpoint.add(sessionId)
|
||||
return 'deferred'
|
||||
}
|
||||
await this.takeSnapshotAndCheckpoint(sessionId, { teardown: false })
|
||||
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { teardown: false })
|
||||
if (checkpoint === 'retryable') {
|
||||
this.sessionsNeedingFullCheckpoint.add(sessionId)
|
||||
return 'deferred'
|
||||
}
|
||||
}
|
||||
return 'done'
|
||||
}
|
||||
@@ -1703,20 +1785,28 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
private async takeSnapshotAndCheckpoint(
|
||||
sessionId: string,
|
||||
opts: { teardown: boolean }
|
||||
): Promise<void> {
|
||||
): Promise<HistoryCheckpointResult> {
|
||||
const take = await this.client.request<TakePendingOutputResult | null>('takePendingOutput', {
|
||||
sessionId,
|
||||
includeSnapshot: true,
|
||||
teardownSnapshot: opts.teardown
|
||||
})
|
||||
if (take?.snapshot && this.historyManager) {
|
||||
await this.historyManager.checkpoint(sessionId, take.snapshot)
|
||||
const checkpoint = await this.historyManager.checkpoint(sessionId, take.snapshot)
|
||||
if (checkpoint !== 'committed') {
|
||||
// Why take.records is dropped, not appended: the pending output this take drained went into the snapshot that
|
||||
// failed to land, so appending the held tail at the next contiguous seq would splice it over that hole and
|
||||
// defeat the log's seq-gap detection. A stale prefix beats an undetectable hole.
|
||||
return checkpoint
|
||||
}
|
||||
this.lastFullCheckpointAt.set(sessionId, Date.now())
|
||||
if (take.records.length > 0) {
|
||||
// Why: held parser-state bytes (an incomplete shell-ready marker) aren't in the snapshot; keep them as a post-checkpoint log tail.
|
||||
await this.historyManager.appendIncrements(sessionId, take.seq, take.records)
|
||||
}
|
||||
return 'committed'
|
||||
}
|
||||
return 'unavailable'
|
||||
}
|
||||
|
||||
// Why: on daemon-death errors, respawn a fresh daemon and retry once rather than leaving terminals broken until app restart.
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
isAgentSessionExecutionClaim,
|
||||
isAgentSessionSurfaceBinding
|
||||
} from '../../shared/agent-session-host-authority'
|
||||
import { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry'
|
||||
|
||||
export type DaemonServerOptions = {
|
||||
socketPath: string
|
||||
@@ -152,6 +153,7 @@ export class DaemonServer {
|
||||
private streamClientIdBySessionId = new Map<string, string>()
|
||||
private lastInputAtBySessionId = new Map<string, number>()
|
||||
private pendingPtySpawnPreparations = new Map<string, Set<PendingPtySpawnPreparation>>()
|
||||
private historySeedTransfers = new TerminalHistorySeedTransferRegistry()
|
||||
private stopStreamBacklogProbe: () => void = () => {}
|
||||
|
||||
// Why: bypass batching within this window so keystroke echo/redraws skip the daemon's fixed batch delay.
|
||||
@@ -272,6 +274,7 @@ export class DaemonServer {
|
||||
})
|
||||
}
|
||||
this.streamDataBatcher.clear()
|
||||
this.historySeedTransfers.dispose()
|
||||
this.pendingShutdownReplies.clear()
|
||||
|
||||
for (const [, client] of this.clients) {
|
||||
@@ -475,6 +478,7 @@ export class DaemonServer {
|
||||
if (previous) {
|
||||
// Why: reconnect reuses clientId before stale close fires; cancel the old owner's preflight at handoff.
|
||||
this.cancelPendingPtySpawnPreparationsForClient(hello.clientId)
|
||||
this.historySeedTransfers.clearOwner(hello.clientId)
|
||||
this.recordFullyAuthenticatedDisconnect(previous.authenticatedPairEstablished)
|
||||
// Why: tear down the old sockets after installing the new owner so a stale close can't delete the replacement.
|
||||
previous.streamSocket?.destroy()
|
||||
@@ -518,6 +522,7 @@ export class DaemonServer {
|
||||
// Why: a client that disconnects mid-preflight would otherwise still create
|
||||
// its daemon PTY, orphaning a durable, unattached session — cancel its preps (F4).
|
||||
this.cancelPendingPtySpawnPreparationsForClient(clientId)
|
||||
this.historySeedTransfers.clearOwner(clientId)
|
||||
const wasFullyAuthenticated = client.authenticatedPairEstablished
|
||||
this.streamDataBatcher.clear(clientId)
|
||||
client.streamSocket?.destroy()
|
||||
@@ -683,6 +688,31 @@ export class DaemonServer {
|
||||
const client = this.clients.get(clientId)
|
||||
|
||||
switch (request.type) {
|
||||
case 'startHistorySeedTransfer': {
|
||||
if (!client?.authenticatedPairEstablished || client.streamSocket === null) {
|
||||
throw new Error('Daemon client connection is incomplete; reconnect')
|
||||
}
|
||||
const transferId = this.historySeedTransfers.start(clientId, request.payload)
|
||||
return { transferId }
|
||||
}
|
||||
|
||||
case 'appendHistorySeedTransfer':
|
||||
this.historySeedTransfers.append(
|
||||
clientId,
|
||||
request.payload.transferId,
|
||||
request.payload.index,
|
||||
request.payload.data
|
||||
)
|
||||
return {}
|
||||
|
||||
case 'finishHistorySeedTransfer':
|
||||
this.historySeedTransfers.finish(clientId, request.payload.transferId)
|
||||
return {}
|
||||
|
||||
case 'abortHistorySeedTransfer':
|
||||
this.historySeedTransfers.abort(clientId, request.payload.transferId)
|
||||
return {}
|
||||
|
||||
case 'createOrAttach': {
|
||||
if (this.idleShutdownState !== 'running') {
|
||||
throw new Error('Daemon temporarily unavailable; reconnect')
|
||||
@@ -704,6 +734,15 @@ export class DaemonServer {
|
||||
throw new Error('agent_session_identity_required')
|
||||
}
|
||||
await this.preparePtySpawnUnlessCanceled(p.sessionId, clientId)
|
||||
if (p.historySeed !== undefined && p.historySeedTransferId !== undefined) {
|
||||
throw new Error('Multiple terminal history seed sources')
|
||||
}
|
||||
const historySeedChunks =
|
||||
p.historySeedTransferId !== undefined
|
||||
? this.historySeedTransfers.take(clientId, p.historySeedTransferId)
|
||||
: p.historySeed !== undefined
|
||||
? [p.historySeed]
|
||||
: undefined
|
||||
result = await this.host.createOrAttach({
|
||||
sessionId: p.sessionId,
|
||||
cols: p.cols,
|
||||
@@ -719,7 +758,7 @@ export class DaemonServer {
|
||||
terminalWindowsWslDistro: p.terminalWindowsWslDistro,
|
||||
terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation,
|
||||
shellReadySupported: p.shellReadySupported,
|
||||
historySeed: p.historySeed,
|
||||
historySeedChunks,
|
||||
startupIngress: parsePtyStartupIngressIntent(p.startupIngress),
|
||||
...(p.shellReadyTimeoutMs !== undefined
|
||||
? { shellReadyTimeoutMs: p.shellReadyTimeoutMs }
|
||||
|
||||
@@ -17,23 +17,32 @@ import {
|
||||
type SessionMeta
|
||||
} from './terminal-history-metadata'
|
||||
import type { PendingOutputRecord, TerminalSnapshot } from './types'
|
||||
import type { HistoryManagerOptions, OpenSessionOptions } from './terminal-history-manager-options'
|
||||
import { TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES } from './terminal-history-file-limits'
|
||||
import { TerminalHistoryMutationTracker } from './terminal-history-mutation-tracker'
|
||||
import type {
|
||||
HistoryCheckpointResult,
|
||||
HistoryManagerOptions,
|
||||
OpenSessionOptions
|
||||
} from './terminal-history-manager-options'
|
||||
|
||||
export type { SessionMeta } from './terminal-history-metadata'
|
||||
export type { HistoryRecoveryFreeze } from './terminal-history-recovery-quarantine'
|
||||
export type { HistoryManagerOptions, OpenSessionOptions } from './terminal-history-manager-options'
|
||||
export type * from './terminal-history-manager-options'
|
||||
|
||||
export class HistoryManager {
|
||||
private basePath: string
|
||||
private writers = new Map<string, TerminalHistorySessionWriter>()
|
||||
private disabledSessions = new Set<string>()
|
||||
private pendingSessionMutations = new Map<string, Set<Promise<unknown>>>()
|
||||
private mutations = new TerminalHistoryMutationTracker()
|
||||
private recoveryFreezes = new Map<string, ActiveHistoryRecoveryFreeze>()
|
||||
private onWriteError?: (sessionId: string, error: Error) => void
|
||||
private checkpointMaxBytes: number
|
||||
|
||||
constructor(basePath: string, opts?: HistoryManagerOptions) {
|
||||
this.basePath = basePath
|
||||
constructor(
|
||||
private readonly basePath: string,
|
||||
opts?: HistoryManagerOptions
|
||||
) {
|
||||
this.onWriteError = opts?.onWriteError
|
||||
this.checkpointMaxBytes = opts?.checkpointMaxBytes ?? TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES
|
||||
}
|
||||
|
||||
async openSession(sessionId: string, opts: OpenSessionOptions): Promise<void> {
|
||||
@@ -81,7 +90,10 @@ export class HistoryManager {
|
||||
}
|
||||
}
|
||||
|
||||
this.writers.set(sessionId, new TerminalHistorySessionWriter(dir, true))
|
||||
this.writers.set(
|
||||
sessionId,
|
||||
new TerminalHistorySessionWriter(dir, true, this.checkpointMaxBytes)
|
||||
)
|
||||
} catch (err) {
|
||||
if (recoveryFreeze) {
|
||||
this.abandonRecoveryFreeze(recoveryFreeze)
|
||||
@@ -103,7 +115,7 @@ export class HistoryManager {
|
||||
const activeFreeze: ActiveHistoryRecoveryFreeze = { handle }
|
||||
this.recoveryFreezes.set(sessionId, activeFreeze)
|
||||
try {
|
||||
await this.waitForSessionMutations(sessionId)
|
||||
await this.mutations.wait(sessionId)
|
||||
activeFreeze.fingerprint = fingerprintTerminalHistorySession(this.basePath, sessionId)
|
||||
return handle
|
||||
} catch (err) {
|
||||
@@ -148,7 +160,10 @@ export class HistoryManager {
|
||||
return
|
||||
}
|
||||
const dir = join(this.basePath, getHistorySessionDirName(sessionId))
|
||||
this.writers.set(sessionId, new TerminalHistorySessionWriter(dir, false))
|
||||
this.writers.set(
|
||||
sessionId,
|
||||
new TerminalHistorySessionWriter(dir, false, this.checkpointMaxBytes)
|
||||
)
|
||||
}
|
||||
|
||||
// Why: wake re-spawns a sleep-killed session; re-register without deleting checkpoint.json, clear endedAt so it can cold-restore again.
|
||||
@@ -181,10 +196,7 @@ export class HistoryManager {
|
||||
seq: number,
|
||||
records: PendingOutputRecord[]
|
||||
): Promise<'ok' | 'needs-checkpoint'> {
|
||||
return this.trackSessionMutation(
|
||||
sessionId,
|
||||
this.appendIncrementsUntracked(sessionId, seq, records)
|
||||
)
|
||||
return this.mutations.track(sessionId, this.appendIncrementsUntracked(sessionId, seq, records))
|
||||
}
|
||||
|
||||
private async appendIncrementsUntracked(
|
||||
@@ -208,25 +220,33 @@ export class HistoryManager {
|
||||
}
|
||||
|
||||
// Full checkpoints are rare (clean disconnect, pending-buffer overflow, log cap); the 5s tick appends increments instead.
|
||||
checkpoint(sessionId: string, snapshot: TerminalSnapshot): Promise<void> {
|
||||
return this.trackSessionMutation(sessionId, this.checkpointUntracked(sessionId, snapshot))
|
||||
checkpoint(sessionId: string, snapshot: TerminalSnapshot): Promise<HistoryCheckpointResult> {
|
||||
return this.mutations.track(sessionId, this.checkpointUntracked(sessionId, snapshot))
|
||||
}
|
||||
|
||||
private async checkpointUntracked(sessionId: string, snapshot: TerminalSnapshot): Promise<void> {
|
||||
private async checkpointUntracked(
|
||||
sessionId: string,
|
||||
snapshot: TerminalSnapshot
|
||||
): Promise<HistoryCheckpointResult> {
|
||||
if (this.disabledSessions.has(sessionId)) {
|
||||
return
|
||||
return 'unavailable'
|
||||
}
|
||||
const writer = this.writers.get(sessionId)
|
||||
if (!writer) {
|
||||
return
|
||||
return 'unavailable'
|
||||
}
|
||||
|
||||
try {
|
||||
// Why: tmp+rename is atomic (corrupt checkpoint > stale); async so a sync ~MB write can't stall IPC (worse under Windows AV).
|
||||
// The adapter's checkpointInFlight guard serializes checkpoints, so concurrent async writes can't collide on the fixed .tmp path.
|
||||
await writer.checkpoint(snapshot)
|
||||
const checkpoint = await writer.checkpoint(snapshot)
|
||||
if (checkpoint.result === 'retryable') {
|
||||
this.onWriteError?.(sessionId, checkpoint.error)
|
||||
}
|
||||
return checkpoint.result
|
||||
} catch (err) {
|
||||
this.handleWriteError(sessionId, err)
|
||||
return 'unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,7 +274,7 @@ export class HistoryManager {
|
||||
if (activeFreeze) {
|
||||
this.recoveryFreezes.delete(sessionId)
|
||||
}
|
||||
await this.waitForSessionMutations(sessionId)
|
||||
await this.mutations.wait(sessionId)
|
||||
rmSync(join(this.basePath, getHistorySessionDirName(sessionId)), {
|
||||
recursive: true,
|
||||
force: true
|
||||
@@ -318,29 +338,4 @@ export class HistoryManager {
|
||||
}
|
||||
return activeFreeze
|
||||
}
|
||||
|
||||
private trackSessionMutation<T>(sessionId: string, operation: Promise<T>): Promise<T> {
|
||||
const mutations = this.pendingSessionMutations.get(sessionId) ?? new Set<Promise<unknown>>()
|
||||
mutations.add(operation)
|
||||
this.pendingSessionMutations.set(sessionId, mutations)
|
||||
void operation.then(
|
||||
() => this.finishSessionMutation(sessionId, operation),
|
||||
() => this.finishSessionMutation(sessionId, operation)
|
||||
)
|
||||
return operation
|
||||
}
|
||||
|
||||
private finishSessionMutation(sessionId: string, operation: Promise<unknown>): void {
|
||||
const mutations = this.pendingSessionMutations.get(sessionId)
|
||||
mutations?.delete(operation)
|
||||
if (mutations?.size === 0) {
|
||||
this.pendingSessionMutations.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForSessionMutations(sessionId: string): Promise<void> {
|
||||
while (this.pendingSessionMutations.has(sessionId)) {
|
||||
await Promise.allSettled(this.pendingSessionMutations.get(sessionId)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,11 +284,15 @@ export class HistoryReader {
|
||||
if (
|
||||
!(await replay.write(checkpoint.scrollbackAnsi ?? '')) ||
|
||||
!(await replay.write(checkpoint.rehydrateSequences)) ||
|
||||
!(await replay.write(checkpoint.snapshotAnsi))
|
||||
!(await replay.write(checkpoint.snapshotAnsi)) ||
|
||||
!(await replay.write(checkpoint.pendingEscapeTailAnsi ?? ''))
|
||||
) {
|
||||
return { restoreInfo: null, readFailed: true }
|
||||
}
|
||||
emulator.setRestoredOscLinks(checkpoint.oscLinks)
|
||||
if (checkpoint.lastTitle) {
|
||||
emulator.setLastTitle(checkpoint.lastTitle)
|
||||
}
|
||||
}
|
||||
for (const batch of log.batches) {
|
||||
for (const record of batch.records) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { jsonUtf8ByteLength } from './json-utf8-byte-length'
|
||||
|
||||
describe('jsonUtf8ByteLength', () => {
|
||||
it('matches JSON.stringify for escapes, Unicode, surrogates, and nested values', () => {
|
||||
const values: unknown[] = [
|
||||
'',
|
||||
'"\\\b\t\n\f\r\u0000\u001f',
|
||||
'plain ASCII',
|
||||
'é漢😀',
|
||||
'\ud800 lone high \udc00 lone low',
|
||||
{
|
||||
omitted: undefined,
|
||||
finite: -1.25e100,
|
||||
nonFinite: Number.POSITIVE_INFINITY,
|
||||
nested: ['😀', undefined, null, { control: '\u0001' }]
|
||||
}
|
||||
]
|
||||
|
||||
for (const value of values) {
|
||||
const json = JSON.stringify(value)
|
||||
expect(jsonUtf8ByteLength(value)).toBe(Buffer.byteLength(json, 'utf8'))
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects the same unsupported structural values as JSON.stringify', () => {
|
||||
const circular: Record<string, unknown> = {}
|
||||
circular.self = circular
|
||||
|
||||
expect(() => jsonUtf8ByteLength(circular)).toThrow('circular')
|
||||
expect(() => jsonUtf8ByteLength(1n)).toThrow('BigInt')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
function jsonStringUtf8Bytes(value: string): number {
|
||||
let bytes = 2
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const codeUnit = value.charCodeAt(index)
|
||||
if (codeUnit === 0x22 || codeUnit === 0x5c || codeUnit === 0x08 || codeUnit === 0x09) {
|
||||
bytes += 2
|
||||
} else if (codeUnit === 0x0a || codeUnit === 0x0c || codeUnit === 0x0d) {
|
||||
bytes += 2
|
||||
} else if (codeUnit < 0x20) {
|
||||
bytes += 6
|
||||
} else if (codeUnit < 0x80) {
|
||||
bytes += 1
|
||||
} else if (codeUnit < 0x800) {
|
||||
bytes += 2
|
||||
} else if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1)
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
bytes += 4
|
||||
index += 1
|
||||
} else {
|
||||
bytes += 6
|
||||
}
|
||||
} else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
||||
bytes += 6
|
||||
} else {
|
||||
bytes += 3
|
||||
}
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
export function jsonUtf8ByteLength(value: unknown): number {
|
||||
const activeObjects = new Set<object>()
|
||||
|
||||
const measure = (current: unknown, arrayElement: boolean): number | null => {
|
||||
if (current === null) {
|
||||
return 4
|
||||
}
|
||||
switch (typeof current) {
|
||||
case 'string':
|
||||
return jsonStringUtf8Bytes(current)
|
||||
case 'boolean':
|
||||
return current ? 4 : 5
|
||||
case 'number':
|
||||
return Number.isFinite(current) ? JSON.stringify(current).length : 4
|
||||
case 'undefined':
|
||||
case 'function':
|
||||
case 'symbol':
|
||||
return arrayElement ? 4 : null
|
||||
case 'bigint':
|
||||
throw new TypeError('Do not know how to serialize a BigInt')
|
||||
case 'object':
|
||||
break
|
||||
}
|
||||
|
||||
const object = current as object
|
||||
if (activeObjects.has(object)) {
|
||||
throw new TypeError('Converting circular structure to JSON')
|
||||
}
|
||||
activeObjects.add(object)
|
||||
try {
|
||||
if (Array.isArray(object)) {
|
||||
let bytes = 2
|
||||
for (let index = 0; index < object.length; index += 1) {
|
||||
if (index > 0) {
|
||||
bytes += 1
|
||||
}
|
||||
bytes += measure(object[index], true) ?? 4
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
let bytes = 2
|
||||
let entries = 0
|
||||
for (const key of Object.keys(object)) {
|
||||
const propertyBytes = measure((object as Record<string, unknown>)[key], false)
|
||||
if (propertyBytes === null) {
|
||||
continue
|
||||
}
|
||||
bytes += (entries > 0 ? 1 : 0) + jsonStringUtf8Bytes(key) + 1 + propertyBytes
|
||||
entries += 1
|
||||
}
|
||||
return bytes
|
||||
} finally {
|
||||
activeObjects.delete(object)
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = measure(value, false)
|
||||
if (bytes === null) {
|
||||
throw new TypeError('Value is not JSON serializable')
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { encodeNdjson, createNdjsonParser, NDJSON_MAX_LINE_BYTES } from './ndjson'
|
||||
import {
|
||||
encodeNdjson,
|
||||
createNdjsonParser,
|
||||
NDJSON_MAX_LINE_BYTES,
|
||||
NdjsonLineTooLongError
|
||||
} from './ndjson'
|
||||
|
||||
describe('encodeNdjson', () => {
|
||||
it('encodes an object as a JSON line ending with newline', () => {
|
||||
@@ -13,6 +18,20 @@ describe('encodeNdjson', () => {
|
||||
expect(result.endsWith('\n')).toBe(true)
|
||||
expect(JSON.parse(result.trim())).toEqual(msg)
|
||||
})
|
||||
|
||||
it('accepts the exact line-byte limit and rejects one byte more', () => {
|
||||
const emptyBytes = Buffer.byteLength(JSON.stringify({ data: '' }), 'utf8')
|
||||
expect(encodeNdjson({ data: 'abc' }, emptyBytes + 3)).toBe('{"data":"abc"}\n')
|
||||
expect(() => encodeNdjson({ data: 'abcd' }, emptyBytes + 3)).toThrow(NdjsonLineTooLongError)
|
||||
})
|
||||
|
||||
// Why: the cap is UTF-8 bytes, not characters — a code-unit count would let a 4-byte emoji slip past.
|
||||
it('measures multibyte payloads in UTF-8 bytes, not characters', () => {
|
||||
const emptyBytes = Buffer.byteLength(JSON.stringify({ data: '' }), 'utf8')
|
||||
expect(encodeNdjson({ data: '🐙' }, emptyBytes + 4)).toBe('{"data":"🐙"}\n')
|
||||
expect(() => encodeNdjson({ data: '🐙' }, emptyBytes + 3)).toThrow(NdjsonLineTooLongError)
|
||||
expect(() => encodeNdjson({ data: 'é' }, emptyBytes + 1)).toThrow(NdjsonLineTooLongError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createNdjsonParser', () => {
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
export function encodeNdjson(msg: unknown): string {
|
||||
return `${JSON.stringify(msg)}\n`
|
||||
export const NDJSON_MAX_LINE_BYTES = 16 * 1024 * 1024
|
||||
|
||||
export class NdjsonLineTooLongError extends Error {
|
||||
constructor(
|
||||
readonly lineBytes: number,
|
||||
readonly maxLineBytes: number
|
||||
) {
|
||||
super(`NDJSON line exceeds max ${maxLineBytes} bytes (${lineBytes} bytes encoded)`)
|
||||
this.name = 'NdjsonLineTooLongError'
|
||||
}
|
||||
}
|
||||
|
||||
export const NDJSON_MAX_LINE_BYTES = 16 * 1024 * 1024
|
||||
export function encodeNdjson(msg: unknown, maxLineBytes = NDJSON_MAX_LINE_BYTES): string {
|
||||
const line = JSON.stringify(msg)
|
||||
const lineBytes = Buffer.byteLength(line, 'utf8')
|
||||
if (lineBytes > maxLineBytes) {
|
||||
throw new NdjsonLineTooLongError(lineBytes, maxLineBytes)
|
||||
}
|
||||
return `${line}\n`
|
||||
}
|
||||
|
||||
export type NdjsonParser = {
|
||||
feed(chunk: string): void
|
||||
|
||||
@@ -80,7 +80,7 @@ export type SessionOptions = {
|
||||
subprocess: SubprocessHandle
|
||||
shellReadySupported: boolean
|
||||
shellReadyTimeoutMs?: number
|
||||
historySeed?: string
|
||||
historySeedChunks?: readonly string[]
|
||||
scrollback?: number
|
||||
wslDistro?: string
|
||||
// Fired once the session reaches a terminal state so the owner (TerminalHost) can reap it; without
|
||||
@@ -146,8 +146,12 @@ export class Session {
|
||||
// the authoritative responder and a daemon reply would race ahead and clobber it. See HeadlessEmulator.
|
||||
})
|
||||
// Why: seed recovery must precede listener registration; shells can emit their prompt synchronously once onData subscribes.
|
||||
// Why the every() short-circuit is safe: writeSync only fails emulator-wide (disposed / no sync write API), so later
|
||||
// chunks could not land either — and writing them past a dropped chunk would seed a torn stream.
|
||||
this._historySeeded =
|
||||
opts.historySeed === undefined ? undefined : this.emulator.writeSync(opts.historySeed)
|
||||
opts.historySeedChunks === undefined
|
||||
? undefined
|
||||
: opts.historySeedChunks.every((chunk) => this.emulator.writeSync(chunk))
|
||||
|
||||
if (opts.shellReadySupported) {
|
||||
this._shellState = 'pending'
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { TerminalCheckpointFile, TerminalSnapshot } from './types'
|
||||
import { ColdRestoreReplayWriter } from './cold-restore-replay-writer'
|
||||
import { HeadlessEmulator } from './headless-emulator'
|
||||
import { jsonUtf8ByteLength } from './json-utf8-byte-length'
|
||||
|
||||
type CheckpointMetadata = {
|
||||
cwd: string | null
|
||||
generation: number
|
||||
checkpointedAt: string
|
||||
}
|
||||
|
||||
function checkpointFile(
|
||||
snapshot: TerminalSnapshot,
|
||||
metadata: CheckpointMetadata
|
||||
): TerminalCheckpointFile {
|
||||
return {
|
||||
snapshotAnsi: snapshot.snapshotAnsi,
|
||||
scrollbackAnsi: snapshot.scrollbackAnsi,
|
||||
oscLinks: snapshot.oscLinks,
|
||||
rehydrateSequences: snapshot.rehydrateSequences,
|
||||
...(snapshot.pendingEscapeTailAnsi
|
||||
? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi }
|
||||
: {}),
|
||||
cwd: metadata.cwd,
|
||||
cols: snapshot.cols,
|
||||
rows: snapshot.rows,
|
||||
modes: snapshot.modes,
|
||||
scrollbackLines: snapshot.scrollbackLines,
|
||||
...(snapshot.lastTitle ? { lastTitle: snapshot.lastTitle } : {}),
|
||||
generation: metadata.generation,
|
||||
checkpointedAt: metadata.checkpointedAt
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyWithinLimit(checkpoint: TerminalCheckpointFile, maxBytes: number): string | null {
|
||||
if (jsonUtf8ByteLength(checkpoint) > maxBytes) {
|
||||
return null
|
||||
}
|
||||
const json = JSON.stringify(checkpoint)
|
||||
if (Buffer.byteLength(json, 'utf8') > maxBytes) {
|
||||
throw new Error('Terminal checkpoint size estimator mismatch')
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
async function replaySnapshot(snapshot: TerminalSnapshot): Promise<HeadlessEmulator> {
|
||||
const emulator = new HeadlessEmulator({
|
||||
cols: snapshot.cols,
|
||||
rows: snapshot.rows,
|
||||
scrollback: Math.max(0, Math.min(50_000, snapshot.scrollbackLines))
|
||||
})
|
||||
const replay = new ColdRestoreReplayWriter(emulator)
|
||||
try {
|
||||
for (const segment of [
|
||||
snapshot.scrollbackAnsi,
|
||||
snapshot.rehydrateSequences,
|
||||
snapshot.snapshotAnsi,
|
||||
snapshot.pendingEscapeTailAnsi ?? ''
|
||||
]) {
|
||||
if (!(await replay.write(segment))) {
|
||||
throw new Error('Terminal checkpoint replay is unavailable')
|
||||
}
|
||||
}
|
||||
emulator.setCwd(snapshot.cwd)
|
||||
if (snapshot.lastTitle) {
|
||||
emulator.setLastTitle(snapshot.lastTitle)
|
||||
}
|
||||
emulator.setRestoredOscLinks(snapshot.oscLinks)
|
||||
return emulator
|
||||
} catch (error) {
|
||||
emulator.dispose()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function serializeTerminalCheckpointWithinLimit(
|
||||
snapshot: TerminalSnapshot,
|
||||
metadata: CheckpointMetadata,
|
||||
maxBytes: number
|
||||
): Promise<string> {
|
||||
const direct = stringifyWithinLimit(checkpointFile(snapshot, metadata), maxBytes)
|
||||
if (direct !== null) {
|
||||
return direct
|
||||
}
|
||||
|
||||
const emulator = await replaySnapshot(snapshot)
|
||||
try {
|
||||
const visibleOnly = emulator.getSnapshot({ scrollbackRows: 0 })
|
||||
let bestJson = stringifyWithinLimit(checkpointFile(visibleOnly, metadata), maxBytes)
|
||||
if (bestJson === null) {
|
||||
throw new Error('Terminal checkpoint metadata exceeds byte limit')
|
||||
}
|
||||
|
||||
let low = 1
|
||||
let high = visibleOnly.scrollbackLines
|
||||
while (low <= high) {
|
||||
const rows = low + Math.floor((high - low) / 2)
|
||||
const candidate = emulator.getSnapshot({ scrollbackRows: rows })
|
||||
const candidateJson = stringifyWithinLimit(checkpointFile(candidate, metadata), maxBytes)
|
||||
if (candidateJson === null) {
|
||||
high = rows - 1
|
||||
} else {
|
||||
bestJson = candidateJson
|
||||
low = rows + 1
|
||||
}
|
||||
}
|
||||
return bestJson
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { HistoryManager } from './history-manager'
|
||||
import { HistoryReader } from './history-reader'
|
||||
import { getHistorySessionDirName } from './history-paths'
|
||||
import type { TerminalSnapshot } from './types'
|
||||
|
||||
const SESSION_ID = 'bounded-checkpoint'
|
||||
|
||||
function snapshot(snapshotAnsi: string): TerminalSnapshot {
|
||||
return {
|
||||
snapshotAnsi,
|
||||
scrollbackAnsi: '',
|
||||
rehydrateSequences: '',
|
||||
cwd: '/workspace',
|
||||
modes: {
|
||||
bracketedPaste: false,
|
||||
mouseTracking: false,
|
||||
applicationCursor: false,
|
||||
alternateScreen: false
|
||||
},
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
scrollbackLines: 500
|
||||
}
|
||||
}
|
||||
|
||||
describe('bounded terminal checkpoint writer', () => {
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'checkpoint-writer-bounds-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('trims oldest rows and commits a checkpoint within the reader byte contract', async () => {
|
||||
const maxBytes = 4_000
|
||||
const manager = new HistoryManager(dir, { checkpointMaxBytes: maxBytes })
|
||||
await manager.openSession(SESSION_ID, { cwd: '/workspace', cols: 80, rows: 24 })
|
||||
const lines = Array.from({ length: 500 }, (_, index) => `history-${index}\r\n`).join('')
|
||||
|
||||
await expect(manager.checkpoint(SESSION_ID, snapshot(`${lines}NEWEST-MARKER`))).resolves.toBe(
|
||||
'committed'
|
||||
)
|
||||
|
||||
const checkpointPath = join(dir, getHistorySessionDirName(SESSION_ID), 'checkpoint.json')
|
||||
const checkpoint = JSON.parse(readFileSync(checkpointPath, 'utf8'))
|
||||
expect(statSync(checkpointPath).size).toBeLessThanOrEqual(maxBytes)
|
||||
expect(checkpoint.snapshotAnsi).toContain('NEWEST-MARKER')
|
||||
expect(checkpoint.snapshotAnsi).not.toContain('history-0')
|
||||
})
|
||||
|
||||
it('keeps serialization failures retryable without disabling the session', async () => {
|
||||
const manager = new HistoryManager(dir)
|
||||
await manager.openSession(SESSION_ID, { cwd: '/workspace', cols: 80, rows: 24 })
|
||||
await expect(manager.checkpoint(SESSION_ID, snapshot('stable'))).resolves.toBe('committed')
|
||||
const checkpointPath = join(dir, getHistorySessionDirName(SESSION_ID), 'checkpoint.json')
|
||||
const previous = readFileSync(checkpointPath, 'utf8')
|
||||
const invalid = snapshot('invalid')
|
||||
const circular: Record<string, unknown> = {}
|
||||
circular.self = circular
|
||||
invalid.oscLinks = [circular as never]
|
||||
|
||||
await expect(manager.checkpoint(SESSION_ID, invalid)).resolves.toBe('retryable')
|
||||
expect(manager.isSessionDisabled(SESSION_ID)).toBe(false)
|
||||
expect(readFileSync(checkpointPath, 'utf8')).toBe(previous)
|
||||
await expect(manager.checkpoint(SESSION_ID, snapshot('recovered'))).resolves.toBe('committed')
|
||||
expect(readFileSync(checkpointPath, 'utf8')).toContain('recovered')
|
||||
})
|
||||
|
||||
it('persists parser-tail and title metadata when present', async () => {
|
||||
const manager = new HistoryManager(dir)
|
||||
await manager.openSession(SESSION_ID, { cwd: '/workspace', cols: 80, rows: 24 })
|
||||
|
||||
await manager.checkpoint(SESSION_ID, {
|
||||
...snapshot('body'),
|
||||
pendingEscapeTailAnsi: '\x1b[38;5;',
|
||||
lastTitle: 'Codex working'
|
||||
})
|
||||
|
||||
const checkpointPath = join(dir, getHistorySessionDirName(SESSION_ID), 'checkpoint.json')
|
||||
expect(existsSync(checkpointPath)).toBe(true)
|
||||
expect(JSON.parse(readFileSync(checkpointPath, 'utf8'))).toMatchObject({
|
||||
pendingEscapeTailAnsi: '\x1b[38;5;',
|
||||
lastTitle: 'Codex working'
|
||||
})
|
||||
})
|
||||
|
||||
it('replays a persisted parser tail before incremental log output', async () => {
|
||||
const manager = new HistoryManager(dir)
|
||||
await manager.openSession(SESSION_ID, { cwd: '/workspace', cols: 80, rows: 24 })
|
||||
await manager.checkpoint(SESSION_ID, {
|
||||
...snapshot('base\r\n'),
|
||||
pendingEscapeTailAnsi: '\x1b[31'
|
||||
})
|
||||
await manager.appendIncrements(SESSION_ID, 1, [{ kind: 'output', data: 'mRED' }])
|
||||
|
||||
const restored = await new HistoryReader(dir).detectColdRestore(SESSION_ID)
|
||||
|
||||
expect(restored?.snapshotAnsi).toContain('\x1b[31mRED')
|
||||
})
|
||||
})
|
||||
@@ -44,7 +44,10 @@ function isTerminalCheckpointFile(value: unknown): value is TerminalCheckpointFi
|
||||
isNonNegativeSafeInteger(checkpoint.scrollbackLines) &&
|
||||
(checkpoint.generation === undefined || isNonNegativeSafeInteger(checkpoint.generation)) &&
|
||||
typeof checkpoint.checkpointedAt === 'string' &&
|
||||
(checkpoint.oscLinks === undefined || isTerminalOscLinkRanges(checkpoint.oscLinks))
|
||||
(checkpoint.oscLinks === undefined || isTerminalOscLinkRanges(checkpoint.oscLinks)) &&
|
||||
(checkpoint.pendingEscapeTailAnsi === undefined ||
|
||||
typeof checkpoint.pendingEscapeTailAnsi === 'string') &&
|
||||
(checkpoint.lastTitle === undefined || typeof checkpoint.lastTitle === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ export type ColdRestoreInfo = {
|
||||
cols: number
|
||||
rows: number
|
||||
modes: TerminalModes
|
||||
pendingEscapeTailAnsi?: string
|
||||
lastTitle?: string
|
||||
}
|
||||
|
||||
type RestoredSnapshot = {
|
||||
@@ -21,6 +23,8 @@ type RestoredSnapshot = {
|
||||
cols: number
|
||||
rows: number
|
||||
modes: TerminalModes
|
||||
pendingEscapeTailAnsi?: string
|
||||
lastTitle?: string
|
||||
}
|
||||
|
||||
export function coldRestoreInfoFromSnapshot(
|
||||
@@ -39,6 +43,10 @@ export function coldRestoreInfoFromSnapshot(
|
||||
cwd: cwd ?? meta.cwd,
|
||||
cols: snapshot.cols,
|
||||
rows: snapshot.rows,
|
||||
modes: snapshot.modes
|
||||
modes: snapshot.modes,
|
||||
...(snapshot.pendingEscapeTailAnsi
|
||||
? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi }
|
||||
: {}),
|
||||
...(snapshot.lastTitle ? { lastTitle: snapshot.lastTitle } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
export const TERMINAL_HISTORY_META_MAX_BYTES = 64 * 1024
|
||||
export const TERMINAL_HISTORY_LOG_MAX_BYTES = 5 * 1024 * 1024
|
||||
// Why deliberately generous: the checkpoint writer is unbounded, and a read cap under what it
|
||||
// can emit silently drops ALL scrollback on cold restore. This guards a corrupt/runaway file,
|
||||
// not retention — a 50k-row max preset of ordinary text serializes to ~14MB, ~15x under. Output
|
||||
// colored per cell can still exceed this; trimming the snapshot writer-side is the real fix.
|
||||
// Shared reader/writer contract: oversized snapshots trim their oldest rows before commit.
|
||||
export const TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES = 200_000_000
|
||||
export const TERMINAL_HISTORY_LEGACY_SCROLLBACK_MAX_BYTES = 16 * 1024 * 1024
|
||||
|
||||
@@ -10,4 +10,7 @@ export type OpenSessionOptions = {
|
||||
|
||||
export type HistoryManagerOptions = {
|
||||
onWriteError?: (sessionId: string, error: Error) => void
|
||||
checkpointMaxBytes?: number
|
||||
}
|
||||
|
||||
export type HistoryCheckpointResult = 'committed' | 'retryable' | 'unavailable'
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export class TerminalHistoryMutationTracker {
|
||||
private pending = new Map<string, Set<Promise<unknown>>>()
|
||||
|
||||
track<T>(sessionId: string, operation: Promise<T>): Promise<T> {
|
||||
const mutations = this.pending.get(sessionId) ?? new Set<Promise<unknown>>()
|
||||
mutations.add(operation)
|
||||
this.pending.set(sessionId, mutations)
|
||||
void operation.then(
|
||||
() => this.finish(sessionId, operation),
|
||||
() => this.finish(sessionId, operation)
|
||||
)
|
||||
return operation
|
||||
}
|
||||
|
||||
async wait(sessionId: string): Promise<void> {
|
||||
while (this.pending.has(sessionId)) {
|
||||
await Promise.allSettled(this.pending.get(sessionId)!)
|
||||
}
|
||||
}
|
||||
|
||||
private finish(sessionId: string, operation: Promise<unknown>): void {
|
||||
const mutations = this.pending.get(sessionId)
|
||||
mutations?.delete(operation)
|
||||
if (mutations?.size === 0) {
|
||||
this.pending.delete(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
iterateTerminalHistorySeedChunks,
|
||||
measureTerminalHistorySeed,
|
||||
TERMINAL_HISTORY_SEED_CHUNK_CODE_UNITS
|
||||
} from './terminal-history-seed-chunks'
|
||||
|
||||
describe('terminal history seed chunks', () => {
|
||||
it('preserves segment order without splitting valid surrogate pairs', () => {
|
||||
const segments = [
|
||||
`${'a'.repeat(TERMINAL_HISTORY_SEED_CHUNK_CODE_UNITS - 1)}\ud83d`,
|
||||
'\ude00tail'
|
||||
]
|
||||
const chunks = [...iterateTerminalHistorySeedChunks(segments)]
|
||||
|
||||
expect(chunks.join('')).toBe(segments.join(''))
|
||||
expect(chunks.every((chunk) => chunk.length <= TERMINAL_HISTORY_SEED_CHUNK_CODE_UNITS)).toBe(
|
||||
true
|
||||
)
|
||||
expect(chunks).toContain('😀')
|
||||
})
|
||||
|
||||
it('measures the exact chunk count, code units, and UTF-16 digest', () => {
|
||||
const segments = ['alpha', '😀', '\x1b[31mred']
|
||||
const metrics = measureTerminalHistorySeed(segments)
|
||||
const expectedDigest = createHash('sha256')
|
||||
.update(Buffer.from(segments.join(''), 'utf16le'))
|
||||
.digest('hex')
|
||||
|
||||
expect(metrics).toEqual({
|
||||
chunkCount: [...iterateTerminalHistorySeedChunks(segments)].length,
|
||||
codeUnits: segments.join('').length,
|
||||
sha256: expectedDigest
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
export const TERMINAL_HISTORY_SEED_CHUNK_CODE_UNITS = 512 * 1024
|
||||
export const TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS = 1024 * 1024
|
||||
|
||||
export type TerminalHistorySeedMetrics = {
|
||||
chunkCount: number
|
||||
codeUnits: number
|
||||
sha256: string
|
||||
}
|
||||
|
||||
function isHighSurrogate(codeUnit: number): boolean {
|
||||
return codeUnit >= 0xd800 && codeUnit <= 0xdbff
|
||||
}
|
||||
|
||||
function isLowSurrogate(codeUnit: number): boolean {
|
||||
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff
|
||||
}
|
||||
|
||||
export function* iterateTerminalHistorySeedChunks(segments: readonly string[]): Generator<string> {
|
||||
let trailingHighSurrogate = ''
|
||||
|
||||
for (const segment of segments) {
|
||||
let offset = 0
|
||||
if (trailingHighSurrogate) {
|
||||
if (segment.length > 0 && isLowSurrogate(segment.charCodeAt(0))) {
|
||||
yield trailingHighSurrogate + segment[0]
|
||||
offset = 1
|
||||
} else {
|
||||
yield trailingHighSurrogate
|
||||
}
|
||||
trailingHighSurrogate = ''
|
||||
}
|
||||
|
||||
while (offset < segment.length) {
|
||||
let end = Math.min(segment.length, offset + TERMINAL_HISTORY_SEED_CHUNK_CODE_UNITS)
|
||||
if (
|
||||
end < segment.length &&
|
||||
isHighSurrogate(segment.charCodeAt(end - 1)) &&
|
||||
isLowSurrogate(segment.charCodeAt(end))
|
||||
) {
|
||||
end -= 1
|
||||
}
|
||||
if (end === segment.length && isHighSurrogate(segment.charCodeAt(end - 1))) {
|
||||
trailingHighSurrogate = segment[end - 1]
|
||||
end -= 1
|
||||
}
|
||||
if (end > offset) {
|
||||
yield segment.slice(offset, end)
|
||||
}
|
||||
offset = Math.max(end, offset + (end === offset ? 1 : 0))
|
||||
}
|
||||
}
|
||||
|
||||
if (trailingHighSurrogate) {
|
||||
yield trailingHighSurrogate
|
||||
}
|
||||
}
|
||||
|
||||
export function measureTerminalHistorySeed(
|
||||
segments: readonly string[]
|
||||
): TerminalHistorySeedMetrics {
|
||||
const hash = createHash('sha256')
|
||||
let chunkCount = 0
|
||||
let codeUnits = 0
|
||||
|
||||
for (const chunk of iterateTerminalHistorySeedChunks(segments)) {
|
||||
hash.update(Buffer.from(chunk, 'utf16le'))
|
||||
chunkCount += 1
|
||||
codeUnits += chunk.length
|
||||
}
|
||||
|
||||
return { chunkCount, codeUnits, sha256: hash.digest('hex') }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ColdRestoreInfo } from './terminal-history-cold-restore-info'
|
||||
|
||||
export function getRecoveredHistorySeedSegments(restoreInfo: ColdRestoreInfo): readonly string[] {
|
||||
if (restoreInfo.modes.alternateScreen) {
|
||||
const normalBuffer = restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi
|
||||
return normalBuffer ? [normalBuffer] : []
|
||||
}
|
||||
return [
|
||||
restoreInfo.rehydrateSequences,
|
||||
restoreInfo.snapshotAnsi,
|
||||
...(restoreInfo.pendingEscapeTailAnsi ? [restoreInfo.pendingEscapeTailAnsi] : [])
|
||||
].filter((segment) => segment.length > 0)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export type TerminalHistorySeedTransferManifest = {
|
||||
chunkCount: number
|
||||
codeUnits: number
|
||||
sha256: string
|
||||
}
|
||||
|
||||
export type CreateOrAttachHistorySeedPayload = {
|
||||
historySeed?: string
|
||||
historySeedTransferId?: string
|
||||
}
|
||||
|
||||
export type StartHistorySeedTransferRequest = {
|
||||
id: string
|
||||
type: 'startHistorySeedTransfer'
|
||||
payload: TerminalHistorySeedTransferManifest
|
||||
}
|
||||
|
||||
export type AppendHistorySeedTransferRequest = {
|
||||
id: string
|
||||
type: 'appendHistorySeedTransfer'
|
||||
payload: {
|
||||
transferId: string
|
||||
index: number
|
||||
data: string
|
||||
}
|
||||
}
|
||||
|
||||
export type FinishHistorySeedTransferRequest = {
|
||||
id: string
|
||||
type: 'finishHistorySeedTransfer'
|
||||
payload: { transferId: string }
|
||||
}
|
||||
|
||||
export type AbortHistorySeedTransferRequest = {
|
||||
id: string
|
||||
type: 'abortHistorySeedTransfer'
|
||||
payload: { transferId: string }
|
||||
}
|
||||
|
||||
export type TerminalHistorySeedTransferRequest =
|
||||
| StartHistorySeedTransferRequest
|
||||
| AppendHistorySeedTransferRequest
|
||||
| FinishHistorySeedTransferRequest
|
||||
| AbortHistorySeedTransferRequest
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
iterateTerminalHistorySeedChunks,
|
||||
measureTerminalHistorySeed
|
||||
} from './terminal-history-seed-chunks'
|
||||
import { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry'
|
||||
|
||||
describe('TerminalHistorySeedTransferRegistry', () => {
|
||||
it('validates and consumes an owner-bound completed transfer', () => {
|
||||
const registry = new TerminalHistorySeedTransferRegistry()
|
||||
const segments = ['first', '😀', 'last']
|
||||
const manifest = measureTerminalHistorySeed(segments)
|
||||
const transferId = registry.start('owner-a', manifest)
|
||||
const chunks = [...iterateTerminalHistorySeedChunks(segments)]
|
||||
chunks.forEach((data, index) => registry.append('owner-a', transferId, index, data))
|
||||
registry.finish('owner-a', transferId)
|
||||
|
||||
expect(() => registry.take('owner-b', transferId)).toThrow('not found')
|
||||
expect(registry.take('owner-a', transferId).join('')).toBe(segments.join(''))
|
||||
expect(() => registry.take('owner-a', transferId)).toThrow('not found')
|
||||
})
|
||||
|
||||
it('rejects out-of-order and over-budget chunks', () => {
|
||||
const segments = ['😀', 'a']
|
||||
const manifest = measureTerminalHistorySeed(segments)
|
||||
const registry = new TerminalHistorySeedTransferRegistry(4)
|
||||
const transferId = registry.start('owner', manifest)
|
||||
|
||||
expect(() => registry.append('owner', transferId, 1, 'a')).toThrow('sequence mismatch')
|
||||
registry.append('owner', transferId, 0, '😀')
|
||||
expect(() => registry.append('owner', transferId, 1, 'a')).toThrow('retained byte limit')
|
||||
})
|
||||
|
||||
it('rejects a digest mismatch and releases the transfer', () => {
|
||||
const registry = new TerminalHistorySeedTransferRegistry()
|
||||
const transferId = registry.start('owner', {
|
||||
chunkCount: 1,
|
||||
codeUnits: 4,
|
||||
sha256: '0'.repeat(64)
|
||||
})
|
||||
registry.append('owner', transferId, 0, 'test')
|
||||
|
||||
expect(() => registry.finish('owner', transferId)).toThrow('digest mismatch')
|
||||
expect(() => registry.take('owner', transferId)).toThrow('not found')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES } from './terminal-history-file-limits'
|
||||
import { TERMINAL_HISTORY_SEED_CHUNK_CODE_UNITS } from './terminal-history-seed-chunks'
|
||||
import type { TerminalHistorySeedTransferManifest } from './terminal-history-seed-transfer-protocol'
|
||||
|
||||
const MAX_TRANSFERS = 8
|
||||
const MAX_CHUNKS = 4096
|
||||
const TRANSFER_TTL_MS = 30_000
|
||||
|
||||
type Transfer = {
|
||||
ownerId: string
|
||||
manifest: TerminalHistorySeedTransferManifest
|
||||
chunks: string[]
|
||||
codeUnits: number
|
||||
utf8Bytes: number
|
||||
hash: ReturnType<typeof createHash>
|
||||
finished: boolean
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
export class TerminalHistorySeedTransferRegistry {
|
||||
private transfers = new Map<string, Transfer>()
|
||||
private retainedBytes = 0
|
||||
|
||||
constructor(
|
||||
private readonly maxRetainedBytes = TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES,
|
||||
private readonly transferTtlMs = TRANSFER_TTL_MS
|
||||
) {}
|
||||
|
||||
start(ownerId: string, manifest: TerminalHistorySeedTransferManifest): string {
|
||||
this.validateManifest(manifest)
|
||||
if (this.transfers.size >= MAX_TRANSFERS) {
|
||||
throw new Error('Too many pending terminal history seed transfers')
|
||||
}
|
||||
const transferId = randomUUID()
|
||||
const timer = setTimeout(() => this.delete(transferId), this.transferTtlMs)
|
||||
timer.unref()
|
||||
this.transfers.set(transferId, {
|
||||
ownerId,
|
||||
manifest: { ...manifest },
|
||||
chunks: [],
|
||||
codeUnits: 0,
|
||||
utf8Bytes: 0,
|
||||
hash: createHash('sha256'),
|
||||
finished: false,
|
||||
timer
|
||||
})
|
||||
return transferId
|
||||
}
|
||||
|
||||
append(ownerId: string, transferId: string, index: number, data: string): void {
|
||||
const transfer = this.getOwned(ownerId, transferId)
|
||||
if (transfer.finished) {
|
||||
throw new Error('Terminal history seed transfer is already finished')
|
||||
}
|
||||
if (index !== transfer.chunks.length || index >= transfer.manifest.chunkCount) {
|
||||
throw new Error('Terminal history seed chunk sequence mismatch')
|
||||
}
|
||||
if (data.length === 0 || data.length > TERMINAL_HISTORY_SEED_CHUNK_CODE_UNITS) {
|
||||
throw new Error('Terminal history seed chunk size is invalid')
|
||||
}
|
||||
const utf8Bytes = Buffer.byteLength(data, 'utf8')
|
||||
if (
|
||||
transfer.codeUnits + data.length > transfer.manifest.codeUnits ||
|
||||
this.retainedBytes + utf8Bytes > this.maxRetainedBytes
|
||||
) {
|
||||
throw new Error('Terminal history seed transfer exceeds retained byte limit')
|
||||
}
|
||||
transfer.chunks.push(data)
|
||||
transfer.codeUnits += data.length
|
||||
transfer.utf8Bytes += utf8Bytes
|
||||
transfer.hash.update(Buffer.from(data, 'utf16le'))
|
||||
this.retainedBytes += utf8Bytes
|
||||
this.refreshExpiry(transferId, transfer)
|
||||
}
|
||||
|
||||
finish(ownerId: string, transferId: string): void {
|
||||
const transfer = this.getOwned(ownerId, transferId)
|
||||
if (transfer.finished) {
|
||||
throw new Error('Terminal history seed transfer is already finished')
|
||||
}
|
||||
if (
|
||||
transfer.chunks.length !== transfer.manifest.chunkCount ||
|
||||
transfer.codeUnits !== transfer.manifest.codeUnits
|
||||
) {
|
||||
throw new Error('Terminal history seed transfer is incomplete')
|
||||
}
|
||||
const digest = transfer.hash.digest('hex')
|
||||
if (digest !== transfer.manifest.sha256) {
|
||||
this.delete(transferId)
|
||||
throw new Error('Terminal history seed transfer digest mismatch')
|
||||
}
|
||||
transfer.finished = true
|
||||
this.refreshExpiry(transferId, transfer)
|
||||
}
|
||||
|
||||
take(ownerId: string, transferId: string): readonly string[] {
|
||||
const transfer = this.getOwned(ownerId, transferId)
|
||||
if (!transfer.finished) {
|
||||
throw new Error('Terminal history seed transfer is not finished')
|
||||
}
|
||||
const chunks = transfer.chunks
|
||||
this.delete(transferId)
|
||||
return chunks
|
||||
}
|
||||
|
||||
abort(ownerId: string, transferId: string): void {
|
||||
this.getOwned(ownerId, transferId)
|
||||
this.delete(transferId)
|
||||
}
|
||||
|
||||
clearOwner(ownerId: string): void {
|
||||
for (const [transferId, transfer] of this.transfers) {
|
||||
if (transfer.ownerId === ownerId) {
|
||||
this.delete(transferId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const transferId of this.transfers.keys()) {
|
||||
this.delete(transferId)
|
||||
}
|
||||
}
|
||||
|
||||
private validateManifest(manifest: TerminalHistorySeedTransferManifest): void {
|
||||
// Why codeUnits is compared to a byte cap: UTF-8 never encodes a UTF-16 code unit in under one byte,
|
||||
// so codeUnits > maxRetainedBytes proves the payload cannot fit. Scaling up for multibyte would
|
||||
// reject ASCII seeds that do fit; append() enforces the real byte budget.
|
||||
if (
|
||||
!Number.isInteger(manifest.chunkCount) ||
|
||||
manifest.chunkCount < 1 ||
|
||||
manifest.chunkCount > MAX_CHUNKS ||
|
||||
!Number.isInteger(manifest.codeUnits) ||
|
||||
manifest.codeUnits < 1 ||
|
||||
manifest.codeUnits > this.maxRetainedBytes ||
|
||||
!/^[a-f0-9]{64}$/.test(manifest.sha256)
|
||||
) {
|
||||
throw new Error('Terminal history seed transfer manifest is invalid')
|
||||
}
|
||||
}
|
||||
|
||||
private getOwned(ownerId: string, transferId: string): Transfer {
|
||||
const transfer = this.transfers.get(transferId)
|
||||
if (!transfer || transfer.ownerId !== ownerId) {
|
||||
throw new Error('Terminal history seed transfer not found')
|
||||
}
|
||||
return transfer
|
||||
}
|
||||
|
||||
private refreshExpiry(transferId: string, transfer: Transfer): void {
|
||||
clearTimeout(transfer.timer)
|
||||
transfer.timer = setTimeout(() => this.delete(transferId), this.transferTtlMs)
|
||||
transfer.timer.unref()
|
||||
}
|
||||
|
||||
private delete(transferId: string): void {
|
||||
const transfer = this.transfers.get(transferId)
|
||||
if (!transfer) {
|
||||
return
|
||||
}
|
||||
clearTimeout(transfer.timer)
|
||||
this.retainedBytes -= transfer.utf8Bytes
|
||||
this.transfers.delete(transferId)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
} from './terminal-history-log'
|
||||
import type { SessionMeta } from './terminal-history-metadata'
|
||||
import { clearTerminalHistoryRecoveryProtection } from './terminal-history-recovery-quarantine'
|
||||
import type { PendingOutputRecord, TerminalCheckpointFile, TerminalSnapshot } from './types'
|
||||
import type { PendingOutputRecord, TerminalSnapshot } from './types'
|
||||
import { TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES } from './terminal-history-file-limits'
|
||||
import { serializeTerminalCheckpointWithinLimit } from './terminal-checkpoint-serializer'
|
||||
|
||||
// Why 5MB: bounds cold-restore replay time and per-session disk; hitting the cap triggers one checkpoint that resets the log.
|
||||
const LOG_MAX_BYTES = 5 * 1024 * 1024
|
||||
@@ -28,7 +30,8 @@ export class TerminalHistorySessionWriter {
|
||||
|
||||
constructor(
|
||||
readonly dir: string,
|
||||
fresh: boolean
|
||||
fresh: boolean,
|
||||
private readonly checkpointMaxBytes = TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES
|
||||
) {
|
||||
this.checkpointPath = join(dir, 'checkpoint.json')
|
||||
this.logPath = join(dir, 'output.log')
|
||||
@@ -55,31 +58,38 @@ export class TerminalHistorySessionWriter {
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
async checkpoint(snapshot: TerminalSnapshot): Promise<void> {
|
||||
async checkpoint(
|
||||
snapshot: TerminalSnapshot
|
||||
): Promise<{ result: 'committed' } | { result: 'retryable'; error: Error }> {
|
||||
// Why: snapshot.cwd is null until OSC-7; preserve meta.json's usable cwd for cold restore.
|
||||
const effectiveCwd = snapshot.cwd ?? this.readMeta()?.cwd ?? null
|
||||
this.resolveLogState()
|
||||
const generation = (this.logGeneration ?? 0) + 1
|
||||
const checkpointFile: TerminalCheckpointFile = {
|
||||
snapshotAnsi: snapshot.snapshotAnsi,
|
||||
scrollbackAnsi: snapshot.scrollbackAnsi,
|
||||
oscLinks: snapshot.oscLinks,
|
||||
rehydrateSequences: snapshot.rehydrateSequences,
|
||||
cwd: effectiveCwd,
|
||||
cols: snapshot.cols,
|
||||
rows: snapshot.rows,
|
||||
modes: snapshot.modes,
|
||||
scrollbackLines: snapshot.scrollbackLines,
|
||||
generation,
|
||||
checkpointedAt: new Date().toISOString()
|
||||
let data: string
|
||||
try {
|
||||
data = await serializeTerminalCheckpointWithinLimit(
|
||||
snapshot,
|
||||
{
|
||||
cwd: effectiveCwd,
|
||||
generation,
|
||||
checkpointedAt: new Date().toISOString()
|
||||
},
|
||||
this.checkpointMaxBytes
|
||||
)
|
||||
} catch (error) {
|
||||
return {
|
||||
result: 'retryable',
|
||||
error: error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
}
|
||||
const tmpPath = `${this.checkpointPath}.tmp`
|
||||
await fsPromises.writeFile(tmpPath, JSON.stringify(checkpointFile))
|
||||
await fsPromises.writeFile(tmpPath, data)
|
||||
await fsPromises.rename(tmpPath, this.checkpointPath)
|
||||
await fsPromises.writeFile(this.logPath, encodeLogHeader(generation))
|
||||
this.logGeneration = generation
|
||||
this.logBytes = LOG_HEADER_BYTES
|
||||
clearTerminalHistoryRecoveryProtection(this.dir)
|
||||
return { result: 'committed' }
|
||||
}
|
||||
|
||||
// Why: a warm writer must append to the existing generation without clobbering its log.
|
||||
|
||||
@@ -25,7 +25,7 @@ export type CreateOrAttachOptions = {
|
||||
terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe'
|
||||
shellReadySupported?: boolean
|
||||
shellReadyTimeoutMs?: number
|
||||
historySeed?: string
|
||||
historySeedChunks?: readonly string[]
|
||||
startupIngress?: PtyStartupIngressIntent
|
||||
agentSessionEnsure?: {
|
||||
claim: AgentSessionExecutionClaim
|
||||
|
||||
@@ -107,7 +107,7 @@ export async function createOrAttachTerminalSession(
|
||||
wslDistro
|
||||
}),
|
||||
shellReadySupported,
|
||||
historySeed: opts.historySeed,
|
||||
historySeedChunks: opts.historySeedChunks,
|
||||
...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}),
|
||||
wslDistro,
|
||||
onExit: () => deps.onSessionExit(opts.sessionId, opts.agentSessionGeneration),
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
AgentSessionOwnerBinding,
|
||||
AgentSessionSurfaceBinding
|
||||
} from '../../shared/agent-session-host-authority'
|
||||
import type * as HistorySeedProtocol from './terminal-history-seed-transfer-protocol'
|
||||
export type { TerminalModes } from './terminal-modes'
|
||||
import type { TerminalSnapshot } from './terminal-snapshot'
|
||||
export type { TerminalSnapshot } from './terminal-snapshot'
|
||||
@@ -57,7 +58,7 @@ export type { DaemonEndpointIdentity, HelloMessage, HelloResponse } from './daem
|
||||
export type CreateOrAttachRequest = {
|
||||
id: string
|
||||
type: 'createOrAttach'
|
||||
payload: {
|
||||
payload: HistorySeedProtocol.CreateOrAttachHistorySeedPayload & {
|
||||
sessionId: string
|
||||
cols: number
|
||||
rows: number
|
||||
@@ -82,8 +83,6 @@ export type CreateOrAttachRequest = {
|
||||
terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe'
|
||||
shellReadySupported?: boolean
|
||||
shellReadyTimeoutMs?: number
|
||||
/** Recovered ANSI applied before the new subprocess can emit startup output. */
|
||||
historySeed?: string
|
||||
startupIngress?: PtyStartupIngressIntent
|
||||
agentSessionEnsure?: {
|
||||
claim: AgentSessionExecutionClaim
|
||||
@@ -101,9 +100,7 @@ export type CloseStartupQueryAuthorityRequest = {
|
||||
export type CancelCreateOrAttachRequest = {
|
||||
id: string
|
||||
type: 'cancelCreateOrAttach'
|
||||
payload: {
|
||||
sessionId: string
|
||||
}
|
||||
payload: { sessionId: string }
|
||||
}
|
||||
|
||||
export type WriteRequest = {
|
||||
@@ -295,6 +292,7 @@ export type TakePendingOutputResult = {
|
||||
|
||||
export type DaemonRequest =
|
||||
| CreateOrAttachRequest
|
||||
| HistorySeedProtocol.TerminalHistorySeedTransferRequest
|
||||
| CancelCreateOrAttachRequest
|
||||
| WriteRequest
|
||||
| ResizeRequest
|
||||
|
||||
@@ -648,6 +648,22 @@ describe('useComposerState host-context boundaries', () => {
|
||||
expect(quickSubmit).not.toContain('platform: CLIENT_PLATFORM')
|
||||
})
|
||||
|
||||
// Why: activation no longer rebuilds a startup from `createdWithAgent`, so this
|
||||
// caller's own `startup` is the only thing that launches the agent it planned.
|
||||
it('passes its own startup to activation when submit planned an agent', () => {
|
||||
const activation = sourceBetween(
|
||||
HOOK_SOURCE,
|
||||
'const activation = activateAndRevealWorktree(worktree.id, {',
|
||||
'if (startupPlan) {'
|
||||
)
|
||||
|
||||
expect(activation).toContain('...(startupPlan && !backendSpawnedStartup')
|
||||
expect(activation).toContain('command: startupPlan.launchCommand')
|
||||
expect(activation).toContain('launchAgent: tuiAgent')
|
||||
// The removed activation-time fallback must not come back through this caller.
|
||||
expect(HOOK_SOURCE).not.toContain('buildCreatedAgentReopenStartup')
|
||||
})
|
||||
|
||||
it('prepares linked quick-create drafts for the selected default agent', () => {
|
||||
const quickSubmit = sourceBetween(
|
||||
HOOK_SOURCE,
|
||||
|
||||
@@ -297,4 +297,36 @@ describe('launchAgentInNewTab Windows shell quoting', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
// Platform resolution lands on posix here because vitest's node environment does not
|
||||
// report Windows. This pins single-quote escaping of user-configured default agent args.
|
||||
it('escapes a single quote inside default agent args', async () => {
|
||||
store.settings.terminalWindowsShell = 'cmd.exe'
|
||||
store.settings.agentDefaultArgs = { codex: '--profile "don\'t"' }
|
||||
store.projects = [
|
||||
{
|
||||
id: 'repo-1',
|
||||
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
}
|
||||
]
|
||||
store.repos = [{ id: 'repo-1', connectionId: null, path: 'C:\\Users\\jinwo\\repo' }]
|
||||
store.worktreesByRepo = {
|
||||
'repo-1': [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
projectId: 'repo-1',
|
||||
path: 'C:\\Users\\jinwo\\repo\\feature',
|
||||
displayName: 'feature'
|
||||
}
|
||||
]
|
||||
}
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
|
||||
|
||||
const queued = mockQueueTabStartupCommand.mock.calls.at(-1)?.[1] as { command: string }
|
||||
expect(queued.command).toContain("'don'\\''t'")
|
||||
expect(queued.command).not.toContain("'don''t'")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -240,8 +240,9 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
|
||||
}
|
||||
if (effectiveAgent) {
|
||||
// Why: direct task launch creates and starts the workspace in separate
|
||||
// steps so agent detection can overlap git worktree creation. Persist
|
||||
// the chosen agent once known so empty-worktree reopen can recreate it.
|
||||
// steps so agent detection can overlap git worktree creation. Persist the
|
||||
// chosen agent once known so removal safety and ownership see it — reopen
|
||||
// no longer relaunches from this field.
|
||||
void store.updateWorktreeMeta(worktreeId, { createdWithAgent: effectiveAgent }).catch(() => {
|
||||
// Non-critical: activation still has the explicit startup below.
|
||||
})
|
||||
|
||||
@@ -27,9 +27,74 @@ export function makeCreatedAgentWorktree(): Worktree {
|
||||
}
|
||||
}
|
||||
|
||||
type StoreState = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
/** The empty-workspace store shape both seeds start from; each layers its own tabs/actions on top. */
|
||||
function baseSeedState(worktree: Worktree, worktrees: Worktree[]): Partial<StoreState> {
|
||||
return {
|
||||
repos: [
|
||||
{
|
||||
id: worktree.repoId,
|
||||
path: path.join(path.sep, 'workspace', 'repo'),
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { [worktree.repoId]: worktrees },
|
||||
activeRepoId: worktree.repoId,
|
||||
activeView: 'terminal',
|
||||
tabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
layoutByWorktree: {},
|
||||
activeGroupIdByWorktree: {},
|
||||
openFiles: [],
|
||||
browserTabsByWorktree: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
activeTabIdByWorktree: {},
|
||||
tabBarOrderByWorktree: {},
|
||||
pendingStartupByTabId: {},
|
||||
settings: {
|
||||
agentCmdOverrides: {},
|
||||
setupScriptLaunchMode: 'new-tab'
|
||||
} as unknown as StoreState['settings'],
|
||||
refreshGitHubForWorktreeIfStale: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
/** Seeds a `createdWithAgent` worktree with zero renderable tabs — the state that used to
|
||||
* trigger the removed creation-agent relaunch. */
|
||||
export function seedEmptyActivatableWorktree(
|
||||
worktree: Worktree,
|
||||
options: { extraWorktrees?: Worktree[] } = {}
|
||||
): { revealWorktreeInSidebar: ReturnType<typeof vi.fn> } {
|
||||
const revealWorktreeInSidebar = vi.fn()
|
||||
|
||||
useAppStore.setState({
|
||||
...baseSeedState(worktree, [...(options.extraWorktrees ?? []), worktree]),
|
||||
markWorktreeVisited: vi.fn(),
|
||||
recordWorktreeVisit: vi.fn(),
|
||||
revealWorktreeInSidebar
|
||||
})
|
||||
|
||||
// Why: orphan terminals and reconnectable PTYs also feed renderableTabCount, so
|
||||
// assert the premise — drift here would make the regression tests pass blind.
|
||||
const { renderableTabCount } = useAppStore.getState().reconcileWorktreeTabModel(worktree.id)
|
||||
if (renderableTabCount !== 0) {
|
||||
throw new Error(
|
||||
`seedEmptyActivatableWorktree: expected 0 renderable tabs, got ${renderableTabCount}`
|
||||
)
|
||||
}
|
||||
|
||||
return { revealWorktreeInSidebar }
|
||||
}
|
||||
|
||||
export function seedAlreadyActiveWorktree(
|
||||
worktree: Worktree,
|
||||
overrides: Partial<ReturnType<typeof useAppStore.getState>> = {}
|
||||
overrides: Partial<StoreState> = {}
|
||||
): {
|
||||
markWorktreeVisited: ReturnType<typeof vi.fn>
|
||||
recordWorktreeVisit: ReturnType<typeof vi.fn>
|
||||
@@ -39,21 +104,9 @@ export function seedAlreadyActiveWorktree(
|
||||
const recordWorktreeVisit = vi.fn()
|
||||
const revealWorktreeInSidebar = vi.fn()
|
||||
const terminalTitle = ['Terminal', '1'].join(' ')
|
||||
const repoPath = path.join(path.sep, 'workspace', 'repo')
|
||||
|
||||
useAppStore.setState({
|
||||
repos: [
|
||||
{
|
||||
id: worktree.repoId,
|
||||
path: repoPath,
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { [worktree.repoId]: [worktree] },
|
||||
activeRepoId: worktree.repoId,
|
||||
activeView: 'terminal',
|
||||
...baseSeedState(worktree, [worktree]),
|
||||
activeWorktreeId: worktree.id,
|
||||
activeTabId: 'tab-1',
|
||||
activeTabType: 'terminal',
|
||||
@@ -101,19 +154,9 @@ export function seedAlreadyActiveWorktree(
|
||||
activeGroupIdByWorktree: { [worktree.id]: 'group-1' },
|
||||
activeTabTypeByWorktree: { [worktree.id]: 'terminal' },
|
||||
everActivatedWorktreeIds: new Set([worktree.id]),
|
||||
openFiles: [],
|
||||
browserTabsByWorktree: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
activeTabIdByWorktree: { [worktree.id]: 'tab-1' },
|
||||
tabBarOrderByWorktree: {},
|
||||
settings: {
|
||||
agentCmdOverrides: {},
|
||||
setupScriptLaunchMode: 'new-tab'
|
||||
} as unknown as ReturnType<typeof useAppStore.getState>['settings'],
|
||||
markWorktreeVisited,
|
||||
recordWorktreeVisit,
|
||||
refreshGitHubForWorktreeIfStale: vi.fn(),
|
||||
revealWorktreeInSidebar,
|
||||
...overrides
|
||||
})
|
||||
|
||||
@@ -9,7 +9,8 @@ import { resetWebSessionTabsSnapshotFreshnessForTests } from '@/runtime/web-sess
|
||||
import { resetWebRuntimeWakeTerminalRespawnForTests } from '@/runtime/web-runtime-wake-terminal-respawn'
|
||||
import {
|
||||
makeCreatedAgentWorktree as makeWorktree,
|
||||
seedAlreadyActiveWorktree
|
||||
seedAlreadyActiveWorktree,
|
||||
seedEmptyActivatableWorktree
|
||||
} from '@/lib/worktree-activation-created-agent-test-state'
|
||||
|
||||
const initialAppStoreState = useAppStore.getState()
|
||||
@@ -22,6 +23,19 @@ function makeWebRuntimeWorktree() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Activates and asserts a focusable tab appeared with no queued startup, returning its id. */
|
||||
function activateAndExpectNoRelaunch(
|
||||
worktreeId: string,
|
||||
opts?: Parameters<typeof activateAndRevealWorktree>[1]
|
||||
): string {
|
||||
const result = activateAndRevealWorktree(worktreeId, opts)
|
||||
const tabId = result === false ? undefined : (result.primaryTabId ?? undefined)
|
||||
|
||||
expect(tabId).toBeDefined()
|
||||
expect(useAppStore.getState().pendingStartupByTabId[tabId!]).toBeUndefined()
|
||||
return tabId!
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__
|
||||
vi.unstubAllGlobals()
|
||||
@@ -30,7 +44,7 @@ afterEach(() => {
|
||||
useAppStore.setState(initialAppStoreState, true)
|
||||
})
|
||||
|
||||
describe('activateAndRevealWorktree created agent reopen', () => {
|
||||
describe('activateAndRevealWorktree', () => {
|
||||
it('does not restamp focus recency when reselecting the already-active terminal worktree', () => {
|
||||
const worktree = makeWorktree()
|
||||
const { markWorktreeVisited, recordWorktreeVisit, revealWorktreeInSidebar } =
|
||||
@@ -57,133 +71,69 @@ describe('activateAndRevealWorktree created agent reopen', () => {
|
||||
expect(recordWorktreeVisit).toHaveBeenCalledWith(worktree.id)
|
||||
})
|
||||
|
||||
it('reopens an empty worktree with the agent selected at creation time', () => {
|
||||
it('does not relaunch the creation-time agent when reopening an empty worktree', () => {
|
||||
const worktree = makeWorktree()
|
||||
const revealWorktreeInSidebar = vi.fn()
|
||||
|
||||
useAppStore.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { 'repo-1': [worktree] },
|
||||
activeRepoId: 'repo-1',
|
||||
activeView: 'terminal',
|
||||
tabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
layoutByWorktree: {},
|
||||
activeGroupIdByWorktree: {},
|
||||
openFiles: [],
|
||||
browserTabsByWorktree: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
activeTabIdByWorktree: {},
|
||||
tabBarOrderByWorktree: {},
|
||||
pendingStartupByTabId: {},
|
||||
settings: {
|
||||
agentCmdOverrides: {},
|
||||
setupScriptLaunchMode: 'new-tab'
|
||||
} as unknown as ReturnType<typeof useAppStore.getState>['settings'],
|
||||
markWorktreeVisited: vi.fn(),
|
||||
recordWorktreeVisit: vi.fn(),
|
||||
refreshGitHubForWorktreeIfStale: vi.fn(),
|
||||
revealWorktreeInSidebar
|
||||
})
|
||||
const { revealWorktreeInSidebar } = seedEmptyActivatableWorktree(worktree)
|
||||
|
||||
const result = activateAndRevealWorktree(worktree.id)
|
||||
const state = useAppStore.getState()
|
||||
const reopenedTab = state.tabsByWorktree[worktree.id]?.[0]
|
||||
|
||||
// A focusable surface still appears — it is just a plain shell, with no queued agent launch.
|
||||
expect(result).toEqual({ primaryTabId: reopenedTab?.id })
|
||||
expect(reopenedTab).toBeDefined()
|
||||
expect(state.pendingStartupByTabId[reopenedTab!.id]).toEqual({
|
||||
command: "codex '--dangerously-bypass-approvals-and-sandbox'",
|
||||
env: {},
|
||||
launchAgent: 'codex',
|
||||
launchConfig: {
|
||||
agentCommand: "codex '--dangerously-bypass-approvals-and-sandbox'",
|
||||
agentArgs: '--dangerously-bypass-approvals-and-sandbox',
|
||||
agentEnv: {}
|
||||
},
|
||||
launchToken: expect.any(String),
|
||||
sessionOptions: undefined,
|
||||
telemetry: {
|
||||
agent_kind: 'codex',
|
||||
launch_source: 'sidebar',
|
||||
request_kind: 'resume'
|
||||
}
|
||||
})
|
||||
expect(state.pendingStartupByTabId[reopenedTab!.id]).toBeUndefined()
|
||||
expect(revealWorktreeInSidebar).toHaveBeenCalledWith(worktree.id)
|
||||
})
|
||||
|
||||
it('uses WSL launch quoting when reopening a Windows-path WSL project agent', () => {
|
||||
const worktree = {
|
||||
...makeWorktree(),
|
||||
path: 'C:\\Users\\jinwo\\repo\\feature'
|
||||
it('does not relaunch on repeated activate/close cycles', () => {
|
||||
const worktree = makeWorktree()
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
|
||||
for (let cycle = 0; cycle < 3; cycle += 1) {
|
||||
activateAndExpectNoRelaunch(worktree.id)
|
||||
|
||||
// Return to zero tabs, the state that used to re-arm the relaunch. Sets state
|
||||
// directly rather than via closeTab — the sleeping-record purge is covered in
|
||||
// worktree-reactivation-tab-forkbomb.test.ts.
|
||||
useAppStore.setState({ tabsByWorktree: {}, activeTabIdByWorktree: {} })
|
||||
}
|
||||
})
|
||||
|
||||
useAppStore.setState({
|
||||
projects: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000000',
|
||||
sourceRepoIds: ['repo-1'],
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
}
|
||||
],
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: 'C:\\Users\\jinwo\\repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { 'repo-1': [worktree] },
|
||||
activeRepoId: 'repo-1',
|
||||
activeView: 'terminal',
|
||||
tabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
layoutByWorktree: {},
|
||||
activeGroupIdByWorktree: {},
|
||||
openFiles: [],
|
||||
browserTabsByWorktree: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
activeTabIdByWorktree: {},
|
||||
tabBarOrderByWorktree: {},
|
||||
pendingStartupByTabId: {},
|
||||
settings: {
|
||||
agentCmdOverrides: {},
|
||||
agentDefaultArgs: { codex: '--profile "don\'t"' },
|
||||
setupScriptLaunchMode: 'new-tab'
|
||||
} as unknown as ReturnType<typeof useAppStore.getState>['settings'],
|
||||
markWorktreeVisited: vi.fn(),
|
||||
recordWorktreeVisit: vi.fn(),
|
||||
refreshGitHubForWorktreeIfStale: vi.fn(),
|
||||
revealWorktreeInSidebar: vi.fn()
|
||||
it('does not relaunch when activating a sibling worktree the user never opened', () => {
|
||||
const sibling = makeWorktree()
|
||||
const target = { ...makeWorktree(), id: 'wt-handoff', displayName: 'handoff' }
|
||||
seedEmptyActivatableWorktree(target, { extraWorktrees: [sibling] })
|
||||
|
||||
// The shape post-delete focus handoff produces. That caller passes no opts at all —
|
||||
// asserted directly in active-worktree-focus-after-delete.test.ts.
|
||||
activateAndExpectNoRelaunch(target.id)
|
||||
})
|
||||
|
||||
it('does not relaunch when activation opts carry no startup payload', () => {
|
||||
const worktree = makeWorktree()
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
|
||||
// The opts shape CLI/relay navigation and notification clicks arrive with; those
|
||||
// callers are asserted in useIpcEvents.test.ts. The host's `didSpawnStartup` leg is
|
||||
// a main-process concern and is not reachable from here.
|
||||
activateAndExpectNoRelaunch(worktree.id, { notifyHostRuntime: false })
|
||||
})
|
||||
|
||||
it('still queues an explicit startup supplied by the caller', () => {
|
||||
const worktree = makeWorktree()
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
|
||||
const result = activateAndRevealWorktree(worktree.id, {
|
||||
startup: { command: 'codex' }
|
||||
})
|
||||
|
||||
const result = activateAndRevealWorktree(worktree.id)
|
||||
const state = useAppStore.getState()
|
||||
const reopenedTab = state.tabsByWorktree[worktree.id]?.[0]
|
||||
const tabId = result === false ? undefined : (result.primaryTabId ?? undefined)
|
||||
|
||||
expect(result).toEqual({ primaryTabId: reopenedTab?.id })
|
||||
expect(state.pendingStartupByTabId[reopenedTab!.id]?.command).toContain("'don'\\''t'")
|
||||
expect(state.pendingStartupByTabId[reopenedTab!.id]?.command).not.toContain("'don''t'")
|
||||
expect(tabId).toBeDefined()
|
||||
expect(state.pendingStartupByTabId[tabId!]).toEqual(
|
||||
expect.objectContaining({ command: 'codex' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not duplicate a sleeping agent session owned by a preserved slept pane', () => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
SetupSplitDirection,
|
||||
Tab,
|
||||
TuiAgent,
|
||||
Worktree,
|
||||
WorktreeDefaultTabsLaunch,
|
||||
WorktreeSetupLaunch
|
||||
} from '../../../shared/types'
|
||||
@@ -19,10 +18,6 @@ import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-t
|
||||
import { buildSetupRunnerCommand } from './setup-runner'
|
||||
import { createSequencedSetupAgentCommands } from '../../../shared/setup-agent-sequencing'
|
||||
import { getSetupRunnerCommandPlatformForPath } from '../../../shared/setup-runner-command'
|
||||
import { buildAgentStartupPlan } from './tui-agent-startup'
|
||||
import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform'
|
||||
import { CLIENT_PLATFORM } from './new-workspace'
|
||||
import { tuiAgentToAgentKind } from './telemetry'
|
||||
import { agentKindToTuiAgent } from '../../../shared/agent-kind'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui'
|
||||
@@ -42,15 +37,8 @@ import {
|
||||
setWorktreeNavActivator,
|
||||
setWorktreeNavViewActivator
|
||||
} from '@/store/slices/worktree-nav-history'
|
||||
import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from '../../../shared/tui-agent-launch-defaults'
|
||||
import { isTuiAgent } from '../../../shared/tui-agent-config'
|
||||
import { repoIsRemote } from '../../../shared/agent-launch-remote'
|
||||
import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session'
|
||||
import { queueHookCommandsForFirstWorktreeTab } from '@/lib/hook-command-delayed-delivery'
|
||||
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
|
||||
import {
|
||||
getRuntimeEnvironmentIdForWorktree,
|
||||
type WorktreeRuntimeOwnerState
|
||||
@@ -67,7 +55,6 @@ import { getConnectionId } from '@/lib/connection-context'
|
||||
import { isDetachedHeadWorkspace } from '@/components/sidebar/visible-worktrees'
|
||||
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
|
||||
import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache'
|
||||
import { resolveNativeChatSessionOptionDefaults } from '../../../shared/native-chat-session-option-defaults'
|
||||
import type { SessionOptionValue } from '../../../shared/native-chat-session-options'
|
||||
|
||||
/** Telemetry threaded from the launch site to `pty:spawn`; main fires `agent_started`
|
||||
@@ -229,56 +216,6 @@ export function activateAndRevealFolderWorkspace(
|
||||
return { primaryTabId }
|
||||
}
|
||||
|
||||
function buildCreatedAgentReopenStartup(worktree: Worktree): WorktreeStartupPayload | undefined {
|
||||
const agent = worktree.createdWithAgent
|
||||
if (!isTuiAgent(agent)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const state = useAppStore.getState()
|
||||
const repo = state.repos.find((entry) => entry.id === worktree.repoId)
|
||||
const launchPlatform = repo
|
||||
? getAgentLaunchPlatformForRepo(
|
||||
repo,
|
||||
repo.connectionId ? undefined : getLocalProjectExecutionRuntimeContext(state, worktree.id)
|
||||
)
|
||||
: CLIENT_PLATFORM
|
||||
|
||||
const startupPlan = buildAgentStartupPlan({
|
||||
agent,
|
||||
prompt: '',
|
||||
cmdOverrides: state.settings?.agentCmdOverrides ?? {},
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, state.settings?.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, state.settings?.agentDefaultEnv),
|
||||
sessionOptions: resolveNativeChatSessionOptionDefaults(
|
||||
state.settings?.nativeChatSessionOptions,
|
||||
agent
|
||||
),
|
||||
platform: launchPlatform,
|
||||
isRemote: repo ? repoIsRemote(repo) : false,
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
if (!startupPlan) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
command: startupPlan.launchCommand,
|
||||
...(startupPlan.env ? { env: startupPlan.env } : {}),
|
||||
launchConfig: startupPlan.launchConfig,
|
||||
launchAgent: agent,
|
||||
...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}),
|
||||
...(startupPlan.startupCommandDelivery
|
||||
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
|
||||
: {}),
|
||||
telemetry: {
|
||||
agent_kind: tuiAgentToAgentKind(agent),
|
||||
launch_source: 'sidebar',
|
||||
request_kind: 'resume'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function activateAndRevealWorktree(
|
||||
worktreeId: string,
|
||||
opts?: {
|
||||
@@ -346,7 +283,7 @@ export function activateAndRevealWorktree(
|
||||
const primaryTabId = ensureWorktreeHasInitialTerminal(
|
||||
useAppStore.getState(),
|
||||
worktreeId,
|
||||
opts?.startup ?? buildCreatedAgentReopenStartup(wt),
|
||||
opts?.startup,
|
||||
opts?.setup,
|
||||
opts?.issueCommand,
|
||||
opts?.defaultTabs
|
||||
|
||||
@@ -723,6 +723,47 @@ describe('staged background worktree creation', () => {
|
||||
expect(store.seedNativeChatLaunchDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: activation no longer rebuilds a startup from `createdWithAgent`, so this
|
||||
// caller's own `startup` is the only thing that launches the agent it created.
|
||||
it('passes its own startup to activation when the create requested an agent', async () => {
|
||||
store.activeView = 'terminal'
|
||||
store.activePendingCreationId = 'creation-1'
|
||||
store.createWorktree.mockResolvedValueOnce({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1' }
|
||||
})
|
||||
vi.mocked(activateAndRevealWorktree).mockReturnValueOnce({ primaryTabId: 'tab-1' })
|
||||
|
||||
const started = continueBackgroundWorktreeCreation(
|
||||
'creation-1',
|
||||
makeRequest({
|
||||
agent: 'codex',
|
||||
startupPlan: {
|
||||
agent: 'codex',
|
||||
launchCommand: 'codex',
|
||||
expectedProcess: 'codex',
|
||||
followupPrompt: null,
|
||||
launchConfig: { agent: 'codex', command: 'codex' },
|
||||
draftPrompt: 'ship it'
|
||||
} as never
|
||||
})
|
||||
)
|
||||
|
||||
expect(started).toBe(true)
|
||||
await vi.waitFor(() =>
|
||||
expect(activateAndRevealWorktree).toHaveBeenCalledWith(
|
||||
'wt-1',
|
||||
expect.objectContaining({
|
||||
startup: expect.objectContaining({
|
||||
command: 'codex',
|
||||
launchAgent: 'codex',
|
||||
draftPrompt: 'ship it'
|
||||
})
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toasts a staged create error after the user leaves the creation surface', async () => {
|
||||
store.activeView = 'tasks'
|
||||
store.createWorktree.mockRejectedValueOnce(new Error('create failed'))
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"appId": "com.stablyai.orca",
|
||||
"stateSchemaVersion": 1,
|
||||
"readableStateSchemaVersions": [1],
|
||||
"daemonProtocolVersion": 29,
|
||||
"daemonProtocolVersion": 30,
|
||||
"attachableDaemonProtocolVersions": [
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29
|
||||
27, 28, 29, 30
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = {
|
||||
appId: 'com.stablyai.orca',
|
||||
stateSchemaVersion: 1,
|
||||
readableStateSchemaVersions: [1],
|
||||
daemonProtocolVersion: 29,
|
||||
daemonProtocolVersion: 30,
|
||||
attachableDaemonProtocolVersions: [
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29
|
||||
27, 28, 29, 30
|
||||
]
|
||||
} as const
|
||||
|
||||
Reference in New Issue
Block a user