fix(terminal): replace park damping with acknowledged handoffs

This commit is contained in:
Neil
2026-08-04 21:33:20 -07:00
parent 67799f40e9
commit 7c6d3b0b14
57 changed files with 4827 additions and 1443 deletions
@@ -238,41 +238,23 @@ describe('renderer breadcrumb IPC routing', () => {
// Why: park-churn notices are per-tab but the ring is process-wide, so many
// tabs churning at once would otherwise evict the pre-crash trail.
it('coalesces park-verdict churn notices by trigger across tabs', () => {
it('coalesces park-verdict churn notices by name across tabs', () => {
emitRendererBreadcrumb({
name: 'terminal_park_verdict_churn',
data: { tabId: 'tab-1', trigger: 'window', flips: 12, elapsedMs: 8 }
data: { tabId: 'tab-1', flips: 12, elapsedMs: 8 }
})
emitRendererBreadcrumb({
name: 'terminal_park_verdict_churn',
data: { tabId: 'tab-2', trigger: 'window', flips: 12, elapsedMs: 9 }
data: { tabId: 'tab-2', flips: 12, elapsedMs: 9 }
})
expect(recordCrashBreadcrumbMock).not.toHaveBeenCalled()
expect(recordCoalescedCrashBreadcrumbMock).toHaveBeenCalledTimes(2)
for (const call of recordCoalescedCrashBreadcrumbMock.mock.calls) {
expect(call[0]).toMatchObject({ coalesceKey: 'terminal_park_verdict_churn:window' })
expect(call[0]).toMatchObject({ coalesceKey: 'terminal_park_verdict_churn' })
}
})
// Why: `burst` means damping engaged a commit short of React #185 while
// `window` is slow benign churn. A shared key would drop the near-crash
// signal into the slow-churn slot.
it('keeps burst and window park-verdict churn triggers in separate slots', () => {
emitRendererBreadcrumb({
name: 'terminal_park_verdict_churn',
data: { tabId: 'tab-1', trigger: 'burst', flips: 3, elapsedMs: 4 }
})
emitRendererBreadcrumb({
name: 'terminal_park_verdict_churn',
data: { tabId: 'tab-1', trigger: 'window', flips: 12, elapsedMs: 900 }
})
expect(
recordCoalescedCrashBreadcrumbMock.mock.calls.map((call) => call[0].coalesceKey)
).toEqual(['terminal_park_verdict_churn:burst', 'terminal_park_verdict_churn:window'])
})
// Why: every hidden pane is 0x0, so one post-reload reattach wave exhausts
// the fit budget once per mounted pane inside ~60ms. Windows crash
// F0BKR84AHEH lost 26-90% of its 30-entry ring to two such bursts.
+9 -11
View File
@@ -327,13 +327,12 @@ function buildUncapturedCrashReportText(
// erasing the pre-crash trail. Coalesce repeats into one entry that carries a
// suppressed count instead.
const DUPLICATE_TAB_OWNER_BREADCRUMB = 'terminal_tab_id_owned_by_multiple_worktrees'
const PARK_VERDICT_CHURN_BREADCRUMB = 'terminal_park_verdict_churn'
const COALESCED_RENDERER_BREADCRUMB_NAMES = new Set([
'renderer_error',
'renderer_unhandled_rejection',
'terminal_park_verdict_churn',
'terminal_safe_fit_retry_exhausted',
DUPLICATE_TAB_OWNER_BREADCRUMB,
PARK_VERDICT_CHURN_BREADCRUMB,
TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB
])
const RENDERER_BREADCRUMB_COALESCE_MS = 30_000
@@ -346,7 +345,10 @@ const RENDERER_BREADCRUMB_COALESCE_MS = 30_000
// mounted pane within ~60ms. Windows crash F0BKR84AHEH lost 26-90% of its
// 30-entry ring to two such bursts. `suppressedSinceLast` keeps the pane count
// — the only signal these carry — in one slot.
const NAME_ONLY_COALESCED_BREADCRUMB_NAMES = new Set(['terminal_safe_fit_retry_exhausted'])
const NAME_ONLY_COALESCED_BREADCRUMB_NAMES = new Set([
'terminal_park_verdict_churn',
'terminal_safe_fit_retry_exhausted'
])
function rendererBreadcrumbCoalesceKey(
name: string,
@@ -355,14 +357,10 @@ function rendererBreadcrumbCoalesceKey(
if (NAME_ONLY_COALESCED_BREADCRUMB_NAMES.has(name)) {
return name
}
// Why trigger and not name alone: `burst` means damping engaged a commit
// short of React #185, `window` means slow benign churn. Collapsing them
// would drop the near-crash signal into a slow-churn slot. Still bounded —
// two slots per storm regardless of tab count.
if (name === PARK_VERDICT_CHURN_BREADCRUMB) {
return `${name}:${String(data?.trigger ?? '')}`
}
// Preserve distinct GPU failures and atlas-reset triggers while coalescing each storm.
// Why kind and not name alone: a context loss (GPU/driver gave up on this
// renderer) and an atlas reset (routine post-wake repaint) must never
// suppress each other. Within one kind the count is the whole signal — every
// live pane emits on a GPU death.
if (name === TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB) {
const kind = String(data?.kind ?? '')
const reason = kind === 'webgl-atlas-reset' ? data?.reason : undefined
+327 -65
View File
@@ -128,14 +128,27 @@ import {
recordTerminalWorktreeParkingDebugVerdicts
} from './terminal-pane/terminal-parking-e2e-overrides'
import { selectEvictionExemptTerminalTabIds } from './terminal-pane/terminal-eviction-exempt-tabs'
import { selectSleepingRecordParkExemptTabIds } from './terminal-pane/sleeping-record-park-exemption'
import {
canWatcherCoverParkedTerminalTab,
disposeAllParkedTerminalWatchers,
planParkedTerminalTabWatcherCoverage,
pruneParkedTerminalWatchers,
subscribeParkedTerminalWatcherOwnershipLoss,
shouldDeferParkedPtyExitTabClose,
syncParkedTerminalTabWatchers,
terminalWatcherLiveWorkspaceIds
syncParkedTerminalTabWatchersWithAcknowledgements,
terminalWatcherLiveWorkspaceIds,
type TerminalParkedWatcherCoveragePlan
} from './terminal-pane/terminal-parked-tab-watchers'
import {
acknowledgeTerminalParkEpisodeSet,
reconcileTerminalParkEpisodeSet,
selectAcknowledgedTerminalParkEpisodeUnmounts,
selectTerminalParkEpisodeWatcherTabIds,
type TerminalParkEpisodeSet
} from './terminal-pane/terminal-park-episode-set'
import { terminalParkedWatcherPlanPtyIds } from './terminal-pane/terminal-park-episode-lease'
import { createParkedTerminalWatcherTopologyKey } from './terminal-pane/terminal-parked-watcher-reconciliation'
import { isMainTerminalSideEffectAuthorityForPty } from './terminal-pane/terminal-side-effect-facts-handler'
import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue'
import { setWindowCloseRequestHandler } from './window-close-request-coordinator'
@@ -333,6 +346,7 @@ function Terminal(): React.JSX.Element | null {
const terminalRetentionBudgetEnabled = useAppStore(
(s) => s.settings?.terminalHiddenWorktreeRetentionBudget !== false
)
const sleepingAgentSessionsByPaneKey = useAppStore((s) => s.sleepingAgentSessionsByPaneKey)
const browserGuestRetentionBudgetEnabled = useAppStore(
(s) => s.settings?.browserGuestWorktreeRetentionBudget !== false
)
@@ -420,7 +434,34 @@ function Terminal(): React.JSX.Element | null {
: [],
[renderedActiveWorktreeId, tabsByWorktree]
)
useTerminalProviderSnapshotCapability(workspaceSessionReady && hydrationSucceeded)
const terminalProviderCapabilityRevision = useTerminalProviderSnapshotCapability(
workspaceSessionReady && hydrationSucceeded
)
const terminalParkedWatcherLayoutsByTabId = useAppStore(
useShallow((state) => {
const layouts: typeof state.terminalLayoutsByTabId = {}
for (const workspace of workspaceSurfaces) {
for (const tab of tabsByWorktree[workspace.id] ?? []) {
const layout = state.terminalLayoutsByTabId[tab.id]
if (layout) {
layouts[tab.id] = layout
}
}
}
return layouts
})
)
const terminalParkedWatcherTopologyKey = useMemo(
() =>
JSON.stringify(
workspaceSurfaces.map((workspace) =>
createParkedTerminalWatcherTopologyKey(workspace.id, tabsByWorktree[workspace.id] ?? [], {
terminalLayoutsByTabId: terminalParkedWatcherLayoutsByTabId
})
)
),
[tabsByWorktree, terminalParkedWatcherLayoutsByTabId, workspaceSurfaces]
)
// Why: TabBar portals into the titlebar (target created by App.tsx) so tabs share the "Orca" title row.
const titlebarTabsTarget = document.getElementById('titlebar-tabs')
@@ -800,8 +841,13 @@ function Terminal(): React.JSX.Element | null {
})
const effectiveParkedTerminalWorktreeIds = useMemo(
() =>
combineTerminalWorktreeParkIds(parkedTerminalWorktreeIds, manuallyParkedTerminalWorktreeIds),
[manuallyParkedTerminalWorktreeIds, parkedTerminalWorktreeIds]
terminalParkingEnabled
? combineTerminalWorktreeParkIds(
parkedTerminalWorktreeIds,
manuallyParkedTerminalWorktreeIds
)
: new Set<string>(),
[manuallyParkedTerminalWorktreeIds, parkedTerminalWorktreeIds, terminalParkingEnabled]
)
// Why separate from parkedTerminalWorktreeIds: force-parked worktrees keep
// their eviction-exempt tabs' panes mounted (per-tab exclusion), which
@@ -816,6 +862,14 @@ function Terminal(): React.JSX.Element | null {
const [evictionExemptTerminalTabIds, setEvictionExemptTerminalTabIds] = useState<
ReadonlySet<string>
>(() => new Set())
const legacyParkEpisodeLeasesRef = useRef<TerminalParkEpisodeSet>(new Map())
const legacyParkPlansByTabIdRef = useRef<ReadonlyMap<string, TerminalParkedWatcherCoveragePlan>>(
new Map()
)
const legacyForcedParkTabIdsRef = useRef<ReadonlySet<string>>(new Set())
const [legacyParkedTerminalTabIds, setLegacyParkedTerminalTabIds] = useState<ReadonlySet<string>>(
() => new Set()
)
// 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.
@@ -824,6 +878,25 @@ function Terminal(): React.JSX.Element | null {
const activationDeferredMountTabIdsByWorktreeRef = useRef(new Map<string, ReadonlySet<string>>())
// Why: run the cold-activation deferral decision once per activation transition, not on every re-render.
const lastActivationWorktreeIdRef = useRef<string | null>(null)
const revealRejectedActivationDeferredTab = useCallback(
(worktreeId: string, tabId: string): void => {
if (!activationDeferredMountTabIdsByWorktreeRef.current.get(worktreeId)?.has(tabId)) {
return
}
const allTabIds = (useAppStore.getState().tabsByWorktree[worktreeId] ?? []).map(
(tab) => tab.id
)
revealActivationDeferredTabs({
restrictions: backgroundMountTabIdsByWorktreeRef.current,
deferredMountTabIdsByWorktree: activationDeferredMountTabIdsByWorktreeRef.current,
worktreeId,
allTabIds,
immediateTabIds: new Set([tabId])
})
setBackgroundMountRevision((revision) => revision + 1)
},
[]
)
useEffect(() => {
const timers = measurableBackgroundWorktreeTimersRef.current
const closeDialogDebounceTimers = closeDialogDebounceTimersRef.current
@@ -986,21 +1059,18 @@ function Terminal(): React.JSX.Element | null {
restorePolicy,
...overrides
})
// Why memoized: the retention pass below re-asks coverage for EVERY mounted
// worktree's tabs, not just the parked few — each call re-reads the store and
// can walk the layout tree, so cache per tab for this pass.
const watcherCoverageByTabId = new Map<string, boolean>()
const watcherCoveragePlanByTabId = new Map<string, TerminalParkedWatcherCoveragePlan>()
const worktreeTabsAreWatcherCovered = (worktreeId: string, tabs: TerminalTab[]): boolean =>
tabs.every((tab) => {
const cached = watcherCoverageByTabId.get(tab.id)
if (cached !== undefined) {
return cached
const cached = watcherCoveragePlanByTabId.get(tab.id)
if (cached) {
return cached.status === 'covered'
}
const covered = canWatcherCoverParkedTerminalTab(worktreeId, tab)
watcherCoverageByTabId.set(tab.id, covered)
return covered
const plan = planParkedTerminalTabWatcherCoverage(worktreeId, tab)
watcherCoveragePlanByTabId.set(tab.id, plan)
return plan.status === 'covered'
})
// Why: a worktree with any watcher-uncoverable tab must never park, or it goes silent for bells/titles/completions (sank the first parking attempt).
// Ordinary parking requires a semantic handoff plan for every tab.
for (const worktreeId of Array.from(nextParkedTerminalWorktreeIds)) {
if (!worktreeTabsAreWatcherCovered(worktreeId, tabsByWorktree[worktreeId] ?? [])) {
nextParkedTerminalWorktreeIds.delete(worktreeId)
@@ -1162,7 +1232,9 @@ function Terminal(): React.JSX.Element | null {
renderedActiveWorktreeId,
tabsByWorktree,
terminalParkingEnabled,
terminalParkedWatcherTopologyKey,
terminalParkingRevision,
terminalProviderCapabilityRevision,
terminalRetentionBudgetEnabled,
terminalSshParkingEnabled,
workspaceSurfaces
@@ -1324,7 +1396,7 @@ function Terminal(): React.JSX.Element | null {
isTabDeferrable: (tabId) => {
const tab = tabById.get(tabId)
return (
// Why: byte-mode watchers can't reconstruct pre-registration output; remote/unresolved ownership mounts eagerly since only a local daemon has snapshots.
// Why: only authoritative hosts can rebuild pane content beyond the bounded handoff buffer.
coldActivationDeferralEnabled &&
activationHostSupportsDeferral &&
tab !== undefined &&
@@ -1389,7 +1461,153 @@ function Terminal(): React.JSX.Element | null {
groupsByWorktree,
activeGroupIdByWorktree
)
// Why: legacy (non-split) host owns watcher reconciliation; split mode's overlay layers own theirs, so only dispose worktrees with no overlay layer.
useEffect(() => {
const requestedTabIds = new Set<string>()
const forcedTabIds = new Set<string>()
const requestedTabs: TerminalTab[] = []
const worktreeIdByTabId = new Map<string, string>()
if (!anyMountedWorktreeHasLayout && terminalParkingEnabled) {
for (const workspace of workspaceSurfaces) {
if (!mountedWorktreeIdsRef.current.has(workspace.id)) {
continue
}
const isVisible = activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
const shouldMeasure =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
const worktreePark =
!isVisible && !shouldMeasure && effectiveParkedTerminalWorktreeIds.has(workspace.id)
const forcePark = worktreePark && forceParkedTerminalWorktreeIds.has(workspace.id)
const deferredTabIds = activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id)
const sleepingRecordOwnedTabIds = selectSleepingRecordParkExemptTabIds(
sleepingAgentSessionsByPaneKey,
workspace.id
)
for (const tab of tabsByWorktree[workspace.id] ?? []) {
const portaled =
findActivityTerminalPortal(activityTerminalPortals, {
worktreeId: workspace.id,
tabId: tab.id
}) !== null
const requestedByWorktree =
worktreePark && !portaled && !evictionExemptTerminalTabIds.has(tab.id)
const requestedByDeferral =
deferredTabIds?.has(tab.id) === true &&
!portaled &&
!sleepingRecordOwnedTabIds.has(tab.id)
if (!requestedByWorktree && !requestedByDeferral) {
continue
}
requestedTabs.push(tab)
requestedTabIds.add(tab.id)
worktreeIdByTabId.set(tab.id, workspace.id)
if (forcePark && requestedByWorktree) {
forcedTabIds.add(tab.id)
}
}
}
}
const reconciled = reconcileTerminalParkEpisodeSet({
current: legacyParkEpisodeLeasesRef.current,
tabs: requestedTabs,
requestedTabIds,
forcedTabIds,
plan: (tab) => {
const worktreeId = worktreeIdByTabId.get(tab.id)
if (!worktreeId) {
throw new Error(`Missing terminal worktree for ${tab.id}`)
}
return planParkedTerminalTabWatcherCoverage(worktreeId, tab)
}
})
let nextLeases = reconciled.leases
const acknowledgementRequiredTabIds = new Set<string>()
if (!anyMountedWorktreeHasLayout) {
for (const workspace of workspaceSurfaces) {
const deferredTabIds =
activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null
if (
!deferredTabIds ||
!Array.from(deferredTabIds).some((tabId) => reconciled.unmountTabIds.has(tabId))
) {
continue
}
const tabs = tabsByWorktree[workspace.id] ?? []
const watcherReadyTabIds = new Set(
tabs
.map((tab) => tab.id)
.filter(
(tabId) =>
reconciled.unmountTabIds.has(tabId) &&
(legacyParkedTerminalTabIds.has(tabId) || deferredTabIds.has(tabId))
)
)
const acknowledgements = syncParkedTerminalTabWatchersWithAcknowledgements({
worktreeId: workspace.id,
tabs,
parkedTabIds: watcherReadyTabIds,
coveragePlansByTabId: reconciled.plansByTabId,
forcedTabIds,
restoreTitleOnStartTabIds: deferredTabIds
})
for (const tabId of watcherReadyTabIds) {
if (deferredTabIds.has(tabId)) {
acknowledgementRequiredTabIds.add(tabId)
}
}
nextLeases = acknowledgeTerminalParkEpisodeSet(nextLeases, acknowledgements).leases
}
}
const nextUnmountTabIds = selectAcknowledgedTerminalParkEpisodeUnmounts({
leases: nextLeases,
plansByTabId: reconciled.plansByTabId,
requestedTabIds,
forcedTabIds,
acknowledgementRequiredTabIds
})
legacyParkEpisodeLeasesRef.current = nextLeases
legacyParkPlansByTabIdRef.current = reconciled.plansByTabId
legacyForcedParkTabIdsRef.current = forcedTabIds
if (!anyMountedWorktreeHasLayout) {
for (const workspace of workspaceSurfaces) {
for (const tabId of activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id) ??
[]) {
if (!nextUnmountTabIds.has(tabId)) {
revealRejectedActivationDeferredTab(workspace.id, tabId)
}
}
}
}
setLegacyParkedTerminalTabIds((current) =>
haveSameIdSet(current, nextUnmountTabIds) ? current : nextUnmountTabIds
)
}, [
activeTabId,
activeTabIdByWorktree,
activeView,
activityTerminalPortals,
anyMountedWorktreeHasLayout,
backgroundMountRevision,
effectiveParkedTerminalWorktreeIds,
evictionExemptTerminalTabIds,
forceParkedTerminalWorktreeIds,
groupsByWorktree,
legacyParkedTerminalTabIds,
pendingStartupByTabId,
pairedRuntimeParkingEnvironmentIds,
renderedActiveWorktreeId,
revealRejectedActivationDeferredTab,
sleepingAgentSessionsByPaneKey,
tabsByWorktree,
terminalParkedWatcherTopologyKey,
terminalParkingEnabled,
terminalProviderCapabilityRevision,
terminalSshParkingEnabled,
terminalTitleSnapshotAuthorityEnabled,
workspaceSessionReady,
workspaceSurfaces
])
// Legacy owns these watchers; split overlay hooks own worktrees with layouts.
useEffect(() => {
pruneParkedTerminalWatchers(
terminalWatcherLiveWorkspaceIds(workspaceSurfaces.map((workspace) => workspace.id))
@@ -1403,59 +1621,55 @@ function Terminal(): React.JSX.Element | null {
continue
}
const tabs = tabsByWorktree[workspace.id] ?? []
const parkedTabIds = new Set<string>()
let deferredTabIds: ReadonlySet<string> | null = null
if (!anyMountedWorktreeHasLayout && mountedWorktreeIdsRef.current.has(workspace.id)) {
const isVisible = activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
const parked =
!isVisible &&
!shouldMeasureHiddenWorktree &&
effectiveParkedTerminalWorktreeIds.has(workspace.id)
if (parked) {
for (const tab of tabs) {
const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, {
worktreeId: workspace.id,
tabId: tab.id
})
// Why the exempt exclusion: force-park keeps eviction-exempt tabs'
// panes mounted (a remount would orphan their live pty) — same
// per-tab carve-out as Activity portals, so no watcher owns them.
// The set is resolved in the force-park pass and holds only those
// worktrees' tabs.
if (!activityTerminalPortal && !evictionExemptTerminalTabIds.has(tab.id)) {
parkedTabIds.add(tab.id)
}
}
}
// Why: activation-deferred tabs are unmounted like parked ones — the same byte watchers own their side effects until first reveal.
deferredTabIds =
activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null
for (const tab of tabs) {
if (
deferredTabIds?.has(tab.id) &&
!parkedTabIds.has(tab.id) &&
canWatcherCoverParkedTerminalTab(workspace.id, tab) &&
!findActivityTerminalPortal(activityTerminalPortals, {
worktreeId: workspace.id,
tabId: tab.id
})
) {
parkedTabIds.add(tab.id)
}
}
}
syncParkedTerminalTabWatchers({
const deferredTabIds =
activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null
const parkedTabIds = selectTerminalParkEpisodeWatcherTabIds({
worktreeId: workspace.id,
tabIds: tabs.map((tab) => tab.id),
leases: legacyParkEpisodeLeasesRef.current,
plansByTabId: legacyParkPlansByTabIdRef.current,
renderedUnmountTabIds: legacyParkedTerminalTabIds,
...(deferredTabIds ? { activationDeferredTabIds: deferredTabIds } : {})
})
const coveragePlansByTabId = new Map(
[...legacyParkPlansByTabIdRef.current].filter(([tabId]) => parkedTabIds.has(tabId))
)
const forcedTabIds = new Set(
[...legacyForcedParkTabIdsRef.current].filter((tabId) => parkedTabIds.has(tabId))
)
const acknowledgements = syncParkedTerminalTabWatchersWithAcknowledgements({
worktreeId: workspace.id,
tabs,
parkedTabIds,
coveragePlansByTabId,
forcedTabIds,
// Why: activation-deferred tabs never mounted a pane to restore their title, unlike ordinary parked tabs.
...(deferredTabIds ? { restoreTitleOnStartTabIds: deferredTabIds } : {})
})
const acknowledged = acknowledgeTerminalParkEpisodeSet(
legacyParkEpisodeLeasesRef.current,
acknowledgements
)
legacyParkEpisodeLeasesRef.current = acknowledged.leases
if (acknowledged.renderChanged) {
const failedTabIds = new Set(
acknowledgements
.filter((acknowledgement) => acknowledgement.status === 'failed')
.map((acknowledgement) => acknowledgement.tabId)
)
setLegacyParkedTerminalTabIds((current) => {
const next = new Set([...current].filter((tabId) => !failedTabIds.has(tabId)))
return haveSameIdSet(current, next) ? current : next
})
for (const tabId of failedTabIds) {
if (deferredTabIds?.has(tabId)) {
revealRejectedActivationDeferredTab(workspace.id, tabId)
}
}
}
}
}, [
// Why activeTabId: revealing a deferred tab mutates the mount restriction in the same render; sync must re-run so its watcher disposes before the pane attaches.
// Why activeTabId: reveal mutates the mount restriction during render; re-run so current leases release stale watcher ownership in the same effect flush.
activeTabId,
activeView,
activityTerminalPortals,
@@ -1465,15 +1679,53 @@ function Terminal(): React.JSX.Element | null {
evictionExemptTerminalTabIds,
getEffectiveLayoutForWorktree,
groupsByWorktree,
effectiveParkedTerminalWorktreeIds,
legacyParkedTerminalTabIds,
pendingStartupByTabId,
pairedRuntimeParkingEnvironmentIds,
renderedActiveWorktreeId,
revealRejectedActivationDeferredTab,
tabsByWorktree,
terminalParkedWatcherTopologyKey,
terminalParkingEnabled,
terminalProviderCapabilityRevision,
terminalSshParkingEnabled,
terminalTitleSnapshotAuthorityEnabled,
workspaceSessionReady,
workspaceSurfaces
])
useEffect(
() =>
subscribeParkedTerminalWatcherOwnershipLoss((event) => {
const lease = legacyParkEpisodeLeasesRef.current.get(event.tabId)
if (!lease || lease.phase === 'forced' || lease.plan.worktreeId !== event.worktreeId) {
return
}
const acknowledged = acknowledgeTerminalParkEpisodeSet(legacyParkEpisodeLeasesRef.current, [
{
status: 'failed',
tabId: event.tabId,
materialKey: lease.plan.materialKey,
reason: 'watcher-ownership-lost',
expectedPtyIds: terminalParkedWatcherPlanPtyIds(lease.plan),
watchedPtyIds: []
}
])
if (!acknowledged.renderChanged) {
return
}
legacyParkEpisodeLeasesRef.current = acknowledged.leases
setLegacyParkedTerminalTabIds((current) => {
if (!current.has(event.tabId)) {
return current
}
const next = new Set(current)
next.delete(event.tabId)
return next
})
revealRejectedActivationDeferredTab(event.worktreeId, event.tabId)
}),
[revealRejectedActivationDeferredTab]
)
// Why: on host unmount no reconciliation effect runs again, so dispose every remaining parked watcher.
useEffect(() => () => disposeAllParkedTerminalWatchers(), [])
// Auto-create first tab when worktree activates
@@ -2467,6 +2719,7 @@ function Terminal(): React.JSX.Element | null {
activationDeferredMountTabIds={
activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null
}
onActivationDeferredWatcherHandoffFailed={revealRejectedActivationDeferredTab}
/>
)
})}
@@ -2531,6 +2784,7 @@ function Terminal(): React.JSX.Element | null {
// tabs stay mounted because a remount would orphan their live pty.
if (
shouldColdParkTerminalPanes &&
legacyParkedTerminalTabIds.has(tab.id) &&
!isActivityPortalTab &&
!evictionExemptTerminalTabIds.has(tab.id)
) {
@@ -2540,6 +2794,7 @@ function Terminal(): React.JSX.Element | null {
<TerminalPane
key={`${tab.id}-${tab.generation ?? 0}`}
tabId={tab.id}
terminalGeneration={tab.generation}
worktreeId={workspace.id}
cwd={tab.startupCwd ?? workspace.path}
isActive={
@@ -2727,7 +2982,8 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
isForceParked,
activityTerminalPortals,
backgroundMountTabIds,
activationDeferredMountTabIds
activationDeferredMountTabIds,
onActivationDeferredWatcherHandoffFailed
}: {
worktreeId: string
worktreePath: string
@@ -2740,6 +2996,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
activityTerminalPortals: ActivityTerminalPortalTarget[]
backgroundMountTabIds: ReadonlySet<string> | null
activationDeferredMountTabIds: ReadonlySet<string> | null
onActivationDeferredWatcherHandoffFailed: (worktreeId: string, tabId: string) => void
}): React.JSX.Element {
const browserPageIds = useAppStore(
useShallow((state) =>
@@ -2752,6 +3009,10 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
const hasMobileDrivenBrowser = useBrowserMobileDriverForAny(browserPageIds)
const shouldKeepPaintable =
shouldMeasureHiddenWorktree || hasAutomationVisibleBrowser || hasMobileDrivenBrowser
const revealRejectedActivationDeferredTab = useCallback(
(tabId: string) => onActivationDeferredWatcherHandoffFailed(worktreeId, tabId),
[onActivationDeferredWatcherHandoffFailed, worktreeId]
)
return (
<div
@@ -2782,6 +3043,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
activityTerminalPortals={activityTerminalPortals}
backgroundMountTabIds={backgroundMountTabIds}
activationDeferredMountTabIds={activationDeferredMountTabIds}
onActivationDeferredWatcherHandoffFailed={revealRejectedActivationDeferredTab}
/>
{/* Why: once eligible, retain slot DOM so hidden worktrees keep their Electron guests alive (STA-3228). */}
<RetainedBrowserPaneOverlayLayer
@@ -218,6 +218,7 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
<TerminalPane
key={`${terminalTabId}-${terminalGeneration ?? 0}`}
tabId={terminalTabId}
terminalGeneration={terminalGeneration}
worktreeId={worktreeId}
cwd={startupCwd ?? worktreePath}
isActive={isActive || activityTerminalPortal?.active === true}
@@ -237,6 +237,7 @@ import { refreshTerminalImeInputContext } from './terminal-ime-input-context-ref
type TerminalPaneProps = {
tabId: string
terminalGeneration?: number
worktreeId: string
cwd?: string
isActive: boolean
@@ -287,6 +288,7 @@ function formatClipboardImagePasteError(error: unknown): string {
function TerminalPane(
{
tabId,
terminalGeneration,
worktreeId,
cwd,
isActive,
@@ -1324,6 +1326,7 @@ function TerminalPane(
useTerminalPaneLifecycle({
tabId,
terminalGeneration,
worktreeId,
cwd,
startup,
@@ -31,7 +31,8 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
shouldMeasureHiddenWorktree = false,
activityTerminalPortals = EMPTY_ACTIVITY_PORTALS,
backgroundMountTabIds = null,
activationDeferredMountTabIds = null
activationDeferredMountTabIds = null,
onActivationDeferredWatcherHandoffFailed
}: {
worktreeId: string
worktreePath: string
@@ -45,6 +46,7 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
backgroundMountTabIds?: ReadonlySet<string> | null
/** Cold-activation deferred tabs receive immediate parked watcher coverage. */
activationDeferredMountTabIds?: ReadonlySet<string> | null
onActivationDeferredWatcherHandoffFailed?: (tabId: string) => void
}): React.JSX.Element | null {
const { terminalTabs, unifiedTabs, groups, activeGroupId } = useAppStore(
useShallow((state) => ({
@@ -109,7 +111,8 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
isForceParked,
shouldMeasureHiddenWorktree,
activityTerminalPortals,
activationDeferredMountTabIds
activationDeferredMountTabIds,
onActivationDeferredWatcherHandoffFailed
})
if (!worktreePath) {
@@ -0,0 +1,148 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as ParkedTerminalCommandStatus from './parked-terminal-command-status'
const PTY_ID = 'pty-construction-failure'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const commandStatusPolicy = {
onCommandFinished: vi.fn(),
onCommandCodeWorking: vi.fn(),
onCommandCodeDone: vi.fn(),
dispose: vi.fn()
}
vi.mock('./parked-terminal-command-status', async (importOriginal) => ({
...(await importOriginal<typeof ParkedTerminalCommandStatus>()),
createParkedTerminalCommandStatusPolicy: vi.fn(() => commandStatusPolicy)
}))
vi.mock('@/lib/terminal-theme', () => ({ getSystemPrefersDark: () => true }))
vi.mock('./use-notification-dispatch', () => ({ dispatchTerminalNotification: vi.fn() }))
const storeState = {
settings: { theme: 'system', terminalMainSideEffectAuthority: false },
agentStatusByPaneKey: {},
markWorktreeUnread: vi.fn(),
markTerminalTabUnread: vi.fn()
}
vi.mock('@/store', () => ({ useAppStore: { getState: () => storeState } }))
describe('parked terminal byte watcher construction', () => {
const originalWindow = (globalThis as { window?: typeof window }).window
let dispatchData: ((payload: { id: string; data: string }) => void) | null
let setPtyDeliveryInterest: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.resetModules()
vi.useFakeTimers()
commandStatusPolicy.dispose.mockClear()
storeState.settings.terminalMainSideEffectAuthority = false
storeState.markWorktreeUnread.mockReset()
storeState.markTerminalTabUnread.mockReset()
dispatchData = null
setPtyDeliveryInterest = vi.fn()
;(globalThis as { window: typeof window }).window = {
...originalWindow,
api: {
...originalWindow?.api,
pty: {
...originalWindow?.api?.pty,
ackData: vi.fn(),
onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => {
dispatchData = callback
return () => {}
}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
setPtyDeliveryInterest
}
}
} as unknown as typeof window
})
afterEach(() => {
vi.useRealTimers()
if (originalWindow) {
;(globalThis as { window: typeof window }).window = originalWindow
} else {
delete (globalThis as { window?: typeof window }).window
}
})
it('rolls back partial resources when buffered adoption throws', async () => {
const { ensurePtyDispatcher, ptyDataSidecars } = await import('./pty-dispatcher')
const { drainPreHandlerPtyData } = await import('./pty-pre-handler-buffer')
const { startParkedTerminalByteWatcher } = await import('./parked-terminal-byte-watcher')
const sendError = new Error('PTY write failed')
ensurePtyDispatcher()
dispatchData?.({ id: PTY_ID, data: '\x1b[?2031h' })
expect(() =>
startParkedTerminalByteWatcher({
ptyId: PTY_ID,
tabId: 'tab-1',
worktreeId: 'worktree-1',
leafId: LEAF_ID,
paneId: 1,
sendInput: () => {
throw sendError
}
})
).toThrow(sendError)
expect(commandStatusPolicy.dispose).toHaveBeenCalledOnce()
expect(ptyDataSidecars.has(PTY_ID)).toBe(false)
expect(setPtyDeliveryInterest.mock.calls).toEqual([
[PTY_ID, true],
[PTY_ID, false]
])
const laterPrimary = vi.fn()
drainPreHandlerPtyData(PTY_ID, laterPrimary)
expect(laterPrimary).not.toHaveBeenCalled()
})
it('disposes its policy when buffered fact registration throws', async () => {
const { _dispatchTerminalSideEffectBatchForTest, registerTerminalSideEffectFactConsumer } =
await import('./terminal-side-effect-facts-handler')
const { startParkedTerminalByteWatcher } = await import('./parked-terminal-byte-watcher')
const seed = registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: {} })
seed()
_dispatchTerminalSideEffectBatchForTest({
ptyId: PTY_ID,
seq: 1,
facts: [{ kind: 'bell' }]
})
const factError = new Error('fact callback failed')
storeState.settings.terminalMainSideEffectAuthority = true
storeState.markWorktreeUnread.mockImplementation(() => {
throw factError
})
expect(() =>
startParkedTerminalByteWatcher({
ptyId: PTY_ID,
tabId: 'tab-1',
worktreeId: 'worktree-1',
leafId: LEAF_ID,
paneId: 1,
sendInput: vi.fn()
})
).toThrow(factError)
expect(commandStatusPolicy.dispose).toHaveBeenCalledOnce()
expect(() =>
_dispatchTerminalSideEffectBatchForTest({
ptyId: PTY_ID,
seq: 2,
facts: [{ kind: 'bell' }]
})
).not.toThrow()
const replacementBell = vi.fn()
registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: { onBell: replacementBell }
})
expect(replacementBell).toHaveBeenCalledOnce()
})
})
@@ -175,6 +175,28 @@ describe('startParkedTerminalByteWatcher', () => {
dispose()
})
it('adopts an OSC title buffered before parked watcher registration', async () => {
const { ensurePtyDispatcher } = await import('./pty-dispatcher')
ensurePtyDispatcher()
emit(WORKING_TITLE_OSC)
const { dispose } = await startWatcher()
flushSideEffects()
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledTimes(1)
dispose()
const { dispose: disposeReplacement } = await startWatcher()
flushSideEffects()
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledTimes(1)
disposeReplacement()
emit('\x1b[?2031h')
const next = await startWatcher()
expect(next.sendInput).toHaveBeenCalledExactlyOnceWith('\x1b[?997;1n')
next.dispose()
})
it('drops the bare cursor-agent native title before it reaches the store', async () => {
const { dispose } = await startWatcher()
@@ -33,6 +33,7 @@ import {
import { dispatchTerminalNotification } from './use-notification-dispatch'
import { acquireHiddenRendererPtyDeliveryClaim } from './pty-renderer-delivery-claims'
import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
import { parkedTerminalLeafDrivesTabTitle } from './terminal-parked-title-authority'
// Why: keep the live path's BEL-vs-completion race window so notification behavior is identical whether a tab is parked or mounted.
const PARKED_NOTIFICATION_GRACE_MS = AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS
@@ -55,7 +56,7 @@ export type ParkedTerminalByteWatcherOptions = {
leafId: string
/** PaneManager pane id the unmounted pane used; the watcher must write this same slot or a stale "working" title strands. */
paneId: number
/** Whether this PTY's pane was the tab's active split — only the focused split drives the tab title. */
/** Fallback title authority when the stored layout has no active leaf. */
drivesTabTitle?: boolean
/** Last runtime title at park time; seeds the agent tracker so an agent working at unmount still fires completion when it goes idle. */
initialTitle?: string
@@ -72,7 +73,7 @@ export function startParkedTerminalByteWatcher(
): () => void {
const { ptyId, tabId, worktreeId, paneId, sendInput } = options
const remoteRuntimePty = isRemoteRuntimePtyId(ptyId)
const drivesTabTitle = options.drivesTabTitle ?? true
const capturedDrivesTabTitle = options.drivesTabTitle ?? true
const paneKey = makePaneKey(tabId, options.leafId)
// Why: one watcher per PTY — a stale watcher from a previous park cycle would double-fire bell/completion for the same bytes.
@@ -128,7 +129,13 @@ export function startParkedTerminalByteWatcher(
const state = useAppStore.getState()
wroteRuntimeTitleSlot = true
state.setRuntimePaneTitle(tabId, paneId, title)
if (drivesTabTitle) {
if (
parkedTerminalLeafDrivesTabTitle({
activeLeafId: state.terminalLayoutsByTabId?.[tabId]?.activeLeafId,
leafId: options.leafId,
capturedAuthority: capturedDrivesTabTitle
})
) {
state.updateTabTitle(tabId, title)
}
},
@@ -251,34 +258,9 @@ export function startParkedTerminalByteWatcher(
onWorking: commandStatusPolicy.onCommandCodeWorking,
onDone: commandStatusPolicy.onCommandCodeDone
})
const unregisterFactConsumer = factSideEffectAuthority
? registerTerminalSideEffectFactConsumer({
ptyId,
// Why: ordinary park already has a pane-owned title; the flag below requests a snapshot only when no pane did.
callbacks: {
...sideEffectCallbacks,
onCommandFinished: commandStatusPolicy.onCommandFinished,
onCommandCodeWorking: commandStatusPolicy.onCommandCodeWorking,
onCommandCodeDone: commandStatusPolicy.onCommandCodeDone,
onPrLink: (link) =>
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link),
// Why (gate mode only): the 2031 subscribe arrives as a fact, but the reply stays here — query authority stays with the view/watcher (invariant 6).
...(factOwnsMode2031 ? { onMode2031Subscribe: sendMode2031Reply } : {})
},
// Why: activation-deferred tabs can start a watcher before any pane restored the title; ordinary parked tabs avoid this IPC.
restoreTitleOnRegister: options.restoreTitleOnRegister === true
})
: null
// Why: no xterm answers DECSET 2031 while parked; with the gate ON, the responder's sidecar would force-feed bytes to the gated PTY, so skip it.
const stopMode2031Responder = factOwnsMode2031
? null
: startParkedTerminalMode2031Responder({ ptyId, sendInput })
// Why: parked tabs are the canonical hidden view — mark the PTY gated so main stops renderer byte delivery.
const releaseHiddenDeliveryClaim = hiddenDeliveryGateActive
? acquireHiddenRendererPtyDeliveryClaim(ptyId)
: null
let unregisterFactConsumer: (() => void) | null = null
let stopMode2031Responder: (() => void) | null = null
let releaseHiddenDeliveryClaim: (() => void) | null = null
const processLiveData = (data: string): void => {
if (!processor) {
@@ -293,10 +275,9 @@ export function startParkedTerminalByteWatcher(
}
}
}
// Why: paired hosts forward derived facts over the one environment event
// stream; parked PTYs never consume terminal multiplex slots or raw bytes.
const unsubscribeByteParsers =
processor === null ? null : subscribeToPtyData(ptyId, processLiveData)
// Why: covered parks restore from an authoritative host snapshot; consuming the
// raw gap prevents duplicate side effects on reveal (force-parks stay best-effort).
let unsubscribeByteParsers: (() => void) | null = null
const dispose = (): void => {
if (disposed) {
@@ -324,6 +305,37 @@ export function startParkedTerminalByteWatcher(
parkedWatcherDisposersByPtyId.delete(ptyId)
}
}
try {
unregisterFactConsumer = factSideEffectAuthority
? registerTerminalSideEffectFactConsumer({
ptyId,
callbacks: {
...sideEffectCallbacks,
onCommandFinished: commandStatusPolicy.onCommandFinished,
onCommandCodeWorking: commandStatusPolicy.onCommandCodeWorking,
onCommandCodeDone: commandStatusPolicy.onCommandCodeDone,
onPrLink: (link) =>
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link),
...(factOwnsMode2031 ? { onMode2031Subscribe: sendMode2031Reply } : {})
},
restoreTitleOnRegister: options.restoreTitleOnRegister === true
})
: null
// Why: gated parked PTYs answer mode 2031 from facts, never raw bytes.
stopMode2031Responder = factOwnsMode2031
? null
: startParkedTerminalMode2031Responder({ ptyId, sendInput })
releaseHiddenDeliveryClaim = hiddenDeliveryGateActive
? acquireHiddenRendererPtyDeliveryClaim(ptyId)
: null
unsubscribeByteParsers =
processor === null
? null
: subscribeToPtyData(ptyId, processLiveData, { adoptPreHandlerData: true })
} catch (error) {
dispose()
throw error
}
parkedWatcherDisposersByPtyId.set(ptyId, dispose)
return dispose
}
@@ -0,0 +1,17 @@
export type PtyDataSidecarDeliveryFailure = { error: unknown }
/** Delivers one chunk to a stable cohort and retains its first callback error. */
export function deliverPtyDataToSidecarCohort(
sidecars: Iterable<(data: string) => void>,
data: string
): PtyDataSidecarDeliveryFailure | null {
let failure: PtyDataSidecarDeliveryFailure | null = null
for (const sidecar of Array.from(sidecars)) {
try {
sidecar(data)
} catch (error) {
failure ??= { error }
}
}
return failure
}
@@ -1,9 +1,22 @@
import { acquirePtyDeliveryInterest } from './pty-delivery-interest'
import { ensurePtyDispatcher, ptyDataSidecars } from './pty-dispatcher'
import { ensurePtyDispatcher, ptyDataHandlers, ptyDataSidecars } from './pty-dispatcher'
import { clearPreHandlerPtyData, drainPreHandlerPtyData } from './pty-pre-handler-buffer'
import {
deliverPtyDataToSidecarCohort,
type PtyDataSidecarDeliveryFailure
} from './pty-data-sidecar-delivery'
export type PtyDataSidecarSubscriptionOptions = {
adoptPreHandlerData?: boolean
}
/** Register a side-channel data watcher for a PTY without taking ownership
* of the primary handler. Returns an unsubscribe fn. */
export function subscribeToPtyData(ptyId: string, watcher: (data: string) => void): () => void {
export function subscribeToPtyData(
ptyId: string,
watcher: (data: string) => void,
options?: PtyDataSidecarSubscriptionOptions
): () => void {
ensurePtyDispatcher()
// Why: a sidecar is, by definition, a raw-byte consumer — its registration
// doubles as the delivery-interest signal that suppresses main's
@@ -14,16 +27,44 @@ export function subscribeToPtyData(ptyId: string, watcher: (data: string) => voi
set = new Set()
ptyDataSidecars.set(ptyId, set)
}
set.add(watcher)
return () => {
const sidecar = options?.adoptPreHandlerData
? (data: string): void => {
if (!ptyDataSidecars.get(ptyId)?.has(sidecar)) {
return
}
if (!ptyDataHandlers.has(ptyId)) {
clearPreHandlerPtyData(ptyId)
}
watcher(data)
}
: watcher
set.add(sidecar)
const unsubscribe = (): void => {
releaseDeliveryInterest()
const current = ptyDataSidecars.get(ptyId)
if (!current) {
return
}
current.delete(watcher)
current.delete(sidecar)
if (current.size === 0) {
ptyDataSidecars.delete(ptyId)
}
}
try {
if (options?.adoptPreHandlerData && !ptyDataHandlers.has(ptyId)) {
const adoption: { failure: PtyDataSidecarDeliveryFailure | null } = { failure: null }
drainPreHandlerPtyData(ptyId, (data) => {
const sidecars = ptyDataSidecars.get(ptyId)
const chunkFailure = sidecars ? deliverPtyDataToSidecarCohort(sidecars, data) : null
adoption.failure ??= chunkFailure
})
if (adoption.failure) {
throw adoption.failure.error
}
}
} catch (error) {
unsubscribe()
throw error
}
return unsubscribe
}
@@ -14,6 +14,12 @@ import {
drainPreHandlerPtyExit
} from './pty-pre-handler-buffer'
import { deliverPtyExitToHandlers } from './pty-exit-delivery'
import {
ptyExitSidecars,
registerPtyExitSidecar,
type PtyExitSubscriptionOptions,
type PtyExitWatcher
} from './pty-exit-sidecar-subscriptions'
import {
clearReceivedPtyCharTotal,
isPtyPushDeliveryBlackholed,
@@ -32,6 +38,7 @@ import {
ptyReplayHandlers
} from './pty-shutdown-data-suspension'
import { markCommittedPtyShutdowns } from './pty-shutdown-exit-deferral'
import { deliverPtyDataToSidecarCohort } from './pty-data-sidecar-delivery'
export {
ptyDataHandlers,
@@ -60,10 +67,6 @@ export type PtyDataMeta = {
/** Sidecar PTY-data observers, invoked AFTER the primary handler so a side-effect-only watcher can't delay xterm rendering. */
/** Per-PTY replay handlers on a dedicated pty:replay channel so the renderer can engage the replay guard and suppress xterm auto-replies. */
const ptyExitSidecars = new Map<
string,
Set<(code: number, context: { hadPrimary: boolean }) => void>
>()
export const ptyWriteUnavailableHandlers = new Map<string, () => void>()
let ptyDispatcherAttached = false
@@ -155,10 +158,9 @@ function handleDispatchedPtyData(payload: {
}
const sidecars = ptyDataSidecars.get(payload.id)
if (sidecars && sidecars.size > 0) {
// Why: snapshot before iterating — watchers often unsubscribe (or subscribe siblings) mid-iteration, and mutating the live Set would skip or double-fire.
const snapshot = Array.from(sidecars)
for (const watcher of snapshot) {
watcher(payload.data)
const failure = deliverPtyDataToSidecarCohort(sidecars, payload.data)
if (failure) {
throw failure.error
}
}
}
@@ -222,27 +224,15 @@ function attachPtySecondaryPushListeners(unsubscribes: (() => void)[]): void {
window.api.pty.rendererDispatcherReady?.()
}
export type { PtyExitSubscriptionOptions } from './pty-exit-sidecar-subscriptions'
export function subscribeToPtyExit(
ptyId: string,
watcher: (code: number, context: { hadPrimary: boolean }) => void
watcher: PtyExitWatcher,
options?: PtyExitSubscriptionOptions
): () => void {
ensurePtyDispatcher()
let set = ptyExitSidecars.get(ptyId)
if (!set) {
set = new Set()
ptyExitSidecars.set(ptyId, set)
}
set.add(watcher)
return () => {
const current = ptyExitSidecars.get(ptyId)
if (!current) {
return
}
current.delete(watcher)
if (current.size === 0) {
ptyExitSidecars.delete(ptyId)
}
}
return registerPtyExitSidecar(ptyId, watcher, options)
}
// ─── Eager PTY buffer for reconnection on restart ────────────────────
@@ -0,0 +1,44 @@
import { drainPreHandlerPtyExit, hasPreHandlerPtyExit } from './pty-pre-handler-buffer'
import { ptyDataHandlers, ptyExitHandlers } from './pty-shutdown-data-suspension'
export type PtyExitWatcher = (code: number, context: { hadPrimary: boolean }) => void
export type PtyExitSubscriptionOptions = {
adoptPreHandlerExit?: boolean
}
export const ptyExitSidecars = new Map<string, Set<PtyExitWatcher>>()
export function registerPtyExitSidecar(
ptyId: string,
watcher: PtyExitWatcher,
options?: PtyExitSubscriptionOptions
): () => void {
let set = ptyExitSidecars.get(ptyId)
if (!set) {
set = new Set()
ptyExitSidecars.set(ptyId, set)
}
set.add(watcher)
const unsubscribe = (): void => {
const current = ptyExitSidecars.get(ptyId)
if (!current) {
return
}
current.delete(watcher)
if (current.size === 0) {
ptyExitSidecars.delete(ptyId)
}
}
if (options?.adoptPreHandlerExit && hasPreHandlerPtyExit(ptyId)) {
// Why: data-derived effects use 0ms timers; FIFO keeps final output ahead of exit cleanup.
setTimeout(() => {
const current = ptyExitSidecars.get(ptyId)
if (!current?.has(watcher) || ptyExitHandlers.has(ptyId) || ptyDataHandlers.has(ptyId)) {
return
}
unsubscribe()
drainPreHandlerPtyExit(ptyId, (code) => watcher(code, { hadPrimary: false }))
}, 0)
}
return unsubscribe
}
@@ -3,6 +3,7 @@ import {
bufferPreHandlerPtyData,
bufferPreHandlerPtyExit,
clearConsumedPreHandlerPtyExit,
clearPreHandlerPtyData,
clearPreHandlerPtyState,
consumePreHandlerPtyState,
drainPreHandlerPtyData,
@@ -125,6 +126,20 @@ describe('pre-handler PTY buffer', () => {
expect(hasPreHandlerPtyExit(EXIT_PTY_ID)).toBe(false)
})
it('clears adopted data without discarding a buffered exit', () => {
bufferPreHandlerPtyData(EXIT_PTY_ID, 'final output')
bufferPreHandlerPtyExit(EXIT_PTY_ID, 7)
clearPreHandlerPtyData(EXIT_PTY_ID)
const data = vi.fn()
const exit = vi.fn()
drainPreHandlerPtyData(EXIT_PTY_ID, data)
drainPreHandlerPtyExit(EXIT_PTY_ID, exit)
expect(data).not.toHaveBeenCalled()
expect(exit).toHaveBeenCalledWith(7)
})
it('discards delayed data and exit until an explicit reconnect', () => {
discardPreHandlerPtyState(EXIT_PTY_ID)
bufferPreHandlerPtyData(EXIT_PTY_ID, 'late data')
@@ -176,8 +176,13 @@ export function drainPreHandlerPtyExit(ptyId: string, handler: (code: number) =>
}
}
export function clearPreHandlerPtyState(ptyId: string): void {
export function clearPreHandlerPtyData(ptyId: string): void {
preHandlerPtyData.delete(ptyId)
warnedLostHandlerPtyIds.delete(ptyId)
}
export function clearPreHandlerPtyState(ptyId: string): void {
clearPreHandlerPtyData(ptyId)
preHandlerPtyExit.delete(ptyId)
consumedPreHandlerPtyExits.delete(ptyId)
const discardTimer = discardedPreHandlerPtyStates.get(ptyId)
@@ -185,5 +190,4 @@ export function clearPreHandlerPtyState(ptyId: string): void {
clearTimeout(discardTimer)
}
discardedPreHandlerPtyStates.delete(ptyId)
warnedLostHandlerPtyIds.delete(ptyId)
}
@@ -0,0 +1,211 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
type DataPayload = { id: string; data: string; rawLength?: number }
type ExitPayload = { id: string; code: number }
function flushTimers(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0))
}
describe('PTY sidecar pre-handler adoption', () => {
const originalWindow = (globalThis as { window?: typeof window }).window
let dispatchData: ((payload: DataPayload) => void) | null
let dispatchExit: ((payload: ExitPayload) => void) | null
beforeEach(() => {
vi.resetModules()
dispatchData = null
dispatchExit = null
;(globalThis as { window: typeof window }).window = {
...originalWindow,
api: {
...originalWindow?.api,
pty: {
...originalWindow?.api?.pty,
ackData: vi.fn(),
rendererDispatcherReady: vi.fn(),
onData: vi.fn((callback: (payload: DataPayload) => void) => {
dispatchData = callback
return () => {}
}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn((callback: (payload: ExitPayload) => void) => {
dispatchExit = callback
return () => {}
})
}
}
} as unknown as typeof window
})
afterEach(() => {
if (originalWindow) {
;(globalThis as { window: typeof window }).window = originalWindow
} else {
delete (globalThis as { window?: typeof window }).window
}
})
it('adopts buffered data through the current sidecar cohort exactly once', async () => {
const { ensurePtyDispatcher } = await import('./pty-dispatcher')
const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions')
const { drainPreHandlerPtyData } = await import('./pty-pre-handler-buffer')
const responder = vi.fn()
const adopter = vi.fn()
ensurePtyDispatcher()
dispatchData?.({ id: 'pty-adopt-data', data: 'gap' })
const unsubscribeResponder = subscribeToPtyData('pty-adopt-data', responder)
const unsubscribeAdopter = subscribeToPtyData('pty-adopt-data', adopter, {
adoptPreHandlerData: true
})
dispatchData?.({ id: 'pty-adopt-data', data: 'live' })
expect(responder.mock.calls).toEqual([['gap'], ['live']])
expect(adopter.mock.calls).toEqual([['gap'], ['live']])
unsubscribeAdopter()
const replacement = vi.fn()
const unsubscribeReplacement = subscribeToPtyData('pty-adopt-data', replacement, {
adoptPreHandlerData: true
})
expect(replacement).not.toHaveBeenCalled()
const laterPrimary = vi.fn()
drainPreHandlerPtyData('pty-adopt-data', laterPrimary)
expect(laterPrimary).not.toHaveBeenCalled()
unsubscribeReplacement()
unsubscribeResponder()
})
it('attempts every buffered chunk for every sidecar before rethrowing', async () => {
const { ensurePtyDispatcher, ptyDataSidecars } = await import('./pty-dispatcher')
const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions')
const { drainPreHandlerPtyData } = await import('./pty-pre-handler-buffer')
const firstError = new Error('first sidecar failed')
const throwingPeer = vi.fn((data: string) => {
if (data === 'gap-1') {
throw firstError
}
})
const peer = vi.fn()
const adopter = vi.fn((data: string) => {
if (data === 'gap-1') {
throw new Error('adopter failed')
}
})
ensurePtyDispatcher()
dispatchData?.({ id: 'pty-adopt-error', data: 'gap-1' })
dispatchData?.({ id: 'pty-adopt-error', data: 'gap-2' })
const unsubscribeThrowingPeer = subscribeToPtyData('pty-adopt-error', throwingPeer)
const unsubscribePeer = subscribeToPtyData('pty-adopt-error', peer)
expect(() =>
subscribeToPtyData('pty-adopt-error', adopter, { adoptPreHandlerData: true })
).toThrow(firstError)
expect(throwingPeer.mock.calls).toEqual([['gap-1'], ['gap-2']])
expect(peer.mock.calls).toEqual([['gap-1'], ['gap-2']])
expect(adopter.mock.calls).toEqual([['gap-1'], ['gap-2']])
expect(ptyDataSidecars.get('pty-adopt-error')?.size).toBe(2)
const laterPrimary = vi.fn()
drainPreHandlerPtyData('pty-adopt-error', laterPrimary)
expect(laterPrimary).not.toHaveBeenCalled()
unsubscribePeer()
unsubscribeThrowingPeer()
})
it('leaves buffered data untouched when no sidecar opts into adoption', async () => {
const { ensurePtyDispatcher } = await import('./pty-dispatcher')
const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions')
const { drainPreHandlerPtyData } = await import('./pty-pre-handler-buffer')
const watcher = vi.fn()
ensurePtyDispatcher()
dispatchData?.({ id: 'pty-default-sidecar', data: 'gap' })
const unsubscribe = subscribeToPtyData('pty-default-sidecar', watcher)
const primary = vi.fn()
drainPreHandlerPtyData('pty-default-sidecar', primary)
expect(watcher).not.toHaveBeenCalled()
expect(primary).toHaveBeenCalledWith('gap', undefined)
unsubscribe()
})
it('delivers live data to every sidecar before rethrowing the first error', async () => {
const { ensurePtyDispatcher } = await import('./pty-dispatcher')
const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions')
const firstError = new Error('first live sidecar failed')
const throwingWatcher = vi.fn(() => {
throw firstError
})
const laterWatcher = vi.fn()
ensurePtyDispatcher()
const unsubscribeThrowing = subscribeToPtyData('pty-live-error', throwingWatcher)
const unsubscribeLater = subscribeToPtyData('pty-live-error', laterWatcher)
expect(() => dispatchData?.({ id: 'pty-live-error', data: 'live' })).toThrow(firstError)
expect(throwingWatcher).toHaveBeenCalledWith('live')
expect(laterWatcher).toHaveBeenCalledWith('live')
unsubscribeLater()
unsubscribeThrowing()
})
it('delivers adopted data effects before its buffered exit', async () => {
const { ensurePtyDispatcher, subscribeToPtyExit } = await import('./pty-dispatcher')
const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions')
const events: string[] = []
const exitWatcher = vi.fn(() => events.push('exit'))
ensurePtyDispatcher()
dispatchData?.({ id: 'pty-data-before-exit', data: 'final frame' })
dispatchExit?.({ id: 'pty-data-before-exit', code: 17 })
subscribeToPtyData(
'pty-data-before-exit',
() => setTimeout(() => events.push('data-effect'), 0),
{ adoptPreHandlerData: true }
)
subscribeToPtyExit('pty-data-before-exit', exitWatcher, { adoptPreHandlerExit: true })
expect(events).toEqual([])
await flushTimers()
expect(events).toEqual(['data-effect', 'exit'])
expect(exitWatcher).toHaveBeenCalledWith(17, { hadPrimary: false })
})
it('preserves buffered exits when the adopter leaves or a primary registers', async () => {
const { ensurePtyDispatcher, ptyDataHandlers, ptyExitHandlers, subscribeToPtyExit } =
await import('./pty-dispatcher')
const { drainPreHandlerPtyExit } = await import('./pty-pre-handler-buffer')
const departedWatcher = vi.fn()
const remountWatcher = vi.fn()
ensurePtyDispatcher()
dispatchExit?.({ id: 'pty-departed-adopter', code: 18 })
const unsubscribeDeparted = subscribeToPtyExit('pty-departed-adopter', departedWatcher, {
adoptPreHandlerExit: true
})
unsubscribeDeparted()
dispatchExit?.({ id: 'pty-remount-wins', code: 19 })
const unsubscribeRemount = subscribeToPtyExit('pty-remount-wins', remountWatcher, {
adoptPreHandlerExit: true
})
ptyDataHandlers.set('pty-remount-wins', vi.fn())
const primaryExit = vi.fn()
ptyExitHandlers.set('pty-remount-wins', primaryExit)
await flushTimers()
const departedOwner = vi.fn()
drainPreHandlerPtyExit('pty-departed-adopter', departedOwner)
drainPreHandlerPtyExit('pty-remount-wins', primaryExit)
expect(departedWatcher).not.toHaveBeenCalled()
expect(remountWatcher).not.toHaveBeenCalled()
expect(departedOwner).toHaveBeenCalledWith(18)
expect(primaryExit).toHaveBeenCalledWith(19)
unsubscribeRemount()
ptyDataHandlers.delete('pty-remount-wins')
ptyExitHandlers.delete('pty-remount-wins')
})
})
@@ -169,45 +169,4 @@ describe('getTerminalTabColdParkRecheckDelayMs', () => {
})
).toBe(250)
})
// Why: a flip-damped tab is long past every hysteresis deadline, so the pin
// expiry is the only wakeup left — drop it and the tab never re-parks.
it('wakes at the flip-damping pin deadline when all ordinary deadlines have passed', () => {
expect(
getTerminalTabColdParkRecheckDelayMs({
parkingEnabled: true,
hiddenSinceMs: 1_000,
nowMs: 1_000_000,
coldParkDelayMs: 100,
hotRetainMs: 1_000,
parkVerdictPinUntilMs: 1_060_000
})
).toBe(60_000)
})
// Why: the earliest pending deadline wins — a pin must not delay a nearer
// cool-down wakeup, and vice versa.
it('takes the nearest of the pin and cool-down deadlines', () => {
const base = {
parkingEnabled: true,
hiddenSinceMs: 1_000,
nowMs: 2_500,
coldParkDelayMs: 100,
hotRetainMs: 1_000
}
expect(
getTerminalTabColdParkRecheckDelayMs({
...base,
parkCooldownUntilMs: 2_750,
parkVerdictPinUntilMs: 62_500
})
).toBe(250)
expect(
getTerminalTabColdParkRecheckDelayMs({
...base,
parkCooldownUntilMs: 62_500,
parkVerdictPinUntilMs: 2_750
})
).toBe(250)
})
})
@@ -1,8 +1,4 @@
/**
* Why: parking decisions change only at the cold-park, hot-retain, retention
* TTL, and post-measure cool-down deadlines, so the verdict effects schedule
* one recheck at the next deadline instead of polling.
*/
/** Computes the next policy deadline without polling. */
import {
TERMINAL_TAB_COLD_PARK_DELAY_MS,
TERMINAL_TAB_HOT_RETAIN_MS,
@@ -18,7 +14,6 @@ function nextColdParkDeadlineDelayMs(args: {
hotRetainMs: number
retentionTtlMs?: number
parkCooldownUntilMs?: number | null
parkVerdictPinUntilMs?: number | null
}): number | null {
if (!args.parkingEnabled || args.hiddenSinceMs === null) {
return null
@@ -27,10 +22,7 @@ function nextColdParkDeadlineDelayMs(args: {
args.hiddenSinceMs + args.coldParkDelayMs,
args.hiddenSinceMs + args.hotRetainMs,
...(args.retentionTtlMs !== undefined ? [args.hiddenSinceMs + args.retentionTtlMs] : []),
// Why: the cool-down holds past-deadline candidates out of the parked set; without this wakeup nothing re-parks them.
...(args.parkCooldownUntilMs != null ? [args.parkCooldownUntilMs] : []),
// Why: damping stops the churn that would otherwise wake this deadline.
...(args.parkVerdictPinUntilMs != null ? [args.parkVerdictPinUntilMs] : [])
...(args.parkCooldownUntilMs != null ? [args.parkCooldownUntilMs] : [])
].filter((deadlineMs) => deadlineMs > args.nowMs)
return pendingDeadlines.length === 0 ? null : Math.min(...pendingDeadlines) - args.nowMs
}
@@ -41,7 +33,6 @@ 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
parkCooldownUntilMs?: number | null
}): number | null {
@@ -63,8 +54,6 @@ export function getTerminalTabColdParkRecheckDelayMs(args: {
coldParkDelayMs?: number
hotRetainMs?: number
parkCooldownUntilMs?: number | null
/** Provided only for tabs damped out of the parked set by flip churn. */
parkVerdictPinUntilMs?: number | null
}): number | null {
return nextColdParkDeadlineDelayMs({
parkingEnabled: args.parkingEnabled,
@@ -72,7 +61,6 @@ export function getTerminalTabColdParkRecheckDelayMs(args: {
nowMs: args.nowMs,
coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS,
hotRetainMs: args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS,
parkCooldownUntilMs: args.parkCooldownUntilMs,
parkVerdictPinUntilMs: args.parkVerdictPinUntilMs
parkCooldownUntilMs: args.parkCooldownUntilMs
})
}
@@ -1,20 +1,5 @@
/** @vitest-environment happy-dom */
/**
* Cold-park verdict oscillation (crash cluster: React #185 in the terminal overlay).
*
* Why this shape: canWatcherCoverParkedTerminalTab is re-derived from store and
* registry state that mounting/unmounting the very pane the verdict controls
* rewrites, and the parked watchers write the tab model back. The pane stand-in
* models that edge — mounted pane grants coverage, parked pane withdraws it,
* and either transition re-mints the tab array the cold-park effect depends on.
*
* Why the store-write chain is parameterised: React bails on *commits*, not on
* flips, so a damping threshold expressed in flips is only safe if it holds at
* the real cycle's commits-per-flip cost (pane-connect updateTabPtyId, the
* watcher-sync writes, the overlay's own subscriber renders). The chain models
* that cost so the derived burst limit stays regression-tested.
*/
import { act, useEffect, useRef, useState } from 'react'
import { act, useEffect } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalTab } from '../../../../shared/types'
@@ -23,8 +8,11 @@ import type { TerminalTab } from '../../../../shared/types'
const park = vi.hoisted(() => ({
worktreeId: 'repo::/wt-park-loop',
/** Whether the byte watchers could cover the tab if it parked right now. */
coverage: true
materialRevision: 0,
captureSource: 'fallback' as 'fallback' | 'capture',
failNextHandoff: false,
syncedParked: new Set<string>(),
observedSources: [] as string[]
}))
vi.mock('../../store', async () => {
@@ -35,16 +23,83 @@ vi.mock('../../store', async () => {
settings: {} as Record<string, unknown>,
terminalLayoutsByTabId: {} as Record<string, unknown>,
runtimePaneTitlesByTabId: {} as Record<string, unknown>,
sleepingAgentSessionsByPaneKey: {} as Record<string, unknown>,
tabsByWorktree: {} as Record<string, TerminalTab[]>
}))
return { useAppStore }
})
vi.mock('./terminal-parked-tab-watchers', () => ({
canWatcherCoverParkedTerminalTab: () => park.coverage,
disposeParkedTerminalWatchersForWorktree: () => {},
resolveParkedTerminalPaneCandidates: () => [],
syncParkedTerminalTabWatchers: () => {}
planParkedTerminalTabWatcherCoverage: (worktreeId: string, tab: TerminalTab) => {
park.observedSources.push(park.captureSource)
return {
status: 'covered',
materialKey: `${worktreeId}:${tab.id}:${tab.ptyId}:${park.materialRevision}`,
worktreeId,
tabId: tab.id,
tabPtyId: tab.ptyId,
generation: tab.generation ?? null,
panes: [
{
leafId: '11111111-1111-4111-8111-111111111111',
ptyId: tab.ptyId
}
]
}
},
subscribeParkedTerminalWatcherOwnershipLoss: () => () => {},
syncParkedTerminalTabWatchersWithAcknowledgements: (args: {
parkedTabIds: ReadonlySet<string>
forcedTabIds: ReadonlySet<string>
coveragePlansByTabId: ReadonlyMap<
string,
{ materialKey: string; panes: readonly { ptyId: string | null }[] }
>
}) => {
const acknowledgements: object[] = []
for (const tabId of args.parkedTabIds) {
const plan = args.coveragePlansByTabId.get(tabId)
const watchedPtyIds = (plan?.panes ?? []).flatMap((pane) =>
pane.ptyId === null ? [] : [pane.ptyId]
)
if (!park.syncedParked.has(tabId)) {
park.syncedParked.add(tabId)
rewriteTabModel(tabId)
}
if (args.forcedTabIds.has(tabId)) {
acknowledgements.push({
status: 'forced',
tabId,
materialKey: plan?.materialKey ?? null,
watchedPtyIds
})
} else if (park.failNextHandoff && tabId === PARKABLE_TAB_ID) {
park.failNextHandoff = false
acknowledgements.push({
status: 'failed',
tabId,
materialKey: plan?.materialKey ?? null,
reason: 'watcher-coverage-incomplete',
expectedPtyIds: watchedPtyIds,
watchedPtyIds: []
})
} else {
acknowledgements.push({
status: 'covering',
tabId,
materialKey: plan?.materialKey ?? '',
watchedPtyIds
})
}
}
for (const tabId of park.syncedParked) {
if (!args.parkedTabIds.has(tabId)) {
park.syncedParked.delete(tabId)
}
}
return acknowledgements
}
}))
vi.mock('./terminal-parking-e2e-overrides', () => ({
@@ -52,20 +107,10 @@ vi.mock('./terminal-parking-e2e-overrides', () => ({
}))
import { useAppStore } from '../../store'
import {
TERMINAL_TAB_PARK_FLIP_BURST_LIMIT,
TERMINAL_TAB_PARK_FLIP_COMMIT_COST
} from './terminal-park-verdict-flip-telemetry'
import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking'
/** The most recently hidden tab holds the last-active retain exemption. */
const EXEMPT_TAB_ID = 'tab-a'
const PARKABLE_TAB_ID = 'tab-b'
/** Stops an unfixed loop from hanging the run if React ever raises its bail. */
const RENDER_HARD_STOP = 500
/** +1: the pin lands in the passive effect observing the burst flip, so the
* verdict settles one flip after damping engages. */
const SETTLED_FLIP_BUDGET = TERMINAL_TAB_PARK_FLIP_BURST_LIMIT + 1
const EMPTY_ASSIGNMENTS = new Map<string, { groupId: string; isActiveInGroup: boolean }>()
const EMPTY_PORTALS: never[] = []
@@ -81,70 +126,37 @@ function terminalTab(id: string): TerminalTab {
let tabModelRevision = 0
// Models the store writes a pane mount/unmount produces — updateTabPtyId on
// connect, parked-watcher title writes — both of which re-mint the tab array.
function rewriteTabModel(tabId: string): void {
tabModelRevision += 1
const revision = tabModelRevision
parkingStore.setState((state) => ({
tabsByWorktree: {
...state.tabsByWorktree,
[park.worktreeId]: (state.tabsByWorktree[park.worktreeId] ?? []).map((tab) =>
tab.id === tabId ? { ...tab, title: `${tab.id}-${revision}` } : tab
tab.id === tabId ? { ...tab, title: `${tab.id}-${tabModelRevision}` } : tab
)
}
}))
}
/** Store writes a single park transition costs; each lands in its own commit. */
let storeCommitsPerParkTransition = 1
let paneMountCount = 0
/** The pane whose mount/unmount the park verdict authorizes. */
function TerminalPaneStandIn(): null {
useEffect(() => {
paneMountCount += 1
park.captureSource = 'fallback'
return () => {
park.captureSource = 'capture'
}
}, [])
return null
}
/**
* The store writes a park transition drags behind it — pane-connect
* updateTabPtyId, then the watcher-sync writes that each only see the tab model
* the previous write produced. Coverage is re-derived from the last of them, so
* the verdict cannot flip until the whole chain has committed. Why staged
* rather than batched: nested commits are what React counts against
* NESTED_UPDATE_LIMIT, so this is the per-flip cost the damping must beat.
*/
function ParkTransitionStoreWrites({ parked }: { parked: boolean }): null {
const pendingWritesRef = useRef(storeCommitsPerParkTransition)
const lastParkedRef = useRef(parked)
const [writeTick, setWriteTick] = useState(0)
useEffect(() => {
if (lastParkedRef.current !== parked) {
lastParkedRef.current = parked
pendingWritesRef.current = storeCommitsPerParkTransition
}
if (pendingWritesRef.current <= 0) {
return
}
pendingWritesRef.current -= 1
if (pendingWritesRef.current === 0) {
// A mounted pane grants byte-watcher coverage; a parked one withdraws it.
park.coverage = !parked
}
rewriteTabModel(PARKABLE_TAB_ID)
setWriteTick((tick) => tick + 1)
}, [parked, writeTick])
return null
}
let hostRenderCount = 0
let worktreeParkRequested = false
let shouldMeasureHiddenWorktree = false
let parkVerdictFlipCount = 0
let lastParkVerdict = false
function OverlayHost(): React.JSX.Element | null {
hostRenderCount += 1
const terminalTabs = useAppStore(
(state) => (state as ParkingStoreState).tabsByWorktree[park.worktreeId]
) as TerminalTab[]
@@ -153,8 +165,8 @@ function OverlayHost(): React.JSX.Element | null {
terminalTabs,
assignments: EMPTY_ASSIGNMENTS,
isWorktreeActive: false,
coldParkTerminalPanes: false,
shouldMeasureHiddenWorktree: false,
coldParkTerminalPanes: worktreeParkRequested,
shouldMeasureHiddenWorktree,
activityTerminalPortals: EMPTY_PORTALS,
activationDeferredMountTabIds: null
})
@@ -163,24 +175,14 @@ function OverlayHost(): React.JSX.Element | null {
parkVerdictFlipCount += 1
}
lastParkVerdict = parked
if (hostRenderCount > RENDER_HARD_STOP) {
return null
}
return (
<>
{parked ? null : <TerminalPaneStandIn />}
<ParkTransitionStoreWrites parked={parked} />
</>
)
return parked ? null : <TerminalPaneStandIn />
}
function renderOverlayHost(root: Root): unknown {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
let thrown: unknown = null
try {
act(() => {
root.render(<OverlayHost />)
})
act(() => root.render(<OverlayHost />))
} catch (error) {
thrown = error
}
@@ -188,19 +190,22 @@ function renderOverlayHost(root: Root): unknown {
return thrown
}
describe('cold-park verdict oscillation', () => {
describe('cold-park episode stability', () => {
let container: HTMLDivElement
// Why optional: a throwing createRoot must not be masked by an afterEach TypeError.
let root: Root | undefined
beforeEach(() => {
park.coverage = true
hostRenderCount = 0
parkVerdictFlipCount = 0
lastParkVerdict = false
park.materialRevision = 0
park.captureSource = 'fallback'
park.failNextHandoff = false
park.syncedParked.clear()
park.observedSources.length = 0
tabModelRevision = 0
paneMountCount = 0
storeCommitsPerParkTransition = 1
worktreeParkRequested = false
shouldMeasureHiddenWorktree = false
parkVerdictFlipCount = 0
lastParkVerdict = false
parkingStore.setState(() => ({
tabsByWorktree: {
[park.worktreeId]: [terminalTab(EXEMPT_TAB_ID), terminalTab(PARKABLE_TAB_ID)]
@@ -211,37 +216,46 @@ describe('cold-park verdict oscillation', () => {
})
afterEach(() => {
act(() => {
root?.unmount()
})
act(() => root?.unmount())
root = undefined
container.remove()
})
it('settles the park verdict when watcher coverage tracks the pane it unmounts', () => {
it('keeps one verdict across title writes and capture-source changes', () => {
root = createRoot(container)
const thrown = renderOverlayHost(root)
// Damping must land inside the burst window and settle on the safe side:
// the pane stays mounted, so bells/titles/completions never go silent.
expect(thrown).toBeNull()
expect(parkVerdictFlipCount).toBeLessThanOrEqual(SETTLED_FLIP_BUDGET)
expect(lastParkVerdict).toBe(false)
expect(paneMountCount).toBeGreaterThan(0)
expect(renderOverlayHost(root)).toBeNull()
expect(lastParkVerdict).toBe(true)
expect(parkVerdictFlipCount).toBe(1)
expect(paneMountCount).toBe(1)
expect(new Set(park.observedSources)).toEqual(new Set(['fallback', 'capture']))
})
// Why: the burst limit is derived from React's 50-commit bail divided by an
// assumed worst-case commits-per-flip. Running the loop at exactly that cost
// is what keeps the derivation honest — a limit tuned on a one-write-per-flip
// model pins only after React has already thrown #185.
it('settles at the assumed worst-case commit cost per park transition', () => {
storeCommitsPerParkTransition = TERMINAL_TAB_PARK_FLIP_COMMIT_COST
it('remounts once after a failed worktree handoff and re-arms on a new plan', () => {
shouldMeasureHiddenWorktree = true
root = createRoot(container)
const thrown = renderOverlayHost(root)
expect(renderOverlayHost(root)).toBeNull()
expect(paneMountCount).toBe(1)
expect(thrown).toBeNull()
expect(parkVerdictFlipCount).toBeLessThanOrEqual(SETTLED_FLIP_BUDGET)
shouldMeasureHiddenWorktree = false
worktreeParkRequested = true
park.failNextHandoff = true
expect(renderOverlayHost(root)).toBeNull()
expect(lastParkVerdict).toBe(false)
expect(paneMountCount).toBeGreaterThan(0)
expect(parkVerdictFlipCount).toBe(2)
expect(paneMountCount).toBe(2)
act(() => {
for (let revision = 0; revision < 20; revision += 1) {
rewriteTabModel(PARKABLE_TAB_ID)
}
})
expect(lastParkVerdict).toBe(false)
expect(paneMountCount).toBe(2)
park.materialRevision += 1
act(() => rewriteTabModel(PARKABLE_TAB_ID))
expect(lastParkVerdict).toBe(true)
expect(parkVerdictFlipCount).toBe(3)
})
})
@@ -1,114 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalTab } from '../../../../shared/types'
import type { ParkVerdictFlipRecord } from './terminal-park-verdict-flip-telemetry'
import { withholdUnparkableTerminalTabs } from './terminal-cold-park-withheld-tabs'
const coverage = vi.hoisted(() => ({ byTabId: new Map<string, boolean>() }))
vi.mock('./terminal-parked-tab-watchers', () => ({
canWatcherCoverParkedTerminalTab: (_worktreeId: string, tab: TerminalTab) =>
coverage.byTabId.get(tab.id) ?? true
}))
const WORKTREE = 'repo::/wt'
function terminalTab(id: string): TerminalTab {
return { id, ptyId: `${WORKTREE}@@${id}`, title: id } as TerminalTab
}
function pinnedRecord(pinnedUntilMs: number | null): ParkVerdictFlipRecord {
return {
parked: false,
windowStartMs: 0,
flips: 0,
notified: false,
burstStartMs: 0,
burstFlips: 0,
pinnedUntilMs
}
}
beforeEach(() => {
coverage.byTabId.clear()
})
describe('withholdUnparkableTerminalTabs', () => {
it('parks a covered tab with no pin', () => {
const { parkedTabIds, parkVerdictPinUntilMsByTabId } = withholdUnparkableTerminalTabs({
worktreeId: WORKTREE,
terminalTabs: [terminalTab('tab-a')],
coldParkedTabIds: new Set(['tab-a']),
parkVerdictRecords: new Map(),
nowMs: 1_000
})
expect(parkedTabIds).toEqual(new Set(['tab-a']))
expect(parkVerdictPinUntilMsByTabId.size).toBe(0)
})
// Why both returned values matter: the caller needs the deadline to schedule
// the recheck that lets the tab park again once damping lapses.
it('withholds a pinned tab and reports its deadline', () => {
const records = new Map([['tab-a', pinnedRecord(9_000)]])
const { parkedTabIds, parkVerdictPinUntilMsByTabId } = withholdUnparkableTerminalTabs({
worktreeId: WORKTREE,
terminalTabs: [terminalTab('tab-a')],
coldParkedTabIds: new Set(['tab-a']),
parkVerdictRecords: records,
nowMs: 1_000
})
expect(parkedTabIds.has('tab-a')).toBe(false)
expect(parkVerdictPinUntilMsByTabId.get('tab-a')).toBe(9_000)
})
it('withholds a tab the watchers cannot cover', () => {
coverage.byTabId.set('tab-a', false)
const { parkedTabIds, parkVerdictPinUntilMsByTabId } = withholdUnparkableTerminalTabs({
worktreeId: WORKTREE,
terminalTabs: [terminalTab('tab-a')],
coldParkedTabIds: new Set(['tab-a']),
parkVerdictRecords: new Map(),
nowMs: 1_000
})
expect(parkedTabIds.has('tab-a')).toBe(false)
// No pin: an uncovered tab has no damping deadline to recheck at.
expect(parkVerdictPinUntilMsByTabId.size).toBe(0)
})
// Why: an expired pin must hand the tab back to the parking policy, not
// strand it mounted for the rest of the session.
it('parks a tab whose pin has lapsed', () => {
const records = new Map([['tab-a', pinnedRecord(500)]])
const { parkedTabIds, parkVerdictPinUntilMsByTabId } = withholdUnparkableTerminalTabs({
worktreeId: WORKTREE,
terminalTabs: [terminalTab('tab-a')],
coldParkedTabIds: new Set(['tab-a']),
parkVerdictRecords: records,
nowMs: 1_000
})
expect(parkedTabIds.has('tab-a')).toBe(true)
expect(parkVerdictPinUntilMsByTabId.size).toBe(0)
})
it('does not consult non-candidates and leaves the input set untouched', () => {
const coldParkedTabIds = new Set(['tab-a'])
coverage.byTabId.set('tab-b', false)
const { parkedTabIds } = withholdUnparkableTerminalTabs({
worktreeId: WORKTREE,
terminalTabs: [terminalTab('tab-a'), terminalTab('tab-b')],
coldParkedTabIds,
parkVerdictRecords: new Map(),
nowMs: 1_000
})
expect(parkedTabIds).toEqual(new Set(['tab-a']))
expect(coldParkedTabIds).toEqual(new Set(['tab-a']))
})
})
@@ -1,54 +0,0 @@
/**
* Last gate between a cold-park candidate and an unmounted pane.
*
* Two reasons withhold a tab, both settling on the safe mounted side: the byte
* watchers cannot cover it, or a verdict-flip burst pinned it. The pin deadline
* is returned because it is also the only remaining recheck wakeup once damping
* has stopped the churn that was waking the parking effect.
*/
import type { TerminalTab } from '../../../../shared/types'
import {
getParkVerdictUnparkPinUntilMs,
type ParkVerdictFlipRecord
} from './terminal-park-verdict-flip-telemetry'
import { canWatcherCoverParkedTerminalTab } from './terminal-parked-tab-watchers'
export type WithheldTerminalTabParking = {
parkedTabIds: Set<string>
parkVerdictPinUntilMsByTabId: Map<string, number>
}
export function withholdUnparkableTerminalTabs(args: {
worktreeId: string
terminalTabs: readonly TerminalTab[]
coldParkedTabIds: ReadonlySet<string>
parkVerdictRecords: Map<string, ParkVerdictFlipRecord>
nowMs: number
}): WithheldTerminalTabParking {
const parkedTabIds = new Set(args.coldParkedTabIds)
const parkVerdictPinUntilMsByTabId = new Map<string, number>()
for (const terminalTab of args.terminalTabs) {
if (!parkedTabIds.has(terminalTab.id)) {
continue
}
const parkVerdictPinUntilMs = getParkVerdictUnparkPinUntilMs({
records: args.parkVerdictRecords,
tabId: terminalTab.id,
nowMs: args.nowMs
})
if (parkVerdictPinUntilMs !== null) {
parkVerdictPinUntilMsByTabId.set(terminalTab.id, parkVerdictPinUntilMs)
}
// Why coverage matters: a parked tab the watchers cannot reach goes silent
// for bells/titles/completions — the failure that sank the first attempt.
if (
parkVerdictPinUntilMs !== null ||
!canWatcherCoverParkedTerminalTab(args.worktreeId, terminalTab)
) {
parkedTabIds.delete(terminalTab.id)
}
}
return { parkedTabIds, parkVerdictPinUntilMsByTabId }
}
@@ -36,7 +36,7 @@ export function isEvictionExemptTerminalTab(
if (isEvictionExemptTerminalPty(tab.ptyId, worktreeId)) {
return true
}
return resolveParkedTerminalPaneCandidates(tab, useAppStore.getState()).some((pane) =>
return resolveParkedTerminalPaneCandidates(worktreeId, tab, useAppStore.getState()).some((pane) =>
isEvictionExemptTerminalPty(pane.ptyId, worktreeId)
)
}
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest'
import {
acknowledgeTerminalParkEpisodeLease,
reconcileTerminalParkEpisodeLease,
terminalParkEpisodeLeaseUnmountsPane,
type TerminalParkEpisodeLease
} from './terminal-park-episode-lease'
import type {
TerminalParkedWatcherCoveragePlan,
TerminalParkedWatcherCoveredPlan
} from './terminal-parked-watcher-coverage-plan'
const coveredPlan = (materialKey: string): TerminalParkedWatcherCoveredPlan => ({
status: 'covered',
materialKey,
worktreeId: 'worktree-1',
tabId: 'tab-1',
tabPtyId: 'pty-1',
generation: 1,
panes: [{ leafId: '11111111-1111-4111-8111-111111111111', ptyId: 'pty-1' }]
})
describe('terminal park episode lease', () => {
it('requests covered plans and becomes covering only after an exact acknowledgement', () => {
const requested = reconcileTerminalParkEpisodeLease(null, coveredPlan('key-1'))
const covering = acknowledgeTerminalParkEpisodeLease(requested, {
status: 'covering',
tabId: 'tab-1',
materialKey: 'key-1',
watchedPtyIds: ['pty-1']
})
expect(requested.phase).toBe('requested')
expect(covering.phase).toBe('covering')
expect(terminalParkEpisodeLeaseUnmountsPane(covering)).toBe(true)
})
it('keeps covering acknowledgements identity-stable across repeated effects', () => {
const covering: TerminalParkEpisodeLease = {
phase: 'covering',
plan: coveredPlan('key-1')
}
expect(
acknowledgeTerminalParkEpisodeLease(covering, {
status: 'covering',
tabId: 'tab-1',
materialKey: 'key-1',
watchedPtyIds: ['pty-1']
})
).toBe(covering)
})
it('latches rejection until the semantic key changes', () => {
const requested = reconcileTerminalParkEpisodeLease(null, coveredPlan('key-1'))
const rejected = acknowledgeTerminalParkEpisodeLease(requested, {
status: 'failed',
tabId: 'tab-1',
materialKey: 'key-1',
reason: 'watcher-coverage-incomplete',
expectedPtyIds: ['pty-1'],
watchedPtyIds: []
})
expect(reconcileTerminalParkEpisodeLease(rejected, coveredPlan('key-1'))).toBe(rejected)
expect(reconcileTerminalParkEpisodeLease(rejected, coveredPlan('key-2')).phase).toBe(
'requested'
)
expect(terminalParkEpisodeLeaseUnmountsPane(rejected)).toBe(false)
})
it('keeps pending and blocked plans mounted without a timer', () => {
const pending: TerminalParkedWatcherCoveragePlan = {
...coveredPlan('pending-key'),
status: 'pending',
issue: { reason: 'provider-capability-pending' }
}
const blocked: TerminalParkedWatcherCoveragePlan = {
...coveredPlan('blocked-key'),
status: 'blocked',
issue: { reason: 'provider-snapshot-unavailable' }
}
expect(
terminalParkEpisodeLeaseUnmountsPane(reconcileTerminalParkEpisodeLease(null, pending))
).toBe(false)
expect(
terminalParkEpisodeLeaseUnmountsPane(reconcileTerminalParkEpisodeLease(null, blocked))
).toBe(false)
})
it('makes retention force-parking an explicit uncovered bypass', () => {
const blocked: TerminalParkedWatcherCoveragePlan = {
...coveredPlan('blocked-key'),
status: 'blocked',
issue: { reason: 'provider-snapshot-unavailable' }
}
const forced = reconcileTerminalParkEpisodeLease(null, blocked, { forceUnmount: true })
expect(forced.phase).toBe('forced')
expect(terminalParkEpisodeLeaseUnmountsPane(forced)).toBe(true)
expect(reconcileTerminalParkEpisodeLease(forced, blocked, { forceUnmount: true })).toBe(forced)
expect(reconcileTerminalParkEpisodeLease(forced, blocked).phase).toBe('blocked')
})
})
@@ -0,0 +1,112 @@
import type {
TerminalParkedWatcherBlockedPlan,
TerminalParkedWatcherCoveragePlan,
TerminalParkedWatcherCoveredPlan,
TerminalParkedWatcherPendingPlan
} from './terminal-parked-watcher-coverage-plan'
import type { ParkableTerminalTabModel } from './terminal-parked-watcher-reconciliation'
export type ParkedTerminalTabWatcherSyncArgs = {
worktreeId: string
tabs: readonly ParkableTerminalTabModel[]
parkedTabIds: ReadonlySet<string>
restoreTitleOnStartTabIds?: ReadonlySet<string>
}
export function terminalParkedWatcherPlanPtyIds(
plan: TerminalParkedWatcherCoveragePlan | undefined
): string[] {
return (plan?.panes ?? [])
.flatMap((pane) => (pane.ptyId === null ? [] : [pane.ptyId]))
.sort((left, right) => left.localeCompare(right))
}
export type TerminalParkedWatcherSyncFailureReason =
| 'coverage-plan-missing'
| 'coverage-plan-not-covered'
| 'material-changed'
| 'watcher-start-failed'
| 'watcher-coverage-incomplete'
| 'watcher-ownership-lost'
export type TerminalParkedWatcherSyncAcknowledgement =
| {
status: 'covering'
tabId: string
materialKey: string
watchedPtyIds: readonly string[]
}
| {
status: 'failed'
tabId: string
materialKey: string | null
reason: TerminalParkedWatcherSyncFailureReason
expectedPtyIds: readonly string[]
watchedPtyIds: readonly string[]
}
| {
status: 'forced'
tabId: string
materialKey: string | null
watchedPtyIds: readonly string[]
}
export type TerminalParkEpisodeLease =
| { phase: 'pending'; plan: TerminalParkedWatcherPendingPlan }
| { phase: 'blocked'; plan: TerminalParkedWatcherBlockedPlan }
| { phase: 'requested'; plan: TerminalParkedWatcherCoveredPlan }
| { phase: 'covering'; plan: TerminalParkedWatcherCoveredPlan }
| { phase: 'forced'; plan: TerminalParkedWatcherCoveragePlan }
| {
phase: 'rejected'
plan: TerminalParkedWatcherCoveredPlan
reason: TerminalParkedWatcherSyncFailureReason
}
export function reconcileTerminalParkEpisodeLease(
lease: TerminalParkEpisodeLease | null,
plan: TerminalParkedWatcherCoveragePlan,
options?: { forceUnmount?: boolean }
): TerminalParkEpisodeLease {
if (options?.forceUnmount) {
return lease?.phase === 'forced' && lease.plan.materialKey === plan.materialKey
? lease
: { phase: 'forced', plan }
}
if (lease?.phase !== 'forced' && lease?.plan.materialKey === plan.materialKey) {
return lease
}
if (plan.status === 'pending') {
return { phase: 'pending', plan }
}
if (plan.status === 'blocked') {
return { phase: 'blocked', plan }
}
return { phase: 'requested', plan }
}
export function acknowledgeTerminalParkEpisodeLease(
lease: TerminalParkEpisodeLease,
acknowledgement: TerminalParkedWatcherSyncAcknowledgement
): TerminalParkEpisodeLease {
if (lease.phase === 'forced' || acknowledgement.status === 'forced') {
return lease
}
if (
acknowledgement.tabId !== lease.plan.tabId ||
acknowledgement.materialKey !== lease.plan.materialKey ||
(lease.phase !== 'requested' && lease.phase !== 'covering')
) {
return lease
}
if (acknowledgement.status === 'covering') {
return lease.phase === 'covering' ? lease : { phase: 'covering', plan: lease.plan }
}
return { phase: 'rejected', plan: lease.plan, reason: acknowledgement.reason }
}
export function terminalParkEpisodeLeaseUnmountsPane(
lease: TerminalParkEpisodeLease | null
): boolean {
return lease?.phase === 'requested' || lease?.phase === 'covering' || lease?.phase === 'forced'
}
@@ -0,0 +1,296 @@
import { describe, expect, it } from 'vitest'
import {
acknowledgeTerminalParkEpisodeSet,
reconcileTerminalParkEpisodeSet,
selectAcknowledgedTerminalParkEpisodeUnmounts,
selectCurrentTerminalParkEpisodeUnmounts,
selectTerminalParkEpisodeWatcherTabIds,
type TerminalParkEpisodeSet,
type TerminalParkEpisodeTab
} from './terminal-park-episode-set'
import type {
TerminalParkedWatcherBlockedPlan,
TerminalParkedWatcherCoveredPlan,
TerminalParkedWatcherPendingPlan
} from './terminal-parked-watcher-coverage-plan'
const WORKTREE_ID = 'worktree-1'
const TAB_ID = 'tab-1'
const OTHER_TAB_ID = 'tab-2'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
function tab(id = TAB_ID): TerminalParkEpisodeTab {
return { id, ptyId: `pty-${id}`, generation: 1 }
}
function coveredPlan(materialKey: string, tabId = TAB_ID): TerminalParkedWatcherCoveredPlan {
return {
status: 'covered',
materialKey,
worktreeId: WORKTREE_ID,
tabId,
tabPtyId: `pty-${tabId}`,
generation: 1,
panes: [{ leafId: LEAF_ID, ptyId: `pty-${tabId}` }]
}
}
function pendingPlan(materialKey: string): TerminalParkedWatcherPendingPlan {
return {
...coveredPlan(materialKey),
status: 'pending',
issue: { reason: 'provider-capability-pending' }
}
}
function blockedPlan(materialKey: string): TerminalParkedWatcherBlockedPlan {
return {
...coveredPlan(materialKey),
status: 'blocked',
issue: { reason: 'provider-snapshot-unavailable' }
}
}
function rejectRequestedLease(
current: TerminalParkEpisodeSet,
plan: TerminalParkedWatcherCoveredPlan
): TerminalParkEpisodeSet {
const requested = reconcileTerminalParkEpisodeSet({
current,
tabs: [tab()],
requestedTabIds: new Set([TAB_ID]),
plan: () => plan
})
return acknowledgeTerminalParkEpisodeSet(requested.leases, [
{
status: 'failed',
tabId: TAB_ID,
materialKey: plan.materialKey,
reason: 'watcher-coverage-incomplete',
expectedPtyIds: [`pty-${TAB_ID}`],
watchedPtyIds: []
}
]).leases
}
describe('terminal park episode set', () => {
it('unmounts a covered request', () => {
const plan = coveredPlan('key-1')
const result = reconcileTerminalParkEpisodeSet({
current: new Map(),
tabs: [tab()],
requestedTabIds: new Set([TAB_ID]),
plan: () => plan
})
expect(result.leases.get(TAB_ID)).toMatchObject({ phase: 'requested', plan })
expect(result.plansByTabId.get(TAB_ID)).toBe(plan)
expect(result.unmountTabIds).toEqual(new Set([TAB_ID]))
})
it('keeps a same-key rejection mounted across reconciliation', () => {
const plan = coveredPlan('key-1')
const rejected = rejectRequestedLease(new Map(), plan)
const result = reconcileTerminalParkEpisodeSet({
current: rejected,
tabs: [tab()],
requestedTabIds: new Set([TAB_ID]),
plan: () => plan
})
expect(result.leases).toBe(rejected)
expect(result.leases.get(TAB_ID)?.phase).toBe('rejected')
expect(result.unmountTabIds).toEqual(new Set())
})
it('rearms a rejected episode when its material key changes', () => {
const rejected = rejectRequestedLease(new Map(), coveredPlan('key-1'))
const result = reconcileTerminalParkEpisodeSet({
current: rejected,
tabs: [tab()],
requestedTabIds: new Set([TAB_ID]),
plan: () => coveredPlan('key-2')
})
expect(result.leases.get(TAB_ID)).toMatchObject({
phase: 'requested',
plan: { materialKey: 'key-2' }
})
expect(result.unmountTabIds).toEqual(new Set([TAB_ID]))
})
it('refuses a stale lease key before reconciliation', () => {
const requested = reconcileTerminalParkEpisodeSet({
current: new Map(),
tabs: [tab()],
requestedTabIds: new Set([TAB_ID]),
plan: () => coveredPlan('key-1')
})
expect(
selectCurrentTerminalParkEpisodeUnmounts({
leases: requested.leases,
plansByTabId: new Map([[TAB_ID, coveredPlan('key-2')]]),
requestedTabIds: new Set([TAB_ID])
})
).toEqual(new Set())
})
it('does not commit an acknowledgement-required request before watcher coverage', () => {
const plan = coveredPlan('key-1')
const requested = reconcileTerminalParkEpisodeSet({
current: new Map(),
tabs: [tab()],
requestedTabIds: new Set([TAB_ID]),
plan: () => plan
})
const selectUnmounts = (leases: TerminalParkEpisodeSet): Set<string> =>
selectAcknowledgedTerminalParkEpisodeUnmounts({
leases,
plansByTabId: requested.plansByTabId,
requestedTabIds: new Set([TAB_ID]),
acknowledgementRequiredTabIds: new Set([TAB_ID])
})
expect(selectUnmounts(requested.leases)).toEqual(new Set())
const covering = acknowledgeTerminalParkEpisodeSet(requested.leases, [
{
status: 'covering',
tabId: TAB_ID,
materialKey: plan.materialKey,
watchedPtyIds: [`pty-${TAB_ID}`]
}
])
expect(selectUnmounts(covering.leases)).toEqual(new Set([TAB_ID]))
})
it('keeps a newly covering deferred watcher across stale rendered state', () => {
const plan = coveredPlan('key-1')
const requested = reconcileTerminalParkEpisodeSet({
current: new Map(),
tabs: [tab()],
requestedTabIds: new Set([TAB_ID]),
plan: () => plan
})
const covering = acknowledgeTerminalParkEpisodeSet(requested.leases, [
{
status: 'covering',
tabId: TAB_ID,
materialKey: plan.materialKey,
watchedPtyIds: [`pty-${TAB_ID}`]
}
])
expect(
selectTerminalParkEpisodeWatcherTabIds({
worktreeId: WORKTREE_ID,
tabIds: [TAB_ID],
leases: covering.leases,
plansByTabId: requested.plansByTabId,
renderedUnmountTabIds: new Set(),
activationDeferredTabIds: new Set([TAB_ID])
})
).toEqual(new Set([TAB_ID]))
})
it('does not start a newly requested ordinary watcher before its unmount render', () => {
const requested = reconcileTerminalParkEpisodeSet({
current: new Map(),
tabs: [tab()],
requestedTabIds: new Set([TAB_ID]),
plan: () => coveredPlan('key-1')
})
expect(
selectTerminalParkEpisodeWatcherTabIds({
worktreeId: WORKTREE_ID,
tabIds: [TAB_ID],
leases: requested.leases,
plansByTabId: requested.plansByTabId,
renderedUnmountTabIds: new Set()
})
).toEqual(new Set())
})
it('drops a revealed tab from stale rendered watcher state', () => {
expect(
selectTerminalParkEpisodeWatcherTabIds({
worktreeId: WORKTREE_ID,
tabIds: [TAB_ID],
leases: new Map(),
plansByTabId: new Map(),
renderedUnmountTabIds: new Set([TAB_ID])
})
).toEqual(new Set())
})
it.each([
['pending', pendingPlan('pending-key')],
['blocked', blockedPlan('blocked-key')]
] as const)('force-unmounts a %s request', (_status, plan) => {
const result = reconcileTerminalParkEpisodeSet({
current: new Map(),
tabs: [tab()],
requestedTabIds: new Set([TAB_ID]),
forcedTabIds: new Set([TAB_ID]),
plan: () => plan
})
expect(result.leases.get(TAB_ID)?.phase).toBe('forced')
expect(result.unmountTabIds).toEqual(new Set([TAB_ID]))
})
it('keeps covering acknowledgements render-stable and reports failures', () => {
const plan = coveredPlan('key-1')
const requested = reconcileTerminalParkEpisodeSet({
current: new Map(),
tabs: [tab()],
requestedTabIds: new Set([TAB_ID]),
plan: () => plan
})
const covering = acknowledgeTerminalParkEpisodeSet(requested.leases, [
{
status: 'covering',
tabId: TAB_ID,
materialKey: plan.materialKey,
watchedPtyIds: [`pty-${TAB_ID}`]
}
])
const failed = acknowledgeTerminalParkEpisodeSet(requested.leases, [
{
status: 'failed',
tabId: TAB_ID,
materialKey: plan.materialKey,
reason: 'watcher-start-failed',
expectedPtyIds: [`pty-${TAB_ID}`],
watchedPtyIds: []
}
])
expect(covering.renderChanged).toBe(false)
expect(covering.leases.get(TAB_ID)?.phase).toBe('covering')
expect(failed.renderChanged).toBe(true)
expect(failed.leases.get(TAB_ID)?.phase).toBe('rejected')
})
it('drops leases for removed tabs', () => {
const current = reconcileTerminalParkEpisodeSet({
current: new Map(),
tabs: [tab(), tab(OTHER_TAB_ID)],
requestedTabIds: new Set([TAB_ID, OTHER_TAB_ID]),
plan: (entry) => coveredPlan(`key-${entry.id}`, entry.id)
})
const result = reconcileTerminalParkEpisodeSet({
current: current.leases,
tabs: [tab()],
requestedTabIds: new Set([TAB_ID, OTHER_TAB_ID]),
plan: () => coveredPlan(`key-${TAB_ID}`)
})
expect(result.leases).not.toBe(current.leases)
expect(Array.from(result.leases.keys())).toEqual([TAB_ID])
expect(Array.from(result.plansByTabId.keys())).toEqual([TAB_ID])
expect(result.unmountTabIds).toEqual(new Set([TAB_ID]))
})
})
@@ -0,0 +1,150 @@
import {
acknowledgeTerminalParkEpisodeLease,
reconcileTerminalParkEpisodeLease,
terminalParkEpisodeLeaseUnmountsPane,
type TerminalParkEpisodeLease,
type TerminalParkedWatcherSyncAcknowledgement
} from './terminal-park-episode-lease'
import type { TerminalParkedWatcherCoveragePlan } from './terminal-parked-watcher-coverage-plan'
import type { ParkableTerminalTabModel } from './terminal-parked-watcher-reconciliation'
export type TerminalParkEpisodeTab = ParkableTerminalTabModel
export type TerminalParkEpisodeSet = ReadonlyMap<string, TerminalParkEpisodeLease>
export function reconcileTerminalParkEpisodeSet(args: {
current: TerminalParkEpisodeSet
tabs: readonly TerminalParkEpisodeTab[]
requestedTabIds: ReadonlySet<string>
forcedTabIds?: ReadonlySet<string>
plan: (tab: TerminalParkEpisodeTab) => TerminalParkedWatcherCoveragePlan
}): {
leases: TerminalParkEpisodeSet
plansByTabId: ReadonlyMap<string, TerminalParkedWatcherCoveragePlan>
unmountTabIds: ReadonlySet<string>
} {
const leases = new Map<string, TerminalParkEpisodeLease>()
const plansByTabId = new Map<string, TerminalParkedWatcherCoveragePlan>()
const unmountTabIds = new Set<string>()
for (const tab of args.tabs) {
if (!args.requestedTabIds.has(tab.id)) {
continue
}
const plan = args.plan(tab)
const lease = reconcileTerminalParkEpisodeLease(args.current.get(tab.id) ?? null, plan, {
forceUnmount: args.forcedTabIds?.has(tab.id) === true
})
leases.set(tab.id, lease)
plansByTabId.set(tab.id, plan)
if (terminalParkEpisodeLeaseUnmountsPane(lease)) {
unmountTabIds.add(tab.id)
}
}
const unchanged =
leases.size === args.current.size &&
Array.from(leases).every(([tabId, lease]) => args.current.get(tabId) === lease)
return {
leases: unchanged ? args.current : leases,
plansByTabId,
unmountTabIds
}
}
export function selectCurrentTerminalParkEpisodeUnmounts(args: {
leases: TerminalParkEpisodeSet
plansByTabId: ReadonlyMap<string, TerminalParkedWatcherCoveragePlan>
requestedTabIds: ReadonlySet<string>
forcedTabIds?: ReadonlySet<string>
}): Set<string> {
const unmountTabIds = new Set<string>()
for (const tabId of args.requestedTabIds) {
if (args.forcedTabIds?.has(tabId)) {
unmountTabIds.add(tabId)
continue
}
const lease = args.leases.get(tabId)
const plan = args.plansByTabId.get(tabId)
if (
lease &&
plan &&
lease.plan.materialKey === plan.materialKey &&
terminalParkEpisodeLeaseUnmountsPane(lease)
) {
unmountTabIds.add(tabId)
}
}
return unmountTabIds
}
export function selectAcknowledgedTerminalParkEpisodeUnmounts(args: {
leases: TerminalParkEpisodeSet
plansByTabId: ReadonlyMap<string, TerminalParkedWatcherCoveragePlan>
requestedTabIds: ReadonlySet<string>
forcedTabIds?: ReadonlySet<string>
acknowledgementRequiredTabIds: ReadonlySet<string>
}): Set<string> {
const unmountTabIds = selectCurrentTerminalParkEpisodeUnmounts(args)
for (const tabId of args.acknowledgementRequiredTabIds) {
if (args.leases.get(tabId)?.phase === 'requested') {
unmountTabIds.delete(tabId)
}
}
return unmountTabIds
}
export function selectTerminalParkEpisodeWatcherTabIds(args: {
worktreeId: string
tabIds: readonly string[]
leases: TerminalParkEpisodeSet
plansByTabId: ReadonlyMap<string, TerminalParkedWatcherCoveragePlan>
renderedUnmountTabIds: ReadonlySet<string>
activationDeferredTabIds?: ReadonlySet<string>
}): Set<string> {
const watcherTabIds = new Set<string>()
for (const tabId of args.tabIds) {
const lease = args.leases.get(tabId)
const plan = args.plansByTabId.get(tabId)
if (
!lease ||
!plan ||
plan.worktreeId !== args.worktreeId ||
lease.plan.materialKey !== plan.materialKey ||
!terminalParkEpisodeLeaseUnmountsPane(lease)
) {
continue
}
const establishedDeferredWatcher =
args.activationDeferredTabIds?.has(tabId) === true &&
(lease.phase === 'covering' || lease.phase === 'forced')
if (args.renderedUnmountTabIds.has(tabId) || establishedDeferredWatcher) {
watcherTabIds.add(tabId)
}
}
return watcherTabIds
}
export function acknowledgeTerminalParkEpisodeSet(
current: TerminalParkEpisodeSet,
acknowledgements: readonly TerminalParkedWatcherSyncAcknowledgement[]
): { leases: TerminalParkEpisodeSet; renderChanged: boolean } {
let leases: Map<string, TerminalParkEpisodeLease> | null = null
let renderChanged = false
for (const acknowledgement of acknowledgements) {
const currentLease = (leases ?? current).get(acknowledgement.tabId)
if (!currentLease) {
continue
}
const nextLease = acknowledgeTerminalParkEpisodeLease(currentLease, acknowledgement)
if (nextLease === currentLease) {
continue
}
leases ??= new Map(current)
leases.set(acknowledgement.tabId, nextLease)
renderChanged ||=
terminalParkEpisodeLeaseUnmountsPane(currentLease) !==
terminalParkEpisodeLeaseUnmountsPane(nextLease)
}
return { leases: leases ?? current, renderChanged }
}
@@ -1,11 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
TERMINAL_TAB_PARK_FLIP_BURST_LIMIT,
TERMINAL_TAB_PARK_FLIP_BURST_WINDOW_MS,
TERMINAL_TAB_PARK_FLIP_COMMIT_COST,
TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT,
TERMINAL_TAB_PARK_FLIP_WINDOW_MS,
getParkVerdictUnparkPinUntilMs,
recordParkVerdictFlips,
type ParkVerdictFlipRecord
} from './terminal-park-verdict-flip-telemetry'
@@ -16,8 +12,6 @@ vi.mock('@/lib/crash-breadcrumb-recorder', () => ({
}))
const TAB = 'tab-1'
/** Slower than the burst window, so only the notice limit can fire. */
const SLOW_CHURN_STEP_MS = TERMINAL_TAB_PARK_FLIP_BURST_WINDOW_MS * 4
function observe(args: {
records: Map<string, ParkVerdictFlipRecord>
@@ -33,180 +27,116 @@ function observe(args: {
})
}
beforeEach(() => {
recordBreadcrumb.mockClear()
})
// Why asserted: the whole point of the burst trigger is that it is derived from
// React's 50-commit bail, not copied from the breadcrumb notice limit. If the
// two ever converge again the damping stops firing before React throws #185.
describe('burst damping threshold', () => {
it('stays under React NESTED_UPDATE_LIMIT at the assumed commits-per-flip cost', () => {
expect(TERMINAL_TAB_PARK_FLIP_BURST_LIMIT * TERMINAL_TAB_PARK_FLIP_COMMIT_COST).toBeLessThan(50)
expect(TERMINAL_TAB_PARK_FLIP_BURST_LIMIT).toBeLessThan(TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT)
})
})
beforeEach(() => recordBreadcrumb.mockClear())
describe('recordParkVerdictFlips', () => {
it('stays silent for a stable verdict', () => {
const records = new Map<string, ParkVerdictFlipRecord>()
for (let i = 0; i < 100; i += 1) {
observe({ records, parked: true, nowMs: 1_000 + i * 1_000 })
for (let index = 0; index < 100; index += 1) {
observe({ records, parked: true, nowMs: 1_000 + index * 1_000 })
}
expect(recordBreadcrumb).not.toHaveBeenCalled()
expect(records.get(TAB)?.flips).toBe(0)
})
it('emits one burst breadcrumb once the verdict churns at render cadence', () => {
it('emits one breadcrumb per window once the verdict churns', () => {
const records = new Map<string, ParkVerdictFlipRecord>()
for (let i = 0; i < 40; i += 1) {
observe({ records, parked: i % 2 === 0, nowMs: 1_000 + i * 10 })
for (let index = 0; index < 40; index += 1) {
observe({ records, parked: index % 2 === 0, nowMs: 1_000 + index * 10 })
}
expect(recordBreadcrumb).toHaveBeenCalledTimes(1)
expect(recordBreadcrumb).toHaveBeenCalledWith(
'terminal_park_verdict_churn',
expect.objectContaining({
tabId: TAB,
trigger: 'burst',
flips: TERMINAL_TAB_PARK_FLIP_BURST_LIMIT,
pinnedForMs: TERMINAL_TAB_PARK_FLIP_WINDOW_MS
})
expect.objectContaining({ tabId: TAB, flips: TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT })
)
})
// Why: the two triggers answer different questions — 'burst' means damping
// engaged before React could bail, 'window' means churn too slow to loop.
it('separates a damped burst from slow churn', () => {
it('reports elapsed time for tight and slow churn', () => {
const tightRecords = new Map<string, ParkVerdictFlipRecord>()
for (let i = 0; i < 40; i += 1) {
observe({ records: tightRecords, parked: i % 2 === 0, nowMs: 1_000 + i })
for (let index = 0; index < 40; index += 1) {
observe({ records: tightRecords, parked: index % 2 === 0, nowMs: 1_000 + index })
}
expect(recordBreadcrumb).toHaveBeenCalledWith(
'terminal_park_verdict_churn',
expect.objectContaining({
trigger: 'burst',
flips: TERMINAL_TAB_PARK_FLIP_BURST_LIMIT,
elapsedMs: TERMINAL_TAB_PARK_FLIP_BURST_LIMIT,
windowMs: TERMINAL_TAB_PARK_FLIP_BURST_WINDOW_MS
})
expect.objectContaining({ flips: TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT, elapsedMs: 12 })
)
recordBreadcrumb.mockClear()
const slowRecords = new Map<string, ParkVerdictFlipRecord>()
for (let i = 0; i < TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT + 1; i += 1) {
observe({ records: slowRecords, parked: i % 2 === 0, nowMs: 1_000 + i * SLOW_CHURN_STEP_MS })
for (let index = 0; index < 13; index += 1) {
observe({ records: slowRecords, parked: index % 2 === 0, nowMs: 1_000 + index * 4_000 })
}
expect(recordBreadcrumb).toHaveBeenCalledTimes(1)
expect(recordBreadcrumb).toHaveBeenCalledWith(
'terminal_park_verdict_churn',
expect.objectContaining({
trigger: 'window',
flips: TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT,
elapsedMs: TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT * SLOW_CHURN_STEP_MS,
windowMs: TERMINAL_TAB_PARK_FLIP_WINDOW_MS
})
expect.objectContaining({ flips: TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT, elapsedMs: 48_000 })
)
})
// Why: slow churn must not be damped — parking it out for a minute would cost
// a mounted pane's memory for a verdict that was never near React's bail.
it('does not pin churn spread past the burst window', () => {
const records = new Map<string, ParkVerdictFlipRecord>()
for (let i = 0; i < TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT + 1; i += 1) {
observe({ records, parked: i % 2 === 0, nowMs: 1_000 + i * SLOW_CHURN_STEP_MS })
}
expect(records.get(TAB)?.pinnedUntilMs ?? null).toBeNull()
})
it('re-arms after the window elapses', () => {
const records = new Map<string, ParkVerdictFlipRecord>()
for (let i = 0; i < 40; i += 1) {
observe({ records, parked: i % 2 === 0, nowMs: 1_000 + i * 10 })
for (let index = 0; index < 40; index += 1) {
observe({ records, parked: index % 2 === 0, nowMs: 1_000 + index * 10 })
}
expect(recordBreadcrumb).toHaveBeenCalledTimes(1)
const laterMs = 1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS * 2
for (let i = 0; i < 40; i += 1) {
observe({ records, parked: i % 2 === 0, nowMs: laterMs + i * 10 })
for (let index = 0; index < 40; index += 1) {
observe({ records, parked: index % 2 === 0, nowMs: laterMs + index * 10 })
}
expect(recordBreadcrumb).toHaveBeenCalledTimes(2)
})
// Why: an unclamped backwards jump would freeze the window and suppress the
// very signal this module exists to capture.
it('treats a backwards clock jump as a fresh window', () => {
const records = new Map<string, ParkVerdictFlipRecord>()
observe({ records, parked: true, nowMs: 10_000_000 })
observe({ records, parked: false, nowMs: 1_000 })
it('treats backwards and exactly elapsed clocks as fresh windows', () => {
const backward = new Map<string, ParkVerdictFlipRecord>()
observe({ records: backward, parked: true, nowMs: 10_000_000 })
observe({ records: backward, parked: false, nowMs: 1_000 })
expect(backward.get(TAB)).toMatchObject({ windowStartMs: 1_000, flips: 1 })
expect(records.get(TAB)?.windowStartMs).toBe(1_000)
expect(records.get(TAB)?.flips).toBe(1)
const elapsed = new Map<string, ParkVerdictFlipRecord>()
observe({ records: elapsed, parked: true, nowMs: 1_000 })
observe({
records: elapsed,
parked: false,
nowMs: 1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS
})
expect(elapsed.get(TAB)).toMatchObject({
windowStartMs: 1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS,
flips: 1
})
})
// Why: >= is the boundary operator; a > regression would silently stretch the
// window and delay every notice by one full period.
it('treats an exactly-elapsed window as expired', () => {
it('honors window and notice overrides', () => {
const records = new Map<string, ParkVerdictFlipRecord>()
observe({ records, parked: true, nowMs: 1_000 })
observe({ records, parked: false, nowMs: 1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS })
expect(records.get(TAB)?.windowStartMs).toBe(1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS)
expect(records.get(TAB)?.flips).toBe(1)
})
it('honours the window, notice, burst-window and burst-limit overrides', () => {
const records = new Map<string, ParkVerdictFlipRecord>()
for (let i = 0; i < 10; i += 1) {
for (let index = 0; index < 10; index += 1) {
recordParkVerdictFlips({
records,
liveTabIds: new Set([TAB]),
nextParkedTabIds: i % 2 === 0 ? new Set([TAB]) : new Set(),
nowMs: 1_000 + i * 100,
flipWindowMs: 5_000,
noticeLimit: 3,
burstWindowMs: 10,
burstLimit: 2
nextParkedTabIds: index % 2 === 0 ? new Set([TAB]) : new Set(),
nowMs: 1_000 + index,
flipWindowMs: 500,
noticeLimit: 3
})
}
// Why 'window': the 100ms step outruns the 10ms burst window, so the burst
// counter resets on every flip and only the notice limit can fire.
expect(recordBreadcrumb).toHaveBeenCalledTimes(1)
expect(recordBreadcrumb).toHaveBeenCalledWith(
'terminal_park_verdict_churn',
expect.objectContaining({ trigger: 'window', flips: 3, windowMs: 5_000 })
expect.objectContaining({ flips: 3, windowMs: 500 })
)
})
it('keeps per-tab windows and notices independent', () => {
it('keeps tabs independent and drops closed tabs', () => {
const records = new Map<string, ParkVerdictFlipRecord>()
const other = 'tab-2'
for (let i = 0; i < 40; i += 1) {
for (let index = 0; index < 40; index += 1) {
recordParkVerdictFlips({
records,
liveTabIds: new Set([TAB, other]),
// Why: TAB churns every call, other stays parked throughout.
nextParkedTabIds: i % 2 === 0 ? new Set([TAB, other]) : new Set([other]),
nowMs: 1_000 + i * 10
nextParkedTabIds: index % 2 === 0 ? new Set([TAB, other]) : new Set([other]),
nowMs: 1_000 + index * 10
})
}
expect(recordBreadcrumb).toHaveBeenCalledTimes(1)
expect(recordBreadcrumb).toHaveBeenCalledWith(
'terminal_park_verdict_churn',
expect.objectContaining({ tabId: TAB })
)
expect(records.get(other)?.flips).toBe(0)
})
it('drops records for tabs that no longer exist', () => {
const records = new Map<string, ParkVerdictFlipRecord>()
observe({ records, parked: true, nowMs: 1_000 })
recordParkVerdictFlips({
records,
@@ -214,126 +144,6 @@ describe('recordParkVerdictFlips', () => {
nextParkedTabIds: new Set(),
nowMs: 2_000
})
expect(records.size).toBe(0)
})
})
describe('getParkVerdictUnparkPinUntilMs', () => {
function churnToBurst(records: Map<string, ParkVerdictFlipRecord>, startMs = 1_000): number {
for (let i = 0; i < 40; i += 1) {
observe({ records, parked: i % 2 === 0, nowMs: startMs + i * 10 })
}
// The first observation only seeds the record, so flip N lands one step later.
return startMs + TERMINAL_TAB_PARK_FLIP_BURST_LIMIT * 10
}
it('does not pin a stable verdict', () => {
const records = new Map<string, ParkVerdictFlipRecord>()
observe({ records, parked: true, nowMs: 1_000 })
expect(getParkVerdictUnparkPinUntilMs({ records, tabId: TAB, nowMs: 2_000 })).toBeNull()
expect(getParkVerdictUnparkPinUntilMs({ records, tabId: 'missing', nowMs: 2_000 })).toBeNull()
})
// Why the deadline and not a boolean: the caller has to schedule a recheck at
// it, or the pin never lifts once it has stopped the churn that woke the
// verdict effect.
it('reports the pin deadline one window out, then re-arms', () => {
const records = new Map<string, ParkVerdictFlipRecord>()
const pinnedAtMs = churnToBurst(records)
const pinUntilMs = pinnedAtMs + TERMINAL_TAB_PARK_FLIP_WINDOW_MS
expect(getParkVerdictUnparkPinUntilMs({ records, tabId: TAB, nowMs: pinnedAtMs + 1 })).toBe(
pinUntilMs
)
expect(getParkVerdictUnparkPinUntilMs({ records, tabId: TAB, nowMs: pinUntilMs })).toBeNull()
expect(records.get(TAB)?.flips).toBe(0)
expect(records.get(TAB)?.burstFlips).toBe(0)
recordBreadcrumb.mockClear()
churnToBurst(records, pinUntilMs)
expect(recordBreadcrumb).toHaveBeenCalledTimes(1)
expect(getParkVerdictUnparkPinUntilMs({ records, tabId: TAB, nowMs: pinUntilMs + 1 })).not.toBe(
null
)
})
// Why: a backwards clock jump must release the pin, not strand it for a window.
it('releases the pin when the clock jumps backwards', () => {
const records = new Map<string, ParkVerdictFlipRecord>()
churnToBurst(records)
expect(getParkVerdictUnparkPinUntilMs({ records, tabId: TAB, nowMs: 5 })).toBeNull()
})
// Why: the notice window starts at the first flip and the pin starts one
// burst later, so the notice window always lapses first. Resetting it must
// not hand the pane back to the parking policy mid-damping.
it('survives a notice-window expiry that lands mid-pin', () => {
const records = new Map<string, ParkVerdictFlipRecord>()
const pinnedAtMs = churnToBurst(records)
const pinUntilMs = pinnedAtMs + TERMINAL_TAB_PARK_FLIP_WINDOW_MS
const windowLapseMs = 1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS
// An exogenous flip (visibility change, tab removal) after the notice
// window lapsed but before the pin deadline.
expect(windowLapseMs).toBeLessThan(pinUntilMs)
observe({ records, parked: true, nowMs: windowLapseMs })
expect(records.get(TAB)?.flips).toBe(1)
expect(getParkVerdictUnparkPinUntilMs({ records, tabId: TAB, nowMs: windowLapseMs })).toBe(
pinUntilMs
)
})
})
// Why liveness and not presence: a pinned tab can stop being cold-park eligible
// before its deadline, and nothing consults getParkVerdictUnparkPinUntilMs for
// it again. A stale deadline must not silence churn telemetry forever.
describe('expired pins stop gating breadcrumbs', () => {
it('re-arms damping and notices without a getParkVerdictUnparkPinUntilMs call', () => {
const records = new Map<string, ParkVerdictFlipRecord>()
for (let i = 0; i < 40; i += 1) {
observe({ records, parked: i % 2 === 0, nowMs: 1_000 + i * 10 })
}
expect(recordBreadcrumb).toHaveBeenCalledTimes(1)
expect(recordBreadcrumb).toHaveBeenLastCalledWith(
'terminal_park_verdict_churn',
expect.objectContaining({ trigger: 'burst' })
)
// Churn resumes past the pin deadline; the pin was never read back.
const afterPinMs = 1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS * 2
for (let i = 0; i < 40; i += 1) {
observe({ records, parked: i % 2 === 0, nowMs: afterPinMs + i * 10 })
}
expect(recordBreadcrumb).toHaveBeenCalledTimes(2)
expect(recordBreadcrumb).toHaveBeenLastCalledWith(
'terminal_park_verdict_churn',
expect.objectContaining({ trigger: 'burst' })
)
expect(records.get(TAB)?.pinnedUntilMs).toBeGreaterThan(afterPinMs)
})
it('still reports slow churn after a pin lapses', () => {
const records = new Map<string, ParkVerdictFlipRecord>()
for (let i = 0; i < 40; i += 1) {
observe({ records, parked: i % 2 === 0, nowMs: 1_000 + i * 10 })
}
recordBreadcrumb.mockClear()
// Slow churn only: each step outruns the burst window, so the notice limit
// is the only trigger left. It must not stay gated by the lapsed pin.
const afterPinMs = 1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS * 2
for (let i = 0; i <= TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT; i += 1) {
observe({ records, parked: i % 2 === 0, nowMs: afterPinMs + i * SLOW_CHURN_STEP_MS })
}
expect(recordBreadcrumb).toHaveBeenCalledWith(
'terminal_park_verdict_churn',
expect.objectContaining({ trigger: 'window' })
)
})
})
@@ -1,83 +1,17 @@
/**
* Cold-park verdict telemetry and a safe-side circuit breaker.
* Field breadcrumbs prove render-cadence flips, but not which eligibility input
* oscillates; burst damping keeps the pane mounted before React reaches #185.
*
* Scope: flips are counted on the rendered verdict, but the pin can only
* subtract from the cold-park candidate set. Churn driven by the other parked
* inputs (forced parking, portal ownership, deferred activation mounts) is
* observed and breadcrumbed, not damped — read a repeating `burst` crumb for
* one tab as "damping did not reach the oscillating input".
*/
/** Observes rendered cold-park verdict churn without changing parking policy. */
import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder'
export const TERMINAL_TAB_PARK_FLIP_WINDOW_MS = 60_000
/** Flips per window that no sane park policy should reach. Breadcrumb only. */
export const TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT = 12
/** react-dom 19.2.x nested commit limit. */
const REACT_NESTED_UPDATE_LIMIT = 50
/** Measured upper bound after the passive-effect pin engages. */
const PARK_PIN_SETTLE_COMMITS = 6
/** Worst-case commits from pane, watcher, and store work per verdict flip. */
export const TERMINAL_TAB_PARK_FLIP_COMMIT_COST = 12
/** Pin threshold derived from React's remaining commit budget. */
export const TERMINAL_TAB_PARK_FLIP_BURST_LIMIT = Math.max(
2,
Math.floor(
(REACT_NESTED_UPDATE_LIMIT - PARK_PIN_SETTLE_COMMITS) / TERMINAL_TAB_PARK_FLIP_COMMIT_COST
)
)
/** Honest cold parking cannot round-trip inside this horizon. */
export const TERMINAL_TAB_PARK_FLIP_BURST_WINDOW_MS = 1_000
export type ParkVerdictFlipRecord = {
parked: boolean
windowStartMs: number
flips: number
notified: boolean
burstStartMs: number
burstFlips: number
/** Set when a flip burst engaged damping; the verdict stays unparked until then. */
pinnedUntilMs?: number | null
}
// Why it leaves pinnedUntilMs alone: the notice window is 60s from the first
// flip, so it lapses mid-pin; clearing here would release damping early.
function resetFlipWindows(record: ParkVerdictFlipRecord, nowMs: number): void {
record.windowStartMs = nowMs
record.flips = 0
record.notified = false
record.burstStartMs = nowMs
record.burstFlips = 0
}
// Why liveness and not presence: a tab pinned while cold-park-eligible can stop
// being a candidate before its deadline, and nothing would consult it again. An
// expired pin must stop damping and stop gating breadcrumbs on its own.
function isParkVerdictPinLive(record: ParkVerdictFlipRecord, nowMs: number): boolean {
return record.pinnedUntilMs != null && nowMs < record.pinnedUntilMs
}
/** Returns the safe-side pin deadline and re-arms an expired window. */
export function getParkVerdictUnparkPinUntilMs(args: {
records: Map<string, ParkVerdictFlipRecord>
tabId: string
nowMs: number
}): number | null {
const record = args.records.get(args.tabId)
if (record?.pinnedUntilMs == null) {
return null
}
if (!isParkVerdictPinLive(record, args.nowMs) || args.nowMs < record.windowStartMs) {
resetFlipWindows(record, args.nowMs)
record.pinnedUntilMs = null
return null
}
return record.pinnedUntilMs
}
/** Records park-verdict churn per tab; damps bursts and breadcrumbs the rest. */
/** Records park-verdict churn per tab; emits one breadcrumb per window. */
export function recordParkVerdictFlips(args: {
records: Map<string, ParkVerdictFlipRecord>
liveTabIds: ReadonlySet<string>
@@ -85,8 +19,6 @@ export function recordParkVerdictFlips(args: {
nowMs: number
flipWindowMs?: number
noticeLimit?: number
burstWindowMs?: number
burstLimit?: number
}): void {
const {
records,
@@ -94,9 +26,7 @@ export function recordParkVerdictFlips(args: {
nextParkedTabIds,
nowMs,
flipWindowMs = TERMINAL_TAB_PARK_FLIP_WINDOW_MS,
noticeLimit = TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT,
burstWindowMs = TERMINAL_TAB_PARK_FLIP_BURST_WINDOW_MS,
burstLimit = TERMINAL_TAB_PARK_FLIP_BURST_LIMIT
noticeLimit = TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT
} = args
for (const tabId of Array.from(records.keys())) {
@@ -110,59 +40,27 @@ export function recordParkVerdictFlips(args: {
const record = records.get(tabId)
if (!record) {
records.set(tabId, {
parked,
windowStartMs: nowMs,
flips: 0,
notified: false,
burstStartMs: nowMs,
burstFlips: 0,
pinnedUntilMs: null
})
records.set(tabId, { parked, windowStartMs: nowMs, flips: 0, notified: false })
continue
}
if (parked === record.parked) {
continue
}
// Why: Date.now() jumps backwards on NTP/sleep-wake; treat any out-of-range
// elapsed value as a fresh window rather than trusting the delta.
const elapsedMs = nowMs - record.windowStartMs
if (elapsedMs >= flipWindowMs || elapsedMs < 0) {
resetFlipWindows(record, nowMs)
}
const burstElapsedMs = nowMs - record.burstStartMs
if (burstElapsedMs >= burstWindowMs || burstElapsedMs < 0) {
record.burstStartMs = nowMs
record.burstFlips = 0
record.windowStartMs = nowMs
record.flips = 0
record.notified = false
}
record.parked = parked
record.flips += 1
record.burstFlips += 1
if (!isParkVerdictPinLive(record, nowMs) && record.burstFlips >= burstLimit) {
record.pinnedUntilMs = nowMs + flipWindowMs
recordRendererCrashBreadcrumb('terminal_park_verdict_churn', {
tabId,
trigger: 'burst',
flips: record.burstFlips,
elapsedMs: nowMs - record.burstStartMs,
windowMs: burstWindowMs,
pinnedForMs: flipWindowMs
})
continue
}
// Why a live pin gates this: the burst crumb already reported the same
// window, so a second crumb would only double the volume the notice limit
// exists to keep down.
if (!isParkVerdictPinLive(record, nowMs) && !record.notified && record.flips >= noticeLimit) {
if (!record.notified && record.flips >= noticeLimit) {
record.notified = true
// Why: flips is always exactly noticeLimit here, so elapsedMs is the only
// field that separates slow churn from a burst the damping already caught.
recordRendererCrashBreadcrumb('terminal_park_verdict_churn', {
tabId,
trigger: 'window',
flips: record.flips,
elapsedMs: nowMs - record.windowStartMs,
windowMs: flipWindowMs
@@ -0,0 +1,255 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalLayoutSnapshot } from '../../../../shared/types'
const WORKTREE_ID = 'repo::/worktree'
const OTHER_WORKTREE_ID = 'repo::/other-worktree'
const TAB_ID = 'tab-1'
const PTY_ID = `${WORKTREE_ID}@@session-1`
const SECOND_PTY_ID = `${WORKTREE_ID}@@session-2`
const REPLACEMENT_PTY_ID = `${WORKTREE_ID}@@replacement`
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222'
type MockStoreState = {
tabsByWorktree: Record<string, { id: string; ptyId: string | null; generation?: number }[]>
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
clearTabLaunchAgent: ReturnType<typeof vi.fn>
clearRuntimePaneTitle: ReturnType<typeof vi.fn>
setTabLayout: ReturnType<typeof vi.fn>
updateTabTitle: ReturnType<typeof vi.fn>
}
let state: MockStoreState
type ExitCallback = (code: number, context: { hadPrimary: boolean }) => void
const watcherTransport = vi.hoisted(() => ({
exitCallbacks: new Map<string, ExitCallback>(),
discardPreHandlerPtyState: vi.fn()
}))
vi.mock('@/store', () => ({ useAppStore: { getState: () => state } }))
vi.mock('./parked-terminal-byte-watcher', () => ({
startParkedTerminalByteWatcher: vi.fn(() => vi.fn())
}))
vi.mock('./pty-dispatcher', () => ({
subscribeToPtyExit: (ptyId: string, callback: ExitCallback) => {
watcherTransport.exitCallbacks.set(ptyId, callback)
return vi.fn()
}
}))
vi.mock('./pty-pre-handler-buffer', () => ({
discardPreHandlerPtyState: watcherTransport.discardPreHandlerPtyState
}))
vi.mock('../terminal/terminal-tab-actions', () => ({ closeTerminalTab: vi.fn() }))
import { collapseParkedExitedLeaf, startParkedPtyWatcher } from './terminal-parked-pty-watcher'
import {
captureParkedTerminalPaneCandidates,
capturedPanesByTabId,
parkedWatchersByTabId
} from './terminal-parked-watcher-registry'
function splitLayout(ptyIdsByLeafId: Record<string, string>): TerminalLayoutSnapshot {
return {
root: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: LEAF_ID },
second: { type: 'leaf', leafId: SECOND_LEAF_ID }
},
activeLeafId: SECOND_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId
}
}
function capture(worktreeId: string, generation: number): void {
captureParkedTerminalPaneCandidates(TAB_ID, worktreeId, generation, [
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
}
function startFirstLeafWatcher(): void {
const entry = parkedWatchersByTabId.get(TAB_ID)
if (!entry) {
throw new Error('missing parked watcher entry')
}
entry.disposersByPtyId.set(SECOND_PTY_ID, vi.fn())
startParkedPtyWatcher({
worktreeId: WORKTREE_ID,
tab: { id: TAB_ID, ptyId: PTY_ID, generation: 1 },
pane: { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
entry,
restoreTitleOnRegister: false,
restorePolicy: { sshParkingEnabled: true }
})
}
describe('parked exit leaf resolution', () => {
beforeEach(() => {
state = {
tabsByWorktree: {
[WORKTREE_ID]: [{ id: TAB_ID, ptyId: PTY_ID, generation: 1 }]
},
terminalLayoutsByTabId: {},
runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'Agent', 2: 'Shell' } },
clearTabLaunchAgent: vi.fn(),
clearRuntimePaneTitle: vi.fn(),
setTabLayout: vi.fn(),
updateTabTitle: vi.fn()
}
parkedWatchersByTabId.set(TAB_ID, {
worktreeId: WORKTREE_ID,
tabPtyId: PTY_ID,
paneIdByPtyId: new Map([
[PTY_ID, 1],
[SECOND_PTY_ID, 2]
]),
disposersByPtyId: new Map()
})
})
afterEach(() => {
capturedPanesByTabId.delete(TAB_ID)
parkedWatchersByTabId.delete(TAB_ID)
watcherTransport.exitCallbacks.clear()
vi.clearAllMocks()
})
it('does not detach a replacement occupant through the watcher leaf fallback', () => {
startFirstLeafWatcher()
captureParkedTerminalPaneCandidates(TAB_ID, OTHER_WORKTREE_ID, 9, [
{ ptyId: SECOND_PTY_ID, paneId: 8, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: PTY_ID, paneId: 9, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
state.terminalLayoutsByTabId[TAB_ID] = splitLayout({
[LEAF_ID]: REPLACEMENT_PTY_ID,
[SECOND_LEAF_ID]: SECOND_PTY_ID
})
watcherTransport.exitCallbacks.get(PTY_ID)?.(0, { hadPrimary: false })
expect(state.setTabLayout).not.toHaveBeenCalled()
})
it('uses a current watcher leaf when the layout leaves it unbound', () => {
startFirstLeafWatcher()
state.terminalLayoutsByTabId[TAB_ID] = splitLayout({
[SECOND_LEAF_ID]: SECOND_PTY_ID
})
watcherTransport.exitCallbacks.get(PTY_ID)?.(0, { hadPrimary: false })
expect(state.setTabLayout).toHaveBeenCalledWith(TAB_ID, {
root: { type: 'leaf', leafId: SECOND_LEAF_ID },
activeLeafId: SECOND_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [SECOND_LEAF_ID]: SECOND_PTY_ID }
})
})
it('prefers a concurrent layout rebind over the watcher leaf', () => {
startFirstLeafWatcher()
state.terminalLayoutsByTabId[TAB_ID] = splitLayout({
[LEAF_ID]: SECOND_PTY_ID,
[SECOND_LEAF_ID]: PTY_ID
})
watcherTransport.exitCallbacks.get(PTY_ID)?.(0, { hadPrimary: false })
expect(state.setTabLayout).toHaveBeenCalledWith(TAB_ID, {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: SECOND_PTY_ID }
})
})
it.each(['generation', 'worktree'] as const)(
'rejects a watcher leaf after its %s identity becomes stale',
(staleIdentity) => {
startFirstLeafWatcher()
if (staleIdentity === 'generation') {
state.tabsByWorktree[WORKTREE_ID] = [{ id: TAB_ID, ptyId: PTY_ID, generation: 2 }]
} else {
parkedWatchersByTabId.set(TAB_ID, {
worktreeId: OTHER_WORKTREE_ID,
tabPtyId: PTY_ID,
paneIdByPtyId: new Map(),
disposersByPtyId: new Map()
})
}
state.terminalLayoutsByTabId[TAB_ID] = splitLayout({
[SECOND_LEAF_ID]: SECOND_PTY_ID
})
watcherTransport.exitCallbacks.get(PTY_ID)?.(0, { hadPrimary: false })
expect(state.setTabLayout).not.toHaveBeenCalled()
}
)
it('prefers the current layout binding over a stale captured leaf', () => {
capture(WORKTREE_ID, 1)
state.terminalLayoutsByTabId[TAB_ID] = splitLayout({
[LEAF_ID]: SECOND_PTY_ID,
[SECOND_LEAF_ID]: PTY_ID
})
collapseParkedExitedLeaf(TAB_ID, PTY_ID)
expect(state.setTabLayout).toHaveBeenCalledWith(TAB_ID, {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: SECOND_PTY_ID }
})
})
it('does not detach a replacement occupant through the capture fallback', () => {
capture(WORKTREE_ID, 1)
state.terminalLayoutsByTabId[TAB_ID] = splitLayout({
[LEAF_ID]: REPLACEMENT_PTY_ID,
[SECOND_LEAF_ID]: SECOND_PTY_ID
})
collapseParkedExitedLeaf(TAB_ID, PTY_ID)
expect(state.setTabLayout).not.toHaveBeenCalled()
})
it('uses a current capture when its leaf is unbound in the layout', () => {
capture(WORKTREE_ID, 1)
state.terminalLayoutsByTabId[TAB_ID] = splitLayout({
[SECOND_LEAF_ID]: SECOND_PTY_ID
})
collapseParkedExitedLeaf(TAB_ID, PTY_ID)
expect(state.setTabLayout).toHaveBeenCalledWith(TAB_ID, {
root: { type: 'leaf', leafId: SECOND_LEAF_ID },
activeLeafId: SECOND_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [SECOND_LEAF_ID]: SECOND_PTY_ID }
})
})
it.each(['generation', 'worktree'] as const)(
'does not detach a leaf from a %s-stale capture',
(staleIdentity) => {
capture(staleIdentity === 'worktree' ? OTHER_WORKTREE_ID : WORKTREE_ID, 1)
if (staleIdentity === 'generation') {
state.tabsByWorktree[WORKTREE_ID] = [{ id: TAB_ID, ptyId: PTY_ID, generation: 2 }]
}
state.terminalLayoutsByTabId[TAB_ID] = splitLayout({
[SECOND_LEAF_ID]: SECOND_PTY_ID
})
collapseParkedExitedLeaf(TAB_ID, PTY_ID)
expect(state.setTabLayout).not.toHaveBeenCalled()
}
)
})
@@ -0,0 +1,126 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher'
const WORKTREE_ID = 'repo::/worktree'
const TAB_ID = 'tab-1'
const PTY_ID = `${WORKTREE_ID}@@session-1`
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const disposeByteWatcher = vi.fn()
const startParkedTerminalByteWatcher = vi.fn(
(_options: ParkedTerminalByteWatcherOptions) => disposeByteWatcher
)
vi.mock('./parked-terminal-byte-watcher', () => ({
startParkedTerminalByteWatcher: (options: ParkedTerminalByteWatcherOptions) =>
startParkedTerminalByteWatcher(options)
}))
const unsubscribeExit = vi.fn()
let exitWatcher: ((code: number, context: { hadPrimary: boolean }) => void) | null = null
const subscribeToPtyExit = vi.fn(
(
_ptyId: string,
watcher: (code: number, context: { hadPrimary: boolean }) => void,
_options?: { adoptPreHandlerExit?: boolean }
) => {
exitWatcher = watcher
return unsubscribeExit
}
)
vi.mock('./pty-dispatcher', () => ({
subscribeToPtyExit: (
ptyId: string,
watcher: (code: number, context: { hadPrimary: boolean }) => void,
options?: { adoptPreHandlerExit?: boolean }
) => subscribeToPtyExit(ptyId, watcher, options)
}))
const storeState = {
runtimePaneTitlesByTabId: {} as Record<string, Record<number, string>>,
settings: null,
clearRuntimePaneTitle: vi.fn()
}
vi.mock('@/store', () => ({
useAppStore: { getState: () => storeState }
}))
import { startParkedPtyWatcher } from './terminal-parked-pty-watcher'
describe('parked PTY watcher handoff adoption', () => {
afterEach(() => {
exitWatcher = null
vi.clearAllMocks()
})
function createEntry() {
return {
worktreeId: WORKTREE_ID,
tabPtyId: PTY_ID,
paneIdByPtyId: new Map<string, number>(),
disposersByPtyId: new Map<string, () => void>()
}
}
it('opts the parked exit owner into buffered-exit adoption', () => {
const entry = createEntry()
startParkedPtyWatcher({
worktreeId: WORKTREE_ID,
tab: { id: TAB_ID, ptyId: PTY_ID, generation: 0 },
pane: { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
entry,
restoreTitleOnRegister: false,
restorePolicy: { sshParkingEnabled: true }
})
expect(subscribeToPtyExit).toHaveBeenCalledWith(PTY_ID, expect.any(Function), {
adoptPreHandlerExit: true
})
entry.disposersByPtyId.get(PTY_ID)?.()
expect(unsubscribeExit).toHaveBeenCalledOnce()
expect(disposeByteWatcher).toHaveBeenCalledOnce()
})
it('rolls back the byte watcher when exit subscription fails', () => {
const entry = createEntry()
entry.paneIdByPtyId.set(PTY_ID, 7)
subscribeToPtyExit.mockImplementationOnce(() => {
throw new Error('exit subscription failed')
})
expect(() =>
startParkedPtyWatcher({
worktreeId: WORKTREE_ID,
tab: { id: TAB_ID, ptyId: PTY_ID, generation: 0 },
pane: { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
entry,
restoreTitleOnRegister: false,
restorePolicy: { sshParkingEnabled: true }
})
).toThrow('exit subscription failed')
expect(disposeByteWatcher).toHaveBeenCalledOnce()
expect(entry.paneIdByPtyId.has(PTY_ID)).toBe(false)
expect(entry.disposersByPtyId.has(PTY_ID)).toBe(false)
})
it('detaches watcher ownership before adopted-exit store cleanup', () => {
const entry = createEntry()
startParkedPtyWatcher({
worktreeId: WORKTREE_ID,
tab: { id: TAB_ID, ptyId: PTY_ID, generation: 0 },
pane: { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
entry,
restoreTitleOnRegister: false,
restorePolicy: { sshParkingEnabled: true }
})
storeState.clearRuntimePaneTitle.mockImplementationOnce(() => {
throw new Error('store cleanup failed')
})
expect(() => exitWatcher?.(0, { hadPrimary: false })).toThrow('store cleanup failed')
expect(disposeByteWatcher).toHaveBeenCalledOnce()
expect(entry.disposersByPtyId.has(PTY_ID)).toBe(false)
})
})
@@ -17,6 +17,8 @@ import {
} from './terminal-pane-close-identity'
import {
capturedPanesByTabId,
markParkedTerminalWatcherOwnershipRestored,
notifyParkedTerminalWatcherOwnershipLoss,
parkedWatchersByTabId,
type ParkedTabWatcherEntry,
type ParkedTerminalPaneCapture
@@ -42,24 +44,33 @@ export function startParkedPtyWatcher(args: {
) {
return
}
let exitHandled = false
const handlePtyExit = (_code: number, { hadPrimary }: { hadPrimary: boolean }): void => {
if (exitHandled) {
return
}
exitHandled = true
const hadSiblingWatcher = entry.disposersByPtyId.size > 1
const dispose = entry.disposersByPtyId.get(ptyId)
entry.disposersByPtyId.delete(ptyId)
dispose?.()
useAppStore.getState().clearRuntimePaneTitle(tab.id, pane.paneId)
if (entry.disposersByPtyId.size > 1) {
if (hadSiblingWatcher) {
discardPreHandlerPtyState(ptyId)
collapseParkedExitedLeaf(tab.id, ptyId)
entry.disposersByPtyId.get(ptyId)?.()
entry.disposersByPtyId.delete(ptyId)
collapseParkedExitedLeaf(tab.id, ptyId, {
entry,
leafId: pane.leafId,
worktreeId,
generation: tab.generation ?? null
})
return
}
if (hadPrimary) {
entry.disposersByPtyId.get(ptyId)?.()
entry.disposersByPtyId.delete(ptyId)
notifyParkedTerminalWatcherOwnershipLoss(tab.id, entry, 'primary-exit-unowned')
return
}
// Why: the empty entry prevents a pending pinned-close confirmation from restarting the dead PTY.
entry.disposersByPtyId.get(ptyId)?.()
entry.disposersByPtyId.delete(ptyId)
closeTerminalTab(tab.id, {
captureRecentlyClosed: false,
hostCloseReason: 'pty-exit',
@@ -70,7 +81,9 @@ export function startParkedPtyWatcher(args: {
parkedWatchersByTabId.delete(tab.id)
}
},
onCancel: () => {}
onCancel: () => {
notifyParkedTerminalWatcherOwnershipLoss(tab.id, entry, 'close-cancelled')
}
})
}
const initialTitle = state.runtimePaneTitlesByTabId[tab.id]?.[pane.paneId]
@@ -87,22 +100,105 @@ export function startParkedPtyWatcher(args: {
sendRuntimePtyInput(useAppStore.getState().settings, ptyId, data)
}
})
const unsubscribeExit = isRemoteRuntimePtyId(ptyId)
? () => {}
: subscribeToPtyExit(ptyId, handlePtyExit)
let unsubscribeExit: () => void
try {
unsubscribeExit = isRemoteRuntimePtyId(ptyId)
? () => {}
: subscribeToPtyExit(ptyId, handlePtyExit, { adoptPreHandlerExit: true })
} catch (error) {
entry.paneIdByPtyId.delete(ptyId)
entry.disposersByPtyId.delete(ptyId)
try {
disposeWatcher()
} catch {
// Why: preserve the subscription failure that prevented watcher ownership.
}
throw error
}
entry.paneIdByPtyId.set(ptyId, pane.paneId)
entry.disposersByPtyId.set(ptyId, () => {
unsubscribeExit()
disposeWatcher()
})
markParkedTerminalWatcherOwnershipRestored(entry)
}
export function collapseParkedExitedLeaf(tabId: string, ptyId: string): void {
type ParkedExitedLeafWatcherFallback = {
entry: ParkedTabWatcherEntry
leafId: string
worktreeId: string
generation: number | null
}
function resolveUnoccupiedParkedExitFallbackLeaf(
layout: ReturnType<typeof useAppStore.getState>['terminalLayoutsByTabId'][string] | undefined,
leafId: string,
ptyId: string
): string | null {
const boundPtyId = layout?.ptyIdsByLeafId?.[leafId]
return boundPtyId === undefined || boundPtyId === ptyId ? leafId : null
}
function isCurrentParkedExitedLeafWatcher(
state: ReturnType<typeof useAppStore.getState>,
tabId: string,
watcher: ParkedExitedLeafWatcherFallback
): boolean {
const liveTab = (state.tabsByWorktree[watcher.worktreeId] ?? []).find(
(candidate) => candidate.id === tabId
)
return (
liveTab !== undefined &&
parkedWatchersByTabId.get(tabId) === watcher.entry &&
watcher.entry.worktreeId === watcher.worktreeId &&
(liveTab.generation ?? null) === watcher.generation
)
}
function resolveParkedExitedLeafId(
state: ReturnType<typeof useAppStore.getState>,
tabId: string,
ptyId: string,
watcher?: ParkedExitedLeafWatcherFallback
): string | null {
if (watcher && !isCurrentParkedExitedLeafWatcher(state, tabId, watcher)) {
return null
}
const layout = state.terminalLayoutsByTabId[tabId]
const boundLeafId = Object.entries(layout?.ptyIdsByLeafId ?? {}).find(
([, boundPtyId]) => boundPtyId === ptyId
)?.[0]
if (boundLeafId) {
return boundLeafId
}
if (watcher) {
return resolveUnoccupiedParkedExitFallbackLeaf(layout, watcher.leafId, ptyId)
}
const entry = parkedWatchersByTabId.get(tabId)
const capture = capturedPanesByTabId.get(tabId)
if (!entry || !capture || capture.worktreeId !== entry.worktreeId) {
return null
}
const liveTab = (state.tabsByWorktree[entry.worktreeId] ?? []).find(
(candidate) => candidate.id === tabId
)
if (!liveTab || capture.generation !== (liveTab.generation ?? null)) {
return null
}
const capturedLeafId = capture.panes.find((pane) => pane.ptyId === ptyId)?.leafId
return capturedLeafId
? resolveUnoccupiedParkedExitFallbackLeaf(layout, capturedLeafId, ptyId)
: null
}
export function collapseParkedExitedLeaf(
tabId: string,
ptyId: string,
watcher?: ParkedExitedLeafWatcherFallback
): void {
const state = useAppStore.getState()
const layout = state.terminalLayoutsByTabId[tabId]
const leafId =
capturedPanesByTabId.get(tabId)?.panes.find((pane) => pane.ptyId === ptyId)?.leafId ??
Object.entries(layout?.ptyIdsByLeafId ?? {}).find(([, boundPtyId]) => boundPtyId === ptyId)?.[0]
const leafId = resolveParkedExitedLeafId(state, tabId, ptyId, watcher)
if (!leafId) {
return
}
@@ -115,15 +115,29 @@ import {
shouldDeferParkedPtyExitTabClose,
syncParkedTerminalTabWatchers
} from './terminal-parked-tab-watchers'
import {
subscribeParkedTerminalWatcherOwnershipLoss,
type ParkedTerminalWatcherOwnershipLossEvent
} from './terminal-parked-watcher-registry'
const ptyWrite = vi.fn()
const originalWindow = (globalThis as { window?: unknown }).window
const ownershipLossUnsubscribers: (() => void)[] = []
function observeOwnershipLoss(): ReturnType<
typeof vi.fn<(event: ParkedTerminalWatcherOwnershipLossEvent) => void>
> {
const listener = vi.fn<(event: ParkedTerminalWatcherOwnershipLossEvent) => void>()
ownershipLossUnsubscribers.push(subscribeParkedTerminalWatcherOwnershipLoss(listener))
return listener
}
function capturePanes(
panes: { ptyId: string | null; paneId: number; leafId: string; drivesTabTitle: boolean }[],
args?: { tabId?: string; worktreeId?: string }
): void {
captureParkedTerminalPaneCandidates(args?.tabId ?? TAB_ID, args?.worktreeId ?? WORKTREE_ID, panes)
const { tabId = TAB_ID, worktreeId = WORKTREE_ID } = args ?? {}
captureParkedTerminalPaneCandidates(tabId, worktreeId, null, panes)
}
function syncParked(args?: {
@@ -145,7 +159,7 @@ function syncParked(args?: {
describe('terminal-parked-tab-watchers', () => {
beforeEach(async () => {
mockStoreState = {
tabsByWorktree: {},
tabsByWorktree: { [WORKTREE_ID]: [{ id: TAB_ID, ptyId: PTY_ID }] },
terminalLayoutsByTabId: {},
runtimePaneTitlesByTabId: {},
settings: null,
@@ -169,6 +183,9 @@ describe('terminal-parked-tab-watchers', () => {
pruneParkedTerminalWatchers(new Set())
startedWatchers.length = 0
exitSubscriptions.length = 0
for (const unsubscribe of ownershipLossUnsubscribers.splice(0)) {
unsubscribe()
}
vi.clearAllMocks()
clearTerminalProviderSnapshotCapabilities()
;(globalThis as { window?: unknown }).window = originalWindow
@@ -386,14 +403,22 @@ describe('terminal-parked-tab-watchers', () => {
})
it('retains the buffered exit and empty registry entry when pinned close is cancelled', () => {
const ownershipLoss = observeOwnershipLoss()
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0, { hadPrimary: false })
const options = closeTerminalTab.mock.calls[0]?.[1] as CloseTerminalTabOptions
expect(ownershipLoss).not.toHaveBeenCalled()
options.onCancel?.()
syncParked({ parkedTabIds: [] })
expect(ownershipLoss).toHaveBeenCalledOnce()
expect(ownershipLoss).toHaveBeenCalledWith({
worktreeId: WORKTREE_ID,
tabId: TAB_ID,
reason: 'close-cancelled'
})
expect(consumePreHandlerPtyState).not.toHaveBeenCalled()
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1)
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
@@ -417,6 +442,7 @@ describe('terminal-parked-tab-watchers', () => {
})
it('does not queue a second close when a retained primary handled the parked exit', () => {
const ownershipLoss = observeOwnershipLoss()
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
@@ -425,6 +451,22 @@ describe('terminal-parked-tab-watchers', () => {
expect(closeTerminalTab).not.toHaveBeenCalled()
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
expect(ownershipLoss).toHaveBeenCalledWith({
worktreeId: WORKTREE_ID,
tabId: TAB_ID,
reason: 'primary-exit-unowned'
})
})
it('does not report intentional shutdown suspension as ownership loss', () => {
const ownershipLoss = observeOwnershipLoss()
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
disposeParkedTerminalWatchersForPtyIds([PTY_ID])
expect(ownershipLoss).not.toHaveBeenCalled()
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
})
it('synchronously disposes watchers for the given PTY ids without unparking the tab', () => {
@@ -5,30 +5,16 @@
* parsers. This module runs a pane-less byte watcher per PTY while parked and
* disposes them on reveal, tab close, PTY exit, or worktree teardown.
*/
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
import { useAppStore } from '@/store'
import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
import { discardPreHandlerPtyState } from './pty-pre-handler-buffer'
import { terminalProviderHasAuthoritativeSnapshot } from '../terminal/terminal-provider-snapshot-capability'
import type { ParkableTerminalTabModel } from './terminal-parked-watcher-reconciliation'
import type { ParkedTerminalPtyEligibility } from './terminal-parked-watcher-coverage-plan'
import { collapseParkedExitedLeaf } from './terminal-parked-pty-watcher'
import { parkedWatchersByTabId } from './terminal-parked-watcher-registry'
import {
isParkRestorableTerminalPty,
selectPairedRuntimeParkingEnvironmentIds,
type TerminalParkRestorePolicy
} from './terminal-hidden-view-parking'
import {
reconcileParkedWatcherPtyIds,
resolveParkedTerminalPaneCandidates,
type ParkableTerminalTabModel
} from './terminal-parked-watcher-reconciliation'
import { collapseParkedExitedLeaf, startParkedPtyWatcher } from './terminal-parked-pty-watcher'
import {
capturedPanesByTabId,
disposeParkedTabWatchers,
parkedWatchersByTabId,
type ParkedTabWatcherEntry,
type ParkedTerminalPaneCapture
} from './terminal-parked-watcher-registry'
planParkedTerminalTabWatcherCoverage,
syncParkedTerminalTabWatchers,
syncParkedTerminalTabWatchersWithAcknowledgements
} from './terminal-parked-watcher-acknowledgement'
// Why: re-export so callers keep one import surface; the registry split only breaks the store-slice import cycle.
export {
@@ -39,37 +25,28 @@ export {
disposeParkedTerminalWatchersForWorktree,
getParkedTerminalWatcherTabIds,
pruneParkedTerminalWatchers,
subscribeParkedTerminalWatcherOwnershipLoss,
terminalWatcherLiveWorkspaceIds
} from './terminal-parked-watcher-registry'
export type { ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry'
export type {
ParkedTerminalPaneCapture,
ParkedTerminalWatcherOwnershipLossEvent
} from './terminal-parked-watcher-registry'
export {
fallbackParkedPaneCandidates,
resolveParkedTerminalPaneCandidates
} from './terminal-parked-watcher-reconciliation'
export type { ParkableTerminalTabModel } from './terminal-parked-watcher-reconciliation'
export type ParkedTerminalPtyEligibility = (ptyId: string) => boolean
const allowOrdinaryParkRestore = (ptyId: string): boolean =>
isRemoteRuntimePtyId(ptyId) ||
parseAppSshPtyId(ptyId) !== null ||
terminalProviderHasAuthoritativeSnapshot(ptyId)
// Why: fact-mode watchers work for any pty whose bytes transit local main —
// SSH included — so watcher coverage follows the park-restore policy, not the
// stricter daemon-snapshot predicate.
function parkRestorePolicyFromState(state: {
settings: { terminalSshViewParking?: boolean } | null
runtimeStatusByEnvironmentId: ReadonlyMap<
string,
{ status: { capabilities?: readonly string[] } | null | undefined }
>
}): TerminalParkRestorePolicy {
return {
sshParkingEnabled: state.settings?.terminalSshViewParking !== false,
pairedRuntimeParkingEnvironmentIds: selectPairedRuntimeParkingEnvironmentIds(
state.runtimeStatusByEnvironmentId
)
}
export type { ParkedTerminalPtyEligibility } from './terminal-parked-watcher-coverage-plan'
export type { TerminalParkedWatcherCoveragePlan } from './terminal-parked-watcher-coverage-plan'
export type {
ParkedTerminalTabWatcherSyncArgs,
TerminalParkedWatcherSyncAcknowledgement
} from './terminal-park-episode-lease'
export {
planParkedTerminalTabWatcherCoverage,
syncParkedTerminalTabWatchers,
syncParkedTerminalTabWatchersWithAcknowledgements
}
/**
@@ -80,122 +57,10 @@ function parkRestorePolicyFromState(state: {
export function canWatcherCoverParkedTerminalTab(
worktreeId: string,
tab: ParkableTerminalTabModel,
// Why: paired and SSH restore through their own policy; local parking must not
// unmount a pane whose preserved daemon can only return a lossy snapshot.
isPtyEligible: ParkedTerminalPtyEligibility = allowOrdinaryParkRestore
isPtyEligible?: ParkedTerminalPtyEligibility
): boolean {
const state = useAppStore.getState()
const panes = resolveParkedTerminalPaneCandidates(tab, state)
const restorePolicy = parkRestorePolicyFromState(state)
return (
panes.length > 0 &&
panes.every(
(pane) =>
pane.ptyId !== null &&
isTerminalLeafId(pane.leafId) &&
isParkRestorableTerminalPty(pane.ptyId, worktreeId, restorePolicy) &&
isPtyEligible(pane.ptyId)
)
)
}
function startParkedTabWatchers(
worktreeId: string,
tab: ParkableTerminalTabModel,
restoreTitleOnRegister: boolean
): void {
const entry: ParkedTabWatcherEntry = {
worktreeId,
tabPtyId: tab.ptyId,
paneIdByPtyId: new Map(),
disposersByPtyId: new Map()
}
parkedWatchersByTabId.set(tab.id, entry)
const restorePolicy = parkRestorePolicyFromState(useAppStore.getState())
for (const pane of resolveParkedTerminalPaneCandidates(tab, useAppStore.getState())) {
startParkedPtyWatcher({
worktreeId,
tab,
pane,
entry,
restoreTitleOnRegister,
restorePolicy
})
}
}
function reconcileParkedTabWatchers(
worktreeId: string,
tab: ParkableTerminalTabModel,
entry: ParkedTabWatcherEntry,
restoreTitleOnRegister: boolean
): void {
const state = useAppStore.getState()
const expectedPanes = watchablePanes(worktreeId, tab)
const expectedPtyIds = new Set(expectedPanes.keys())
const reconciliation = reconcileParkedWatcherPtyIds({
currentTabPtyId: tab.ptyId,
entryTabPtyId: entry.tabPtyId,
paneIdByPtyId: entry.paneIdByPtyId,
expectedPtyIds
})
if (reconciliation.restartAll) {
const retainedTitles = reconciliation.retainedPtyIds.flatMap((ptyId) => {
const paneId = entry.paneIdByPtyId.get(ptyId)
const title =
paneId === undefined ? undefined : state.runtimePaneTitlesByTabId[tab.id]?.[paneId]
return paneId !== undefined && title !== undefined ? [{ paneId, title }] : []
})
for (const paneId of reconciliation.retiredPaneIds) {
state.clearRuntimePaneTitle(tab.id, paneId)
}
disposeParkedTabWatchers(tab.id)
for (const { paneId, title } of retainedTitles) {
useAppStore.getState().setRuntimePaneTitle(tab.id, paneId, title)
}
startParkedTabWatchers(worktreeId, tab, restoreTitleOnRegister)
return
}
for (const [ptyId, paneId] of Array.from(entry.paneIdByPtyId)) {
if (expectedPtyIds.has(ptyId)) {
continue
}
entry.paneIdByPtyId.delete(ptyId)
const dispose = entry.disposersByPtyId.get(ptyId)
entry.disposersByPtyId.delete(ptyId)
dispose?.()
state.clearRuntimePaneTitle(tab.id, paneId)
}
const restorePolicy = parkRestorePolicyFromState(useAppStore.getState())
for (const ptyId of reconciliation.addedPtyIds) {
const pane = expectedPanes.get(ptyId)
if (pane) {
startParkedPtyWatcher({
worktreeId,
tab,
pane,
entry,
restoreTitleOnRegister,
restorePolicy
})
}
}
}
function watchablePanes(
worktreeId: string,
tab: ParkableTerminalTabModel
): Map<string, ParkedTerminalPaneCapture> {
const state = useAppStore.getState()
const restorePolicy = parkRestorePolicyFromState(state)
return new Map(
resolveParkedTerminalPaneCandidates(tab, state).flatMap((pane) =>
pane.ptyId &&
isTerminalLeafId(pane.leafId) &&
isParkRestorableTerminalPty(pane.ptyId, worktreeId, restorePolicy)
? [[pane.ptyId, pane] as const]
: []
)
planParkedTerminalTabWatcherCoverage(worktreeId, tab, { isPtyEligible }).status === 'covered'
)
}
@@ -231,62 +96,3 @@ export function shouldDeferParkedPtyExitTabClose(tabId: string, ptyId: string):
}
return defer
}
function disposeClosedParkedTabWatchers(
tabId: string,
entry: { paneIdByPtyId: ReadonlyMap<string, number> }
): void {
// Why: a queued pinned-close may close the tab first, leaving no pane to drain retained frames.
for (const ptyId of entry.paneIdByPtyId.keys()) {
discardPreHandlerPtyState(ptyId)
}
for (const paneId of entry.paneIdByPtyId.values()) {
useAppStore.getState().clearRuntimePaneTitle(tabId, paneId)
}
disposeParkedTabWatchers(tabId)
}
/**
* Reconciles watchers for one worktree against its rendered parked set.
* Run from an effect keyed on committed render state so disposal shares the
* reveal remount's flush (before PTY data IPC) and start follows the park unmount.
*/
export function syncParkedTerminalTabWatchers(args: {
worktreeId: string
tabs: readonly ParkableTerminalTabModel[]
parkedTabIds: ReadonlySet<string>
/** Parked-equivalent tabs whose pane has not restored the current title. */
restoreTitleOnStartTabIds?: ReadonlySet<string>
}): void {
const liveTabIds = new Set(args.tabs.map((tab) => tab.id))
for (const [tabId, entry] of parkedWatchersByTabId) {
if (entry.worktreeId !== args.worktreeId) {
continue
}
if (!liveTabIds.has(tabId)) {
disposeClosedParkedTabWatchers(tabId, entry)
continue
}
if (!args.parkedTabIds.has(tabId) && entry.disposersByPtyId.size > 0) {
disposeParkedTabWatchers(tabId)
}
}
// Why: closed tabs never park/reveal again; drop captures to keep the registry bounded.
for (const [tabId, capture] of capturedPanesByTabId) {
if (capture.worktreeId === args.worktreeId && !liveTabIds.has(tabId)) {
capturedPanesByTabId.delete(tabId)
}
}
for (const tab of args.tabs) {
if (!args.parkedTabIds.has(tab.id)) {
continue
}
const entry = parkedWatchersByTabId.get(tab.id)
const restoreTitleOnRegister = args.restoreTitleOnStartTabIds?.has(tab.id) === true
if (entry) {
reconcileParkedTabWatchers(args.worktreeId, tab, entry, restoreTitleOnRegister)
} else {
startParkedTabWatchers(args.worktreeId, tab, restoreTitleOnRegister)
}
}
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach'
import { parkedTerminalLeafDrivesTabTitle } from './terminal-parked-title-authority'
const FIRST_LEAF_ID = '11111111-1111-4111-8111-111111111111'
const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222'
describe('parked terminal title authority', () => {
it('promotes the surviving watcher when the active split leaf exits', () => {
const collapsed = detachTerminalLayoutLeaf(
{
root: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: FIRST_LEAF_ID },
second: { type: 'leaf', leafId: SECOND_LEAF_ID }
},
activeLeafId: FIRST_LEAF_ID,
expandedLeafId: null
},
FIRST_LEAF_ID
)
if (!collapsed) {
throw new Error('expected split collapse')
}
expect(
parkedTerminalLeafDrivesTabTitle({
activeLeafId: collapsed.sourceLayout.activeLeafId,
leafId: SECOND_LEAF_ID,
capturedAuthority: false
})
).toBe(true)
})
it('lets current layout authority override stale captured authority', () => {
expect(
parkedTerminalLeafDrivesTabTitle({
activeLeafId: SECOND_LEAF_ID,
leafId: FIRST_LEAF_ID,
capturedAuthority: true
})
).toBe(false)
})
})
@@ -0,0 +1,7 @@
export function parkedTerminalLeafDrivesTabTitle(args: {
activeLeafId: string | null | undefined
leafId: string
capturedAuthority: boolean
}): boolean {
return args.activeLeafId ? args.activeLeafId === args.leafId : args.capturedAuthority
}
@@ -0,0 +1,218 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
ParkedTabWatcherEntry,
ParkedTerminalPaneCapture
} from './terminal-parked-watcher-registry'
const WORKTREE_ID = 'repo::/worktree'
const TAB_ID = 'tab-1'
const FIRST_LEAF_ID = '11111111-1111-4111-8111-111111111111'
const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222'
const FIRST_PTY_ID = 'ssh:connection-1@@pty-1'
const SECOND_PTY_ID = 'ssh:connection-1@@pty-2'
const watcherMock = vi.hoisted(() => ({
mode: 'complete' as 'complete' | 'partial' | 'throw',
starts: [] as { ptyId: string; dispose: ReturnType<typeof vi.fn> }[]
}))
vi.mock('./terminal-parked-pty-watcher', () => ({
collapseParkedExitedLeaf: vi.fn(),
startParkedPtyWatcher: (args: {
pane: ParkedTerminalPaneCapture
entry: ParkedTabWatcherEntry
}) => {
if (watcherMock.mode === 'throw') {
throw new Error('watcher start failed')
}
if (!args.pane.ptyId || (watcherMock.mode === 'partial' && args.pane.ptyId === SECOND_PTY_ID)) {
return
}
const dispose = vi.fn()
watcherMock.starts.push({ ptyId: args.pane.ptyId, dispose })
args.entry.paneIdByPtyId.set(args.pane.ptyId, args.pane.paneId)
args.entry.disposersByPtyId.set(args.pane.ptyId, dispose)
}
}))
vi.mock('./pty-pre-handler-buffer', () => ({ discardPreHandlerPtyState: vi.fn() }))
type MockTab = { id: string; ptyId: string | null; generation?: number }
type MockStoreState = {
tabsByWorktree: Record<string, MockTab[]>
terminalLayoutsByTabId: Record<string, object>
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
settings: { terminalSshViewParking?: boolean } | null
runtimeStatusByEnvironmentId: Map<string, { status: null }>
clearRuntimePaneTitle: ReturnType<typeof vi.fn>
setRuntimePaneTitle: ReturnType<typeof vi.fn>
}
let state: MockStoreState
vi.mock('@/store', () => ({ useAppStore: { getState: () => state } }))
import {
captureParkedTerminalPaneCandidates,
getParkedTerminalWatcherTabIds,
planParkedTerminalTabWatcherCoverage,
pruneParkedTerminalWatchers,
syncParkedTerminalTabWatchersWithAcknowledgements
} from './terminal-parked-tab-watchers'
function capture(panes?: ParkedTerminalPaneCapture[]): void {
captureParkedTerminalPaneCandidates(
TAB_ID,
WORKTREE_ID,
1,
panes ?? [
{ leafId: FIRST_LEAF_ID, ptyId: FIRST_PTY_ID, paneId: 1, drivesTabTitle: true },
{ leafId: SECOND_LEAF_ID, ptyId: SECOND_PTY_ID, paneId: 2, drivesTabTitle: false }
]
)
}
function planAndSync() {
const tab = state.tabsByWorktree[WORKTREE_ID][0]
const plan = planParkedTerminalTabWatcherCoverage(WORKTREE_ID, tab)
const acknowledgements = syncParkedTerminalTabWatchersWithAcknowledgements({
worktreeId: WORKTREE_ID,
tabs: [tab],
parkedTabIds: new Set([TAB_ID]),
coveragePlansByTabId: new Map([[TAB_ID, plan]])
})
return { plan, acknowledgement: acknowledgements[0] }
}
describe('parked watcher sync acknowledgement', () => {
beforeEach(() => {
state = {
tabsByWorktree: {
[WORKTREE_ID]: [{ id: TAB_ID, ptyId: FIRST_PTY_ID, generation: 1 }]
},
terminalLayoutsByTabId: {},
runtimePaneTitlesByTabId: {},
settings: null,
runtimeStatusByEnvironmentId: new Map(),
clearRuntimePaneTitle: vi.fn(),
setRuntimePaneTitle: vi.fn()
}
watcherMock.mode = 'complete'
watcherMock.starts.length = 0
capture()
})
afterEach(() => {
pruneParkedTerminalWatchers(new Set())
vi.clearAllMocks()
})
it('acknowledges only registry-committed coverage for every planned PTY', () => {
const { plan, acknowledgement } = planAndSync()
expect(plan.status).toBe('covered')
expect(acknowledgement).toEqual({
status: 'covering',
tabId: TAB_ID,
materialKey: plan.materialKey,
watchedPtyIds: [FIRST_PTY_ID, SECOND_PTY_ID]
})
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
})
it('returns failure and rolls back a partial watcher start', () => {
watcherMock.mode = 'partial'
const { acknowledgement } = planAndSync()
expect(acknowledgement).toMatchObject({
status: 'failed',
reason: 'watcher-coverage-incomplete',
watchedPtyIds: [FIRST_PTY_ID]
})
expect(watcherMock.starts[0].dispose).toHaveBeenCalledOnce()
expect(getParkedTerminalWatcherTabIds()).toEqual([])
})
it('turns a thrown watcher start into an effect-safe failure result', () => {
watcherMock.mode = 'throw'
expect(planAndSync().acknowledgement).toMatchObject({
status: 'failed',
reason: 'watcher-start-failed'
})
expect(getParkedTerminalWatcherTabIds()).toEqual([])
})
it('rejects a same-PTY-set leaf swap against the requested material plan', () => {
const tab = state.tabsByWorktree[WORKTREE_ID][0]
const requested = planParkedTerminalTabWatcherCoverage(WORKTREE_ID, tab)
capture([
{ leafId: FIRST_LEAF_ID, ptyId: SECOND_PTY_ID, paneId: 7, drivesTabTitle: true },
{ leafId: SECOND_LEAF_ID, ptyId: FIRST_PTY_ID, paneId: 8, drivesTabTitle: false }
])
const [acknowledgement] = syncParkedTerminalTabWatchersWithAcknowledgements({
worktreeId: WORKTREE_ID,
tabs: [tab],
parkedTabIds: new Set([TAB_ID]),
coveragePlansByTabId: new Map([[TAB_ID, requested]])
})
expect(acknowledgement).toMatchObject({ status: 'failed', reason: 'material-changed' })
expect(watcherMock.starts).toEqual([])
})
it('restarts established watchers when a new plan swaps the same PTY set', () => {
planAndSync()
const originalWatchers = watcherMock.starts.slice()
capture([
{ leafId: FIRST_LEAF_ID, ptyId: SECOND_PTY_ID, paneId: 7, drivesTabTitle: true },
{ leafId: SECOND_LEAF_ID, ptyId: FIRST_PTY_ID, paneId: 8, drivesTabTitle: false }
])
const { acknowledgement } = planAndSync()
expect(acknowledgement.status).toBe('covering')
expect(watcherMock.starts).toHaveLength(4)
expect(originalWatchers.every(({ dispose }) => dispose.mock.calls.length === 1)).toBe(true)
})
it('rejects a stale tab generation or primary PTY before watcher startup', () => {
const tab = state.tabsByWorktree[WORKTREE_ID][0]
const requested = planParkedTerminalTabWatcherCoverage(WORKTREE_ID, tab)
state.tabsByWorktree[WORKTREE_ID] = [{ id: TAB_ID, ptyId: SECOND_PTY_ID, generation: 2 }]
const [acknowledgement] = syncParkedTerminalTabWatchersWithAcknowledgements({
worktreeId: WORKTREE_ID,
tabs: [tab],
parkedTabIds: new Set([TAB_ID]),
coveragePlansByTabId: new Map([[TAB_ID, requested]])
})
expect(acknowledgement).toMatchObject({ status: 'failed', reason: 'material-changed' })
expect(watcherMock.starts).toEqual([])
})
it('keeps force-parking explicitly best-effort for an uncovered plan', () => {
const tab = state.tabsByWorktree[WORKTREE_ID][0]
const plan = planParkedTerminalTabWatcherCoverage(WORKTREE_ID, tab, {
isPtyEligible: () => false
})
const [acknowledgement] = syncParkedTerminalTabWatchersWithAcknowledgements({
worktreeId: WORKTREE_ID,
tabs: [tab],
parkedTabIds: new Set([TAB_ID]),
coveragePlansByTabId: new Map([[TAB_ID, plan]]),
forcedTabIds: new Set([TAB_ID])
})
expect(plan.status).toBe('blocked')
expect(acknowledgement).toMatchObject({
status: 'forced',
materialKey: plan.materialKey,
watchedPtyIds: [FIRST_PTY_ID, SECOND_PTY_ID]
})
})
})
@@ -0,0 +1,259 @@
/** Commits planned watcher handoffs and reports their exact coverage. */
import { useAppStore } from '@/store'
import { getTerminalProviderSnapshotCapabilityState } from '../terminal/terminal-provider-snapshot-capability'
import { discardPreHandlerPtyState } from './pty-pre-handler-buffer'
import {
parkedWatcherRestorePolicyFromState,
reconcileParkedTabWatchers,
resolveParkedTerminalPaneMaterialBindings,
startParkedTabWatchers,
type ParkableTerminalTabModel
} from './terminal-parked-watcher-reconciliation'
import {
createTerminalParkedWatcherCoveragePlan,
type ParkedTerminalPtyEligibility,
type TerminalParkedWatcherCoveragePlan,
type TerminalParkedWatcherPlanGeneration
} from './terminal-parked-watcher-coverage-plan'
import {
terminalParkedWatcherPlanPtyIds,
type ParkedTerminalTabWatcherSyncArgs,
type TerminalParkedWatcherSyncAcknowledgement,
type TerminalParkedWatcherSyncFailureReason
} from './terminal-park-episode-lease'
import {
capturedPanesByTabId,
disposeParkedTabWatchers,
parkedWatchersByTabId,
type ParkedTabWatcherEntry
} from './terminal-parked-watcher-registry'
const materialKeyByWatcherEntry = new WeakMap<ParkedTabWatcherEntry, string>()
export function planParkedTerminalTabWatcherCoverage(
worktreeId: string,
tab: ParkableTerminalTabModel,
options?: {
generation?: TerminalParkedWatcherPlanGeneration
isPtyEligible?: ParkedTerminalPtyEligibility
}
): TerminalParkedWatcherCoveragePlan {
const state = useAppStore.getState()
return createTerminalParkedWatcherCoveragePlan({
worktreeId,
tab,
panes: resolveParkedTerminalPaneMaterialBindings(worktreeId, tab, state),
restorePolicy: parkedWatcherRestorePolicyFromState(state),
providerSnapshotCapability: getTerminalProviderSnapshotCapabilityState,
generation: options?.generation ?? tab.generation ?? null,
...(options?.isPtyEligible ? { isPtyEligible: options.isPtyEligible } : {})
})
}
function disposeClosedParkedTabWatchers(
tabId: string,
entry: { paneIdByPtyId: ReadonlyMap<string, number> }
): void {
// Why: a queued pinned-close may close the tab first, leaving no pane to drain retained frames.
for (const ptyId of entry.paneIdByPtyId.keys()) {
discardPreHandlerPtyState(ptyId)
}
for (const paneId of entry.paneIdByPtyId.values()) {
useAppStore.getState().clearRuntimePaneTitle(tabId, paneId)
}
disposeParkedTabWatchers(tabId)
}
function failedWatcherSync(
tabId: string,
plan: TerminalParkedWatcherCoveragePlan | undefined,
reason: TerminalParkedWatcherSyncFailureReason,
watchedPtyIds: readonly string[] = []
): TerminalParkedWatcherSyncAcknowledgement {
disposeParkedTabWatchers(tabId)
return {
status: 'failed',
tabId,
materialKey: plan?.materialKey ?? null,
reason,
expectedPtyIds: terminalParkedWatcherPlanPtyIds(plan),
watchedPtyIds
}
}
function startOrReconcileParkedTabWatchers(args: {
worktreeId: string
tab: ParkableTerminalTabModel
restoreTitleOnRegister: boolean
}): void {
const entry = parkedWatchersByTabId.get(args.tab.id)
if (entry) {
reconcileParkedTabWatchers(args.worktreeId, args.tab, entry, args.restoreTitleOnRegister)
} else {
startParkedTabWatchers(args.worktreeId, args.tab, args.restoreTitleOnRegister)
}
}
function restartWatchersForChangedMaterial(tabId: string, materialKey: string): void {
const entry = parkedWatchersByTabId.get(tabId)
if (entry && materialKeyByWatcherEntry.get(entry) !== materialKey) {
disposeParkedTabWatchers(tabId)
}
}
function syncPlannedParkedTabWatchers(args: {
worktreeId: string
tab: ParkableTerminalTabModel
plan?: TerminalParkedWatcherCoveragePlan
restoreTitleOnRegister: boolean
}): TerminalParkedWatcherSyncAcknowledgement {
const { tab, plan } = args
if (!plan) {
return failedWatcherSync(tab.id, plan, 'coverage-plan-missing')
}
if (plan.status !== 'covered') {
return failedWatcherSync(tab.id, plan, 'coverage-plan-not-covered')
}
const liveTab = (useAppStore.getState().tabsByWorktree[args.worktreeId] ?? []).find(
(candidate) => candidate.id === tab.id
)
if (
!liveTab ||
liveTab.ptyId !== plan.tabPtyId ||
(liveTab.generation ?? null) !== plan.generation ||
plan.worktreeId !== args.worktreeId
) {
return failedWatcherSync(tab.id, plan, 'material-changed')
}
const currentPlan = planParkedTerminalTabWatcherCoverage(args.worktreeId, liveTab)
if (currentPlan.status !== 'covered' || currentPlan.materialKey !== plan.materialKey) {
return failedWatcherSync(tab.id, plan, 'material-changed')
}
try {
restartWatchersForChangedMaterial(tab.id, plan.materialKey)
startOrReconcileParkedTabWatchers({ ...args, tab: liveTab })
} catch {
return failedWatcherSync(tab.id, plan, 'watcher-start-failed')
}
const entry = parkedWatchersByTabId.get(tab.id)
const watchedPtyIds = Array.from(entry?.disposersByPtyId.keys() ?? []).sort((left, right) =>
left.localeCompare(right)
)
const expectedPtyIds = terminalParkedWatcherPlanPtyIds(plan)
if (
watchedPtyIds.length !== expectedPtyIds.length ||
expectedPtyIds.some(
(ptyId, index) => watchedPtyIds[index] !== ptyId || !entry?.paneIdByPtyId.has(ptyId)
)
) {
return failedWatcherSync(tab.id, plan, 'watcher-coverage-incomplete', watchedPtyIds)
}
if (entry) {
materialKeyByWatcherEntry.set(entry, plan.materialKey)
}
return { status: 'covering', tabId: tab.id, materialKey: plan.materialKey, watchedPtyIds }
}
function syncForcedParkedTabWatchers(args: {
worktreeId: string
tab: ParkableTerminalTabModel
plan?: TerminalParkedWatcherCoveragePlan
restoreTitleOnRegister: boolean
}): TerminalParkedWatcherSyncAcknowledgement {
try {
if (args.plan) {
restartWatchersForChangedMaterial(args.tab.id, args.plan.materialKey)
}
startOrReconcileParkedTabWatchers(args)
} catch {
return failedWatcherSync(args.tab.id, args.plan, 'watcher-start-failed')
}
const entry = parkedWatchersByTabId.get(args.tab.id)
const watchedPtyIds = Array.from(entry?.disposersByPtyId.keys() ?? []).sort((left, right) =>
left.localeCompare(right)
)
if (entry && args.plan) {
materialKeyByWatcherEntry.set(entry, args.plan.materialKey)
}
return {
status: 'forced',
tabId: args.tab.id,
materialKey: args.plan?.materialKey ?? null,
watchedPtyIds
}
}
function syncParkedTerminalTabWatchersInternal(
args: ParkedTerminalTabWatcherSyncArgs,
coveragePlansByTabId?: ReadonlyMap<string, TerminalParkedWatcherCoveragePlan>,
forcedTabIds?: ReadonlySet<string>
): TerminalParkedWatcherSyncAcknowledgement[] {
const acknowledgements: TerminalParkedWatcherSyncAcknowledgement[] = []
const liveTabIds = new Set(args.tabs.map((tab) => tab.id))
for (const [tabId, entry] of parkedWatchersByTabId) {
if (entry.worktreeId !== args.worktreeId) {
continue
}
if (!liveTabIds.has(tabId)) {
disposeClosedParkedTabWatchers(tabId, entry)
continue
}
if (!args.parkedTabIds.has(tabId) && entry.disposersByPtyId.size > 0) {
disposeParkedTabWatchers(tabId)
}
}
// Why: closed tabs never park/reveal again; drop captures to keep the registry bounded.
for (const [tabId, capture] of capturedPanesByTabId) {
if (capture.worktreeId === args.worktreeId && !liveTabIds.has(tabId)) {
capturedPanesByTabId.delete(tabId)
}
}
for (const tab of args.tabs) {
if (!args.parkedTabIds.has(tab.id)) {
continue
}
const entry = parkedWatchersByTabId.get(tab.id)
const restoreTitleOnRegister = args.restoreTitleOnStartTabIds?.has(tab.id) === true
if (coveragePlansByTabId) {
if (forcedTabIds?.has(tab.id)) {
acknowledgements.push(
syncForcedParkedTabWatchers({
worktreeId: args.worktreeId,
tab,
plan: coveragePlansByTabId.get(tab.id),
restoreTitleOnRegister
})
)
continue
}
acknowledgements.push(
syncPlannedParkedTabWatchers({
worktreeId: args.worktreeId,
tab,
plan: coveragePlansByTabId.get(tab.id),
restoreTitleOnRegister
})
)
continue
}
if (entry) {
reconcileParkedTabWatchers(args.worktreeId, tab, entry, restoreTitleOnRegister)
} else {
startParkedTabWatchers(args.worktreeId, tab, restoreTitleOnRegister)
}
}
return acknowledgements
}
export function syncParkedTerminalTabWatchers(args: ParkedTerminalTabWatcherSyncArgs): void {
syncParkedTerminalTabWatchersInternal(args)
}
export function syncParkedTerminalTabWatchersWithAcknowledgements(
args: ParkedTerminalTabWatcherSyncArgs & {
coveragePlansByTabId: ReadonlyMap<string, TerminalParkedWatcherCoveragePlan>
forcedTabIds?: ReadonlySet<string>
}
): TerminalParkedWatcherSyncAcknowledgement[] {
return syncParkedTerminalTabWatchersInternal(args, args.coveragePlansByTabId, args.forcedTabIds)
}
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import { createTerminalParkedWatcherCoveragePlan } from './terminal-parked-watcher-coverage-plan'
const WORKTREE_ID = 'repo::/worktree'
const TAB_ID = 'tab-1'
const FIRST_LEAF_ID = '11111111-1111-4111-8111-111111111111'
const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222'
const FIRST_PTY_ID = `${WORKTREE_ID}@@session-1`
const SECOND_PTY_ID = `${WORKTREE_ID}@@session-2`
function createPlan(args?: {
panes?: readonly { leafId: string; ptyId: string | null }[]
generation?: number
capability?: 'pending' | 'authoritative' | 'unavailable'
sshParkingEnabled?: boolean
pairedEnvironments?: readonly string[]
}) {
return createTerminalParkedWatcherCoveragePlan({
worktreeId: WORKTREE_ID,
tab: { id: TAB_ID, ptyId: FIRST_PTY_ID, generation: args?.generation ?? 1 },
panes: args?.panes ?? [{ leafId: FIRST_LEAF_ID, ptyId: FIRST_PTY_ID }],
restorePolicy: {
sshParkingEnabled: args?.sshParkingEnabled ?? true,
pairedRuntimeParkingEnvironmentIds: new Set(args?.pairedEnvironments ?? [])
},
providerSnapshotCapability: () => args?.capability ?? 'authoritative'
})
}
describe('terminal parked watcher coverage plan', () => {
it('ignores pane order and mount-only pane metadata', () => {
const mountedPanes = [
{
leafId: FIRST_LEAF_ID,
ptyId: FIRST_PTY_ID,
paneId: 1,
drivesTabTitle: true
},
{
leafId: SECOND_LEAF_ID,
ptyId: SECOND_PTY_ID,
paneId: 2,
drivesTabTitle: false
}
]
const recapturedPanes = [
{ ...mountedPanes[1], paneId: 9, drivesTabTitle: true },
{ ...mountedPanes[0], paneId: 8, drivesTabTitle: false }
]
expect(createPlan({ panes: mountedPanes }).materialKey).toBe(
createPlan({ panes: recapturedPanes }).materialKey
)
})
it('includes generation and exact leaf-to-PTY topology', () => {
const panes = [
{ leafId: FIRST_LEAF_ID, ptyId: FIRST_PTY_ID },
{ leafId: SECOND_LEAF_ID, ptyId: SECOND_PTY_ID }
]
const original = createPlan({ panes })
const reminted = createPlan({ panes, generation: 2 })
const swapped = createPlan({
panes: [
{ leafId: FIRST_LEAF_ID, ptyId: SECOND_PTY_ID },
{ leafId: SECOND_LEAF_ID, ptyId: FIRST_PTY_ID }
]
})
expect(reminted.materialKey).not.toBe(original.materialKey)
expect(swapped.materialKey).not.toBe(original.materialKey)
})
it('distinguishes pending, unsupported, and authoritative local providers', () => {
const pending = createPlan({ capability: 'pending' })
const blocked = createPlan({ capability: 'unavailable' })
const covered = createPlan({ capability: 'authoritative' })
expect(pending).toMatchObject({
status: 'pending',
issue: { reason: 'provider-capability-pending' }
})
expect(blocked).toMatchObject({
status: 'blocked',
issue: { reason: 'provider-snapshot-unavailable' }
})
expect(covered.status).toBe('covered')
expect(new Set([pending.materialKey, blocked.materialKey, covered.materialKey]).size).toBe(3)
})
it('keeps missing models pending and malformed topology blocked', () => {
expect(createPlan({ panes: [] })).toMatchObject({
status: 'pending',
issue: { reason: 'pane-model-pending' }
})
expect(createPlan({ panes: [{ leafId: FIRST_LEAF_ID, ptyId: null }] })).toMatchObject({
status: 'pending',
issue: { reason: 'pane-pty-pending' }
})
expect(createPlan({ panes: [{ leafId: 'legacy-leaf', ptyId: FIRST_PTY_ID }] })).toMatchObject({
status: 'blocked',
issue: { reason: 'invalid-leaf-id' }
})
})
it('keys SSH and paired-runtime coverage to their exact authority', () => {
const sshPanes = [{ leafId: FIRST_LEAF_ID, ptyId: 'ssh:connection-1@@pty-1' }]
const pairedPanes = [{ leafId: FIRST_LEAF_ID, ptyId: 'remote:env-1@@terminal-1' }]
expect(createPlan({ panes: sshPanes, sshParkingEnabled: false }).status).toBe('blocked')
expect(createPlan({ panes: sshPanes, sshParkingEnabled: true }).status).toBe('covered')
expect(createPlan({ panes: pairedPanes }).status).toBe('blocked')
expect(createPlan({ panes: pairedPanes, pairedEnvironments: ['env-1'] }).status).toBe('covered')
})
})
@@ -0,0 +1,222 @@
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
import type { TerminalProviderSnapshotCapabilityState } from '../terminal/terminal-provider-snapshot-capability'
import {
isSnapshotBackedTerminalPty,
type TerminalParkRestorePolicy
} from './terminal-hidden-view-parking'
import {
normalizeParkedTerminalPaneMaterialBindings,
type ParkableTerminalTabModel,
type ParkedTerminalPaneMaterialBinding
} from './terminal-parked-watcher-reconciliation'
export type TerminalParkedWatcherPlanGeneration = number | null
export type TerminalParkedWatcherCoverageIssueReason =
| 'pane-model-pending'
| 'pane-pty-pending'
| 'invalid-leaf-id'
| 'duplicate-leaf-binding'
| 'duplicate-pty-binding'
| 'pty-not-restorable'
| 'ssh-parking-disabled'
| 'paired-runtime-unavailable'
| 'provider-capability-pending'
| 'provider-snapshot-unavailable'
| 'pty-ineligible'
export type TerminalParkedWatcherCoverageIssue = {
reason: TerminalParkedWatcherCoverageIssueReason
leafId?: string
ptyId?: string | null
}
type TerminalParkedWatcherCoveragePlanBase = {
materialKey: string
worktreeId: string
tabId: string
tabPtyId: string | null
generation: TerminalParkedWatcherPlanGeneration
panes: readonly ParkedTerminalPaneMaterialBinding[]
}
export type TerminalParkedWatcherPendingPlan = TerminalParkedWatcherCoveragePlanBase & {
status: 'pending'
issue: TerminalParkedWatcherCoverageIssue
}
export type TerminalParkedWatcherBlockedPlan = TerminalParkedWatcherCoveragePlanBase & {
status: 'blocked'
issue: TerminalParkedWatcherCoverageIssue
}
export type TerminalParkedWatcherCoveredPlan = TerminalParkedWatcherCoveragePlanBase & {
status: 'covered'
}
export type TerminalParkedWatcherCoveragePlan =
| TerminalParkedWatcherPendingPlan
| TerminalParkedWatcherBlockedPlan
| TerminalParkedWatcherCoveredPlan
export type ParkedTerminalPtyEligibility = (ptyId: string) => boolean
type TerminalParkedWatcherPtyCoverage =
| { status: 'covered'; authority: string }
| { status: 'pending'; reason: TerminalParkedWatcherCoverageIssueReason }
| { status: 'blocked'; reason: TerminalParkedWatcherCoverageIssueReason }
type EvaluatedPane = {
pane: ParkedTerminalPaneMaterialBinding
coverage: TerminalParkedWatcherPtyCoverage
}
function resolveTerminalParkedWatcherPtyCoverage(args: {
ptyId: string
worktreeId: string
restorePolicy: TerminalParkRestorePolicy
providerSnapshotCapability: (ptyId: string) => TerminalProviderSnapshotCapabilityState
isPtyEligible?: ParkedTerminalPtyEligibility
}): TerminalParkedWatcherPtyCoverage {
const ssh = parseAppSshPtyId(args.ptyId)
let coverage: TerminalParkedWatcherPtyCoverage
if (ssh) {
coverage =
args.restorePolicy.sshParkingEnabled === true
? { status: 'covered', authority: `ssh:${ssh.connectionId}` }
: { status: 'blocked', reason: 'ssh-parking-disabled' }
} else if (isRemoteRuntimePtyId(args.ptyId)) {
const environmentId = getRemoteRuntimePtyEnvironmentId(args.ptyId)
coverage =
environmentId !== null &&
args.restorePolicy.pairedRuntimeParkingEnvironmentIds?.has(environmentId) === true
? { status: 'covered', authority: `paired-runtime:${environmentId}` }
: { status: 'blocked', reason: 'paired-runtime-unavailable' }
} else if (!isSnapshotBackedTerminalPty(args.ptyId, args.worktreeId)) {
coverage = { status: 'blocked', reason: 'pty-not-restorable' }
} else {
const capability = args.providerSnapshotCapability(args.ptyId)
coverage =
capability === 'pending'
? { status: 'pending', reason: 'provider-capability-pending' }
: capability === 'authoritative'
? { status: 'covered', authority: 'local-provider-snapshot' }
: { status: 'blocked', reason: 'provider-snapshot-unavailable' }
}
if (coverage.status === 'covered' && args.isPtyEligible?.(args.ptyId) === false) {
return { status: 'blocked', reason: 'pty-ineligible' }
}
return coverage
}
function evaluatePane(args: {
pane: ParkedTerminalPaneMaterialBinding
leafCounts: ReadonlyMap<string, number>
ptyCounts: ReadonlyMap<string, number>
worktreeId: string
restorePolicy: TerminalParkRestorePolicy
providerSnapshotCapability: (ptyId: string) => TerminalProviderSnapshotCapabilityState
isPtyEligible?: ParkedTerminalPtyEligibility
}): EvaluatedPane {
const { pane } = args
if (!isTerminalLeafId(pane.leafId)) {
return { pane, coverage: { status: 'blocked', reason: 'invalid-leaf-id' } }
}
if ((args.leafCounts.get(pane.leafId) ?? 0) > 1) {
return { pane, coverage: { status: 'blocked', reason: 'duplicate-leaf-binding' } }
}
if (pane.ptyId === null) {
return { pane, coverage: { status: 'pending', reason: 'pane-pty-pending' } }
}
if ((args.ptyCounts.get(pane.ptyId) ?? 0) > 1) {
return { pane, coverage: { status: 'blocked', reason: 'duplicate-pty-binding' } }
}
return {
pane,
coverage: resolveTerminalParkedWatcherPtyCoverage({
ptyId: pane.ptyId,
worktreeId: args.worktreeId,
restorePolicy: args.restorePolicy,
providerSnapshotCapability: args.providerSnapshotCapability,
...(args.isPtyEligible ? { isPtyEligible: args.isPtyEligible } : {})
})
}
}
function coverageMaterialRow({ pane, coverage }: EvaluatedPane): readonly unknown[] {
return [
pane.leafId,
pane.ptyId,
coverage.status,
coverage.status === 'covered' ? coverage.authority : coverage.reason
]
}
export function createTerminalParkedWatcherCoveragePlan(args: {
worktreeId: string
tab: ParkableTerminalTabModel
panes: readonly ParkedTerminalPaneMaterialBinding[]
restorePolicy: TerminalParkRestorePolicy
providerSnapshotCapability: (ptyId: string) => TerminalProviderSnapshotCapabilityState
generation?: TerminalParkedWatcherPlanGeneration
isPtyEligible?: ParkedTerminalPtyEligibility
}): TerminalParkedWatcherCoveragePlan {
const panes = normalizeParkedTerminalPaneMaterialBindings(args.panes)
const leafCounts = new Map<string, number>()
const ptyCounts = new Map<string, number>()
for (const pane of panes) {
leafCounts.set(pane.leafId, (leafCounts.get(pane.leafId) ?? 0) + 1)
if (pane.ptyId !== null) {
ptyCounts.set(pane.ptyId, (ptyCounts.get(pane.ptyId) ?? 0) + 1)
}
}
const evaluated = panes.map((pane) =>
evaluatePane({
pane,
leafCounts,
ptyCounts,
worktreeId: args.worktreeId,
restorePolicy: args.restorePolicy,
providerSnapshotCapability: args.providerSnapshotCapability,
...(args.isPtyEligible ? { isPtyEligible: args.isPtyEligible } : {})
})
)
const generation = args.generation ?? args.tab.generation ?? null
const base: TerminalParkedWatcherCoveragePlanBase = {
materialKey: JSON.stringify([
'terminal-parked-watcher-plan-v1',
args.worktreeId,
args.tab.id,
args.tab.ptyId,
generation,
evaluated.map(coverageMaterialRow)
]),
worktreeId: args.worktreeId,
tabId: args.tab.id,
tabPtyId: args.tab.ptyId,
generation,
panes
}
if (panes.length === 0) {
return { ...base, status: 'pending', issue: { reason: 'pane-model-pending' } }
}
const blocked = evaluated.find((entry) => entry.coverage.status === 'blocked')
if (blocked?.coverage.status === 'blocked') {
return {
...base,
status: 'blocked',
issue: { ...blocked.pane, reason: blocked.coverage.reason }
}
}
const pending = evaluated.find((entry) => entry.coverage.status === 'pending')
if (pending?.coverage.status === 'pending') {
return {
...base,
status: 'pending',
issue: { ...pending.pane, reason: pending.coverage.reason }
}
}
return { ...base, status: 'covered' }
}
@@ -0,0 +1,271 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalParkedWatcherSyncAcknowledgement } from './terminal-park-episode-lease'
const WORKTREE_ID = 'repo::/worktree'
const TAB_ID = 'tab-1'
const PTY_ID = `${WORKTREE_ID}@@session-1`
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
type ExitCallback = (code: number, context: { hadPrimary: boolean }) => void
const transport = vi.hoisted(() => ({
dataHandlers: new Map<string, (data: string) => void>(),
dataSidecars: new Map<string, Set<(data: string) => void>>(),
exitSidecars: new Map<string, Set<ExitCallback>>(),
setHiddenRendererPty: vi.fn(),
setPtyDeliveryInterest: vi.fn(),
closeTerminalTab: vi.fn(),
discardPreHandlerPtyState: vi.fn(),
clearPreHandlerPtyData: vi.fn(),
drainPreHandlerPtyData: vi.fn(),
dispatchNotification: vi.fn()
}))
vi.mock('./pty-dispatcher', () => ({
ensurePtyDispatcher: vi.fn(),
ptyDataHandlers: transport.dataHandlers,
ptyDataSidecars: transport.dataSidecars,
subscribeToPtyExit: (ptyId: string, callback: ExitCallback) => {
const callbacks = transport.exitSidecars.get(ptyId) ?? new Set<ExitCallback>()
callbacks.add(callback)
transport.exitSidecars.set(ptyId, callbacks)
return () => {
const current = transport.exitSidecars.get(ptyId)
current?.delete(callback)
if (current?.size === 0) {
transport.exitSidecars.delete(ptyId)
}
}
}
}))
vi.mock('./pty-pre-handler-buffer', () => ({
discardPreHandlerPtyState: transport.discardPreHandlerPtyState,
clearPreHandlerPtyData: transport.clearPreHandlerPtyData,
drainPreHandlerPtyData: transport.drainPreHandlerPtyData
}))
vi.mock('../terminal/terminal-tab-actions', () => ({
closeTerminalTab: transport.closeTerminalTab
}))
vi.mock('./use-notification-dispatch', () => ({
dispatchTerminalNotification: transport.dispatchNotification
}))
vi.mock('./parked-terminal-command-status', () => ({
createParkedTerminalCommandStatusPolicy: () => ({
onCommandFinished: vi.fn(),
onCommandCodeWorking: vi.fn(),
onCommandCodeDone: vi.fn(),
dispose: vi.fn()
}),
readInFlightCommandCodeTurn: () => null
}))
vi.mock('@/lib/terminal-theme', () => ({ getSystemPrefersDark: () => true }))
vi.mock('./terminal-freeze-breadcrumbs', () => ({ recordTerminalFreezeBreadcrumb: vi.fn() }))
type MockStoreState = {
tabsByWorktree: Record<string, { id: string; ptyId: string | null; generation: number }[]>
terminalLayoutsByTabId: Record<string, object>
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
settings: {
terminalMainSideEffectAuthority: boolean
terminalHiddenDeliveryGate: boolean
terminalSshViewParking: boolean
notifications: { enabled: boolean }
}
runtimeStatusByEnvironmentId: Map<string, never>
agentStatusByPaneKey: Record<string, never>
clearRuntimePaneTitle: ReturnType<typeof vi.fn>
setRuntimePaneTitle: ReturnType<typeof vi.fn>
updateTabTitle: ReturnType<typeof vi.fn>
markWorktreeUnread: ReturnType<typeof vi.fn>
markTerminalTabUnread: ReturnType<typeof vi.fn>
markTerminalPaneUnread: ReturnType<typeof vi.fn>
setCacheTimerStartedAt: ReturnType<typeof vi.fn>
observeTerminalGitHubPullRequestLink: ReturnType<typeof vi.fn>
clearTabLaunchAgent: ReturnType<typeof vi.fn>
setTabLayout: ReturnType<typeof vi.fn>
}
let storeState: MockStoreState
vi.mock('@/store', () => ({ useAppStore: { getState: () => storeState } }))
import {
clearTerminalProviderSnapshotCapabilities,
synchronizeTerminalProviderSnapshotCapabilities
} from '../terminal/terminal-provider-snapshot-capability'
import {
captureParkedTerminalPaneCandidates,
getParkedTerminalWatcherTabIds,
planParkedTerminalTabWatcherCoverage,
pruneParkedTerminalWatchers,
syncParkedTerminalTabWatchersWithAcknowledgements
} from './terminal-parked-tab-watchers'
import { parkedWatchersByTabId } from './terminal-parked-watcher-registry'
import {
_dispatchTerminalSideEffectBatchForTest,
_resetTerminalSideEffectFactConsumersForTest
} from './terminal-side-effect-facts-handler'
import { _resetPtyRendererDeliveryClaimsForTest } from './pty-renderer-delivery-claims'
type Host = 'split' | 'legacy'
type WatcherMode = 'bytes' | 'hidden-facts'
function createStoreState(mode: WatcherMode): MockStoreState {
return {
tabsByWorktree: {
[WORKTREE_ID]: [{ id: TAB_ID, ptyId: PTY_ID, generation: 1 }]
},
terminalLayoutsByTabId: {},
runtimePaneTitlesByTabId: {},
settings: {
terminalMainSideEffectAuthority: mode === 'hidden-facts',
terminalHiddenDeliveryGate: true,
terminalSshViewParking: true,
notifications: { enabled: false }
},
runtimeStatusByEnvironmentId: new Map<string, never>(),
agentStatusByPaneKey: {},
clearRuntimePaneTitle: vi.fn(),
setRuntimePaneTitle: vi.fn(),
updateTabTitle: vi.fn(),
markWorktreeUnread: vi.fn(),
markTerminalTabUnread: vi.fn(),
markTerminalPaneUnread: vi.fn(),
setCacheTimerStartedAt: vi.fn(),
observeTerminalGitHubPullRequestLink: vi.fn(),
clearTabLaunchAgent: vi.fn(),
setTabLayout: vi.fn()
}
}
function syncFromHost(_host: Host): TerminalParkedWatcherSyncAcknowledgement {
const tab = storeState.tabsByWorktree[WORKTREE_ID][0]
const plan = planParkedTerminalTabWatcherCoverage(WORKTREE_ID, tab)
const [acknowledgement] = syncParkedTerminalTabWatchersWithAcknowledgements({
worktreeId: WORKTREE_ID,
tabs: [tab],
parkedTabIds: new Set([TAB_ID]),
coveragePlansByTabId: new Map([[TAB_ID, plan]]),
restoreTitleOnStartTabIds: new Set([TAB_ID])
})
return acknowledgement
}
function emitData(data: string): void {
for (const callback of Array.from(transport.dataSidecars.get(PTY_ID) ?? [])) {
callback(data)
}
}
function emitExit(): void {
const callbacks = Array.from(transport.exitSidecars.get(PTY_ID) ?? [])
transport.exitSidecars.delete(PTY_ID)
for (const callback of callbacks) {
callback(0, { hadPrimary: false })
}
}
describe('parked watcher split and legacy host transitions', () => {
const originalWindow = (globalThis as { window?: Window }).window
beforeEach(async () => {
vi.useFakeTimers()
;(globalThis as { window: Window }).window = {
...originalWindow,
api: {
pty: {
setHiddenRendererPty: transport.setHiddenRendererPty,
setPtyDeliveryInterest: transport.setPtyDeliveryInterest,
onSideEffect: vi.fn(() => () => {}),
write: vi.fn()
}
}
} as unknown as Window
clearTerminalProviderSnapshotCapabilities()
await synchronizeTerminalProviderSnapshotCapabilities([PTY_ID], async () => [
{ id: PTY_ID, authoritative: true }
])
})
afterEach(() => {
pruneParkedTerminalWatchers(new Set())
_resetTerminalSideEffectFactConsumersForTest()
_resetPtyRendererDeliveryClaimsForTest()
clearTerminalProviderSnapshotCapabilities()
transport.dataHandlers.clear()
transport.dataSidecars.clear()
transport.exitSidecars.clear()
vi.clearAllMocks()
vi.useRealTimers()
if (originalWindow) {
;(globalThis as { window: Window }).window = originalWindow
} else {
delete (globalThis as { window?: Window }).window
}
})
it.each([
['split', 'legacy', 'bytes'],
['legacy', 'split', 'bytes'],
['split', 'legacy', 'hidden-facts'],
['legacy', 'split', 'hidden-facts']
] as const)('keeps one owner across %s to %s in %s mode', async (first, second, mode) => {
storeState = createStoreState(mode)
captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, 1, [
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }
])
const firstAcknowledgement = syncFromHost(first)
const secondAcknowledgement = syncFromHost(second)
expect(firstAcknowledgement).toMatchObject({
status: 'covering',
tabId: TAB_ID,
watchedPtyIds: [PTY_ID]
})
expect(secondAcknowledgement).toEqual(firstAcknowledgement)
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
expect(parkedWatchersByTabId.get(TAB_ID)?.disposersByPtyId.size).toBe(1)
expect(transport.exitSidecars.get(PTY_ID)?.size).toBe(1)
if (mode === 'bytes') {
expect(transport.dataSidecars.get(PTY_ID)?.size).toBe(2)
expect(transport.setPtyDeliveryInterest.mock.calls).toEqual([[PTY_ID, true]])
expect(transport.setHiddenRendererPty).not.toHaveBeenCalled()
emitData('\x07')
vi.advanceTimersByTime(0)
} else {
expect(transport.dataSidecars.get(PTY_ID)).toBeUndefined()
expect(transport.setPtyDeliveryInterest).not.toHaveBeenCalled()
expect(transport.setHiddenRendererPty.mock.calls).toEqual([[PTY_ID, true]])
_dispatchTerminalSideEffectBatchForTest({
ptyId: PTY_ID,
seq: 1,
facts: [{ kind: 'bell' }]
})
}
expect(storeState.markWorktreeUnread).toHaveBeenCalledOnce()
expect(storeState.markTerminalTabUnread).toHaveBeenCalledOnce()
emitExit()
expect(transport.closeTerminalTab).toHaveBeenCalledOnce()
expect(transport.exitSidecars.get(PTY_ID)).toBeUndefined()
if (mode === 'bytes') {
expect(transport.setPtyDeliveryInterest.mock.calls).toEqual([
[PTY_ID, true],
[PTY_ID, false]
])
} else {
expect(transport.setHiddenRendererPty.mock.calls).toEqual([
[PTY_ID, true],
[PTY_ID, false]
])
}
})
})
@@ -77,7 +77,7 @@ afterEach(() => {
})
it('retains a continuing watcher and title while reconciling a reminted split leaf', () => {
captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, [
captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, null, [
{ ptyId: FIRST_PTY_ID, paneId: 1, leafId: FIRST_LEAF_ID, drivesTabTitle: true },
{ ptyId: OLD_SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
@@ -4,11 +4,14 @@ import {
retireParkedTerminalTab
} from './terminal-parked-watcher-registry'
import {
createParkedTerminalWatcherTopologyKey,
normalizeParkedTerminalPaneMaterialBindings,
reconcileParkedWatcherPtyIds,
resolveParkedTerminalPaneCandidates
} from './terminal-parked-watcher-reconciliation'
const TAB_ID = 'tab-1'
const OTHER_TAB_ID = 'tab-2'
const WORKTREE_ID = 'repo::/worktree'
const FIRST_LEAF_ID = '11111111-1111-4111-8111-111111111111'
const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222'
@@ -16,13 +19,159 @@ const FIRST_PTY_ID = 'remote:env-1@@terminal-1'
const OLD_SECOND_PTY_ID = 'remote:env-1@@terminal-2'
const NEW_SECOND_PTY_ID = 'remote:env-1@@terminal-3'
type TopologyState = Parameters<typeof resolveParkedTerminalPaneCandidates>[2]
function singlePaneTopologyState(args?: {
firstTitleSlot?: number
firstTitle?: string
}): TopologyState {
return {
runtimePaneTitlesByTabId: {
[TAB_ID]: { [args?.firstTitleSlot ?? 1]: args?.firstTitle ?? 'first title' },
[OTHER_TAB_ID]: { 2: 'second title' }
},
terminalLayoutsByTabId: {
[TAB_ID]: {
root: { type: 'leaf', leafId: FIRST_LEAF_ID },
activeLeafId: FIRST_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [FIRST_LEAF_ID]: FIRST_PTY_ID }
},
[OTHER_TAB_ID]: {
root: { type: 'leaf', leafId: SECOND_LEAF_ID },
activeLeafId: SECOND_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [SECOND_LEAF_ID]: OLD_SECOND_PTY_ID }
}
}
}
}
function splitPaneTopologyState(secondPtyId: string): TopologyState {
return {
runtimePaneTitlesByTabId: {},
terminalLayoutsByTabId: {
[TAB_ID]: {
root: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: FIRST_LEAF_ID },
second: { type: 'leaf', leafId: SECOND_LEAF_ID }
},
activeLeafId: FIRST_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: {
[FIRST_LEAF_ID]: FIRST_PTY_ID,
[SECOND_LEAF_ID]: secondPtyId
}
}
}
}
}
afterEach(() => {
retireParkedTerminalTab(TAB_ID)
retireParkedTerminalTab(OTHER_TAB_ID)
})
describe('paired parked-watcher reconciliation', () => {
it('normalizes only mount-independent leaf and PTY bindings', () => {
const captures = [
{
leafId: SECOND_LEAF_ID,
ptyId: OLD_SECOND_PTY_ID,
paneId: 7,
drivesTabTitle: true
},
{ leafId: FIRST_LEAF_ID, ptyId: FIRST_PTY_ID, paneId: 4, drivesTabTitle: false }
]
expect(normalizeParkedTerminalPaneMaterialBindings(captures)).toEqual([
{ leafId: FIRST_LEAF_ID, ptyId: FIRST_PTY_ID },
{ leafId: SECOND_LEAF_ID, ptyId: OLD_SECOND_PTY_ID }
])
})
it('keeps the topology key stable across tab order', () => {
const tabs = [
{ id: TAB_ID, ptyId: FIRST_PTY_ID, generation: 1 },
{ id: OTHER_TAB_ID, ptyId: OLD_SECOND_PTY_ID, generation: 2 }
]
const state = singlePaneTopologyState()
expect(createParkedTerminalWatcherTopologyKey(WORKTREE_ID, tabs, state)).toBe(
createParkedTerminalWatcherTopologyKey(WORKTREE_ID, tabs.toReversed(), state)
)
})
it('ignores pane metadata, runtime titles, and capture source', () => {
const tabs = [{ id: TAB_ID, ptyId: FIRST_PTY_ID, generation: 1 }]
captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, 1, [
{
leafId: FIRST_LEAF_ID,
ptyId: FIRST_PTY_ID,
paneId: 99,
drivesTabTitle: false
}
])
const capturedKey = createParkedTerminalWatcherTopologyKey(
WORKTREE_ID,
tabs,
singlePaneTopologyState({ firstTitleSlot: 3, firstTitle: 'old title' })
)
retireParkedTerminalTab(TAB_ID)
const fallbackKey = createParkedTerminalWatcherTopologyKey(
WORKTREE_ID,
tabs,
singlePaneTopologyState({ firstTitleSlot: 17, firstTitle: 'new title' })
)
expect(fallbackKey).toBe(capturedKey)
})
it('changes the topology key when a tab generation changes', () => {
const state = singlePaneTopologyState()
const original = createParkedTerminalWatcherTopologyKey(
WORKTREE_ID,
[{ id: TAB_ID, ptyId: FIRST_PTY_ID, generation: 1 }],
state
)
const reminted = createParkedTerminalWatcherTopologyKey(
WORKTREE_ID,
[{ id: TAB_ID, ptyId: FIRST_PTY_ID, generation: 2 }],
state
)
expect(reminted).not.toBe(original)
})
it('changes the topology key for a layout-only leaf-to-PTY remint', () => {
const tabs = [{ id: TAB_ID, ptyId: FIRST_PTY_ID, generation: 1 }]
captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, 1, [
{ leafId: FIRST_LEAF_ID, ptyId: FIRST_PTY_ID, paneId: 1, drivesTabTitle: true },
{
leafId: SECOND_LEAF_ID,
ptyId: OLD_SECOND_PTY_ID,
paneId: 2,
drivesTabTitle: false
}
])
const original = createParkedTerminalWatcherTopologyKey(
WORKTREE_ID,
tabs,
splitPaneTopologyState(OLD_SECOND_PTY_ID)
)
const reminted = createParkedTerminalWatcherTopologyKey(
WORKTREE_ID,
tabs,
splitPaneTopologyState(NEW_SECOND_PTY_ID)
)
expect(reminted).not.toBe(original)
})
it('prefers an authoritative inactive split-leaf remint over the unmount capture', () => {
captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, [
captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, null, [
{ ptyId: FIRST_PTY_ID, paneId: 1, leafId: FIRST_LEAF_ID, drivesTabTitle: true },
{
ptyId: OLD_SECOND_PTY_ID,
@@ -33,6 +182,7 @@ describe('paired parked-watcher reconciliation', () => {
])
const panes = resolveParkedTerminalPaneCandidates(
WORKTREE_ID,
{ id: TAB_ID, ptyId: FIRST_PTY_ID },
{
runtimePaneTitlesByTabId: {},
@@ -66,6 +216,94 @@ describe('paired parked-watcher reconciliation', () => {
])
})
it('takes split title authority from the current layout instead of the capture', () => {
captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, null, [
{ ptyId: FIRST_PTY_ID, paneId: 1, leafId: FIRST_LEAF_ID, drivesTabTitle: true },
{
ptyId: OLD_SECOND_PTY_ID,
paneId: 2,
leafId: SECOND_LEAF_ID,
drivesTabTitle: false
}
])
const panes = resolveParkedTerminalPaneCandidates(
WORKTREE_ID,
{ id: TAB_ID, ptyId: FIRST_PTY_ID },
{
runtimePaneTitlesByTabId: {},
terminalLayoutsByTabId: {
[TAB_ID]: {
...splitPaneTopologyState(OLD_SECOND_PTY_ID).terminalLayoutsByTabId[TAB_ID],
activeLeafId: SECOND_LEAF_ID
}
}
}
)
expect(panes.map(({ leafId, drivesTabTitle }) => ({ leafId, drivesTabTitle }))).toEqual([
{ leafId: FIRST_LEAF_ID, drivesTabTitle: false },
{ leafId: SECOND_LEAF_ID, drivesTabTitle: true }
])
})
it('accepts only a capture from the requested worktree and generation', () => {
const capturedPanes = [
{ ptyId: FIRST_PTY_ID, paneId: 1, leafId: FIRST_LEAF_ID, drivesTabTitle: true }
]
captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, 4, capturedPanes)
expect(
resolveParkedTerminalPaneCandidates(
WORKTREE_ID,
{ id: TAB_ID, ptyId: null, generation: 4 },
{ runtimePaneTitlesByTabId: {}, terminalLayoutsByTabId: {} }
)
).toEqual(capturedPanes)
})
it('rejects a capture from another worktree when no fallback topology exists', () => {
captureParkedTerminalPaneCandidates(TAB_ID, 'repo::/old-worktree', 4, [
{ ptyId: FIRST_PTY_ID, paneId: 1, leafId: FIRST_LEAF_ID, drivesTabTitle: true }
])
expect(
resolveParkedTerminalPaneCandidates(
WORKTREE_ID,
{ id: TAB_ID, ptyId: null, generation: 4 },
{ runtimePaneTitlesByTabId: {}, terminalLayoutsByTabId: {} }
)
).toEqual([])
})
it('rejects a same-PTY capture from a previous tab generation', () => {
captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, 3, [
{ ptyId: FIRST_PTY_ID, paneId: 1, leafId: FIRST_LEAF_ID, drivesTabTitle: true }
])
expect(
resolveParkedTerminalPaneCandidates(
WORKTREE_ID,
{ id: TAB_ID, ptyId: FIRST_PTY_ID, generation: 4 },
{ runtimePaneTitlesByTabId: {}, terminalLayoutsByTabId: {} }
)
).toEqual([])
})
it('does not reuse pane metadata from a stale capture', () => {
captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, 3, [
{ ptyId: FIRST_PTY_ID, paneId: 99, leafId: FIRST_LEAF_ID, drivesTabTitle: false }
])
expect(
resolveParkedTerminalPaneCandidates(
WORKTREE_ID,
{ id: TAB_ID, ptyId: FIRST_PTY_ID, generation: 4 },
singlePaneTopologyState()
)
).toEqual([{ ptyId: FIRST_PTY_ID, paneId: 1, leafId: FIRST_LEAF_ID, drivesTabTitle: true }])
})
it('surgically reconciles a reminted split leaf without restarting its sibling', () => {
expect(
reconcileParkedWatcherPtyIds({
@@ -1,21 +1,35 @@
import type { TerminalTab } from '../../../../shared/types'
import type { useAppStore } from '@/store'
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
import { useAppStore } from '@/store'
import {
collectLeafIdsInOrder,
resolveRootlessTerminalLayoutLeafId
} from './terminal-layout-leaf-ids'
import {
capturedPanesByTabId,
disposeParkedTabWatchers,
parkedWatchersByTabId,
type ParkedTabWatcherEntry,
type ParkedTerminalPaneCapture
} from './terminal-parked-watcher-registry'
import {
isParkRestorableTerminalPty,
selectPairedRuntimeParkingEnvironmentIds,
type TerminalParkRestorePolicy
} from './terminal-hidden-view-parking'
import { startParkedPtyWatcher } from './terminal-parked-pty-watcher'
export type ParkableTerminalTabModel = Pick<TerminalTab, 'id' | 'ptyId'>
export type ParkableTerminalTabModel = Pick<TerminalTab, 'id' | 'ptyId' | 'generation'>
export type ParkedTerminalPaneMaterialBinding = Pick<ParkedTerminalPaneCapture, 'leafId' | 'ptyId'>
type ParkedPaneFallbackState = {
terminalLayoutsByTabId: ReturnType<typeof useAppStore.getState>['terminalLayoutsByTabId']
runtimePaneTitlesByTabId: ReturnType<typeof useAppStore.getState>['runtimePaneTitlesByTabId']
}
type ParkedPaneTopologyState = Pick<ParkedPaneFallbackState, 'terminalLayoutsByTabId'> &
Partial<Pick<ParkedPaneFallbackState, 'runtimePaneTitlesByTabId'>>
export function fallbackParkedPaneCandidates(
tab: ParkableTerminalTabModel,
state: ParkedPaneFallbackState
@@ -41,13 +55,18 @@ export function fallbackParkedPaneCandidates(
}
export function resolveParkedTerminalPaneCandidates(
worktreeId: string,
tab: ParkableTerminalTabModel,
state: ParkedPaneFallbackState
): ParkedTerminalPaneCapture[] {
const captured = capturedPanesByTabId.get(tab.id)
const fallback = fallbackParkedPaneCandidates(tab, state)
const capturedIsCurrent =
const capturedHasCurrentIdentity =
captured !== undefined &&
captured.worktreeId === worktreeId &&
captured.generation === (tab.generation ?? null)
const capturedIsCurrent =
capturedHasCurrentIdentity &&
captured.panes.length > 0 &&
(tab.ptyId === null || captured.panes.some((pane) => pane.ptyId === tab.ptyId)) &&
(fallback.length === 0 ||
@@ -58,14 +77,67 @@ export function resolveParkedTerminalPaneCandidates(
)
)))
if (capturedIsCurrent) {
return captured.panes
return captured.panes.map((pane) => {
const current = fallback.find(
(candidate) => candidate.leafId === pane.leafId && candidate.ptyId === pane.ptyId
)
return current ? { ...pane, drivesTabTitle: current.drivesTabTitle } : pane
})
}
return fallback.map((pane) => {
const prior = captured?.panes.find((candidate) => candidate.leafId === pane.leafId)
return prior ? { ...pane, paneId: prior.paneId, drivesTabTitle: prior.drivesTabTitle } : pane
const prior = capturedHasCurrentIdentity
? captured.panes.find((candidate) => candidate.leafId === pane.leafId)
: undefined
return prior ? { ...pane, paneId: prior.paneId } : pane
})
}
export function normalizeParkedTerminalPaneMaterialBindings(
panes: readonly ParkedTerminalPaneMaterialBinding[]
): ParkedTerminalPaneMaterialBinding[] {
return panes
.map(({ leafId, ptyId }) => ({ leafId, ptyId }))
.sort(
(left, right) =>
left.leafId.localeCompare(right.leafId) ||
(left.ptyId ?? '').localeCompare(right.ptyId ?? '')
)
}
export function resolveParkedTerminalPaneMaterialBindings(
worktreeId: string,
tab: ParkableTerminalTabModel,
state: ParkedPaneFallbackState
): ParkedTerminalPaneMaterialBinding[] {
return normalizeParkedTerminalPaneMaterialBindings(
resolveParkedTerminalPaneCandidates(worktreeId, tab, state)
)
}
export function createParkedTerminalWatcherTopologyKey(
worktreeId: string,
tabs: readonly ParkableTerminalTabModel[],
state: ParkedPaneTopologyState
): string {
const topologyState: ParkedPaneFallbackState = {
terminalLayoutsByTabId: state.terminalLayoutsByTabId,
// Runtime titles affect only watcher pane slots, never material leaf-to-PTY topology.
runtimePaneTitlesByTabId: {}
}
return JSON.stringify([
'terminal-parked-watcher-topology-v1',
worktreeId,
[...tabs]
.sort((left, right) => left.id.localeCompare(right.id))
.map((tab) => [
tab.id,
tab.ptyId,
tab.generation ?? null,
resolveParkedTerminalPaneMaterialBindings(worktreeId, tab, topologyState)
])
])
}
export function reconcileParkedWatcherPtyIds(args: {
currentTabPtyId: string | null
entryTabPtyId: string | null
@@ -89,3 +161,117 @@ export function reconcileParkedWatcherPtyIds(args: {
.map(([, paneId]) => paneId)
}
}
export function parkedWatcherRestorePolicyFromState(state: {
settings: { terminalSshViewParking?: boolean } | null
runtimeStatusByEnvironmentId: ReadonlyMap<
string,
{ status: { capabilities?: readonly string[] } | null | undefined }
>
}): TerminalParkRestorePolicy {
return {
sshParkingEnabled: state.settings?.terminalSshViewParking !== false,
pairedRuntimeParkingEnvironmentIds: selectPairedRuntimeParkingEnvironmentIds(
state.runtimeStatusByEnvironmentId
)
}
}
function watchablePanes(
worktreeId: string,
tab: ParkableTerminalTabModel
): Map<string, ParkedTerminalPaneCapture> {
const state = useAppStore.getState()
const restorePolicy = parkedWatcherRestorePolicyFromState(state)
return new Map(
resolveParkedTerminalPaneCandidates(worktreeId, tab, state).flatMap((pane) =>
pane.ptyId &&
isTerminalLeafId(pane.leafId) &&
isParkRestorableTerminalPty(pane.ptyId, worktreeId, restorePolicy)
? [[pane.ptyId, pane] as const]
: []
)
)
}
export function startParkedTabWatchers(
worktreeId: string,
tab: ParkableTerminalTabModel,
restoreTitleOnRegister: boolean
): void {
const entry: ParkedTabWatcherEntry = {
worktreeId,
tabPtyId: tab.ptyId,
paneIdByPtyId: new Map(),
disposersByPtyId: new Map()
}
parkedWatchersByTabId.set(tab.id, entry)
const restorePolicy = parkedWatcherRestorePolicyFromState(useAppStore.getState())
for (const pane of resolveParkedTerminalPaneCandidates(worktreeId, tab, useAppStore.getState())) {
startParkedPtyWatcher({
worktreeId,
tab,
pane,
entry,
restoreTitleOnRegister,
restorePolicy
})
}
}
export function reconcileParkedTabWatchers(
worktreeId: string,
tab: ParkableTerminalTabModel,
entry: ParkedTabWatcherEntry,
restoreTitleOnRegister: boolean
): void {
const state = useAppStore.getState()
const expectedPanes = watchablePanes(worktreeId, tab)
const expectedPtyIds = new Set(expectedPanes.keys())
const reconciliation = reconcileParkedWatcherPtyIds({
currentTabPtyId: tab.ptyId,
entryTabPtyId: entry.tabPtyId,
paneIdByPtyId: entry.paneIdByPtyId,
expectedPtyIds
})
if (reconciliation.restartAll) {
const retainedTitles = reconciliation.retainedPtyIds.flatMap((ptyId) => {
const paneId = entry.paneIdByPtyId.get(ptyId)
const title =
paneId === undefined ? undefined : state.runtimePaneTitlesByTabId[tab.id]?.[paneId]
return paneId !== undefined && title !== undefined ? [{ paneId, title }] : []
})
for (const paneId of reconciliation.retiredPaneIds) {
state.clearRuntimePaneTitle(tab.id, paneId)
}
disposeParkedTabWatchers(tab.id)
for (const { paneId, title } of retainedTitles) {
useAppStore.getState().setRuntimePaneTitle(tab.id, paneId, title)
}
startParkedTabWatchers(worktreeId, tab, restoreTitleOnRegister)
return
}
for (const [ptyId, paneId] of Array.from(entry.paneIdByPtyId)) {
if (expectedPtyIds.has(ptyId)) {
continue
}
entry.paneIdByPtyId.delete(ptyId)
entry.disposersByPtyId.get(ptyId)?.()
entry.disposersByPtyId.delete(ptyId)
state.clearRuntimePaneTitle(tab.id, paneId)
}
const restorePolicy = parkedWatcherRestorePolicyFromState(useAppStore.getState())
for (const ptyId of reconciliation.addedPtyIds) {
const pane = expectedPanes.get(ptyId)
if (pane) {
startParkedPtyWatcher({
worktreeId,
tab,
pane,
entry,
restoreTitleOnRegister,
restorePolicy
})
}
}
}
@@ -75,9 +75,14 @@ describe('terminal parked watcher registry removal', () => {
paneIdByPtyId: new Map([[FLOATING_PTY_ID, 1]]),
disposersByPtyId: new Map([[FLOATING_PTY_ID, floatingDispose]])
})
capturedPanesByTabId.set(TAB_ID, { worktreeId: 'removed-worktree', panes: [] })
capturedPanesByTabId.set(TAB_ID, {
worktreeId: 'removed-worktree',
generation: null,
panes: []
})
capturedPanesByTabId.set(FLOATING_TAB_ID, {
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
generation: null,
panes: []
})
@@ -19,7 +19,11 @@ export type ParkedTerminalPaneCapture = {
drivesTabTitle: boolean
}
export type CapturedTabPanes = { worktreeId: string; panes: ParkedTerminalPaneCapture[] }
export type CapturedTabPanes = {
worktreeId: string
generation: number | null
panes: ParkedTerminalPaneCapture[]
}
export const capturedPanesByTabId = new Map<string, CapturedTabPanes>()
@@ -30,9 +34,10 @@ export const capturedPanesByTabId = new Map<string, CapturedTabPanes>()
export function captureParkedTerminalPaneCandidates(
tabId: string,
worktreeId: string,
generation: number | null | undefined,
panes: ParkedTerminalPaneCapture[]
): void {
capturedPanesByTabId.set(tabId, { worktreeId, panes })
capturedPanesByTabId.set(tabId, { worktreeId, generation: generation ?? null, panes })
}
export type ParkedTabWatcherEntry = {
@@ -48,6 +53,53 @@ export type ParkedTabWatcherEntry = {
export const parkedWatchersByTabId = new Map<string, ParkedTabWatcherEntry>()
export type ParkedTerminalWatcherOwnershipLossEvent = {
worktreeId: string
tabId: string
reason: 'close-cancelled' | 'primary-exit-unowned'
}
type ParkedTerminalWatcherOwnershipLossListener = (
event: ParkedTerminalWatcherOwnershipLossEvent
) => void
const ownershipLossListeners = new Set<ParkedTerminalWatcherOwnershipLossListener>()
const ownershipLossReportedEntries = new WeakSet<ParkedTabWatcherEntry>()
export function subscribeParkedTerminalWatcherOwnershipLoss(
listener: ParkedTerminalWatcherOwnershipLossListener
): () => void {
ownershipLossListeners.add(listener)
return () => ownershipLossListeners.delete(listener)
}
export function markParkedTerminalWatcherOwnershipRestored(entry: ParkedTabWatcherEntry): void {
ownershipLossReportedEntries.delete(entry)
}
export function notifyParkedTerminalWatcherOwnershipLoss(
tabId: string,
entry: ParkedTabWatcherEntry,
reason: ParkedTerminalWatcherOwnershipLossEvent['reason']
): void {
if (
parkedWatchersByTabId.get(tabId) !== entry ||
entry.disposersByPtyId.size > 0 ||
ownershipLossReportedEntries.has(entry)
) {
return
}
ownershipLossReportedEntries.add(entry)
const event = { worktreeId: entry.worktreeId, tabId, reason } as const
for (const listener of Array.from(ownershipLossListeners)) {
try {
listener(event)
} catch {
// Why: one host listener must not block another host's recovery.
}
}
}
export function getParkedTerminalWatcherTabIds(): string[] {
return Array.from(parkedWatchersByTabId.keys())
}
@@ -184,6 +184,26 @@ describe('registerTerminalSideEffectFactConsumer', () => {
])
})
it('attempts every live fact before rethrowing the first callback error', () => {
const events: string[] = []
const firstError = new Error('bell failed')
registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: {
onBell: () => {
events.push('bell')
throw firstError
},
onAgentBecameWorking: () => events.push('working')
}
})
expect(() =>
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }, { kind: 'agent-working' }]))
).toThrow(firstError)
expect(events).toEqual(['bell', 'working'])
})
it('routes command-finished and pr-link facts to the registered consumer', () => {
const events: unknown[][] = []
registerTerminalSideEffectFactConsumer({
@@ -495,6 +515,41 @@ describe('registerTerminalSideEffectFactConsumer', () => {
expect(later).toEqual([])
})
it('removes a consumer whose handoff drain throws and buffers for its replacement', () => {
const dispose = registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: createCallbackRecorder().callbacks
})
dispose()
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }], { seq: 1 }))
_dispatchTerminalSideEffectBatchForTest(
batch([{ kind: 'agent-working' }, { kind: 'agent-exited' }], { seq: 2 })
)
const firstError = new Error('buffered bell failed')
const failedEvents: string[] = []
expect(() =>
registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: {
onBell: () => {
failedEvents.push('bell')
throw firstError
},
onAgentBecameWorking: () => failedEvents.push('working'),
onAgentExited: () => failedEvents.push('exited')
}
})
).toThrow(firstError)
expect(failedEvents).toEqual(['bell', 'working', 'exited'])
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }], { seq: 3 }))
expect(failedEvents).toEqual(['bell', 'working', 'exited'])
const replacement = createCallbackRecorder()
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: replacement.callbacks })
expect(replacement.events).toEqual([['bell']])
})
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.
@@ -101,6 +101,8 @@ type ConsumerEntry = {
lastLiveTitleSeq: number | null
}
type FactDeliveryFailure = { error: unknown }
const consumersByPtyId = new Map<string, ConsumerEntry>()
let channelUnsubscribe: (() => void) | null = null
@@ -152,24 +154,36 @@ function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq:
}
}
function applyBatchToConsumer(entry: ConsumerEntry, batch: TerminalSideEffectBatch): void {
function applyBatchToConsumer(
entry: ConsumerEntry,
batch: TerminalSideEffectBatch
): FactDeliveryFailure | null {
let failure: FactDeliveryFailure | null = null
const apply = (callback: () => void): void => {
try {
callback()
} catch (error) {
failure ??= { error }
}
}
if (batch.replay) {
// Why: the no-attention-replay rule — (re)attach snapshots restore title
// state only; historical bells/completions must never fire again. A replay
// older (by output sequence) than the last live title fact is stale.
if (entry.lastLiveTitleSeq !== null && batch.seq <= entry.lastLiveTitleSeq) {
return
return null
}
for (const fact of batch.facts) {
if (fact.kind === 'title') {
entry.callbacks.onTitleChange?.(fact.normalizedTitle, fact.rawTitle)
apply(() => entry.callbacks.onTitleChange?.(fact.normalizedTitle, fact.rawTitle))
}
}
return
return failure
}
for (const fact of batch.facts) {
applyLiveFact(entry, fact, batch.seq)
apply(() => applyLiveFact(entry, fact, batch.seq))
}
return failure
}
// Why: a reveal remount unregisters the parked watcher synchronously, but the
@@ -243,18 +257,21 @@ function bufferHandoffFactBatch(batch: TerminalSideEffectBatch): void {
buffer.batches.push(batch)
}
function drainHandoffFactBuffer(ptyId: string, entry: ConsumerEntry): void {
function drainHandoffFactBuffer(ptyId: string, entry: ConsumerEntry): FactDeliveryFailure | null {
const buffer = handoffFactBuffersByPtyId.get(ptyId)
if (!buffer) {
return
return null
}
deleteHandoffFactBuffer(ptyId)
if (buffer.expiresAtMs <= Date.now()) {
return
return null
}
let failure: FactDeliveryFailure | null = null
for (const batch of buffer.batches) {
applyBatchToConsumer(entry, batch)
const batchFailure = applyBatchToConsumer(entry, batch)
failure ??= batchFailure
}
return failure
}
export function dispatchTerminalSideEffectBatch(batch: TerminalSideEffectBatch): void {
@@ -263,7 +280,10 @@ export function dispatchTerminalSideEffectBatch(batch: TerminalSideEffectBatch):
bufferHandoffFactBatch(batch)
return
}
applyBatchToConsumer(entry, batch)
const failure = applyBatchToConsumer(entry, batch)
if (failure) {
throw failure.error
}
}
function ensureSideEffectChannelSubscription(): void {
@@ -304,7 +324,14 @@ export function registerTerminalSideEffectFactConsumer(
}
consumersByPtyId.set(options.ptyId, entry)
// Why before the snapshot request: draining live facts sets lastLiveTitleSeq, so the async title replay is correctly dropped as stale.
drainHandoffFactBuffer(options.ptyId, entry)
const failure = drainHandoffFactBuffer(options.ptyId, entry)
if (failure) {
if (consumersByPtyId.get(options.ptyId) === entry) {
consumersByPtyId.delete(options.ptyId)
openHandoffFactBuffer(options.ptyId)
}
throw failure.error
}
if (options.restoreTitleOnRegister) {
const getSnapshot = (globalThis as { window?: Window }).window?.api?.pty?.getSideEffectSnapshot
@@ -222,6 +222,7 @@ async function formatTerminalUrlTooltip(
type UseTerminalPaneLifecycleDeps = {
tabId: string
worktreeId: string
terminalGeneration?: number
cwd?: string
startup?: {
command: string
@@ -587,6 +588,7 @@ export function retireMountedTerminalPaneSurface(args: {
export function useTerminalPaneLifecycle({
tabId,
worktreeId,
terminalGeneration,
cwd,
startup,
setupSplit,
@@ -1745,6 +1747,7 @@ export function useTerminalPaneLifecycle({
captureParkedTerminalPaneCandidates(
tabId,
worktreeId,
terminalGeneration,
manager.getPanes().map((capturedPane) => ({
ptyId: paneTransports.get(capturedPane.id)?.getPtyId() ?? null,
paneId: capturedPane.id,
@@ -1788,7 +1791,7 @@ export function useTerminalPaneLifecycle({
setTabCanExpandPane(tabId, false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabId, cwd])
}, [tabId, cwd, terminalGeneration])
// Why: mobile wake fanout — pane self-selects by worktreeId and fires its own armed --resume while staying hidden (no reveal/focus change).
useEffect(() => {
@@ -0,0 +1,182 @@
import { useEffect, useRef, useState } from 'react'
import type { TerminalTab } from '../../../../shared/types'
import type { ActivityTerminalPortalTarget } from '../activity/activity-terminal-portal'
import { getTerminalTabColdParkRecheckDelayMs } from './terminal-cold-park-recheck-deadlines'
import {
TERMINAL_TAB_COLD_PARK_DELAY_MS,
selectColdParkedTerminalTabs,
type TerminalTabColdParkCandidate
} from './terminal-hidden-view-parking'
import { getTerminalParkingPolicyOverrides } from './terminal-parking-e2e-overrides'
export type TerminalOverlayTabAssignment = {
groupId: string
isActiveInGroup: boolean
}
function haveSameTerminalTabIds(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
if (left.size !== right.size) {
return false
}
for (const id of left) {
if (!right.has(id)) {
return false
}
}
return true
}
export function useTerminalTabColdParkCandidates(args: {
worktreeId: string
terminalTabs: readonly TerminalTab[]
assignments: ReadonlyMap<string, TerminalOverlayTabAssignment>
isWorktreeActive: boolean
shouldMeasureHiddenWorktree: boolean
activityTerminalPortals: readonly ActivityTerminalPortalTarget[]
pendingStartupByTabId: Readonly<Record<string, unknown>>
terminalParkingEnabled: boolean
terminalSshParkingEnabled: boolean
pairedRuntimeParkingEnvironmentIds: ReadonlySet<string>
}): ReadonlySet<string> {
const {
worktreeId,
terminalTabs,
assignments,
isWorktreeActive,
shouldMeasureHiddenWorktree,
activityTerminalPortals,
pendingStartupByTabId,
terminalParkingEnabled,
terminalSshParkingEnabled,
pairedRuntimeParkingEnvironmentIds
} = args
const hiddenSinceByTabIdRef = useRef(new Map<string, number>())
const wasMeasuringHiddenWorktreeRef = useRef(false)
const measureParkCooldownUntilRef = useRef<number | null>(null)
const recheckTimersByTabIdRef = useRef(new Map<string, number>())
const [recheckRevision, setRecheckRevision] = useState(0)
const [candidateTabIds, setCandidateTabIds] = useState<ReadonlySet<string>>(() => new Set())
useEffect(() => {
const timers = recheckTimersByTabIdRef.current
return () => {
for (const timer of timers.values()) {
window.clearTimeout(timer)
}
timers.clear()
}
}, [])
useEffect(() => {
const timers = recheckTimersByTabIdRef.current
for (const timer of timers.values()) {
window.clearTimeout(timer)
}
timers.clear()
const nowMs = Date.now()
const overrides = getTerminalParkingPolicyOverrides()
const currentTabIds = new Set(terminalTabs.map((tab) => tab.id))
const portalTabIds = new Set(
activityTerminalPortals
.filter((portal) => portal.worktreeId === worktreeId)
.map((portal) => portal.tabId)
)
for (const tabId of Array.from(hiddenSinceByTabIdRef.current.keys())) {
if (!currentTabIds.has(tabId)) {
hiddenSinceByTabIdRef.current.delete(tabId)
}
}
if (shouldMeasureHiddenWorktree) {
wasMeasuringHiddenWorktreeRef.current = true
} else {
if (wasMeasuringHiddenWorktreeRef.current) {
measureParkCooldownUntilRef.current =
nowMs + (overrides.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS)
}
wasMeasuringHiddenWorktreeRef.current = false
}
if (isWorktreeActive) {
measureParkCooldownUntilRef.current = null
}
const candidates: TerminalTabColdParkCandidate[] = terminalTabs.map((terminalTab) => {
const assignment = assignments.get(terminalTab.id)
const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup)
const hasActivityTerminalPortal = portalTabIds.has(terminalTab.id)
if (isVisible || hasActivityTerminalPortal) {
hiddenSinceByTabIdRef.current.delete(terminalTab.id)
} else if (
!shouldMeasureHiddenWorktree &&
!hiddenSinceByTabIdRef.current.has(terminalTab.id)
) {
hiddenSinceByTabIdRef.current.set(terminalTab.id, nowMs)
}
return {
id: terminalTab.id,
ptyId: terminalTab.ptyId,
pendingActivationSpawn: terminalTab.pendingActivationSpawn,
isVisible,
hasActivityTerminalPortal,
hiddenSinceMs: hiddenSinceByTabIdRef.current.get(terminalTab.id) ?? null
}
})
const nextCandidateTabIds = selectColdParkedTerminalTabs({
worktreeId,
terminalTabs: candidates,
pendingStartupByTabId,
parkingEnabled: terminalParkingEnabled,
nowMs,
parkCooldownUntilMs: measureParkCooldownUntilRef.current,
restorePolicy: {
sshParkingEnabled: terminalSshParkingEnabled,
pairedRuntimeParkingEnvironmentIds
},
...overrides
})
setCandidateTabIds((current) =>
haveSameTerminalTabIds(current, nextCandidateTabIds) ? current : nextCandidateTabIds
)
for (const candidate of candidates) {
if (
candidate.isVisible ||
candidate.hasActivityTerminalPortal ||
nextCandidateTabIds.has(candidate.id)
) {
continue
}
const delayMs = getTerminalTabColdParkRecheckDelayMs({
parkingEnabled: terminalParkingEnabled,
hiddenSinceMs: candidate.hiddenSinceMs,
parkCooldownUntilMs: measureParkCooldownUntilRef.current,
nowMs,
...overrides
})
if (delayMs !== null && delayMs > 0) {
const tabId = candidate.id
const timer = window.setTimeout(() => {
timers.delete(tabId)
setRecheckRevision((revision) => revision + 1)
}, delayMs)
timers.set(tabId, timer)
}
}
}, [
activityTerminalPortals,
assignments,
isWorktreeActive,
pairedRuntimeParkingEnvironmentIds,
pendingStartupByTabId,
recheckRevision,
shouldMeasureHiddenWorktree,
terminalParkingEnabled,
terminalSshParkingEnabled,
terminalTabs,
worktreeId
])
return candidateTabIds
}
@@ -16,8 +16,10 @@ const mocks = vi.hoisted(() => ({
},
exemptTabIds: new Set<string>(),
exemptSelectCalls: 0,
/** Toggled to churn the park verdict the way the crash cluster does. */
watcherCoverage: true
planRevision: 0,
failWatcherSync: false,
watcherSyncParkedTabIds: [] as string[][],
ownershipLossListener: null as null | ((event: { worktreeId: string; tabId: string }) => void)
}))
vi.mock('../../store', () => ({
@@ -41,9 +43,60 @@ vi.mock('./terminal-eviction-exempt-tabs', () => ({
}))
vi.mock('./terminal-parked-tab-watchers', () => ({
canWatcherCoverParkedTerminalTab: () => mocks.watcherCoverage,
disposeParkedTerminalWatchersForWorktree: vi.fn(),
syncParkedTerminalTabWatchers: vi.fn()
planParkedTerminalTabWatcherCoverage: (worktreeId: string, tab: TerminalTab) => ({
status: 'covered',
materialKey: `${worktreeId}:${tab.id}:${tab.ptyId}:${mocks.planRevision}`,
worktreeId,
tabId: tab.id,
tabPtyId: tab.ptyId,
generation: tab.generation ?? null,
panes: [
{
leafId: '11111111-1111-4111-8111-111111111111',
ptyId: tab.ptyId
}
]
}),
subscribeParkedTerminalWatcherOwnershipLoss: (
listener: (event: { worktreeId: string; tabId: string }) => void
) => {
mocks.ownershipLossListener = listener
return () => {
if (mocks.ownershipLossListener === listener) {
mocks.ownershipLossListener = null
}
}
},
syncParkedTerminalTabWatchersWithAcknowledgements: (args: {
parkedTabIds: ReadonlySet<string>
forcedTabIds: ReadonlySet<string>
coveragePlansByTabId: ReadonlyMap<
string,
{ materialKey: string; panes: readonly { ptyId: string | null }[] }
>
}) => {
mocks.watcherSyncParkedTabIds.push([...args.parkedTabIds].sort())
return [...args.parkedTabIds].map((tabId) => {
const plan = args.coveragePlansByTabId.get(tabId)
const watchedPtyIds = (plan?.panes ?? []).flatMap((pane) =>
pane.ptyId === null ? [] : [pane.ptyId]
)
if (args.forcedTabIds.has(tabId)) {
return { status: 'forced', tabId, materialKey: plan?.materialKey ?? null, watchedPtyIds }
}
return mocks.failWatcherSync
? {
status: 'failed',
tabId,
materialKey: plan?.materialKey ?? null,
reason: 'watcher-coverage-incomplete',
expectedPtyIds: watchedPtyIds,
watchedPtyIds: []
}
: { status: 'covering', tabId, materialKey: plan?.materialKey ?? '', watchedPtyIds }
})
}
}))
vi.mock('@/lib/crash-breadcrumb-recorder', () => ({
@@ -54,10 +107,6 @@ import {
TERMINAL_TAB_COLD_PARK_DELAY_MS,
TERMINAL_TAB_HOT_RETAIN_MS
} from './terminal-hidden-view-parking'
import {
TERMINAL_TAB_PARK_FLIP_BURST_LIMIT,
TERMINAL_TAB_PARK_FLIP_WINDOW_MS
} from './terminal-park-verdict-flip-telemetry'
import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking'
const WORKTREE_ID = 'wt-1'
@@ -89,7 +138,10 @@ describe('useTerminalTabColdParking measure-clock contract', () => {
vi.useRealTimers()
mocks.exemptTabIds = new Set()
mocks.exemptSelectCalls = 0
mocks.watcherCoverage = true
mocks.planRevision = 0
mocks.failWatcherSync = false
mocks.watcherSyncParkedTabIds = []
mocks.ownershipLossListener = null
mocks.storeState.terminalLayoutsByTabId = {}
mocks.storeState.sleepingAgentSessionsByPaneKey = {}
mocks.storeState.runtimeStatusByEnvironmentId = new Map()
@@ -122,12 +174,57 @@ describe('useTerminalTabColdParking measure-clock contract', () => {
}
})
// Why: the flip-damping pin removes the tab from the parked set, and every
// hysteresis deadline of a long-hidden tab is already past — so the pin
// deadline is the only wakeup that can ever re-park it. Without scheduling
// it, damping silently becomes a permanent unpark: the pane (~4-5MB) stays
// mounted for the life of the window, which is the renderer-OOM cluster.
it('re-parks a damped tab once the pin expires with no other store change', () => {
it('retries a rejected handoff only after the material plan changes', () => {
mocks.failWatcherSync = true
const { result, rerender } = renderHook(
(args: ReturnType<typeof hookArgs>) => useTerminalTabColdParking(args),
{ initialProps: hookArgs(false) }
)
act(() => {
vi.advanceTimersByTime(TERMINAL_TAB_HOT_RETAIN_MS + 1)
})
expect(result.current.size).toBe(0)
mocks.failWatcherSync = false
act(() => {
rerender(hookArgs(false))
})
expect(result.current.size).toBe(0)
mocks.planRevision += 1
act(() => {
rerender(hookArgs(false))
})
expect(result.current).toEqual(new Set(['tab-2']))
})
it('reveals an activation-deferred tab when its watcher handoff fails', () => {
mocks.failWatcherSync = true
const onHandoffFailed = vi.fn()
const args = {
...hookArgs(false),
activationDeferredMountTabIds: new Set(['tab-2']),
onActivationDeferredWatcherHandoffFailed: onHandoffFailed
}
const { result } = renderHook(() => useTerminalTabColdParking(args))
expect(result.current.has('tab-2')).toBe(false)
expect(onHandoffFailed).toHaveBeenCalledWith('tab-2')
})
it('starts an activation-deferred watcher in the first effect flush', () => {
renderHook(() =>
useTerminalTabColdParking({
...hookArgs(false),
activationDeferredMountTabIds: new Set(['tab-2'])
})
)
expect(mocks.watcherSyncParkedTabIds[0]).toEqual(['tab-2'])
})
it('remounts after established watcher ownership is lost', () => {
const { result, rerender } = renderHook(
(args: ReturnType<typeof hookArgs>) => useTerminalTabColdParking(args),
{ initialProps: hookArgs(false) }
@@ -137,23 +234,16 @@ describe('useTerminalTabColdParking measure-clock contract', () => {
})
expect(result.current).toEqual(new Set(['tab-2']))
// Churn the coverage veto at render cadence — no clock advance, so every
// flip lands inside the burst window and damping engages.
for (let flip = 0; flip <= TERMINAL_TAB_PARK_FLIP_BURST_LIMIT + 1; flip += 1) {
mocks.watcherCoverage = flip % 2 === 1
act(() => {
rerender(hookArgs(false))
})
}
mocks.watcherCoverage = true
act(() => {
rerender(hookArgs(false))
mocks.ownershipLossListener?.({ worktreeId: WORKTREE_ID, tabId: 'tab-2' })
})
expect(result.current.size).toBe(0)
act(() => {
vi.advanceTimersByTime(TERMINAL_TAB_PARK_FLIP_WINDOW_MS)
})
act(() => rerender(hookArgs(false)))
expect(result.current.size).toBe(0)
mocks.planRevision += 1
act(() => rerender(hookArgs(false)))
expect(result.current).toEqual(new Set(['tab-2']))
})
@@ -1,79 +1,43 @@
/**
* Per-tab hidden-view parking for TerminalPaneOverlayLayer.
*
* Why: owns the cold-park policy bookkeeping (hiddenSince tracking, recheck
* timers, parked-set selection) and the parked byte-watcher reconciliation so
* the overlay layer only consumes the final parked tab set when deciding to
* render a slot as null.
*/
import { useEffect, useMemo, useRef, useState } from 'react'
/** Per-tab hidden-view parking coordinator for TerminalPaneOverlayLayer. */
import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
import { useShallow } from 'zustand/react/shallow'
import type { TerminalTab } from '../../../../shared/types'
import { useAppStore } from '../../store'
import {
findActivityTerminalPortal,
type ActivityTerminalPortalTarget
} from '../activity/activity-terminal-portal'
import { getTerminalTabColdParkRecheckDelayMs } from './terminal-cold-park-recheck-deadlines'
import {
TERMINAL_TAB_COLD_PARK_DELAY_MS,
selectPairedRuntimeParkingEnvironmentIds,
selectColdParkedTerminalTabs,
type TerminalTabColdParkCandidate
} from './terminal-hidden-view-parking'
getTerminalProviderSnapshotCapabilityRevision,
subscribeTerminalProviderSnapshotCapabilityRevision
} from '../terminal/terminal-provider-snapshot-capability'
import type { ActivityTerminalPortalTarget } from '../activity/activity-terminal-portal'
import { selectPairedRuntimeParkingEnvironmentIds } from './terminal-hidden-view-parking'
import {
recordParkVerdictFlips,
type ParkVerdictFlipRecord
} from './terminal-park-verdict-flip-telemetry'
import { withholdUnparkableTerminalTabs } from './terminal-cold-park-withheld-tabs'
import { getTerminalParkingPolicyOverrides } from './terminal-parking-e2e-overrides'
import {
selectEvictionExemptTerminalTabIds,
selectEvictionExemptTerminalTabLayoutKey
} from './terminal-eviction-exempt-tabs'
import { selectSleepingRecordParkExemptTabIds } from './sleeping-record-park-exemption'
import { createParkedTerminalWatcherTopologyKey } from './terminal-parked-watcher-reconciliation'
import {
canWatcherCoverParkedTerminalTab,
disposeParkedTerminalWatchersForWorktree,
syncParkedTerminalTabWatchers
} from './terminal-parked-tab-watchers'
type TerminalOverlayTabAssignment = {
groupId: string
isActiveInGroup: boolean
}
useTerminalTabColdParkCandidates,
type TerminalOverlayTabAssignment
} from './use-terminal-tab-cold-park-candidates'
import { useTerminalTabParkEpisodes } from './use-terminal-tab-park-episodes'
const EMPTY_TAB_IDS: ReadonlySet<string> = new Set()
function haveSameTerminalTabIds(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
if (left.size !== right.size) {
return false
}
for (const id of left) {
if (!right.has(id)) {
return false
}
}
return true
}
export function useTerminalTabColdParking(args: {
worktreeId: string
terminalTabs: readonly TerminalTab[]
assignments: ReadonlyMap<string, TerminalOverlayTabAssignment>
isWorktreeActive: boolean
/** Worktree-level park verdict from Terminal.tsx. */
coldParkTerminalPanes: boolean
/** Retention-budget force-park (C1 slice B): unlike ordinary parks, the
* worktree may hold eviction-exempt tabs, whose panes must stay mounted —
* a remount would orphan their live pty (same carve-out as portals). */
isForceParked?: boolean
/** Hidden-measuring startup probe from Terminal.tsx — the panes must stay
* mounted for their first xterm fit, mirroring the worktree-level guard. */
shouldMeasureHiddenWorktree: boolean
activityTerminalPortals: ActivityTerminalPortalTarget[]
/** Tabs cold activation keeps unmounted — parked-equivalent for watcher
* purposes. Targeted background restrictions intentionally stay bounded. */
activationDeferredMountTabIds?: ReadonlySet<string> | null
onActivationDeferredWatcherHandoffFailed?: (tabId: string) => void
}): ReadonlySet<string> {
const {
worktreeId,
@@ -84,7 +48,8 @@ export function useTerminalTabColdParking(args: {
isForceParked = false,
shouldMeasureHiddenWorktree,
activityTerminalPortals,
activationDeferredMountTabIds
activationDeferredMountTabIds,
onActivationDeferredWatcherHandoffFailed
} = args
const pendingStartupByTabId = useAppStore((state) => state.pendingStartupByTabId)
const terminalParkingEnabled = useAppStore(
@@ -98,6 +63,38 @@ export function useTerminalTabColdParking(args: {
() => selectPairedRuntimeParkingEnvironmentIds(runtimeStatusByEnvironmentId),
[runtimeStatusByEnvironmentId]
)
const watcherRestoreAuthorityKey = useMemo(
() =>
JSON.stringify([
terminalSshParkingEnabled,
[...pairedRuntimeParkingEnvironmentIds].sort((left, right) => left.localeCompare(right))
]),
[pairedRuntimeParkingEnvironmentIds, terminalSshParkingEnabled]
)
const watcherLayoutsByTabId = useAppStore(
useShallow((state) => {
const layouts: typeof state.terminalLayoutsByTabId = {}
for (const terminalTab of terminalTabs) {
const layout = state.terminalLayoutsByTabId[terminalTab.id]
if (layout) {
layouts[terminalTab.id] = layout
}
}
return layouts
})
)
const watcherTopologyKey = useMemo(
() =>
createParkedTerminalWatcherTopologyKey(worktreeId, terminalTabs, {
terminalLayoutsByTabId: watcherLayoutsByTabId
}),
[terminalTabs, watcherLayoutsByTabId, worktreeId]
)
const providerCapabilityRevision = useSyncExternalStore(
subscribeTerminalProviderSnapshotCapabilityRevision,
getTerminalProviderSnapshotCapabilityRevision,
getTerminalProviderSnapshotCapabilityRevision
)
const sleepingAgentSessionsByPaneKey = useAppStore(
(state) => state.sleepingAgentSessionsByPaneKey
)
@@ -105,245 +102,54 @@ export function useTerminalTabColdParking(args: {
() => selectSleepingRecordParkExemptTabIds(sleepingAgentSessionsByPaneKey, worktreeId),
[sleepingAgentSessionsByPaneKey, worktreeId]
)
const terminalTabHiddenSinceRef = useRef(new Map<string, number>())
// Why (shared measure-clock contract with Terminal.tsx): tab hiddenSince
// survives a background-measure window so per-tab park deadlines stay in
// sync with the worktree retention/TTL clock, and a post-measure cool-down
// re-grants the hysteresis so measure end can't immediately re-park.
const wasMeasuringHiddenWorktreeRef = useRef(false)
const measureParkCooldownUntilRef = useRef<number | null>(null)
const terminalTabParkingTimersRef = useRef(new Map<string, number>())
const parkVerdictRecordsRef = useRef(new Map<string, ParkVerdictFlipRecord>())
const [terminalTabParkingRevision, setTerminalTabParkingRevision] = useState(0)
const [coldParkedTerminalTabIds, setColdParkedTerminalTabIds] = useState<ReadonlySet<string>>(
() => new Set()
)
useEffect(() => {
const timers = terminalTabParkingTimersRef.current
return () => {
for (const timer of timers.values()) {
window.clearTimeout(timer)
}
timers.clear()
}
}, [])
// Why: per-tab cold-park policy — hiddenSince bookkeeping, parked-set
// selection, and one recheck timer per still-pending deadline so React
// re-renders exactly when the hysteresis elapses instead of polling.
useEffect(() => {
const timers = terminalTabParkingTimersRef.current
for (const timer of timers.values()) {
window.clearTimeout(timer)
}
timers.clear()
const nowMs = Date.now()
const overrides = getTerminalParkingPolicyOverrides()
const currentTerminalTabIds = new Set(terminalTabs.map((tab) => tab.id))
const portalTabIds = new Set(
activityTerminalPortals
.filter((portal) => portal.worktreeId === worktreeId)
.map((portal) => portal.tabId)
)
for (const tabId of Array.from(terminalTabHiddenSinceRef.current.keys())) {
if (!currentTerminalTabIds.has(tabId)) {
terminalTabHiddenSinceRef.current.delete(tabId)
}
}
// Why: measure end starts the re-park cool-down (worktree measure-clock
// contract) — hiddenSince is preserved through the window, so without the
// cool-down every past-deadline tab would re-park the instant it closes.
if (shouldMeasureHiddenWorktree) {
wasMeasuringHiddenWorktreeRef.current = true
} else {
if (wasMeasuringHiddenWorktreeRef.current) {
measureParkCooldownUntilRef.current =
nowMs + (overrides.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS)
}
wasMeasuringHiddenWorktreeRef.current = false
}
// Why: mirrors Terminal.tsx's worktree clock — a visible worktree ends the
// measure episode outright, so no re-park cool-down is owed.
if (isWorktreeActive) {
measureParkCooldownUntilRef.current = null
}
const candidates: TerminalTabColdParkCandidate[] = terminalTabs.map((terminalTab) => {
const assignment = assignments.get(terminalTab.id)
const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup)
const hasActivityTerminalPortal = portalTabIds.has(terminalTab.id)
// Why measuring preserves the clock: the startup probe still needs
// mounted panes (selection + render veto below), but deleting
// hiddenSince would restart the hysteresis AND desync per-tab deadlines
// from the worktree retention/TTL clock on every ~3s probe.
if (isVisible || hasActivityTerminalPortal) {
terminalTabHiddenSinceRef.current.delete(terminalTab.id)
} else if (
!shouldMeasureHiddenWorktree &&
!terminalTabHiddenSinceRef.current.has(terminalTab.id)
) {
terminalTabHiddenSinceRef.current.set(terminalTab.id, nowMs)
}
return {
id: terminalTab.id,
ptyId: terminalTab.ptyId,
pendingActivationSpawn: terminalTab.pendingActivationSpawn,
isVisible,
hasActivityTerminalPortal,
hiddenSinceMs: terminalTabHiddenSinceRef.current.get(terminalTab.id) ?? null
}
})
const nextColdParkedTerminalTabIds = selectColdParkedTerminalTabs({
worktreeId,
terminalTabs: candidates,
pendingStartupByTabId,
parkingEnabled: terminalParkingEnabled,
nowMs,
parkCooldownUntilMs: measureParkCooldownUntilRef.current,
restorePolicy: {
sshParkingEnabled: terminalSshParkingEnabled,
pairedRuntimeParkingEnvironmentIds
},
...overrides
})
const { parkedTabIds, parkVerdictPinUntilMsByTabId } = withholdUnparkableTerminalTabs({
worktreeId,
terminalTabs,
coldParkedTabIds: nextColdParkedTerminalTabIds,
parkVerdictRecords: parkVerdictRecordsRef.current,
nowMs
})
setColdParkedTerminalTabIds((current) =>
haveSameTerminalTabIds(current, parkedTabIds) ? current : parkedTabIds
)
for (const candidate of candidates) {
if (
candidate.isVisible ||
candidate.hasActivityTerminalPortal ||
parkedTabIds.has(candidate.id)
) {
continue
}
const delayMs = getTerminalTabColdParkRecheckDelayMs({
parkingEnabled: terminalParkingEnabled,
hiddenSinceMs: candidate.hiddenSinceMs,
parkCooldownUntilMs: measureParkCooldownUntilRef.current,
// Why: pin expiry may be the only remaining wakeup after damping stops churn.
parkVerdictPinUntilMs: parkVerdictPinUntilMsByTabId.get(candidate.id) ?? null,
nowMs,
...overrides
})
if (delayMs !== null && delayMs > 0) {
const tabId = candidate.id
const timer = window.setTimeout(() => {
timers.delete(tabId)
setTerminalTabParkingRevision((revision) => revision + 1)
}, delayMs)
timers.set(tabId, timer)
}
}
}, [
activityTerminalPortals,
const coldParkCandidateTabIds = useTerminalTabColdParkCandidates({
worktreeId,
terminalTabs,
assignments,
isWorktreeActive,
pendingStartupByTabId,
pairedRuntimeParkingEnvironmentIds,
shouldMeasureHiddenWorktree,
activityTerminalPortals,
pendingStartupByTabId,
terminalParkingEnabled,
terminalSshParkingEnabled,
terminalTabParkingRevision,
terminalTabs,
worktreeId
])
pairedRuntimeParkingEnvironmentIds
})
// Why subscribed: the exemption also reads layout leaf PTYs, which change
// without a terminalTabs change (split added, pty re-minted); gated on
// isForceParked so only force-parked worktrees build the key per store change.
const evictionExemptLayoutKey = useAppStore((state) =>
isForceParked ? selectEvictionExemptTerminalTabLayoutKey(state, terminalTabs) : ''
)
// Why memoized: resolving an exemption re-reads the store and walks the
// layout tree per tab, so recompute only when the force-park verdict, the
// tabs, or their layout PTYs change — not on every assignment/park-set change
// below.
const evictionExemptTerminalTabIds = useMemo(
() =>
isForceParked ? selectEvictionExemptTerminalTabIds(worktreeId, terminalTabs) : EMPTY_TAB_IDS,
// eslint-disable-next-line react-hooks/exhaustive-deps -- the layout key encodes the store fields the selector re-reads internally.
const evictionExemptionSnapshot = useMemo(
() => ({
layoutKey: evictionExemptLayoutKey,
tabIds: isForceParked
? selectEvictionExemptTerminalTabIds(worktreeId, terminalTabs)
: EMPTY_TAB_IDS
}),
[evictionExemptLayoutKey, isForceParked, terminalTabs, worktreeId]
)
// Why: the rendered park verdict — worktree-level park (prop from
// Terminal.tsx) or per-tab cold park, never portal-hosted tabs. Render and
// the watcher-sync effect must share this exact set so watcher lifecycle
// tracks the committed unmounts.
const parkedTerminalTabIds = useMemo(() => {
const parked = new Set<string>()
for (const terminalTab of terminalTabs) {
const assignment = assignments.get(terminalTab.id)
const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup)
const hasActivityTerminalPortal =
findActivityTerminalPortal(activityTerminalPortals, {
worktreeId,
tabId: terminalTab.id
}) !== null
if (
(coldParkTerminalPanes ||
(!isVisible &&
coldParkedTerminalTabIds.has(terminalTab.id) &&
// Why: a pane owning a sleeping-session record must stay mountable
// on an active worktree — parked it can never cold-restore, so the
// agent's resume strands until the user reveals the tab. Scoped to
// per-tab parks: the worktree-level park clears on activation.
!sleepingRecordOwnedTabIds.has(terminalTab.id))) &&
!hasActivityTerminalPortal &&
// Why: a force-parked worktree's eviction-exempt tabs keep their
// mounted panes — a remount would orphan their live pty. Scoped to
// force-parks: ordinary parks never contain exempt tabs (eligibility
// requires every tab restorable, so the memo is empty for them).
!evictionExemptTerminalTabIds.has(terminalTab.id) &&
// Why: the hidden-measuring startup probe needs mounted panes; gate
// here too so the reveal lands in the same render that starts it.
!shouldMeasureHiddenWorktree
) {
parked.add(terminalTab.id)
}
// Why: activation-deferred tabs render no pane regardless of the park
// policy, so watchers must own their side effects immediately. Targeted
// restrictions do not enter this set or add a new eager watcher burst.
if (
activationDeferredMountTabIds?.has(terminalTab.id) &&
!hasActivityTerminalPortal &&
canWatcherCoverParkedTerminalTab(worktreeId, terminalTab)
) {
parked.add(terminalTab.id)
}
}
return parked
}, [
activityTerminalPortals,
assignments,
coldParkTerminalPanes,
coldParkedTerminalTabIds,
activationDeferredMountTabIds,
evictionExemptTerminalTabIds,
isWorktreeActive,
shouldMeasureHiddenWorktree,
sleepingRecordOwnedTabIds,
const parkedTerminalTabIds = useTerminalTabParkEpisodes({
worktreeId,
terminalTabs,
worktreeId
])
assignments,
isWorktreeActive,
coldParkTerminalPanes,
isForceParked,
shouldMeasureHiddenWorktree,
activityTerminalPortals,
activationDeferredMountTabIds,
onActivationDeferredWatcherHandoffFailed,
terminalParkingEnabled,
coldParkCandidateTabIds,
sleepingRecordOwnedTabIds,
evictionExemptTerminalTabIds: evictionExemptionSnapshot.tabIds,
providerCapabilityRevision,
watcherTopologyKey,
watcherRestoreAuthorityKey
})
// Why: observation only — records whether the *rendered* park verdict churns,
// so a crash bundle can confirm or refute a park-flip update loop. Watching
// the pre-gate cold set instead would miss loops driven by coldParkTerminalPanes
// or the portal/measuring gates. Changes no verdict; see
// terminal-park-verdict-flip-telemetry.ts.
const parkVerdictRecordsRef = useRef(new Map<string, ParkVerdictFlipRecord>())
useEffect(() => {
recordParkVerdictFlips({
records: parkVerdictRecordsRef.current,
@@ -353,22 +159,5 @@ export function useTerminalTabColdParking(args: {
})
}, [parkedTerminalTabIds, terminalTabs])
// Why: runs in the same effect flush as the commit that parked/revealed the
// panes — watcher disposal therefore lands before any PTY data IPC can
// reach a freshly remounted pane, and watcher start lands after the parked
// pane's unmount capture.
useEffect(() => {
syncParkedTerminalTabWatchers({
worktreeId,
tabs: terminalTabs,
parkedTabIds: parkedTerminalTabIds,
// Why: activation-deferred tabs have no prior pane-owned title slot;
// pull main's title-only snapshot when their watcher starts.
restoreTitleOnStartTabIds: activationDeferredMountTabIds ?? undefined
})
}, [activationDeferredMountTabIds, parkedTerminalTabIds, terminalTabs, worktreeId])
useEffect(() => () => disposeParkedTerminalWatchersForWorktree(worktreeId), [worktreeId])
return parkedTerminalTabIds
}
@@ -0,0 +1,286 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { TerminalTab } from '../../../../shared/types'
import {
findActivityTerminalPortal,
type ActivityTerminalPortalTarget
} from '../activity/activity-terminal-portal'
import {
disposeParkedTerminalWatchersForWorktree,
planParkedTerminalTabWatcherCoverage,
subscribeParkedTerminalWatcherOwnershipLoss,
syncParkedTerminalTabWatchersWithAcknowledgements,
type TerminalParkedWatcherCoveragePlan
} from './terminal-parked-tab-watchers'
import {
acknowledgeTerminalParkEpisodeSet,
reconcileTerminalParkEpisodeSet,
selectCurrentTerminalParkEpisodeUnmounts,
type TerminalParkEpisodeSet
} from './terminal-park-episode-set'
import { terminalParkedWatcherPlanPtyIds } from './terminal-park-episode-lease'
import type { TerminalOverlayTabAssignment } from './use-terminal-tab-cold-park-candidates'
type TerminalTabParkRequests = {
requestedTabIds: ReadonlySet<string>
forcedTabIds: ReadonlySet<string>
}
function selectTerminalTabParkRequests(args: {
worktreeId: string
terminalTabs: readonly TerminalTab[]
assignments: ReadonlyMap<string, TerminalOverlayTabAssignment>
isWorktreeActive: boolean
coldParkTerminalPanes: boolean
isForceParked: boolean
shouldMeasureHiddenWorktree: boolean
activityTerminalPortals: ActivityTerminalPortalTarget[]
activationDeferredMountTabIds?: ReadonlySet<string> | null
terminalParkingEnabled: boolean
coldParkCandidateTabIds: ReadonlySet<string>
sleepingRecordOwnedTabIds: ReadonlySet<string>
evictionExemptTerminalTabIds: ReadonlySet<string>
}): TerminalTabParkRequests {
const requestedTabIds = new Set<string>()
const forcedTabIds = new Set<string>()
for (const terminalTab of args.terminalTabs) {
const assignment = args.assignments.get(terminalTab.id)
const isVisible = Boolean(args.isWorktreeActive && assignment && assignment.isActiveInGroup)
const hasActivityTerminalPortal =
findActivityTerminalPortal(args.activityTerminalPortals, {
worktreeId: args.worktreeId,
tabId: terminalTab.id
}) !== null
if (hasActivityTerminalPortal) {
continue
}
const forcePark =
args.terminalParkingEnabled &&
args.coldParkTerminalPanes &&
args.isForceParked &&
!args.shouldMeasureHiddenWorktree &&
!args.evictionExemptTerminalTabIds.has(terminalTab.id)
const ordinaryPark =
args.terminalParkingEnabled &&
!args.shouldMeasureHiddenWorktree &&
((args.coldParkTerminalPanes && !args.isForceParked) ||
(!isVisible &&
args.coldParkCandidateTabIds.has(terminalTab.id) &&
!args.sleepingRecordOwnedTabIds.has(terminalTab.id)))
const activationDeferred =
args.terminalParkingEnabled &&
args.activationDeferredMountTabIds?.has(terminalTab.id) === true &&
!args.sleepingRecordOwnedTabIds.has(terminalTab.id)
if (forcePark || ordinaryPark || activationDeferred) {
requestedTabIds.add(terminalTab.id)
}
if (forcePark) {
forcedTabIds.add(terminalTab.id)
}
}
return { requestedTabIds, forcedTabIds }
}
export function useTerminalTabParkEpisodes(args: {
worktreeId: string
terminalTabs: readonly TerminalTab[]
assignments: ReadonlyMap<string, TerminalOverlayTabAssignment>
isWorktreeActive: boolean
coldParkTerminalPanes: boolean
isForceParked: boolean
shouldMeasureHiddenWorktree: boolean
activityTerminalPortals: ActivityTerminalPortalTarget[]
activationDeferredMountTabIds?: ReadonlySet<string> | null
onActivationDeferredWatcherHandoffFailed?: (tabId: string) => void
terminalParkingEnabled: boolean
coldParkCandidateTabIds: ReadonlySet<string>
sleepingRecordOwnedTabIds: ReadonlySet<string>
evictionExemptTerminalTabIds: ReadonlySet<string>
providerCapabilityRevision: number
watcherTopologyKey: string
watcherRestoreAuthorityKey: string
}): ReadonlySet<string> {
const {
worktreeId,
terminalTabs,
assignments,
isWorktreeActive,
coldParkTerminalPanes,
isForceParked,
shouldMeasureHiddenWorktree,
activityTerminalPortals,
activationDeferredMountTabIds,
onActivationDeferredWatcherHandoffFailed,
terminalParkingEnabled,
coldParkCandidateTabIds,
sleepingRecordOwnedTabIds,
evictionExemptTerminalTabIds,
providerCapabilityRevision,
watcherTopologyKey,
watcherRestoreAuthorityKey
} = args
const leasesRef = useRef<TerminalParkEpisodeSet>(new Map())
const [, setLeaseRevision] = useState(0)
const parkRequests = useMemo(
() =>
selectTerminalTabParkRequests({
worktreeId,
terminalTabs,
assignments,
isWorktreeActive,
coldParkTerminalPanes,
isForceParked,
shouldMeasureHiddenWorktree,
activityTerminalPortals,
activationDeferredMountTabIds,
terminalParkingEnabled,
coldParkCandidateTabIds,
sleepingRecordOwnedTabIds,
evictionExemptTerminalTabIds
}),
[
activationDeferredMountTabIds,
activityTerminalPortals,
assignments,
coldParkCandidateTabIds,
coldParkTerminalPanes,
evictionExemptTerminalTabIds,
isForceParked,
isWorktreeActive,
shouldMeasureHiddenWorktree,
sleepingRecordOwnedTabIds,
terminalParkingEnabled,
terminalTabs,
worktreeId
]
)
const coverageMaterialRevision = JSON.stringify([
providerCapabilityRevision,
watcherTopologyKey,
watcherRestoreAuthorityKey
])
const coveragePlanSnapshot = useMemo(() => {
const plansByTabId = new Map<string, TerminalParkedWatcherCoveragePlan>()
for (const terminalTab of terminalTabs) {
if (parkRequests.requestedTabIds.has(terminalTab.id)) {
plansByTabId.set(
terminalTab.id,
planParkedTerminalTabWatcherCoverage(worktreeId, terminalTab)
)
}
}
return { materialRevision: coverageMaterialRevision, plansByTabId }
}, [coverageMaterialRevision, parkRequests.requestedTabIds, terminalTabs, worktreeId])
const renderBaseLeases = leasesRef.current
const reconciledForRender = reconcileTerminalParkEpisodeSet({
current: renderBaseLeases,
tabs: terminalTabs,
requestedTabIds: parkRequests.requestedTabIds,
forcedTabIds: parkRequests.forcedTabIds,
plan: (tab) => {
const plan = coveragePlanSnapshot.plansByTabId.get(tab.id)
if (!plan) {
throw new Error(`Missing terminal park plan for ${tab.id}`)
}
return plan
}
})
const parkedTerminalTabIds = useMemo(
() =>
selectCurrentTerminalParkEpisodeUnmounts({
leases: reconciledForRender.leases,
plansByTabId: coveragePlanSnapshot.plansByTabId,
requestedTabIds: parkRequests.requestedTabIds,
forcedTabIds: parkRequests.forcedTabIds
}),
[coveragePlanSnapshot, parkRequests, reconciledForRender.leases]
)
useEffect(() => {
if (leasesRef.current === renderBaseLeases) {
leasesRef.current = reconciledForRender.leases
}
for (const tabId of activationDeferredMountTabIds ?? []) {
if (!parkedTerminalTabIds.has(tabId)) {
onActivationDeferredWatcherHandoffFailed?.(tabId)
}
}
}, [
activationDeferredMountTabIds,
onActivationDeferredWatcherHandoffFailed,
parkedTerminalTabIds,
reconciledForRender.leases,
renderBaseLeases
])
useEffect(() => {
const acknowledgements = syncParkedTerminalTabWatchersWithAcknowledgements({
worktreeId,
tabs: terminalTabs,
parkedTabIds: parkedTerminalTabIds,
coveragePlansByTabId: coveragePlanSnapshot.plansByTabId,
forcedTabIds: parkRequests.forcedTabIds,
restoreTitleOnStartTabIds: activationDeferredMountTabIds ?? undefined
})
const acknowledged = acknowledgeTerminalParkEpisodeSet(leasesRef.current, acknowledgements)
leasesRef.current = acknowledged.leases
if (!acknowledged.renderChanged) {
return
}
for (const acknowledgement of acknowledgements) {
if (
acknowledgement.status === 'failed' &&
activationDeferredMountTabIds?.has(acknowledgement.tabId)
) {
onActivationDeferredWatcherHandoffFailed?.(acknowledgement.tabId)
}
}
setLeaseRevision((revision) => revision + 1)
}, [
activationDeferredMountTabIds,
coveragePlanSnapshot,
onActivationDeferredWatcherHandoffFailed,
parkedTerminalTabIds,
parkRequests.forcedTabIds,
terminalTabs,
worktreeId
])
useEffect(
() =>
subscribeParkedTerminalWatcherOwnershipLoss((event) => {
if (event.worktreeId !== worktreeId) {
return
}
const lease = leasesRef.current.get(event.tabId)
if (!lease || lease.phase === 'forced') {
return
}
const acknowledged = acknowledgeTerminalParkEpisodeSet(leasesRef.current, [
{
status: 'failed',
tabId: event.tabId,
materialKey: lease.plan.materialKey,
reason: 'watcher-ownership-lost',
expectedPtyIds: terminalParkedWatcherPlanPtyIds(lease.plan),
watchedPtyIds: []
}
])
if (!acknowledged.renderChanged) {
return
}
leasesRef.current = acknowledged.leases
if (activationDeferredMountTabIds?.has(event.tabId)) {
onActivationDeferredWatcherHandoffFailed?.(event.tabId)
}
setLeaseRevision((revision) => revision + 1)
}),
[activationDeferredMountTabIds, onActivationDeferredWatcherHandoffFailed, worktreeId]
)
useEffect(() => () => disposeParkedTerminalWatchersForWorktree(worktreeId), [worktreeId])
return parkedTerminalTabIds
}
@@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearTerminalProviderSnapshotCapabilities,
collectTerminalProviderSnapshotPtyIds,
getTerminalProviderSnapshotCapabilityRevision,
getTerminalProviderSnapshotCapabilityState,
refreshTerminalProviderSnapshotCapabilities,
subscribeTerminalProviderSnapshotCapabilityRevision,
synchronizeTerminalProviderSnapshotCapabilities,
terminalProviderHasAuthoritativeSnapshot
} from './terminal-provider-snapshot-capability'
@@ -43,6 +46,24 @@ describe('terminal provider snapshot capabilities', () => {
expect(resolve).toHaveBeenCalledWith(['current', 'legacy'])
expect(terminalProviderHasAuthoritativeSnapshot('current')).toBe(true)
expect(terminalProviderHasAuthoritativeSnapshot('legacy')).toBe(false)
expect(getTerminalProviderSnapshotCapabilityState('current')).toBe('authoritative')
expect(getTerminalProviderSnapshotCapabilityState('legacy')).toBe('unavailable')
expect(getTerminalProviderSnapshotCapabilityState('unresolved')).toBe('pending')
})
it('publishes one semantic revision for a resolved batch', async () => {
const listener = vi.fn()
const unsubscribe = subscribeTerminalProviderSnapshotCapabilityRevision(listener)
const before = getTerminalProviderSnapshotCapabilityRevision()
await synchronizeTerminalProviderSnapshotCapabilities(['pty-1', 'pty-2'], async (ids) =>
ids.map((id) => ({ id, authoritative: true }))
)
await synchronizeTerminalProviderSnapshotCapabilities(['pty-1', 'pty-2'], async () => [])
expect(getTerminalProviderSnapshotCapabilityRevision()).toBe(before + 1)
expect(listener).toHaveBeenCalledOnce()
unsubscribe()
})
it('refreshes a pre-provider false after startup services become ready', async () => {
@@ -11,6 +11,7 @@ type SnapshotCapabilityBindingState = {
}
const authoritativeSnapshotByPtyId = new Map<string, boolean>()
const capabilityRevisionListeners = new Set<() => void>()
const unknownCapabilityRetryAtByPtyId = new Map<string, number>()
const unknownCapabilityAttemptsByPtyId = new Map<string, number>()
const UNKNOWN_CAPABILITY_RETRY_MS = 1_000
@@ -21,6 +22,30 @@ const CAPABILITY_RESOLUTION_TIMEOUT_MS = 1_000
let lastSynchronizedLivePtyIds: readonly string[] | null = null
let earliestUnknownCapabilityRetryAtMs = Number.POSITIVE_INFINITY
let synchronizationGeneration = 0
let terminalProviderSnapshotCapabilityRevision = 0
export type TerminalProviderSnapshotCapabilityState = 'pending' | 'authoritative' | 'unavailable'
function publishTerminalProviderSnapshotCapabilityChange(changed: boolean): void {
if (!changed) {
return
}
terminalProviderSnapshotCapabilityRevision += 1
for (const listener of capabilityRevisionListeners) {
listener()
}
}
function setTerminalProviderSnapshotCapability(ptyId: string, authoritative: boolean): boolean {
if (
authoritativeSnapshotByPtyId.has(ptyId) &&
authoritativeSnapshotByPtyId.get(ptyId) === authoritative
) {
return false
}
authoritativeSnapshotByPtyId.set(ptyId, authoritative)
return true
}
export function collectTerminalProviderSnapshotPtyIds(
state: SnapshotCapabilityBindingState
@@ -55,13 +80,13 @@ function refreshEarliestUnknownCapabilityRetry(): void {
}
// Unknown routes settle eager after bounded retries to avoid lifelong capability polling.
function backOffUnknownCapability(ptyId: string, nowMs: number): void {
function backOffUnknownCapability(ptyId: string, nowMs: number): boolean {
const attempts = (unknownCapabilityAttemptsByPtyId.get(ptyId) ?? 0) + 1
if (attempts >= UNKNOWN_CAPABILITY_MAX_ATTEMPTS) {
authoritativeSnapshotByPtyId.set(ptyId, false)
const changed = setTerminalProviderSnapshotCapability(ptyId, false)
unknownCapabilityAttemptsByPtyId.delete(ptyId)
unknownCapabilityRetryAtByPtyId.delete(ptyId)
return
return changed
}
unknownCapabilityAttemptsByPtyId.set(ptyId, attempts)
unknownCapabilityRetryAtByPtyId.set(
@@ -69,6 +94,7 @@ function backOffUnknownCapability(ptyId: string, nowMs: number): void {
nowMs +
Math.min(UNKNOWN_CAPABILITY_RETRY_MS * 2 ** (attempts - 1), UNKNOWN_CAPABILITY_MAX_RETRY_MS)
)
return false
}
function unknownCapabilityRetryDelayMs(nowMs: number): number | null {
@@ -112,9 +138,10 @@ export async function synchronizeTerminalProviderSnapshotCapabilities(
const generation = ++synchronizationGeneration
lastSynchronizedLivePtyIds = livePtyIds
const live = new Set(livePtyIds.filter((id) => id.length > 0))
let capabilityChanged = false
for (const cachedId of authoritativeSnapshotByPtyId.keys()) {
if (!live.has(cachedId)) {
authoritativeSnapshotByPtyId.delete(cachedId)
capabilityChanged = authoritativeSnapshotByPtyId.delete(cachedId) || capabilityChanged
}
}
for (const pendingId of unknownCapabilityRetryAtByPtyId.keys()) {
@@ -123,6 +150,8 @@ export async function synchronizeTerminalProviderSnapshotCapabilities(
unknownCapabilityAttemptsByPtyId.delete(pendingId)
}
}
publishTerminalProviderSnapshotCapabilityChange(capabilityChanged)
capabilityChanged = false
const missing = [...live].filter(
(id) =>
@@ -132,9 +161,10 @@ export async function synchronizeTerminalProviderSnapshotCapabilities(
const resolve = resolveCapabilities ?? window.api.pty.getAuthoritativeBufferSnapshotCapabilities
if (!resolve) {
for (const id of missing) {
backOffUnknownCapability(id, nowMs)
capabilityChanged = backOffUnknownCapability(id, nowMs) || capabilityChanged
}
refreshEarliestUnknownCapabilityRetry()
publishTerminalProviderSnapshotCapabilityChange(capabilityChanged)
return unknownCapabilityRetryDelayMs(nowMs)
}
for (let offset = 0; offset < missing.length; offset += 512) {
@@ -149,7 +179,7 @@ export async function synchronizeTerminalProviderSnapshotCapabilities(
// Why: unknown capability must keep the pane mounted. Do not cache the
// failure as supported; back off before retrying daemon startup.
for (const id of batch) {
backOffUnknownCapability(id, nowMs)
capabilityChanged = backOffUnknownCapability(id, nowMs) || capabilityChanged
}
continue
}
@@ -158,7 +188,7 @@ export async function synchronizeTerminalProviderSnapshotCapabilities(
}
if (!resolved) {
for (const id of missing.slice(offset)) {
backOffUnknownCapability(id, nowMs)
capabilityChanged = backOffUnknownCapability(id, nowMs) || capabilityChanged
}
break
}
@@ -166,15 +196,17 @@ export async function synchronizeTerminalProviderSnapshotCapabilities(
for (const id of batch) {
const authoritative = resolvedById.get(id)
if (typeof authoritative === 'boolean') {
authoritativeSnapshotByPtyId.set(id, authoritative)
capabilityChanged =
setTerminalProviderSnapshotCapability(id, authoritative) || capabilityChanged
unknownCapabilityRetryAtByPtyId.delete(id)
unknownCapabilityAttemptsByPtyId.delete(id)
} else {
backOffUnknownCapability(id, nowMs)
capabilityChanged = backOffUnknownCapability(id, nowMs) || capabilityChanged
}
}
}
refreshEarliestUnknownCapabilityRetry()
publishTerminalProviderSnapshotCapabilityChange(capabilityChanged)
return unknownCapabilityRetryDelayMs(observedAtMs === undefined ? Date.now() : nowMs)
}
@@ -183,12 +215,14 @@ export async function refreshTerminalProviderSnapshotCapabilities(
resolveCapabilities?: SnapshotCapabilityResolver
): Promise<number | null> {
lastSynchronizedLivePtyIds = null
let capabilityChanged = false
for (const id of livePtyIds) {
authoritativeSnapshotByPtyId.delete(id)
capabilityChanged = authoritativeSnapshotByPtyId.delete(id) || capabilityChanged
unknownCapabilityRetryAtByPtyId.delete(id)
unknownCapabilityAttemptsByPtyId.delete(id)
}
refreshEarliestUnknownCapabilityRetry()
publishTerminalProviderSnapshotCapabilityChange(capabilityChanged)
return synchronizeTerminalProviderSnapshotCapabilities(livePtyIds, resolveCapabilities)
}
@@ -214,11 +248,31 @@ export function terminalProviderHasAuthoritativeSnapshot(ptyId: string): boolean
return authoritativeSnapshotByPtyId.get(ptyId) === true
}
export function getTerminalProviderSnapshotCapabilityState(
ptyId: string
): TerminalProviderSnapshotCapabilityState {
const authoritative = authoritativeSnapshotByPtyId.get(ptyId)
return authoritative === undefined ? 'pending' : authoritative ? 'authoritative' : 'unavailable'
}
export function getTerminalProviderSnapshotCapabilityRevision(): number {
return terminalProviderSnapshotCapabilityRevision
}
export function subscribeTerminalProviderSnapshotCapabilityRevision(
listener: () => void
): () => void {
capabilityRevisionListeners.add(listener)
return () => capabilityRevisionListeners.delete(listener)
}
export function clearTerminalProviderSnapshotCapabilities(): void {
const capabilityChanged = authoritativeSnapshotByPtyId.size > 0
authoritativeSnapshotByPtyId.clear()
unknownCapabilityRetryAtByPtyId.clear()
unknownCapabilityAttemptsByPtyId.clear()
lastSynchronizedLivePtyIds = null
earliestUnknownCapabilityRetryAtMs = Number.POSITIVE_INFINITY
synchronizationGeneration += 1
publishTerminalProviderSnapshotCapabilityChange(capabilityChanged)
}
@@ -54,6 +54,14 @@ describe('useTerminalProviderSnapshotCapability', () => {
hook.unmount()
})
it('publishes a render revision when capability authority resolves', async () => {
resolveCapabilities.mockResolvedValue([{ id: 'ssh:target@@pty-1', authoritative: true }])
const hook = renderHook(() => useTerminalProviderSnapshotCapability(true))
const initialRevision = hook.result.current
await waitFor(() => expect(hook.result.current).toBeGreaterThan(initialRevision))
})
it('cancels an unknown-capability retry when the hook unmounts', async () => {
vi.useFakeTimers()
resolveCapabilities.mockResolvedValue([{ id: 'ssh:target@@pty-1', authoritative: null }])
@@ -1,13 +1,20 @@
import { useEffect, useMemo } from 'react'
import { useEffect, useMemo, useSyncExternalStore } from 'react'
import { useAppStore } from '@/store'
import {
collectTerminalProviderSnapshotPtyIds,
getTerminalProviderSnapshotCapabilityRevision,
subscribeTerminalProviderSnapshotCapabilityRevision,
startTerminalProviderSnapshotCapabilitySynchronization
} from './terminal-provider-snapshot-capability'
export function useTerminalProviderSnapshotCapability(enabled: boolean): void {
export function useTerminalProviderSnapshotCapability(enabled: boolean): number {
const tabsByWorktree = useAppStore((state) => state.tabsByWorktree)
const ptyIdsByTabId = useAppStore((state) => state.ptyIdsByTabId)
const capabilityRevision = useSyncExternalStore(
subscribeTerminalProviderSnapshotCapabilityRevision,
getTerminalProviderSnapshotCapabilityRevision,
getTerminalProviderSnapshotCapabilityRevision
)
const boundPtyIds = useMemo(
() => collectTerminalProviderSnapshotPtyIds({ tabsByWorktree, ptyIdsByTabId }),
[ptyIdsByTabId, tabsByWorktree]
@@ -20,4 +27,6 @@ export function useTerminalProviderSnapshotCapability(enabled: boolean): void {
}
return startTerminalProviderSnapshotCapabilitySynchronization(boundPtyIds)
}, [boundPtyIds, enabled])
return capabilityRevision
}
@@ -100,7 +100,7 @@ describe('terminal tab retirement store boundary', () => {
paneIdByPtyId: new Map([['pty-primary', 1]]),
disposersByPtyId: new Map([['pty-primary', dispose]])
})
capturedPanesByTabId.set('tab-1', { worktreeId: 'wt-1', panes: [] })
capturedPanesByTabId.set('tab-1', { worktreeId: 'wt-1', generation: null, panes: [] })
store.getState().closeTab('tab-1')
await vi.waitFor(() => expect(mockKill).toHaveBeenCalledTimes(5))