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