Reset agent sleep after terminal visits (#6450)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-26 17:41:12 -07:00 committed by GitHub
parent e1f93238d1
commit d18cdc693f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 467 additions and 194 deletions

View File

@ -64,7 +64,7 @@ import {
anyMountedWorktreeHasLayout as computeAnyMountedWorktreeHasLayout
} from './terminal/split-group-mount'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { setForegroundTerminalWorktreeIds } from '@/lib/foreground-terminal-worktrees'
import { setForegroundTerminalTabIds } from '@/lib/foreground-terminal-tabs'
import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue'
import { setWindowCloseRequestHandler } from './window-close-request-coordinator'
import CodexRestartChip from './CodexRestartChip'
@ -279,23 +279,23 @@ function Terminal(): React.JSX.Element | null {
const activityTerminalPortals: ActivityTerminalPortalTarget[] = useActivityTerminalPortals(
activeView === 'activity'
)
const foregroundTerminalWorktreeIds = useMemo(() => {
const foregroundTerminalTabIds = useMemo(() => {
const ids = new Set<string>()
if (activeView === 'terminal' && renderedActiveWorktreeId) {
ids.add(renderedActiveWorktreeId)
if (activeView === 'terminal' && activeTabType === 'terminal' && activeTabId) {
ids.add(activeTabId)
}
for (const portal of activityTerminalPortals) {
ids.add(portal.worktreeId)
ids.add(portal.tabId)
}
return Array.from(ids)
}, [activeView, activityTerminalPortals, renderedActiveWorktreeId])
}, [activeTabId, activeTabType, activeView, activityTerminalPortals])
useEffect(() => {
// Why: hibernation must treat terminals portaled into foreground surfaces
// as visible even when they are not the singular active worktree.
setForegroundTerminalWorktreeIds(foregroundTerminalWorktreeIds)
return () => setForegroundTerminalWorktreeIds([])
}, [foregroundTerminalWorktreeIds])
// as visible even when they are not the singular active terminal tab.
setForegroundTerminalTabIds(foregroundTerminalTabIds)
return () => setForegroundTerminalTabIds([])
}, [foregroundTerminalTabIds])
const tabs = useMemo(
() => (renderedActiveWorktreeId ? (tabsByWorktree[renderedActiveWorktreeId] ?? []) : []),

View File

@ -117,7 +117,7 @@ import { keybindingMatchesAction } from '../../../../shared/keybindings'
import { pasteTerminalClipboard } from './terminal-clipboard-paste'
import { scheduleImagePasteWebglAtlasRecovery } from './terminal-webgl-paste-recovery'
import { restoreTerminalFitToDesktop, restoreTerminalFitsToDesktop } from './terminal-fit-restore'
import { useVisibleTerminalWorktreeClaim } from './use-visible-terminal-worktree-claim'
import { useVisibleTerminalTabClaim } from './use-visible-terminal-tab-claim'
// Why: registry lives in a leaf module so the store slice can import it
// without re-entering the `slice → TerminalPane → store → slice` cycle
@ -271,7 +271,7 @@ export default function TerminalPane({
const isVisibleRef = useRef(isVisible)
isVisibleRef.current = isVisible
useVisibleTerminalWorktreeClaim({ isVisible, worktreeId })
useVisibleTerminalTabClaim({ isVisible, tabId })
const [expandedPaneId, setExpandedPaneId] = useState<number | null>(null)
// Why: tracked in React state (not derived from managerRef.getPanes().length)

View File

@ -1,10 +1,10 @@
import type * as ReactModule from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
getForegroundTerminalWorktreeIds,
resetForegroundTerminalWorktreeIdsForTests
} from '@/lib/foreground-terminal-worktrees'
import { useVisibleTerminalWorktreeClaim } from './use-visible-terminal-worktree-claim'
getForegroundTerminalTabIds,
resetForegroundTerminalTabIdsForTests
} from '@/lib/foreground-terminal-tabs'
import { useVisibleTerminalTabClaim } from './use-visible-terminal-tab-claim'
const reactEffects = vi.hoisted(() => ({
layoutEffects: [] as (() => void | (() => void))[],
@ -25,30 +25,30 @@ vi.mock('react', async (importOriginal) => {
})
afterEach(() => {
resetForegroundTerminalWorktreeIdsForTests()
resetForegroundTerminalTabIdsForTests()
reactEffects.layoutEffects = []
reactEffects.passiveEffects = []
})
describe('useVisibleTerminalWorktreeClaim', () => {
describe('useVisibleTerminalTabClaim', () => {
it('registers visible panes through a layout effect', () => {
useVisibleTerminalWorktreeClaim({ isVisible: true, worktreeId: 'wt-visible' })
useVisibleTerminalTabClaim({ isVisible: true, tabId: 'tab-visible' })
expect(reactEffects.passiveEffects).toHaveLength(0)
expect(reactEffects.layoutEffects).toHaveLength(1)
const cleanup = reactEffects.layoutEffects[0]()
expect(getForegroundTerminalWorktreeIds()).toEqual(['wt-visible'])
expect(getForegroundTerminalTabIds()).toEqual(['tab-visible'])
cleanup?.()
expect(getForegroundTerminalWorktreeIds()).toEqual([])
expect(getForegroundTerminalTabIds()).toEqual([])
})
it('does not claim hidden panes', () => {
useVisibleTerminalWorktreeClaim({ isVisible: false, worktreeId: 'wt-hidden' })
useVisibleTerminalTabClaim({ isVisible: false, tabId: 'tab-hidden' })
reactEffects.layoutEffects[0]()
expect(getForegroundTerminalWorktreeIds()).toEqual([])
expect(getForegroundTerminalTabIds()).toEqual([])
})
})

View File

@ -0,0 +1,21 @@
import { useLayoutEffect } from 'react'
import { registerVisibleTerminalTab } from '@/lib/foreground-terminal-tabs'
type VisibleTerminalTabClaimOptions = {
isVisible: boolean
tabId: string
}
export function useVisibleTerminalTabClaim({
isVisible,
tabId
}: VisibleTerminalTabClaimOptions): void {
useLayoutEffect(() => {
if (!isVisible) {
return
}
// Why: agent sleep must fail closed before paint for any pane the user can
// see, even when global active-worktree state is between views.
return registerVisibleTerminalTab(tabId)
}, [isVisible, tabId])
}

View File

@ -1,21 +0,0 @@
import { useLayoutEffect } from 'react'
import { registerVisibleTerminalWorktree } from '@/lib/foreground-terminal-worktrees'
type VisibleTerminalWorktreeClaimOptions = {
isVisible: boolean
worktreeId: string
}
export function useVisibleTerminalWorktreeClaim({
isVisible,
worktreeId
}: VisibleTerminalWorktreeClaimOptions): void {
useLayoutEffect(() => {
if (!isVisible) {
return
}
// Why: agent sleep must fail closed before paint for any pane the user can
// see, even when global active-worktree state is between views.
return registerVisibleTerminalWorktree(worktreeId)
}, [isVisible, worktreeId])
}

View File

@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import type { AgentHibernationCandidate } from './agent-hibernation-planner'
import { confirmAgentHibernationCandidates } from './agent-hibernation-confirmation'
function candidate(overrides: Partial<AgentHibernationCandidate> = {}): AgentHibernationCandidate {
return {
id: 'wt-bg|tab-1:leaf-1',
worktreeId: 'wt-bg',
paneKey: 'tab-1:leaf-1',
tabId: 'tab-1',
leafId: 'leaf-1',
paneKeys: ['tab-1:leaf-1'],
targetPtyIds: ['pty-1'],
expectedRuntimePtyIds: ['pty-1'],
signature: 'stable-signature',
...overrides
}
}
describe('agent sleep confirmation', () => {
it('requires two stable ticks and resets on signature changes', () => {
const firstCandidate = candidate()
const first = confirmAgentHibernationCandidates({}, [firstCandidate])
expect(first.candidates).toEqual([])
expect(
confirmAgentHibernationCandidates(first.confirmationState, [firstCandidate]).candidates
).toEqual([firstCandidate])
const changed = candidate({ signature: 'changed-signature' })
expect(
confirmAgentHibernationCandidates(first.confirmationState, [changed]).candidates
).toEqual([])
})
})

View File

@ -0,0 +1,23 @@
import type { AgentHibernationCandidate } from './agent-hibernation-planner'
export type AgentHibernationConfirmationState = Record<string, string>
export type AgentHibernationPlan = {
candidates: AgentHibernationCandidate[]
confirmationState: AgentHibernationConfirmationState
}
export function confirmAgentHibernationCandidates(
previous: AgentHibernationConfirmationState,
candidates: AgentHibernationCandidate[]
): AgentHibernationPlan {
const confirmationState: AgentHibernationConfirmationState = {}
const confirmed: AgentHibernationCandidate[] = []
for (const candidate of candidates) {
confirmationState[candidate.id] = candidate.signature
if (previous[candidate.id] === candidate.signature) {
confirmed.push(candidate)
}
}
return { candidates: confirmed, confirmationState }
}

View File

@ -5,14 +5,15 @@ import { useAppStore } from '@/store'
import { DEFAULT_AGENT_HIBERNATION_IDLE_MS } from './agent-hibernation-planner'
import {
resetAgentHibernationCoordinatorForTests,
runAgentHibernationTick,
startAgentHibernationCoordinator
} from './agent-hibernation-coordinator'
import { hydrateDrivers, setDriverForPty } from './pane-manager/mobile-driver-state'
import {
registerVisibleTerminalWorktree,
resetForegroundTerminalWorktreeIdsForTests,
setForegroundTerminalWorktreeIds
} from './foreground-terminal-worktrees'
registerVisibleTerminalTab,
resetForegroundTerminalTabIdsForTests,
setForegroundTerminalTabIds
} from './foreground-terminal-tabs'
import {
recordAgentHibernationPaneOutput,
resetAgentHibernationOutputActivityForTests
@ -163,7 +164,7 @@ function deferred<T>(): {
afterEach(() => {
resetAgentHibernationCoordinatorForTests()
clearRuntimeCompatibilityCacheForTests()
resetForegroundTerminalWorktreeIdsForTests()
resetForegroundTerminalTabIdsForTests()
resetAgentHibernationOutputActivityForTests()
hydrateDrivers([])
mockRuntimeEnvironmentCall.mockReset()
@ -229,10 +230,10 @@ describe('agent sleep coordinator', () => {
expect(shutdown).not.toHaveBeenCalled()
})
it('does not hibernate a foreground worktree that is not the active worktree', async () => {
it('does not hibernate a foreground terminal tab that is not in the active worktree', async () => {
vi.useFakeTimers()
const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined))
setForegroundTerminalWorktreeIds(['wt-bg'])
setForegroundTerminalTabIds(['tab-1'])
startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW })
await vi.advanceTimersByTimeAsync(3000)
@ -240,21 +241,27 @@ describe('agent sleep coordinator', () => {
expect(shutdown).not.toHaveBeenCalled()
})
it('does not hibernate a worktree with a visible mounted terminal pane', async () => {
it('does not hibernate a visible mounted terminal tab', async () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined))
const unregister = registerVisibleTerminalWorktree('wt-bg')
startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW })
const unregister = registerVisibleTerminalTab('tab-1')
await vi.advanceTimersByTimeAsync(3000)
await runAgentHibernationTick()
expect(shutdown).not.toHaveBeenCalled()
vi.setSystemTime(NOW + 1_000)
unregister()
// Why: the coordinator requires one tick to confirm a stable candidate
// and a second tick to revalidate before shutdown.
await vi.advanceTimersByTimeAsync(1000)
await vi.advanceTimersByTimeAsync(1000)
await runAgentHibernationTick()
expect(shutdown).not.toHaveBeenCalled()
vi.setSystemTime(NOW + 1_000 + DEFAULT_AGENT_HIBERNATION_IDLE_MS + 1)
await runAgentHibernationTick()
expect(shutdown).not.toHaveBeenCalled()
await runAgentHibernationTick()
await Promise.resolve()
await Promise.resolve()
expect(shutdown).toHaveBeenCalledWith('wt-bg', {
paneKey: `tab-1:${LEAF}`,
tabId: 'tab-1',
@ -292,6 +299,37 @@ describe('agent sleep coordinator', () => {
expect(shutdown).not.toHaveBeenCalled()
})
it('restarts confirmation when a foreground terminal visit refreshes idle state', async () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined))
await runAgentHibernationTick()
expect(shutdown).not.toHaveBeenCalled()
vi.setSystemTime(NOW + 1_999)
setForegroundTerminalTabIds(['tab-1'])
vi.setSystemTime(NOW + 2_000)
setForegroundTerminalTabIds([])
await runAgentHibernationTick()
expect(shutdown).not.toHaveBeenCalled()
vi.setSystemTime(NOW + 2_000 + DEFAULT_AGENT_HIBERNATION_IDLE_MS + 1)
await runAgentHibernationTick()
expect(shutdown).not.toHaveBeenCalled()
await runAgentHibernationTick()
await Promise.resolve()
await Promise.resolve()
expect(shutdown).toHaveBeenCalledWith('wt-bg', {
paneKey: `tab-1:${LEAF}`,
tabId: 'tab-1',
leafId: LEAF,
ptyId: 'pty-1'
})
})
it('blocks shutdown when terminal input arrives between confirmation ticks', async () => {
vi.useFakeTimers()
const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined))

View File

@ -1,14 +1,19 @@
import { useAppStore } from '@/store'
import {
confirmAgentHibernationCandidates,
planAgentHibernationCandidates,
type AgentHibernationCandidate,
type AgentHibernationConfirmationState,
type AgentHibernationPlannerSnapshot
} from './agent-hibernation-planner'
import {
confirmAgentHibernationCandidates,
type AgentHibernationConfirmationState
} from './agent-hibernation-confirmation'
import type { AppState } from '@/store/types'
import { getAllDrivers } from './pane-manager/mobile-driver-state'
import { getForegroundTerminalWorktreeIds } from './foreground-terminal-worktrees'
import {
getForegroundTerminalTabIds,
getForegroundTerminalTabLastSeenAtById
} from './foreground-terminal-tabs'
import { getAgentHibernationOutputSignature } from './agent-hibernation-output-activity'
import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
@ -56,7 +61,7 @@ function snapshotFromState(
return {
settings: state.settings,
activeWorktreeId: state.activeWorktreeId,
foregroundWorktreeIds: getForegroundTerminalWorktreeIds(),
foregroundTerminalTabIds: getForegroundTerminalTabIds(),
tabsByWorktree: state.tabsByWorktree,
terminalLayoutsByTabId: state.terminalLayoutsByTabId,
ptyIdsByTabId: state.ptyIdsByTabId,
@ -68,6 +73,7 @@ function snapshotFromState(
agentStatusByPaneKey: state.agentStatusByPaneKey,
sleepingAgentSessionsByPaneKey: state.sleepingAgentSessionsByPaneKey,
lastTerminalInputAtByPaneKey: state.lastTerminalInputAtByPaneKey,
foregroundTerminalLastSeenAtByTabId: getForegroundTerminalTabLastSeenAtById(),
now
}
}

View File

@ -5,7 +5,6 @@ import {
DEFAULT_AGENT_HIBERNATION_IDLE_MS,
MAX_AGENT_HIBERNATION_IDLE_MS,
MIN_AGENT_HIBERNATION_IDLE_MS,
confirmAgentHibernationCandidates,
getEffectiveAgentHibernationIdleMs,
planAgentHibernationCandidates,
type AgentHibernationPlannerSnapshot
@ -65,7 +64,7 @@ function snapshot(
agentHibernationIdleMs: DEFAULT_AGENT_HIBERNATION_IDLE_MS
},
activeWorktreeId: 'wt-active',
foregroundWorktreeIds: ['wt-active'],
foregroundTerminalTabIds: [],
tabsByWorktree: { 'wt-bg': [tab()] },
terminalLayoutsByTabId: { 'tab-1': layout() },
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
@ -73,6 +72,7 @@ function snapshot(
agentStatusByPaneKey: { [agentEntry.paneKey]: agentEntry },
sleepingAgentSessionsByPaneKey: {},
lastTerminalInputAtByPaneKey: {},
foregroundTerminalLastSeenAtByTabId: {},
now: NOW,
...overrides
}
@ -99,9 +99,7 @@ describe('agent sleep planner', () => {
)
).toEqual([])
expect(plannedWorktrees(snapshot({ activeWorktreeId: 'wt-bg' }))).toEqual([])
expect(plannedWorktrees(snapshot({ foregroundWorktreeIds: ['wt-active', 'wt-bg'] }))).toEqual(
[]
)
expect(plannedWorktrees(snapshot({ foregroundTerminalTabIds: ['tab-1'] }))).toEqual([])
})
it('requires done resumable provider-session entries', () => {
@ -136,6 +134,89 @@ describe('agent sleep planner', () => {
).toEqual(['wt-bg'])
})
it('uses foreground terminal tab last-seen as the idle baseline when it is newer', () => {
expect(
plannedWorktrees(
snapshot({
foregroundTerminalLastSeenAtByTabId: {
'tab-1': NOW - DEFAULT_AGENT_HIBERNATION_IDLE_MS + 1
}
})
)
).toEqual([])
expect(
plannedWorktrees(
snapshot({
foregroundTerminalLastSeenAtByTabId: {
'tab-1': NOW - DEFAULT_AGENT_HIBERNATION_IDLE_MS - 1
}
})
)
).toEqual(['wt-bg'])
expect(
plannedWorktrees(
snapshot({
foregroundTerminalLastSeenAtByTabId: {
'tab-1': NOW - DEFAULT_AGENT_HIBERNATION_IDLE_MS - 1
},
lastTerminalInputAtByPaneKey: { [`tab-1:${LEAF}`]: OLD + 1 }
})
)
).toEqual([])
})
it('does not let one foreground terminal tab reset a sibling tab in the same worktree', () => {
const siblingEntry = entry({
paneKey: `tab-2:${OTHER_LEAF}`,
tabId: 'tab-2',
providerSession: { key: 'session_id', id: 'session-2' }
})
expect(
plannedPaneKeys(
snapshot({
foregroundTerminalTabIds: ['tab-1'],
foregroundTerminalLastSeenAtByTabId: {
'tab-1': NOW
},
tabsByWorktree: { 'wt-bg': [tab('tab-1'), tab('tab-2')] },
terminalLayoutsByTabId: {
'tab-1': layout(),
'tab-2': layout(OTHER_LEAF, 'pty-2')
},
ptyIdsByTabId: {
'tab-1': ['pty-1'],
'tab-2': ['pty-2']
},
agentStatusByPaneKey: {
[`tab-1:${LEAF}`]: entry(),
[siblingEntry.paneKey]: siblingEntry
}
})
)
).toEqual([`tab-2:${OTHER_LEAF}`])
})
it('includes the effective idle start in the candidate signature', () => {
const oldEntry = entry({
updatedAt: NOW - DEFAULT_AGENT_HIBERNATION_IDLE_MS - 10_000,
stateStartedAt: NOW - DEFAULT_AGENT_HIBERNATION_IDLE_MS - 10_000
})
const [withoutVisit] = planAgentHibernationCandidates(
snapshot({ agentStatusByPaneKey: { [oldEntry.paneKey]: oldEntry } })
)
const [withVisit] = planAgentHibernationCandidates(
snapshot({
agentStatusByPaneKey: { [oldEntry.paneKey]: oldEntry },
foregroundTerminalLastSeenAtByTabId: {
'tab-1': NOW - DEFAULT_AGENT_HIBERNATION_IDLE_MS - 1
}
})
)
expect(withoutVisit.signature).not.toEqual(withVisit.signature)
})
it('emits a pane candidate when a sibling shell PTY is live', () => {
expect(
planAgentHibernationCandidates(
@ -309,19 +390,6 @@ describe('agent sleep planner', () => {
).toEqual([`tab-1:${LEAF}`, `tab-1:${OTHER_LEAF}`])
})
it('requires two stable ticks and resets on signature changes', () => {
const [candidate] = planAgentHibernationCandidates(snapshot())
const first = confirmAgentHibernationCandidates({}, [candidate])
expect(first.candidates).toEqual([])
expect(
confirmAgentHibernationCandidates(first.confirmationState, [candidate]).candidates
).toEqual([candidate])
const changed = { ...candidate, signature: `${candidate.signature}:changed` }
expect(
confirmAgentHibernationCandidates(first.confirmationState, [changed]).candidates
).toEqual([])
})
it('clamps corrupt or out-of-range idle durations to the default', () => {
expect(getEffectiveAgentHibernationIdleMs(0)).toBe(DEFAULT_AGENT_HIBERNATION_IDLE_MS)
expect(getEffectiveAgentHibernationIdleMs(Number.NaN)).toBe(DEFAULT_AGENT_HIBERNATION_IDLE_MS)

View File

@ -15,7 +15,7 @@ export const MAX_AGENT_HIBERNATION_IDLE_MS = 24 * 60 * 60 * 1000
export type AgentHibernationPlannerSnapshot = {
settings: Pick<GlobalSettings, 'experimentalAgentHibernation' | 'agentHibernationIdleMs'> | null
activeWorktreeId: string | null
foregroundWorktreeIds: string[]
foregroundTerminalTabIds: string[]
tabsByWorktree: Record<string, TerminalTab[]>
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot | undefined>
ptyIdsByTabId: Record<string, string[] | undefined>
@ -25,6 +25,7 @@ export type AgentHibernationPlannerSnapshot = {
agentStatusByPaneKey: Record<string, AgentStatusEntry | undefined>
sleepingAgentSessionsByPaneKey: Record<string, SleepingAgentSessionRecord | undefined>
lastTerminalInputAtByPaneKey: Record<string, number | undefined>
foregroundTerminalLastSeenAtByTabId: Record<string, number | undefined>
now: number
}
@ -40,13 +41,6 @@ export type AgentHibernationCandidate = {
signature: string
}
export type AgentHibernationConfirmationState = Record<string, string>
export type AgentHibernationPlan = {
candidates: AgentHibernationCandidate[]
confirmationState: AgentHibernationConfirmationState
}
type EligiblePane = {
paneKey: string
tabId: string
@ -56,6 +50,7 @@ type EligiblePane = {
providerSessionId: string
state: AgentStatusEntry['state']
updatedAt: number
effectiveIdleStart: number
inputAt: number
}
@ -120,6 +115,7 @@ function getEligiblePane(args: {
livePtyIds: Set<string>
sleepingAgentSessionsByPaneKey: AgentHibernationPlannerSnapshot['sleepingAgentSessionsByPaneKey']
lastTerminalInputAtByPaneKey: AgentHibernationPlannerSnapshot['lastTerminalInputAtByPaneKey']
foregroundTerminalLastSeenAtByTabId: AgentHibernationPlannerSnapshot['foregroundTerminalLastSeenAtByTabId']
mobileLockedPtyIds: Set<string>
now: number
idleMs: number
@ -131,6 +127,7 @@ function getEligiblePane(args: {
livePtyIds,
sleepingAgentSessionsByPaneKey,
lastTerminalInputAtByPaneKey,
foregroundTerminalLastSeenAtByTabId,
mobileLockedPtyIds
} = args
if (
@ -152,7 +149,16 @@ function getEligiblePane(args: {
if (!getAgentResumeArgv(entry.agentType, entry.providerSession)) {
return null
}
if (args.now - entry.updatedAt < args.idleMs) {
// Why: returning to the containing terminal tab should restart sleep even
// without pane input; sibling tabs in the worktree should keep their age.
const foregroundLastSeenAt = foregroundTerminalLastSeenAtByTabId[tab.id]
const effectiveIdleStart = Math.max(
entry.updatedAt,
typeof foregroundLastSeenAt === 'number' && Number.isFinite(foregroundLastSeenAt)
? foregroundLastSeenAt
: 0
)
if (args.now - effectiveIdleStart < args.idleMs) {
return null
}
const inputAt = lastTerminalInputAtByPaneKey[entry.paneKey]
@ -177,6 +183,7 @@ function getEligiblePane(args: {
providerSessionId: entry.providerSession.id,
state: entry.state,
updatedAt: entry.updatedAt,
effectiveIdleStart,
inputAt: typeof inputAt === 'number' && Number.isFinite(inputAt) ? inputAt : 0
}
}
@ -187,7 +194,7 @@ function signatureFor(worktreeId: string, panes: EligiblePane[]): string {
.sort((a, b) => a.paneKey.localeCompare(b.paneKey))
.map(
(pane) =>
`${pane.paneKey}:${pane.ptyId}:${pane.runtimePtyId}:${pane.providerSessionId}:${pane.state}:${pane.updatedAt}:${pane.inputAt}`
`${pane.paneKey}:${pane.ptyId}:${pane.runtimePtyId}:${pane.providerSessionId}:${pane.state}:${pane.updatedAt}:${pane.effectiveIdleStart}:${pane.inputAt}`
)
return `${worktreeId}|${parts.join('|')}`
}
@ -226,19 +233,14 @@ export function planAgentHibernationCandidates(
}
const idleMs = getEffectiveAgentHibernationIdleMs(snapshot.settings.agentHibernationIdleMs)
const mobileLockedPtyIds = new Set(snapshot.mobileLockedPtyIds.map(toRuntimePtyId))
const foregroundWorktreeIds = new Set(snapshot.foregroundWorktreeIds)
const foregroundTerminalTabIds = new Set(snapshot.foregroundTerminalTabIds)
const runtimeLivenessRequiredWorktreeIds = new Set(
snapshot.runtimeLivenessRequiredWorktreeIds ?? []
)
const agentEntriesByTabId = getAgentEntriesByTabId(snapshot.agentStatusByPaneKey)
const candidates: AgentHibernationCandidate[] = []
for (const [worktreeId, tabs] of Object.entries(snapshot.tabsByWorktree)) {
if (
!worktreeId ||
worktreeId === snapshot.activeWorktreeId ||
foregroundWorktreeIds.has(worktreeId) ||
tabs.length === 0
) {
if (!worktreeId || worktreeId === snapshot.activeWorktreeId || tabs.length === 0) {
continue
}
if (
@ -251,6 +253,9 @@ export function planAgentHibernationCandidates(
continue
}
for (const tab of tabs) {
if (foregroundTerminalTabIds.has(tab.id)) {
continue
}
const tabLivePtyIds = getLivePtyIdsForTab(
tab,
snapshot.ptyIdsByTabId,
@ -269,6 +274,7 @@ export function planAgentHibernationCandidates(
livePtyIds: new Set(tabLivePtyIds),
sleepingAgentSessionsByPaneKey: snapshot.sleepingAgentSessionsByPaneKey,
lastTerminalInputAtByPaneKey: snapshot.lastTerminalInputAtByPaneKey,
foregroundTerminalLastSeenAtByTabId: snapshot.foregroundTerminalLastSeenAtByTabId,
mobileLockedPtyIds,
now: snapshot.now,
idleMs
@ -293,18 +299,3 @@ export function planAgentHibernationCandidates(
(a, b) => a.worktreeId.localeCompare(b.worktreeId) || a.paneKey.localeCompare(b.paneKey)
)
}
export function confirmAgentHibernationCandidates(
previous: AgentHibernationConfirmationState,
candidates: AgentHibernationCandidate[]
): AgentHibernationPlan {
const confirmationState: AgentHibernationConfirmationState = {}
const confirmed: AgentHibernationCandidate[] = []
for (const candidate of candidates) {
confirmationState[candidate.id] = candidate.signature
if (previous[candidate.id] === candidate.signature) {
confirmed.push(candidate)
}
}
return { candidates: confirmed, confirmationState }
}

View File

@ -0,0 +1,119 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
getForegroundTerminalTabIds,
getForegroundTerminalTabLastSeenAtById,
registerVisibleTerminalTab,
resetForegroundTerminalTabIdsForTests,
setForegroundTerminalTabIds
} from './foreground-terminal-tabs'
afterEach(() => {
resetForegroundTerminalTabIdsForTests()
vi.useRealTimers()
})
describe('foreground terminal tabs', () => {
it('returns the union of explicit foreground ids and visible terminal claims', () => {
setForegroundTerminalTabIds(['tab-explicit', null, '', undefined])
const unregister = registerVisibleTerminalTab('tab-visible')
expect(getForegroundTerminalTabIds().sort()).toEqual(['tab-explicit', 'tab-visible'])
unregister()
expect(getForegroundTerminalTabIds()).toEqual(['tab-explicit'])
})
it('keeps duplicate visible terminal tab claims until every token unregisters', () => {
const unregisterFirst = registerVisibleTerminalTab('tab-visible')
const unregisterSecond = registerVisibleTerminalTab('tab-visible')
expect(getForegroundTerminalTabIds()).toEqual(['tab-visible'])
unregisterFirst()
expect(getForegroundTerminalTabIds()).toEqual(['tab-visible'])
unregisterSecond()
expect(getForegroundTerminalTabIds()).toEqual([])
})
it('records last-seen timestamps for explicit foreground entries and clears them in tests', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
setForegroundTerminalTabIds(['tab-explicit', null, '', undefined])
expect(getForegroundTerminalTabLastSeenAtById()).toEqual({ 'tab-explicit': 1_000 })
resetForegroundTerminalTabIdsForTests()
expect(getForegroundTerminalTabLastSeenAtById()).toEqual({})
})
it('refreshes last-seen when explicit foreground ids leave the combined foreground set', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
setForegroundTerminalTabIds(['tab-old'])
vi.setSystemTime(2_000)
setForegroundTerminalTabIds(['tab-new'])
expect(getForegroundTerminalTabLastSeenAtById()).toEqual({
'tab-old': 2_000,
'tab-new': 2_000
})
})
it('does not refresh an explicit foreground removal while a visible claim remains', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
setForegroundTerminalTabIds(['tab-combined'])
vi.setSystemTime(2_000)
const unregister = registerVisibleTerminalTab('tab-combined')
vi.setSystemTime(3_000)
setForegroundTerminalTabIds([])
expect(getForegroundTerminalTabIds()).toEqual(['tab-combined'])
expect(getForegroundTerminalTabLastSeenAtById()).toEqual({ 'tab-combined': 2_000 })
vi.setSystemTime(4_000)
unregister()
expect(getForegroundTerminalTabLastSeenAtById()).toEqual({ 'tab-combined': 4_000 })
})
it('refreshes visible-claim last-seen only when the last claim leaves', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const unregisterFirst = registerVisibleTerminalTab('tab-visible')
vi.setSystemTime(2_000)
const unregisterSecond = registerVisibleTerminalTab('tab-visible')
vi.setSystemTime(3_000)
unregisterFirst()
expect(getForegroundTerminalTabIds()).toEqual(['tab-visible'])
expect(getForegroundTerminalTabLastSeenAtById()).toEqual({ 'tab-visible': 2_000 })
vi.setSystemTime(4_000)
unregisterSecond()
expect(getForegroundTerminalTabIds()).toEqual([])
expect(getForegroundTerminalTabLastSeenAtById()).toEqual({ 'tab-visible': 4_000 })
})
it('keeps visible-claim cleanup idempotent for last-seen timestamps', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const unregister = registerVisibleTerminalTab('tab-visible')
vi.setSystemTime(2_000)
unregister()
vi.setSystemTime(3_000)
unregister()
expect(getForegroundTerminalTabLastSeenAtById()).toEqual({ 'tab-visible': 2_000 })
})
})

View File

@ -0,0 +1,75 @@
let explicitForegroundTerminalTabIds = new Set<string>()
const visibleTerminalTabClaimsByToken = new Map<symbol, string>()
const foregroundTerminalTabLastSeenAtById = new Map<string, number>()
function normalizeTerminalTabIds(tabIds: Iterable<string | null | undefined>): Set<string> {
return new Set(
Array.from(tabIds).filter(
(tabId): tabId is string => typeof tabId === 'string' && tabId.length > 0
)
)
}
export function setForegroundTerminalTabIds(tabIds: Iterable<string | null | undefined>): void {
const previousForegroundTerminalTabIds = new Set(getForegroundTerminalTabIds())
explicitForegroundTerminalTabIds = normalizeTerminalTabIds(tabIds)
const now = Date.now()
for (const tabId of explicitForegroundTerminalTabIds) {
foregroundTerminalTabLastSeenAtById.set(tabId, now)
}
refreshExitedForegroundTerminalTabLastSeen(previousForegroundTerminalTabIds, now)
}
export function registerVisibleTerminalTab(tabId: string | null | undefined): () => void {
const normalized = normalizeTerminalTabIds([tabId])
const id = Array.from(normalized)[0]
if (!id) {
return () => {}
}
// Why: multiple visible panes can belong to one terminal tab; tokenized claims
// let each pane clean up without dropping sibling foreground protection.
const token = Symbol(id)
visibleTerminalTabClaimsByToken.set(token, id)
foregroundTerminalTabLastSeenAtById.set(id, Date.now())
return () => {
if (!visibleTerminalTabClaimsByToken.delete(token)) {
return
}
if (!getForegroundTerminalTabIds().includes(id)) {
// Why: keep the sleep timer anchored to the end of the full foreground visit.
foregroundTerminalTabLastSeenAtById.set(id, Date.now())
}
}
}
export function getForegroundTerminalTabIds(): string[] {
// Why: hibernation already reasons by terminal tab, so visible pane claims
// join the page-level foreground set instead of adding pane rules.
return Array.from(
new Set([...explicitForegroundTerminalTabIds, ...visibleTerminalTabClaimsByToken.values()])
)
}
export function getForegroundTerminalTabLastSeenAtById(): Record<string, number> {
return Object.fromEntries(foregroundTerminalTabLastSeenAtById)
}
export function resetForegroundTerminalTabIdsForTests(): void {
explicitForegroundTerminalTabIds = new Set()
visibleTerminalTabClaimsByToken.clear()
foregroundTerminalTabLastSeenAtById.clear()
}
function refreshExitedForegroundTerminalTabLastSeen(
previousForegroundTerminalTabIds: Set<string>,
now: number
): void {
const currentForegroundTerminalTabIds = new Set(getForegroundTerminalTabIds())
for (const tabId of previousForegroundTerminalTabIds) {
if (!currentForegroundTerminalTabIds.has(tabId)) {
// Why: visible panes can keep a terminal tab foreground after explicit ids change.
foregroundTerminalTabLastSeenAtById.set(tabId, now)
}
}
}

View File

@ -1,36 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
getForegroundTerminalWorktreeIds,
registerVisibleTerminalWorktree,
resetForegroundTerminalWorktreeIdsForTests,
setForegroundTerminalWorktreeIds
} from './foreground-terminal-worktrees'
afterEach(() => {
resetForegroundTerminalWorktreeIdsForTests()
})
describe('foreground terminal worktrees', () => {
it('returns the union of explicit foreground ids and visible terminal claims', () => {
setForegroundTerminalWorktreeIds(['wt-explicit', null, '', undefined])
const unregister = registerVisibleTerminalWorktree('wt-visible')
expect(getForegroundTerminalWorktreeIds().sort()).toEqual(['wt-explicit', 'wt-visible'])
unregister()
expect(getForegroundTerminalWorktreeIds()).toEqual(['wt-explicit'])
})
it('keeps duplicate visible worktree claims until every token unregisters', () => {
const unregisterFirst = registerVisibleTerminalWorktree('wt-visible')
const unregisterSecond = registerVisibleTerminalWorktree('wt-visible')
expect(getForegroundTerminalWorktreeIds()).toEqual(['wt-visible'])
unregisterFirst()
expect(getForegroundTerminalWorktreeIds()).toEqual(['wt-visible'])
unregisterSecond()
expect(getForegroundTerminalWorktreeIds()).toEqual([])
})
})

View File

@ -1,45 +0,0 @@
let explicitForegroundWorktreeIds = new Set<string>()
const visibleTerminalClaimsByToken = new Map<symbol, string>()
function normalizeWorktreeIds(worktreeIds: Iterable<string | null | undefined>): Set<string> {
return new Set(
Array.from(worktreeIds).filter(
(worktreeId): worktreeId is string => typeof worktreeId === 'string' && worktreeId.length > 0
)
)
}
export function setForegroundTerminalWorktreeIds(
worktreeIds: Iterable<string | null | undefined>
): void {
explicitForegroundWorktreeIds = normalizeWorktreeIds(worktreeIds)
}
export function registerVisibleTerminalWorktree(worktreeId: string | null | undefined): () => void {
const normalized = normalizeWorktreeIds([worktreeId])
const id = Array.from(normalized)[0]
if (!id) {
return () => {}
}
// Why: multiple visible panes can belong to one worktree; tokenized claims
// let each pane clean up without dropping sibling foreground protection.
const token = Symbol(id)
visibleTerminalClaimsByToken.set(token, id)
return () => {
visibleTerminalClaimsByToken.delete(token)
}
}
export function getForegroundTerminalWorktreeIds(): string[] {
// Why: hibernation already gates by foreground worktree, so visible pane
// claims join the page-level foreground set instead of adding pane rules.
return Array.from(
new Set([...explicitForegroundWorktreeIds, ...visibleTerminalClaimsByToken.values()])
)
}
export function resetForegroundTerminalWorktreeIdsForTests(): void {
explicitForegroundWorktreeIds = new Set()
visibleTerminalClaimsByToken.clear()
}