fix(terminal): demote un-parkable worktrees the force-park lever spared (C1 review #4)

The last-active exemption means a single hidden un-parkable worktree never
force-parks — and slice C previously only targeted exempt-tab worktrees, so
its panes held full scrollback forever. Demotion now also covers un-parkable
non-exempt worktrees past the retention TTL that are absent from the
force-parked set (last-active spared, or slice B switched off). Membership
stays time-monotone for fixed inputs; covered by new idempotence/monotone
selector tests.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-07-28 13:21:53 -07:00
co-authored by Orca
parent 51b29d1c48
commit fbf80b54b3
2 changed files with 73 additions and 7 deletions
@@ -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(
@@ -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)
}
}