Stop relaunching creation-time agents on workspace activation (#10647)

* fix(activation): stop relaunching the creation-time agent on workspace activation

Activating a workspace with zero renderable tabs launched the agent it was
created with, unprompted and in approval-bypass mode. Navigation is not consent
to start a process: the same fallback fired from post-delete focus handoff, the
jump palette, keyboard cycling, CLI/relay activation, and notification clicks.

The mechanism was superseded. #1814 added it when relaunching the created agent
*was* the resume feature; #4706 later added real provider-session resume six
lines above and left the fallback in place. What remained fired whenever a
workspace had no renderable tabs -- including when nothing had ever slept -- and
reported itself as `request_kind: 'resume'` while resuming nothing, discarding
any resumable session a plain tab close had already purged.

No caller depends on it. All seven intent-carrying callers pass an explicit
`startup` on the branch where they intend a launch, and every no-startup branch
either declined an agent, already has one running (host `didSpawnStartup`), or
is this same defect arriving over IPC.

Drops the now-orphaned imports, retargets the stale comment in
launch-work-item-direct that cited reopen-relaunch as the reason to persist
`createdWithAgent`, and moves the WSL default-args quoting assertion to
launch-agent-in-new-tab, whose launch path still resolves those args.

Regression tests are revert-sensitive -- all four fail if the fallback returns.

* test(activation): name the relaunch regression tests after what they reach

Three tests were named after scenarios they never invoked, which is the
failure mode that lets a coverage gap read as closed.

- The "host-originated" test's `notifyHostRuntime: false` is inert here: both
  gates resolve through `isWebRuntimeSessionActive`, false with no runtime
  environment seeded, so it was byte-identical to the plain reopen test. It no
  longer claims to cover the host `didSpawnStartup` leg, which lives in main and
  is unreachable from this layer.
- The "post-delete focus handoff" test never deleted anything and never touched
  `prepareActiveWorktreeFocusAfterDelete`. That caller is asserted directly in
  active-worktree-focus-after-delete.test.ts, which locks out any opts.
- The activate/close loop resets state instead of calling `closeTab`, so it does
  not exercise the sleeping-record purge its comment claimed.

Also folds the primary reopen test onto `seedEmptyActivatableWorktree` — the
fixture extracted for exactly that state, which its inline copy had drifted from
by hardcoding a POSIX repo path.

`preflight` is dropped from the launch-work-item-direct comment: the trust
preflight reads the create-time argument (worktree-remote.ts), not the persisted
meta. Removal safety and ownership do read the field and remain accurate.

Renames the ported quoting test to what it pins. Under vitest's node
environment `navigator.userAgent` carries no "Windows", so platform resolution
bails before the WSL branch and the WSL preference is inert — the real coverage
is single-quote escaping of user-configured agentDefaultArgs.

* transfer large terminal history seeds across bounded protocol messages

- Oversized cold-restore snapshots (>1MB) now upload via chunked startHistorySeedTransfer/appendHistorySeedTransfer protocol instead of inline, avoiding NDJSON line-size violations
- Checkpoints automatically trim oldest rows to fit within configured byte limit (200MB) before commit
- Protocol v30 required for chunked transfers; v29 daemons gracefully fall back to renderer-only recovery
- NDJSON encodeNdjson() validates line size and rejects oversized payloads; notifications silently swallow encoding errors

* fix(daemon): drop held output when teardown checkpoint fails to serializ

When a final snapshot checkpoint fails to serialize (returns retryable), the
pending output records must not be appended later—doing so would splice them
over the seq gap left by the failed snapshot, defeating gap detection. Drop
the records and retry the checkpoint instead.

* Bump daemon protocol version to 30

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