fix(terminal): persist a parked remote pane's scrollback across a hard restart (#21295) (#21367)

* fix(terminal): route a parked pane's scrollback patch to the remote host's partition

A park capture changes only terminalLayoutsByTabId, so its debounced session
patch carries no tabsByWorktree. splitWorkspaceSessionByHost built its
tab->worktree index from the patch alone, resolved nothing, and routed every
layout to the 'local' partition, where main's pruneLocalTerminalScrollbackBuffers
strips scrollback it cannot attribute to a remote worktree. The remote host's
runtime:<id> partition never received the capture, so anything parked since the
last clean checkpoint was lost on a crash, SIGKILL, or a forced kill during an
app update (#21295).

Route tab-keyed patch fields with the renderer's live tab catalogs as a fallback
when the payload names no tab rows. Payload rows still win, so full-payload
writes are byte-identical. Once routed to runtime:<id>, main merges the
partition's own prior tabsByWorktree and the prune preserves.

Proven by tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts: a
hard kill (no checkpoint) then relaunch, asserting the capture is in the remote
host's partition on disk. Mutation: reverting the routing fix turns that
assertion red and fails the 3 catalog-dependent unit routing tests.

(cherry picked from commit 58a344c1d4)

* test(terminal): read both scrollback homes in the restart spec, and ratchet the resolver to the cap's home list

The restart spec read only buffersByLeafId, but the ordinary park now writes
localOnlyScrollbackByTabId, so its own proof reported a false zero and both tests failed for the
wrong reason. Both readers now go through resolveLeafScrollbackBuffers: the on-disk reader calls
it directly (it is a pure function), and the store reader — which runs inside page.evaluate —
reaches it through a new window.__terminalParkingDebug.resolveLeafScrollback(tabId) handle.

resolveTabScrollbackBuffers is typed off TERMINAL_SCROLLBACK_SESSION_HOMES and its unit test
enumerates that constant, so adding a third home fails to compile and fails a test until the
resolver reads it — the 'no consumer reads a home directly' invariant becomes enforceable.

The clean-quit control no longer asserts tokenAfterReveal (measured true, true, false on identical
product code; the live host can serve the reveal from its own tail). It keeps the five
deterministic fields and logs the reveal; the hard-kill test still asserts it, because there the
host is forced unavailable and the reveal must come from the client copy.

(cherry picked from commit 0cd3db1489)

* docs(persistence): pin why the local-only scrollback home stays outside full normalization

The two scrollback homes look symmetric (TERMINAL_SCROLLBACK_SESSION_HOMES), so the missing key
reads as an oversight. It is load-bearing: adding it would route the field through the fail-closed
strip and reintroduce the loss this branch fixes. The renderer prunes it with attribution before
the patch is sent, so the cap still holds without main as a second line.

(cherry picked from commit 1092e35b0a)
This commit is contained in:
Neil
2026-09-20 22:57:33 -07:00
committed by GitHub
parent f492054bf0
commit 98299d879b
11 changed files with 707 additions and 7 deletions
@@ -8,6 +8,11 @@ import {
type TerminalScrollbackSnapshotStorage
} from '../../terminal-scrollback-snapshots'
// Why localOnlyScrollbackByTabId is deliberately NOT here despite TERMINAL_SCROLLBACK_SESSION_HOMES
// pairing it with terminalLayoutsByTabId: full normalization reaches the fail-closed strip in
// workspace-session-terminal-buffers.ts, which drops a leaf whose worktree main cannot attribute —
// the renderer already capped that field with attribution in hand, and re-stripping it here is the
// scrollback loss the local-only home exists to prevent. Add it only together with that strip.
const WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS = new Set<keyof WorkspaceSessionState>([
'tabsByWorktree',
'terminalLayoutsByTabId'
@@ -1,5 +1,43 @@
import { describe, expect, it } from 'vitest'
import { resolveLeafScrollbackBuffers } from './leaf-scrollback-resolution'
import { TERMINAL_SCROLLBACK_SESSION_HOMES } from '../../../../shared/workspace-session-terminal-buffers'
import {
resolveLeafScrollbackBuffers,
resolveTabScrollbackBuffers,
type TerminalScrollbackSessionHomes
} from './leaf-scrollback-resolution'
// Why enumerated off the cap's constant: the invariant "no consumer reads a home directly" only
// holds while the resolver reads every home the session can persist. A third entry in
// TERMINAL_SCROLLBACK_SESSION_HOMES fails here (and the Pick type above) until the resolver reads it.
describe.each(TERMINAL_SCROLLBACK_SESSION_HOMES)('resolveTabScrollbackBuffers reads %s', (home) => {
const sessionWithBytesOnlyIn: Record<
(typeof TERMINAL_SCROLLBACK_SESSION_HOMES)[number],
TerminalScrollbackSessionHomes
> = {
terminalLayoutsByTabId: {
terminalLayoutsByTabId: {
'tab-1': {
root: null,
activeLeafId: null,
expandedLeafId: null,
buffersByLeafId: { 'leaf-1': 'from-shared' }
}
},
localOnlyScrollbackByTabId: {}
},
localOnlyScrollbackByTabId: {
terminalLayoutsByTabId: {},
localOnlyScrollbackByTabId: { 'tab-1': { 'leaf-1': 'from-local-only' } }
}
}
it('returns the bytes that home holds for the tab', () => {
expect(resolveTabScrollbackBuffers(sessionWithBytesOnlyIn[home], 'tab-1')).toEqual({
'leaf-1': home === 'terminalLayoutsByTabId' ? 'from-shared' : 'from-local-only'
})
expect(resolveTabScrollbackBuffers(sessionWithBytesOnlyIn[home], 'tab-other')).toBeUndefined()
})
})
describe('resolveLeafScrollbackBuffers', () => {
it('returns the shared layout buffers when nothing is held locally', () => {
@@ -1,4 +1,13 @@
import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
import type { TERMINAL_SCROLLBACK_SESSION_HOMES } from '../../../../shared/workspace-session-terminal-buffers'
/** The session fields a tab's scrollback can live in — typed off the same constant the cap
* enumerates, so a third home is a compile error here until this resolver reads it. */
export type TerminalScrollbackSessionHomes = Pick<
WorkspaceSessionState,
(typeof TERMINAL_SCROLLBACK_SESSION_HOMES)[number]
>
export type LeafScrollbackHomes = {
/** `TerminalLayoutSnapshot.buffersByLeafId` — shared with peers through the remote projection. */
@@ -20,3 +29,14 @@ export function resolveLeafScrollbackBuffers({
}
return sharedBuffers ? { ...sharedBuffers, ...localOnly } : localOnly
}
/** Same read, addressed by tab over a session-shaped record (the store or a parsed session file). */
export function resolveTabScrollbackBuffers(
session: Partial<TerminalScrollbackSessionHomes>,
tabId: string
): Record<string, string> | undefined {
return resolveLeafScrollbackBuffers({
shared: session.terminalLayoutsByTabId?.[tabId],
localOnly: session.localOnlyScrollbackByTabId?.[tabId]
})
}
@@ -4,6 +4,8 @@ import {
type TerminalColdParkPolicyOverrides
} from './terminal-hidden-view-parking'
import { getParkedTerminalWatcherTabIds } from './terminal-parked-tab-watchers'
import { resolveTabScrollbackBuffers } from './leaf-scrollback-resolution'
import { useAppStore } from '@/store'
export type TerminalWorktreeParkingDebugVerdict = {
worktreeId: string
@@ -51,6 +53,9 @@ export function registerTerminalParkingDebugHandle(): void {
parkDelayMs:
getTerminalParkingPolicyOverrides().coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS,
parkedTabIds: () => getParkedTerminalWatcherTabIds(),
// Why through the resolver: a spec that reads one store home directly reports a false zero
// whenever the bytes live in the other one.
resolveLeafScrollback: (tabId) => resolveTabScrollbackBuffers(useAppStore.getState(), tabId),
retentionLimit: getTerminalParkingPolicyOverrides().retentionLimit ?? null,
worktreeVerdicts: () => worktreeVerdicts
}
+2
View File
@@ -84,6 +84,8 @@ declare global {
__terminalParkingDebug?: {
parkDelayMs: number
parkedTabIds: () => string[]
/** A tab's scrollback across both store homes, via the one resolver production reads through. */
resolveLeafScrollback: (tabId: string) => Record<string, string> | undefined
retentionLimit: number | null
worktreeVerdicts: () => TerminalWorktreeParkingDebugVerdict[]
}
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope'
import {
@@ -482,6 +483,112 @@ describe('fetchWorkspaceSessionFromHosts', () => {
})
})
describe('patchWorkspaceSessionByHost tab-keyed routing', () => {
const remoteWorktreeId = 'remote-repo::/srv/remote'
const localWorktreeId = 'local-repo::/home/me/local'
const remoteTab = {
id: 'remote-tab',
ptyId: null,
worktreeId: remoteWorktreeId,
title: 'Remote',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
const parkedLayout: TerminalLayoutSnapshot = {
root: { type: 'leaf', leafId: 'leaf-1' },
activeLeafId: 'leaf-1',
expandedLeafId: null,
buffersByLeafId: { 'leaf-1': 'scrollback captured at park' }
}
const catalog = {
repos: [
{ id: 'local-repo', connectionId: null, executionHostId: 'local' },
{ id: 'remote-repo', connectionId: null, executionHostId: 'runtime:env-1' }
],
worktreesByRepo: {
'local-repo': [{ id: localWorktreeId, repoId: 'local-repo' }],
'remote-repo': [{ id: remoteWorktreeId, repoId: 'remote-repo', hostId: 'runtime:env-1' }]
}
} satisfies HostPersistenceState
it('routes a layouts-only patch to the partition of the tab the live catalog names', async () => {
// A park capture changes only terminalLayoutsByTabId, so the patch carries no tab rows. Routed
// by the payload alone it fell into 'local', where main strips scrollback it cannot attribute
// to a remote worktree — the runtime partition never received the capture (#21295).
const patch = vi.fn().mockResolvedValue(undefined)
await patchWorkspaceSessionByHost(
{ get: vi.fn(), patch, setSync: vi.fn() },
{ terminalLayoutsByTabId: { 'remote-tab': parkedLayout } },
{ ...catalog, tabsByWorktree: { [remoteWorktreeId]: [remoteTab] } }
)
expect(patch).toHaveBeenCalledWith(
{ terminalLayoutsByTabId: { 'remote-tab': parkedLayout } },
'runtime:env-1'
)
expect(patch).toHaveBeenCalledWith({ terminalLayoutsByTabId: {} })
})
it('routes a remote-session-id-only patch the same way', async () => {
const patch = vi.fn().mockResolvedValue(undefined)
await patchWorkspaceSessionByHost(
{ get: vi.fn(), patch, setSync: vi.fn() },
{ remoteSessionIdsByTabId: { 'remote-tab': 'sess-1' } },
{ ...catalog, tabsByWorktree: { [remoteWorktreeId]: [remoteTab] } }
)
expect(patch).toHaveBeenCalledWith(
{ remoteSessionIdsByTabId: { 'remote-tab': 'sess-1' } },
'runtime:env-1'
)
expect(patch).toHaveBeenCalledWith({ remoteSessionIdsByTabId: {} })
})
it('resolves a tab only the unified catalog lists', async () => {
const patch = vi.fn().mockResolvedValue(undefined)
await patchWorkspaceSessionByHost(
{ get: vi.fn(), patch, setSync: vi.fn() },
{ terminalLayoutsByTabId: { 'remote-tab': parkedLayout } },
{
...catalog,
unifiedTabsByWorktree: {
[remoteWorktreeId]: [{ id: 'remote-tab', worktreeId: remoteWorktreeId }]
}
}
)
expect(patch).toHaveBeenCalledWith(
{ terminalLayoutsByTabId: { 'remote-tab': parkedLayout } },
'runtime:env-1'
)
})
it("lets the payload's own tab row outrank the live catalog", async () => {
// Main merges the payload's tabsByWorktree into whichever partition it lands in, so the layout
// must follow the tab row in this write even when the store has since moved the tab.
const patch = vi.fn().mockResolvedValue(undefined)
await patchWorkspaceSessionByHost(
{ get: vi.fn(), patch, setSync: vi.fn() },
{
tabsByWorktree: { [localWorktreeId]: [{ ...remoteTab, worktreeId: localWorktreeId }] },
terminalLayoutsByTabId: { 'remote-tab': parkedLayout }
},
{ ...catalog, tabsByWorktree: { [remoteWorktreeId]: [remoteTab] } }
)
expect(patch).toHaveBeenCalledTimes(1)
expect(patch).toHaveBeenCalledWith(
expect.objectContaining({ terminalLayoutsByTabId: { 'remote-tab': parkedLayout } })
)
})
})
describe('buildHostIdByWorktreeId nested ownership', () => {
it('persists an SSH worktree in its paired HUB session partition', () => {
const worktreeId = 'nested-repo::/srv/remote-wt'
@@ -31,8 +31,13 @@ import {
indexWorkspaceRuntimeHostOwnership,
type WorkspaceRuntimeOwnerProjection
} from './workspace-runtime-host-ownership'
import {
buildWorktreeIdByTabId,
extendWorktreeIdByTabId,
type WorkspaceTabOwnerCatalog
} from '../../../shared/workspace-session-host-records'
export type HostPersistenceState = {
export type HostPersistenceState = WorkspaceTabOwnerCatalog & {
repos: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
projectGroups?: readonly { id: string; executionHostId?: string | null }[]
folderWorkspaces?: readonly {
@@ -214,7 +219,14 @@ function splitWorkspaceSessionForWrite(
mode: HostSessionWriteMode
): HostSessionSlices {
const routing = buildHostSessionRouting(state)
const slices = splitWorkspaceSessionByHost(payload, routing.hostIdByWorktreeId)
// Why the live catalogs: a debounced patch carries only the fields that changed, so a park
// capture's layouts-only patch names no tab rows. Routed by the payload alone, every tab-keyed
// row fell into 'local', where main pruned the scrollback it could not attribute to a remote
// worktree — the runtime partition never received the capture (#21295).
const worktreeIdByTabId = extendWorktreeIdByTabId(buildWorktreeIdByTabId(payload), state)
const slices = splitWorkspaceSessionByHost(payload, routing.hostIdByWorktreeId, {
worktreeIdByTabId
})
attachHostSessionShadow(slices, state.contestedHostWorkspaceSessions, routing.claims, mode)
return slices
}
@@ -210,6 +210,37 @@ describe('splitWorkspaceSessionByHost', () => {
expect(slices[RUNTIME_A]).toBeUndefined()
})
it('routes tab-keyed rows through a caller-supplied tab index when the payload has no tab rows', () => {
// A debounced patch that changed only layouts (a park capture) or only PTY bindings carries
// no tabsByWorktree; the index stands in for the rows the payload never mentioned.
const state: WorkspaceSessionState = {
...getDefaultWorkspaceSession(),
terminalLayoutsByTabId: { 't-a': makeLayout() },
remoteSessionIdsByTabId: { 't-a': 'sess-a' },
terminalPtyIncarnationsByPaneKey: { 't-a:leaf-1': 'inc-3' }
}
const slices = splitWorkspaceSessionByHost(state, ownerByPrefix(), {
worktreeIdByTabId: new Map([['t-a', 'a-wt-1']])
})
expect(slices[RUNTIME_A]?.terminalLayoutsByTabId).toHaveProperty('t-a')
expect(slices[RUNTIME_A]?.remoteSessionIdsByTabId).toEqual({ 't-a': 'sess-a' })
expect(slices[RUNTIME_A]?.terminalPtyIncarnationsByPaneKey).toEqual({ 't-a:leaf-1': 'inc-3' })
expect(slices[LOCAL_EXECUTION_HOST_ID]?.terminalLayoutsByTabId).toEqual({})
expect(slices[LOCAL_EXECUTION_HOST_ID]?.remoteSessionIdsByTabId).toEqual({})
})
it('keeps a tab the supplied index does not name in the local slice', () => {
const state: WorkspaceSessionState = {
...getDefaultWorkspaceSession(),
terminalLayoutsByTabId: { orphan: makeLayout() }
}
const slices = splitWorkspaceSessionByHost(state, ownerByPrefix(), {
worktreeIdByTabId: new Map([['t-a', 'a-wt-1']])
})
expect(slices[LOCAL_EXECUTION_HOST_ID]?.terminalLayoutsByTabId).toHaveProperty('orphan')
expect(slices[RUNTIME_A]).toBeUndefined()
})
it('routes browser pages via their record worktreeId', () => {
const state: WorkspaceSessionState = {
...getDefaultWorkspaceSession(),
@@ -147,11 +147,13 @@ function assignKeyedByResolvedWorktree(
/** Partition a unified session into per-host slices keyed by ExecutionHostId.
* Global fields are copied to the 'local' slice; worktree-scoped data is routed
* to its owner host. Entries whose owning worktree is unknown (orphan tabs,
* files, pages) stay in 'local' so they are never silently dropped. */
* to its owner host. Entries whose owning worktree is unknown to the payload and
* to `worktreeIdByTabId` (orphan tabs, files, pages) stay in 'local' so they are
* never silently dropped. */
export function splitWorkspaceSessionByHost(
state: WorkspaceSessionState,
hostIdByWorktreeId: HostIdByWorktreeId
hostIdByWorktreeId: HostIdByWorktreeId,
options: { worktreeIdByTabId?: Map<string, string> } = {}
): HostSessionSlices {
// Template carries only the global fields; per-field assigners add the rest.
// Why: copy only own-keys so a partial patch (where most globals are absent)
@@ -176,7 +178,7 @@ export function splitWorkspaceSessionByHost(
const ctx: SplitContext = {
hostIdByWorktreeId,
worktreeIdByTabId: buildWorktreeIdByTabId(state),
worktreeIdByTabId: options.worktreeIdByTabId ?? buildWorktreeIdByTabId(state),
worktreeIdByFileId: buildWorktreeIdByFileId(state)
}
@@ -24,6 +24,36 @@ export function buildWorktreeIdByTabId(state: WorkspaceSessionState): Map<string
return byTab
}
/** The renderer's live tab catalogs, for routing a payload that carries no tab rows of its own. */
export type WorkspaceTabOwnerCatalog = {
tabsByWorktree?: Readonly<Record<string, readonly { id: string }[]>>
unifiedTabsByWorktree?: Readonly<Record<string, readonly { id: string; worktreeId: string }[]>>
}
/** Fill tabs the payload never mentioned from the live catalogs. Payload rows win: main merges the
* payload's own `tabsByWorktree` into whichever partition it lands in, so a tab-keyed row has to
* follow the tab row in THIS write, not a newer store state the debounce has not emitted yet. */
export function extendWorktreeIdByTabId(
byTab: Map<string, string>,
catalog: WorkspaceTabOwnerCatalog | undefined
): Map<string, string> {
for (const [worktreeId, tabs] of Object.entries(catalog?.tabsByWorktree ?? {})) {
for (const tab of tabs) {
if (!byTab.has(tab.id)) {
byTab.set(tab.id, worktreeId)
}
}
}
for (const tabs of Object.values(catalog?.unifiedTabsByWorktree ?? {})) {
for (const tab of tabs) {
if (!byTab.has(tab.id)) {
byTab.set(tab.id, tab.worktreeId)
}
}
}
return byTab
}
/** The workspace a pane key belongs to. A pane key is `<tabId>:<leafId>`; both the split and the
* stranded-partition adoption resolve it here so neither can parse it its own way. */
export function worktreeIdForPaneKey(
@@ -0,0 +1,448 @@
/**
* Does a parked remote pane's scrollback survive a full client restart?
*
* The within-session case is covered by paired-remote-terminal-parked-scrollback-survives.spec.ts.
* This restarts the client on the SAME profile — the shape of an app update — and reports, hop by
* hop, where the park capture lands.
*
* The load-bearing oracle is ON DISK, not the reveal. The paired host's pty stays alive across the
* client restart and keeps a retained tail (terminal-multiplex-initial-snapshot:
* `data: serialized?.data ?? read.tail…`), so a reveal can be served by the host rather than by the
* client's persisted copy — it is kept as an end-to-end check, not the proof. The contradiction-
* capable signal for this fix is which partition on disk holds the buffer after a debounced write.
*
* Two quits, because they exercise different writers:
* - clean quit: the beforeunload checkpoint writes a full per-partition snapshot, which carries
* tabsByWorktree and so routes the layout correctly even on `main` — a control that the harness
* works, not a proof of this fix.
* - hard kill: only the debounced patch writer ran. A park capture's patch changes only
* terminalLayoutsByTabId, so it carries no tab rows; before the fix every layout fell into the
* 'local' partition, where main strips scrollback it cannot attribute to a remote worktree, and
* the remote host's partition never received the capture (#21295). This is the test that fails
* with the fix reverted.
*
* The on-disk reader walks the local `workspaceSession` AND every `workspaceSessionsByHostId`
* partition, and names the partition each reading came from — the issue's original "onDisk: 0" was a
* reader that inspected only the local blob while the capture sat in the runtime partition, a
* reading that could not contradict itself. An empty list means no session file at all (a deleted
* profile), distinguished from an empty buffer.
*
* Run:
* pnpm exec playwright test \
* tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts \
* --config tests/playwright.config.ts --project electron-headless --workers=1
*/
import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { randomUUID } from 'node:crypto'
import os from 'node:os'
import path from 'node:path'
import type { Page, TestInfo } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { resolveLeafScrollbackBuffers } from '../../src/renderer/src/components/terminal-pane/leaf-scrollback-resolution'
import {
createRuntimeDesktopPairingOffer,
launchPairedElectronClient,
type PairedElectronClient
} from './helpers/paired-electron-client'
import { cleanupE2EDaemons, forceQuitElectronAppForE2E } from './helpers/electron-process-shutdown'
import {
callEnvironment,
createPairedHostTerminal,
openPairedClientTab,
waitForPairedPaneMarker
} from './helpers/paired-host-terminal'
import { focusActiveTerminalInput } from './helpers/terminal'
import { waitForTabParked } from './helpers/terminal-hidden-parking'
const PARK_DELAY_MS = 2_000
const PAINT_BUDGET_MS = 30_000
/** Renderer debounce (150 ms) + main's save debounce (1 s, 5 s max wait), with slack. */
const DEBOUNCED_WRITE_BUDGET_MS = 20_000
const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-parked-restart-'))
const fixturePath = path.join(scratch, 'echo-terminal.mjs')
writeFileSync(
fixturePath,
[
"process.stdout.write('READY\\r\\n')",
"process.stdin.setEncoding('utf8')",
"let pending = ''",
"process.stdin.on('data', (data) => {",
' pending += data',
' const lines = pending.split(/\\r\\n|\\r|\\n/)',
" pending = lines.pop() ?? ''",
' for (const line of lines) {',
' process.stdout.write(`LINE:${line}\\r\\n`)',
' }',
'})',
'process.stdin.resume()'
].join('\n')
)
test.afterAll(() => {
rmSync(scratch, { recursive: true, force: true })
})
function fixtureCommand(): string {
const command = [process.execPath, fixturePath]
return process.platform === 'win32'
? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ')
: command.map((value) => `'${value.replaceAll("'", `'\\''`)}'`).join(' ')
}
type OnDiskPartitionReading = {
partition: string
hasTabRow: boolean
hasLayout: boolean
bufferLength: number
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function readSessionPartition(
partition: string,
session: unknown,
webTabId: string
): OnDiskPartitionReading | null {
if (!isRecord(session)) {
return null
}
const tabsByWorktree = isRecord(session.tabsByWorktree) ? session.tabsByWorktree : {}
const hasTabRow = Object.values(tabsByWorktree).some(
(tabs) => Array.isArray(tabs) && tabs.some((tab) => isRecord(tab) && tab.id === webTabId)
)
const layouts = isRecord(session.terminalLayoutsByTabId) ? session.terminalLayoutsByTabId : {}
const layout = layouts[webTabId]
const hasLayout = isRecord(layout)
const localOnlyHomes = isRecord(session.localOnlyScrollbackByTabId)
? session.localOnlyScrollbackByTabId
: {}
// Why the resolver: an ordinary park writes localOnlyScrollbackByTabId, not buffersByLeafId, so
// a reader of one home reports a false zero. Same read production restores through.
const buffers = resolveLeafScrollbackBuffers({
shared: hasLayout ? { buffersByLeafId: stringRecord(layout.buffersByLeafId) } : undefined,
localOnly: stringRecord(localOnlyHomes[webTabId])
})
const bufferLength = Object.values(buffers ?? {}).join('').length
return { partition, hasTabRow, hasLayout, bufferLength }
}
function stringRecord(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) {
return undefined
}
return Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
)
}
/** Walks the local `workspaceSession` and every `workspaceSessionsByHostId` partition. An empty
* list means no session file was found at all — a reader problem, not an empty buffer. */
function readOnDiskPartitions(userDataDir: string, webTabId: string): OnDiskPartitionReading[] {
const readings: OnDiskPartitionReading[] = []
for (const file of globSync(path.join(userDataDir, '**', 'orca-data.json'))) {
try {
const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
if (!isRecord(parsed)) {
continue
}
const local = readSessionPartition('local', parsed.workspaceSession, webTabId)
if (local) {
readings.push(local)
}
const partitions = isRecord(parsed.workspaceSessionsByHostId)
? parsed.workspaceSessionsByHostId
: {}
for (const [hostId, session] of Object.entries(partitions)) {
const reading = readSessionPartition(hostId, session, webTabId)
if (reading) {
readings.push(reading)
}
}
} catch {
// A partially written profile is itself a datapoint; keep scanning the rest.
}
}
return readings
}
/** Bytes the remote host's own partition holds for the tab; -1 when no partition names it. */
function runtimePartitionBufferLength(readings: OnDiskPartitionReading[]): number {
const runtime = readings.filter((reading) => reading.partition.startsWith('runtime:'))
return runtime.length === 0 ? -1 : Math.max(...runtime.map((reading) => reading.bufferLength))
}
/** Bytes any 'local' partition reading holds for the tab; the pre-fix bug parked a stripped (0) or
* buffered copy here instead of in the remote host's partition. */
function localPartitionBufferLength(readings: OnDiskPartitionReading[]): number {
const local = readings.filter((reading) => reading.partition === 'local')
return local.length === 0 ? -1 : Math.max(...local.map((reading) => reading.bufferLength))
}
async function waitForRuntimePartitionCapture(
userDataDir: string,
webTabId: string
): Promise<OnDiskPartitionReading[]> {
const deadline = Date.now() + DEBOUNCED_WRITE_BUDGET_MS
let readings = readOnDiskPartitions(userDataDir, webTabId)
while (runtimePartitionBufferLength(readings) <= 0 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 500))
readings = readOnDiskPartitions(userDataDir, webTabId)
}
return readings
}
async function readStoreBufferLength(page: Page, webTabId: string): Promise<number> {
// Why the debug handle: this body runs in the renderer, so it reaches the resolver through the
// e2e handle rather than reading either store home directly.
return page.evaluate((id) => {
const buffers = window.__terminalParkingDebug?.resolveLeafScrollback(id)
return Object.values(buffers ?? {}).join('').length
}, webTabId)
}
async function activateWorktree(page: Page, worktreeId: string): Promise<void> {
await page.evaluate((id) => {
const state = window.__store?.getState()
state?.setActiveView('terminal')
state?.setActiveWorktree(id)
}, worktreeId)
}
type ParkedRemoteTerminal = {
client: PairedElectronClient
worktreeId: string
webTabId: string
token: string
tokenBeforePark: boolean
storeAtPark: number
}
/** Open a remote-runtime terminal on the paired client, type a token into it, and cold-park it
* behind two decoy tabs. The token exists only in that pane's buffer — nothing replays stdin.
* Every host terminal it creates is pushed into `createdTerminals` as it is created, so the
* caller's `finally` can close them even if this throws partway. */
async function parkRemoteTerminalWithToken(
orcaPage: Page,
client: PairedElectronClient,
createdTerminals: string[]
): Promise<ParkedRemoteTerminal> {
const worktreeId = await orcaPage.evaluate(() => {
const id = window.__store?.getState().activeWorktreeId
if (!id) {
throw new Error('headed host has no active worktree')
}
return id
})
await expect
.poll(
() =>
client.page.evaluate(
(id) =>
window.__store
?.getState()
.allWorktrees()
.some((worktree) => worktree.id === id) ?? false,
worktreeId
),
{ timeout: 60_000, message: 'paired client never saw the host worktree' }
)
.toBe(true)
await activateWorktree(client.page, worktreeId)
const target = await createPairedHostTerminal(
client.page,
client.environmentId,
worktreeId,
fixtureCommand()
)
createdTerminals.push(target.terminal)
const decoys = []
for (let index = 0; index < 2; index += 1) {
const decoy = await createPairedHostTerminal(
client.page,
client.environmentId,
worktreeId,
fixtureCommand()
)
createdTerminals.push(decoy.terminal)
decoys.push(decoy)
}
await openPairedClientTab(client.page, worktreeId, target.webTabId)
await waitForPairedPaneMarker(client.page, target.webTabId, 'READY', PAINT_BUDGET_MS)
const token = `LINE:token-${randomUUID()}`
await focusActiveTerminalInput(client.page)
await client.page.keyboard.type(token.slice('LINE:'.length))
await client.page.keyboard.press('Enter')
const tokenBeforePark = await waitForPairedPaneMarker(
client.page,
target.webTabId,
token,
PAINT_BUDGET_MS
)
for (const decoy of decoys) {
await openPairedClientTab(client.page, worktreeId, decoy.webTabId)
}
await waitForTabParked(client.page, target.webTabId, { parkDelayMs: PARK_DELAY_MS })
const storeAtPark = await readStoreBufferLength(client.page, target.webTabId)
return { client, worktreeId, webTabId: target.webTabId, token, tokenBeforePark, storeAtPark }
}
async function relaunchAndReveal(
offer: Awaited<ReturnType<typeof createRuntimeDesktopPairingOffer>>,
testInfo: TestInfo,
parked: ParkedRemoteTerminal,
extraEnv: Record<string, string>
): Promise<{
relaunched: PairedElectronClient
storeAfterRelaunch: number
tokenAfterReveal: boolean
}> {
const relaunched = await launchPairedElectronClient(offer, testInfo, 'parked-restart-relaunch', {
extraEnv,
reuseUserDataDir: parked.client.userDataDir
})
await activateWorktree(relaunched.page, parked.worktreeId)
const storeAfterRelaunch = await readStoreBufferLength(relaunched.page, parked.webTabId)
await openPairedClientTab(relaunched.page, parked.worktreeId, parked.webTabId)
const tokenAfterReveal = await waitForPairedPaneMarker(
relaunched.page,
parked.webTabId,
parked.token,
PAINT_BUDGET_MS
)
return { relaunched, storeAfterRelaunch, tokenAfterReveal }
}
async function closeCreatedTerminals(
client: PairedElectronClient,
createdTerminals: readonly string[]
): Promise<void> {
for (const terminal of createdTerminals) {
await callEnvironment(client.page, client.environmentId, 'terminal.closeTab', {
terminal
}).catch(() => undefined)
}
}
test.describe('host retains nothing', () => {
test.use({
orcaAppExtraEnv: { ORCA_E2E_FORCE_REMOTE_TERMINAL_SNAPSHOT_UNAVAILABLE: '1' }
})
test('the debounced write alone lands the parked scrollback in the remote hosts partition, so a hard kill loses nothing', async ({
orcaPage
}, testInfo) => {
test.setTimeout(600_000)
const offer = await createRuntimeDesktopPairingOffer(orcaPage)
const extraEnv = { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARK_DELAY_MS) }
const first = await launchPairedElectronClient(offer, testInfo, 'parked-kill', { extraEnv })
const userDataDir = first.userDataDir
const createdTerminals: string[] = []
let relaunched: PairedElectronClient | null = null
try {
const parked = await parkRemoteTerminalWithToken(orcaPage, first, createdTerminals)
// Why no beforeunload and no ordinary close: both run the shutdown checkpoint, whose full
// snapshot routes the layout correctly and would mask a misrouted debounced patch. Only the
// debounced patch writer runs between the park and this kill.
const onDiskAfterPark = await waitForRuntimePartitionCapture(userDataDir, parked.webTabId)
await forceQuitElectronAppForE2E(first.app)
await cleanupE2EDaemons(userDataDir)
const reveal = await relaunchAndReveal(offer, testInfo, parked, extraEnv)
relaunched = reveal.relaunched
console.log(
`[parked-restart] hard-kill ${JSON.stringify({
tokenBeforePark: parked.tokenBeforePark,
storeAtPark: parked.storeAtPark,
onDiskAfterPark,
storeAfterRelaunch: reveal.storeAfterRelaunch,
tokenAfterReveal: reveal.tokenAfterReveal
})}`
)
expect({
tokenBeforePark: parked.tokenBeforePark,
capturedAtPark: parked.storeAtPark > 0,
profileSurvived: onDiskAfterPark.length > 0,
// The load-bearing assertion: the debounced writer routed the capture to the remote host's
// own partition, and did not leave it stripped in 'local'. This is what fails on `main`.
runtimePartitionHoldsCapture: runtimePartitionBufferLength(onDiskAfterPark) > 0,
localPartitionDidNotKeepCapture: localPartitionBufferLength(onDiskAfterPark) <= 0,
// Secondary: the reveal may be served by the live host's retained tail rather than the
// disk copy, so it is not the fix's oracle — it confirms relaunch and a visible pane.
tokenAfterReveal: reveal.tokenAfterReveal
}).toEqual({
tokenBeforePark: true,
capturedAtPark: true,
profileSurvived: true,
runtimePartitionHoldsCapture: true,
localPartitionDidNotKeepCapture: true,
tokenAfterReveal: true
})
} finally {
const live = relaunched ?? first
await closeCreatedTerminals(live, createdTerminals)
await live.dispose().catch(() => undefined)
}
})
test('a clean quit also lands the parked scrollback in the remote hosts partition (control)', async ({
orcaPage
}, testInfo) => {
test.setTimeout(600_000)
const offer = await createRuntimeDesktopPairingOffer(orcaPage)
const extraEnv = { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARK_DELAY_MS) }
const first = await launchPairedElectronClient(offer, testInfo, 'parked-restart', { extraEnv })
const userDataDir = first.userDataDir
const createdTerminals: string[] = []
let relaunched: PairedElectronClient | null = null
try {
const parked = await parkRemoteTerminalWithToken(orcaPage, first, createdTerminals)
await first.page.evaluate(() => window.dispatchEvent(new Event('beforeunload')))
await first.quitPreservingProfile()
const onDiskAfterQuit = readOnDiskPartitions(userDataDir, parked.webTabId)
const reveal = await relaunchAndReveal(offer, testInfo, parked, extraEnv)
relaunched = reveal.relaunched
console.log(
`[parked-restart] clean-quit ${JSON.stringify({
tokenBeforePark: parked.tokenBeforePark,
storeAtPark: parked.storeAtPark,
onDiskAfterQuit,
storeAfterRelaunch: reveal.storeAfterRelaunch,
tokenAfterReveal: reveal.tokenAfterReveal
})}`
)
// Why tokenAfterReveal is logged and not asserted here: this control proves the harness
// (park, quit, profile, partition routing), not the reveal. On identical product code the
// clean-quit reveal measured true, true, false across three runs — the live host may serve
// it from its own tail — so it cannot carry an assertion. The hard-kill test keeps it,
// because there the host is forced unavailable and the reveal must come from the client copy.
expect({
tokenBeforePark: parked.tokenBeforePark,
capturedAtPark: parked.storeAtPark > 0,
// Distinguishes a deleted profile (no partitions) from an empty buffer.
profileSurvived: onDiskAfterQuit.length > 0,
runtimePartitionHoldsCapture: runtimePartitionBufferLength(onDiskAfterQuit) > 0,
localPartitionDidNotKeepCapture: localPartitionBufferLength(onDiskAfterQuit) <= 0
}).toEqual({
tokenBeforePark: true,
capturedAtPark: true,
profileSurvived: true,
runtimePartitionHoldsCapture: true,
localPartitionDidNotKeepCapture: true
})
} finally {
const live = relaunched ?? first
await closeCreatedTerminals(live, createdTerminals)
await live.dispose().catch(() => undefined)
}
})
})