Keep completed-agent hibernation pane-scoped (#5749)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-18 21:37:15 -07:00 committed by GitHub
parent fc17631af3
commit 8c4e99ed2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1088 additions and 80 deletions

View File

@ -12949,6 +12949,80 @@ describe('OrcaRuntimeService', () => {
expect(stopped).toEqual([])
})
it('allows target-only exact terminal stop when sibling PTYs remain live', async () => {
const runtime = new OrcaRuntimeService(store)
const stopped: string[] = []
const processLists = [
[
{ id: 'pty-1', cwd: TEST_WORKTREE_PATH, title: 'Claude' },
{ id: 'pty-shell', cwd: TEST_WORKTREE_PATH, title: 'Shell' }
],
[{ id: 'pty-shell', cwd: TEST_WORKTREE_PATH, title: 'Shell' }]
]
runtime.setPtyController({
write: () => true,
kill: () => false,
stopAndWait: async (ptyId, opts) => {
stopped.push(ptyId)
expect(opts).toEqual({ keepHistory: true })
runtime.onPtyExit(ptyId, -1)
return true
},
getForegroundProcess: async () => null,
listProcesses: async () => processLists.shift() ?? []
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: TEST_WORKTREE_ID,
title: 'Claude',
activeLeafId: 'pane:1',
layout: null
},
{
tabId: 'tab-2',
worktreeId: TEST_WORKTREE_ID,
title: 'Shell',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: TEST_WORKTREE_ID,
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'pty-1'
},
{
tabId: 'tab-2',
worktreeId: TEST_WORKTREE_ID,
leafId: 'pane:1',
paneRuntimeId: 2,
ptyId: 'pty-shell'
}
]
})
await expect(
runtime.stopExactTerminalsForWorktree(`id:${TEST_WORKTREE_ID}`, ['pty-1'], {
keepHistory: true,
targetOnly: true
})
).resolves.toEqual({
stopped: 1,
stoppedPtyIds: ['pty-1'],
livePtyIds: ['pty-1', 'pty-shell'],
postStopVerified: true,
remainingLivePtyIds: ['pty-shell']
})
expect(stopped).toEqual(['pty-1'])
})
it('rejects exact terminal stop for multiple expected PTYs before stopping anything', async () => {
const runtime = new OrcaRuntimeService(store)
const stopped: string[] = []

View File

@ -13732,7 +13732,7 @@ export class OrcaRuntimeService {
async stopExactTerminalsForWorktree(
worktreeSelector: string,
expectedPtyIds: readonly string[],
opts: { keepHistory?: boolean } = {}
opts: { keepHistory?: boolean; targetOnly?: boolean } = {}
): Promise<{
stopped: number
stoppedPtyIds: string[]
@ -13741,8 +13741,8 @@ export class OrcaRuntimeService {
postStopFailure?: string
remainingLivePtyIds?: string[]
}> {
// Why: hibernation may commit sleeping state only after the runtime proves
// the selected PTYs are still the complete live set for this worktree.
// Why: worktree sleep needs proof of the complete live set; pane hibernation
// only needs proof that its target PTY was live and is now gone.
const graphEpoch = this.captureReadyGraphEpoch()
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
this.assertStableReadyGraph(graphEpoch)
@ -13757,7 +13757,9 @@ export class OrcaRuntimeService {
throw new Error('terminal_liveness_unavailable')
}
const livePtyIds = this.getLivePtyIdsForWorktree(worktree.id, refreshedPtyLiveness)
if (!setsEqual(livePtyIds, expected)) {
const targetOnly = opts.targetOnly === true
const expectedIsLive = [...expected].every((ptyId) => livePtyIds.has(ptyId))
if (targetOnly ? !expectedIsLive : !setsEqual(livePtyIds, expected)) {
const error = Object.assign(new Error('terminal_stop_pty_set_mismatch'), {
livePtyIds: [...livePtyIds].sort(),
expectedPtyIds: [...expected].sort()
@ -13787,7 +13789,8 @@ export class OrcaRuntimeService {
}
}
const remainingLivePtyIds = this.getLivePtyIdsForWorktree(worktree.id, postStopLiveness)
if (remainingLivePtyIds.size > 0) {
const stoppedTargetsStillLive = [...expected].filter((ptyId) => remainingLivePtyIds.has(ptyId))
if (targetOnly ? stoppedTargetsStillLive.length > 0 : remainingLivePtyIds.size > 0) {
return {
stopped: stoppedPtyIds.length,
stoppedPtyIds,
@ -13801,7 +13804,10 @@ export class OrcaRuntimeService {
stopped: stoppedPtyIds.length,
stoppedPtyIds,
livePtyIds: [...livePtyIds].sort(),
postStopVerified: true
postStopVerified: true,
...(targetOnly && remainingLivePtyIds.size > 0
? { remainingLivePtyIds: [...remainingLivePtyIds].sort() }
: {})
}
}

View File

@ -539,7 +539,8 @@ const TerminalStop = z.object({
const TerminalStopExact = TerminalStop.extend({
expectedPtyIds: z.array(requiredString('Missing PTY ID')).min(1),
keepHistory: z.boolean().optional()
keepHistory: z.boolean().optional(),
targetOnly: z.boolean().optional()
})
const AgentTeamsTmuxCompat = z.object({
@ -813,7 +814,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
params: TerminalStopExact,
handler: async (params, { runtime }) =>
runtime.stopExactTerminalsForWorktree(params.worktree, params.expectedPtyIds, {
keepHistory: params.keepHistory
keepHistory: params.keepHistory,
targetOnly: params.targetOnly
})
}),
defineMethod({

View File

@ -72,6 +72,13 @@ const ptyExitSidecars = new Map<string, Set<(code: number) => void>>()
export const ptyTeardownHandlers = new Map<string, () => void>()
let ptyDispatcherAttached = false
export type PtyDataHandlerShutdownSnapshot = {
ptyId: string
dataHandler?: (data: string, meta?: PtyDataMeta) => void
replayHandler?: (data: string) => void
teardownHandler?: () => void
}
/**
* Remove data and status handlers for the given PTY IDs so that any final
* data flushed by the main process during PTY teardown cannot trigger
@ -82,13 +89,37 @@ let ptyDispatcherAttached = false
* Exit handlers are intentionally kept alive so the normal exit-cleanup
* path (unregister, clear stale timers, update store) still runs.
*/
export function unregisterPtyDataHandlers(ptyIds: string[]): void {
export function unregisterPtyDataHandlers(ptyIds: string[]): PtyDataHandlerShutdownSnapshot[] {
const snapshots: PtyDataHandlerShutdownSnapshot[] = []
for (const id of ptyIds) {
snapshots.push({
ptyId: id,
dataHandler: ptyDataHandlers.get(id),
replayHandler: ptyReplayHandlers.get(id),
teardownHandler: ptyTeardownHandlers.get(id)
})
ptyDataHandlers.delete(id)
ptyReplayHandlers.delete(id)
ptyTeardownHandlers.get(id)?.()
ptyTeardownHandlers.delete(id)
}
return snapshots
}
export function restorePtyDataHandlersAfterFailedShutdown(
snapshots: readonly PtyDataHandlerShutdownSnapshot[]
): void {
for (const snapshot of snapshots) {
if (snapshot.dataHandler) {
ptyDataHandlers.set(snapshot.ptyId, snapshot.dataHandler)
}
if (snapshot.replayHandler) {
ptyReplayHandlers.set(snapshot.ptyId, snapshot.replayHandler)
}
if (snapshot.teardownHandler) {
ptyTeardownHandlers.set(snapshot.ptyId, snapshot.teardownHandler)
}
}
}
export function ensurePtyDispatcher(): void {

View File

@ -746,6 +746,27 @@ describe('createIpcPtyTransport', () => {
expect(onPtyExit).toHaveBeenCalledWith('pty-1')
})
it('restores data handlers when an intentional shutdown fails before exit', async () => {
const {
createIpcPtyTransport,
restorePtyDataHandlersAfterFailedShutdown,
unregisterPtyDataHandlers
} = await import('./pty-transport')
const onDataCallback = vi.fn()
const transport = createIpcPtyTransport()
await transport.connect({ url: '', callbacks: { onData: onDataCallback } })
const snapshots = unregisterPtyDataHandlers(['pty-1'])
onData?.({ id: 'pty-1', data: 'final burst while detached' })
expect(onDataCallback).not.toHaveBeenCalled()
restorePtyDataHandlersAfterFailedShutdown(snapshots)
onData?.({ id: 'pty-1', data: 'live again' })
expect(onDataCallback).toHaveBeenCalledWith('live again')
})
it('unregisterPtyDataHandlers cancels staleTitleTimer so it cannot fire stale idle transition', async () => {
vi.useFakeTimers()
try {

View File

@ -35,6 +35,7 @@ export {
ensurePtyDispatcher,
getEagerPtyBufferHandle,
registerEagerPtyBuffer,
restorePtyDataHandlersAfterFailedShutdown,
subscribeToPtyExit,
unregisterPtyDataHandlers
} from './pty-dispatcher'

View File

@ -71,9 +71,9 @@ function entry(): AgentStatusEntry {
}
function installEligibleState(
shutdownWorktreeTerminals = vi.fn(),
shutdownCompletedAgentPaneForHibernation = vi.fn(),
overrides: Partial<AppState> = {}
): typeof shutdownWorktreeTerminals {
): typeof shutdownCompletedAgentPaneForHibernation {
const e = entry()
useAppStore.setState({
settings: {
@ -87,10 +87,11 @@ function installEligibleState(
agentStatusByPaneKey: { [e.paneKey]: e },
sleepingAgentSessionsByPaneKey: {},
lastTerminalInputAtByPaneKey: {},
shutdownWorktreeTerminals: shutdownWorktreeTerminals as never,
shutdownCompletedAgentPaneForHibernation: shutdownCompletedAgentPaneForHibernation as never,
shutdownWorktreeTerminals: vi.fn() as never,
...overrides
})
return shutdownWorktreeTerminals
return shutdownCompletedAgentPaneForHibernation
}
function runtimeListResult(ptyIds: string[], truncated = false) {
@ -179,9 +180,29 @@ describe('agent hibernation coordinator', () => {
await vi.advanceTimersByTimeAsync(1000)
expect(shutdown).toHaveBeenCalledWith('wt-bg', {
keepIdentifiers: true,
shutdownReason: 'auto-hibernate-completed-agent',
sleepingPaneKeys: [`tab-1:${LEAF}`]
paneKey: `tab-1:${LEAF}`,
tabId: 'tab-1',
leafId: LEAF,
ptyId: 'pty-1'
})
expect(useAppStore.getState().shutdownWorktreeTerminals).not.toHaveBeenCalled()
})
it('hibernates an eligible pane when a sibling shell PTY is live', async () => {
vi.useFakeTimers()
const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), {
ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-shell'] }
})
startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW })
await vi.advanceTimersByTimeAsync(1000)
await vi.advanceTimersByTimeAsync(1000)
expect(shutdown).toHaveBeenCalledWith('wt-bg', {
paneKey: `tab-1:${LEAF}`,
tabId: 'tab-1',
leafId: LEAF,
ptyId: 'pty-1'
})
})
@ -318,10 +339,11 @@ describe('agent hibernation coordinator', () => {
await vi.advanceTimersByTimeAsync(1000)
expect(shutdown).toHaveBeenCalledWith('wt-bg', {
keepIdentifiers: true,
shutdownReason: 'auto-hibernate-completed-agent',
sleepingPaneKeys: [`tab-1:${LEAF}`],
expectedRuntimePtyIds: ['pty-1']
paneKey: `tab-1:${LEAF}`,
tabId: 'tab-1',
leafId: LEAF,
ptyId: 'pty-1',
expectedRuntimePtyId: 'pty-1'
})
expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith(
expect.objectContaining({
@ -336,7 +358,7 @@ describe('agent hibernation coordinator', () => {
installRuntimeListResponses(
runtimeListResult(['pty-1']),
runtimeListResult(['pty-1']),
runtimeListResult(['pty-1', 'pty-shell'])
runtimeListResult(['pty-shell'])
)
const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), {
settings: {
@ -405,9 +427,12 @@ describe('agent hibernation coordinator', () => {
expect(shutdown).not.toHaveBeenCalled()
})
it('skips runtime-backed candidates with multiple live PTYs', async () => {
it('hibernates runtime-backed candidates independently when siblings remain live', async () => {
vi.useFakeTimers()
installRuntimeListResponses(
runtimeListResult(['pty-1', 'pty-2']),
runtimeListResult(['pty-1', 'pty-2']),
runtimeListResult(['pty-1', 'pty-2']),
runtimeListResult(['pty-1', 'pty-2']),
runtimeListResult(['pty-1', 'pty-2'])
)
@ -440,7 +465,21 @@ describe('agent hibernation coordinator', () => {
await vi.advanceTimersByTimeAsync(1000)
await vi.advanceTimersByTimeAsync(1000)
expect(shutdown).not.toHaveBeenCalled()
expect(shutdown).toHaveBeenCalledTimes(2)
expect(shutdown).toHaveBeenCalledWith('wt-bg', {
paneKey: `tab-1:${LEAF}`,
tabId: 'tab-1',
leafId: LEAF,
ptyId: 'pty-1',
expectedRuntimePtyId: 'pty-1'
})
expect(shutdown).toHaveBeenCalledWith('wt-bg', {
paneKey: `tab-1:${secondLeaf}`,
tabId: 'tab-1',
leafId: secondLeaf,
ptyId: 'pty-2',
expectedRuntimePtyId: 'pty-2'
})
})
it('fails closed on truncated runtime liveness samples', async () => {

View File

@ -31,7 +31,7 @@ type AgentHibernationCoordinatorState = {
interval: IntervalHandle | null
confirmationState: AgentHibernationConfirmationState
tickInFlight: boolean
shuttingDownWorktreeIds: Set<string>
shuttingDownCandidateIds: Set<string>
now: () => number
}
@ -39,7 +39,7 @@ const coordinator: AgentHibernationCoordinatorState = {
interval: null,
confirmationState: {},
tickInFlight: false,
shuttingDownWorktreeIds: new Set(),
shuttingDownCandidateIds: new Set(),
now: () => Date.now()
}
@ -152,37 +152,38 @@ async function currentCandidates(now: number) {
}))
}
async function hibernateWorktreeIfStillEligible(
async function hibernatePaneIfStillEligible(
confirmedCandidate: AgentHibernationCandidate
): Promise<void> {
const { worktreeId } = confirmedCandidate
if (coordinator.shuttingDownWorktreeIds.has(worktreeId)) {
const { id, worktreeId } = confirmedCandidate
if (coordinator.shuttingDownCandidateIds.has(id)) {
return
}
const candidates = await currentCandidates(coordinator.now())
const stillEligible = candidates.some(
(candidate) =>
candidate.worktreeId === worktreeId && candidate.signature === confirmedCandidate.signature
candidate.id === confirmedCandidate.id && candidate.signature === confirmedCandidate.signature
)
if (!stillEligible) {
return
}
coordinator.shuttingDownWorktreeIds.add(worktreeId)
coordinator.shuttingDownCandidateIds.add(id)
try {
const state = useAppStore.getState()
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId)
await state.shutdownWorktreeTerminals(worktreeId, {
keepIdentifiers: true,
shutdownReason: 'auto-hibernate-completed-agent',
sleepingPaneKeys: confirmedCandidate.paneKeys,
await state.shutdownCompletedAgentPaneForHibernation(worktreeId, {
paneKey: confirmedCandidate.paneKey,
tabId: confirmedCandidate.tabId,
leafId: confirmedCandidate.leafId,
ptyId: confirmedCandidate.targetPtyIds[0],
...(runtimeEnvironmentId
? { expectedRuntimePtyIds: confirmedCandidate.expectedRuntimePtyIds }
? { expectedRuntimePtyId: confirmedCandidate.expectedRuntimePtyIds[0] }
: {})
})
} catch (err) {
console.warn('[agent-hibernation] failed to hibernate worktree:', worktreeId, err)
console.warn('[agent-hibernation] failed to hibernate agent pane:', id, err)
} finally {
coordinator.shuttingDownWorktreeIds.delete(worktreeId)
coordinator.shuttingDownCandidateIds.delete(id)
}
}
@ -198,7 +199,7 @@ export async function runAgentHibernationTick(): Promise<void> {
)
coordinator.confirmationState = plan.confirmationState
for (const candidate of plan.candidates) {
void hibernateWorktreeIfStillEligible(candidate)
void hibernatePaneIfStillEligible(candidate)
}
} finally {
coordinator.tickInFlight = false
@ -231,7 +232,7 @@ export function isAgentHibernationCoordinatorRunning(): boolean {
export function resetAgentHibernationCoordinatorForTests(): void {
stopAgentHibernationCoordinator()
coordinator.shuttingDownWorktreeIds.clear()
coordinator.shuttingDownCandidateIds.clear()
coordinator.tickInFlight = false
coordinator.now = () => Date.now()
}

View File

@ -82,6 +82,10 @@ function plannedWorktrees(input: AgentHibernationPlannerSnapshot): string[] {
return planAgentHibernationCandidates(input).map((candidate) => candidate.worktreeId)
}
function plannedPaneKeys(input: AgentHibernationPlannerSnapshot): string[] {
return planAgentHibernationCandidates(input).map((candidate) => candidate.paneKey)
}
describe('agent hibernation planner', () => {
it('selects nothing when disabled, active, or foreground', () => {
expect(
@ -128,10 +132,26 @@ describe('agent hibernation planner', () => {
).toEqual(['wt-bg'])
})
it('rejects untracked live PTYs and already-sleeping panes', () => {
it('emits a pane candidate when a sibling shell PTY is live', () => {
expect(
plannedWorktrees(snapshot({ ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-shell'] } }))
).toEqual([])
planAgentHibernationCandidates(
snapshot({ ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-shell'] } })
)
).toMatchObject([
{
id: `wt-bg|tab-1:${LEAF}`,
worktreeId: 'wt-bg',
paneKey: `tab-1:${LEAF}`,
tabId: 'tab-1',
leafId: LEAF,
targetPtyIds: ['pty-1'],
expectedRuntimePtyIds: ['pty-1'],
paneKeys: [`tab-1:${LEAF}`]
}
])
})
it('rejects panes without live PTYs and already-sleeping panes', () => {
expect(plannedWorktrees(snapshot({ ptyIdsByTabId: { 'tab-1': [] } }))).toEqual([])
expect(
plannedWorktrees(
@ -144,6 +164,17 @@ describe('agent hibernation planner', () => {
expect(plannedWorktrees(snapshot({ mobileLockedPtyIds: ['pty-1'] }))).toEqual([])
})
it('does not let a mobile-locked sibling PTY block an unlocked target pane', () => {
expect(
plannedPaneKeys(
snapshot({
ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-shell'] },
mobileLockedPtyIds: ['pty-shell']
})
)
).toEqual([`tab-1:${LEAF}`])
})
it('selects runtime-backed live PTYs when the renderer live map is empty', () => {
const [candidate] = planAgentHibernationCandidates(
snapshot({
@ -206,16 +237,16 @@ describe('agent hibernation planner', () => {
).toEqual([])
})
it('rejects runtime-backed worktrees with extra unknown live PTYs', () => {
it('allows runtime-backed worktrees with sibling live PTYs', () => {
expect(
plannedWorktrees(
plannedPaneKeys(
snapshot({
ptyIdsByTabId: { 'tab-1': [] },
runtimeLivePtyIdsByWorktreeId: { 'wt-bg': ['pty-1', 'pty-shell'] },
runtimeLivenessRequiredWorktreeIds: ['wt-bg']
})
)
).toEqual([])
).toEqual([`tab-1:${LEAF}`])
})
it('applies mobile locks to runtime-backed PTYs', () => {
@ -245,14 +276,14 @@ describe('agent hibernation planner', () => {
).toEqual([])
})
it('selects a worktree when all live PTYs are eligible done agents', () => {
it('selects each eligible done agent pane independently', () => {
expect(plannedWorktrees(snapshot())).toEqual(['wt-bg'])
const second = entry({
paneKey: `tab-1:${OTHER_LEAF}`,
providerSession: { key: 'session_id', id: 'session-2' }
})
expect(
plannedWorktrees(
plannedPaneKeys(
snapshot({
terminalLayoutsByTabId: {
'tab-1': {
@ -271,7 +302,7 @@ describe('agent hibernation planner', () => {
agentStatusByPaneKey: { [`tab-1:${LEAF}`]: entry(), [second.paneKey]: second }
})
)
).toEqual(['wt-bg'])
).toEqual([`tab-1:${LEAF}`, `tab-1:${OTHER_LEAF}`])
})
it('requires two stable ticks and resets on signature changes', () => {

View File

@ -29,8 +29,13 @@ export type AgentHibernationPlannerSnapshot = {
}
export type AgentHibernationCandidate = {
id: string
worktreeId: string
paneKey: string
tabId: string
leafId: string
paneKeys: string[]
targetPtyIds: string[]
expectedRuntimePtyIds: string[]
signature: string
}
@ -44,6 +49,8 @@ export type AgentHibernationPlan = {
type EligiblePane = {
paneKey: string
tabId: string
leafId: string
ptyId: string
runtimePtyId: string
providerSessionId: string
@ -90,12 +97,13 @@ function getLivePtyIdsForTab(
function getPaneLivePtyId(
entry: AgentStatusEntry,
layout: TerminalLayoutSnapshot | undefined
): string | null {
): { leafId: string; ptyId: string } | null {
const parsed = parsePaneKey(entry.paneKey)
if (!parsed || parsed.tabId !== entry.tabId) {
if (!parsed || (entry.tabId && parsed.tabId !== entry.tabId)) {
return null
}
return layout?.ptyIdsByLeafId?.[parsed.leafId] ?? null
const ptyId = layout?.ptyIdsByLeafId?.[parsed.leafId]
return ptyId ? { leafId: parsed.leafId, ptyId } : null
}
function getEntryTabId(entry: AgentStatusEntry): string | null {
@ -112,6 +120,7 @@ function getEligiblePane(args: {
livePtyIds: Set<string>
sleepingAgentSessionsByPaneKey: AgentHibernationPlannerSnapshot['sleepingAgentSessionsByPaneKey']
lastTerminalInputAtByPaneKey: AgentHibernationPlannerSnapshot['lastTerminalInputAtByPaneKey']
mobileLockedPtyIds: Set<string>
now: number
idleMs: number
}): EligiblePane | null {
@ -121,7 +130,8 @@ function getEligiblePane(args: {
layout,
livePtyIds,
sleepingAgentSessionsByPaneKey,
lastTerminalInputAtByPaneKey
lastTerminalInputAtByPaneKey,
mobileLockedPtyIds
} = args
if (entry.state !== 'done' || sleepingAgentSessionsByPaneKey[entry.paneKey]) {
return null
@ -145,16 +155,19 @@ function getEligiblePane(args: {
if (typeof inputAt === 'number' && Number.isFinite(inputAt) && inputAt > entry.updatedAt) {
return null
}
const ptyId = getPaneLivePtyId(entry, layout)
if (!ptyId) {
const livePane = getPaneLivePtyId(entry, layout)
if (!livePane) {
return null
}
const { leafId, ptyId } = livePane
const runtimePtyId = toRuntimePtyId(ptyId)
if (!livePtyIds.has(runtimePtyId)) {
if (!livePtyIds.has(runtimePtyId) || mobileLockedPtyIds.has(runtimePtyId)) {
return null
}
return {
paneKey: entry.paneKey,
tabId: tab.id,
leafId,
ptyId,
runtimePtyId,
providerSessionId: entry.providerSession.id,
@ -175,6 +188,10 @@ function signatureFor(worktreeId: string, panes: EligiblePane[]): string {
return `${worktreeId}|${parts.join('|')}`
}
function candidateIdFor(worktreeId: string, paneKey: string): string {
return `${worktreeId}|${paneKey}`
}
function getAgentEntriesByTabId(
agentStatusByPaneKey: AgentHibernationPlannerSnapshot['agentStatusByPaneKey']
): Map<string, AgentStatusEntry[]> {
@ -229,9 +246,6 @@ export function planAgentHibernationCandidates(
) {
continue
}
const livePtyIds = new Set<string>()
const eligibleByPtyId = new Map<string, EligiblePane>()
let rejected = false
for (const tab of tabs) {
const tabLivePtyIds = getLivePtyIdsForTab(
tab,
@ -239,12 +253,6 @@ export function planAgentHibernationCandidates(
snapshot.runtimeLivePtyIdsByWorktreeId,
runtimeLivenessRequiredWorktreeIds.has(worktreeId)
)
for (const ptyId of tabLivePtyIds) {
livePtyIds.add(ptyId)
}
if (tabLivePtyIds.some((ptyId) => mobileLockedPtyIds.has(ptyId))) {
rejected = true
}
if (tabLivePtyIds.length === 0) {
continue
}
@ -257,28 +265,29 @@ export function planAgentHibernationCandidates(
livePtyIds: new Set(tabLivePtyIds),
sleepingAgentSessionsByPaneKey: snapshot.sleepingAgentSessionsByPaneKey,
lastTerminalInputAtByPaneKey: snapshot.lastTerminalInputAtByPaneKey,
mobileLockedPtyIds,
now: snapshot.now,
idleMs
})
if (eligible) {
eligibleByPtyId.set(eligible.runtimePtyId, eligible)
} else if (entry.state !== 'done' || getPaneLivePtyId(entry, layout)) {
rejected = true
candidates.push({
id: candidateIdFor(worktreeId, eligible.paneKey),
worktreeId,
paneKey: eligible.paneKey,
tabId: eligible.tabId,
leafId: eligible.leafId,
paneKeys: [eligible.paneKey],
targetPtyIds: [eligible.ptyId],
expectedRuntimePtyIds: [eligible.runtimePtyId],
signature: signatureFor(worktreeId, [eligible])
})
}
}
}
if (rejected || livePtyIds.size === 0 || eligibleByPtyId.size !== livePtyIds.size) {
continue
}
const panes = [...eligibleByPtyId.values()]
candidates.push({
worktreeId,
paneKeys: panes.map((pane) => pane.paneKey).sort(),
expectedRuntimePtyIds: [...livePtyIds].sort(),
signature: signatureFor(worktreeId, panes)
})
}
return candidates.sort((a, b) => a.worktreeId.localeCompare(b.worktreeId))
return candidates.sort(
(a, b) => a.worktreeId.localeCompare(b.worktreeId) || a.paneKey.localeCompare(b.paneKey)
)
}
export function confirmAgentHibernationCandidates(
@ -288,8 +297,8 @@ export function confirmAgentHibernationCandidates(
const confirmationState: AgentHibernationConfirmationState = {}
const confirmed: AgentHibernationCandidate[] = []
for (const candidate of candidates) {
confirmationState[candidate.worktreeId] = candidate.signature
if (previous[candidate.worktreeId] === candidate.signature) {
confirmationState[candidate.id] = candidate.signature
if (previous[candidate.id] === candidate.signature) {
confirmed.push(candidate)
}
}

View File

@ -52,6 +52,10 @@ type DropAgentStatusByWorktreeOptions = {
retainedCompletionEvidence?: readonly RetainedAgentEntry[]
}
type DropHibernatedAgentPaneOptions = {
retainedCompletionEvidence?: readonly RetainedAgentEntry[]
}
export type AgentStatusSlice = {
/** Explicit agent status entries keyed by `${tabId}:${leafId}` composite.
* Real-time only lives in renderer memory, not persisted to disk. */
@ -114,6 +118,14 @@ export type AgentStatusSlice = {
* remaining agent rows (live or retained) must not reappear. */
dropAgentStatusByTabPrefix: (tabIdPrefix: string) => void
/** Remove one automatically hibernated completed-agent pane while preserving
* sibling live/retained rows in the same worktree. */
dropHibernatedAgentStatusPane: (
worktreeId: string,
paneKey: string,
opts?: DropHibernatedAgentPaneOptions
) => void
/** Remove all entries for a worktree AND suppress re-retention for live rows.
* Used on worktree sleep/remove the whole worktree surface is folding, so
* retained rows must drop even if their original tab is no longer present.
@ -1111,6 +1123,96 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
}
},
dropHibernatedAgentStatusPane: (worktreeId, paneKey, opts) => {
let hadLive = false
set((s) => {
const liveEntry = s.agentStatusByPaneKey[paneKey]
const hasLive = liveEntry !== undefined
const hasRetained = paneKey in s.retainedAgentsByPaneKey
const migrationUnsupported = pruneMigrationUnsupportedEntries(
s.migrationUnsupportedByPtyId,
(entry) => entry.paneKey === paneKey
)
const retainedEvidence = new Map<string, RetainedAgentEntry>()
for (const retained of opts?.retainedCompletionEvidence ?? []) {
if (
retained.entry.paneKey === paneKey &&
!liveEntry &&
shouldReplaceRetainedWithLive(retainedEvidence.get(paneKey), retained)
) {
retainedEvidence.set(paneKey, retained)
}
}
if (
liveEntry?.state === 'done' &&
liveEntry.agentType !== undefined &&
liveEntry.interrupted !== true
) {
retainedEvidence.set(
paneKey,
retainedAgentEntryFromLive(s, worktreeId, liveEntry, liveEntry.agentType)
)
}
const keepsCompletionEvidence = retainedEvidence.has(paneKey)
let nextAck = s.acknowledgedAgentsByPaneKey
if (!keepsCompletionEvidence && paneKey in nextAck) {
nextAck = { ...nextAck }
delete nextAck[paneKey]
}
if (!hasLive && !hasRetained && !migrationUnsupported.changed && !keepsCompletionEvidence) {
if (nextAck !== s.acknowledgedAgentsByPaneKey) {
return { acknowledgedAgentsByPaneKey: nextAck }
}
return s
}
hadLive = hasLive
const nextLive = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey
if (hasLive) {
delete nextLive[paneKey]
}
const nextRetained =
hasRetained || keepsCompletionEvidence
? { ...s.retainedAgentsByPaneKey }
: s.retainedAgentsByPaneKey
if (hasRetained && !keepsCompletionEvidence) {
delete nextRetained[paneKey]
}
for (const [key, retained] of retainedEvidence) {
if (shouldReplaceRetainedWithLive(nextRetained[key], retained)) {
nextRetained[key] = retained
}
}
const needsSuppressor =
hasLive && !keepsCompletionEvidence && !(paneKey in s.retentionSuppressedPaneKeys)
return {
agentStatusByPaneKey: nextLive,
retainedAgentsByPaneKey: nextRetained,
migrationUnsupportedByPtyId: migrationUnsupported.next,
...(nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
: {}),
...(needsSuppressor
? {
retentionSuppressedPaneKeys: {
...s.retentionSuppressedPaneKeys,
[paneKey]: true
}
}
: {}),
agentStatusEpoch:
hasLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch,
sortEpoch: hasLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch
}
})
if (hadLive) {
queueMicrotask(() => freshness.schedule())
}
},
dropAgentStatusByWorktree: (worktreeId, opts) => {
let hadLive = false
set((s) => {

View File

@ -8,6 +8,7 @@ import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rp
import { toast } from 'sonner'
const mockUnregisterPtyDataHandlers = vi.hoisted(() => vi.fn())
const mockRestorePtyDataHandlersAfterFailedShutdown = vi.hoisted(() => vi.fn())
// Mock sonner (imported by repos.ts)
vi.mock('sonner', () => ({
@ -15,6 +16,7 @@ vi.mock('sonner', () => ({
}))
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
restorePtyDataHandlersAfterFailedShutdown: mockRestorePtyDataHandlersAfterFailedShutdown,
unregisterPtyDataHandlers: mockUnregisterPtyDataHandlers
}))
@ -2627,6 +2629,481 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
shutdownBufferCaptures.clear()
})
it('automatically hibernates only the completed agent pane and preserves siblings', async () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
const targetLeaf = '11111111-1111-4111-8111-111111111111'
const siblingLeaf = '22222222-2222-4222-8222-222222222222'
const targetPaneKey = `tab-1:${targetLeaf}`
const siblingPaneKey = `tab-1:${siblingLeaf}`
const dropByWorktree = vi.fn()
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex', ptyId: 'pty-agent' })]
},
terminalLayoutsByTabId: {
'tab-1': {
root: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: targetLeaf },
second: { type: 'leaf', leafId: siblingLeaf }
},
activeLeafId: siblingLeaf,
expandedLeafId: null,
ptyIdsByLeafId: { [targetLeaf]: 'pty-agent', [siblingLeaf]: 'pty-shell' }
}
},
ptyIdsByTabId: { 'tab-1': ['pty-agent', 'pty-shell'] },
unreadTerminalTabs: { 'tab-1': true },
unreadTerminalPanes: { [targetPaneKey]: true, [siblingPaneKey]: true },
unreadAgentCompletionPanes: { [targetPaneKey]: true, [siblingPaneKey]: true },
lastTerminalInputAtByPaneKey: { [targetPaneKey]: 1000, [siblingPaneKey]: 1100 },
pendingSetupSplitByTabId: { 'tab-1': { command: 'setup', direction: 'horizontal' } },
pendingIssueCommandSplitByTabId: { 'tab-1': { command: 'issue' } }
})
store.setState({ dropAgentStatusByWorktree: dropByWorktree as never })
store.getState().setAgentStatus(
targetPaneKey,
{
state: 'done',
prompt: 'resume target',
agentType: 'codex',
lastAssistantMessage: 'done'
},
'Codex',
{ updatedAt: 2000, stateStartedAt: 1000 },
{ tabId: 'tab-1', worktreeId: wt },
{ providerSession: { key: 'session_id', id: 'target-session' } }
)
store
.getState()
.setAgentStatus(
siblingPaneKey,
{ state: 'working', prompt: 'keep running', agentType: 'claude' },
'Claude',
{ updatedAt: 2100, stateStartedAt: 2100 },
{ tabId: 'tab-1', worktreeId: wt },
{ providerSession: { key: 'session_id', id: 'sibling-session' } }
)
const siblingSleepingRecordBefore =
store.getState().sleepingAgentSessionsByPaneKey[siblingPaneKey]
await store.getState().shutdownCompletedAgentPaneForHibernation(wt, {
paneKey: targetPaneKey,
tabId: 'tab-1',
leafId: targetLeaf,
ptyId: 'pty-agent'
})
const state = store.getState()
expect(mockApi.pty.kill).toHaveBeenCalledWith('pty-agent', { keepHistory: true })
expect(mockApi.pty.kill).not.toHaveBeenCalledWith('pty-shell', expect.anything())
expect(mockUnregisterPtyDataHandlers).toHaveBeenCalledWith(['pty-agent'])
expect(mockUnregisterPtyDataHandlers.mock.invocationCallOrder[0]).toBeLessThan(
mockApi.pty.kill.mock.invocationCallOrder[0]
)
expect(state.ptyIdsByTabId['tab-1']).toEqual(['pty-shell'])
expect(state.tabsByWorktree[wt]?.[0]?.ptyId).toBe('pty-shell')
expect(state.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({
[targetLeaf]: 'pty-agent',
[siblingLeaf]: 'pty-shell'
})
expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toMatchObject({
providerSession: { key: 'session_id', id: 'target-session' }
})
expect(state.sleepingAgentSessionsByPaneKey[siblingPaneKey]).toBe(siblingSleepingRecordBefore)
expect(state.agentStatusByPaneKey[targetPaneKey]).toBeUndefined()
expect(state.agentStatusByPaneKey[siblingPaneKey]).toBeDefined()
expect(state.retainedAgentsByPaneKey[targetPaneKey]).toMatchObject({
entry: { lastAssistantMessage: 'done' }
})
expect(state.unreadTerminalTabs['tab-1']).toBe(true)
expect(state.unreadTerminalPanes[targetPaneKey]).toBeUndefined()
expect(state.unreadTerminalPanes[siblingPaneKey]).toBe(true)
expect(state.unreadAgentCompletionPanes[targetPaneKey]).toBeUndefined()
expect(state.unreadAgentCompletionPanes[siblingPaneKey]).toBe(true)
expect(state.lastTerminalInputAtByPaneKey[targetPaneKey]).toBeUndefined()
expect(state.lastTerminalInputAtByPaneKey[siblingPaneKey]).toBe(1100)
expect(state.pendingSetupSplitByTabId['tab-1']).toBeDefined()
expect(state.pendingIssueCommandSplitByTabId['tab-1']).toBeDefined()
expect(dropByWorktree).not.toHaveBeenCalled()
})
it('keeps manual sleep worktree-wide', async () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Terminal', ptyId: 'pty-agent' })]
},
ptyIdsByTabId: { 'tab-1': ['pty-agent', 'pty-shell'] }
})
await store.getState().shutdownWorktreeTerminals(wt, { keepIdentifiers: true })
expect(store.getState().ptyIdsByTabId['tab-1']).toEqual([])
expect(mockApi.pty.kill).toHaveBeenCalledWith('pty-agent', { keepHistory: true })
expect(mockApi.pty.kill).toHaveBeenCalledWith('pty-shell', { keepHistory: true })
})
it('does not commit pane sleep state when local target kill fails', async () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
const targetLeaf = '11111111-1111-4111-8111-111111111111'
const siblingLeaf = '22222222-2222-4222-8222-222222222222'
const targetPaneKey = `tab-1:${targetLeaf}`
const handlerSnapshots = [{ ptyId: 'pty-agent' }]
mockApi.pty.kill.mockRejectedValueOnce(new Error('kill failed'))
mockUnregisterPtyDataHandlers.mockReturnValueOnce(handlerSnapshots)
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex', ptyId: 'pty-agent' })]
},
terminalLayoutsByTabId: {
'tab-1': {
root: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: targetLeaf },
second: { type: 'leaf', leafId: siblingLeaf }
},
activeLeafId: siblingLeaf,
expandedLeafId: null,
ptyIdsByLeafId: { [targetLeaf]: 'pty-agent', [siblingLeaf]: 'pty-shell' }
}
},
ptyIdsByTabId: { 'tab-1': ['pty-agent', 'pty-shell'] }
})
store
.getState()
.setAgentStatus(
targetPaneKey,
{ state: 'done', prompt: 'resume target', agentType: 'codex' },
'Codex',
{ updatedAt: 2000, stateStartedAt: 1000 },
{ tabId: 'tab-1', worktreeId: wt },
{ providerSession: { key: 'session_id', id: 'target-session' } }
)
await expect(
store.getState().shutdownCompletedAgentPaneForHibernation(wt, {
paneKey: targetPaneKey,
tabId: 'tab-1',
leafId: targetLeaf,
ptyId: 'pty-agent'
})
).rejects.toThrow('kill failed')
const state = store.getState()
expect(state.ptyIdsByTabId['tab-1']).toEqual(['pty-agent', 'pty-shell'])
expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toBeUndefined()
expect(state.agentStatusByPaneKey[targetPaneKey]).toBeDefined()
expect(state.suppressedPtyExitIds['pty-agent']).toBeUndefined()
expect(mockRestorePtyDataHandlersAfterFailedShutdown).toHaveBeenCalledWith(handlerSnapshots)
})
it('uses target-only runtime stop for automatic pane hibernation', async () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
const targetLeaf = '11111111-1111-4111-8111-111111111111'
const siblingLeaf = '22222222-2222-4222-8222-222222222222'
const targetPaneKey = `tab-1:${targetLeaf}`
mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) =>
Promise.resolve(
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? {
id: 'rpc-default',
ok: true,
result:
args.method === 'terminal.stopExact'
? {
stoppedPtyIds: ['terminal-1'],
livePtyIds: ['terminal-1', 'terminal-2'],
postStopVerified: true,
remainingLivePtyIds: ['terminal-2']
}
: {},
_meta: { runtimeId: 'remote-runtime' }
}
)
)
seedStore(store, {
settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' },
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })]
},
terminalLayoutsByTabId: {
'tab-1': {
root: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: targetLeaf },
second: { type: 'leaf', leafId: siblingLeaf }
},
activeLeafId: siblingLeaf,
expandedLeafId: null,
ptyIdsByLeafId: {
[targetLeaf]: 'remote:env-1@@terminal-1',
[siblingLeaf]: 'remote:env-1@@terminal-2'
}
}
},
ptyIdsByTabId: {
'tab-1': ['remote:env-1@@terminal-1', 'remote:env-1@@terminal-2']
}
})
store
.getState()
.setAgentStatus(
targetPaneKey,
{ state: 'done', prompt: 'resume target', agentType: 'codex' },
'Codex',
{ updatedAt: 2000, stateStartedAt: 1000 },
{ tabId: 'tab-1', worktreeId: wt },
{ providerSession: { key: 'session_id', id: 'target-session' } }
)
await store.getState().shutdownCompletedAgentPaneForHibernation(wt, {
paneKey: targetPaneKey,
tabId: 'tab-1',
leafId: targetLeaf,
ptyId: 'remote:env-1@@terminal-1',
expectedRuntimePtyId: 'terminal-1'
})
expect(mockApi.runtimeEnvironments.call).toHaveBeenCalledWith(
expect.objectContaining({
selector: 'runtime-1',
method: 'terminal.stopExact',
params: expect.objectContaining({
expectedPtyIds: ['terminal-1'],
keepHistory: true,
targetOnly: true
})
})
)
expect(mockApi.runtimeEnvironments.call).not.toHaveBeenCalledWith(
expect.objectContaining({ method: 'terminal.stop' })
)
expect(store.getState().ptyIdsByTabId['tab-1']).toEqual(['remote:env-1@@terminal-2'])
expect(mockApi.pty.kill).not.toHaveBeenCalled()
})
it('clears stale relay wake hints when pane hibernation leaves no live PTYs in the tab', async () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
const targetLeaf = '11111111-1111-4111-8111-111111111111'
const targetPaneKey = `tab-1:${targetLeaf}`
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [
makeTab({
id: 'tab-1',
worktreeId: wt,
title: 'Codex',
ptyId: 'ssh:ssh-1@@pty-agent'
})
]
},
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: targetLeaf },
activeLeafId: targetLeaf,
expandedLeafId: null,
ptyIdsByLeafId: { [targetLeaf]: 'ssh:ssh-1@@pty-agent' }
}
},
ptyIdsByTabId: { 'tab-1': ['ssh:ssh-1@@pty-agent'] },
lastKnownRelayPtyIdByTabId: { 'tab-1': 'ssh:ssh-1@@pty-agent' }
})
store
.getState()
.setAgentStatus(
targetPaneKey,
{ state: 'done', prompt: 'resume target', agentType: 'codex' },
'Codex',
{ updatedAt: 2000, stateStartedAt: 1000 },
{ tabId: 'tab-1', worktreeId: wt },
{ providerSession: { key: 'session_id', id: 'target-session' } }
)
await store.getState().shutdownCompletedAgentPaneForHibernation(wt, {
paneKey: targetPaneKey,
tabId: 'tab-1',
leafId: targetLeaf,
ptyId: 'ssh:ssh-1@@pty-agent'
})
expect(store.getState().ptyIdsByTabId['tab-1']).toEqual([])
expect(store.getState().lastKnownRelayPtyIdByTabId['tab-1']).toBeUndefined()
})
it('does not retain stale completion evidence when pane status changes during hibernation', async () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
const targetLeaf = '11111111-1111-4111-8111-111111111111'
const targetPaneKey = `tab-1:${targetLeaf}`
mockApi.pty.kill.mockImplementationOnce(async () => {
store
.getState()
.setAgentStatus(
targetPaneKey,
{ state: 'working', prompt: 'still running', agentType: 'codex' },
'Codex',
{ updatedAt: 3000, stateStartedAt: 3000 },
{ tabId: 'tab-1', worktreeId: wt },
{ providerSession: { key: 'session_id', id: 'target-session' } }
)
})
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex', ptyId: 'pty-agent' })]
},
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: targetLeaf },
activeLeafId: targetLeaf,
expandedLeafId: null,
ptyIdsByLeafId: { [targetLeaf]: 'pty-agent' }
}
},
ptyIdsByTabId: { 'tab-1': ['pty-agent'] }
})
store.getState().setAgentStatus(
targetPaneKey,
{
state: 'done',
prompt: 'stale done',
agentType: 'codex',
lastAssistantMessage: 'old done'
},
'Codex',
{ updatedAt: 2000, stateStartedAt: 1000 },
{ tabId: 'tab-1', worktreeId: wt },
{ providerSession: { key: 'session_id', id: 'target-session' } }
)
await store.getState().shutdownCompletedAgentPaneForHibernation(wt, {
paneKey: targetPaneKey,
tabId: 'tab-1',
leafId: targetLeaf,
ptyId: 'pty-agent'
})
expect(store.getState().agentStatusByPaneKey[targetPaneKey]).toBeUndefined()
expect(store.getState().retainedAgentsByPaneKey[targetPaneKey]).toBeUndefined()
expect(store.getState().retentionSuppressedPaneKeys[targetPaneKey]).toBe(true)
})
it('rolls back target suppressions when target-only runtime stop fails', async () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
const targetLeaf = '11111111-1111-4111-8111-111111111111'
const siblingLeaf = '22222222-2222-4222-8222-222222222222'
const targetPaneKey = `tab-1:${targetLeaf}`
mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) =>
Promise.resolve(
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? {
id: 'rpc-default',
ok: true,
result:
args.method === 'terminal.stopExact'
? {
stoppedPtyIds: ['terminal-1'],
livePtyIds: ['terminal-1', 'terminal-2'],
postStopVerified: false,
postStopFailure: 'target_still_live'
}
: {},
_meta: { runtimeId: 'remote-runtime' }
}
)
)
seedStore(store, {
settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' },
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })]
},
terminalLayoutsByTabId: {
'tab-1': {
root: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: targetLeaf },
second: { type: 'leaf', leafId: siblingLeaf }
},
activeLeafId: siblingLeaf,
expandedLeafId: null,
ptyIdsByLeafId: {
[targetLeaf]: 'remote:env-1@@terminal-1',
[siblingLeaf]: 'remote:env-1@@terminal-2'
}
}
},
ptyIdsByTabId: {
'tab-1': ['remote:env-1@@terminal-1', 'remote:env-1@@terminal-2']
}
})
store
.getState()
.setAgentStatus(
targetPaneKey,
{ state: 'done', prompt: 'resume target', agentType: 'codex' },
'Codex',
{ updatedAt: 2000, stateStartedAt: 1000 },
{ tabId: 'tab-1', worktreeId: wt },
{ providerSession: { key: 'session_id', id: 'target-session' } }
)
await expect(
store.getState().shutdownCompletedAgentPaneForHibernation(wt, {
paneKey: targetPaneKey,
tabId: 'tab-1',
leafId: targetLeaf,
ptyId: 'remote:env-1@@terminal-1',
expectedRuntimePtyId: 'terminal-1'
})
).rejects.toThrow('target_still_live')
const state = store.getState()
expect(state.ptyIdsByTabId['tab-1']).toEqual([
'remote:env-1@@terminal-1',
'remote:env-1@@terminal-2'
])
expect(state.suppressedPtyExitIds['remote:env-1@@terminal-1']).toBeUndefined()
expect(state.suppressedPtyExitIds['terminal-1']).toBeUndefined()
expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toBeUndefined()
expect(state.agentStatusByPaneKey[targetPaneKey]).toBeDefined()
})
it('records terminal input even before agent hibernation is enabled', () => {
const store = createTestStore()

View File

@ -40,6 +40,7 @@ import {
} from './tab-group-state'
import {
ensurePtyDispatcher,
restorePtyDataHandlersAfterFailedShutdown,
unregisterPtyDataHandlers
} from '@/components/terminal-pane/pty-transport'
import { normalizeTerminalLayoutSnapshot } from '@/components/terminal-pane/terminal-layout-leaf-ids'
@ -435,6 +436,16 @@ export type TerminalSlice = {
expectedRuntimePtyIds?: string[]
}
) => Promise<void>
shutdownCompletedAgentPaneForHibernation: (
worktreeId: string,
opts: {
paneKey: string
tabId: string
leafId: string
ptyId: string
expectedRuntimePtyId?: string
}
) => Promise<void>
suppressPtyExit: (ptyId: string) => void
consumeSuppressedPtyExit: (ptyId: string) => boolean
queueCodexPaneRestarts: (ptyIds: string[]) => void
@ -1685,6 +1696,209 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
}
},
shutdownCompletedAgentPaneForHibernation: async (worktreeId, opts) => {
const paneKeys = [opts.paneKey]
const expectedRuntimePtyIds = sortedUniquePtyIds(
opts.expectedRuntimePtyId ? [opts.expectedRuntimePtyId] : []
)
const shutdownPtyIds = sortedUniquePtyIds([opts.ptyId, ...expectedRuntimePtyIds])
const state = get()
const tab = (state.tabsByWorktree[worktreeId] ?? []).find(
(candidate) => candidate.id === opts.tabId
)
const parsed = parsePaneKey(opts.paneKey)
const layout = state.terminalLayoutsByTabId[opts.tabId]
const liveTabPtyIds = state.ptyIdsByTabId[opts.tabId] ?? []
if (
!tab ||
!parsed ||
parsed.tabId !== opts.tabId ||
parsed.leafId !== opts.leafId ||
layout?.ptyIdsByLeafId?.[opts.leafId] !== opts.ptyId ||
(expectedRuntimePtyIds.length === 0 && !liveTabPtyIds.includes(opts.ptyId))
) {
throw new Error('agent_hibernation_pane_binding_mismatch')
}
const sleepingAgentSessionRecords = collectSleepingAgentSessionRecordsForWorktree(
state,
worktreeId,
paneKeys
)
const retainedCompletionEvidence = collectHibernatedCompletionEvidenceForWorktree(
state,
worktreeId,
paneKeys
)
const capture = shutdownBufferCaptures.get(opts.tabId)
if (capture) {
try {
capture({ includeLocalBuffers: false })
} catch {
// Don't let one tab's capture failure block the pane hibernation.
}
}
const clearTargetSuppressions = (): void => {
set((s) => {
const next = { ...s.suppressedPtyExitIds }
for (const ptyId of shutdownPtyIds) {
delete next[ptyId]
}
return { suppressedPtyExitIds: next }
})
}
set((s) => ({
suppressedPtyExitIds: {
...s.suppressedPtyExitIds,
...Object.fromEntries(shutdownPtyIds.map((ptyId) => [ptyId, true] as const))
}
}))
if (expectedRuntimePtyIds.length > 0) {
const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(get(), worktreeId)
if (!runtimeEnvironmentId) {
clearTargetSuppressions()
throw new Error('missing_runtime_for_exact_terminal_stop')
}
let stopResult: {
stoppedPtyIds?: string[]
livePtyIds?: string[]
postStopVerified?: boolean
postStopFailure?: string
}
try {
stopResult = await callRuntimeRpc<{
stoppedPtyIds?: string[]
livePtyIds?: string[]
postStopVerified?: boolean
postStopFailure?: string
}>(
{ kind: 'environment', environmentId: runtimeEnvironmentId },
'terminal.stopExact',
{
worktree: toRuntimeWorktreeSelector(worktreeId),
expectedPtyIds: expectedRuntimePtyIds,
keepHistory: true,
targetOnly: true
},
{ timeoutMs: 15_000 }
)
} catch (err) {
clearTargetSuppressions()
throw err
}
const stoppedPtyIds = sortedUniquePtyIds(stopResult.stoppedPtyIds)
const livePtyIds = sortedUniquePtyIds(stopResult.livePtyIds)
const targetWasLive = expectedRuntimePtyIds.every((ptyId) => livePtyIds.includes(ptyId))
if (!equalStringSets(stoppedPtyIds, expectedRuntimePtyIds) || !targetWasLive) {
clearTargetSuppressions()
throw new Error('exact_terminal_stop_mismatch')
}
if (stopResult.postStopVerified !== true) {
clearTargetSuppressions()
throw new Error(stopResult.postStopFailure ?? 'exact_terminal_stop_unverified')
}
unregisterPtyDataHandlers(shutdownPtyIds)
} else if (!opts.ptyId.startsWith('remote:')) {
// Why: pty.kill can flush final data before exit; unregister first so
// pane hibernation cannot fire phantom notifications from stale handlers.
const handlerSnapshots = unregisterPtyDataHandlers(shutdownPtyIds)
try {
await window.api.pty.kill(opts.ptyId, { keepHistory: true })
} catch (err) {
restorePtyDataHandlersAfterFailedShutdown(handlerSnapshots)
clearTargetSuppressions()
throw err
}
}
set((s) => {
const existingPtyIds = s.ptyIdsByTabId[opts.tabId] ?? []
const shutdownPtyIdSet = new Set(shutdownPtyIds)
const remainingPtyIds = existingPtyIds.filter((ptyId) => !shutdownPtyIdSet.has(ptyId))
const nextTabsByWorktree = { ...s.tabsByWorktree }
const tabs = nextTabsByWorktree[worktreeId] ?? []
const tabIndex = tabs.findIndex((candidate) => candidate.id === opts.tabId)
if (tabIndex !== -1) {
const nextTabs = [...tabs]
nextTabs[tabIndex] = {
...nextTabs[tabIndex],
ptyId: remainingPtyIds.at(-1) ?? null
}
nextTabsByWorktree[worktreeId] = nextTabs
}
const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId }
for (const ptyId of shutdownPtyIds) {
delete nextCodexRestartNoticeByPtyId[ptyId]
}
const nextLastKnownRelay =
remainingPtyIds.length === 0
? { ...s.lastKnownRelayPtyIdByTabId }
: s.lastKnownRelayPtyIdByTabId
if (remainingPtyIds.length === 0) {
delete nextLastKnownRelay[opts.tabId]
}
let nextRuntimePaneTitlesByTabId = s.runtimePaneTitlesByTabId
const numericPaneId = Number(opts.leafId)
if (
Number.isInteger(numericPaneId) &&
s.runtimePaneTitlesByTabId[opts.tabId]?.[numericPaneId]
) {
const nextByPane = { ...s.runtimePaneTitlesByTabId[opts.tabId] }
delete nextByPane[numericPaneId]
nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId }
if (Object.keys(nextByPane).length > 0) {
nextRuntimePaneTitlesByTabId[opts.tabId] = nextByPane
} else {
delete nextRuntimePaneTitlesByTabId[opts.tabId]
}
}
const nextUnreadTerminalPanes = { ...s.unreadTerminalPanes }
const nextUnreadAgentCompletionPanes = { ...s.unreadAgentCompletionPanes }
const nextLastTerminalInputAtByPaneKey = { ...s.lastTerminalInputAtByPaneKey }
delete nextUnreadTerminalPanes[opts.paneKey]
delete nextUnreadAgentCompletionPanes[opts.paneKey]
delete nextLastTerminalInputAtByPaneKey[opts.paneKey]
return {
tabsByWorktree: nextTabsByWorktree,
ptyIdsByTabId: {
...s.ptyIdsByTabId,
[opts.tabId]: remainingPtyIds
},
lastKnownRelayPtyIdByTabId: nextLastKnownRelay,
suppressedPtyExitIds: {
...s.suppressedPtyExitIds,
...Object.fromEntries(shutdownPtyIds.map((ptyId) => [ptyId, true] as const))
},
codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId,
...(nextRuntimePaneTitlesByTabId !== s.runtimePaneTitlesByTabId
? { runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId }
: {}),
unreadTerminalPanes: nextUnreadTerminalPanes,
unreadAgentCompletionPanes: nextUnreadAgentCompletionPanes,
lastTerminalInputAtByPaneKey: nextLastTerminalInputAtByPaneKey
}
})
set((s) => ({
sleepingAgentSessionsByPaneKey: {
...s.sleepingAgentSessionsByPaneKey,
...sleepingAgentSessionRecords
}
}))
get().dropHibernatedAgentStatusPane(worktreeId, opts.paneKey, {
retainedCompletionEvidence
})
},
shutdownWorktreeTerminals: async (worktreeId, opts) => {
const keepIdentifiers = opts?.keepIdentifiers ?? false
const shutdownReason: AgentStatusWorktreeShutdownReason =