fix(sleep): reopen a slept agent whose tab is gone in a fresh tab instead of stranding it (#33598)

A slept agent whose tab disappeared now resumes in a fresh tab (with --resume, through the existing duplicate/host guards) when its workspace opens, and main's stranded workspace-sleep record is then dropped (only if no tab still lists that pane), instead of the record sitting forever while the workspace opens a bare shell.

Fixes #22657
This commit is contained in:
Neil
2026-10-25 11:00:28 -07:00
committed by GitHub
parent f37c1f6b95
commit 82a3214049
16 changed files with 222 additions and 18 deletions
@@ -37,6 +37,7 @@ const WORKTREE_HANDLER_CHANNELS = [
'worktrees:sleep',
'worktrees:sleepReopened',
'worktrees:resumeSleptPane',
'worktrees:retireStrandedSleepRecord',
'worktrees:previewNestedRemoval',
'worktrees:forgetLocal',
'worktrees:forceDeletePreservedBranch',
+1
View File
@@ -42,6 +42,7 @@ const WORKTREE_HANDLER_CHANNELS = [
'worktrees:sleep',
'worktrees:sleepReopened',
'worktrees:resumeSleptPane',
'worktrees:retireStrandedSleepRecord',
'worktrees:previewNestedRemoval',
'worktrees:forgetLocal',
'worktrees:forceDeletePreservedBranch',
@@ -34,4 +34,13 @@ export function registerWorktreeSleepHandlers(context: WorktreeIpcContext): void
}
return context.runtime.resumeSleptPane(pane)
})
ipcMain.handle(
'worktrees:retireStrandedSleepRecord',
(_event, args: { worktreeId?: unknown; paneKey?: unknown } | undefined) => {
if (typeof args?.worktreeId !== 'string' || typeof args.paneKey !== 'string') {
throw new Error('invalid_sleep_record')
}
return context.runtime.retireStrandedSleepRecord(args.worktreeId, args.paneKey)
}
)
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume'
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
import { dropStrandedSleepRecord } from './worktree-sleep-resume-records'
const slept: SleepingAgentSessionRecord = {
paneKey: 'tab-1:leaf-1',
tabId: 'tab-1',
worktreeId: 'wt-1',
agent: 'claude',
providerSession: { key: 'session_id', id: 'sess-1' },
prompt: '',
state: 'done',
capturedAt: 1,
updatedAt: 1,
origin: 'worktree-sleep',
restoreOnTabOpenOnly: true
}
function session(
tabIds: string[],
record: SleepingAgentSessionRecord = slept
): WorkspaceSessionState {
const tab = (id: string) => ({
id,
ptyId: null,
worktreeId: 'wt-1',
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
})
return {
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: { 'wt-1': tabIds.map(tab) },
terminalLayoutsByTabId: {},
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
}
}
describe('dropStrandedSleepRecord', () => {
it("drops a workspace sleep's record once no tab holds its pane", () => {
expect(
dropStrandedSleepRecord(session([]), slept.paneKey)?.sleepingAgentSessionsByPaneKey
).toEqual({})
})
// A window must not discard a record main still resumes when its tab opens.
it('keeps a record whose tab is still listed, and window-owned records', () => {
expect(dropStrandedSleepRecord(session(['tab-1']), slept.paneKey)).toBeNull()
const quit = { ...slept, origin: 'quit' as const }
expect(dropStrandedSleepRecord(session([], quit), quit.paneKey)).toBeNull()
})
})
@@ -10,6 +10,7 @@ import type { AgentProcessVerdict } from '../../../../shared/agent-process-prese
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { isLocalHookConnectionId } from '../../../../shared/wsl-hook-relay-contract'
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
import { isWorkspaceSleepRecord } from '../../../../shared/workspace-sleep'
/** Per slept PTY: the resume records captured, and the panes the hook store proves moved on. */
export type WorktreeSleepResumeCapture = Map<
@@ -158,3 +159,20 @@ export function clearClosedWorktreeResumeRecords(
? { ...session, sleepingAgentSessionsByPaneKey, terminalPtyIncarnationsByPaneKey }
: null
}
/** The session without a workspace-sleep record whose tab it no longer lists, or null when the record is still placed. */
export function dropStrandedSleepRecord(
session: WorkspaceSessionState,
paneKey: string
): WorkspaceSessionState | null {
const record = session.sleepingAgentSessionsByPaneKey?.[paneKey]
const tabId = record?.tabId ?? parsePaneKey(paneKey)?.tabId
const tabListed = Object.values(session.tabsByWorktree).some((tabs) =>
tabs.some((tab) => tab.id === tabId)
)
if (!record || !isWorkspaceSleepRecord(record) || tabListed) {
return null
}
const { [paneKey]: _dropped, ...rest } = session.sleepingAgentSessionsByPaneKey ?? {}
return { ...session, sleepingAgentSessionsByPaneKey: rest }
}
@@ -38,6 +38,7 @@ import type {
WorkspaceSleepRequest
} from '../../../shared/workspace-sleep'
import { WorkspaceSleepCoordinator } from './workspace-sleep-runtime'
import { dropStrandedSleepRecord } from '../terminal/stop/worktree-sleep-resume-records'
import type { RestoredPane } from '../terminal/recovery/restored-pane-start'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
@@ -265,6 +266,14 @@ export abstract class OrcaRuntimeWithListManagedWorktrees extends OrcaRuntimeWit
return this.workspaceSleep.resumePane(pane)
}
/** The window reopened a slept agent whose tab is gone in a fresh tab; this record is spent. */
async retireStrandedSleepRecord(worktreeId: string, paneKey: string): Promise<void> {
await this.commitWorkspaceSessionEdit(
this.getWorkspaceSessionHostIdForWorktree(worktreeId),
(session) => dropStrandedSleepRecord(session, paneKey)
)
}
wakeWindowHibernatedAgents(worktreeId: string): void {
this.notifier?.resumeSleepingAgents?.(worktreeId)
}
+2
View File
@@ -89,6 +89,8 @@ export type WorktreeApi = {
sleepReopened: (worktreeId: string) => Promise<void>
/** True once running, so the renderer's subsequent spawn reattaches. */
resumeSleptPane: (pane: { worktreeId: string; tabId: string; leafId: string }) => Promise<boolean>
/** Drops main's workspace-sleep record only while no tab holds its pane. */
retireStrandedSleepRecord: (args: { worktreeId: string; paneKey: string }) => Promise<void>
remove: (args: {
worktreeId: string
hostId?: ExecutionHostId
+2
View File
@@ -45,6 +45,8 @@ export const worktreesApi = {
sleep: (request) => ipcRenderer.invoke('worktrees:sleep', request),
sleepReopened: (worktreeId) => ipcRenderer.invoke('worktrees:sleepReopened', worktreeId),
resumeSleptPane: (pane) => ipcRenderer.invoke('worktrees:resumeSleptPane', pane),
retireStrandedSleepRecord: (args) =>
ipcRenderer.invoke('worktrees:retireStrandedSleepRecord', args),
remove: (args) => ipcRenderer.invoke('worktrees:remove', args),
@@ -0,0 +1,55 @@
import { afterEach, expect, it, vi } from 'vitest'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import { makePaneKey } from '../../../shared/stable-pane-id'
import { useAppStore } from '@/store'
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const initialAppStoreState = useAppStore.getState()
afterEach(() => {
vi.unstubAllGlobals()
useAppStore.setState(initialAppStoreState, true)
})
// #22657: deleting or ignoring it would leave the session with no resume handle at all.
it('reopens a slept agent whose tab is gone in a fresh tab and retires main’s record', async () => {
const retireStrandedSleepRecord = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('window', { api: { worktrees: { retireStrandedSleepRecord } } })
const record: SleepingAgentSessionRecord = {
paneKey: makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111'),
tabId: 'tab-1',
worktreeId: 'wt-1',
agent: 'claude',
providerSession: { key: 'session_id', id: 'sess-1' },
prompt: '',
state: 'done',
capturedAt: 1,
updatedAt: 1,
origin: 'worktree-sleep',
restoreOnTabOpenOnly: true
}
useAppStore.setState({
tabsByWorktree: { 'wt-1': [] },
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
} as never)
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(1)
const state = useAppStore.getState()
const resumedTab = state.tabsByWorktree['wt-1']?.[0]
expect(state.pendingStartupByTabId[resumedTab!.id]?.resumeProviderSession).toEqual(
record.providerSession
)
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
await vi.waitFor(() =>
expect(retireStrandedSleepRecord).toHaveBeenCalledWith({
worktreeId: 'wt-1',
paneKey: record.paneKey
})
)
})
@@ -498,16 +498,16 @@ describe('resumeSleepingAgentSessionsForWorktree', () => {
expect(state.sleepingAgentSessionsByPaneKey[second.paneKey]).toBeUndefined()
})
it("leaves a workspace sleep's records to main: no fresh tab, record kept", () => {
it("leaves a workspace sleep's records to main while their tab remains", () => {
const record = makeRecord({ origin: 'worktree-sleep' })
useAppStore.setState({
tabsByWorktree: { 'wt-1': [] },
tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] },
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
} as never)
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(0)
const state = useAppStore.getState()
expect(state.tabsByWorktree['wt-1']).toEqual([])
expect(state.tabsByWorktree['wt-1']).toHaveLength(1)
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
})
@@ -9,10 +9,12 @@ import { parsePaneKey } from '../../../shared/stable-pane-id'
import {
getProviderSessionClaimKey,
activationTreatsNoteAsFinished,
isStrandedWorkspaceSleepRecord,
recordPaneIsOwnedByPreservedPane,
stablePaneHasLivePty
} from './sleeping-agent-pane-ownership'
import { isPaneSpawnDeferred } from './pane-spawn-deferral-registry'
import { settleSweptRecord, sweepTreatsAsFinished } from './swept-sleep-record-settlement'
import {
launchSleepingAgentSession,
type ResumeSleepingAgentSessionsOptions
@@ -41,7 +43,7 @@ function clearPassiveCompletedRecordsForClaimKey(
): void {
const state = useAppStore.getState()
for (const record of records) {
if (record.paneKey === keepPaneKey || !activationTreatsNoteAsFinished(record)) {
if (record.paneKey === keepPaneKey || !sweepTreatsAsFinished(record)) {
continue
}
if (getProviderSessionClaimKey(record) === claimKey) {
@@ -57,7 +59,7 @@ function getCurrentPaneOwnedClaimKeys(records: readonly SleepingAgentSessionReco
if (
state.sleepingAgentSessionsByPaneKey[record.paneKey] !== record ||
isInvalidWorktreeActivationRecord(record) ||
activationTreatsNoteAsFinished(record)
sweepTreatsAsFinished(record)
) {
continue
}
@@ -220,15 +222,19 @@ export function resumeSleepingAgentSessionsForWorktree(
if (hostAuthority === 'unverifiable') {
return 0
}
// Main owns workspace-sleep resumes in their original panes; this sweep must not create fresh tabs.
// Main resumes workspace sleep records in their own panes; only stranded ones get a fresh tab here.
const worktreeRecords = Object.values(state.sleepingAgentSessionsByPaneKey)
.filter((record) => record.worktreeId === worktreeId && !isWorkspaceSleepRecord(record))
.filter(
(record) =>
record.worktreeId === worktreeId &&
(!isWorkspaceSleepRecord(record) || isStrandedWorkspaceSleepRecord(record, state))
)
.sort((a, b) => a.capturedAt - b.capturedAt || a.updatedAt - b.updatedAt)
const validWorktreeRecords = worktreeRecords.filter(
(record) => !isInvalidWorktreeActivationRecord(record)
)
const activeWorktreeRecords = validWorktreeRecords.filter(
(record) => !activationTreatsNoteAsFinished(record)
(record) => !sweepTreatsAsFinished(record)
)
const activeClaimKeys = new Set(activeWorktreeRecords.map(getProviderSessionClaimKey))
// Why: a record this host may not resume must not outrank, and so delete, one it may.
@@ -255,7 +261,7 @@ export function resumeSleepingAgentSessionsForWorktree(
continue
}
if (isInvalidWorktreeActivationRecord(record)) {
state.clearSleepingAgentSession(record.paneKey)
settleSweptRecord(record)
continue
}
// Host disagreement does not invalidate this machine-scoped locator. Retain it for manual retry; deleting loses the transcript handle, while wrong-host resume can fail or fork.
@@ -269,31 +275,31 @@ export function resumeSleepingAgentSessionsForWorktree(
continue
}
const isPaneOwned = recordPaneIsOwnedByPreservedPane(record, currentState)
if (activationTreatsNoteAsFinished(record)) {
if (sweepTreatsAsFinished(record)) {
// Completed hibernated agents are passive history; activation preserves display evidence without starting work.
if (!isPaneOwned || activeClaimKeys.has(claimKey)) {
state.clearSleepingAgentSession(record.paneKey)
settleSweptRecord(record)
}
continue
}
if (activeOrQueuedResumeClaimsProviderSession(record, currentState, isPaneOwned)) {
// Clear stale main wake replays once this provider session is already queued in a fresh tab.
state.clearSleepingAgentSession(record.paneKey)
settleSweptRecord(record)
continue
}
const paneOwnedClaimKeys = getCurrentPaneOwnedClaimKeys(activeWorktreeRecords)
if (paneOwnedClaimKeys.has(claimKey)) {
if (!isPaneOwned) {
state.clearSleepingAgentSession(record.paneKey)
settleSweptRecord(record)
}
continue
}
if (freshlyLaunchedClaimKeys.has(claimKey)) {
state.clearSleepingAgentSession(record.paneKey)
settleSweptRecord(record)
continue
}
if (claimRepresentativeByKey.get(claimKey) !== record) {
state.clearSleepingAgentSession(record.paneKey)
settleSweptRecord(record)
continue
}
if (isPaneOwned) {
@@ -305,10 +311,11 @@ export function resumeSleepingAgentSessionsForWorktree(
}
// Provider sessions span workspaces; stale record IDs must not relaunch a transcript owned by another record’s pane.
if (providerSessionOwnedElsewhere(record, currentState)) {
state.clearSleepingAgentSession(record.paneKey)
settleSweptRecord(record)
continue
}
if (launchSleepingAgentSession(record, options)) {
settleSweptRecord(record)
launched += 1
freshlyLaunchedClaimKeys.add(claimKey)
clearPassiveCompletedRecordsForClaimKey(worktreeRecords, claimKey, record.paneKey)
@@ -11,6 +11,7 @@ import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-
import { getProviderSessionClaimKey } from '../../../shared/sleeping-record-legacy-pane-key'
import { isWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { isWorkspaceSleepRecord } from '../../../shared/workspace-sleep'
import { isPaneResumingInPlace, isPaneSpawnDeferred } from './pane-spawn-deferral-registry'
export { getProviderSessionClaimKey }
@@ -138,6 +139,19 @@ function findPreservedTabOwner(
return null
}
/** A workspace sleep's record whose tab is gone: main has no pane to resume it in, so the window reopens it in a fresh tab (#22657). */
export function isStrandedWorkspaceSleepRecord(
record: SleepingAgentSessionRecord,
state: AppStoreState
): boolean {
const tabId = record.tabId ?? parsePaneKey(record.paneKey)?.tabId
return (
isWorkspaceSleepRecord(record) &&
tabId !== undefined &&
!findPreservedTabOwner(state, record.worktreeId, tabId)
)
}
export function recordPaneIsOwnedByPreservedPane(
record: SleepingAgentSessionRecord,
state: AppStoreState
@@ -0,0 +1,23 @@
import { useAppStore } from '@/store'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import { isWorkspaceSleepRecord } from '../../../shared/workspace-sleep'
import { activationTreatsNoteAsFinished } from './sleeping-agent-pane-ownership'
// A stranded workspace sleep record is the only handle to an unfinished session, never passive history.
export function sweepTreatsAsFinished(record: SleepingAgentSessionRecord): boolean {
return activationTreatsNoteAsFinished(record) && !isWorkspaceSleepRecord(record)
}
// Main keeps workspace sleep records, so one this sweep settles must leave main's session too.
export function settleSweptRecord(record: SleepingAgentSessionRecord): void {
useAppStore.getState().clearSleepingAgentSession(record.paneKey)
if (!isWorkspaceSleepRecord(record)) {
return
}
const { worktreeId, paneKey } = record
void Promise.resolve()
.then(() => window.api.worktrees.retireStrandedSleepRecord({ worktreeId, paneKey }))
.catch((error: unknown) =>
console.warn('[sleep-resume] could not retire a stranded sleep record', error)
)
}
@@ -26,6 +26,7 @@ const isPassiveSpy = vi.fn()
vi.mock('./sleeping-agent-pane-ownership', () => ({
activationTreatsNoteAsFinished: (record: unknown) => isPassiveSpy(record),
recordPaneIsOwnedByPreservedPane: () => false,
isStrandedWorkspaceSleepRecord: () => false,
getProviderSessionClaimKey: (record: {
worktreeId: string
agent?: string
@@ -13,6 +13,7 @@ import { gateWorktreeAgentActivation } from './worktree-agent-activation-gate'
import {
getProviderSessionClaimKey,
activationTreatsNoteAsFinished,
isStrandedWorkspaceSleepRecord,
recordPaneIsOwnedByPreservedPane
} from './sleeping-agent-pane-ownership'
@@ -146,8 +147,11 @@ export function wakeSleepingAgentsForWorktreeInBackground(worktreeId: string): v
detail: { worktreeId, wokenClaimKeys }
})
)
// Why: main resumes a workspace sleep's records; the steps below are the window's own hibernation.
const worktreeRecords = sleptRecords.filter((record) => !isWorkspaceSleepRecord(record))
// Why: main resumes a workspace sleep's placed records; the steps below are the window's own hibernation and stranded sleeps.
const state = useAppStore.getState()
const worktreeRecords = sleptRecords.filter(
(record) => !isWorkspaceSleepRecord(record) || isStrandedWorkspaceSleepRecord(record, state)
)
if (worktreeRecords.length === 0) {
return
}
@@ -176,6 +176,7 @@ export function createWorktreesApi(): NonNullable<Partial<PreloadApi>['worktrees
// A web client runs no VM, so a reopen during a sleep changes nothing.
sleepReopened: async () => {},
resumeSleptPane: async () => false,
retireStrandedSleepRecord: async () => {},
remove: async ({ worktreeId, hostId, force, allowUnverifiedPtyStop, skipArchive }) => {
invalidateRuntimeWorktreeCaches()
return callRuntimeResult<RemoveWorktreeResult>(