mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 00:02:48 +00:00
fix(terminal): bound hidden-worktree retention with a force-park budget (C1, slice B)
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 <help@stably.ai>
This commit is contained in:
@@ -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<string>())
|
||||
// Tab restriction for targeted background mounts (wake/resume); a worktree absent from this map mounts all its tabs.
|
||||
const backgroundMountTabIdsByWorktreeRef = useRef(new Map<string, ReadonlySet<string>>())
|
||||
// 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
|
||||
])
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -24,6 +24,8 @@ export type TerminalColdParkPolicyOverrides = {
|
||||
coldParkDelayMs?: number
|
||||
hotRetainMs?: number
|
||||
hotRetainLimit?: number
|
||||
retentionTtlMs?: number
|
||||
retentionLimit?: number
|
||||
}
|
||||
|
||||
export type ColdParkableTerminalTab = Pick<TerminalTab, 'id' | 'ptyId' | 'pendingActivationSpawn'>
|
||||
@@ -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<string> {
|
||||
@@ -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 } : {})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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> = {}
|
||||
): 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<TerminalTab, 'ptyId'>,
|
||||
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<string> {
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
: {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user