From 95e0606fd02b19ee7e5e55331275b425c55ebead Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:52:45 -0700 Subject: [PATCH] fix(terminal): harden remote parking under load --- src/renderer/src/components/Terminal.tsx | 18 ++- .../components/terminal-pane/TerminalPane.tsx | 26 +++- .../force-park-buffer-capture.test.ts | 5 +- .../force-park-buffer-capture.ts | 3 +- .../terminal-pane/pty-connection.test.ts | 53 ++++++- .../terminal-pane/pty-connection.ts | 6 +- .../terminal-pane/pty-transport.test.ts | 90 ++++++++++++ .../components/terminal-pane/pty-transport.ts | 50 ++++++- .../terminal-pane/shutdown-buffer-captures.ts | 2 + .../terminal-eviction-exempt-tabs.test.ts | 17 ++- .../terminal-eviction-exempt-tabs.ts | 17 +-- .../terminal-hidden-view-parking.ts | 1 + ...terminal-hidden-worktree-retention.test.ts | 137 +++++++++++++++++- .../terminal-hidden-worktree-retention.ts | 130 +++++++++++++---- .../terminal-parking-e2e-overrides.ts | 7 +- .../terminal-shutdown-layout-capture.test.ts | 86 ++++++++++- .../terminal-shutdown-layout-capture.ts | 33 ++++- ...terminal-side-effect-facts-handler.test.ts | 56 ++++++- .../terminal-side-effect-facts-handler.ts | 15 +- src/shared/e2e-config.ts | 2 +- src/shared/types.ts | 2 +- tests/e2e/terminal-parked-memory.spec.ts | 18 +-- tests/e2e/terminal-retention-budget.spec.ts | 26 +++- 23 files changed, 697 insertions(+), 103 deletions(-) diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 4fc153c010b..d0f927eb87b 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -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 ]) diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 2cd4d3c0d8b..076ea78410b 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -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) { diff --git a/src/renderer/src/components/terminal-pane/force-park-buffer-capture.test.ts b/src/renderer/src/components/terminal-pane/force-park-buffer-capture.test.ts index 4083fcface8..ce9ac0bf6d4 100644 --- a/src/renderer/src/components/terminal-pane/force-park-buffer-capture.test.ts +++ b/src/renderer/src/components/terminal-pane/force-park-buffer-capture.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/terminal-pane/force-park-buffer-capture.ts b/src/renderer/src/components/terminal-pane/force-park-buffer-capture.ts index 14f73071169..7fcfc5155d5 100644 --- a/src/renderer/src/components/terminal-pane/force-park-buffer-capture.ts +++ b/src/renderer/src/components/terminal-pane/force-park-buffer-capture.ts @@ -23,7 +23,8 @@ export function captureForceParkedWorktreeBuffers({ return true } const { requested, captured } = captureTerminalShutdownBuffersBestEffort(tabIds, { - includeLocalBuffers: false + includeLocalBuffers: false, + excludeRemoteRuntimeScrollback: true }) return captured === requested } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index bdeca30e7f2..9cff352ddcc 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -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() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 27c52889e0d..a17d5027789 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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) diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index ea7d9d043e1..c017a1a1cc8 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -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 { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 6b777cbb21e..e06a4c6eac7 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -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() diff --git a/src/renderer/src/components/terminal-pane/shutdown-buffer-captures.ts b/src/renderer/src/components/terminal-pane/shutdown-buffer-captures.ts index 8e349586860..3998aa1b07b 100644 --- a/src/renderer/src/components/terminal-pane/shutdown-buffer-captures.ts +++ b/src/renderer/src/components/terminal-pane/shutdown-buffer-captures.ts @@ -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. diff --git a/src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.test.ts b/src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.test.ts index 0560057b3dd..bcb3f55310b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.test.ts @@ -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, []]]) ) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.ts b/src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.ts index 3a11fab51fd..a2eb6aa467e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.ts +++ b/src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.ts @@ -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) + ) + ]) + ) } diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts index 92dcfd48b99..1e42fd8c92d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts @@ -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 } diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts index 3b1621b3fec..b020d51a852 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts @@ -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' }] diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts index 86291b62ce4..1b2c4ebaa0b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts @@ -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 +} + +let cachedTerminalLayoutsByTabId: Record | null = null +let cachedTerminalLayoutPaneCountByTabId: Readonly> = {} + +export function selectTerminalLayoutPaneCountByTabId( + state: TerminalLayoutPaneCountState +): Readonly> { + 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 { 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() + 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) } } diff --git a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts index cbef3cebf5e..bb774c5dff3 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts @@ -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 } : {}) diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts index dcbf3782f4b..aeefa6200b3 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts @@ -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 = { diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts index 006a76b913f..8809ba30dc4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts @@ -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[] + paneTransports: ReadonlyMap> + existingLayout: TerminalLayoutSnapshot | undefined +}): Set { + const excludedLeafIds = new Set() + 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 + excludedScrollbackLeafIds?: ReadonlySet } 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 = {} 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 = {} const preservedPtyIdsByLeafId: Record = {} 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 }) diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts index c5f2b696f53..fed6b965f86 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts @@ -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() } diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts index 95374ae9f8a..f12466d2f74 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -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) } diff --git a/src/shared/e2e-config.ts b/src/shared/e2e-config.ts index e24ab578625..734cdd3507b 100644 --- a/src/shared/e2e-config.ts +++ b/src/shared/e2e-config.ts @@ -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) && diff --git a/src/shared/types.ts b/src/shared/types.ts index 0aac474ca04..f220a04e327 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 diff --git a/tests/e2e/terminal-parked-memory.spec.ts b/tests/e2e/terminal-parked-memory.spec.ts index 83b2e32da7d..451b5307df6 100644 --- a/tests/e2e/terminal-parked-memory.spec.ts +++ b/tests/e2e/terminal-parked-memory.spec.ts @@ -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 diff --git a/tests/e2e/terminal-retention-budget.spec.ts b/tests/e2e/terminal-retention-budget.spec.ts index d877a29cee2..c2818aa1c1d 100644 --- a/tests/e2e/terminal-retention-budget.spec.ts +++ b/tests/e2e/terminal-retention-budget.spec.ts @@ -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 {