diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts index b713d7226c0..3f3e986f589 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts @@ -233,6 +233,61 @@ describe('selectScrollbackDemotedTerminalWorktrees', () => { ).toEqual(new Set(['wt-a', 'wt-b'])) }) + it('demotes un-parkable non-exempt worktrees the force-park lever spared, past the TTL', () => { + // wt-solo is the only candidate → last-active exempt from force-park; its + // panes stay fully mounted forever unless demotion bounds them. + const solo = [exemptCandidate('wt-solo', aged, { hasEvictionExemptTab: false })] + expect(selectScrollbackDemotedTerminalWorktrees({ ...base, worktrees: solo })).toEqual( + new Set(['wt-solo']) + ) + // With slice B off nothing force-parks; every un-parkable worktree past + // the TTL demotes (C bounds independently). + const pair = [ + exemptCandidate('wt-a', aged - 1, { hasEvictionExemptTab: false }), + exemptCandidate('wt-b', aged, { hasEvictionExemptTab: false }) + ] + expect( + selectScrollbackDemotedTerminalWorktrees({ + ...base, + worktrees: pair, + retentionBudgetEnabled: false + }) + ).toEqual(new Set(['wt-a', 'wt-b'])) + // With slice B on, wt-a force-parks (unmounted — no demotion needed) and + // only the spared last-active wt-b demotes. + expect(selectScrollbackDemotedTerminalWorktrees({ ...base, worktrees: pair })).toEqual( + new Set(['wt-b']) + ) + // Ordinary-parking-covered worktrees are bounded by the warm cap already. + const covered = [ + exemptCandidate('wt-covered', aged, { + hasEvictionExemptTab: false, + ordinaryParkingCovers: true + }) + ] + expect(selectScrollbackDemotedTerminalWorktrees({ ...base, worktrees: covered })).toEqual( + new Set() + ) + }) + + it('is idempotent and only grows as time advances (flip-loop dwell)', () => { + const worktrees = [ + exemptCandidate('wt-exempt', aged), + exemptCandidate('wt-force-parked', aged, { hasEvictionExemptTab: false }), + exemptCandidate('wt-last-active', nowMs - 60_000, { hasEvictionExemptTab: false }) + ] + const first = selectScrollbackDemotedTerminalWorktrees({ ...base, worktrees }) + expect(selectScrollbackDemotedTerminalWorktrees({ ...base, worktrees })).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 = selectScrollbackDemotedTerminalWorktrees({ ...base, worktrees, nowMs: laterMs }) + for (const id of first) { + expect(later.has(id)).toBe(true) + } + } + }) + it('honors the retentionTtlMs override and stays time-monotone', () => { const worktrees = [exemptCandidate('wt-1', nowMs - 500)] expect( diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts index 281aba4f77d..a9f799c07a2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts @@ -96,11 +96,14 @@ export function selectRetentionForceParkedTerminalWorktrees( /** * Scrollback demotion targets the hidden panes that stay mounted past the - * retention deadlines: eviction-exempt tabs' worktrees (their panes survive - * force-park via the per-tab exclusion) demote past the TTL — or immediately - * when their worktree force-parks under the count budget, since the exempt - * panes are then the only ones left mounted. Time-monotone for fixed inputs: - * the force-parked set and the past-TTL set only ever grow with nowMs, so + * retention deadlines: (a) eviction-exempt tabs' worktrees (their panes + * survive force-park via the per-tab exclusion) demote past the TTL — or + * immediately when their worktree force-parks under the count budget, since + * the exempt panes are then the only ones left mounted; (b) un-parkable + * worktrees the force-park lever spared (the last-active exemption, or slice + * B switched off) demote past the TTL instead of holding full scrollback + * forever. Time-monotone for fixed inputs: the force-parked and past-TTL sets + * only ever grow with nowMs and the last-active pick is time-independent, so * membership only grows until a reveal resets hiddenSince — no oscillation * surface. */ @@ -128,12 +131,20 @@ export function selectScrollbackDemotedTerminalWorktrees( worktree.isVisible || worktree.shouldMeasureHiddenWorktree || worktree.hasActivityTerminalPortal || - !worktree.hasEvictionExemptTab || worktree.hasPendingSpawnWork ) { continue } - if (args.nowMs - worktree.hiddenSinceMs >= ttlMs || forceParked.has(worktree.worktreeId)) { + const pastTtl = args.nowMs - worktree.hiddenSinceMs >= ttlMs + if (worktree.hasEvictionExemptTab) { + if (pastTtl || forceParked.has(worktree.worktreeId)) { + demoted.add(worktree.worktreeId) + } + continue + } + // Force-park candidates the lever spared stay fully mounted, so demotion + // is their only bound; force-parked ones already unmounted. + if (!worktree.ordinaryParkingCovers && pastTtl && !forceParked.has(worktree.worktreeId)) { demoted.add(worktree.worktreeId) } }