From 82a3214049bbc2b0003d2ef0c80bc49e40d875e2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:00:28 -0700 Subject: [PATCH] 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 --- ...ktrees-create-metadata-persistence.test.ts | 1 + src/main/ipc/worktrees.ts | 1 + .../sleep/register-worktree-sleep-handlers.ts | 9 +++ .../worktree-sleep-resume-records.test.ts | 57 +++++++++++++++++++ .../stop/worktree-sleep-resume-records.ts | 18 ++++++ .../managed-workspace-operations.ts | 9 +++ src/preload/api/worktree-api.ts | 2 + src/preload/api/worktrees-bridge.ts | 2 + ...eping-agent-session-stranded-sleep.test.ts | 55 ++++++++++++++++++ .../lib/resume-sleeping-agent-session.test.ts | 6 +- .../src/lib/resume-sleeping-agent-session.ts | 33 ++++++----- .../src/lib/sleeping-agent-pane-ownership.ts | 14 +++++ .../src/lib/swept-sleep-record-settlement.ts | 23 ++++++++ ...wake-sleeping-agents-in-background.test.ts | 1 + .../lib/wake-sleeping-agents-in-background.ts | 8 ++- .../src/web/preload-api/web-worktrees-api.ts | 1 + 16 files changed, 222 insertions(+), 18 deletions(-) create mode 100644 src/main/runtime/terminal/stop/worktree-sleep-resume-records.test.ts create mode 100644 src/renderer/src/lib/resume-sleeping-agent-session-stranded-sleep.test.ts create mode 100644 src/renderer/src/lib/swept-sleep-record-settlement.ts diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index 953d8ed545f..f3f1a353e67 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -37,6 +37,7 @@ const WORKTREE_HANDLER_CHANNELS = [ 'worktrees:sleep', 'worktrees:sleepReopened', 'worktrees:resumeSleptPane', + 'worktrees:retireStrandedSleepRecord', 'worktrees:previewNestedRemoval', 'worktrees:forgetLocal', 'worktrees:forceDeletePreservedBranch', diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 8abf22e346c..902996934a2 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -42,6 +42,7 @@ const WORKTREE_HANDLER_CHANNELS = [ 'worktrees:sleep', 'worktrees:sleepReopened', 'worktrees:resumeSleptPane', + 'worktrees:retireStrandedSleepRecord', 'worktrees:previewNestedRemoval', 'worktrees:forgetLocal', 'worktrees:forceDeletePreservedBranch', diff --git a/src/main/ipc/worktrees/sleep/register-worktree-sleep-handlers.ts b/src/main/ipc/worktrees/sleep/register-worktree-sleep-handlers.ts index e9c014dc522..98c4425414d 100644 --- a/src/main/ipc/worktrees/sleep/register-worktree-sleep-handlers.ts +++ b/src/main/ipc/worktrees/sleep/register-worktree-sleep-handlers.ts @@ -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) + } + ) } diff --git a/src/main/runtime/terminal/stop/worktree-sleep-resume-records.test.ts b/src/main/runtime/terminal/stop/worktree-sleep-resume-records.test.ts new file mode 100644 index 00000000000..46d6c485ae8 --- /dev/null +++ b/src/main/runtime/terminal/stop/worktree-sleep-resume-records.test.ts @@ -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() + }) +}) diff --git a/src/main/runtime/terminal/stop/worktree-sleep-resume-records.ts b/src/main/runtime/terminal/stop/worktree-sleep-resume-records.ts index 02b645070e1..dd700e6fc72 100644 --- a/src/main/runtime/terminal/stop/worktree-sleep-resume-records.ts +++ b/src/main/runtime/terminal/stop/worktree-sleep-resume-records.ts @@ -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 } +} diff --git a/src/main/runtime/workspaces/managed-workspace-operations.ts b/src/main/runtime/workspaces/managed-workspace-operations.ts index be2bd10a462..f5682a72989 100644 --- a/src/main/runtime/workspaces/managed-workspace-operations.ts +++ b/src/main/runtime/workspaces/managed-workspace-operations.ts @@ -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 { + await this.commitWorkspaceSessionEdit( + this.getWorkspaceSessionHostIdForWorktree(worktreeId), + (session) => dropStrandedSleepRecord(session, paneKey) + ) + } + wakeWindowHibernatedAgents(worktreeId: string): void { this.notifier?.resumeSleepingAgents?.(worktreeId) } diff --git a/src/preload/api/worktree-api.ts b/src/preload/api/worktree-api.ts index 33e698c2f52..6ca23b4f0b3 100644 --- a/src/preload/api/worktree-api.ts +++ b/src/preload/api/worktree-api.ts @@ -89,6 +89,8 @@ export type WorktreeApi = { sleepReopened: (worktreeId: string) => Promise /** True once running, so the renderer's subsequent spawn reattaches. */ resumeSleptPane: (pane: { worktreeId: string; tabId: string; leafId: string }) => Promise + /** Drops main's workspace-sleep record only while no tab holds its pane. */ + retireStrandedSleepRecord: (args: { worktreeId: string; paneKey: string }) => Promise remove: (args: { worktreeId: string hostId?: ExecutionHostId diff --git a/src/preload/api/worktrees-bridge.ts b/src/preload/api/worktrees-bridge.ts index 67b48ba7e4c..44692d18209 100644 --- a/src/preload/api/worktrees-bridge.ts +++ b/src/preload/api/worktrees-bridge.ts @@ -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), diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-stranded-sleep.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-stranded-sleep.test.ts new file mode 100644 index 00000000000..3ad8b187174 --- /dev/null +++ b/src/renderer/src/lib/resume-sleeping-agent-session-stranded-sleep.test.ts @@ -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 + }) + ) +}) diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts index d975349cc28..96f689b2383 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts @@ -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) }) diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index 86959f0d3d0..61b384a6ab2 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -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) diff --git a/src/renderer/src/lib/sleeping-agent-pane-ownership.ts b/src/renderer/src/lib/sleeping-agent-pane-ownership.ts index b9e5129e855..ab7bc4fcf91 100644 --- a/src/renderer/src/lib/sleeping-agent-pane-ownership.ts +++ b/src/renderer/src/lib/sleeping-agent-pane-ownership.ts @@ -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 diff --git a/src/renderer/src/lib/swept-sleep-record-settlement.ts b/src/renderer/src/lib/swept-sleep-record-settlement.ts new file mode 100644 index 00000000000..920d4952b5e --- /dev/null +++ b/src/renderer/src/lib/swept-sleep-record-settlement.ts @@ -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) + ) +} diff --git a/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts b/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts index 8135ed35911..8673309158e 100644 --- a/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts +++ b/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts @@ -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 diff --git a/src/renderer/src/lib/wake-sleeping-agents-in-background.ts b/src/renderer/src/lib/wake-sleeping-agents-in-background.ts index ffc14b42a1e..ff63c35d5d3 100644 --- a/src/renderer/src/lib/wake-sleeping-agents-in-background.ts +++ b/src/renderer/src/lib/wake-sleeping-agents-in-background.ts @@ -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 } diff --git a/src/renderer/src/web/preload-api/web-worktrees-api.ts b/src/renderer/src/web/preload-api/web-worktrees-api.ts index c79aa1d13a8..bb5c7668c9d 100644 --- a/src/renderer/src/web/preload-api/web-worktrees-api.ts +++ b/src/renderer/src/web/preload-api/web-worktrees-api.ts @@ -176,6 +176,7 @@ export function createWorktreesApi(): NonNullable['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(