mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
fix(terminal): replace park damping with acknowledged handoffs
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+148
@@ -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')
|
||||
})
|
||||
})
|
||||
-41
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
+127
-113
@@ -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 }
|
||||
}
|
||||
+45
-235
@@ -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
|
||||
|
||||
+255
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
+126
@@ -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
|
||||
}
|
||||
+218
@@ -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)
|
||||
}
|
||||
+115
@@ -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' }
|
||||
}
|
||||
+271
@@ -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]
|
||||
])
|
||||
}
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -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 }
|
||||
])
|
||||
|
||||
+239
-1
@@ -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({
|
||||
|
||||
+192
-6
@@ -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)
|
||||
}
|
||||
|
||||
+8
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user