diff --git a/src/main/daemon/client.test.ts b/src/main/daemon/client.test.ts index 60a22232d5e..22e02965e0d 100644 --- a/src/main/daemon/client.test.ts +++ b/src/main/daemon/client.test.ts @@ -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 + } + 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) + }) }) }) diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts index 277b7278ff5..b6229521c8b 100644 --- a/src/main/daemon/client.ts +++ b/src/main/daemon/client.ts @@ -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((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 { diff --git a/src/main/daemon/daemon-checkpoint-file.ts b/src/main/daemon/daemon-checkpoint-file.ts index d7e4bd7a6a5..311a5577484 100644 --- a/src/main/daemon/daemon-checkpoint-file.ts +++ b/src/main/daemon/daemon-checkpoint-file.ts @@ -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 diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 790042c840a..a9e5285c8f6 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -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) }) }) diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts index e01a4f58053..db00c375065 100644 --- a/src/main/daemon/daemon-protocol-version.test.ts +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -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) } }) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index f999b6c22b4..5a7f8bb99bb 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -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 { diff --git a/src/main/daemon/daemon-pty-adapter-history-recovery.test.ts b/src/main/daemon/daemon-pty-adapter-history-recovery.test.ts index a1ee3dcfc66..221a37a3718 100644 --- a/src/main/daemon/daemon-pty-adapter-history-recovery.test.ts +++ b/src/main/daemon/daemon-pty-adapter-history-recovery.test.ts @@ -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((resolve) => { releaseCheckpoint = resolve }) } - await originalCheckpoint(...args) + return originalCheckpoint(...args) }) const shuttingDown = historyAdapter.shutdown(id, { diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index b37feba878e..af036c85c08 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -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) => { 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 } + } + ).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 } + } + ).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 + } + 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' }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 57cc9d55e54..e658ef3db26 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -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 @@ -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 => { + // 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 => { + 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 => { 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('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 { + ): Promise { const take = await this.client.request('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. diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 0ac8d158b27..0d8123ce11b 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -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() private lastInputAtBySessionId = new Map() private pendingPtySpawnPreparations = new Map>() + 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 } diff --git a/src/main/daemon/history-manager.ts b/src/main/daemon/history-manager.ts index 126df0593de..7aa786063e7 100644 --- a/src/main/daemon/history-manager.ts +++ b/src/main/daemon/history-manager.ts @@ -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() private disabledSessions = new Set() - private pendingSessionMutations = new Map>>() + private mutations = new TerminalHistoryMutationTracker() private recoveryFreezes = new Map() 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 { @@ -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 { - return this.trackSessionMutation(sessionId, this.checkpointUntracked(sessionId, snapshot)) + checkpoint(sessionId: string, snapshot: TerminalSnapshot): Promise { + return this.mutations.track(sessionId, this.checkpointUntracked(sessionId, snapshot)) } - private async checkpointUntracked(sessionId: string, snapshot: TerminalSnapshot): Promise { + private async checkpointUntracked( + sessionId: string, + snapshot: TerminalSnapshot + ): Promise { 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(sessionId: string, operation: Promise): Promise { - const mutations = this.pendingSessionMutations.get(sessionId) ?? new Set>() - 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): void { - const mutations = this.pendingSessionMutations.get(sessionId) - mutations?.delete(operation) - if (mutations?.size === 0) { - this.pendingSessionMutations.delete(sessionId) - } - } - - private async waitForSessionMutations(sessionId: string): Promise { - while (this.pendingSessionMutations.has(sessionId)) { - await Promise.allSettled(this.pendingSessionMutations.get(sessionId)!) - } - } } diff --git a/src/main/daemon/history-reader.ts b/src/main/daemon/history-reader.ts index 39092b3aa5e..f950c550d99 100644 --- a/src/main/daemon/history-reader.ts +++ b/src/main/daemon/history-reader.ts @@ -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) { diff --git a/src/main/daemon/json-utf8-byte-length.test.ts b/src/main/daemon/json-utf8-byte-length.test.ts new file mode 100644 index 00000000000..fd05398907d --- /dev/null +++ b/src/main/daemon/json-utf8-byte-length.test.ts @@ -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 = {} + circular.self = circular + + expect(() => jsonUtf8ByteLength(circular)).toThrow('circular') + expect(() => jsonUtf8ByteLength(1n)).toThrow('BigInt') + }) +}) diff --git a/src/main/daemon/json-utf8-byte-length.ts b/src/main/daemon/json-utf8-byte-length.ts new file mode 100644 index 00000000000..dafc8b6970a --- /dev/null +++ b/src/main/daemon/json-utf8-byte-length.ts @@ -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() + + 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)[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 +} diff --git a/src/main/daemon/ndjson.test.ts b/src/main/daemon/ndjson.test.ts index 3d1d2c1512b..44020fc20b4 100644 --- a/src/main/daemon/ndjson.test.ts +++ b/src/main/daemon/ndjson.test.ts @@ -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', () => { diff --git a/src/main/daemon/ndjson.ts b/src/main/daemon/ndjson.ts index 17b37cb8d11..2557844bad7 100644 --- a/src/main/daemon/ndjson.ts +++ b/src/main/daemon/ndjson.ts @@ -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 diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 7edaa500663..7d96484edea 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -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' diff --git a/src/main/daemon/terminal-checkpoint-serializer.ts b/src/main/daemon/terminal-checkpoint-serializer.ts new file mode 100644 index 00000000000..595d42fd6a2 --- /dev/null +++ b/src/main/daemon/terminal-checkpoint-serializer.ts @@ -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 { + 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 { + 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() + } +} diff --git a/src/main/daemon/terminal-checkpoint-writer-bounds.test.ts b/src/main/daemon/terminal-checkpoint-writer-bounds.test.ts new file mode 100644 index 00000000000..d7a1fd3e9fc --- /dev/null +++ b/src/main/daemon/terminal-checkpoint-writer-bounds.test.ts @@ -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 = {} + 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') + }) +}) diff --git a/src/main/daemon/terminal-history-checkpoint-reader.ts b/src/main/daemon/terminal-history-checkpoint-reader.ts index 2b8acbd2963..16bb7086bf6 100644 --- a/src/main/daemon/terminal-history-checkpoint-reader.ts +++ b/src/main/daemon/terminal-history-checkpoint-reader.ts @@ -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') ) } diff --git a/src/main/daemon/terminal-history-cold-restore-info.ts b/src/main/daemon/terminal-history-cold-restore-info.ts index 8a68072a2da..fe71909f3b4 100644 --- a/src/main/daemon/terminal-history-cold-restore-info.ts +++ b/src/main/daemon/terminal-history-cold-restore-info.ts @@ -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 } : {}) } } diff --git a/src/main/daemon/terminal-history-file-limits.ts b/src/main/daemon/terminal-history-file-limits.ts index 5fc4523d785..207d918abbc 100644 --- a/src/main/daemon/terminal-history-file-limits.ts +++ b/src/main/daemon/terminal-history-file-limits.ts @@ -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 diff --git a/src/main/daemon/terminal-history-manager-options.ts b/src/main/daemon/terminal-history-manager-options.ts index 7e03e856eb0..8f846ac9276 100644 --- a/src/main/daemon/terminal-history-manager-options.ts +++ b/src/main/daemon/terminal-history-manager-options.ts @@ -10,4 +10,7 @@ export type OpenSessionOptions = { export type HistoryManagerOptions = { onWriteError?: (sessionId: string, error: Error) => void + checkpointMaxBytes?: number } + +export type HistoryCheckpointResult = 'committed' | 'retryable' | 'unavailable' diff --git a/src/main/daemon/terminal-history-mutation-tracker.ts b/src/main/daemon/terminal-history-mutation-tracker.ts new file mode 100644 index 00000000000..80475f60535 --- /dev/null +++ b/src/main/daemon/terminal-history-mutation-tracker.ts @@ -0,0 +1,28 @@ +export class TerminalHistoryMutationTracker { + private pending = new Map>>() + + track(sessionId: string, operation: Promise): Promise { + const mutations = this.pending.get(sessionId) ?? new Set>() + 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 { + while (this.pending.has(sessionId)) { + await Promise.allSettled(this.pending.get(sessionId)!) + } + } + + private finish(sessionId: string, operation: Promise): void { + const mutations = this.pending.get(sessionId) + mutations?.delete(operation) + if (mutations?.size === 0) { + this.pending.delete(sessionId) + } + } +} diff --git a/src/main/daemon/terminal-history-seed-chunks.test.ts b/src/main/daemon/terminal-history-seed-chunks.test.ts new file mode 100644 index 00000000000..79c65a2a597 --- /dev/null +++ b/src/main/daemon/terminal-history-seed-chunks.test.ts @@ -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 + }) + }) +}) diff --git a/src/main/daemon/terminal-history-seed-chunks.ts b/src/main/daemon/terminal-history-seed-chunks.ts new file mode 100644 index 00000000000..91f6ab5a90f --- /dev/null +++ b/src/main/daemon/terminal-history-seed-chunks.ts @@ -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 { + 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') } +} diff --git a/src/main/daemon/terminal-history-seed-segments.ts b/src/main/daemon/terminal-history-seed-segments.ts new file mode 100644 index 00000000000..78732e81931 --- /dev/null +++ b/src/main/daemon/terminal-history-seed-segments.ts @@ -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) +} diff --git a/src/main/daemon/terminal-history-seed-transfer-protocol.ts b/src/main/daemon/terminal-history-seed-transfer-protocol.ts new file mode 100644 index 00000000000..ce3b3db5a56 --- /dev/null +++ b/src/main/daemon/terminal-history-seed-transfer-protocol.ts @@ -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 diff --git a/src/main/daemon/terminal-history-seed-transfer-registry.test.ts b/src/main/daemon/terminal-history-seed-transfer-registry.test.ts new file mode 100644 index 00000000000..a7e0bd63657 --- /dev/null +++ b/src/main/daemon/terminal-history-seed-transfer-registry.test.ts @@ -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') + }) +}) diff --git a/src/main/daemon/terminal-history-seed-transfer-registry.ts b/src/main/daemon/terminal-history-seed-transfer-registry.ts new file mode 100644 index 00000000000..339762828b5 --- /dev/null +++ b/src/main/daemon/terminal-history-seed-transfer-registry.ts @@ -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 + finished: boolean + timer: ReturnType +} + +export class TerminalHistorySeedTransferRegistry { + private transfers = new Map() + 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) + } +} diff --git a/src/main/daemon/terminal-history-session-writer.ts b/src/main/daemon/terminal-history-session-writer.ts index 1af964e2c05..644f247a1f1 100644 --- a/src/main/daemon/terminal-history-session-writer.ts +++ b/src/main/daemon/terminal-history-session-writer.ts @@ -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 { + 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. diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index e98144e516f..3a3e9b1c904 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -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 diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index f61fb25d798..dcc88fada05 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -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), diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 53fc791bb05..bc0be4070d7 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -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 diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index 6d36c1366a3..858d23cd87b 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -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, diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts index fbca834fdc4..c4034ba5bee 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts @@ -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'") + }) }) diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index b6f86602e87..0981f8d40b1 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -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. }) diff --git a/src/renderer/src/lib/worktree-activation-created-agent-test-state.ts b/src/renderer/src/lib/worktree-activation-created-agent-test-state.ts index 68196ad5f55..5cd35181547 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent-test-state.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent-test-state.ts @@ -27,9 +27,74 @@ export function makeCreatedAgentWorktree(): Worktree { } } +type StoreState = ReturnType + +/** The empty-workspace store shape both seeds start from; each layers its own tabs/actions on top. */ +function baseSeedState(worktree: Worktree, worktrees: Worktree[]): Partial { + 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 } { + 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> = {} + overrides: Partial = {} ): { markWorktreeVisited: ReturnType recordWorktreeVisit: ReturnType @@ -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['settings'], markWorktreeVisited, recordWorktreeVisit, - refreshGitHubForWorktreeIfStale: vi.fn(), revealWorktreeInSidebar, ...overrides }) diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index 93da1bb423e..cc2af6e7d7c 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -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[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['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['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', () => { diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index b3c9abe7fb7..ea06052d992 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -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 diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index 9e53b1a2411..512b4c742c4 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -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')) diff --git a/src/shared/local-build-compatibility-contract.json b/src/shared/local-build-compatibility-contract.json index f4b1a07abfe..f10216ddeee 100644 --- a/src/shared/local-build-compatibility-contract.json +++ b/src/shared/local-build-compatibility-contract.json @@ -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 ] } diff --git a/src/shared/local-build-compatibility-contract.ts b/src/shared/local-build-compatibility-contract.ts index ad80dd85808..ecc9fcef184 100644 --- a/src/shared/local-build-compatibility-contract.ts +++ b/src/shared/local-build-compatibility-contract.ts @@ -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