From e7703a772b8c6b2fa118bbe158db33fc09667993 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:30:37 -0700 Subject: [PATCH] fix(terminal): bound hidden-worktree retention with a force-park budget (C1, slice B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un-parkable worktrees (remote-runtime ptys, uncoverable tabs, SSH with the slice-A switch off) had unlimited retention: the parking cap/TTL only ever saw eligibility-passing worktrees, so one bad tab pinned a whole worktree's panes forever. Retention is now memory-bounded, not eligibility-bounded. - terminal-hidden-worktree-retention.ts: retention budget (12 hidden / 45min TTL, sized from the measured 2.5-19MB per-pane V8 cost, DESIGN.md §2) over hidden worktrees ordinary parking can never evict; reuses the hot-retain ranking so last-active exemption, deterministic ties, and deadline-driven rechecks hold. Fail-open/foreign-pty tabs are eviction-exempt (a remount would fresh-spawn and orphan the live shell). - Terminal.tsx: force-parked ids join the parked set AFTER the coverage veto (darkness for uncoverable tabs is the accepted cost); buffers captured via the sleep-flow registry before the unmount render; retention TTL added to the recheck deadlines for budget candidates only. - Verdict stays out of its own effect deps; policy test asserts idempotence and time-monotone membership (flip-loop dwell regression). - Kill switch: settings.terminalHiddenWorktreeRetentionBudget (default on). Co-authored-by: Orca --- src/renderer/src/components/Terminal.tsx | 94 +++++++++++- .../terminal-hidden-view-parking.test.ts | 22 +++ .../terminal-hidden-view-parking.ts | 15 +- ...terminal-hidden-worktree-retention.test.ts | 141 ++++++++++++++++++ .../terminal-hidden-worktree-retention.ts | 92 ++++++++++++ .../terminal-parking-e2e-overrides.test.ts | 5 +- .../terminal-parking-e2e-overrides.ts | 3 +- src/shared/types.ts | 2 + 8 files changed, 366 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 8314f7271bf..3a815b9a261 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -79,10 +79,18 @@ import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-op import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { setForegroundTerminalTabIds } from '@/lib/foreground-terminal-tabs' import { + canParkTerminalWorktreeRenderers, getTerminalWorktreeColdParkRecheckDelayMs, selectColdParkedTerminalWorktrees, type TerminalWorktreeColdParkCandidate } from './terminal-pane/terminal-hidden-view-parking' +import { + TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS, + isEvictionExemptTerminalTab, + selectRetentionForceParkedTerminalWorktrees, + type TerminalWorktreeRetentionCandidate +} from './terminal-pane/terminal-hidden-worktree-retention' +import { shutdownBufferCaptures } from './terminal-pane/shutdown-buffer-captures' import { getTerminalParkingPolicyOverrides } from './terminal-pane/terminal-parking-e2e-overrides' import { useColdParkedTerminalPresentation } from './terminal-pane/use-cold-parked-terminal-presentation' import { @@ -273,6 +281,9 @@ function Terminal(): React.JSX.Element | null { const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId) const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false) const terminalSshParkingEnabled = useAppStore((s) => s.settings?.terminalSshViewParking !== false) + const terminalRetentionBudgetEnabled = useAppStore( + (s) => s.settings?.terminalHiddenWorktreeRetentionBudget !== false + ) const terminalTitleSnapshotAuthorityEnabled = useAppStore((s) => isMainTerminalSideEffectAuthorityForPty({ settings: s.settings, @@ -752,6 +763,8 @@ function Terminal(): React.JSX.Element | null { combineTerminalWorktreeParkIds(parkedTerminalWorktreeIds, manuallyParkedTerminalWorktreeIds), [manuallyParkedTerminalWorktreeIds, parkedTerminalWorktreeIds] ) + // Why a ref: eviction captures buffers exactly once per force-park episode, before the unmount render. + const forceParkedCaptureDoneRef = useRef(new Set()) // Tab restriction for targeted background mounts (wake/resume); a worktree absent from this map mounts all its tabs. const backgroundMountTabIdsByWorktreeRef = useRef(new Map>()) // Why: only cold-activation deferral (not targeted mounts, which share the map above) creates watcher coverage for every unmounted tab. @@ -894,11 +907,83 @@ 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 — + // added AFTER the coverage veto because darkness for their uncoverable tabs + // is the accepted cost of bounding retention. + const restorePolicy = { sshParkingEnabled: terminalSshParkingEnabled } + const retentionBudgetCandidates: TerminalWorktreeRetentionCandidate[] = retentionCandidates.map( + (candidate) => { + const tabs = tabsByWorktree[candidate.worktreeId] ?? [] + const parkEligible = canParkTerminalWorktreeRenderers({ + ...candidate, + pendingStartupByTabId, + parkingEnabled: terminalParkingEnabled, + nowMs, + restorePolicy, + ...(overrides.coldParkDelayMs !== undefined + ? { coldParkDelayMs: overrides.coldParkDelayMs } + : {}) + }) + return { + worktreeId: candidate.worktreeId, + hiddenSinceMs: candidate.hiddenSinceMs, + isVisible: candidate.isVisible, + shouldMeasureHiddenWorktree: candidate.shouldMeasureHiddenWorktree, + hasActivityTerminalPortal: candidate.hasActivityTerminalPortal, + ordinaryParkingCovers: + parkEligible && + tabs.every((tab) => canWatcherCoverParkedTerminalTab(candidate.worktreeId, tab)), + hasEvictionExemptTab: tabs.some((tab) => + isEvictionExemptTerminalTab(tab, candidate.worktreeId) + ), + hasPendingSpawnWork: tabs.some( + (tab) => + pendingStartupByTabId[tab.id] !== undefined || + tab.pendingActivationSpawn === true || + (typeof tab.pendingActivationSpawn === 'number' && tab.pendingActivationSpawn > 0) + ) + } + } + ) + const forceParkedWorktreeIds = selectRetentionForceParkedTerminalWorktrees({ + worktrees: retentionBudgetCandidates, + parkingEnabled: terminalParkingEnabled, + retentionBudgetEnabled: terminalRetentionBudgetEnabled, + nowMs, + ...overrides + }) + const capturedForceParked = forceParkedCaptureDoneRef.current + for (const id of Array.from(capturedForceParked)) { + if (!forceParkedWorktreeIds.has(id)) { + capturedForceParked.delete(id) + } + } + for (const worktreeId of forceParkedWorktreeIds) { + if (!capturedForceParked.has(worktreeId)) { + capturedForceParked.add(worktreeId) + // Why: serialize buffers while the panes still exist (same registry sleep uses), so classes with no snapshot source still reveal last-known content. + for (const tab of tabsByWorktree[worktreeId] ?? []) { + shutdownBufferCaptures.get(tab.id)?.() + } + } + nextParkedTerminalWorktreeIds.add(worktreeId) + } setParkedTerminalWorktreeIds((current) => haveSameWorktreeIds(current, nextParkedTerminalWorktreeIds) ? current : nextParkedTerminalWorktreeIds ) + const retentionTtlEligibleIds = new Set( + retentionBudgetCandidates + .filter( + (candidate) => + !candidate.ordinaryParkingCovers && + !candidate.hasEvictionExemptTab && + !candidate.hasPendingSpawnWork + ) + .map((candidate) => candidate.worktreeId) + ) for (const candidate of retentionCandidates) { if ( @@ -913,7 +998,13 @@ function Terminal(): React.JSX.Element | null { parkingEnabled: terminalParkingEnabled, hiddenSinceMs: candidate.hiddenSinceMs, nowMs, - ...overrides + ...overrides, + // Why: only retention candidates wake at the eviction TTL; everyone else keeps the ordinary deadlines. + ...(terminalRetentionBudgetEnabled && retentionTtlEligibleIds.has(candidate.worktreeId) + ? { + retentionTtlMs: overrides.retentionTtlMs ?? TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS + } + : {}) }) if (delayMs !== null && delayMs > 0) { const worktreeId = candidate.worktreeId @@ -933,6 +1024,7 @@ function Terminal(): React.JSX.Element | null { tabsByWorktree, terminalParkingEnabled, terminalParkingRevision, + terminalRetentionBudgetEnabled, terminalSshParkingEnabled, workspaceSurfaces ]) diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts index b8102fa9492..15cf9b1d2da 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts @@ -592,6 +592,28 @@ describe('getTerminalWorktreeColdParkRecheckDelayMs', () => { }) ).toBeNull() }) + + it('wakes at the retention TTL for budget candidates past the ordinary deadlines', () => { + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 2_500, + coldParkDelayMs: 100, + hotRetainMs: 1_000, + retentionTtlMs: 5_000 + }) + ).toBe(3_500) + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 2_500, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + }) }) describe('getTerminalTabColdParkRecheckDelayMs', () => { 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 26357d14a9c..e2fcdc8e31d 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 @@ -24,6 +24,8 @@ export type TerminalColdParkPolicyOverrides = { coldParkDelayMs?: number hotRetainMs?: number hotRetainLimit?: number + retentionTtlMs?: number + retentionLimit?: number } export type ColdParkableTerminalTab = Pick @@ -158,7 +160,7 @@ export function canParkTerminalTabRenderer(args: { return isParkRestorableTerminalPty(tab.ptyId, args.worktreeId, args.restorePolicy) } -type ColdParkRetainCandidate = { id: string; hiddenSinceMs: number } +export type ColdParkRetainCandidate = { id: string; hiddenSinceMs: number } // Why: the single most-recently-hidden candidate is the view the user just // switched away from; keeping it warm regardless of the TTL or cap means @@ -183,7 +185,7 @@ function selectLastActiveRetainedId(candidates: ColdParkRetainCandidate[]): stri // ids hidden past hotRetainMs or beyond the limit cold-park. The last-active // id is exempt from both so returning to it never pays a remount. Ties sort by // id so the selection is deterministic. -function selectIdsBeyondHotRetain( +export function selectIdsBeyondHotRetain( candidates: ColdParkRetainCandidate[], args: { nowMs: number; hotRetainMs: number; hotRetainLimit: number } ): Set { @@ -298,13 +300,15 @@ function nextColdParkDeadlineDelayMs(args: { nowMs: number coldParkDelayMs: number hotRetainMs: number + retentionTtlMs?: number }): number | null { if (!args.parkingEnabled || args.hiddenSinceMs === null) { return null } const pendingDeadlines = [ args.hiddenSinceMs + args.coldParkDelayMs, - args.hiddenSinceMs + args.hotRetainMs + args.hiddenSinceMs + args.hotRetainMs, + ...(args.retentionTtlMs !== undefined ? [args.hiddenSinceMs + args.retentionTtlMs] : []) ].filter((deadlineMs) => deadlineMs > args.nowMs) return pendingDeadlines.length === 0 ? null : Math.min(...pendingDeadlines) - args.nowMs } @@ -315,13 +319,16 @@ export function getTerminalWorktreeColdParkRecheckDelayMs(args: { nowMs: number coldParkDelayMs?: number hotRetainMs?: number + /** Provided only for retention-budget candidates so their TTL wakes the verdict effect. */ + retentionTtlMs?: number }): number | null { return nextColdParkDeadlineDelayMs({ parkingEnabled: args.parkingEnabled, hiddenSinceMs: args.hiddenSinceMs, nowMs: args.nowMs, coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS, - hotRetainMs: args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS + hotRetainMs: args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS, + ...(args.retentionTtlMs !== undefined ? { retentionTtlMs: args.retentionTtlMs } : {}) }) } 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 new file mode 100644 index 00000000000..f7424bcc49c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' +import { TERMINAL_WORKTREE_PARK_DELAY_MS } from './terminal-hidden-view-parking' +import { + TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS, + isEvictionExemptTerminalTab, + selectRetentionForceParkedTerminalWorktrees, + type TerminalWorktreeRetentionCandidate +} from './terminal-hidden-worktree-retention' + +describe('isEvictionExemptTerminalTab', () => { + const worktreeId = 'repo::/worktree' + + it('exempts only live local ptys a remount could not reattach', () => { + expect(isEvictionExemptTerminalTab({ ptyId: 'pty-local-detached' }, worktreeId)).toBe(true) + expect(isEvictionExemptTerminalTab({ ptyId: 'other::wt@@session-1' }, worktreeId)).toBe(true) + }) + + it('never exempts snapshot-backed, SSH, remote-runtime, or unbound tabs', () => { + expect(isEvictionExemptTerminalTab({ ptyId: `${worktreeId}@@session-1` }, worktreeId)).toBe( + false + ) + expect(isEvictionExemptTerminalTab({ ptyId: 'ssh:conn-1@@pty-1' }, worktreeId)).toBe(false) + expect(isEvictionExemptTerminalTab({ ptyId: 'remote:env-1@@t-1' }, worktreeId)).toBe(false) + expect(isEvictionExemptTerminalTab({ ptyId: null }, worktreeId)).toBe(false) + }) +}) + +describe('selectRetentionForceParkedTerminalWorktrees', () => { + const nowMs = 5_000_000 + + function retentionCandidate( + worktreeId: string, + hiddenSinceMs: number | null, + partial: Partial = {} + ): TerminalWorktreeRetentionCandidate { + return { + worktreeId, + hiddenSinceMs, + isVisible: false, + shouldMeasureHiddenWorktree: false, + hasActivityTerminalPortal: false, + ordinaryParkingCovers: false, + hasEvictionExemptTab: false, + hasPendingSpawnWork: false, + ...partial + } + } + + const base = { + parkingEnabled: true, + retentionBudgetEnabled: true, + nowMs + } + + it('returns empty when either kill switch is off', () => { + const worktrees = [ + retentionCandidate('wt-1', nowMs - TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS) + ] + expect( + selectRetentionForceParkedTerminalWorktrees({ ...base, worktrees, parkingEnabled: false }) + ).toEqual(new Set()) + expect( + selectRetentionForceParkedTerminalWorktrees({ + ...base, + worktrees, + retentionBudgetEnabled: false + }) + ).toEqual(new Set()) + }) + + it('force-parks the least-recently-hidden candidates beyond the retention limit', () => { + const worktrees = [ + retentionCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 3), + retentionCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2), + 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. + expect( + selectRetentionForceParkedTerminalWorktrees({ ...base, worktrees, retentionLimit: 2 }) + ).toEqual(new Set(['wt-1', 'wt-2'])) + }) + + it('force-parks past the TTL even under the limit, but never the last-active candidate', () => { + const worktrees = [ + retentionCandidate('wt-old', nowMs - TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS), + retentionCandidate('wt-recent', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS) + ] + expect(selectRetentionForceParkedTerminalWorktrees({ ...base, worktrees })).toEqual( + new Set(['wt-old']) + ) + }) + + it('never force-parks visible, measuring, portaled, covered, exempt, pending, or fresh candidates', () => { + const aged = nowMs - TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS + const worktrees = [ + retentionCandidate('wt-visible', aged, { isVisible: true }), + retentionCandidate('wt-measure', aged, { shouldMeasureHiddenWorktree: true }), + retentionCandidate('wt-portal', aged, { hasActivityTerminalPortal: true }), + retentionCandidate('wt-covered', aged, { ordinaryParkingCovers: true }), + retentionCandidate('wt-exempt', aged, { hasEvictionExemptTab: true }), + retentionCandidate('wt-pending', aged, { hasPendingSpawnWork: true }), + retentionCandidate('wt-fresh', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS + 1), + retentionCandidate('wt-unhidden', null) + ] + expect(selectRetentionForceParkedTerminalWorktrees({ ...base, worktrees })).toEqual(new Set()) + }) + + it('is idempotent and only grows as time advances (flip-loop dwell)', () => { + const worktrees = [ + retentionCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 3), + retentionCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2), + retentionCandidate('wt-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1), + retentionCandidate('wt-4', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS) + ] + const first = selectRetentionForceParkedTerminalWorktrees({ + ...base, + worktrees, + retentionLimit: 2 + }) + const second = selectRetentionForceParkedTerminalWorktrees({ + ...base, + worktrees, + retentionLimit: 2 + }) + expect(second).toEqual(first) + // Why: with unchanged inputs, a later evaluation may only ADD members — + // a verdict that oscillates with time is the React-#185 ingredient. + for (const laterMs of [nowMs + 1_000, nowMs + TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS]) { + const later = selectRetentionForceParkedTerminalWorktrees({ + ...base, + worktrees, + retentionLimit: 2, + nowMs: laterMs + }) + for (const id of first) { + expect(later.has(id)).toBe(true) + } + } + }) +}) 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 new file mode 100644 index 00000000000..bdddacabcbd --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts @@ -0,0 +1,92 @@ +import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { TerminalTab } from '../../../../shared/types' +import { + TERMINAL_WORKTREE_COLD_PARK_DELAY_MS, + isSnapshotBackedTerminalPty, + selectIdsBeyondHotRetain, + type ColdParkRetainCandidate, + type TerminalColdParkPolicyOverrides +} from './terminal-hidden-view-parking' + +// Why these sizes (C1, DESIGN.md §2): 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 hard +// retention budget: at most 12 stay mounted while hidden, and none past 45 +// minutes, evicted least-recently-hidden first via force-park. +export const TERMINAL_HIDDEN_WORKTREE_RETENTION_LIMIT = 12 +export const TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS = 45 * 60_000 + +// Why: an eviction-exempt tab holds a live local pty a remount could not +// reattach (daemon-fail-open separator-less ids, ptys minted under another +// worktree) — a fresh spawn would orphan the live shell, so these worktrees +// never force-park and rely on scrollback demotion instead. +export function isEvictionExemptTerminalTab( + tab: Pick, + worktreeId: string +): boolean { + const ptyId = tab.ptyId + if (!ptyId || isRemoteRuntimePtyId(ptyId) || parseAppSshPtyId(ptyId)) { + return false + } + return !isSnapshotBackedTerminalPty(ptyId, worktreeId) +} + +export type TerminalWorktreeRetentionCandidate = { + worktreeId: string + hiddenSinceMs: number | null + isVisible: boolean + shouldMeasureHiddenWorktree: boolean + hasActivityTerminalPortal: boolean + /** Ordinary cold parking can evict this worktree (park-eligible AND watcher-coverable) — the warm cap bounds it already. */ + ordinaryParkingCovers: boolean + /** See isEvictionExemptTerminalTab. */ + hasEvictionExemptTab: boolean + /** Pending startup or activation spawn — a mount is imminent; never evict. */ + hasPendingSpawnWork: boolean +} + +/** + * Retention budget over the worktrees ordinary parking can never evict: any + * hidden un-parkable worktree beyond the retention limit or TTL force-parks — + * panes unmount, watchers cover the tabs whose transport exists, and reveal + * restores per pty class (the app-restart experience). Ranking reuses the + * hot-retain machinery, so the last-active exemption and deterministic ties + * hold here too, and the verdict changes only at deadlines or on real state + * transitions (no new flip-loop inputs). + */ +export function selectRetentionForceParkedTerminalWorktrees( + args: { + worktrees: readonly TerminalWorktreeRetentionCandidate[] + parkingEnabled: boolean + retentionBudgetEnabled: boolean + nowMs: number + } & TerminalColdParkPolicyOverrides +): Set { + if (!args.parkingEnabled || !args.retentionBudgetEnabled) { + return new Set() + } + const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS + const candidates: ColdParkRetainCandidate[] = [] + for (const worktree of args.worktrees) { + if ( + worktree.hiddenSinceMs === null || + worktree.isVisible || + worktree.shouldMeasureHiddenWorktree || + worktree.hasActivityTerminalPortal || + worktree.ordinaryParkingCovers || + worktree.hasEvictionExemptTab || + worktree.hasPendingSpawnWork || + args.nowMs - worktree.hiddenSinceMs < coldParkDelayMs + ) { + continue + } + candidates.push({ id: worktree.worktreeId, hiddenSinceMs: worktree.hiddenSinceMs }) + } + return selectIdsBeyondHotRetain(candidates, { + nowMs: args.nowMs, + hotRetainMs: args.retentionTtlMs ?? TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS, + hotRetainLimit: args.retentionLimit ?? TERMINAL_HIDDEN_WORKTREE_RETENTION_LIMIT + }) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts index 40e226cf5df..317a3ea4141 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts @@ -46,12 +46,13 @@ describe('getTerminalParkingPolicyOverrides', () => { expect(getTerminalParkingPolicyOverrides()).toEqual({}) }) - it('maps the e2e delay to BOTH coldParkDelayMs and hotRetainMs', async () => { + it('maps the e2e delay to coldParkDelayMs, hotRetainMs, and retentionTtlMs', async () => { mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500 } const { getTerminalParkingPolicyOverrides } = await importOverridesModule() expect(getTerminalParkingPolicyOverrides()).toEqual({ coldParkDelayMs: 500, - hotRetainMs: 500 + hotRetainMs: 500, + retentionTtlMs: 500 }) }) 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 1717b170bb9..915e03c2ba1 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 @@ -12,7 +12,8 @@ import { getParkedTerminalWatcherTabIds } from './terminal-parked-tab-watchers' export function getTerminalParkingPolicyOverrides(): TerminalColdParkPolicyOverrides { const delayMs = e2eConfig.exposeStore ? e2eConfig.terminalParkingDelayMs : null return typeof delayMs === 'number' && Number.isFinite(delayMs) && delayMs > 0 - ? { coldParkDelayMs: delayMs, hotRetainMs: delayMs } + ? // Why retentionTtlMs too: the retention-budget e2e needs its 45min TTL shrunk the same way. + { coldParkDelayMs: delayMs, hotRetainMs: delayMs, retentionTtlMs: delayMs } : {} } diff --git a/src/shared/types.ts b/src/shared/types.ts index 4a47488cd15..8965a085142 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2850,6 +2850,8 @@ 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. */ + 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 /** Kill switch for main's hidden-delivery gate (Phase 4): drops PTY bytes to hidden views after model ingestion; requires terminalMainSideEffectAuthority. */