fix(runtime): seed terminal previews and titles from restore payloads (#12579)

* fix(terminal): seed list/read records from reattach restore payloads

After an app relaunch the PTY daemon survives and spawn silently
reattaches, but the restore payload (reattach snapshot, cold-restore
scrollback, relay replay, lastTitle) arrives as a spawn RPC result and
never passes through runtime.onPtyData — the only feeder of the terminal
records behind `terminal list`/`terminal read`. Every restart therefore
left connected terminals with empty title/preview/lastOutputAt and a
zero-line read tail, blinding orchestrators that poll terminals.

The spawn flow now calls runtime.seedTerminalRestoreTail with the restore
text and lastTitle, unconditionally of the renderer-authority emulator
gate (the records are main-side only). The seed reuses the live path's
normalize/tail/preview pipeline on a capped 256 KiB suffix (re-anchored
at a line boundary so a cut escape cannot leak), only fills records that
never saw output (a remount reattach cannot re-apply history), routes
titles through the applySeededAgentStatus precedent (state writes only —
no waiters, no side-effect facts), and never stamps lastOutputAt or
waitBlockedAt: restored bytes are historical, not fresh activity.

lastTitle is threaded from the daemon reattach snapshot and cold-restore
checkpoint into PtySpawnResult; relay replays seed preview only. SSH and
runtime-controller paths are unchanged — seeding is gated on the fields
existing.

* fix(terminal): seed restore records on the controller spawn path and prime the wait baseline

Follow-ups to the restore-record seed, from independent verification:

1. The runtime-controller spawn flow (createTerminal background creates —
   headless `orca serve`/CLI — and pane splits) never consumed restore
   payloads, so the exact orchestrator-blindness this fix targets survived
   on the topology that needs it most. The extraction now lives in one
   helper called from both spawn choke points (renderer pty:spawn and the
   controller flow); the runtime's empty-record guard makes overlapping
   seeds a no-op.

2. The throttled per-PTY wait scanner starts with a null baseline, so a
   permission prompt visible only in seeded HISTORY read as newly gained
   on the first benign live chunk and stamped waitBlockedAt "now".
   Seeding now primes the scanner baseline from the seeded tail without
   stamping; only a signal appearing in genuinely new output counts.

3. Cap re-anchoring accepts \r as well as \n (newline-free CR-redraw
   streams), consuming a full \r\n pair so the seed does not start with
   a phantom blank line.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
Jinwoo Hong
2026-08-04 17:23:01 -07:00
committed by GitHub
co-authored by OrcaWin
parent 3d8131d7ea
commit 51ca82d028
7 changed files with 685 additions and 3 deletions
@@ -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 {
+4 -1
View File
@@ -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 } : {})
}
}
+251 -1
View File
@@ -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<string, NodeJS.Platform>
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'
+39
View File
@@ -334,6 +334,39 @@ const pendingRuntimePaneCreatesByOwnerKey = new Map<string, PendingRuntimePaneCr
const agentSessionOwners = new ClaimedAgentPtyOwnerRegistry()
let agentSessionOwnerReconciliation: Promise<void> | 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?.(
+6
View File
@@ -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
}
}
+154
View File
@@ -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())
@@ -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<string, { pendingFacts: unknown[] }>
}
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()
})
})