fix(terminal): harden remote parking under load

This commit is contained in:
Neil
2026-07-29 03:52:26 -07:00
parent 4543bb6826
commit 95e0606fd0
23 changed files with 697 additions and 103 deletions
+13 -5
View File
@@ -93,6 +93,7 @@ import {
} from './terminal-pane/terminal-hidden-view-parking'
import {
TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS,
selectTerminalLayoutPaneCountByTabId,
selectForceParkEvictableTabIds,
selectRetentionForceParkedTerminalWorktrees,
type TerminalWorktreeRetentionCandidate
@@ -293,9 +294,11 @@ function Terminal(): React.JSX.Element | null {
)
const activeView = useAppStore((s) => s.activeView)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const terminalLayoutPaneCountByTabId = useAppStore(selectTerminalLayoutPaneCountByTabId)
const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId)
const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false)
const terminalSshParkingEnabled = useAppStore((s) => s.settings?.terminalSshViewParking !== false)
const terminalScrollbackRows = useAppStore((s) => s.settings?.terminalScrollbackRows)
const terminalRetentionBudgetEnabled = useAppStore(
(s) => s.settings?.terminalHiddenWorktreeRetentionBudget !== false
)
@@ -978,8 +981,8 @@ function Terminal(): React.JSX.Element | null {
nextParkedTerminalWorktreeIds.delete(worktreeId)
}
}
// C1 retention budget: worktrees ordinary parking can never evict (SSH off,
// remote-runtime, uncoverable tabs) force-park beyond a count/TTL bound —
// Worktrees ordinary parking cannot evict (SSH off, remote-runtime,
// uncoverable tabs) force-park beyond the pane-weight budget or TTL —
// added AFTER the coverage veto because darkness for their uncoverable tabs
// is the accepted cost of bounding retention.
const retentionBudgetCandidates: TerminalWorktreeRetentionCandidate[] = retentionCandidates.map(
@@ -1014,6 +1017,10 @@ function Terminal(): React.JSX.Element | null {
pendingStartupByTabId[tab.id] !== undefined ||
tab.pendingActivationSpawn === true ||
(typeof tab.pendingActivationSpawn === 'number' && tab.pendingActivationSpawn > 0)
),
retainedPaneCount: tabs.reduce(
(count, tab) => count + (terminalLayoutPaneCountByTabId[tab.id] ?? 1),
0
)
}
}
@@ -1023,6 +1030,7 @@ function Terminal(): React.JSX.Element | null {
parkingEnabled: terminalParkingEnabled,
retentionBudgetEnabled: terminalRetentionBudgetEnabled,
nowMs,
scrollbackRows: terminalScrollbackRows,
...overrides
})
const capturedForceParked = forceParkedCaptureDoneRef.current
@@ -1047,9 +1055,7 @@ function Terminal(): React.JSX.Element | null {
// uses), so SSH classes whose reveal has no local snapshot still show
// last-known content. includeLocalBuffers:false is required here, not
// optional — a heap fix must not plant 512KB/pane of scrollback strings
// in the store for local worktrees that already have the daemon
// snapshot; a remote-runtime pane in a local repo likewise needs none,
// its reveal repaints from the runtime's own subscribe snapshot.
// in the store when the daemon/runtime already owns the snapshot.
// Eviction-exempt tabs never unmount (per-tab exclusion), so they need
// no pre-unmount capture — skipping spares a serialize+setTabLayout
// walk on live panes per force-park episode.
@@ -1128,9 +1134,11 @@ function Terminal(): React.JSX.Element | null {
pendingStartupByTabId,
renderedActiveWorktreeId,
tabsByWorktree,
terminalLayoutPaneCountByTabId,
terminalParkingEnabled,
terminalParkingRevision,
terminalRetentionBudgetEnabled,
terminalScrollbackRows,
terminalSshParkingEnabled,
workspaceSurfaces
])
@@ -127,7 +127,10 @@ import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution
import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops'
import { applyDesktopFitFallbackAfterReplay } from './desktop-fit-fallback'
import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear'
import { captureTerminalShutdownLayout } from './terminal-shutdown-layout-capture'
import {
captureTerminalShutdownLayout,
selectRemoteRuntimeScrollbackLeafIds
} from './terminal-shutdown-layout-capture'
import { getOverrideAffectedPanes, getPanesNeedingOverrideFit } from './override-affected-panes'
import {
inspectRuntimeTerminalProcess,
@@ -200,7 +203,10 @@ function isInsideNativeChatRoot(target: EventTarget | null): boolean {
}
// Why: registry lives in a leaf module to break the slice → TerminalPane → store → slice import cycle that leaves createTerminalSlice undefined at init.
import { shutdownBufferCaptures } from './shutdown-buffer-captures'
import {
shutdownBufferCaptures,
type ShutdownBufferCaptureOptions
} from './shutdown-buffer-captures'
import { mergeCapturedLeafState } from './merge-captured-leaf-state'
import { pasteTerminalText } from './terminal-bracketed-paste'
import {
@@ -2370,7 +2376,7 @@ function TerminalPane(
// Register a shutdown capture callback; App.tsx's beforeunload handler invokes all to serialize terminal buffers.
useEffect(() => {
const captureBuffers = (options?: { includeLocalBuffers?: boolean }): void => {
const captureBuffers = (options?: ShutdownBufferCaptureOptions): void => {
const manager = managerRef.current
const container = containerRef.current
if (!manager || !container) {
@@ -2384,19 +2390,29 @@ function TerminalPane(
const state = useAppStore.getState()
const existing = state.terminalLayoutsByTabId[tabId]
const includeLocalBuffers = options?.includeLocalBuffers ?? true
const paneTransports = paneTransportsRef.current
const shouldCaptureScrollbackBuffers = includeLocalBuffers
? true
: shouldPreserveTerminalScrollbackBuffers(worktreeId, state.repos)
const excludedScrollbackLeafIds =
options?.excludeRemoteRuntimeScrollback === true
? selectRemoteRuntimeScrollbackLeafIds({
panes,
paneTransports,
existingLayout: existing
})
: undefined
const layout = captureTerminalShutdownLayout({
manager,
container,
expandedPaneId: expandedPaneIdRef.current,
paneTransports: paneTransportsRef.current,
paneTransports,
paneTitlesByPaneId: paneTitlesRef.current,
existingLayout: existing,
// Why: beforeunload skips local/floating bytes (session payloads prune them); worktree sleep keeps them as defense-in-depth.
captureBuffers: shouldCaptureScrollbackBuffers,
clearedScrollbackLeafIds: clearedScrollbackLeafIdsRef.current
clearedScrollbackLeafIds: clearedScrollbackLeafIdsRef.current,
excludedScrollbackLeafIds
})
setTabLayout(tabId, layout)
for (const pane of panes) {
@@ -44,7 +44,10 @@ describe('captureForceParkedWorktreeBuffers', () => {
})
expect(captured).toBe(true)
expect(capture).toHaveBeenCalledWith({ includeLocalBuffers: false })
expect(capture).toHaveBeenCalledWith({
includeLocalBuffers: false,
excludeRemoteRuntimeScrollback: true
})
})
it('reports an incomplete episode when a tab has no registered capture', () => {
@@ -23,7 +23,8 @@ export function captureForceParkedWorktreeBuffers({
return true
}
const { requested, captured } = captureTerminalShutdownBuffersBestEffort(tabIds, {
includeLocalBuffers: false
includeLocalBuffers: false,
excludeRemoteRuntimeScrollback: true
})
return captured === requested
}
@@ -192,6 +192,7 @@ type StoreState = {
localWindowsRuntimeDefault?: { kind: 'windows-host' } | { kind: 'wsl'; distro: string | null }
terminalMainSideEffectAuthority?: boolean
terminalHiddenDeliveryGate?: boolean
terminalSshViewParking?: boolean
notifications?: {
enabled?: boolean
agentTaskComplete?: boolean
@@ -19896,9 +19897,13 @@ describe('connectPanePty', () => {
const pane = createPane(1)
const { writes } = captureCallbackTerminalWrites(pane)
pane.terminal.write(
`captured-early-marker\r\n${Array.from({ length: 12 }, (_, index) => `captured-${index}\r\n`).join('')}`
)
const deps = createDeps({
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId }
restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId },
restoredViewportBlankingPanesRef: { current: new Set([1]) }
})
connectPanePty(pane as never, createManager(1) as never, deps as never)
@@ -19913,6 +19918,11 @@ describe('connectPanePty', () => {
await flushAsyncTicks(20)
expect(writes).toContain('relay-fallback-output')
expect(writes).toContain('\x1b[2J\x1b[H')
expect(writes).not.toContain('\x1b[2J\x1b[3J\x1b[H')
expect((await renderHeadlessBuffer(writes, 80, 6)).join('\n')).toContain(
'captured-early-marker'
)
// Why: a stalled reveal must cost exactly one bounded probe — a re-probe
// would buy a second timeout window before the relay paint.
expect(window.api.pty.getMainBufferSnapshot).toHaveBeenCalledTimes(1)
@@ -19954,6 +19964,47 @@ describe('connectPanePty', () => {
expect(writes).toContain('relay-reconnect-output')
})
it('preserves restored scrollback through relay replay without a parked watcher', async () => {
const { connectPanePty } = await import('./pty-connection')
const sshPtyId = toAppSshPtyId('conn-1', 'relay-pty-1')
const transport = createMockTransport(sshPtyId)
transport.connect.mockImplementation(async () => {
transport.getPtyId.mockReturnValue(sshPtyId)
return { id: sshPtyId, isReattach: true, replay: 'relay-reconnect-output' }
})
transportFactoryQueue.push(transport)
mockStoreState = {
...mockStoreState,
settings: { ...mockStoreState.settings, terminalSshViewParking: false },
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
repos: [{ id: 'repo1', connectionId: 'conn-1' }],
deferredSshReconnectTargets: ['conn-1'],
deferredSshSessionIdsByTabId: { 'tab-1': sshPtyId }
}
const pane = createPane(1)
const { writes } = captureCallbackTerminalWrites(pane)
pane.terminal.write(
`captured-early-marker\r\n${Array.from({ length: 12 }, (_, index) => `captured-${index}\r\n`).join('')}`
)
const deps = createDeps({
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId },
restoredViewportBlankingPanesRef: { current: new Set([1]) }
})
connectPanePty(pane as never, createManager(1) as never, deps as never)
await flushAsyncTicks(20)
expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled()
expect(writes).toContain('\x1b[2J\x1b[H')
expect(writes).not.toContain('\x1b[2J\x1b[3J\x1b[H')
expect((await renderHeadlessBuffer(writes, 80, 6)).join('\n')).toContain(
'captured-early-marker'
)
})
it('does not auto-reconnect after a user cancels deferred SSH passphrase auth', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
@@ -7663,6 +7663,8 @@ export function connectPanePty(
const revealFollowsTerminalPark =
mountFollowsTerminalPark && connectResult?.isReattach === true
mountFollowsTerminalPark = false
const hasRestoredScrollback =
deps.restoredViewportBlankingPanesRef?.current.has(pane.id) ?? false
// Why: a relay restart empties the replay buffer, but main's model may
// still hold the session — a park-reveal probes it even with no replay
// so the reveal is never blank when main has content. Prefetched (before
@@ -7769,8 +7771,8 @@ export function connectPanePty(
}
} else if (connectResult?.replay) {
rememberReattachPayloadAgentSignal(connectResult.replay, { fullScreenReplay: true })
// Relay replay may overlap xterm's pre-disconnect content; clear first to avoid duplication.
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
// A restored capture may be the only history outside the relay's 100KiB tail.
writeReplayData(hasRestoredScrollback ? '\x1b[2J\x1b[H' : '\x1b[2J\x1b[3J\x1b[H')
// Why: raw relay replay may contain the app's own kitty pushes; re-arm with set semantics so redelivery can't grow the stack.
kittyKeyboardModes.scanReplay(connectResult.replay)
writeReplayData(connectResult.replay)
@@ -929,6 +929,96 @@ describe('createIpcPtyTransport', () => {
}
})
it('preserves the latest agent lifecycle transition through queue eviction', async () => {
vi.useFakeTimers()
try {
const { createPtyOutputProcessor, MAX_PENDING_PTY_SIDE_EFFECTS } =
await import('./pty-transport')
const onAgentBecameWorking = vi.fn()
const onAgentBecameIdle = vi.fn()
const processor = createPtyOutputProcessor({
onTitleChange: vi.fn(),
onAgentBecameWorking,
onAgentBecameIdle
})
const callbacks = { onData: vi.fn() }
processor.processData('\x1b]0;. Claude working\x07', callbacks)
for (let i = 0; i < MAX_PENDING_PTY_SIDE_EFFECTS; i++) {
processor.processData(`\x1b]0;shell-title-${i}\x07`, callbacks)
}
processor.processData('\x1b]0;* Claude idle\x07', callbacks)
processor.flushPendingSideEffects()
expect(onAgentBecameWorking).toHaveBeenCalledTimes(1)
expect(onAgentBecameIdle).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('resets stale-title timing when a working title survives queue eviction', async () => {
vi.useFakeTimers()
try {
const { createPtyOutputProcessor, MAX_PENDING_PTY_SIDE_EFFECTS } =
await import('./pty-transport')
const onAgentBecameIdle = vi.fn()
const processor = createPtyOutputProcessor({
onTitleChange: vi.fn(),
onAgentBecameWorking: vi.fn(),
onAgentBecameIdle,
onAgentStatus: vi.fn()
})
const callbacks = { onData: vi.fn() }
processor.processData('\x1b]0;. Claude working\x07', callbacks)
await vi.advanceTimersByTimeAsync(0)
processor.processData('plain output\r\n', callbacks)
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(2_900)
processor.processData('\x1b]0;. Claude working\x07', callbacks)
for (let i = 0; i < MAX_PENDING_PTY_SIDE_EFFECTS; i++) {
processor.processData(`\x1b]9999;{"state":"working","prompt":"${i}"}\x07`, callbacks)
}
processor.flushPendingSideEffects()
await vi.advanceTimersByTimeAsync(100)
expect(onAgentBecameIdle).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('preserves an agent exit title through queue eviction', async () => {
vi.useFakeTimers()
try {
const { createPtyOutputProcessor, MAX_PENDING_PTY_SIDE_EFFECTS } =
await import('./pty-transport')
const onAgentExited = vi.fn()
const processor = createPtyOutputProcessor({
onTitleChange: vi.fn(),
onAgentBecameWorking: vi.fn(),
onAgentBecameIdle: vi.fn(),
onAgentExited,
onAgentStatus: vi.fn()
})
const callbacks = { onData: vi.fn() }
processor.processData('\x1b]0;. Claude working\x07', callbacks)
processor.processData('\x1b]0;* Claude idle\x07', callbacks)
processor.processData('\x1b]0;shell prompt\x07', callbacks)
for (let i = 0; i < MAX_PENDING_PTY_SIDE_EFFECTS; i++) {
processor.processData(`\x1b]9999;{"state":"working","prompt":"${i}"}\x07`, callbacks)
}
processor.flushPendingSideEffects()
expect(onAgentExited).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('collapses evicted agent-status payloads onto the survivor, keeping the newest', async () => {
vi.useFakeTimers()
try {
@@ -108,6 +108,11 @@ type PendingPtySideEffect = {
suppressAttentionEvents: boolean
}
type EvictedAgentTitleTransition = {
phase: 'working' | 'settled' | 'cleared'
title: string
}
function isIgnoredCursorNativeTitle(title: string): boolean {
return title.trim().toLowerCase() === 'cursor agent'
}
@@ -165,6 +170,7 @@ export function createPtyOutputProcessor({
let pendingSideEffects: PendingPtySideEffect[] = []
let pendingSideEffectIndex = 0
let pendingWorkingTitleSideEffects = 0
let evictedAgentTitleTransitions: EvictedAgentTitleTransition[] = []
const agentTracker =
onAgentBecameIdle || onAgentBecameWorking || onAgentExited
? createAgentStatusTracker(
@@ -199,6 +205,37 @@ export function createPtyOutputProcessor({
}
}
function carryEvictedAgentTitleTransitions(next: PendingPtySideEffect): void {
if (!agentTracker || next.suppressAttentionEvents) {
return
}
for (const title of next.titles) {
const status = detectAgentStatusFromTitle(title)
const phase = status === 'working' ? 'working' : status === null ? 'cleared' : 'settled'
const prior = evictedAgentTitleTransitions.at(-1)
if (prior?.phase === phase) {
prior.title = title
continue
}
evictedAgentTitleTransitions.push({ phase, title })
if (evictedAgentTitleTransitions.length > 3) {
evictedAgentTitleTransitions.shift()
}
}
}
function flushEvictedAgentTitleTransitions(): void {
const transitions = evictedAgentTitleTransitions
evictedAgentTitleTransitions = []
if (transitions.length > 0) {
// Carried titles are fresh observations; an older fallback must not clear them.
clearStaleTitleTimer()
}
for (const transition of transitions) {
applyObservedTerminalTitle(transition.title)
}
}
function clearStaleTitleTimer(): void {
if (staleTitleTimer) {
clearTimeout(staleTitleTimer)
@@ -214,9 +251,8 @@ export function createPtyOutputProcessor({
sideEffectDrainTimer = setTimeout(drainPtySideEffects, 0)
}
// Why: oldest-first eviction at the cap. Evicted titles are safe to drop (titles are last-wins);
// bells and agent-status payloads collapse onto the next-oldest survivor so a pending bell latch
// and the newest statuses still apply on drain instead of vanishing.
// Why: oldest-first eviction at the cap. Display titles are last-wins, lifecycle phases carry
// separately, and bells/statuses collapse onto the next survivor instead of vanishing.
function evictOldestPendingSideEffectsIfFull(): void {
while (pendingSideEffects.length - pendingSideEffectIndex >= MAX_PENDING_PTY_SIDE_EFFECTS) {
const evicted = pendingSideEffects[pendingSideEffectIndex]
@@ -224,6 +260,8 @@ export function createPtyOutputProcessor({
return
}
pendingSideEffectIndex += 1
// Why: the queue may drop display titles, but the latest agent lifecycle edge must survive.
carryEvictedAgentTitleTransitions(evicted)
// Why: mirror applyPtySideEffect's accounting so stale-probe arming doesn't stick past eviction.
pendingWorkingTitleSideEffects -= countWorkingTitles(evicted.titles)
if (pendingWorkingTitleSideEffects < 0) {
@@ -288,7 +326,9 @@ export function createPtyOutputProcessor({
data.length > 0 &&
titles.length === 0 &&
!suppressAttentionEvents &&
(isWorkingTitle(lastEmittedTitle) || pendingWorkingTitleSideEffects > 0)
(isWorkingTitle(lastEmittedTitle) ||
pendingWorkingTitleSideEffects > 0 ||
evictedAgentTitleTransitions.some((transition) => transition.phase === 'working'))
)
const shouldEmitEmptyTitleScan = scannedForTitles || needsStaleTitleProbe
const emptyTitleScanEffect: PendingPtySideEffect['titleScanEffect'] = ignoredCursorNativeTitle
@@ -390,6 +430,7 @@ export function createPtyOutputProcessor({
function drainPtySideEffects(options: { flushAll?: boolean } = {}): void {
sideEffectDrainTimer = null
flushEvictedAgentTitleTransitions()
const maxEffects = options.flushAll ? Number.POSITIVE_INFINITY : MAX_PTY_SIDE_EFFECTS_PER_DRAIN
let processed = 0
while (pendingSideEffectIndex < pendingSideEffects.length && processed < maxEffects) {
@@ -488,6 +529,7 @@ export function createPtyOutputProcessor({
pendingSideEffects.length = 0
pendingSideEffectIndex = 0
pendingWorkingTitleSideEffects = 0
evictedAgentTitleTransitions.length = 0
clearStaleTitleTimer()
agentTracker?.reset()
bellDetector.reset()
@@ -1,5 +1,7 @@
export type ShutdownBufferCaptureOptions = {
includeLocalBuffers?: boolean
/** Force-parking remote-runtime panes restores from the host snapshot. */
excludeRemoteRuntimeScrollback?: boolean
}
/** Map of tabId → buffer-capture callback, one per mounted TerminalPane.
@@ -41,9 +41,24 @@ describe('selectEvictionExemptTerminalTabLayoutKey', () => {
).not.toBe(selectEvictionExemptTerminalTabLayoutKey(layoutState({ [LEAF_ID]: PTY_ID }), TABS))
})
it('does not collide when a pty id contains layout-key delimiters', () => {
const embeddedLeaf = `pty-local-detached,${SECOND_LEAF_ID}:second`
expect(
selectEvictionExemptTerminalTabLayoutKey(layoutState({ [LEAF_ID]: embeddedLeaf }), TABS)
).not.toBe(
selectEvictionExemptTerminalTabLayoutKey(
layoutState({
[LEAF_ID]: 'pty-local-detached',
[SECOND_LEAF_ID]: 'second'
}),
TABS
)
)
})
it('tolerates tabs with no persisted layout', () => {
expect(selectEvictionExemptTerminalTabLayoutKey({ terminalLayoutsByTabId: {} }, TABS)).toBe(
`${TAB_ID}=`
JSON.stringify([[TAB_ID, []]])
)
})
})
@@ -75,13 +75,12 @@ export function selectEvictionExemptTerminalTabLayoutKey(
state: EvictionExemptTabLayoutState,
tabs: readonly ParkableTerminalTabModel[]
): string {
return tabs
.map((tab) => {
const ptyIdsByLeafId = state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}
const leafPtys = Object.entries(ptyIdsByLeafId)
.map(([leafId, ptyId]) => `${leafId}:${ptyId}`)
.join(',')
return `${tab.id}=${leafPtys}`
})
.join('|')
return JSON.stringify(
tabs.map((tab) => [
tab.id,
Object.entries(state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}).sort(
([leftLeafId], [rightLeafId]) => leftLeafId.localeCompare(rightLeafId)
)
])
)
}
@@ -25,6 +25,7 @@ export type TerminalColdParkPolicyOverrides = {
hotRetainMs?: number
hotRetainLimit?: number
retentionTtlMs?: number
/** Default-scrollback pane units for un-parkable hidden renderer views. */
retentionLimit?: number
}
@@ -2,9 +2,12 @@ import { describe, expect, it } from 'vitest'
import { TERMINAL_WORKTREE_PARK_DELAY_MS } from './terminal-hidden-view-parking'
import {
TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS,
countTerminalLayoutPanes,
getTerminalHiddenPaneRetentionWeight,
isEvictionExemptTerminalPty,
selectForceParkEvictableTabIds,
selectRetentionForceParkedTerminalWorktrees,
selectTerminalLayoutPaneCountByTabId,
type TerminalWorktreeRetentionCandidate
} from './terminal-hidden-worktree-retention'
@@ -40,6 +43,7 @@ describe('selectRetentionForceParkedTerminalWorktrees', () => {
hasActivityTerminalPortal: false,
ordinaryParkingCovers: false,
hasPendingSpawnWork: false,
retainedPaneCount: 1,
...partial
}
}
@@ -73,13 +77,62 @@ describe('selectRetentionForceParkedTerminalWorktrees', () => {
retentionCandidate('wt-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1),
retentionCandidate('wt-4', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS)
]
// Why limit 2: wt-4 is last-active exempt, wt-3 fills the remaining slot; the two oldest evict.
// Why limit 2: newest wt-4 claims one unit, wt-3 claims the other; the two oldest evict.
expect(
selectRetentionForceParkedTerminalWorktrees({ ...base, worktrees, retentionLimit: 2 })
).toEqual(new Set(['wt-1', 'wt-2']))
})
it('force-parks past the TTL even under the limit, sparing a last-active candidate inside it', () => {
it('force-parks a newest candidate that exceeds the pane budget by itself', () => {
const worktrees = [
retentionCandidate('wt-many-panes', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS, {
retainedPaneCount: 13
})
]
expect(selectRetentionForceParkedTerminalWorktrees({ ...base, worktrees })).toEqual(
new Set(['wt-many-panes'])
)
})
it('spends the budget on the newest weighted worktrees first', () => {
const worktrees = [
retentionCandidate('wt-old-small', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2, {
retainedPaneCount: 2
}),
retentionCandidate('wt-middle', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1, {
retainedPaneCount: 3
}),
retentionCandidate('wt-new', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS, {
retainedPaneCount: 4
})
]
expect(
selectRetentionForceParkedTerminalWorktrees({
...base,
worktrees,
retentionLimit: 6
})
).toEqual(new Set(['wt-middle']))
})
it('weights high-scrollback panes against the same renderer budget', () => {
const worktrees = [
retentionCandidate('wt-old', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1),
retentionCandidate('wt-new', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS)
]
expect(
selectRetentionForceParkedTerminalWorktrees({
...base,
worktrees,
scrollbackRows: 50_000
})
).toEqual(new Set(['wt-old']))
})
it('force-parks past the TTL even under the limit while retaining a newer candidate', () => {
const worktrees = [
retentionCandidate('wt-old', nowMs - TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS),
retentionCandidate('wt-recent', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS)
@@ -89,10 +142,9 @@ describe('selectRetentionForceParkedTerminalWorktrees', () => {
)
})
// Why: the last-active exemption keeps the warm cap's "return is always instant"
// promise, but carrying it into the eviction clock made "none past 45 minutes"
// false — a lone hidden un-parkable worktree stayed mounted for the whole session.
it('force-parks the last-active candidate once it passes the TTL (the exemption bounds the cap, not the clock)', () => {
// Why: capacity never overrides the absolute clock; otherwise a lone hidden
// un-parkable worktree could stay mounted for the whole session.
it('force-parks the newest candidate once it passes the TTL', () => {
const lone = [retentionCandidate('wt-lone', nowMs - TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS)]
expect(selectRetentionForceParkedTerminalWorktrees({ ...base, worktrees: lone })).toEqual(
new Set(['wt-lone'])
@@ -124,7 +176,7 @@ describe('selectRetentionForceParkedTerminalWorktrees', () => {
// again the instant the lease ends — the remount/reattach thrash Bug #2.
it('holds a candidate out of force-park until its post-measure cool-down ends', () => {
const aged = nowMs - TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS
// Why the recent sibling: it takes the last-active exemption, so the aged
// Why the recent sibling: it claims the only capacity, so the aged
// candidate's verdict is decided by the cool-down alone.
const recent = retentionCandidate('wt-recent', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS)
expect(
@@ -178,6 +230,77 @@ describe('selectRetentionForceParkedTerminalWorktrees', () => {
})
})
describe('terminal hidden pane retention weight', () => {
it('counts split leaves, PTY-only rootless layouts, and a fresh one-pane layout', () => {
expect(
countTerminalLayoutPanes({
root: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: 'leaf-1' },
second: { type: 'leaf', leafId: 'leaf-2' }
},
activeLeafId: 'leaf-1',
expandedLeafId: null
})
).toBe(2)
expect(
countTerminalLayoutPanes({
root: null,
activeLeafId: null,
expandedLeafId: null,
ptyIdsByLeafId: { 'leaf-1': 'pty-1', 'leaf-2': 'pty-2', 'leaf-3': 'pty-3' }
})
).toBe(3)
expect(countTerminalLayoutPanes(undefined)).toBe(1)
})
it('scales pane weight by configured scrollback rows', () => {
expect(getTerminalHiddenPaneRetentionWeight(3, 5_000)).toBe(3)
expect(getTerminalHiddenPaneRetentionWeight(3, 25_000)).toBe(15)
expect(getTerminalHiddenPaneRetentionWeight(2, 50_000)).toBe(20)
})
it('reuses pane counts across unrelated store writes and buffer-only layout changes', () => {
const root = { type: 'leaf' as const, leafId: 'leaf-1' }
const terminalLayoutsByTabId = {
'tab-1': { root, activeLeafId: 'leaf-1', expandedLeafId: null }
}
const first = selectTerminalLayoutPaneCountByTabId({ terminalLayoutsByTabId })
expect(selectTerminalLayoutPaneCountByTabId({ terminalLayoutsByTabId })).toBe(first)
expect(
selectTerminalLayoutPaneCountByTabId({
terminalLayoutsByTabId: {
'tab-1': {
root,
activeLeafId: 'leaf-1',
expandedLeafId: null,
buffersByLeafId: { 'leaf-1': 'updated buffer' }
}
}
})
).toBe(first)
const changed = selectTerminalLayoutPaneCountByTabId({
terminalLayoutsByTabId: {
'tab-1': {
root: {
type: 'split',
direction: 'horizontal',
first: root,
second: { type: 'leaf', leafId: 'leaf-2' }
},
activeLeafId: 'leaf-1',
expandedLeafId: null
}
}
})
expect(changed).not.toBe(first)
expect(changed).toEqual({ 'tab-1': 2 })
})
})
describe('selectForceParkEvictableTabIds', () => {
const tabs = [{ id: 'tab-exempt' }, { id: 'tab-evictable' }]
@@ -1,33 +1,84 @@
import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
import {
DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT,
normalizeDesktopTerminalScrollbackRows
} from '../../../../shared/terminal-scrollback-policy'
import type { TerminalLayoutSnapshot } from '../../../../shared/types'
import {
TERMINAL_WORKTREE_COLD_PARK_DELAY_MS,
isSnapshotBackedTerminalPty,
selectIdsBeyondHotRetain,
type ColdParkRetainCandidate,
type TerminalColdParkPolicyOverrides
} from './terminal-hidden-view-parking'
import { collectLeafIdsInOrder } from './terminal-layout-leaf-ids'
// Why these sizes: a retained hidden pane costs a measured ~2.5MB of V8 heap
// at the 5k-row default scrollback and ~19MB at 50k (plus per-pane queues),
// not the ~4-5MB per WORKTREE the warm cap assumed — so un-parkable worktrees
// (pty classes parking can't restore) get a retention budget: at most 12 stay
// mounted while hidden and none past 45 minutes, evicted least-recently-hidden
// first via force-park. The TTL is absolute: the last-active exemption bounds
// the cap, never the clock.
// not the ~4-5MB per WORKTREE the warm cap assumed. Un-parkable worktrees
// therefore share 12 default-scrollback pane units while hidden; split panes,
// tabs, and larger scrollback all spend that budget. Newer worktrees claim
// capacity first, candidates that do not fit force-park, and none survive past
// 45 minutes. The newest candidate stays warm only when it fits.
// NOT covered by this bound: eviction-exempt TABS (isEvictionExemptTerminalPty
// — live local ptys a remount would respawn, orphaning the shell). Their panes
// stay mounted through a force-park at any age, so a fleet-wide daemon
// fail-open can leave the budget freeing nothing; Terminal.tsx logs that
// degenerate case rather than pretending the bound held.
// Also NOT covered: per-pane scrollback size. Hidden-pane scrollback demotion
// was intentionally removed — the bound is worktree count + TTL only, so a
// spared worktree (last-active, exempt tabs) can hold full 50k-row scrollback
// indefinitely. Accepted tradeoff: high-scrollback users rely on unmount
// eviction, not demotion.
export const TERMINAL_HIDDEN_WORKTREE_RETENTION_LIMIT = 12
// Scrollback is weighted instead of demoted: a 50k pane spends 10 units.
export const TERMINAL_HIDDEN_PANE_RETENTION_LIMIT = 12
export const TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS = 45 * 60_000
export function countTerminalLayoutPanes(layout: TerminalLayoutSnapshot | undefined): number {
return Math.max(
1,
collectLeafIdsInOrder(layout?.root).length,
Object.keys(layout?.ptyIdsByLeafId ?? {}).length
)
}
type TerminalLayoutPaneCountState = {
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
}
let cachedTerminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot> | null = null
let cachedTerminalLayoutPaneCountByTabId: Readonly<Record<string, number>> = {}
export function selectTerminalLayoutPaneCountByTabId(
state: TerminalLayoutPaneCountState
): Readonly<Record<string, number>> {
if (state.terminalLayoutsByTabId === cachedTerminalLayoutsByTabId) {
return cachedTerminalLayoutPaneCountByTabId
}
const next = Object.fromEntries(
Object.entries(state.terminalLayoutsByTabId).map(([tabId, layout]) => [
tabId,
countTerminalLayoutPanes(layout)
])
)
const nextKeys = Object.keys(next)
if (
Object.keys(cachedTerminalLayoutPaneCountByTabId).length !== nextKeys.length ||
nextKeys.some((tabId) => cachedTerminalLayoutPaneCountByTabId[tabId] !== next[tabId])
) {
cachedTerminalLayoutPaneCountByTabId = next
}
cachedTerminalLayoutsByTabId = state.terminalLayoutsByTabId
return cachedTerminalLayoutPaneCountByTabId
}
export function getTerminalHiddenPaneRetentionWeight(
retainedPaneCount: number,
scrollbackRows: unknown
): number {
const paneCount =
Number.isFinite(retainedPaneCount) && retainedPaneCount > 0 ? Math.floor(retainedPaneCount) : 0
const rowWeight = Math.ceil(
normalizeDesktopTerminalScrollbackRows(scrollbackRows) /
DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT
)
return paneCount * rowWeight
}
// Why: an eviction-exempt pty is a live local one a remount could not reattach
// (daemon-fail-open separator-less ids, ptys minted under another worktree) — a
// fresh spawn would orphan the live shell. Its TAB keeps its mounted pane when
@@ -59,21 +110,22 @@ export type TerminalWorktreeRetentionCandidate = {
ordinaryParkingCovers: boolean
/** Pending startup or activation spawn — a mount is imminent; never evict. */
hasPendingSpawnWork: boolean
/** Mounted renderer burden represented by the persisted tab/split topology. */
retainedPaneCount: number
}
/**
* Retention budget over the worktrees ordinary parking can never evict: any
* hidden un-parkable worktree beyond the retention limit or TTL force-parks —
* Retention budget over the worktrees ordinary parking can never evict: hidden
* pane weight beyond the retention limit, or any worktree past TTL, force-parks —
* panes unmount, watchers cover the tabs whose transport exists, and reveal
* restores per pty class (the app-restart experience). Eviction-exempt tabs
* do NOT veto the worktree: they keep their mounted panes via the per-tab
* exclusion (Activity-portal pattern) while sibling tabs unmount, so one
* exempt tab can no longer pin co-located remote-runtime tabs forever.
* Ranking reuses the hot-retain machinery, so deterministic ties hold here too,
* and the verdict changes only at deadlines or on real state transitions (no
* new flip-loop inputs). The last-active exemption it carries spares one
* worktree from the CAP only — the TTL below overrides it, else a lone hidden
* un-parkable worktree would stay mounted for the whole session.
* Newest candidates claim the pane/scrollback budget first, deterministic ties
* break by id, and an individually-overweight newest candidate force-parks
* instead of defeating the bound. Older candidates may use capacity left by a
* newer candidate that did not fit.
*/
export function selectRetentionForceParkedTerminalWorktrees(
args: {
@@ -81,13 +133,20 @@ export function selectRetentionForceParkedTerminalWorktrees(
parkingEnabled: boolean
retentionBudgetEnabled: boolean
nowMs: number
scrollbackRows?: number
} & TerminalColdParkPolicyOverrides
): Set<string> {
if (!args.parkingEnabled || !args.retentionBudgetEnabled) {
return new Set()
}
const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS
const candidates: ColdParkRetainCandidate[] = []
const retentionTtlMs = args.retentionTtlMs ?? TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS
const forceParkedIds = new Set<string>()
const retainedCandidates: {
id: string
hiddenSinceMs: number
weight: number
}[] = []
for (const worktree of args.worktrees) {
if (
worktree.hiddenSinceMs === null ||
@@ -101,19 +160,26 @@ export function selectRetentionForceParkedTerminalWorktrees(
) {
continue
}
candidates.push({ id: worktree.worktreeId, hiddenSinceMs: worktree.hiddenSinceMs })
if (args.nowMs - worktree.hiddenSinceMs >= retentionTtlMs) {
forceParkedIds.add(worktree.worktreeId)
continue
}
retainedCandidates.push({
id: worktree.worktreeId,
hiddenSinceMs: worktree.hiddenSinceMs,
weight: getTerminalHiddenPaneRetentionWeight(worktree.retainedPaneCount, args.scrollbackRows)
})
}
const retentionTtlMs = args.retentionTtlMs ?? TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS
const forceParkedIds = selectIdsBeyondHotRetain(candidates, {
nowMs: args.nowMs,
hotRetainMs: retentionTtlMs,
hotRetainLimit: args.retentionLimit ?? TERMINAL_HIDDEN_WORKTREE_RETENTION_LIMIT
retainedCandidates.sort((left, right) => {
const recencyDelta = right.hiddenSinceMs - left.hiddenSinceMs
return recencyDelta === 0 ? left.id.localeCompare(right.id) : recencyDelta
})
// Why re-applied here: selectIdsBeyondHotRetain spares the last-active id from
// its clock too, which is right for the warm cap (instant return after a
// meeting) but makes "none past 45 minutes" false for a lone hidden worktree.
for (const candidate of candidates) {
if (args.nowMs - candidate.hiddenSinceMs >= retentionTtlMs) {
const retentionLimit = Math.max(0, args.retentionLimit ?? TERMINAL_HIDDEN_PANE_RETENTION_LIMIT)
let retainedWeight = 0
for (const candidate of retainedCandidates) {
if (retainedWeight + candidate.weight <= retentionLimit) {
retainedWeight += candidate.weight
} else {
forceParkedIds.add(candidate.id)
}
}
@@ -16,13 +16,12 @@ export function getTerminalParkingPolicyOverrides(): TerminalColdParkPolicyOverr
const delayMs = e2eConfig.terminalParkingDelayMs
const retentionLimit = e2eConfig.terminalRetentionLimit
return {
// Why the retention TTL keeps production timing: it is absolute (the
// last-active exemption does not spare it), so shrinking it here would
// evict the newest hidden worktree the cap specs assert stays mounted.
// Why the retention TTL keeps production timing: it is absolute, so
// shrinking it here would evict the newest candidate despite fitting capacity.
...(typeof delayMs === 'number' && Number.isFinite(delayMs) && delayMs > 0
? { coldParkDelayMs: delayMs, hotRetainMs: delayMs }
: {}),
// Why: limit=1 lets a spec force-park with only two hidden un-parkable worktrees (production floor is 12).
// Why: a one-unit override lets two one-pane worktrees exceed the test budget.
...(typeof retentionLimit === 'number' && Number.isInteger(retentionLimit) && retentionLimit > 0
? { retentionLimit }
: {})
@@ -2,9 +2,10 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits'
import type { TerminalLayoutSnapshot } from '../../../../shared/types'
import { getUtf8ByteLength } from '../../../../shared/utf8-byte-limits'
import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
const LEAF_ID = '11111111-1111-4111-8111-111111111111' as const
const LEAF_ID_2 = '22222222-2222-4222-8222-222222222222' as const
const LEAF_ID = '11111111-1111-4111-8111-111111111111' as TerminalLeafId
const LEAF_ID_2 = '22222222-2222-4222-8222-222222222222' as TerminalLeafId
// Why: capture now appends an absolute cursor restore (see
// terminal-serialize-absolute-cursor.ts), so pane mocks must expose the
@@ -329,6 +330,87 @@ describe('captureTerminalShutdownLayout', () => {
expect(layout.titlesByLeafId).toEqual({ [LEAF_ID]: 'local shell' })
})
it('skips excluded remote-runtime scrollback while capturing sibling panes', async () => {
const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture')
const excludedSerialize = vi.fn(() => 'remote snapshot')
const capturedSerialize = vi.fn(() => 'ssh snapshot')
const firstPane = {
id: 1,
leafId: LEAF_ID,
terminal: mockTerminal(1_000),
serializeAddon: { serialize: excludedSerialize }
}
const secondPane = {
id: 2,
leafId: LEAF_ID_2,
terminal: mockTerminal(1_000),
serializeAddon: { serialize: capturedSerialize }
}
const manager = {
getPanes: vi.fn(() => [firstPane, secondPane]),
getActivePane: vi.fn(() => secondPane)
}
const layout = captureTerminalShutdownLayout({
manager: manager as never,
container: mockRootForSplit(),
expandedPaneId: null,
paneTransports: new Map([
[1, { getPtyId: vi.fn(() => 'remote:env-1@@terminal-1') }],
[2, { getPtyId: vi.fn(() => 'ssh:conn-1@@pty-2') }]
]),
paneTitlesByPaneId: {},
existingLayout: {
root: null,
activeLeafId: LEAF_ID,
expandedLeafId: null,
buffersByLeafId: {
[LEAF_ID]: 'prior remote snapshot',
[LEAF_ID_2]: 'prior ssh snapshot'
},
scrollbackRefsByLeafId: {
[LEAF_ID]: 'remote-ref',
[LEAF_ID_2]: 'ssh-ref'
}
},
excludedScrollbackLeafIds: new Set([LEAF_ID])
})
expect(excludedSerialize).not.toHaveBeenCalled()
expect(capturedSerialize).toHaveBeenCalledOnce()
expect(layout.buffersByLeafId).toEqual({
[LEAF_ID_2]: `ssh snapshot${CURSOR_HOME}`
})
expect(layout.scrollbackRefsByLeafId).toEqual({ [LEAF_ID_2]: 'ssh-ref' })
})
it('uses the persisted pty binding to exclude a remote-runtime pane during attach', async () => {
const { selectRemoteRuntimeScrollbackLeafIds } =
await import('./terminal-shutdown-layout-capture')
const panes = [
{ id: 1, leafId: LEAF_ID },
{ id: 2, leafId: LEAF_ID_2 }
]
const excluded = selectRemoteRuntimeScrollbackLeafIds({
panes,
paneTransports: new Map([
[1, { getPtyId: () => null }],
[2, { getPtyId: () => 'repo::/worktree@@live-local' }]
]),
existingLayout: {
root: null,
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: {
[LEAF_ID]: 'remote:env-1@@terminal-1',
[LEAF_ID_2]: 'remote:stale-env@@terminal-2'
}
}
})
expect(excluded).toEqual(new Set([LEAF_ID]))
})
it('does not preserve stale prior PTY bindings as shutdown focus targets', async () => {
const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture')
const deadPane = {
@@ -8,6 +8,7 @@ import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids'
import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits'
import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor'
import { getUtf8ByteLength, measureUtf8ByteLength } from '../../../../shared/utf8-byte-limits'
import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
const MAX_BUFFER_BYTES = TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT
@@ -18,6 +19,23 @@ type ShutdownPaneManager = {
getActivePane(): ShutdownPane | null
}
export function selectRemoteRuntimeScrollbackLeafIds(args: {
panes: readonly Pick<ShutdownPane, 'id' | 'leafId'>[]
paneTransports: ReadonlyMap<number, Pick<PtyTransport, 'getPtyId'>>
existingLayout: TerminalLayoutSnapshot | undefined
}): Set<string> {
const excludedLeafIds = new Set<string>()
for (const pane of args.panes) {
const livePtyId = args.paneTransports.get(pane.id)?.getPtyId()
// The persisted binding covers the attach gap before transport reports its PTY.
const ptyId = livePtyId ?? args.existingLayout?.ptyIdsByLeafId?.[pane.leafId] ?? null
if (ptyId && isRemoteRuntimePtyId(ptyId)) {
excludedLeafIds.add(pane.leafId)
}
}
return excludedLeafIds
}
type CaptureTerminalShutdownLayoutArgs = {
manager: ShutdownPaneManager
container: HTMLDivElement
@@ -27,6 +45,7 @@ type CaptureTerminalShutdownLayoutArgs = {
existingLayout: TerminalLayoutSnapshot | undefined
captureBuffers?: boolean
clearedScrollbackLeafIds?: ReadonlySet<string>
excludedScrollbackLeafIds?: ReadonlySet<string>
}
function omitClearedLeafState(
@@ -97,13 +116,17 @@ export function captureTerminalShutdownLayout({
paneTitlesByPaneId,
existingLayout,
captureBuffers = true,
clearedScrollbackLeafIds
clearedScrollbackLeafIds,
excludedScrollbackLeafIds
}: CaptureTerminalShutdownLayoutArgs): TerminalLayoutSnapshot {
const panes = manager.getPanes()
const buffers: Record<string, string> = {}
if (captureBuffers) {
for (const pane of panes) {
if (excludedScrollbackLeafIds?.has(pane.leafId)) {
continue
}
try {
// Why: non-focused panes may have renderer-throttled PTY bytes queued;
// push them into xterm before taking the shutdown scrollback snapshot.
@@ -138,6 +161,10 @@ export function captureTerminalShutdownLayout({
new Map(panes.map((pane) => [pane.id, pane.leafId]))
)
const currentLeafIds = new Set(panes.map((p) => p.leafId))
const omittedScrollbackLeafIds = new Set([
...(clearedScrollbackLeafIds ?? []),
...(excludedScrollbackLeafIds ?? [])
])
const livePtyIdsByLeafId: Record<string, string> = {}
const preservedPtyIdsByLeafId: Record<string, string> = {}
for (const pane of panes) {
@@ -157,13 +184,13 @@ export function captureTerminalShutdownLayout({
const mergedBuffers = captureBuffers
? mergeCapturedLeafState({
prior: omitClearedLeafState(existingLayout?.buffersByLeafId, clearedScrollbackLeafIds),
prior: omitClearedLeafState(existingLayout?.buffersByLeafId, omittedScrollbackLeafIds),
fresh: buffers,
currentLeafIds
})
: {}
const mergedScrollbackRefs = mergeCapturedLeafState({
prior: omitClearedLeafState(existingLayout?.scrollbackRefsByLeafId, clearedScrollbackLeafIds),
prior: omitClearedLeafState(existingLayout?.scrollbackRefsByLeafId, omittedScrollbackLeafIds),
fresh: {},
currentLeafIds
})
@@ -484,6 +484,45 @@ describe('registerTerminalSideEffectFactConsumer', () => {
expect(later).toEqual([])
})
it('keeps attention facts through title churn that exceeds the handoff cap', () => {
const dispose = registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: createCallbackRecorder().callbacks
})
dispose()
_dispatchTerminalSideEffectBatchForTest(
batch([{ kind: 'bell' }, { kind: 'command-code-done', prompt: 'Ship it' }], { seq: 1 })
)
for (let seq = 2; seq <= 130; seq += 1) {
_dispatchTerminalSideEffectBatchForTest(
batch(
[
{
kind: 'title',
normalizedTitle: `Codex frame ${seq}`,
rawTitle: `Codex frame ${seq}`
}
],
{ seq }
)
)
}
const events: unknown[][] = []
registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: {
onBell: () => events.push(['bell']),
onCommandCodeDone: (prompt) => events.push(['cc-done', prompt]),
onTitleChange: (title) => events.push(['title', title])
}
})
expect(events).toContainEqual(['bell'])
expect(events).toContainEqual(['cc-done', 'Ship it'])
expect(events.at(-1)).toEqual(['title', 'Codex frame 130'])
})
it('does not buffer replay batches across a handoff', () => {
// Why: the next consumer requests its own snapshot; a held replay would
// regress the title it just restored.
@@ -538,7 +577,7 @@ describe('registerTerminalSideEffectFactConsumer', () => {
expect(events).toEqual([['title', 'live', 'live']])
})
it('drops a handoff-buffered batch once the buffer TTL expires', () => {
it('keeps handoff facts through relay and SSH retry settlement, then expires them', () => {
vi.useFakeTimers()
try {
const dispose = registerTerminalSideEffectFactConsumer({
@@ -548,12 +587,21 @@ describe('registerTerminalSideEffectFactConsumer', () => {
dispose()
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }]))
vi.advanceTimersByTime(15_001)
vi.advanceTimersByTime(61_001)
const { callbacks, events } = createCallbackRecorder()
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
const disposeAfterRetry = registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
expect(events).toEqual([])
expect(events).toEqual([['bell']])
disposeAfterRetry()
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }]))
vi.advanceTimersByTime(75_001)
const expired = createCallbackRecorder()
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: expired.callbacks })
expect(expired.events).toEqual([])
} finally {
vi.useRealTimers()
}
@@ -173,7 +173,9 @@ function applyBatchToConsumer(entry: ConsumerEntry, batch: TerminalSideEffectBat
// dropped in that window is lost for good. Only a PTY that just lost its
// consumer buffers (never-consumed PTYs still drop, per the module contract),
// bounded in time, batches, and PTYs so an abandoned handoff retains nothing.
const HANDOFF_FACT_BUFFER_TTL_MS = 15_000
// Why 75s: a reveal can traverse a 30s relay request and 31s direct-pane
// settlement before registration; keep scheduling margin beyond that chain.
const HANDOFF_FACT_BUFFER_TTL_MS = 75_000
const MAX_HANDOFF_FACT_BATCHES = 64
const MAX_HANDOFF_FACT_PTYS = 32
@@ -215,8 +217,15 @@ function bufferHandoffFactBatch(batch: TerminalSideEffectBatch): void {
if (batch.replay) {
return
}
if (buffer.batches.length >= MAX_HANDOFF_FACT_BATCHES) {
buffer.batches.shift()
const isTitleOnly = (candidate: TerminalSideEffectBatch): boolean =>
candidate.facts.every((fact) => fact.kind === 'title')
if (isTitleOnly(batch)) {
// Why: title state is last-wins; compact spinner frames so they cannot evict attention facts.
buffer.batches = buffer.batches.filter((candidate) => !isTitleOnly(candidate))
}
while (buffer.batches.length >= MAX_HANDOFF_FACT_BATCHES) {
const titleOnlyIndex = buffer.batches.findIndex(isTitleOnly)
buffer.batches.splice(Math.max(titleOnlyIndex, 0), 1)
}
buffer.batches.push(batch)
}
+1 -1
View File
@@ -29,7 +29,7 @@ export function createE2EConfig(input: E2EConfigInput): E2EConfig {
input.terminalParkingDelayMs > 0
? input.terminalParkingDelayMs
: null
// Why: a worktree count — only a positive integer is a meaningful budget.
// Why: pane-weight units — only a positive integer is a meaningful budget.
const terminalRetentionLimit =
typeof input.terminalRetentionLimit === 'number' &&
Number.isInteger(input.terminalRetentionLimit) &&
+1 -1
View File
@@ -2857,7 +2857,7 @@ export type GlobalSettings = {
terminalHiddenViewParking?: boolean
/** Kill switch for SSH terminal parking (C1): SSH panes park like local ones; reveal restores from main's headless model, falling back to relay replay. */
terminalSshViewParking?: boolean
/** Kill switch for the hidden-worktree retention budget (C1): force-parks the least-recently-hidden un-parkable worktrees beyond a count budget or TTL. */
/** Kill switch for the hidden-worktree retention budget (C1): force-parks un-parkable worktrees beyond a pane-weight budget or TTL. */
terminalHiddenWorktreeRetentionBudget?: boolean
/** Kill switch for main-process PTY side-effect authority; on (default) = title/bell/agent facts via pty:sideEffect channel, not renderer byte parsing. */
terminalMainSideEffectAuthority?: boolean
+8 -10
View File
@@ -17,6 +17,7 @@ import {
waitForActiveTerminalManager,
waitForPaneIdentitySnapshot
} from './helpers/terminal'
import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from '../../src/shared/terminal-scrollback-policy'
// Why: production cold-park hysteresis is 30s. The fast-park env override is
// scoped to this spec's app launches via orcaAppExtraEnv (same pattern as
@@ -394,6 +395,9 @@ test.describe('Terminal parked memory', () => {
const RETENTION_TAB_COUNT = 4
const RETENTION_FILL_LINE_COUNT = 12_000
const RETENTION_SCROLLBACK_ROWS = 25_000
const RETENTION_PANE_WEIGHT = Math.ceil(
RETENTION_SCROLLBACK_ROWS / DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT
)
// Why 12: xterm packs each cell as 3 uint32s in the BufferLine typed array.
const XTERM_BYTES_PER_CELL = 12
// Why 40: this staging measures ~87 MB of retained buffer, so half of that is a
@@ -597,12 +601,8 @@ test.describe('Terminal hidden worktree retention budget', () => {
test.use({
orcaAppExtraEnv: {
ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS),
// Why limit=1: the retention TTL is absolute production timing (45min) and
// the parking-delay override deliberately no longer shrinks it, so the
// COUNT CAP is the only knob a test can drive. With a budget of 1 the
// newest hidden un-parkable worktree takes the last-active exemption and
// the older one force-parks — cap and exemption proven in one run.
ORCA_E2E_TERMINAL_RETENTION_LIMIT: '1'
// The newer one-pane decoy exactly fits; the four-pane victim does not.
ORCA_E2E_TERMINAL_RETENTION_LIMIT: String(RETENTION_PANE_WEIGHT)
},
orcaAppExtraArgs: ['--enable-precise-memory-info']
})
@@ -666,8 +666,7 @@ test.describe('Terminal hidden worktree retention budget', () => {
await stageUnparkableWorktreeTabs(orcaPage, victimWorktreeId)
}
// Hiding the victim first makes the decoy the more-recently-hidden
// candidate, so the cap's last-active exemption lands on the decoy.
// Hiding the victim first lets the newer decoy claim the weighted budget.
await switchToWorktree(orcaPage, decoyWorktreeId)
await expect
.poll(() => orcaPage.evaluate(() => window.__store?.getState().activeWorktreeId), {
@@ -761,8 +760,7 @@ test.describe('Terminal hidden worktree retention budget', () => {
// Primary gate: the staged buffers are gone, not merely hidden.
expect(after.buffers.cells).toBeLessThan(before.buffers.cells * MAX_RETAINED_CELL_FRACTION)
// The decoy holds the cap's last-active exemption, so it stays mounted —
// this is the same run proving the cap did not simply evict everything.
// The newer decoy fits the budget, proving the cap did not evict everything.
expect(await countMountedPaneManagers(orcaPage, decoyTabIds)).toBe(decoyTabIds.length)
// Secondary only: freed typed arrays return to the allocator's free lists,
// not the OS, so RSS fell just 0.7-3.0 MB locally while 87 MB of buffer was
+19 -7
View File
@@ -25,8 +25,7 @@ test.use({
seedTestRepo: false,
orcaAppExtraEnv: {
ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS),
// Why limit=1: two hidden un-parkable worktrees then exceed the budget while
// the last-active exemption still spares exactly one — the smallest live proof.
// Two one-pane worktrees exceed this one-unit test budget.
ORCA_E2E_TERMINAL_RETENTION_LIMIT: '1'
}
})
@@ -66,6 +65,19 @@ test.describe('terminal hidden-worktree retention budget', () => {
message: 'older worktree marker did not render before hiding'
})
.toContain(`${olderMarker}:`)
// Push the marker outside the relay's 100KiB tail while retaining it in
// the force-park capture.
await sendToTerminal(
orcaPage,
olderPtyId,
`for i in $(seq 1 3000); do echo "RETENTION_PAD_$i:0123456789012345678901234567890123456789"; done; echo "${olderMarker}_PAD_DONE:"\r`
)
await expect
.poll(() => getTerminalContent(orcaPage, 20_000), {
timeout: 60_000,
message: 'retention pad output did not finish before hiding'
})
.toContain(`${olderMarker}_PAD_DONE:`)
// Why: with SSH view parking off, SSH ptys are not park-restorable, so the
// hidden remote worktrees join the un-parkable class the budget governs.
@@ -110,15 +122,15 @@ test.describe('terminal hidden-worktree retention budget', () => {
// The older worktree must force-park (its pane managers unmount)…
await waitForTabParked(orcaPage, olderTabId, { parkDelayMs: PARKING_DELAY_MS })
// …while the newest hidden worktree keeps its mounted panes (last-active exemption).
// …while the newest hidden worktree claims the one-pane retention capacity.
const newerStillMounted = await orcaPage.evaluate(
(tabId) => window.__paneManagers?.get(tabId) !== undefined,
newerTabId
)
expect(newerStillMounted).toBe(true)
// Reveal the evicted worktree: with SSH parking disabled the model paint is
// off, so the relay replay must restore the marker tail — never a blank pane.
// SSH model restore is disabled, so deep history must survive through the
// force-park capture while the relay tail repaints the viewport.
await orcaPage.evaluate(
({ worktreeId, tabId }) => {
const state = window.__store?.getState()
@@ -130,9 +142,9 @@ test.describe('terminal hidden-worktree retention budget', () => {
)
await waitForActiveTerminalManager(orcaPage, 60_000)
await expect
.poll(() => getTerminalContent(orcaPage, 20_000), {
.poll(() => getTerminalContent(orcaPage, 2_000_000), {
timeout: 60_000,
message: 'revealed evicted worktree did not restore the marker via relay replay'
message: 'revealed evicted worktree lost captured history beyond the relay tail'
})
.toContain(`${olderMarker}:`)
} finally {