diff --git a/src/main/daemon/cold-restore-payload-cache.ts b/src/main/daemon/cold-restore-payload-cache.ts index 126c1d38bfe..edbc7cfbaef 100644 --- a/src/main/daemon/cold-restore-payload-cache.ts +++ b/src/main/daemon/cold-restore-payload-cache.ts @@ -6,6 +6,8 @@ export type ColdRestorePayload = { cols: number rows: number oscLinks?: TerminalOscLinkRange[] + /** Last OSC title from the recovered checkpoint; seeds title records only. */ + lastTitle?: string } // Why: restore payloads remain sticky only for remount safety; cap their aggregate main-process footprint. @@ -15,7 +17,13 @@ export function getColdRestorePayloadBytes(payload: ColdRestorePayload): number const oscLinkBytes = payload.oscLinks?.reduce((bytes, link) => bytes + link.uri.length * 2 + 24, 0) ?? 0 // Why: code-unit sizing bounds V8 string storage without rescanning or flattening multi-MB ropes. - return payload.scrollback.length * 2 + payload.cwd.length * 2 + oscLinkBytes + 16 + return ( + payload.scrollback.length * 2 + + payload.cwd.length * 2 + + (payload.lastTitle?.length ?? 0) * 2 + + oscLinkBytes + + 16 + ) } export class ColdRestorePayloadCache { diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 0b0c0c41eaf..acbe37b76f3 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -852,6 +852,8 @@ export class DaemonPtyAdapter implements IPtyProvider { : {}), isReattach: true, isAlternateScreen: isAltScreen, + // Why: the snapshot ANSI has no title frame; carry lastTitle beside it so main can seed title records after a relaunch. + ...(result.snapshot.lastTitle ? { lastTitle: result.snapshot.lastTitle } : {}), // Why: carry the mid-escape tail so the renderer writes it after the reattach reset, else a split escape renders literally (#7329). ...(result.snapshot.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: result.snapshot.pendingEscapeTailAnsi } @@ -1135,7 +1137,8 @@ export class DaemonPtyAdapter implements IPtyProvider { cwd: restoreInfo.cwd, cols: restoreInfo.cols, rows: restoreInfo.rows, - oscLinks: restoreInfo.oscLinks + oscLinks: restoreInfo.oscLinks, + ...(restoreInfo.lastTitle ? { lastTitle: restoreInfo.lastTitle } : {}) } } diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index fc73725f8cb..59201709ce0 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -9,7 +9,7 @@ import { } from '../../shared/terminal-input' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text' import { redactPtyIdForDiagnostics } from '../../shared/pty-delivery-diagnostics' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultWorkspaceSession } from '../../shared/constants' import type { TuiAgent } from '../../shared/types' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-host-authority' @@ -102,6 +102,8 @@ const { })) vi.mock('electron', () => ({ + // Why defined-but-undefined: the real OrcaRuntimeService guards BrowserWindow with `?.`; vitest throws on reading exports the mock omits. + BrowserWindow: undefined, app: { isPackaged: true, getPath: getPathMock, @@ -16958,6 +16960,254 @@ describe('registerPtyHandlers', () => { ) }) + // STA repro (post-restart blind orchestrator): reattach restore payloads + // arrive as spawn RPC results, never through onPtyData, so without record + // seeding `terminal list` reported connected terminals with empty + // title/preview/lastOutputAt after every relaunch and `terminal read` + // returned a zero-line tail for a running session. + it('leaves the runtime reporting preview and title after a reattach spawn (restart restore)', async () => { + const worktreeId = 'repo-restore::/tmp/restore-records' + const tabId = 'tab-restore-records' + const leafId = '55555555-5555-4555-8555-555555555555' + const ptyId = `${worktreeId}@@session-restore-1` + const session = getDefaultWorkspaceSession() + const runtime = new OrcaRuntimeService({ + getWorkspaceSession: () => session, + setWorkspaceSession: () => {}, + getRepos: () => [ + { + id: 'repo-restore', + path: '/tmp/restore-records', + displayName: 'restore', + badgeColor: '#000000', + addedAt: 0 + } + ], + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getSettings: () => ({ workspaceDir: '/tmp/workspaces' }), + getProjects: () => [] + } as never) + runtime.attachWindow(1) + // The restored window graph still knows the persisted ptyId binding. + runtime.syncWindowGraph(1, { + tabs: [{ tabId, worktreeId, title: '', activeLeafId: leafId, layout: null }], + leaves: [{ tabId, worktreeId, leafId, paneRuntimeId: 1, ptyId, paneTitle: null, title: '' }] + }) + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ + id: ptyId, + isReattach: true, + snapshot: '\x1b[32m$\x1b[0m npm test\r\n\x1b[1mall 42 tests passed\x1b[0m\r\n', + snapshotCols: 80, + snapshotRows: 24, + lastTitle: 'restored-agent-title' + })), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => [{ id: ptyId, cwd: '/tmp/restore-records' }]), + getForegroundProcess: vi.fn(async () => null) + } as never) + registerPtyHandlers(mainWindow as never, runtime) + + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, worktreeId, tabId, leafId }) + + const { terminals } = await runtime.listTerminals(`id:${worktreeId}`) + expect(terminals).toHaveLength(1) + const terminal = terminals[0]! + expect(terminal.preview).toContain('$ npm test') + expect(terminal.preview).toContain('all 42 tests passed') + expect(terminal.title).toBe('restored-agent-title') + // Seeded scrollback is historical — recency must come only from live bytes. + expect(terminal.lastOutputAt).toBeNull() + const read = await runtime.readTerminal(terminal.handle) + expect(read.tail).toEqual(['$ npm test', 'all 42 tests passed']) + }) + + it('seeds restore records even when the renderer pre-signals serializer ownership', async () => { + const tabId = 'tab-gated-restore' + const leafId = '66666666-6666-4666-8666-666666666666' + const paneKey = makePaneKey(tabId, leafId) + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ + id: 'pty-gated-reattach', + isReattach: true, + snapshot: 'gated snapshot\r\n', + snapshotCols: 80, + snapshotRows: 24, + lastTitle: 'gated-title' + })), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + const runtime = { + setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + seedHeadlessTerminal: vi.fn(), + seedTerminalRestoreTail: vi.fn(), + onPtySpawned: vi.fn(), + onPtyData: vi.fn(), + onPtyExit: vi.fn(), + registerPty: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => 'handle-gated-restore'), + registerPreAllocatedHandleForPty: vi.fn(), + preAllocateHandleForPty: vi.fn() + } + registerPtyHandlers(mainWindow as never, runtime as never) + const gen = await handlers.get('pty:declarePendingPaneSerializer')!(null, { paneKey }) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + worktreeId: 'wt-gated', + tabId, + leafId, + env: { ORCA_PANE_KEY: paneKey } + }) + + // The renderer owns the emulator snapshot here — but the list/read records + // are main-side only, so the record seed must still run. + expect(runtime.seedHeadlessTerminal).not.toHaveBeenCalled() + expect(runtime.seedTerminalRestoreTail).toHaveBeenCalledWith('pty-gated-reattach', { + text: 'gated snapshot\r\n', + lastTitle: 'gated-title' + }) + await handlers.get('pty:clearPendingPaneSerializer')!(null, { paneKey, gen }) + }) + + it('seeds restore records from a cold-restore payload including its checkpoint title', async () => { + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ + id: 'pty-cold-restore-records', + coldRestore: { + scrollback: 'cold restored history\r\n', + cwd: '/projects/restored', + cols: 132, + rows: 43, + lastTitle: 'checkpoint-title' + } + })), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + const runtime = { + setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + seedHeadlessTerminal: vi.fn(), + seedTerminalRestoreTail: vi.fn(), + onPtySpawned: vi.fn(), + onPtyData: vi.fn(), + onPtyExit: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => 'handle-cold-restore-records'), + registerPreAllocatedHandleForPty: vi.fn(), + preAllocateHandleForPty: vi.fn() + } + registerPtyHandlers(mainWindow as never, runtime as never) + + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + + expect(runtime.seedTerminalRestoreTail).toHaveBeenCalledWith('pty-cold-restore-records', { + text: 'cold restored history\r\n', + lastTitle: 'checkpoint-title' + }) + }) + + // Why windowless: `orca serve`/CLI runtime creation is the topology that most + // needs informative records — its controller.spawn path must seed them too. + it('seeds restore records for a runtime-controller created terminal (headless reattach)', async () => { + const worktreeId = 'repo-restore::/tmp/restore-records' + const ptyId = `${worktreeId}@@session-headless-1` + const session = getDefaultWorkspaceSession() + const repo = { + id: 'repo-restore', + path: '/tmp/restore-records', + displayName: 'restore', + badgeColor: '#000000', + addedAt: 0 + } + const runtime = new OrcaRuntimeService({ + getWorkspaceSession: () => session, + setWorkspaceSession: () => {}, + getRepo: (repoId: string) => (repoId === repo.id ? repo : undefined), + getRepos: () => [repo], + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getSettings: () => ({ workspaceDir: '/tmp/workspaces' }), + getProjects: () => [], + persistPtyBinding: vi.fn() + } as never) + // Why: selector resolution shells out to git for real repos; prime the + // resolved-worktree cache so this headless fixture resolves offline. + const worktreeResolutionInternals = runtime as unknown as { + buildResolvedWorktreeFromId(id: string): unknown + resolvedWorktreeCache: { + worktrees: unknown[] + platformByRepoId: Map + expiresAt: number + } | null + } + worktreeResolutionInternals.resolvedWorktreeCache = { + worktrees: [worktreeResolutionInternals.buildResolvedWorktreeFromId(worktreeId)], + platformByRepoId: new Map([[repo.id, process.platform]]), + expiresAt: Date.now() + 60_000 + } + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ + id: ptyId, + isReattach: true, + snapshot: 'headless reattach history\r\n$ ', + snapshotCols: 80, + snapshotRows: 24, + lastTitle: 'headless-restored-title' + })), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => [{ id: ptyId, cwd: '/tmp/restore-records' }]), + getForegroundProcess: vi.fn(async () => null) + } as never) + registerPtyHandlers(mainWindow as never, runtime, undefined, undefined, undefined, { + persistPtyBinding: vi.fn() + } as never) + + const created = await runtime.createTerminal(`id:${worktreeId}`, { + presentation: 'background' + }) + expect(created.ptyId).toBe(ptyId) + + const { terminals } = await runtime.listTerminals(`id:${worktreeId}`) + const terminal = terminals.find((entry) => entry.ptyId === ptyId) + expect(terminal).toBeDefined() + expect(terminal!.preview).toContain('headless reattach history') + expect(terminal!.title).toBe('headless-restored-title') + expect(terminal!.lastOutputAt).toBeNull() + const read = await runtime.readTerminal(created.handle) + expect(read.tail).toContain('headless reattach history') + }) + it('upgrades legacy numeric pane keys when the spawn metadata proves the stable leaf', async () => { registerPtyHandlers(mainWindow as never) const leafId = '11111111-1111-4111-8111-111111111111' diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 7c20eb17e13..94538e71b79 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -334,6 +334,39 @@ const pendingRuntimePaneCreatesByOwnerKey = new Map | null = null +// Why: restore payloads (reattach snapshot / cold-restore scrollback / relay +// replay + lastTitle) ride spawn RPC results, never onPtyData, so EVERY spawn +// choke point — renderer pty:spawn and the runtime controller — must seed the +// terminal list/read records or headless/CLI-created reattaches stay blank. +// The runtime's empty-record guard makes a second seed for the same session a +// no-op, so overlapping paths cannot double-apply history. +function seedTerminalRestoreRecordsFromSpawnResult( + runtime: OrcaRuntimeService | undefined, + result: PtySpawnResult +): void { + const text = + typeof result.snapshot === 'string' && result.snapshot.length > 0 + ? result.snapshot + : typeof result.coldRestore?.scrollback === 'string' && + result.coldRestore.scrollback.length > 0 + ? result.coldRestore.scrollback + : typeof result.replay === 'string' && result.replay.length > 0 + ? result.replay + : undefined + const lastTitle = + typeof result.lastTitle === 'string' && result.lastTitle.length > 0 + ? result.lastTitle + : typeof result.coldRestore?.lastTitle === 'string' && result.coldRestore.lastTitle.length > 0 + ? result.coldRestore.lastTitle + : undefined + if (text !== undefined || lastTitle !== undefined) { + runtime?.seedTerminalRestoreTail?.(result.id, { + ...(text !== undefined ? { text } : {}), + ...(lastTitle !== undefined ? { lastTitle } : {}) + }) + } +} + function assertSpawnReplyWasLive(result: PtySpawnResult): void { if (!result.exitedBeforeSpawnReply) { return @@ -4974,6 +5007,8 @@ export function registerPtyHandlers( // Why: non-worktree PTYs have no later surface-registration phase to clear admission intent. runtime?.cancelPendingPtyRegistration?.(result.id, result.incarnationId) } + // Why: runtime-controller creates (headless serve, CLI, splits) adopt surviving daemon sessions too; without this seed their records stay blank. + seedTerminalRestoreRecordsFromSpawnResult(runtime, result) // Why: arms main's per-PTY Command Code output detector from the launch command (renderer startupCommand parity). if (!stablePaneOwner) { runtime?.noteTerminalSpawnCommand?.(result.id, launchCommand ?? null) @@ -6337,6 +6372,10 @@ export function registerPtyHandlers( runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId, result.incarnationId) pendingRegistrationPtyId = null } + // Why: seed after registerPty binds the worktree — including on + // desktop, where the renderer-authority gate above skips the emulator + // seed but the list/read records still live main-side. + seedTerminalRestoreRecordsFromSpawnResult(runtime, result) // Why: arm main's per-PTY Command Code output detector from the launch command (startupCommand parity); banner detection covers PTYs without one. if (!stablePaneOwner) { runtime?.noteTerminalSpawnCommand?.( diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 6608b63a7fd..235672e05e7 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -45,6 +45,10 @@ export type PtySpawnResult = { snapshotKittyKeyboardFlags?: number /** True when the spawn reattached to an existing daemon session. */ isReattach?: boolean + /** Last OSC title tracked by the daemon session the snapshot came from. + * Seeds main's terminal title records after a relaunch; never replayed + * into a terminal. */ + lastTitle?: string /** True when the reattached session uses the alternate screen buffer * (e.g., Codex CLI, vim). Normal-screen TUIs like Claude Code are false. */ isAlternateScreen?: boolean @@ -65,5 +69,7 @@ export type PtySpawnResult = { cols?: number rows?: number oscLinks?: TerminalOscLinkRange[] + /** Last OSC title from the recovered checkpoint (see `lastTitle` above). */ + lastTitle?: string } } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index d5584eae3e7..e7b472fea7a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9694,6 +9694,30 @@ export class OrcaRuntimeService { state.appended = '' } + // Why: the scanner's first run after a restore seed compares against a null + // baseline, so a permission prompt visible only in seeded HISTORY would read + // as newly gained and stamp waitBlockedAt "now" on the next benign chunk. + // Store the seeded tail's wait state as the baseline WITHOUT stamping; only + // a signal that appears in genuinely new output counts as gained. + private primeWaitBlockedBaselineFromSeededTail(ptyId: string): void { + const pty = this.ptysById.get(ptyId) + if (!pty) { + return + } + let state = this.waitBlockedCheckStateByPtyId.get(ptyId) + if (!state) { + state = { lastAt: 0, lastWaitState: null, appended: '', keywordCarry: '', timer: null } + this.waitBlockedCheckStateByPtyId.set(ptyId, state) + } + if (state.lastWaitState === null) { + state.lastWaitState = computeTerminalTailWaitState( + pty.tailBuffer, + pty.tailPartialLine, + pty.preview + ) + } + } + private clearWaitBlockedCheckState(ptyId: string): void { const state = this.waitBlockedCheckStateByPtyId.get(ptyId) if (state?.timer) { @@ -10885,6 +10909,34 @@ export class OrcaRuntimeService { }) } + // Why: reattach/cold-restore/replay payloads arrive as spawn RPC results and + // never pass through onPtyData, so after a relaunch the records backing + // `terminal list`/`terminal read` stayed blank while the session was alive. + // Seed semantics (applySeededAgentStatus precedent): write state only — no + // waiters, no orchestration events, and no lastOutputAt, because restored + // bytes are historical output, not fresh activity. + seedTerminalRestoreTail(ptyId: string, restore: { text?: string; lastTitle?: string }): void { + const seed = restore.text ? buildRestoredTerminalTailSeed(restore.text) : null + if (seed) { + const pty = this.getOrCreatePtyWorktreeRecord(ptyId) + // Why: live bytes outrank the seed — only never-written records take it, + // so a same-run remount reattach cannot re-apply history it already has. + if (pty && restoredTerminalTailSeedAllowed(pty)) { + applyRestoredTerminalTailSeed(pty, seed) + this.primeWaitBlockedBaselineFromSeededTail(ptyId) + } + for (const leaf of this.getLeavesForPty(ptyId)) { + if (restoredTerminalTailSeedAllowed(leaf)) { + applyRestoredTerminalTailSeed(leaf, seed) + } + } + } + if (restore.lastTitle) { + // Why: mirror renderer hydration — a title main already tracked live outranks the payload's persisted one. + this.applySeededAgentStatus(ptyId, this.getTrackedRawTitleForPty(ptyId) ?? restore.lastTitle) + } + } + // Why: hydrate the runtime headless emulator from the desktop renderer's // xterm buffer on the first onPtyData byte after a PTY is taken over by a // pane. Eager-state pattern matches seedHeadlessTerminal: headlessTerminals @@ -34855,6 +34907,108 @@ export function buildPreview(lines: string[], partialLine: string): string { : preview } +// Why: restore payloads can be multi-MB; the records only retain a bounded tail, +// so cap the one-time parse on the spawn path to the suffix that can matter. +const MAX_RESTORE_TAIL_SEED_CHARS = 256 * 1024 + +type RestoredTerminalTailSeed = { + lines: string[] + transcriptLines: string[] + transcriptChars: number + partialLine: string + pendingAnsi: string + redrawCursor: RetainedTailRedrawCursor | null + truncated: boolean + linesTotal: number + preview: string +} + +type RestorableTerminalTailRecord = Pick< + RuntimePtyWorktreeRecord, + | 'lastOutputAt' + | 'tailBuffer' + | 'tailTranscriptBuffer' + | 'tailTranscriptChars' + | 'tailPartialLine' + | 'tailPendingAnsi' + | 'tailRedrawCursor' + | 'tailTruncated' + | 'tailLinesTotal' + | 'preview' +> + +export function buildRestoredTerminalTailSeed(text: string): RestoredTerminalTailSeed | null { + let bounded = text + let sliced = false + if (bounded.length > MAX_RESTORE_TAIL_SEED_CHARS) { + bounded = bounded.slice(-MAX_RESTORE_TAIL_SEED_CHARS) + // Why: an arbitrary suffix can start mid-escape; restarting after the first + // line break resumes at a boundary (escape params never span \n or \r — + // \r covers newline-free CR-redraw streams). Consume a full \r\n pair so + // the seed does not begin with a phantom blank line. + const anchor = bounded.search(/[\r\n]/) + if (anchor !== -1) { + bounded = bounded.slice( + bounded[anchor] === '\r' && bounded[anchor + 1] === '\n' ? anchor + 2 : anchor + 1 + ) + } + sliced = true + } + // Why: the live-path pipeline, so seeded records equal what streaming the + // same bytes through onPtyData would have produced. + const normalized = normalizeTerminalChunk(bounded) + const tail = appendNormalizedToTailBuffer([], '', normalized.text, null) + if (tail.lines.length === 0 && tail.partialLine.length === 0) { + return null + } + const transcript = appendCompletedTerminalTranscript( + [], + 0, + tail.newlyCompletedLines, + tail.newCompleteLines + ) + return { + lines: tail.lines, + transcriptLines: transcript.lines, + transcriptChars: transcript.characters, + partialLine: tail.partialLine, + pendingAnsi: normalized.pendingAnsi, + redrawCursor: tail.redrawCursor, + truncated: sliced || tail.truncated || transcript.truncated, + linesTotal: tail.newCompleteLines, + preview: buildPreview(tail.lines, tail.partialLine) + } +} + +function restoredTerminalTailSeedAllowed(record: RestorableTerminalTailRecord): boolean { + return ( + record.lastOutputAt === null && + record.preview.length === 0 && + record.tailBuffer.length === 0 && + record.tailPartialLine.length === 0 + ) +} + +// Deliberately untouched: lastOutputAt (historical bytes must not read as fresh +// activity) and waitBlockedAt/tailWaitState (a restored prompt is not a live +// wait signal; the next live chunk recomputes both from this seeded tail). +function applyRestoredTerminalTailSeed( + record: RestorableTerminalTailRecord, + seed: RestoredTerminalTailSeed +): void { + // Why shared instances: append helpers never mutate prior arrays, and equal + // references let tailStateMatches keep its O(1) leaf/pty reuse fast path. + record.tailBuffer = seed.lines + record.tailTranscriptBuffer = seed.transcriptLines + record.tailTranscriptChars = seed.transcriptChars + record.tailPartialLine = seed.partialLine + record.tailPendingAnsi = seed.pendingAnsi + record.tailRedrawCursor = seed.redrawCursor + record.tailTruncated = seed.truncated + record.tailLinesTotal = seed.linesTotal + record.preview = seed.preview +} + function buildTerminalWaitText(lines: string[], partialLine: string, preview: string): string { const waitText = buildTailLines(lines, partialLine) .map((line) => line.trim()) diff --git a/src/main/runtime/terminal-restore-record-seed.test.ts b/src/main/runtime/terminal-restore-record-seed.test.ts new file mode 100644 index 00000000000..8e3b49620d4 --- /dev/null +++ b/src/main/runtime/terminal-restore-record-seed.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { WorkspaceSessionState } from '../../shared/types' + +// STA repro (post-restart blind orchestrator incident): after an app relaunch +// the daemon survives and spawn reattaches silently, but the restore payload +// (snapshot/scrollback/replay + lastTitle) arrives as an RPC result, never as +// an onPtyData event. The runtime's terminal records therefore stayed empty: +// `terminal list` showed connected terminals with no title/preview/lastOutputAt +// and `terminal read` returned a zero-line tail for a running session. +// seedTerminalRestoreTail must fill preview/tail/title from the payload — +// WITHOUT fabricating recency (lastOutputAt) or emitting side-effect facts. + +const WORKTREE_ID = 'repo-1::/tmp/probe-worktree' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PTY_ID = `${WORKTREE_ID}@@session-restore` + +function makeStore() { + const session: WorkspaceSessionState = getDefaultWorkspaceSession() + return { + getWorkspaceSession: vi.fn(() => session), + setWorkspaceSession: vi.fn(), + getRepos: vi.fn(() => [ + { + id: 'repo-1', + path: '/tmp/probe-worktree', + displayName: 'probe', + badgeColor: '#000000', + addedAt: 0 + } + ]), + getAllWorktreeMeta: vi.fn(() => ({})), + getWorktreeMeta: vi.fn(() => undefined), + setWorktreeMeta: vi.fn(), + removeWorktreeMeta: vi.fn(), + getSettings: vi.fn(() => ({ workspaceDir: '/tmp/workspaces' })), + getProjects: vi.fn(() => []) + } +} + +function makeRuntimeWithLeaf(): OrcaRuntimeService { + const runtime = new OrcaRuntimeService(makeStore() as never) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'never' })), + write: () => true, + kill: () => true, + listProcesses: vi.fn(async () => [{ id: PTY_ID, cwd: '/tmp/probe-worktree' }]) + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + title: '', + activeLeafId: LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneRuntimeId: 1, + ptyId: PTY_ID, + paneTitle: null, + title: '' + } + ] + }) + return runtime +} + +type RuntimeRecordInternals = { + ptysById: Map< + string, + { + preview: string + lastOutputAt: number | null + waitBlockedAt: number | null + lastOscTitle: string | null + tailBuffer: string[] + } + > + ptyTitleTrackersByPtyId: Map +} + +describe('seedTerminalRestoreTail', () => { + it('seeds preview/tail/title from a restore payload without fabricating recency', async () => { + const runtime = makeRuntimeWithLeaf() + + runtime.seedTerminalRestoreTail(PTY_ID, { + text: '\x1b]0;osc-title-noise\x07\x1b[32m$ npm test\x1b[0m\r\n\x1b[1mall 42 tests passed\x1b[0m\r\nspinner frame 1\rspinner frame 2\r\n', + lastTitle: 'restored-agent-title' + }) + + const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`) + expect(terminals).toHaveLength(1) + const terminal = terminals[0]! + expect(terminal.preview).toContain('$ npm test') + expect(terminal.preview).toContain('all 42 tests passed') + // CR redraws collapse to the surviving frame, exactly like live bytes. + expect(terminal.preview).toContain('spinner frame 2') + expect(terminal.preview).not.toContain('spinner frame 1') + expect(terminal.preview).not.toContain('\x1b') + expect(terminal.title).toBe('restored-agent-title') + // Seeded scrollback is historical — recency must come only from live bytes. + expect(terminal.lastOutputAt).toBeNull() + + const read = await runtime.readTerminal(terminal.handle) + expect(read.tail).toEqual(['$ npm test', 'all 42 tests passed', 'spinner frame 2']) + + const internals = runtime as unknown as RuntimeRecordInternals + const pty = internals.ptysById.get(PTY_ID)! + expect(pty.preview).toContain('all 42 tests passed') + expect(pty.lastOutputAt).toBeNull() + expect(pty.waitBlockedAt).toBeNull() + expect(pty.lastOscTitle).toBe('restored-agent-title') + // Seed semantics: state writes only — no side-effect facts to deliver. + expect(internals.ptyTitleTrackersByPtyId.get(PTY_ID)?.pendingFacts ?? []).toEqual([]) + }) + + it('lets live bytes land on top of the seed and refuses to re-apply history', async () => { + const runtime = makeRuntimeWithLeaf() + + runtime.seedTerminalRestoreTail(PTY_ID, { text: 'restored line\r\n' }) + runtime.onPtyData(PTY_ID, 'live line\r\n', 1234567) + + const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`) + const terminal = terminals[0]! + expect(terminal.preview).toContain('restored line') + expect(terminal.preview).toContain('live line') + expect(terminal.lastOutputAt).toBe(1234567) + + // A remount reattach delivering the same payload again must be a no-op. + runtime.seedTerminalRestoreTail(PTY_ID, { text: 'restored line\r\n' }) + const read = await runtime.readTerminal(terminal.handle) + expect(read.tail).toEqual(['restored line', 'live line']) + }) + + it('does not seed records that already saw live output', async () => { + const runtime = makeRuntimeWithLeaf() + + runtime.onPtyData(PTY_ID, 'live first\r\n', 42) + runtime.seedTerminalRestoreTail(PTY_ID, { text: 'stale history\r\n' }) + + const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`) + expect(terminals[0]!.preview).toBe('live first') + expect(terminals[0]!.lastOutputAt).toBe(42) + }) + + it('keeps a live-tracked title over the payload title', async () => { + const runtime = makeRuntimeWithLeaf() + + runtime.onPtyData(PTY_ID, '\x1b]0;live-title\x07', 42) + runtime.seedTerminalRestoreTail(PTY_ID, { lastTitle: 'stale-persisted-title' }) + + const internals = runtime as unknown as RuntimeRecordInternals + expect(internals.ptysById.get(PTY_ID)!.lastOscTitle).toBe('live-title') + }) + + it('seeds title-only payloads (no restore text)', async () => { + const runtime = makeRuntimeWithLeaf() + + runtime.seedTerminalRestoreTail(PTY_ID, { lastTitle: 'title-only-restore' }) + + const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`) + expect(terminals[0]!.title).toBe('title-only-restore') + expect(terminals[0]!.preview).toBe('') + expect(terminals[0]!.lastOutputAt).toBeNull() + }) + + it('caps the parsed suffix of an oversized payload at a line boundary', async () => { + const runtime = makeRuntimeWithLeaf() + + runtime.seedTerminalRestoreTail(PTY_ID, { + text: `${'x'.repeat(300 * 1024)}\r\npartial-first-line\r\nfinal restored line\r\n` + }) + + const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`) + const terminal = terminals[0]! + expect(terminal.preview).toContain('final restored line') + // The truncated head (which can start mid-escape) is dropped whole. + expect(terminal.preview).not.toContain('x') + const read = await runtime.readTerminal(terminal.handle) + expect(read.truncated).toBe(true) + expect(read.tail).toEqual(['partial-first-line', 'final restored line']) + }) + + it('does not stamp waitBlockedAt from a blocked prompt that exists only in seeded history', async () => { + const runtime = makeRuntimeWithLeaf() + + runtime.seedTerminalRestoreTail(PTY_ID, { + text: 'Do you trust this workspace?\r\nPress t to trust\r\n' + }) + const internals = runtime as unknown as RuntimeRecordInternals + const pty = internals.ptysById.get(PTY_ID)! + expect(pty.waitBlockedAt).toBeNull() + + // A benign heartbeat must not resurrect the historical prompt as blocked NOW. + runtime.onPtyData(PTY_ID, 'heartbeat ok\r\n', 1_000_000) + expect(pty.waitBlockedAt).toBeNull() + + // A prompt arriving in genuinely new output still stamps. + runtime.onPtyData(PTY_ID, 'Do you trust this workspace?\r\nPress t to trust\r\n', 2_000_000) + expect(pty.waitBlockedAt).toBe(2_000_000) + }) + + it('seeds the pty record for a daemon-scoped id with no synced leaf', async () => { + const runtime = makeRuntimeWithLeaf() + const orphanPtyId = `${WORKTREE_ID}@@session-orphan` + + runtime.seedTerminalRestoreTail(orphanPtyId, { text: 'orphan restored\r\n' }) + + const internals = runtime as unknown as RuntimeRecordInternals + const pty = internals.ptysById.get(orphanPtyId)! + expect(pty.preview).toBe('orphan restored') + expect(pty.lastOutputAt).toBeNull() + }) +})