From 21513fa9eef7a98db9ecbac93e9927dc412f8fc2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:14:00 -0700 Subject: [PATCH 01/30] fix(sidebar): Sleep stops a workspace's native chat agents Sleeping a workspace stopped terminals and browsers but never its structured (native) chats, so their claude/codex process kept running, and Sleep was disabled for a workspace whose only content was a chat. Sleep now stops each chat's agent through a new agentSession.sleep (the host's close, keeping the tab), routed to the host recorded on the chat's tab; an older host without the capability fails Sleep with a clear message. The Sleep menu counts a chat. --- ...ession-gate-classification.test-fixture.ts | 5 ++ ...agent-session-pi-dialog-capability.test.ts | 3 +- .../methods/structured-agent-session.test.ts | 2 +- .../rpc/methods/structured-agent-session.ts | 11 ++++ .../sidebar/sleep-workspace-chats.ts | 38 +++++++++++ .../sleep-worktree-activation-race.test.ts | 1 + .../sidebar/sleep-worktree-flow.test.ts | 65 ++++++++++++++----- .../components/sidebar/sleep-worktree-flow.ts | 16 ++++- .../use-worktree-context-menu-model.tsx | 17 ++++- .../workspace-lineage-menu-actions.test.ts | 40 ++++++++++-- .../sidebar/workspace-lineage-menu-actions.ts | 33 ++++++++-- .../sidebar/worktree-context-menu-policy.ts | 1 + src/renderer/src/i18n/locales/en.json | 5 +- .../runtime/structured-agent-session-close.ts | 20 ++++++ .../rpc-params-catalog.generated.ts | 1 + ...ured-agent-session-surface-capabilities.ts | 5 ++ 16 files changed, 229 insertions(+), 34 deletions(-) create mode 100644 src/renderer/src/components/sidebar/sleep-workspace-chats.ts diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts index 12813a8f66ea..e1d83a80a058 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts @@ -16,6 +16,11 @@ export const CLEANUP_METHODS = [ params: { sessionId: SESSION }, hostCall: 'close' }, + { + method: 'agentSession.sleep', + params: { sessionId: SESSION }, + hostCall: 'close' + }, { method: 'agentSession.cancel', params: { envelope: envelope(), turnId: 'turn-1' }, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-pi-dialog-capability.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-pi-dialog-capability.test.ts index 9b290a449b20..4112e26887e4 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-pi-dialog-capability.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-pi-dialog-capability.test.ts @@ -214,7 +214,8 @@ describe('Pi dialog-shape client capability', () => { expect(await call(method, params, client), method).toMatchObject({ ok: true }) } expect(hostCalls.cancel).toHaveBeenCalledOnce() - expect(hostCalls.close).toHaveBeenCalledOnce() + // `agentSession.close` and `agentSession.sleep` both end in the host's close. + expect(hostCalls.close).toHaveBeenCalledTimes(2) }) it('admits current desktop Pi send, options and commands, and preserves other agents', async () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index f67dd8ed74fb..7d839fcd19e3 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -175,7 +175,7 @@ describe('capability gating', () => { } // Bump deliberately: the whole agentSession.* surface is behind the structured capability, // so an additive method is invisible to old clients and needs no protocol bump. - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(36) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(37) }) it('hides the surface from a declared client that did not advertise it', async () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index a25d40b52b28..962e07ca3872 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -141,6 +141,17 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ return { ok: true as const } } }), + defineMethod({ + // Sleeping the chat's workspace: the same close, but the tab stays. `close` throws unless the + // provider child's exit is proven, so resolving is the proof the workspace may read asleep. + name: 'agentSession.sleep', + permission: 'workspace', + params: OptionsParams, + handler: async (params, ctx) => { + await requireStructuredCleanupHost(ctx).close(params.sessionId, 'user-close') + return { ok: true as const } + } + }), ...STRUCTURED_AGENT_SESSION_PROMPT_RESPONSE_METHODS, defineMethod({ name: 'agentSession.history', diff --git a/src/renderer/src/components/sidebar/sleep-workspace-chats.ts b/src/renderer/src/components/sidebar/sleep-workspace-chats.ts new file mode 100644 index 000000000000..15c3ae61d1e4 --- /dev/null +++ b/src/renderer/src/components/sidebar/sleep-workspace-chats.ts @@ -0,0 +1,38 @@ +import type { AppState } from '@/store/types' +import { isStructuredTab } from '@/components/native-chat/structured-agent-session-tabs' +import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' +import { + AGENT_SESSION_SLEEP_UNSUPPORTED, + sleepStructuredAgentSession +} from '@/runtime/structured-agent-session-close' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' + +export type WorkspaceChatSleep = { sessionId: string; target: RuntimeClientTarget | null } + +/** Each chat routes to the host stamped on its tab; one with none is not stopped, it fails Sleep. */ +export function resolveWorkspaceChatSleeps( + state: Pick, + worktreeId: string +): WorkspaceChatSleep[] { + return (state.unifiedTabsByWorktree[worktreeId] ?? []).filter(isStructuredTab).map((tab) => ({ + sessionId: tab.entityId, + target: structuredAgentSessionTargetForHost(tab.executionHostId) + })) +} + +/** Stops every chat's agent, keeping the tabs; tries them all, then throws the first failure. */ +export async function sleepWorkspaceChats(chats: readonly WorkspaceChatSleep[]): Promise { + const results = await Promise.allSettled( + chats.map(({ sessionId, target }) => + target + ? sleepStructuredAgentSession(target, sessionId) + : Promise.reject(new Error(AGENT_SESSION_SLEEP_UNSUPPORTED)) + ) + ) + const failure = results.find( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ) + if (failure) { + throw failure.reason + } +} diff --git a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts index 5f3b588f93c2..fb7d07410d33 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => { state.ptyIdsByTabId[tab.id] = [] } }), + unifiedTabsByWorktree: {}, tabsByWorktree: {} as Record, ptyIdsByTabId: {} as Record } diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index 34497baf390a..e9d9879f8a32 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => { shutdownWorktreeTerminals: ReturnType suppressPtyExit: ReturnType consumeSuppressedPtyExit: ReturnType + unifiedTabsByWorktree: Record[]> tabsByWorktree: Record ptyIdsByTabId: Record } = { @@ -19,6 +20,7 @@ const mocks = vi.hoisted(() => { shutdownWorktreeTerminals: vi.fn().mockResolvedValue(undefined), suppressPtyExit: vi.fn(), consumeSuppressedPtyExit: vi.fn(), + unifiedTabsByWorktree: {}, tabsByWorktree: {} as Record, ptyIdsByTabId: {} as Record } @@ -26,8 +28,10 @@ const mocks = vi.hoisted(() => { const toastError = vi.fn() const markWorktreeSleepIntent = vi.fn() const clearWorktreeSleepIntent = vi.fn() + const sleepChat = vi.fn().mockResolvedValue(undefined) return { clearWorktreeSleepIntent, + sleepChat, markWorktreeSleepIntent, state, suspendWorkspace, @@ -48,11 +52,15 @@ vi.mock('@/lib/worktree-sleep-intent', () => ({ withWorktreeSleepTeardown: (_worktreeId: string, teardown: () => Promise) => teardown() })) -import { - runSleepWorktree, - runSleepWorktrees, - sleepWorktreesReportingFailures -} from './sleep-worktree-flow' +vi.mock('@/runtime/structured-agent-session-owner', () => ({ + structuredAgentSessionTargetForHost: () => ({ kind: 'local' }) +})) +vi.mock('@/runtime/structured-agent-session-close', () => ({ + AGENT_SESSION_SLEEP_UNSUPPORTED: 'agent_session_sleep_unsupported', + sleepStructuredAgentSession: mocks.sleepChat +})) + +import { runSleepWorktree, runSleepWorktrees } from './sleep-worktree-flow' describe('runSleepWorktree', () => { beforeEach(() => { @@ -77,6 +85,41 @@ describe('runSleepWorktree', () => { mocks.state.activeWorktreeId = null mocks.state.tabsByWorktree = {} mocks.state.ptyIdsByTabId = {} + mocks.state.unifiedTabsByWorktree = {} + mocks.sleepChat.mockClear().mockResolvedValue(undefined) + }) + + const chatTab = { + id: 'chat-1', + entityId: 'session-1', + worktreeId: 'wt-1', + contentType: 'agent-session', + agentSessionAgent: 'claude' + } + + it("stops a workspace's chat agents", async () => { + mocks.state.unifiedTabsByWorktree = { 'wt-1': [chatTab] } + + await runSleepWorktree('wt-1') + + expect(mocks.sleepChat).toHaveBeenCalledWith({ kind: 'local' }, 'session-1') + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('keeps the workspace awake when its host cannot stop chats', async () => { + mocks.state.unifiedTabsByWorktree = { 'wt-1': [chatTab] } + mocks.sleepChat.mockRejectedValue(new Error('agent_session_sleep_unsupported')) + + await runSleepWorktree('wt-1') + + expect(mocks.clearWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') + expect(mocks.toastError).toHaveBeenCalledWith( + 'Failed to sleep workspace', + expect.objectContaining({ + description: + "This workspace's host can't stop its chats yet. The workspace was kept open; update the host and try again." + }) + ) }) it('tears down browsers before terminals on the sleep path', async () => { @@ -314,18 +357,6 @@ describe('runSleepWorktree', () => { ) }) - it('reports failed worktrees to a caller that answers for them, without a toast', async () => { - mocks.state.shutdownWorktreeBrowsers.mockImplementation((worktreeId: string) => - worktreeId === 'wt-1' ? Promise.reject(new Error('first failed')) : Promise.resolve() - ) - - const outcome = await sleepWorktreesReportingFailures(['wt-1', 'wt-2']) - - expect(outcome.failedWorktreeIds).toEqual(['wt-1']) - expect(outcome.errors).toHaveLength(1) - expect(mocks.toastError).not.toHaveBeenCalled() - }) - it('sleeps multiple worktrees and clears active only once when included', async () => { mocks.state.activeWorktreeId = 'wt-2' diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index 24fb2a707b6b..938e24839734 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -7,6 +7,8 @@ import { } from '@/lib/worktree-sleep-intent' import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor' import { translate } from '@/i18n/i18n' +import { AGENT_SESSION_SLEEP_UNSUPPORTED } from '@/runtime/structured-agent-session-close' +import { resolveWorkspaceChatSleeps, sleepWorkspaceChats } from './sleep-workspace-chats' /** * Shared "sleep worktree" flow (close all panels to free memory / CPU) @@ -112,6 +114,12 @@ function preserveSidebarWorktreePosition(worktreeId: string): () => void { function describeSleepFailure(error: unknown): string { const detail = error instanceof Error ? error.message : String(error) + if (detail === AGENT_SESSION_SLEEP_UNSUPPORTED) { + return translate( + 'auto.components.sidebar.sleep.worktree.flow.chat.unsupported', + "This workspace's host can't stop its chats yet. The workspace was kept open; update the host and try again." + ) + } if (detail.includes('legacy')) { return translate( 'auto.components.sidebar.sleep.worktree.flow.legacy.unverified', @@ -165,12 +173,16 @@ export async function sleepWorktreesReportingFailures( if (worktreeIds.length === 0) { return { failedWorktreeIds: [], errors: [] } } + const state = useAppStore.getState() const { activeWorktreeId, setActiveWorktree, shutdownWorktreeBrowsers, shutdownWorktreeTerminals - } = useAppStore.getState() + } = state + const chatsByWorktreeId = new Map( + worktreeIds.map((worktreeId) => [worktreeId, resolveWorkspaceChatSleeps(state, worktreeId)]) + ) const sleptActiveWorktreeId = activeWorktreeId && worktreeIds.includes(activeWorktreeId) ? activeWorktreeId : null if (sleptActiveWorktreeId) { @@ -214,6 +226,8 @@ export async function sleepWorktreesReportingFailures( // scrollback. See DESIGN_DOC_TERMINAL_HISTORY_FIX_V2.md §3.3.c. await withWorktreeSleepTeardown(worktreeId, async () => { await shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true }) + // Each chat keeps its tab and transcript; its next send starts a new agent. + await sleepWorkspaceChats(chatsByWorktreeId.get(worktreeId) ?? []) if (typeof window !== 'undefined' && window.api?.ephemeralVm?.suspendWorkspace) { await window.api.ephemeralVm.suspendWorkspace({ workspaceId: worktreeId }) } diff --git a/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx b/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx index 4daf1a3b2d0f..99d02799313b 100644 --- a/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx @@ -20,6 +20,7 @@ import { EMPTY_DELETE_STATE_BY_WORKTREE_ID, EMPTY_PTY_IDS_BY_TAB_ID, EMPTY_TABS_BY_WORKTREE, + EMPTY_UNIFIED_TABS_BY_WORKTREE, EMPTY_WORKSPACE_LINEAGE_BY_CHILD_KEY, EMPTY_WORKTREE_LINEAGE_BY_ID, hasWorktreeParentLink, @@ -121,6 +122,9 @@ export function useWorktreeContextMenuModel({ const browserTabsByWorktree = useAppStore((s) => selectMenuScopedMap(menuOpen, s.browserTabsByWorktree, EMPTY_BROWSER_TABS_BY_WORKTREE) ) + const unifiedTabsByWorktree = useAppStore((s) => + selectMenuScopedMap(menuOpen, s.unifiedTabsByWorktree, EMPTY_UNIFIED_TABS_BY_WORKTREE) + ) const deleteStateByWorktreeId = useAppStore((s) => selectMenuScopedMap(menuOpen, s.deleteStateByWorktreeId, EMPTY_DELETE_STATE_BY_WORKTREE_ID) ) @@ -137,17 +141,24 @@ export function useWorktreeContextMenuModel({ hasSleepableWorkspaceActivity(item.id, { tabsByWorktree, ptyIdsByTabId, - browserTabsByWorktree + browserTabsByWorktree, + unifiedTabsByWorktree }) ), - [activeContextWorktrees, browserTabsByWorktree, ptyIdsByTabId, tabsByWorktree] + [ + activeContextWorktrees, + browserTabsByWorktree, + ptyIdsByTabId, + tabsByWorktree, + unifiedTabsByWorktree + ] ) const lineageMenuActions = useWorkspaceLineageMenuActions({ enabled: !isMultiContext, parent: worktree, worktrees: allWorktrees, lineageById: worktreeLineageById, - activity: { tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree } + activity: { tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree, unifiedTabsByWorktree } }) const lineageDescendantCount = lineageMenuActions.descendants.length const subtreeSleepableWorktrees = lineageMenuActions.sleepableTargets diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts index f4a06ccfada0..ea8ab9648493 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import type { Tab } from '../../../../shared/tab-types' import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' import type { Worktree } from '../../../../shared/worktree/types' import { @@ -66,7 +67,8 @@ describe('workspace lineage menu actions', () => { }, browserTabsByWorktree: { [grandchild.id]: [{ id: 'grandchild-browser' }] - } + }, + unifiedTabsByWorktree: {} } }) @@ -91,7 +93,8 @@ describe('workspace lineage menu actions', () => { activity: { tabsByWorktree: { [child.id]: [{ id: 'child-tab' }] }, ptyIdsByTabId: { 'child-tab': ['child-pty'] }, - browserTabsByWorktree: {} + browserTabsByWorktree: {}, + unifiedTabsByWorktree: {} } }) @@ -106,7 +109,8 @@ describe('hasSleepableWorkspaceActivity', () => { hasSleepableWorkspaceActivity('wt-1', { tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, ptyIdsByTabId: { 'tab-1': [] }, - browserTabsByWorktree: {} + browserTabsByWorktree: {}, + unifiedTabsByWorktree: {} }) ).toBe(false) }) @@ -116,14 +120,40 @@ describe('hasSleepableWorkspaceActivity', () => { hasSleepableWorkspaceActivity('wt-1', { tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, ptyIdsByTabId: { 'tab-1': ['pty-1'] }, - browserTabsByWorktree: {} + browserTabsByWorktree: {}, + unifiedTabsByWorktree: {} + }) + ).toBe(true) + expect( + hasSleepableWorkspaceActivity('wt-1', { + tabsByWorktree: {}, + ptyIdsByTabId: {}, + browserTabsByWorktree: { 'wt-1': [{ id: 'browser-1' }] }, + unifiedTabsByWorktree: {} }) ).toBe(true) + }) + + it('offers Sleep for a chat-only workspace', () => { + const chat: Tab = { + id: 'chat-1', + entityId: 'session-1', + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'agent-session', + agentSessionAgent: 'claude', + label: 'Claude', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } expect( hasSleepableWorkspaceActivity('wt-1', { tabsByWorktree: {}, ptyIdsByTabId: {}, - browserTabsByWorktree: { 'wt-1': [{ id: 'browser-1' }] } + browserTabsByWorktree: {}, + unifiedTabsByWorktree: { 'wt-1': [chat] } }) ).toBe(true) }) diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts index 1ff8c20ea6cb..5cd5f6481757 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts @@ -2,12 +2,15 @@ import { useMemo } from 'react' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' import type { Worktree } from '../../../../shared/worktree/types' +import type { Tab } from '../../../../shared/tab-types' import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' +import { getWorktreeIdsWithStructuredChat } from './visible-worktree-activity-inputs' type WorkspaceActivityMaps = { tabsByWorktree: Record ptyIdsByTabId: Record browserTabsByWorktree: Record + unifiedTabsByWorktree: Record } export type WorkspaceLineageMenuActions = { @@ -18,12 +21,18 @@ export type WorkspaceLineageMenuActions = { export function hasSleepableWorkspaceActivity( worktreeId: string, - { tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree }: WorkspaceActivityMaps + { + tabsByWorktree, + ptyIdsByTabId, + browserTabsByWorktree, + unifiedTabsByWorktree + }: WorkspaceActivityMaps ): boolean { const tabs = tabsByWorktree[worktreeId] ?? [] return ( tabs.some((tab) => tabHasLivePty(ptyIdsByTabId, tab.id)) || - (browserTabsByWorktree[worktreeId] ?? []).length > 0 + (browserTabsByWorktree[worktreeId] ?? []).length > 0 || + getWorktreeIdsWithStructuredChat(unifiedTabsByWorktree).has(worktreeId) ) } @@ -54,7 +63,7 @@ export function useWorkspaceLineageMenuActions( args: Parameters[0] & { enabled: boolean } ): WorkspaceLineageMenuActions { const { enabled, parent, worktrees, lineageById, activity } = args - const { tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree } = activity + const { tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree, unifiedTabsByWorktree } = activity return useMemo( () => enabled @@ -62,9 +71,23 @@ export function useWorkspaceLineageMenuActions( parent, worktrees, lineageById, - activity: { tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree } + activity: { + tabsByWorktree, + ptyIdsByTabId, + browserTabsByWorktree, + unifiedTabsByWorktree + } }) : EMPTY_LINEAGE_MENU_ACTIONS, - [browserTabsByWorktree, enabled, lineageById, parent, ptyIdsByTabId, tabsByWorktree, worktrees] + [ + browserTabsByWorktree, + enabled, + lineageById, + parent, + ptyIdsByTabId, + tabsByWorktree, + unifiedTabsByWorktree, + worktrees + ] ) } diff --git a/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts b/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts index 488459af972c..eb845dead13d 100644 --- a/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts +++ b/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts @@ -27,6 +27,7 @@ export const PARENT_PICKER_EXIT_ANIMATION_MS = 200 export const EMPTY_TABS_BY_WORKTREE: AppState['tabsByWorktree'] = {} export const EMPTY_PTY_IDS_BY_TAB_ID: AppState['ptyIdsByTabId'] = {} export const EMPTY_BROWSER_TABS_BY_WORKTREE: AppState['browserTabsByWorktree'] = {} +export const EMPTY_UNIFIED_TABS_BY_WORKTREE: AppState['unifiedTabsByWorktree'] = {} export const EMPTY_DELETE_STATE_BY_WORKTREE_ID: AppState['deleteStateByWorktreeId'] = {} export const EMPTY_WORKTREE_LINEAGE_BY_ID: AppState['worktreeLineageById'] = {} export const EMPTY_WORKSPACE_LINEAGE_BY_CHILD_KEY: AppState['workspaceLineageByChildKey'] = {} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 55f9b369a91b..1a600683a6dd 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6289,7 +6289,10 @@ "host": { "unverified": "The host could not confirm terminal shutdown. The workspace was kept open; check the connection and try again." }, - "retry": "The workspace was kept open. Try again; if the problem continues, check the host connection." + "retry": "The workspace was kept open. Try again; if the problem continues, check the host connection.", + "chat": { + "unsupported": "This workspace's host can't stop its chats yet. The workspace was kept open; update the host and try again." + } } } }, diff --git a/src/renderer/src/runtime/structured-agent-session-close.ts b/src/renderer/src/runtime/structured-agent-session-close.ts index a5f750980945..ea42e8c916d1 100644 --- a/src/renderer/src/runtime/structured-agent-session-close.ts +++ b/src/renderer/src/runtime/structured-agent-session-close.ts @@ -1,4 +1,5 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { STRUCTURED_AGENT_SESSION_SLEEP_RUNTIME_CAPABILITY } from '../../../shared/structured-agent-session-surface-capabilities' import { runtimeEnvironmentSupportsCapability } from './runtime-rpc-client' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import { callStructuredAgentSession } from './structured-agent-session-client' @@ -19,3 +20,22 @@ export async function closeStructuredAgentSession( await callStructuredAgentSession(target, 'agentSession.close', { sessionId }) return 'closed' } + +/** Stops a chat's agent and keeps its tab, for Sleep; throws unless the host proved the stop. */ +export async function sleepStructuredAgentSession( + target: RuntimeClientTarget, + sessionId: string +): Promise { + if ( + target.kind === 'environment' && + !(await runtimeEnvironmentSupportsCapability( + target.environmentId, + STRUCTURED_AGENT_SESSION_SLEEP_RUNTIME_CAPABILITY + )) + ) { + throw new Error(AGENT_SESSION_SLEEP_UNSUPPORTED) + } + await callStructuredAgentSession(target, 'agentSession.sleep', { sessionId }) +} + +export const AGENT_SESSION_SLEEP_UNSUPPORTED = 'agent_session_sleep_unsupported' diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index f0392b87b75e..f0bf5007d5f8 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -681,6 +681,7 @@ export const RPC_PARAMS_BY_METHOD = { 'agentSession.rewind': RewindParams, 'agentSession.send': SendParams, 'agentSession.setOption': SetOptionParams, + 'agentSession.sleep': OptionsParams, 'agentSession.subscribe': SubscribeParams, 'agentSession.subscribeStatus': null, 'agentSession.subscribeTurnCompletions': SubscribeTurnCompletionsParams, diff --git a/src/shared/structured-agent-session-surface-capabilities.ts b/src/shared/structured-agent-session-surface-capabilities.ts index 4aba07206f7c..29446ff9a5ca 100644 --- a/src/shared/structured-agent-session-surface-capabilities.ts +++ b/src/shared/structured-agent-session-surface-capabilities.ts @@ -25,6 +25,10 @@ export const STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY = // adopted the conversation or quietly started a blank one. Negotiate before offering the action. export const STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY = 'agent-session.structured.resume-history.v1' as const +// Why: `agentSession.sleep` stops a chat's agent but keeps its tab; an older host has only the close +// that hides the tab, so a client must learn during negotiation that Sleep cannot stop its chats. +export const STRUCTURED_AGENT_SESSION_SLEEP_RUNTIME_CAPABILITY = + 'agent-session.structured.sleep.v1' as const // Why: `agentSession.modelCatalog` takes `savedOnly`, an answer from what the host saved that starts // no listing. Its params are strict, so an older host refuses the key; a client preloads every agent @@ -38,5 +42,6 @@ export const STRUCTURED_AGENT_SESSION_SURFACE_RUNTIME_CAPABILITIES = [ STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_SLEEP_RUNTIME_CAPABILITY, AGENT_SESSION_MODEL_CATALOG_SAVED_ONLY_RUNTIME_CAPABILITY ] as const From 0d8222b008c4129bd6a975c1e5136a69a0eb31a8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:05:05 -0700 Subject: [PATCH 02/30] Fix native chat Sleep ownership and delayed teardown --- .../sidebar/sleep-workspace-chats.test.ts | 49 ++++++++ .../sidebar/sleep-workspace-chats.ts | 96 +++++++++++++-- .../sleep-worktree-activation-race.test.ts | 3 +- .../sidebar/sleep-worktree-flow.test.ts | 88 +++++++++++++- .../components/sidebar/sleep-worktree-flow.ts | 115 ++++++++++++------ .../use-worktree-context-menu-commands.ts | 8 +- .../use-worktree-context-menu-model.tsx | 13 +- .../workspace-lineage-menu-actions.test.ts | 34 +++++- .../sidebar/workspace-lineage-menu-actions.ts | 28 +++-- src/renderer/src/i18n/locales/en.json | 3 +- .../runtime/structured-agent-session-close.ts | 8 +- 11 files changed, 367 insertions(+), 78 deletions(-) create mode 100644 src/renderer/src/components/sidebar/sleep-workspace-chats.test.ts diff --git a/src/renderer/src/components/sidebar/sleep-workspace-chats.test.ts b/src/renderer/src/components/sidebar/sleep-workspace-chats.test.ts new file mode 100644 index 000000000000..36680b6575ca --- /dev/null +++ b/src/renderer/src/components/sidebar/sleep-workspace-chats.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import type { Tab } from '../../../../shared/tab-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { resolveWorkspaceChatSleeps } from './sleep-workspace-chats' + +function chat(sessionId: string, executionHostId?: ExecutionHostId): Tab { + return { + id: sessionId, + entityId: sessionId, + groupId: 'group', + worktreeId: 'workspace', + contentType: 'agent-session', + agentSessionAgent: 'claude', + label: 'Claude', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + ...(executionHostId ? { executionHostId } : {}) + } +} + +describe('workspace chat Sleep ownership', () => { + it('stops only chats on the selected host when workspace IDs collide', () => { + const state = { + unifiedTabsByWorktree: { + workspace: [chat('local-chat', 'local'), chat('remote-chat', 'runtime:remote')] + } + } + + expect( + resolveWorkspaceChatSleeps(state, { id: 'workspace', repoId: 'repo', hostId: 'local' }) + ).toEqual([{ sessionId: 'local-chat', target: { kind: 'local' } }]) + }) + + it('routes an unstamped saved chat through its known workspace owner', () => { + const state = { + unifiedTabsByWorktree: { workspace: [chat('legacy-chat')] }, + worktreesByRepo: { + repo: [{ id: 'workspace', repoId: 'repo', hostId: 'local' as const }] + }, + repos: [{ id: 'repo', executionHostId: 'local' as const }] + } + + expect( + resolveWorkspaceChatSleeps(state, { id: 'workspace', repoId: 'repo', hostId: 'local' }) + ).toEqual([{ sessionId: 'legacy-chat', target: { kind: 'local' } }]) + }) +}) diff --git a/src/renderer/src/components/sidebar/sleep-workspace-chats.ts b/src/renderer/src/components/sidebar/sleep-workspace-chats.ts index 15c3ae61d1e4..5c3d22d3678b 100644 --- a/src/renderer/src/components/sidebar/sleep-workspace-chats.ts +++ b/src/renderer/src/components/sidebar/sleep-workspace-chats.ts @@ -3,29 +3,105 @@ import { isStructuredTab } from '@/components/native-chat/structured-agent-sessi import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' import { AGENT_SESSION_SLEEP_UNSUPPORTED, - sleepStructuredAgentSession + assertStructuredAgentSessionSleepSupported } from '@/runtime/structured-agent-session-close' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { + findIndexedFolderWorkspaceOwner, + resolveIndexedWorktreeOwner +} from '@/lib/worktree-runtime-owner-index' +import { resolveExplicitWorktreeOperationRouteResult } from '@/lib/worktree-operation-catalog-route' +import { + getRepoExecutionHostId, + parseExecutionHostId, + toRuntimeExecutionHostId +} from '../../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import type { Worktree } from '../../../../shared/worktree/types' +import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner-state' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' export type WorkspaceChatSleep = { sessionId: string; target: RuntimeClientTarget | null } +export type WorkspaceSleepTarget = + | string + | Pick + +type ChatOwnerState = WorktreeRuntimeOwnerState & Pick -/** Each chat routes to the host stamped on its tab; one with none is not stopped, it fails Sleep. */ +function sleepTargetHost(state: ChatOwnerState, target: WorkspaceSleepTarget): string | null { + const worktreeId = typeof target === 'string' ? target : target.id + if (typeof target !== 'string') { + const runtimeOwner = target.runtimeOwnerEnvironmentId?.trim() + if (runtimeOwner) { + return toRuntimeExecutionHostId(runtimeOwner) + } + const stampedHost = parseExecutionHostId(target.hostId) + if (stampedHost) { + return stampedHost.id + } + } + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + const owner = findIndexedFolderWorkspaceOwner(state.folderWorkspaces, scope.folderWorkspaceId) + return owner ? getRepoExecutionHostId(owner) : null + } + const resolution = resolveExplicitWorktreeOperationRouteResult(state, worktreeId) + if (resolution.kind !== 'resolved') { + return null + } + return resolution.route.runtimeEnvironmentId + ? toRuntimeExecutionHostId(resolution.route.runtimeEnvironmentId) + : resolution.route.executionHostId +} + +/** Qualify each chat by the selected workspace host before routing it. */ export function resolveWorkspaceChatSleeps( - state: Pick, - worktreeId: string + state: ChatOwnerState, + target: WorkspaceSleepTarget ): WorkspaceChatSleep[] { - return (state.unifiedTabsByWorktree[worktreeId] ?? []).filter(isStructuredTab).map((tab) => ({ - sessionId: tab.entityId, - target: structuredAgentSessionTargetForHost(tab.executionHostId) - })) + const worktreeId = typeof target === 'string' ? target : target.id + const selectedHost = sleepTargetHost(state, target) + const legacyOwnerAmbiguous = + resolveIndexedWorktreeOwner(state.worktreesByRepo, worktreeId).kind === 'ambiguous' + return (state.unifiedTabsByWorktree[worktreeId] ?? []).filter(isStructuredTab).flatMap((tab) => { + const tabHost = tab.executionHostId ?? (legacyOwnerAmbiguous ? null : selectedHost) + if (tabHost && selectedHost && tabHost !== selectedHost) { + return [] + } + return [ + { + sessionId: tab.entityId, + target: tabHost && selectedHost ? structuredAgentSessionTargetForHost(tabHost) : null + } + ] + }) +} + +/** Resolve predictable failures before browsers or terminals are torn down. */ +export async function preflightWorkspaceChatSleep( + chats: readonly WorkspaceChatSleep[] +): Promise { + const preflight = await Promise.allSettled( + chats.map(({ target }) => + target + ? assertStructuredAgentSessionSleepSupported(target) + : Promise.reject(new Error(AGENT_SESSION_SLEEP_UNSUPPORTED)) + ) + ) + const unsupported = preflight.find( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ) + if (unsupported) { + throw unsupported.reason + } } -/** Stops every chat's agent, keeping the tabs; tries them all, then throws the first failure. */ +/** Issue every stop in the same turn after the caller's final activation check. */ export async function sleepWorkspaceChats(chats: readonly WorkspaceChatSleep[]): Promise { const results = await Promise.allSettled( chats.map(({ sessionId, target }) => target - ? sleepStructuredAgentSession(target, sessionId) + ? callStructuredAgentSession(target, 'agentSession.sleep', { sessionId }) : Promise.reject(new Error(AGENT_SESSION_SLEEP_UNSUPPORTED)) ) ) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts index fb7d07410d33..0858e06bc1ba 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts @@ -30,7 +30,8 @@ const mocks = vi.hoisted(() => { vi.mock('@/store', () => ({ useAppStore: { - getState: () => mocks.state + getState: () => mocks.state, + subscribe: () => () => {} } })) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index e9d9879f8a32..cdac95880727 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -1,26 +1,37 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => { + const subscribers = new Set<(next: typeof state, previous: typeof state) => void>() const state: { activeWorktreeId: string | null + activeWorkspaceExecutionHostId: string | null setActiveWorktree: ReturnType shutdownWorktreeBrowsers: ReturnType shutdownWorktreeTerminals: ReturnType suppressPtyExit: ReturnType consumeSuppressedPtyExit: ReturnType unifiedTabsByWorktree: Record[]> + worktreesByRepo: Record + repos: { id: string; executionHostId: 'local' }[] tabsByWorktree: Record ptyIdsByTabId: Record } = { activeWorktreeId: null, + activeWorkspaceExecutionHostId: null, setActiveWorktree: vi.fn((worktreeId: string | null) => { + const previous = { ...state } state.activeWorktreeId = worktreeId + for (const subscriber of subscribers) { + subscriber(state, previous) + } }), shutdownWorktreeBrowsers: vi.fn().mockResolvedValue(undefined), shutdownWorktreeTerminals: vi.fn().mockResolvedValue(undefined), suppressPtyExit: vi.fn(), consumeSuppressedPtyExit: vi.fn(), unifiedTabsByWorktree: {}, + worktreesByRepo: { repo: [{ id: 'wt-1', repoId: 'repo', hostId: 'local' }] }, + repos: [{ id: 'repo', executionHostId: 'local' }], tabsByWorktree: {} as Record, ptyIdsByTabId: {} as Record } @@ -29,9 +40,16 @@ const mocks = vi.hoisted(() => { const markWorktreeSleepIntent = vi.fn() const clearWorktreeSleepIntent = vi.fn() const sleepChat = vi.fn().mockResolvedValue(undefined) + const preflightChat = vi.fn().mockResolvedValue(undefined) return { clearWorktreeSleepIntent, sleepChat, + preflightChat, + subscribers, + subscribe: (subscriber: (next: typeof state, previous: typeof state) => void) => { + subscribers.add(subscriber) + return () => subscribers.delete(subscriber) + }, markWorktreeSleepIntent, state, suspendWorkspace, @@ -41,7 +59,8 @@ const mocks = vi.hoisted(() => { vi.mock('@/store', () => ({ useAppStore: { - getState: () => mocks.state + getState: () => mocks.state, + subscribe: mocks.subscribe } })) @@ -57,7 +76,10 @@ vi.mock('@/runtime/structured-agent-session-owner', () => ({ })) vi.mock('@/runtime/structured-agent-session-close', () => ({ AGENT_SESSION_SLEEP_UNSUPPORTED: 'agent_session_sleep_unsupported', - sleepStructuredAgentSession: mocks.sleepChat + assertStructuredAgentSessionSleepSupported: mocks.preflightChat +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.sleepChat })) import { runSleepWorktree, runSleepWorktrees } from './sleep-worktree-flow' @@ -83,10 +105,15 @@ describe('runSleepWorktree', () => { mocks.clearWorktreeSleepIntent.mockClear() mocks.toastError.mockClear() mocks.state.activeWorktreeId = null + mocks.state.activeWorkspaceExecutionHostId = null + mocks.subscribers.clear() mocks.state.tabsByWorktree = {} mocks.state.ptyIdsByTabId = {} mocks.state.unifiedTabsByWorktree = {} + mocks.state.worktreesByRepo = { repo: [{ id: 'wt-1', repoId: 'repo', hostId: 'local' }] } + mocks.state.repos = [{ id: 'repo', executionHostId: 'local' }] mocks.sleepChat.mockClear().mockResolvedValue(undefined) + mocks.preflightChat.mockClear().mockResolvedValue(undefined) }) const chatTab = { @@ -102,17 +129,21 @@ describe('runSleepWorktree', () => { await runSleepWorktree('wt-1') - expect(mocks.sleepChat).toHaveBeenCalledWith({ kind: 'local' }, 'session-1') + expect(mocks.sleepChat).toHaveBeenCalledWith({ kind: 'local' }, 'agentSession.sleep', { + sessionId: 'session-1' + }) expect(mocks.toastError).not.toHaveBeenCalled() }) it('keeps the workspace awake when its host cannot stop chats', async () => { mocks.state.unifiedTabsByWorktree = { 'wt-1': [chatTab] } - mocks.sleepChat.mockRejectedValue(new Error('agent_session_sleep_unsupported')) + mocks.preflightChat.mockRejectedValue(new Error('agent_session_sleep_unsupported')) await runSleepWorktree('wt-1') expect(mocks.clearWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') + expect(mocks.state.shutdownWorktreeBrowsers).not.toHaveBeenCalled() + expect(mocks.state.shutdownWorktreeTerminals).not.toHaveBeenCalled() expect(mocks.toastError).toHaveBeenCalledWith( 'Failed to sleep workspace', expect.objectContaining({ @@ -122,6 +153,25 @@ describe('runSleepWorktree', () => { ) }) + it('reports prior teardown when a chat stop fails after preflight', async () => { + mocks.state.unifiedTabsByWorktree = { 'wt-1': [chatTab] } + mocks.sleepChat.mockRejectedValue(new Error('connection lost')) + + await runSleepWorktree('wt-1') + + expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenCalledWith('wt-1') + expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenCalledWith('wt-1', { + keepIdentifiers: true + }) + expect(mocks.toastError).toHaveBeenCalledWith( + 'Failed to sleep workspace', + expect.objectContaining({ + description: + 'The host could not confirm the chat stopped. Other panels may have closed; check the connection and try again.' + }) + ) + }) + it('tears down browsers before terminals on the sleep path', async () => { mocks.state.activeWorktreeId = 'wt-1' @@ -259,7 +309,7 @@ describe('runSleepWorktree', () => { ) const run = runSleepWorktrees(['wt-1', 'wt-2']) - await Promise.resolve() + await vi.waitFor(() => expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenCalled()) // Why: the user clicked wt-2 while wt-1 was tearing down; sleeping it anyway // must not leave the active workspace marked with no clear pending. mocks.state.activeWorktreeId = 'wt-2' @@ -269,6 +319,32 @@ describe('runSleepWorktree', () => { expect(mocks.clearWorktreeSleepIntent).toHaveBeenLastCalledWith('wt-2') }) + it('does not stop a chat after its workspace is reopened during browser teardown', async () => { + mocks.state.activeWorktreeId = 'wt-1' + mocks.state.unifiedTabsByWorktree = { 'wt-1': [chatTab] } + let releaseBrowser: () => void = () => {} + mocks.state.shutdownWorktreeBrowsers.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseBrowser = resolve + }) + ) + + const run = runSleepWorktree('wt-1') + await vi.waitFor(() => expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenCalled()) + const previous = { ...mocks.state } + mocks.state.activeWorktreeId = 'wt-1' + for (const subscriber of mocks.subscribers) { + subscriber(mocks.state, previous) + } + releaseBrowser() + await run + + expect(mocks.sleepChat).not.toHaveBeenCalled() + expect(mocks.state.shutdownWorktreeTerminals).not.toHaveBeenCalled() + expect(mocks.clearWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') + }) + it('marks each worktree only when its own teardown starts', async () => { let releaseFirst: () => void = () => {} mocks.state.shutdownWorktreeBrowsers.mockImplementationOnce( @@ -279,7 +355,7 @@ describe('runSleepWorktree', () => { ) const run = runSleepWorktrees(['wt-1', 'wt-2']) - await Promise.resolve() + await vi.waitFor(() => expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenCalled()) // Why: wt-2 is still awake while wt-1 tears down; marking it early would // hold its panes cold and swallow its activity. diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index 938e24839734..9bcd3d496acc 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -8,7 +8,12 @@ import { import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor' import { translate } from '@/i18n/i18n' import { AGENT_SESSION_SLEEP_UNSUPPORTED } from '@/runtime/structured-agent-session-close' -import { resolveWorkspaceChatSleeps, sleepWorkspaceChats } from './sleep-workspace-chats' +import { + preflightWorkspaceChatSleep, + resolveWorkspaceChatSleeps, + sleepWorkspaceChats, + type WorkspaceSleepTarget +} from './sleep-workspace-chats' /** * Shared "sleep worktree" flow (close all panels to free memory / CPU) @@ -143,36 +148,18 @@ function describeSleepFailure(error: unknown): string { ) } -export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise { - const { errors } = await sleepWorktreesReportingFailures(worktreeIds) - if (errors.length > 0) { - // Why: callers are fire-and-forget; surface actionable teardown failures after restoring retryable UI state. - toast.error( - worktreeIds.length === 1 - ? translate( - 'auto.components.sidebar.sleep.worktree.flow.8bc3fc0671', - 'Failed to sleep workspace' - ) - : translate( - 'auto.components.sidebar.sleep.worktree.flow.c460fecc4a', - 'Failed to sleep some workspaces' - ), - { - description: errors.join('\n') - } - ) - } +function describeChatStopFailure(): string { + return translate( + 'auto.components.sidebar.sleep.worktree.flow.chat.partial', + 'The host could not confirm the chat stopped. Other panels may have closed; check the connection and try again.' + ) } -export type WorktreeSleepOutcome = { failedWorktreeIds: string[]; errors: string[] } - -/** The sleep without the toast, for callers that report failures themselves. */ -export async function sleepWorktreesReportingFailures( - worktreeIds: readonly string[] -): Promise { - if (worktreeIds.length === 0) { - return { failedWorktreeIds: [], errors: [] } +export async function runSleepWorktrees(targets: readonly WorkspaceSleepTarget[]): Promise { + if (targets.length === 0) { + return } + const worktreeIds = targets.map((target) => (typeof target === 'string' ? target : target.id)) const state = useAppStore.getState() const { activeWorktreeId, @@ -180,9 +167,10 @@ export async function sleepWorktreesReportingFailures( shutdownWorktreeBrowsers, shutdownWorktreeTerminals } = state - const chatsByWorktreeId = new Map( - worktreeIds.map((worktreeId) => [worktreeId, resolveWorkspaceChatSleeps(state, worktreeId)]) - ) + const sleepTargets = targets.map((target) => ({ + worktreeId: typeof target === 'string' ? target : target.id, + chats: resolveWorkspaceChatSleeps(state, target) + })) const sleptActiveWorktreeId = activeWorktreeId && worktreeIds.includes(activeWorktreeId) ? activeWorktreeId : null if (sleptActiveWorktreeId) { @@ -195,14 +183,38 @@ export async function sleepWorktreesReportingFailures( setActiveWorktree(null) restoreSidebarPosition() } + const reactivatedWorktreeIds = new Set() + const wasReactivated = (worktreeId: string): boolean => + reactivatedWorktreeIds.has(worktreeId) || useAppStore.getState().activeWorktreeId === worktreeId + const unsubscribe = useAppStore.subscribe((next, previous) => { + if ( + next.activeWorktreeId && + (next.activeWorktreeId !== previous.activeWorktreeId || + next.activeWorkspaceExecutionHostId !== previous.activeWorkspaceExecutionHostId) + ) { + reactivatedWorktreeIds.add(next.activeWorktreeId) + } + }) const errors: string[] = [] const failedWorktreeIds = new Set() try { - for (const worktreeId of worktreeIds) { + for (const { worktreeId, chats } of sleepTargets) { // Why: the marker outlives teardown so the panes left mounted stay cold // until an explicit wake (#10205); mark per workspace so an earlier // slow teardown never leaves a later, still-awake one marked. markWorktreeSleepIntent(worktreeId) + try { + await preflightWorkspaceChatSleep(chats) + } catch (err) { + console.error('[sleep-worktree] chat sleep preflight failed', { worktreeId, error: err }) + failedWorktreeIds.add(worktreeId) + errors.push(describeSleepFailure(err)) + continue + } + if (wasReactivated(worktreeId)) { + clearWorktreeSleepIntent(worktreeId) + continue + } try { // Why: sleep mirrors removeWorktree's shutdown sequence — browsers first // so destroyPersistentWebview unregisters the Chromium guests before any @@ -216,6 +228,11 @@ export async function sleepWorktreesReportingFailures( errors.push(describeSleepFailure(err)) continue } + if (wasReactivated(worktreeId)) { + clearWorktreeSleepIntent(worktreeId) + continue + } + let chatStopFailed = false try { // Why: sleep is reversible — the tab record stays in tabsByWorktree, the // layout stays in terminalLayoutsByTabId, only the live PTY processes are @@ -226,8 +243,19 @@ export async function sleepWorktreesReportingFailures( // scrollback. See DESIGN_DOC_TERMINAL_HISTORY_FIX_V2.md §3.3.c. await withWorktreeSleepTeardown(worktreeId, async () => { await shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true }) + if (wasReactivated(worktreeId)) { + return + } // Each chat keeps its tab and transcript; its next send starts a new agent. - await sleepWorkspaceChats(chatsByWorktreeId.get(worktreeId) ?? []) + try { + await sleepWorkspaceChats(chats) + } catch (error) { + chatStopFailed = true + throw error + } + if (wasReactivated(worktreeId)) { + return + } if (typeof window !== 'undefined' && window.api?.ephemeralVm?.suspendWorkspace) { await window.api.ephemeralVm.suspendWorkspace({ workspaceId: worktreeId }) } @@ -242,10 +270,11 @@ export async function sleepWorktreesReportingFailures( error: err }) failedWorktreeIds.add(worktreeId) - errors.push(describeSleepFailure(err)) + errors.push(chatStopFailed ? describeChatStopFailure() : describeSleepFailure(err)) } } } finally { + unsubscribe() // Why: a failed sleep leaves the workspace awake and retryable. for (const worktreeId of failedWorktreeIds) { clearWorktreeSleepIntent(worktreeId) @@ -254,5 +283,21 @@ export async function sleepWorktreesReportingFailures( setActiveWorktree(sleptActiveWorktreeId) } } - return { failedWorktreeIds: [...failedWorktreeIds], errors } + if (errors.length > 0) { + // Why: callers are fire-and-forget; surface actionable teardown failures after restoring retryable UI state. + toast.error( + worktreeIds.length === 1 + ? translate( + 'auto.components.sidebar.sleep.worktree.flow.8bc3fc0671', + 'Failed to sleep workspace' + ) + : translate( + 'auto.components.sidebar.sleep.worktree.flow.c460fecc4a', + 'Failed to sleep some workspaces' + ), + { + description: errors.join('\n') + } + ) + } } diff --git a/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts b/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts index 1a9d76b2d43d..b0601234be1a 100644 --- a/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts +++ b/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts @@ -144,17 +144,17 @@ export function useWorktreeContextMenuCommands(args: { }) }, [args]) const sleepWorktreesAfterMenuClose = useCallback( - (worktreeIds: string[]) => { + (worktrees: readonly Worktree[]) => { args.setMenuOpenState(false) - window.setTimeout(() => void runSleepWorktrees(worktreeIds), 50) + window.setTimeout(() => void runSleepWorktrees(worktrees), 50) }, [args] ) const handleCloseTerminals = useCallback(() => { - sleepWorktreesAfterMenuClose(args.sleepableWorktrees.map((item) => item.id)) + sleepWorktreesAfterMenuClose(args.sleepableWorktrees) }, [args.sleepableWorktrees, sleepWorktreesAfterMenuClose]) const handleSleepSubtree = useCallback(() => { - sleepWorktreesAfterMenuClose(args.subtreeSleepableWorktrees.map((item) => item.id)) + sleepWorktreesAfterMenuClose(args.subtreeSleepableWorktrees) }, [args.subtreeSleepableWorktrees, sleepWorktreesAfterMenuClose]) const handleDelete = useCallback(() => { const restoreSidebarPosition = preserveDeleteSiblingPosition(args.scopeRef.current) diff --git a/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx b/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx index 99d02799313b..a3d17fc813da 100644 --- a/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx @@ -138,11 +138,12 @@ export function useWorktreeContextMenuModel({ const sleepableWorktrees = useMemo( () => activeContextWorktrees.filter((item) => - hasSleepableWorkspaceActivity(item.id, { + hasSleepableWorkspaceActivity(item, { tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree, - unifiedTabsByWorktree + unifiedTabsByWorktree, + chatOwnerState: useAppStore.getState() }) ), [ @@ -158,7 +159,13 @@ export function useWorktreeContextMenuModel({ parent: worktree, worktrees: allWorktrees, lineageById: worktreeLineageById, - activity: { tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree, unifiedTabsByWorktree } + activity: { + tabsByWorktree, + ptyIdsByTabId, + browserTabsByWorktree, + unifiedTabsByWorktree, + chatOwnerState: useAppStore.getState() + } }) const lineageDescendantCount = lineageMenuActions.descendants.length const subtreeSleepableWorktrees = lineageMenuActions.sleepableTargets diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts index ea8ab9648493..d86522df1257 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts @@ -68,7 +68,8 @@ describe('workspace lineage menu actions', () => { browserTabsByWorktree: { [grandchild.id]: [{ id: 'grandchild-browser' }] }, - unifiedTabsByWorktree: {} + unifiedTabsByWorktree: {}, + chatOwnerState: {} } }) @@ -94,7 +95,8 @@ describe('workspace lineage menu actions', () => { tabsByWorktree: { [child.id]: [{ id: 'child-tab' }] }, ptyIdsByTabId: { 'child-tab': ['child-pty'] }, browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} + unifiedTabsByWorktree: {}, + chatOwnerState: {} } }) @@ -110,7 +112,8 @@ describe('hasSleepableWorkspaceActivity', () => { tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, ptyIdsByTabId: { 'tab-1': [] }, browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} + unifiedTabsByWorktree: {}, + chatOwnerState: {} }) ).toBe(false) }) @@ -121,7 +124,8 @@ describe('hasSleepableWorkspaceActivity', () => { tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, ptyIdsByTabId: { 'tab-1': ['pty-1'] }, browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} + unifiedTabsByWorktree: {}, + chatOwnerState: {} }) ).toBe(true) expect( @@ -129,7 +133,8 @@ describe('hasSleepableWorkspaceActivity', () => { tabsByWorktree: {}, ptyIdsByTabId: {}, browserTabsByWorktree: { 'wt-1': [{ id: 'browser-1' }] }, - unifiedTabsByWorktree: {} + unifiedTabsByWorktree: {}, + chatOwnerState: {} }) ).toBe(true) }) @@ -153,8 +158,25 @@ describe('hasSleepableWorkspaceActivity', () => { tabsByWorktree: {}, ptyIdsByTabId: {}, browserTabsByWorktree: {}, - unifiedTabsByWorktree: { 'wt-1': [chat] } + unifiedTabsByWorktree: { 'wt-1': [chat] }, + chatOwnerState: { + worktreesByRepo: { + 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', hostId: 'local' }] + } + } }) ).toBe(true) + expect( + hasSleepableWorkspaceActivity( + { id: 'wt-1', repoId: 'repo-1', hostId: 'local' }, + { + tabsByWorktree: {}, + ptyIdsByTabId: {}, + browserTabsByWorktree: {}, + unifiedTabsByWorktree: { 'wt-1': [{ ...chat, executionHostId: 'runtime:remote' }] }, + chatOwnerState: {} + } + ) + ).toBe(false) }) }) diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts index 5cd5f6481757..586c33c432a0 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts @@ -3,14 +3,16 @@ import { tabHasLivePty } from '@/lib/tab-has-live-pty' import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' import type { Worktree } from '../../../../shared/worktree/types' import type { Tab } from '../../../../shared/tab-types' +import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner-state' import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' -import { getWorktreeIdsWithStructuredChat } from './visible-worktree-activity-inputs' +import { resolveWorkspaceChatSleeps, type WorkspaceSleepTarget } from './sleep-workspace-chats' type WorkspaceActivityMaps = { tabsByWorktree: Record ptyIdsByTabId: Record browserTabsByWorktree: Record unifiedTabsByWorktree: Record + chatOwnerState: WorktreeRuntimeOwnerState } export type WorkspaceLineageMenuActions = { @@ -20,19 +22,23 @@ export type WorkspaceLineageMenuActions = { } export function hasSleepableWorkspaceActivity( - worktreeId: string, + target: WorkspaceSleepTarget, { tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree, - unifiedTabsByWorktree + unifiedTabsByWorktree, + chatOwnerState }: WorkspaceActivityMaps ): boolean { + const worktreeId = typeof target === 'string' ? target : target.id const tabs = tabsByWorktree[worktreeId] ?? [] return ( tabs.some((tab) => tabHasLivePty(ptyIdsByTabId, tab.id)) || (browserTabsByWorktree[worktreeId] ?? []).length > 0 || - getWorktreeIdsWithStructuredChat(unifiedTabsByWorktree).has(worktreeId) + resolveWorkspaceChatSleeps({ ...chatOwnerState, unifiedTabsByWorktree }, target).some( + (chat) => chat.target !== null + ) ) } @@ -48,7 +54,7 @@ export function getWorkspaceLineageMenuActions(args: { descendants, targets, sleepableTargets: targets.filter((target) => - hasSleepableWorkspaceActivity(target.id, args.activity) + hasSleepableWorkspaceActivity(target, args.activity) ) } } @@ -63,7 +69,13 @@ export function useWorkspaceLineageMenuActions( args: Parameters[0] & { enabled: boolean } ): WorkspaceLineageMenuActions { const { enabled, parent, worktrees, lineageById, activity } = args - const { tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree, unifiedTabsByWorktree } = activity + const { + tabsByWorktree, + ptyIdsByTabId, + browserTabsByWorktree, + unifiedTabsByWorktree, + chatOwnerState + } = activity return useMemo( () => enabled @@ -75,7 +87,8 @@ export function useWorkspaceLineageMenuActions( tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree, - unifiedTabsByWorktree + unifiedTabsByWorktree, + chatOwnerState } }) : EMPTY_LINEAGE_MENU_ACTIONS, @@ -87,6 +100,7 @@ export function useWorkspaceLineageMenuActions( ptyIdsByTabId, tabsByWorktree, unifiedTabsByWorktree, + chatOwnerState, worktrees ] ) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1a600683a6dd..03539b07d65a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6291,7 +6291,8 @@ }, "retry": "The workspace was kept open. Try again; if the problem continues, check the host connection.", "chat": { - "unsupported": "This workspace's host can't stop its chats yet. The workspace was kept open; update the host and try again." + "unsupported": "This workspace's host can't stop its chats yet. The workspace was kept open; update the host and try again.", + "partial": "The host could not confirm the chat stopped. Other panels may have closed; check the connection and try again." } } } diff --git a/src/renderer/src/runtime/structured-agent-session-close.ts b/src/renderer/src/runtime/structured-agent-session-close.ts index ea42e8c916d1..a13c1359f0ce 100644 --- a/src/renderer/src/runtime/structured-agent-session-close.ts +++ b/src/renderer/src/runtime/structured-agent-session-close.ts @@ -21,10 +21,9 @@ export async function closeStructuredAgentSession( return 'closed' } -/** Stops a chat's agent and keeps its tab, for Sleep; throws unless the host proved the stop. */ -export async function sleepStructuredAgentSession( - target: RuntimeClientTarget, - sessionId: string +/** Refuse Sleep before teardown when a paired host cannot stop native chats. */ +export async function assertStructuredAgentSessionSleepSupported( + target: RuntimeClientTarget ): Promise { if ( target.kind === 'environment' && @@ -35,7 +34,6 @@ export async function sleepStructuredAgentSession( ) { throw new Error(AGENT_SESSION_SLEEP_UNSUPPORTED) } - await callStructuredAgentSession(target, 'agentSession.sleep', { sessionId }) } export const AGENT_SESSION_SLEEP_UNSUPPORTED = 'agent_session_sleep_unsupported' From 6dfded8e06147b66e8c1991bf31958c7b772d2b0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:22:58 -0700 Subject: [PATCH 03/30] Stop native chat sessions when sleeping their workspace --- .../host-screen/use-host-worktree-actions.ts | 4 +- .../acp/acp-structured-session-adapter.ts | 2 + .../claude-structured-session-acquisition.ts | 5 +- .../claude-structured-session-adapter.ts | 4 + src/main/codex/codex-app-server-connection.ts | 8 +- .../codex/codex-structured-session-adapter.ts | 2 + src/main/ipc/dashboard-payload-validation.ts | 10 +- ...hemeral-vm-runtime-handler-cleanup.test.ts | 46 +- src/main/ipc/ephemeral-vm-runtime-handlers.ts | 26 +- ...tured-agent-session-adapter-router.test.ts | 11 +- ...structured-agent-session-adapter-router.ts | 54 +- .../structured-agent-session-adapter.ts | 2 + ...ured-agent-session-attach-orchestration.ts | 8 +- ...red-agent-session-conversation-lifetime.ts | 83 +++ .../structured-agent-session-create.ts | 3 +- ...ured-agent-session-host-mutation-wiring.ts | 50 ++ .../structured-agent-session-host.ts | 52 +- ...red-agent-session-surface-lifetime.test.ts | 19 + .../structured-agent-session-task-queue.ts | 50 +- ...tructured-agent-session-workspace-sleep.ts | 79 +++ src/main/pi/rpc-session-adapter.ts | 3 + .../provider-process-teardown.test.ts | 25 +- .../provider-process-teardown.ts | 68 ++- .../runtime/agent-session-record-founding.ts | 14 + ...ession-gate-classification.test-fixture.ts | 5 - ...agent-session-pi-dialog-capability.test.ts | 3 +- ...ructured-agent-session-rpc.test-fixture.ts | 5 +- .../structured-agent-session-schemas.ts | 1 + .../methods/structured-agent-session.test.ts | 10 + .../rpc/methods/structured-agent-session.ts | 47 +- src/main/runtime/rpc/methods/worktree.ts | 9 +- src/main/runtime/runtime-notifier-contract.ts | 3 +- .../runtime/structured-worker-authority.ts | 16 +- .../managed-workspace-operations.ts | 36 +- src/main/window/runtime-window-lifecycle.ts | 3 +- src/preload/api/ephemeral-vm-api.ts | 11 +- ...ui-bridge-clipboard-and-window-controls.ts | 5 +- src/preload/api/ui-command-event-api.ts | 5 +- .../dashboard/useDashboardPopoutBridge.ts | 4 +- .../sidebar/sleep-workspace-chats.test.ts | 49 -- .../sidebar/sleep-workspace-chats.ts | 114 ---- .../sleep-worktree-activation-race.test.ts | 39 +- .../sidebar/sleep-worktree-flow.test.ts | 545 +++++------------- .../components/sidebar/sleep-worktree-flow.ts | 414 ++++++------- .../sleep-worktree-sidebar-position.ts | 86 +++ .../sidebar/workspace-lineage-menu-actions.ts | 42 +- .../pty-connection/run-deferred-connect.ts | 51 +- .../mobile-terminal-close-ipc-bridge.ts | 4 +- src/renderer/src/i18n/locales/en.json | 1 - .../src/lib/ephemeral-vm-runtime-cleanup.ts | 32 +- .../lib/sidebar-worktree-activation.test.ts | 12 + .../src/lib/sidebar-worktree-activation.ts | 5 +- src/renderer/src/lib/worktree-sleep-intent.ts | 148 +++-- .../structured-agent-session-client.ts | 3 +- .../runtime/structured-agent-session-close.ts | 18 - .../worktrees/session/set-active-worktree.ts | 2 +- .../store/terminals/terminal-pty-bindings.ts | 6 +- .../store/terminals/terminal-pty-release.ts | 6 +- .../store/terminals/terminal-tab-creation.ts | 6 +- .../terminal-worktree-sleep-owner.test.ts | 23 + .../terminal-worktree-sleep-owner.ts | 25 + src/shared/dashboard-snapshot.ts | 1 + src/shared/ephemeral-vm-runtime-host-match.ts | 21 + .../rpc-params-catalog.generated.ts | 6 +- .../structured-agent-session-params.ts | 1 + src/shared/rpc-contract/worktree-params.ts | 2 + ...ured-agent-session-surface-capabilities.ts | 5 - 67 files changed, 1388 insertions(+), 1070 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-mutation-wiring.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts delete mode 100644 src/renderer/src/components/sidebar/sleep-workspace-chats.test.ts delete mode 100644 src/renderer/src/components/sidebar/sleep-workspace-chats.ts create mode 100644 src/renderer/src/components/sidebar/sleep-worktree-sidebar-position.ts create mode 100644 src/renderer/src/store/terminals/terminal-worktree-sleep-owner.test.ts create mode 100644 src/renderer/src/store/terminals/terminal-worktree-sleep-owner.ts create mode 100644 src/shared/ephemeral-vm-runtime-host-match.ts diff --git a/mobile/src/host-screen/use-host-worktree-actions.ts b/mobile/src/host-screen/use-host-worktree-actions.ts index 07639fbd590b..29d092a30bc7 100644 --- a/mobile/src/host-screen/use-host-worktree-actions.ts +++ b/mobile/src/host-screen/use-host-worktree-actions.ts @@ -165,7 +165,9 @@ export function useHostWorktreeActions(args: { return } setSleptIds((prev) => new Set(prev).add(getWorktreeRowIdentity(item))) - void worktreeSleep.request(client, { worktree: `id:${item.worktreeId}` }).catch(() => null) + void worktreeSleep + .request(client, { worktree: `id:${item.worktreeId}`, hostId: item.hostId }) + .catch(() => null) }, [client, setSleptIds] ) diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 8b48a97b963f..8505932e2723 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -251,6 +251,8 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte closeSession = (sessionId: string): Promise => this.close(sessionId) disposeSession = (sessionId: string): Promise => this.close(sessionId) releaseAcquisition = (input: { sessionId: string }) => this.close(input.sessionId) + hasOwnedAcquisition = (sessionId: string): boolean => + this.sessions.has(sessionId) || this.starts.failedSessionIds().includes(sessionId) /** After a sink failure: the exit is recovered as unexpected. */ forceCloseSession = (sessionId: string): Promise => this.stop(sessionId, false) diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 1cf0ded65df2..2cca1b5457cb 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -2,6 +2,7 @@ import type { AgentSessionAcquisition, StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { AgentSessionAcquisitionExitUnprovenError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { openClaudeStreamJsonConnection } from './claude-stream-json-connection' import { buildClaudePermissionCallbacks } from './claude-structured-inbound-control' import { resolveClaudeReplayTurn } from './claude-replay-turn-resolution' @@ -307,7 +308,9 @@ export async function acquireClaudeSession({ translator, prompts }) - acquisitions.deleteIfCurrent(sessionId, attempt) + if (!(acquisitionError instanceof AgentSessionAcquisitionExitUnprovenError)) { + acquisitions.deleteIfCurrent(sessionId, attempt) + } throw acquisitionError } finally { attempt.finish() diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index bbc1d6fea64e..cc1237a8e0bf 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -248,6 +248,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda releaseAcquisition = (input: { sessionId: string }): Promise => this.afterClose(input.sessionId, () => this.releaseProviderSession(input.sessionId)) + hasOwnedAcquisition = (sessionId: string): boolean => + this.acquisitions.get(sessionId) !== undefined || + this.sessions.has(sessionId) || + this.exits.has(sessionId) /** A close clears the session's tasks outside `emit`; its ending still reaches the host. */ private async afterClose(sessionId: string, close: () => Promise): Promise { diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index 60ce06db743a..68c3cc5321a0 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -51,7 +51,7 @@ export async function openCodexAppServerConnection( site: 'codex-app-server-teardown', ...(handlers.onOutput ? { onOutput: handlers.onOutput } : {}) }) - const { child, terminateTree: terminateProcessTree } = managed + const { child } = managed let nextRequestId = 1 let closing = false @@ -71,7 +71,7 @@ export async function openCodexAppServerConnection( writeResponse, onProtocolFailure: (error) => { handleUnexpectedEnd(error) - void terminateProcessTree() + void managed.close() } }) @@ -108,7 +108,7 @@ export async function openCodexAppServerConnection( return } handleUnexpectedEnd(error) - void terminateProcessTree() + void managed.close() }) const recordReader = createProviderRecordReader({ @@ -129,7 +129,7 @@ export async function openCodexAppServerConnection( }, onFatal: (error) => { handleUnexpectedEnd(error) - void terminateProcessTree() + void managed.close() } }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index f515e426153e..0323dd121b8e 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -331,6 +331,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap closeAll = (): Promise => this.teardown.closeAll() releaseAcquisition = (input: { sessionId: string }): Promise => this.teardown.close(input.sessionId) + hasOwnedAcquisition = (sessionId: string): boolean => + this.acquisitions.get(sessionId) !== undefined || this.sessions.has(sessionId) private session(sessionId: string): CodexSession { return requireLiveCodexSession(this.sessions, sessionId) diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index 8463be64bba5..859336f26be5 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -93,7 +93,15 @@ export function isDashboardSleepWorkspaceArgs( if (!value || typeof value !== 'object' || Array.isArray(value)) { return false } - return isBoundedString((value as Record).worktreeId, MAX_ID_LENGTH) + if (!('worktreeId' in value)) { + return false + } + const hostId = 'executionHostId' in value ? value.executionHostId : undefined + return ( + isBoundedString(value.worktreeId, MAX_ID_LENGTH) && + (hostId === undefined || + (typeof hostId === 'string' && normalizeExecutionHostId(hostId) !== null)) + ) } export function isDashboardSnapshot(value: unknown): value is DashboardSnapshot { diff --git a/src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts b/src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts index adfabdd35bf0..b3e349e9d7d4 100644 --- a/src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts +++ b/src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts @@ -3,8 +3,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { upsertEphemeralVmRuntime } from '../../shared/ephemeral-vm-runtime-store' +import type { ExecutionHostId } from '../../shared/execution-host' -const handlers = new Map unknown>() +type HandlerArgs = { runtimeId?: string; workspaceId?: string; executionHostId?: ExecutionHostId } +const handlers = new Map unknown>() const { getPathMock, handleMock, removeRuntimeOwnedSshTargetMock, removeHandlerMock } = vi.hoisted( () => ({ getPathMock: vi.fn(), @@ -39,7 +41,7 @@ beforeEach(() => { removeRuntimeOwnedSshTargetMock.mockReset().mockResolvedValue(undefined) removeHandlerMock.mockReset() handleMock.mockImplementation( - (channel: string, handler: (_event: unknown, args: { runtimeId: string }) => unknown) => { + (channel: string, handler: (_event: unknown, args: HandlerArgs) => unknown) => { handlers.set(channel, handler) } ) @@ -51,6 +53,46 @@ afterEach(() => { } }) +it('does not resume a same-id VM on another host or guess between legacy matches', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-vm-runtime-handler-')) + tempDirs.push(userDataPath) + getPathMock.mockReturnValue(userDataPath) + for (const environmentId of ['env-a', 'env-b']) { + upsertEphemeralVmRuntime(userDataPath, { + id: `runtime-${environmentId}`, + recipeId: 'cloud-sandbox', + repoId: 'repo-1', + workspaceId: 'same-workspace', + runtimeEnvironmentId: environmentId, + status: 'suspended', + cleanupStatus: 'not_started', + createdAt: 1, + updatedAt: 1, + recipeResult: { + schemaVersion: 1, + connection: { + type: 'ssh', + projectRoot: '/workspace/repo', + target: { label: 'VM', host: 'host', port: 22, username: 'orca' } + } + } + }) + } + registerEphemeralVmRuntimeHandlers({ getRepo: vi.fn() } as never) + + expect( + await handlers.get('ephemeralVm:resumeWorkspace')?.(null, { + workspaceId: 'same-workspace', + executionHostId: 'runtime:other' + }) + ).toBeNull() + expect( + await handlers.get('ephemeralVm:resumeWorkspace')?.(null, { + workspaceId: 'same-workspace' + }) + ).toBeNull() +}) + it('removes the hidden SSH target when provider cleanup cannot start', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-vm-runtime-handler-')) tempDirs.push(userDataPath) diff --git a/src/main/ipc/ephemeral-vm-runtime-handlers.ts b/src/main/ipc/ephemeral-vm-runtime-handlers.ts index b1354d324027..d41a917e66fa 100644 --- a/src/main/ipc/ephemeral-vm-runtime-handlers.ts +++ b/src/main/ipc/ephemeral-vm-runtime-handlers.ts @@ -30,6 +30,8 @@ import { import { getRuntimeRecipeContext } from './ephemeral-vm-recipe-context' import { invalidateRuntimeEnvironmentTransport } from './runtime-environments' import { attachEphemeralVmRuntimeToWorkspace } from '../ephemeral-vm-runtime-attachment' +import { ephemeralVmMatchesWorkspaceHost } from '../../shared/ephemeral-vm-runtime-host-match' +import type { ExecutionHostId } from '../../shared/execution-host' export type EphemeralVmCleanupCommandResult = { runtimeId: string @@ -153,14 +155,20 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { ipcMain.handle( 'ephemeralVm:suspendWorkspace', - async (_event, args: { workspaceId: string }): Promise => { + async ( + _event, + args: { workspaceId: string; executionHostId?: ExecutionHostId } + ): Promise => { const userDataPath = app.getPath('userData') - const runtime = listEphemeralVmRuntimes(userDataPath).find( + const candidates = listEphemeralVmRuntimes(userDataPath).filter( (entry) => entry.workspaceId === args.workspaceId && entry.status !== 'cleaned' && - entry.status !== 'cleanup_pending' + entry.status !== 'cleanup_pending' && + (!args.executionHostId || + ephemeralVmMatchesWorkspaceHost(entry, args.workspaceId, args.executionHostId)) ) + const runtime = candidates.length === 1 ? candidates[0] : null if (!runtime?.repoId) { return null } @@ -189,14 +197,20 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { ipcMain.handle( 'ephemeralVm:resumeWorkspace', - async (_event, args: { workspaceId: string }): Promise => { + async ( + _event, + args: { workspaceId: string; executionHostId?: ExecutionHostId } + ): Promise => { const userDataPath = app.getPath('userData') - const runtime = listEphemeralVmRuntimes(userDataPath).find( + const candidates = listEphemeralVmRuntimes(userDataPath).filter( (entry) => entry.workspaceId === args.workspaceId && entry.status !== 'cleaned' && - entry.status !== 'cleanup_pending' + entry.status !== 'cleanup_pending' && + (!args.executionHostId || + ephemeralVmMatchesWorkspaceHost(entry, args.workspaceId, args.executionHostId)) ) + const runtime = candidates.length === 1 ? candidates[0] : null if (!runtime?.repoId) { return null } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts index 27c4a323506d..8545e6009a9c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts @@ -39,6 +39,7 @@ function adapterOf( return { acquire: vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)), releaseAcquisition, + hasOwnedAcquisition: () => true, dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), @@ -47,7 +48,7 @@ function adapterOf( } describe('StructuredAgentSessionAdapterRouter.releaseAcquisition', () => { - it('drops the owner even when its release reports a typed failure', async () => { + it('retains the actual owner after an unproved release for a scoped retry', async () => { const failure = new Error('root exited') const claude = adapterOf(vi.fn().mockRejectedValueOnce(failure).mockResolvedValue(false)) const codex = adapterOf(vi.fn(async () => false)) @@ -56,10 +57,9 @@ describe('StructuredAgentSessionAdapterRouter.releaseAcquisition', () => { await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) await expect(router.releaseAcquisition({ sessionId: 'session-1' })).rejects.toBe(failure) - // With no owner left, a later release asks every adapter instead of the stale one. await expect(router.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(false) expect(claude.releaseAcquisition).toHaveBeenCalledTimes(2) - expect(codex.releaseAcquisition).toHaveBeenCalledTimes(1) + expect(codex.releaseAcquisition).not.toHaveBeenCalled() }) }) @@ -359,10 +359,9 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => { await router.closeAll() resolveAcquire(acquisition(2, 'spawn-2')) - // The route is NOT published behind a closed adapter, so nothing routes back out to it — and - // with no route the router has nothing to stop and no stop to report. + // Shutdown preserves the stop receipt for an acquisition that resolves behind it. await expect(acquiring).rejects.toThrow('router is closed') - await expect(router.closeSession('session-1')).resolves.toBe(false) + await expect(router.closeSession('session-1')).resolves.toBe(true) expect(closeSession).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index cb842d04c846..ec8461ca8614 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -13,7 +13,7 @@ import type { type SessionRoute = { registration: StructuredAgentRegistration - state: 'live' | 'stopped' + state: 'acquiring' | 'live' | 'stopped' } /** Routes each session to the adapter its agent is registered with. Adding an agent is one more @@ -44,21 +44,49 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi throw new Error('structured session adapter router is closed') } const registration = this.requireAgent(input.identity) - const acquired = await registration.adapter.acquire(input) - if (this.allAdaptersClosed) { - throw new Error('structured session adapter router is closed') + const route: SessionRoute = { registration, state: 'acquiring' } + this.routes.set(input.identity.sessionId, route) + try { + const acquired = await registration.adapter.acquire(input) + if (this.allAdaptersClosed) { + throw new Error('structured session adapter router is closed') + } + route.state = 'live' + return acquired + } catch (error) { + if ( + this.routes.get(input.identity.sessionId) === route && + !registration.adapter.hasOwnedAcquisition?.(input.identity.sessionId) + ) { + this.routes.delete(input.identity.sessionId) + } + throw error } - this.routes.set(input.identity.sessionId, { registration, state: 'live' }) - return acquired } async releaseAcquisition(input: { sessionId: string }): Promise { const route = this.routes.get(input.sessionId) if (route) { + if (!route.registration.adapter.hasOwnedAcquisition?.(input.sessionId)) { + if (this.routes.get(input.sessionId) === route) { + this.routes.delete(input.sessionId) + } + return false + } try { - return (await route.registration.adapter.releaseAcquisition?.(input)) === true - } finally { - this.routes.delete(input.sessionId) + const stopped = (await route.registration.adapter.releaseAcquisition?.(input)) === true + if (stopped && this.routes.get(input.sessionId) === route) { + this.routes.delete(input.sessionId) + } + return stopped + } catch (error) { + if ( + this.routes.get(input.sessionId) === route && + !route.registration.adapter.hasOwnedAcquisition?.(input.sessionId) + ) { + this.routes.delete(input.sessionId) + } + throw error } } let released = false @@ -233,6 +261,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi if (route.state === 'stopped') { return true } + if (route.state === 'acquiring') { + return this.releaseAcquisition({ sessionId }) + } const { adapter } = route.registration const stop = selectStop(adapter) const stopped = await stop?.call(adapter, sessionId) @@ -243,6 +274,11 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return false } + hasOwnedAcquisition = (sessionId: string): boolean => { + const route = this.routes.get(sessionId) + return route?.registration.adapter.hasOwnedAcquisition?.(sessionId) === true + } + async closeAll(): Promise { if (this.allAdaptersClosed) { return diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 664cb423838b..3176fdda0718 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -298,6 +298,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & * `AgentSessionAcquisitionRootExitObservedError` when the provider root's own * exit was observed first-hand but its descendants were not proven gone. */ releaseAcquisition?(input: { sessionId: string }): Promise + /** Whether this adapter still owns a current child or failed acquisition for this session. */ + hasOwnedAcquisition?(sessionId: string): boolean dispatch(input: { sessionId: string clientMessageId: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 2fdc842b335f..dbf0c82768ea 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -69,7 +69,10 @@ export function attachStructuredAgentSessionUnderSerialize( params: AgentSessionAttachParams, options: StructuredAgentSessionAttachOptions = {} ): Promise> { - return context.tasks.trackAttach(runAttach(context, callerKey, params, options)) + return context.tasks.trackAttach(runAttach(context, callerKey, params, options), { + sessionId: params.envelope.sessionId, + location: params.location + }) } export function attachStructuredAgentSession( @@ -85,7 +88,8 @@ export function attachStructuredAgentSession( context.tasks.trackAttach( context.serialize(sessionId, () => runAttach(context, callerKey, params, { ...options, recordPhase }) - ) + ), + { sessionId, location: params.location } ) if (params.envelope.expectedRuntimeFence !== null) { return run() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 6ab7eb4c841f..f654e33cf0bd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -26,6 +26,9 @@ import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { deferredStructuredAgentSessionLogger } from './structured-agent-session-logger' import { sameProviderChild } from './structured-agent-session-provider-child' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { structuredSessionNeverStarted } from '../../runtime/agent-session-record-founding' +import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' import type { StructuredAgentSessionExpiredStartup } from './structured-agent-session-startup-attempt' export type StructuredAgentSessionConversationLifetime = ReturnType< @@ -84,6 +87,85 @@ export function createStructuredAgentSessionConversationLifetime(host: { const providerHoldsDispatch = (sessionId: string): boolean => deps().adapter.holdsDispatch?.(sessionId) === true + const closeForWorkspaceSleep = ( + sessionId: string, + workspaceId: string, + executionHostId: ExecutionHostId + ): Promise<{ fence: number; stopped: boolean } | null> => { + const matches = (): boolean => { + const location = deps().store.getRecord(sessionId)?.location + return location?.workspaceId === workspaceId && location.executionHostId === executionHostId + } + // Pending creates do not yet have a record; their queued close waits for founding to finish. + if (matches()) { + host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') + } + return serialize(sessionId, async () => { + if (!matches()) { + return null + } + readRefusals.forget(sessionId) + const record = deps().store.getRecord(sessionId) + if (!record) { + return null + } + const fence = record.lease.runtimeFence + const session = sessions.get(sessionId) + const child = session?.child + const owned = deps().adapter.hasOwnedAcquisition?.(sessionId) === true + if (session) { + await holdClosedStructuredAgentSessionSends(deps(), sessionId, session.journal, { + mark: 'always' + }) + } + let ownedStopped = false + try { + if (child) { + await stopAgent(sessionId, { cause: 'user-close' }) + } else if (owned) { + ownedStopped = await stopAgentSessionProviderRoot( + () => deps().adapter.releaseAcquisition?.({ sessionId }) ?? Promise.resolve(false) + ) + } + } catch (error) { + deps().logger.warn('workspace chat stop was not proven', { + scope: 'workspace-chat-stop', + sessionId, + error + }) + } + const ended = sessions.get(sessionId)?.lastEndedChild + const after = deps().store.getRecord(sessionId) + const childStopped = + !!child && + !sessions.get(sessionId)?.child && + ended?.rootGone === true && + sameProviderChild(ended, child) + const durableStopped = + (child || owned) && + after?.lease.runtimeFence === fence && + after.lease.claimStatus === 'released' && + !!after.lease.deathEvidence + const stopped = + childStopped || + (owned && ownedStopped) || + (!child && !owned && !!after && structuredSessionNeverStarted(after)) || + durableStopped + try { + await closeConversation(sessionId) + } catch (error) { + deps().logger.warn('closing a stopped workspace chat handle failed', { + scope: 'workspace-chat-close', + sessionId, + error + }) + } + // Lease release advances the fence; preserve the value after this close for the caller's + // final identity check against a later restart. + return { fence: deps().store.getRecord(sessionId)?.lease.runtimeFence ?? fence, stopped } + }) + } + const idleSweep = new StructuredAgentSessionIdleSweep({ sessions, serialize, @@ -112,6 +194,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { }) return { + closeForWorkspaceSleep, idleSweep, stopAgent, /** A host stop of the child the expired attempt published, if it is still that one and still diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts index d9c149c50af0..fc3b5968fa28 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts @@ -54,7 +54,8 @@ export function createStructuredAgentSession( } throw error } - }) + }), + { sessionId: params.envelope.sessionId, location: params.location } ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutation-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutation-wiring.ts new file mode 100644 index 000000000000..fc6f225970fa --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutation-wiring.ts @@ -0,0 +1,50 @@ +import * as agentStart from './structured-agent-session-agent-start' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import type { StructuredAgentSessionClientDelivery } from './structured-agent-session-client-delivery' +import type { StructuredAgentSessionConversationLifetime } from './structured-agent-session-conversation-lifetime' +import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' +import type * as conversation from './structured-agent-session-host-delivery' +import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import type { wireStructuredAgentSessionQueuedMessages } from './structured-agent-session-queued-wiring' + +type Inputs = { + deps: StructuredAgentSessionHostDeps + sessions: StructuredAgentSessionConversations + subscribers: StructuredAgentSessionClientDelivery['subscribers'] + lifetime: StructuredAgentSessionConversationLifetime + clientDelivery: StructuredAgentSessionClientDelivery + conversationDelivery: conversation.StructuredAgentSessionConversationDelivery + queued: ReturnType + runtimeState: StructuredAgentSessionHostRuntimeState + attachContext: () => StructuredAgentSessionAttachContext + serialize: StructuredAgentSessionMutationContext['serialize'] + now: () => number +} + +export function createStructuredAgentSessionHostMutationContext( + input: Inputs +): StructuredAgentSessionMutationContext { + return { + deps: input.deps, + sessions: input.sessions, + publish: (sessionId, journal) => input.subscribers.publish(sessionId, journal), + conversation: input.lifetime.conversation, + readChildWork: input.clientDelivery.readChildWork, + publishChildWork: input.clientDelivery.publishChildWork, + childWorkIncomplete: input.clientDelivery.childWorkIncomplete, + serialize: input.serialize, + openConversation: input.conversationDelivery.open, + ensureAgent: (sessionId) => + agentStart.ensureStructuredAgentSessionAgentForOperation(input.attachContext(), sessionId), + joinChildClose: (sessionId) => + agentStart.joinClosingStructuredAgentSessionChild(input.attachContext(), sessionId), + wakeDelivery: (sessionId) => input.conversationDelivery.loop.wake(sessionId), + stopAgent: (sessionId, ending) => input.lifetime.stopAgent(sessionId, ending), + wakeQueuedDrain: (sessionId) => input.queued.drain.schedule(sessionId), + acquireAborts: input.runtimeState.acquireAborts, + optionRevisions: input.runtimeState.optionRevisions, + now: input.now + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 18781369de43..801ecb0f4ec2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -8,7 +8,6 @@ import { StructuredConversationCommandController } from './structured-conversati import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' -import type * as SessionWire from '../../../shared/agent-session-wire' import { createRestartReconciler } from './structured-agent-session-restart-reconcile' import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' @@ -19,6 +18,7 @@ import { StructuredAgentSessionHostRuntimeState } from './structured-agent-sessi import { structuredAgentSessionEntryDelegates } from './structured-agent-session-create' import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import * as agentStart from './structured-agent-session-agent-start' +import { createStructuredAgentSessionHostMutationContext } from './structured-agent-session-host-mutation-wiring' import { createStructuredAgentSessionConversationLifetime, type StructuredAgentSessionConversationLifetime @@ -49,6 +49,7 @@ import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent- import * as conversation from './structured-agent-session-host-delivery' import { wireStructuredAgentSessionQueuedMessages } from './structured-agent-session-queued-wiring' import * as sessionLogger from './structured-agent-session-logger' +import { sleepStructuredAgentSessionWorkspace } from './structured-agent-session-workspace-sleep' export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' export class StructuredAgentSessionHost { @@ -207,6 +208,20 @@ export class StructuredAgentSessionHost { close: StructuredAgentSessionConversationLifetime['close'] = (sessionId, cause) => this.lifetime.close(sessionId, cause) + /** One bounded host-owned snapshot; a tab need not be visible for its child to be stopped. */ + sleepWorkspace = (workspaceId: string): Promise<{ stoppedSessionIds: string[] }> => + sleepStructuredAgentSessionWorkspace( + { + deps: this.deps, + tasks: this.tasks, + lifetime: this.lifetime, + sessions: this.sessions, + deliveryRunning: (sessionId) => this.conversationDelivery.loop.isRunning(sessionId), + childWorkIncomplete: this.clientDelivery.childWorkIncomplete + }, + workspaceId + ) + supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent) @@ -269,29 +284,20 @@ export class StructuredAgentSessionHost { }).finally(() => this.clientDelivery.closeAll()) } - private mutationContext(): StructuredAgentSessionMutationContext { - return { + private mutationContext = (): StructuredAgentSessionMutationContext => + createStructuredAgentSessionHostMutationContext({ deps: this.deps, sessions: this.sessions, - publish: (sessionId, journal) => this.subscribers.publish(sessionId, journal), - conversation: this.lifetime.conversation, - readChildWork: this.clientDelivery.readChildWork, - publishChildWork: this.clientDelivery.publishChildWork, - childWorkIncomplete: this.clientDelivery.childWorkIncomplete, + subscribers: this.subscribers, + lifetime: this.lifetime, + clientDelivery: this.clientDelivery, + conversationDelivery: this.conversationDelivery, + queued: this.queued, + runtimeState: this.runtimeState, + attachContext: () => this.attachContext(), serialize: (sessionId, task) => this.serialize(sessionId, task), - openConversation: this.conversationDelivery.open, - ensureAgent: (sessionId) => - agentStart.ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), - joinChildClose: (sessionId) => - agentStart.joinClosingStructuredAgentSessionChild(this.attachContext(), sessionId), - wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), - stopAgent: (sessionId, ending) => this.lifetime.stopAgent(sessionId, ending), - wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), - acquireAborts: this.runtimeState.acquireAborts, - optionRevisions: this.runtimeState.optionRevisions, now: () => this.now() - } - } + }) send = this.conversationCommands.send @@ -313,14 +319,12 @@ export class StructuredAgentSessionHost { rewind = (caller: StructuredAgentSessionCaller, params: AgentSessionRewindParams) => rewindStructuredAgentSession(this.mutationContext(), this.attachContext(), caller, params) - conversationCommand = (...args: Parameters) => - this.conversationCommands.run(...args) + conversationCommand = this.conversationCommands.run /** Undefined means unavailable; an empty array is an authoritative catalog. */ readCommands = (sessionId: string) => ({ commands: this.clientDelivery.readCommands(sessionId) }) /** From the record store, never the session map: an idle-released chat has no map entry. */ - handoffStatus = (sessionId: string): SessionWire.AgentSessionHandoffStatus => - structuredAgentSessionOwnerStatus(this.deps, sessionId) + handoffStatus = (sessionId: string) => structuredAgentSessionOwnerStatus(this.deps, sessionId) history: StructuredAgentSessionBackgroundTaskChannel['history'] = (request, scope) => this.backgroundTasks.history(request, scope) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index d0b286b24b91..970a542d69aa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -250,6 +250,25 @@ afterEach(async () => { }) describe('a chat that closes', () => { + it('sleeps a workspace child and retains its conversation with a positive stop receipt', async () => { + await attach() + await host['tasks'].drainAttaches() + + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) + expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(store.getRecord(SESSION)).not.toBeNull() + expect(host.hasSession(SESSION)).toBe(false) + expect(await host.sleepWorkspace('other-workspace')).toEqual({ stoppedSessionIds: [] }) + }) + + it('omits an unproven workspace child from the stop receipt', async () => { + await attach() + closeSession.mockResolvedValueOnce(false) + + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [] }) + expect(store.getRecord(SESSION)).not.toBeNull() + }) + it('stops the provider child it started', async () => { await attach() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts index f0237835c756..1121c4bc6770 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts @@ -1,22 +1,64 @@ -import { runKeyedSerializedOperation } from '../../cli/keyed-promise-queue' +import { + getKeyedSerializedQueueTail, + runKeyedSerializedOperation +} from '../../cli/keyed-promise-queue' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' export class StructuredAgentSessionTaskQueue { private readonly chains = new Map>() private readonly attaching = new Set>() + private readonly pendingLocations = new Map< + Promise, + { + sessionId: string + location: AgentSessionExecutionLocation + } + >() serialize(sessionId: string, task: () => Promise): Promise { return runKeyedSerializedOperation(this.chains, sessionId, task) } - trackAttach(operation: Promise): Promise { + trackAttach( + operation: Promise, + pending?: { sessionId: string; location: AgentSessionExecutionLocation } + ): Promise { this.attaching.add(operation) + if (pending) { + this.pendingLocations.set(operation, pending) + } void operation.then( - () => this.attaching.delete(operation), - () => this.attaching.delete(operation) + () => this.finishAttach(operation), + () => this.finishAttach(operation) ) return operation } + private finishAttach(operation: Promise): void { + this.attaching.delete(operation) + this.pendingLocations.delete(operation) + } + + pendingMembers(workspaceId: string, executionHostId: string): string[] { + return [...this.pendingLocations.values()] + .filter( + ({ location }) => + location.workspaceId === workspaceId && location.executionHostId === executionHostId + ) + .map(({ sessionId }) => sessionId) + } + + hasPending(sessionId: string): boolean { + return ( + this.chains.has(sessionId) || + [...this.pendingLocations.values()].some((pending) => pending.sessionId === sessionId) + ) + } + + waitForCurrent(sessionId: string): Promise { + return getKeyedSerializedQueueTail(this.chains, sessionId) + } + async drainAttaches(): Promise { while (this.attaching.size > 0) { await Promise.allSettled(this.attaching) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts new file mode 100644 index 000000000000..9a315c8dbf13 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts @@ -0,0 +1,79 @@ +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import type { StructuredAgentSessionConversationLifetime } from './structured-agent-session-conversation-lifetime' +import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' +import type { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' + +type WorkspaceSleepHost = { + deps: Pick + tasks: StructuredAgentSessionTaskQueue + lifetime: Pick + sessions: StructuredAgentSessionConversations + deliveryRunning: (sessionId: string) => boolean + childWorkIncomplete: (sessionId: string) => boolean +} + +/** Snapshot host-owned members and return only same-call, current positive stop receipts. */ +export async function sleepStructuredAgentSessionWorkspace( + host: WorkspaceSleepHost, + workspaceId: string +): Promise<{ stoppedSessionIds: string[] }> { + let records: ReturnType + try { + records = host.deps.store.listRecords() + } catch (error) { + host.deps.logger.warn('workspace chat inventory failed', { + scope: 'workspace-chat-inventory', + workspaceId, + error + }) + return { stoppedSessionIds: [] } + } + const belongs = (location: AgentSessionExecutionLocation): boolean => + location.workspaceId === workspaceId && location.executionHostId === LOCAL_EXECUTION_HOST_ID + const candidates = new Set([ + ...records.filter((record) => belongs(record.location)).map((record) => record.sessionId), + ...host.tasks.pendingMembers(workspaceId, LOCAL_EXECUTION_HOST_ID) + ]) + const outcomes = await Promise.allSettled( + [...candidates].map((sessionId) => + host.lifetime + .closeForWorkspaceSleep(sessionId, workspaceId, LOCAL_EXECUTION_HOST_ID) + .then((result) => ({ sessionId, result })) + ) + ) + await Promise.all([...candidates].map((sessionId) => host.tasks.waitForCurrent(sessionId))) + const stoppedSessionIds = outcomes.flatMap((outcome) => { + if (outcome.status !== 'fulfilled' || !outcome.value.result?.stopped) { + return [] + } + const { sessionId, result } = outcome.value + try { + const record = host.deps.store.getRecord(sessionId) + const session = host.sessions.get(sessionId) + if ( + !record || + !belongs(record.location) || + record.lease.runtimeFence !== result.fence || + session?.child || + host.tasks.hasPending(sessionId) || + host.deliveryRunning(sessionId) || + host.childWorkIncomplete(sessionId) || + session?.journal.pendingSubmissions().length || + host.deps.hasOpenDispatch?.(record) + ) { + return [] + } + return [sessionId] + } catch (error) { + host.deps.logger.warn('workspace chat stop receipt could not be checked', { + scope: 'workspace-chat-receipt', + sessionId, + error + }) + return [] + } + }) + return { stoppedSessionIds } +} diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index 139268fe0a78..11452053b28b 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -263,6 +263,9 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { releaseAcquisition(input: { sessionId: string }): Promise { return this.closeSession(input.sessionId) } + hasOwnedAcquisition(sessionId: string): boolean { + return this.sessions.has(sessionId) + } forceCloseSession(id: string): Promise { this.sessions.get(id)?.fail(new Error('Pi event sink failed')) return this.closeSession(id, false) diff --git a/src/main/provider-process/provider-process-teardown.test.ts b/src/main/provider-process/provider-process-teardown.test.ts index 41ad22387d7c..249a553df092 100644 --- a/src/main/provider-process/provider-process-teardown.test.ts +++ b/src/main/provider-process/provider-process-teardown.test.ts @@ -183,18 +183,35 @@ describe('terminateProviderProcessTree', () => { ['live', 'live'], ['unverifiable', 'unverifiable'] ] as const)( - 'reports a %s descendant snapshot as-is and leaves the stopped root resumable', + 'reports a %s descendant snapshot after killing anchored child groups and the held root', async (observed, verdict) => { const target = child() + const signalProcessGroup = vi.fn() + const signalProcess = vi.fn() await expect( terminateProviderProcessTree(target, { site: 'codex-app-server-teardown', platform: 'darwin', - captureDescendants: async () => ({ rootPgid: 1234, descendants: [], capturedAtMs: 1 }), - terminateDescendants: async () => observed + captureDescendants: async () => ({ + rootPgid: 1234, + descendants: [ + { pid: 2000, ppid: 1234, pgid: 2000, startedAt: 'now' }, + { pid: 3000, ppid: 2000, pgid: 2000, startedAt: 'now' } + ], + capturedAtMs: 1 + }), + terminateDescendants: async () => observed, + signalProcessGroup, + signalProcess }) ).resolves.toBe(verdict) - expect(target.kill).toHaveBeenLastCalledWith('SIGCONT') + expect(signalProcessGroup).toHaveBeenCalledWith(2000, 'SIGKILL') + expect(signalProcess).toHaveBeenCalledWith(2000, 'SIGKILL') + expect(signalProcess).not.toHaveBeenCalledWith(3000, 'SIGKILL') + expect(target.kill).toHaveBeenLastCalledWith('SIGKILL') + expect(signalProcess.mock.invocationCallOrder[0]).toBeLessThan( + target.kill.mock.invocationCallOrder.at(-1)! + ) } ) diff --git a/src/main/provider-process/provider-process-teardown.ts b/src/main/provider-process/provider-process-teardown.ts index 32820726feef..c177fda2ac52 100644 --- a/src/main/provider-process/provider-process-teardown.ts +++ b/src/main/provider-process/provider-process-teardown.ts @@ -23,6 +23,7 @@ export type ProviderProcessTeardownDeps = { /** Resolves true only when taskkill reports the whole tree terminated. */ terminateWindowsTree?: (rootPid: number, deps?: { site?: string }) => Promise signalProcessGroup?: (pgid: number, signal: NodeJS.Signals) => void + signalProcess?: (pid: number, signal: NodeJS.Signals) => void } function terminateDedicatedPosixGroup( @@ -66,41 +67,48 @@ async function terminatePosixTree( const terminate = deps.terminateDescendants ?? ((captured: DescendantSnapshot) => terminateDescendantSnapshotWithVerdict(captured)) - const verdict = await terminate(snapshot) - const descendantsExited = verdict === 'exited' - // A detached POSIX launch is the leader of its own process group. Group - // signalling reaches grandchildren even after they daemonise/reparent, - // while the stopped root and captured pgid make the ownership proof exact. - // The identity-gated descendant sweep remains the fallback for older hosts - // or launches that could not establish a dedicated group. - if (descendantsExited && snapshot.rootPgid === rootPid) { + let verdict: ProviderProcessTeardownVerdict = 'unverifiable' + try { + verdict = await terminate(snapshot) + } finally { + const signal = deps.signalProcess ?? process.kill const signalGroup = deps.signalProcessGroup ?? - ((pgid: number, signal: NodeJS.Signals) => process.kill(-pgid, signal)) - let groupSignalled = false - try { - signalGroup(snapshot.rootPgid, 'SIGKILL') - groupSignalled = true - } catch { - // Already-gone is still the desired outcome, but nothing here killed it, - // and a crumb for a kill we never landed is a false render-process-gone suspect. + ((pgid: number, toSend: NodeJS.Signals) => process.kill(-pgid, toSend)) + // The stopped root cannot reap or release its direct children's PIDs. Its snapshot pins + // their identity until after this root is killed, even if a later table read fails. + for (const descendant of snapshot.descendants) { + if (descendant.ppid !== rootPid) { + continue + } + if (descendant.pgid === descendant.pid) { + try { + signalGroup(descendant.pid, 'SIGKILL') + } catch { + // An already exited group is safe to ignore. + } + } + try { + signal(descendant.pid, 'SIGKILL') + } catch { + // The root still has to be stopped when a child rejects a signal. + } } - if (groupSignalled) { - // Outside the try, as in terminateDedicatedPosixGroup: that catch is the - // already-gone contract, not a breadcrumb handler. - recordSelfInitiatedTreeKill({ - pid: snapshot.rootPgid, - site: deps.site, - scope: 'posix-process-group' - }) + if (snapshot.rootPgid === rootPid) { + try { + signalGroup(rootPid, 'SIGKILL') + recordSelfInitiatedTreeKill({ + pid: rootPid, + site: deps.site, + scope: 'posix-process-group' + }) + } catch { + // The held root is still addressed through its exact child handle below. + } } + child.kill('SIGKILL') } - if (!descendantsExited) { - child.kill('SIGCONT') - return verdict - } - child.kill('SIGKILL') - return 'exited' + return verdict } /** Stops every process owned by one provider launch before releasing its wrapper. */ diff --git a/src/main/runtime/agent-session-record-founding.ts b/src/main/runtime/agent-session-record-founding.ts index 7c750ca3dc12..c79265f088e5 100644 --- a/src/main/runtime/agent-session-record-founding.ts +++ b/src/main/runtime/agent-session-record-founding.ts @@ -42,6 +42,20 @@ export function agentSessionRecordIdentityFields( /** The fence a founded conversation starts at; every reservation moves it. */ export const AGENT_SESSION_FOUNDING_FENCE = 1 +/** A founded row with no reservation, owner, or provider handle has never started an agent. */ +export function structuredSessionNeverStarted(record: AgentSessionRecord): boolean { + const { lease } = record + return ( + lease.claimStatus === 'released' && + lease.runtimeFence === AGENT_SESSION_FOUNDING_FENCE && + lease.minimumNextFence === undefined && + lease.ownerProcess === null && + lease.reservedSpawnToken === null && + lease.handoffStage === null && + record.providerHandleChain.length === 0 + ) +} + /** * A conversation no agent has run yet, at rest: its first send starts one. The empty handle chain * is what makes that start a fresh conversation rather than a resume. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts index e1d83a80a058..12813a8f66ea 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts @@ -16,11 +16,6 @@ export const CLEANUP_METHODS = [ params: { sessionId: SESSION }, hostCall: 'close' }, - { - method: 'agentSession.sleep', - params: { sessionId: SESSION }, - hostCall: 'close' - }, { method: 'agentSession.cancel', params: { envelope: envelope(), turnId: 'turn-1' }, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-pi-dialog-capability.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-pi-dialog-capability.test.ts index 4112e26887e4..9b290a449b20 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-pi-dialog-capability.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-pi-dialog-capability.test.ts @@ -214,8 +214,7 @@ describe('Pi dialog-shape client capability', () => { expect(await call(method, params, client), method).toMatchObject({ ok: true }) } expect(hostCalls.cancel).toHaveBeenCalledOnce() - // `agentSession.close` and `agentSession.sleep` both end in the host's close. - expect(hostCalls.close).toHaveBeenCalledTimes(2) + expect(hostCalls.close).toHaveBeenCalledOnce() }) it('admits current desktop Pi send, options and commands, and preserves other agents', async () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index ce9b35c8f3c3..2fb75b03c2a0 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -176,6 +176,7 @@ export function hostStub(): StructuredAgentSessionHost { waitForSendSettlement: vi.fn(), cancel: vi.fn(async () => ({ ok: true, replayed: false })), close: vi.fn(async () => undefined), + sleepWorkspace: vi.fn(async () => ({ stoppedSessionIds: [SESSION] })), revealSession: vi.fn(async () => ({ sessionId: SESSION, workspaceId: 'workspace-1', @@ -322,6 +323,4 @@ export function installStructuredHostStub(): void { setStructuredAgentSessionHost(hostStub()) } -export function clearStructuredHostStub(): void { - setStructuredAgentSessionHost(null) -} +export const clearStructuredHostStub = (): void => setStructuredAgentSessionHost(null) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 76ce9c1b8249..7098c46ea3cd 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -26,6 +26,7 @@ export { RestartResumeParams, RewindParams, SendParams, + SleepWorkspaceParams, SessionId, SetOptionParams, SubscribeParams, diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 7d839fcd19e3..5fe6abe0c044 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -43,6 +43,16 @@ afterEach(() => { clearStructuredHostStub() }) +it('stops a workspace through its host without a dedicated sleep capability', async () => { + const response = await call( + 'agentSession.sleepWorkspace', + { workspaceId: 'workspace-1' }, + { clientKind: 'runtime', clientCapabilities: [] } + ) + expect(response).toMatchObject({ ok: true, result: { stoppedSessionIds: [SESSION] } }) + expect(hostCalls.sleepWorkspace).toHaveBeenCalledWith('workspace-1') +}) + describe('agentSession.reveal', () => { it.each(['codex', 'claude'] as const)('republishes a persisted %s chat tab', async (agent) => { hostCalls.revealSession.mockResolvedValueOnce({ diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 962e07ca3872..54cce9e7a91b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -54,10 +54,14 @@ import { OptionsParams, RewindParams, SendParams, + SleepWorkspaceParams, SubscribeParams, UnsubscribeParams } from './structured-agent-session-schemas' import { sendStructuredAgentSessionForClient } from './structured-agent-session-send-compatibility' +import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { structuredSessionNeverStarted } from '../../agent-session-record-founding' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' export const STRUCTURED_AGENT_SESSION_METHODS = [ defineMethod({ @@ -142,14 +146,45 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ } }), defineMethod({ - // Sleeping the chat's workspace: the same close, but the tab stays. `close` throws unless the - // provider child's exit is proven, so resolving is the proof the workspace may read asleep. - name: 'agentSession.sleep', + name: 'agentSession.sleepWorkspace', permission: 'workspace', - params: OptionsParams, + params: SleepWorkspaceParams, handler: async (params, ctx) => { - await requireStructuredCleanupHost(ctx).close(params.sessionId, 'user-close') - return { ok: true as const } + const installed = getStructuredAgentSessionHost() + if (installed) { + return installed.sleepWorkspace(params.workspaceId) + } + let store: Awaited> + try { + store = await ctx.runtime.openAgentSessionRecordStore() + } catch (error) { + const appeared = getStructuredAgentSessionHost() + if (appeared) { + return appeared.sleepWorkspace(params.workspaceId) + } + console.warn('[agent-session] workspace sleep record inventory unavailable', error) + return { stoppedSessionIds: [] } + } + const appeared = getStructuredAgentSessionHost() + if (appeared) { + return appeared.sleepWorkspace(params.workspaceId) + } + try { + return { + stoppedSessionIds: store + .listRecords() + .filter( + (record) => + record.location.workspaceId === params.workspaceId && + record.location.executionHostId === LOCAL_EXECUTION_HOST_ID && + structuredSessionNeverStarted(record) + ) + .map((record) => record.sessionId) + } + } catch (error) { + console.warn('[agent-session] workspace sleep record inventory failed', error) + return { stoppedSessionIds: [] } + } } }), ...STRUCTURED_AGENT_SESSION_PROMPT_RESPONSE_METHODS, diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 32d5985b641d..89062fa3c3a9 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -26,6 +26,7 @@ import { WorktreeResolveMrBase, WorktreeResolvePrBase, WorktreeSelector, + WorktreeSleep, WorktreeSet, WorktreeSortOrder, WorktreeTeardownMissingTerminalsParams @@ -67,8 +68,12 @@ export const WORKTREE_METHODS = [ defineMethod({ name: 'worktree.sleep', permission: 'workspace', - params: WorktreeSelector, - handler: async (params, { runtime }) => runtime.sleepManagedWorktree(params.worktree) + params: WorktreeSleep, + handler: async (params, { runtime }) => + runtime.sleepManagedWorktree( + params.worktree, + resolvePairedCallerHostId(() => runtime.listRepos(), params.worktree, params.hostId) + ) }), defineMethod({ name: 'worktree.activate', diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index d03395dba654..99b28eed6fef 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -9,6 +9,7 @@ import type { import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' import type { TerminalRevealIdentity } from '../../shared/terminal-reveal-identity' import type { TuiAgent } from '../../shared/tui-agent' +import type { ExecutionHostId } from '../../shared/execution-host' import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' import type { @@ -154,7 +155,7 @@ export type RuntimeNotifier = { tabId: string, options?: { localPtyTeardownOwnedExternally?: boolean; force?: boolean } ): Promise - sleepWorktree(worktreeId: string): void + sleepWorktree(worktreeId: string, executionHostId?: ExecutionHostId): void // Why: a phone opening a worktree wakes its slept agents by asking the host // renderer to run its own navigation-free wake (experimental agent sleep); // the runtime has no in-memory sleeping records or wake authority. Optional to diff --git a/src/main/runtime/structured-worker-authority.ts b/src/main/runtime/structured-worker-authority.ts index 0916ca112950..4e9bdc22029a 100644 --- a/src/main/runtime/structured-worker-authority.ts +++ b/src/main/runtime/structured-worker-authority.ts @@ -31,7 +31,7 @@ import { structuredSessionTabRetired, structuredWorkerAddressable } from './structured-worker-custody' -import { AGENT_SESSION_FOUNDING_FENCE } from './agent-session-record-founding' +import { structuredSessionNeverStarted } from './agent-session-record-founding' import { isTuiAgent } from '../../shared/tui-agent-config' import type { TuiAgent } from '../../shared/tui-agent' import { @@ -284,20 +284,6 @@ export function observeStructuredWorker( * owner, spawn, handoff or provider handle. A released reservation whose exit was never proven has * moved the fence, so it stays unverifiable. */ -function structuredSessionNeverStarted(record: AgentSessionRecord): boolean { - const { lease } = record - return ( - lease.claimStatus === 'released' && - lease.runtimeFence === AGENT_SESSION_FOUNDING_FENCE && - // A fence floor marks a copy restored from backup, which may hide a reservation it lost. - lease.minimumNextFence === undefined && - lease.ownerProcess === null && - lease.reservedSpawnToken === null && - lease.handoffStage === null && - record.providerHandleChain.length === 0 - ) -} - /** One session's own liveness, for callers that act on that session rather than on a worker. */ export function observeStructuredSession(sessionId: string): StructuredWorkerObservation { const host = getStructuredAgentSessionHost() diff --git a/src/main/runtime/workspaces/managed-workspace-operations.ts b/src/main/runtime/workspaces/managed-workspace-operations.ts index 6c7b4dc592b8..26aa7609bc89 100644 --- a/src/main/runtime/workspaces/managed-workspace-operations.ts +++ b/src/main/runtime/workspaces/managed-workspace-operations.ts @@ -28,7 +28,11 @@ import { type WorkspacePortExecutionHostDeps } from '../../ports/workspace-port-execution-host' import { resolveWorktreeHostRouting } from '../worktree-launch-host-repo' -import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { + getWorktreeExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostId +} from '../../../shared/execution-host' export abstract class OrcaRuntimeWithListManagedWorktrees extends OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce { protected abstract resolveTerminalWorkspaceLaunchTarget( @@ -210,12 +214,34 @@ export abstract class OrcaRuntimeWithListManagedWorktrees extends OrcaRuntimeWit ) } - async sleepManagedWorktree(worktreeSelector: string): Promise<{ worktreeId: string }> { - const worktree = await this.resolveWorktreeSelector(worktreeSelector) + async sleepManagedWorktree( + worktreeSelector: string, + hostId?: ExecutionHostId + ): Promise<{ worktreeId: string; executionHostId: ExecutionHostId }> { + const explicitId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) + const worktree = + hostId && explicitId + ? (await this.listResolvedWorktrees()).find( + (candidate) => + candidate.id === explicitId && + getWorktreeExecutionHostId(candidate, this.store?.getRepo(candidate.repoId)) === + hostId + ) + : await this.resolveWorktreeSelector(worktreeSelector) + if (!worktree) { + throw new Error('selector_not_found') + } + const executionHostId = getWorktreeExecutionHostId( + worktree, + this.store?.getRepo(worktree.repoId) + ) + if (hostId && hostId !== executionHostId) { + throw new Error('selector_not_found') + } // Why: sleep is renderer-initiated on desktop (it tears down tab state // before killing PTYs). The notifier tells the renderer to run its own // sleep flow so all cleanup happens in the correct order. - this.notifier?.sleepWorktree(worktree.id) - return { worktreeId: worktree.id } + this.notifier?.sleepWorktree(worktree.id, executionHostId) + return { worktreeId: worktree.id, executionHostId } } } diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index dbd9a6921fba..19075474138e 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -215,7 +215,8 @@ export function registerRuntimeWindowLifecycle( closeTerminalPane: (tabId, leafId) => send('ui:closeTerminal', { kind: 'pane', tabId, leafId }), closeTerminalTab: (tabId, options) => requestTerminalTabCloseFromRenderer(mainWindow, tabId, options), - sleepWorktree: (worktreeId) => send('ui:sleepWorktree', { worktreeId }), + sleepWorktree: (worktreeId, executionHostId) => + send('ui:sleepWorktree', { worktreeId, executionHostId }), resumeSleepingAgents: (worktreeId) => send('ui:resumeSleepingAgents', { worktreeId }), terminalFitOverrideChanged: (ptyId, mode, cols, rows) => send('runtime:terminalFitOverrideChanged', { ptyId, mode, cols, rows }), diff --git a/src/preload/api/ephemeral-vm-api.ts b/src/preload/api/ephemeral-vm-api.ts index bacfb239f350..704dfce9460d 100644 --- a/src/preload/api/ephemeral-vm-api.ts +++ b/src/preload/api/ephemeral-vm-api.ts @@ -3,6 +3,7 @@ import type { PublicKnownRuntimeEnvironment } from '../../shared/runtime-environ import type { EphemeralVmRecipeDoctorResult } from '../../shared/ephemeral-vm-recipes' import type { EphemeralVmRecipeResultWarning } from '../../shared/ephemeral-vm-recipe-diagnostics' import type { EphemeralVmRuntimeRecord } from '../../shared/ephemeral-vm-runtimes' +import type { ExecutionHostId } from '../../shared/execution-host' export type EphemeralVmApi = { listRecipes: (args: { repoId: string }) => Promise<{ @@ -62,8 +63,14 @@ export type EphemeralVmApi = { runtimeId: string workspaceId: string }) => Promise - suspendWorkspace: (args: { workspaceId: string }) => Promise - resumeWorkspace: (args: { workspaceId: string }) => Promise + suspendWorkspace: (args: { + workspaceId: string + executionHostId?: ExecutionHostId + }) => Promise + resumeWorkspace: (args: { + workspaceId: string + executionHostId?: ExecutionHostId + }) => Promise cleanup: (args: { runtimeId: string }) => Promise stopCleanup: (args: { runtimeId: string }) => Promise getCleanupCommand: (args: { runtimeId: string }) => Promise<{ diff --git a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts index 333ebd8a3bb7..5f63cf53abe4 100644 --- a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -1,5 +1,6 @@ import type { AgentSessionAttachmentClipboardTarget } from '../../shared/agent-session-attachments' import { ipcRenderer, webFrame } from 'electron' +import type { ExecutionHostId } from '../../shared/execution-host' import type { RuntimeMobileMarkdownRequest, RuntimeMobileMarkdownResponse @@ -67,7 +68,9 @@ export const uiClipboardAndWindowControlsApi = { respondTerminalTabClose: (response) => { ipcRenderer.send('ui:terminalTabCloseResponse', response) }, - onSleepWorktree: (callback: (data: { worktreeId: string }) => void): (() => void) => { + onSleepWorktree: ( + callback: (data: { worktreeId: string; executionHostId?: ExecutionHostId }) => void + ): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => callback(data) ipcRenderer.on('ui:sleepWorktree', listener) diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index 1a919ddeea4e..2b520b3129ee 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -4,6 +4,7 @@ import type { JiraSavedFilterState, JiraSavedFilterMutation } from '../../shared import type { MarkdownDocument } from '../../shared/filesystem-entry-types' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { TuiAgent } from '../../shared/tui-agent' +import type { ExecutionHostId } from '../../shared/execution-host' import type { AgentLaunchTabPublishReply, AgentLaunchTabViewerRule, @@ -266,7 +267,9 @@ export type UiCommandEventApi = { onCloseTerminal: (callback: (target: TerminalSurfaceCloseTarget) => void) => () => void onTerminalTabCloseRequest: (callback: (request: TerminalTabCloseRequest) => void) => () => void respondTerminalTabClose: (response: TerminalTabCloseResponse) => void - onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void + onSleepWorktree: ( + callback: (data: { worktreeId: string; executionHostId?: ExecutionHostId }) => void + ) => () => void onWorktreeSleepRequest: (callback: (request: WorktreeSleepRequest) => void) => () => void respondWorktreeSleep: (response: WorktreeSleepResponse) => void onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void) => () => void diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts index 98bc30b7c4a6..2019d7f62ae9 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts @@ -123,8 +123,8 @@ export function useDashboardPopoutBridge(enabled: boolean): void { if (!enabled) { return } - return window.api.dashboard.onSleepWorkspace?.(({ worktreeId }) => { - void runSleepWorktree(worktreeId) + return window.api.dashboard.onSleepWorkspace?.(({ worktreeId, executionHostId }) => { + void runSleepWorktree(worktreeId, executionHostId) }) }, [enabled]) diff --git a/src/renderer/src/components/sidebar/sleep-workspace-chats.test.ts b/src/renderer/src/components/sidebar/sleep-workspace-chats.test.ts deleted file mode 100644 index 36680b6575ca..000000000000 --- a/src/renderer/src/components/sidebar/sleep-workspace-chats.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { Tab } from '../../../../shared/tab-types' -import type { ExecutionHostId } from '../../../../shared/execution-host' -import { resolveWorkspaceChatSleeps } from './sleep-workspace-chats' - -function chat(sessionId: string, executionHostId?: ExecutionHostId): Tab { - return { - id: sessionId, - entityId: sessionId, - groupId: 'group', - worktreeId: 'workspace', - contentType: 'agent-session', - agentSessionAgent: 'claude', - label: 'Claude', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 0, - ...(executionHostId ? { executionHostId } : {}) - } -} - -describe('workspace chat Sleep ownership', () => { - it('stops only chats on the selected host when workspace IDs collide', () => { - const state = { - unifiedTabsByWorktree: { - workspace: [chat('local-chat', 'local'), chat('remote-chat', 'runtime:remote')] - } - } - - expect( - resolveWorkspaceChatSleeps(state, { id: 'workspace', repoId: 'repo', hostId: 'local' }) - ).toEqual([{ sessionId: 'local-chat', target: { kind: 'local' } }]) - }) - - it('routes an unstamped saved chat through its known workspace owner', () => { - const state = { - unifiedTabsByWorktree: { workspace: [chat('legacy-chat')] }, - worktreesByRepo: { - repo: [{ id: 'workspace', repoId: 'repo', hostId: 'local' as const }] - }, - repos: [{ id: 'repo', executionHostId: 'local' as const }] - } - - expect( - resolveWorkspaceChatSleeps(state, { id: 'workspace', repoId: 'repo', hostId: 'local' }) - ).toEqual([{ sessionId: 'legacy-chat', target: { kind: 'local' } }]) - }) -}) diff --git a/src/renderer/src/components/sidebar/sleep-workspace-chats.ts b/src/renderer/src/components/sidebar/sleep-workspace-chats.ts deleted file mode 100644 index 5c3d22d3678b..000000000000 --- a/src/renderer/src/components/sidebar/sleep-workspace-chats.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { AppState } from '@/store/types' -import { isStructuredTab } from '@/components/native-chat/structured-agent-session-tabs' -import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' -import { - AGENT_SESSION_SLEEP_UNSUPPORTED, - assertStructuredAgentSessionSleepSupported -} from '@/runtime/structured-agent-session-close' -import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' -import { - findIndexedFolderWorkspaceOwner, - resolveIndexedWorktreeOwner -} from '@/lib/worktree-runtime-owner-index' -import { resolveExplicitWorktreeOperationRouteResult } from '@/lib/worktree-operation-catalog-route' -import { - getRepoExecutionHostId, - parseExecutionHostId, - toRuntimeExecutionHostId -} from '../../../../shared/execution-host' -import { parseWorkspaceKey } from '../../../../shared/workspace-scope' -import type { Worktree } from '../../../../shared/worktree/types' -import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner-state' -import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' - -export type WorkspaceChatSleep = { sessionId: string; target: RuntimeClientTarget | null } -export type WorkspaceSleepTarget = - | string - | Pick - -type ChatOwnerState = WorktreeRuntimeOwnerState & Pick - -function sleepTargetHost(state: ChatOwnerState, target: WorkspaceSleepTarget): string | null { - const worktreeId = typeof target === 'string' ? target : target.id - if (typeof target !== 'string') { - const runtimeOwner = target.runtimeOwnerEnvironmentId?.trim() - if (runtimeOwner) { - return toRuntimeExecutionHostId(runtimeOwner) - } - const stampedHost = parseExecutionHostId(target.hostId) - if (stampedHost) { - return stampedHost.id - } - } - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - const owner = findIndexedFolderWorkspaceOwner(state.folderWorkspaces, scope.folderWorkspaceId) - return owner ? getRepoExecutionHostId(owner) : null - } - const resolution = resolveExplicitWorktreeOperationRouteResult(state, worktreeId) - if (resolution.kind !== 'resolved') { - return null - } - return resolution.route.runtimeEnvironmentId - ? toRuntimeExecutionHostId(resolution.route.runtimeEnvironmentId) - : resolution.route.executionHostId -} - -/** Qualify each chat by the selected workspace host before routing it. */ -export function resolveWorkspaceChatSleeps( - state: ChatOwnerState, - target: WorkspaceSleepTarget -): WorkspaceChatSleep[] { - const worktreeId = typeof target === 'string' ? target : target.id - const selectedHost = sleepTargetHost(state, target) - const legacyOwnerAmbiguous = - resolveIndexedWorktreeOwner(state.worktreesByRepo, worktreeId).kind === 'ambiguous' - return (state.unifiedTabsByWorktree[worktreeId] ?? []).filter(isStructuredTab).flatMap((tab) => { - const tabHost = tab.executionHostId ?? (legacyOwnerAmbiguous ? null : selectedHost) - if (tabHost && selectedHost && tabHost !== selectedHost) { - return [] - } - return [ - { - sessionId: tab.entityId, - target: tabHost && selectedHost ? structuredAgentSessionTargetForHost(tabHost) : null - } - ] - }) -} - -/** Resolve predictable failures before browsers or terminals are torn down. */ -export async function preflightWorkspaceChatSleep( - chats: readonly WorkspaceChatSleep[] -): Promise { - const preflight = await Promise.allSettled( - chats.map(({ target }) => - target - ? assertStructuredAgentSessionSleepSupported(target) - : Promise.reject(new Error(AGENT_SESSION_SLEEP_UNSUPPORTED)) - ) - ) - const unsupported = preflight.find( - (result): result is PromiseRejectedResult => result.status === 'rejected' - ) - if (unsupported) { - throw unsupported.reason - } -} - -/** Issue every stop in the same turn after the caller's final activation check. */ -export async function sleepWorkspaceChats(chats: readonly WorkspaceChatSleep[]): Promise { - const results = await Promise.allSettled( - chats.map(({ sessionId, target }) => - target - ? callStructuredAgentSession(target, 'agentSession.sleep', { sessionId }) - : Promise.reject(new Error(AGENT_SESSION_SLEEP_UNSUPPORTED)) - ) - ) - const failure = results.find( - (result): result is PromiseRejectedResult => result.status === 'rejected' - ) - if (failure) { - throw failure.reason - } -} diff --git a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts index 0858e06bc1ba..6011ef08d086 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts @@ -1,10 +1,25 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +type ActivationTestState = { + activeWorktreeId: string | null + activeWorkspaceExecutionHostId: string | null + setActiveWorktree: ReturnType + shutdownWorktreeBrowsers: ReturnType + shutdownWorktreeTerminals: ReturnType + unifiedTabsByWorktree: Record + tabsByWorktree: Record + ptyIdsByTabId: Record +} + const mocks = vi.hoisted(() => { - const state = { - activeWorktreeId: null as string | null, + const state: ActivationTestState = { + activeWorktreeId: null, + activeWorkspaceExecutionHostId: 'local', setActiveWorktree: vi.fn((worktreeId: string | null) => { state.activeWorktreeId = worktreeId + if (!worktreeId) { + state.activeWorkspaceExecutionHostId = null + } }), shutdownWorktreeBrowsers: vi.fn().mockResolvedValue(undefined), shutdownWorktreeTerminals: vi.fn(async (worktreeId: string) => { @@ -13,8 +28,8 @@ const mocks = vi.hoisted(() => { } }), unifiedTabsByWorktree: {}, - tabsByWorktree: {} as Record, - ptyIdsByTabId: {} as Record + tabsByWorktree: {}, + ptyIdsByTabId: {} } const activateAndRevealWorktree = vi.fn() const activateAndRevealFolderWorkspace = vi.fn() @@ -41,6 +56,12 @@ vi.mock('@/lib/worktree-activation', () => ({ })) vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) +vi.mock('@/runtime/structured-agent-session-owner', () => ({ + structuredAgentSessionTargetForHost: () => ({ kind: 'local' }) +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: async () => ({ stoppedSessionIds: [] }) +})) import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation' import { runSleepWorktrees } from './sleep-worktree-flow' @@ -59,6 +80,7 @@ describe('sleep flow vs slept-workspace activation', () => { } }) mocks.state.activeWorktreeId = 'wt-parent' + mocks.state.activeWorkspaceExecutionHostId = 'local' mocks.state.setActiveWorktree.mockClear() mocks.state.shutdownWorktreeBrowsers.mockClear().mockResolvedValue(undefined) mocks.state.shutdownWorktreeTerminals.mockClear().mockImplementation(async (worktreeId) => { @@ -81,7 +103,7 @@ describe('sleep flow vs slept-workspace activation', () => { }) it('does not leave behind a delayed parent activation after sleeping children', async () => { - await runSleepWorktrees(['wt-parent']) + await runSleepWorktrees([{ id: 'wt-parent', executionHostId: 'local' }]) expect(mocks.state.activeWorktreeId).toBeNull() expect(mocks.state.ptyIdsByTabId['tab-parent']).toEqual([]) @@ -94,7 +116,12 @@ describe('sleep flow vs slept-workspace activation', () => { revealInSidebar: false }) - await runSleepWorktrees(['wt-child-1', 'wt-child-2', 'wt-child-3']) + await runSleepWorktrees( + ['wt-child-1', 'wt-child-2', 'wt-child-3'].map((id) => ({ + id, + executionHostId: 'local' + })) + ) expect(mocks.activateAndRevealWorktree).toHaveBeenCalledTimes(1) }) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index cdac95880727..bef242477d64 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -1,454 +1,173 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +type SleepTestState = { + activeWorktreeId: string | null + activeWorkspaceExecutionHostId: string | null + folderWorkspaces: { + id: string + projectGroupId: string + connectionId: null + executionHostId: null + }[] + projectGroups: { id: string; connectionId: null; executionHostId: string }[] + setActiveWorktree: ReturnType + shutdownWorktreeBrowsers: ReturnType + shutdownWorktreeTerminals: ReturnType +} + const mocks = vi.hoisted(() => { - const subscribers = new Set<(next: typeof state, previous: typeof state) => void>() - const state: { - activeWorktreeId: string | null - activeWorkspaceExecutionHostId: string | null - setActiveWorktree: ReturnType - shutdownWorktreeBrowsers: ReturnType - shutdownWorktreeTerminals: ReturnType - suppressPtyExit: ReturnType - consumeSuppressedPtyExit: ReturnType - unifiedTabsByWorktree: Record[]> - worktreesByRepo: Record - repos: { id: string; executionHostId: 'local' }[] - tabsByWorktree: Record - ptyIdsByTabId: Record - } = { + const state: SleepTestState = { activeWorktreeId: null, activeWorkspaceExecutionHostId: null, - setActiveWorktree: vi.fn((worktreeId: string | null) => { - const previous = { ...state } - state.activeWorktreeId = worktreeId - for (const subscriber of subscribers) { - subscriber(state, previous) + folderWorkspaces: [], + projectGroups: [], + setActiveWorktree: vi.fn((id: string | null) => { + state.activeWorktreeId = id + if (id === null) { + state.activeWorkspaceExecutionHostId = null } }), shutdownWorktreeBrowsers: vi.fn().mockResolvedValue(undefined), - shutdownWorktreeTerminals: vi.fn().mockResolvedValue(undefined), - suppressPtyExit: vi.fn(), - consumeSuppressedPtyExit: vi.fn(), - unifiedTabsByWorktree: {}, - worktreesByRepo: { repo: [{ id: 'wt-1', repoId: 'repo', hostId: 'local' }] }, - repos: [{ id: 'repo', executionHostId: 'local' }], - tabsByWorktree: {} as Record, - ptyIdsByTabId: {} as Record + shutdownWorktreeTerminals: vi.fn().mockResolvedValue(undefined) } - const suspendWorkspace = vi.fn().mockResolvedValue(null) - const toastError = vi.fn() - const markWorktreeSleepIntent = vi.fn() - const clearWorktreeSleepIntent = vi.fn() - const sleepChat = vi.fn().mockResolvedValue(undefined) - const preflightChat = vi.fn().mockResolvedValue(undefined) return { - clearWorktreeSleepIntent, - sleepChat, - preflightChat, - subscribers, - subscribe: (subscriber: (next: typeof state, previous: typeof state) => void) => { - subscribers.add(subscriber) - return () => subscribers.delete(subscriber) - }, - markWorktreeSleepIntent, state, - suspendWorkspace, - toastError + call: vi.fn().mockResolvedValue({ stoppedSessionIds: ['session-1'] }), + target: vi.fn(() => ({ kind: 'local' })), + suspend: vi.fn().mockResolvedValue(null), + toast: vi.fn() } }) -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mocks.state, - subscribe: mocks.subscribe - } -})) - -vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) -vi.mock('@/lib/worktree-sleep-intent', () => ({ - clearWorktreeSleepIntent: mocks.clearWorktreeSleepIntent, - markWorktreeSleepIntent: mocks.markWorktreeSleepIntent, - withWorktreeSleepTeardown: (_worktreeId: string, teardown: () => Promise) => teardown() -})) - +vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } })) +vi.mock('sonner', () => ({ toast: { error: mocks.toast } })) vi.mock('@/runtime/structured-agent-session-owner', () => ({ - structuredAgentSessionTargetForHost: () => ({ kind: 'local' }) -})) -vi.mock('@/runtime/structured-agent-session-close', () => ({ - AGENT_SESSION_SLEEP_UNSUPPORTED: 'agent_session_sleep_unsupported', - assertStructuredAgentSessionSleepSupported: mocks.preflightChat + structuredAgentSessionTargetForHost: mocks.target })) vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mocks.sleepChat + callStructuredAgentSession: mocks.call })) import { runSleepWorktree, runSleepWorktrees } from './sleep-worktree-flow' +import { + clearWorktreeSleepIntent, + forgetWorktreeSleepIntent, + hasWorktreeSleepIntent +} from '@/lib/worktree-sleep-intent' -describe('runSleepWorktree', () => { +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +describe('workspace sleep flow', () => { beforeEach(() => { - vi.unstubAllGlobals() vi.stubGlobal('window', { - api: { - ephemeralVm: { - suspendWorkspace: mocks.suspendWorkspace - } - }, - requestAnimationFrame: vi.fn() + api: { ephemeralVm: { suspendWorkspace: mocks.suspend } } }) + for (const id of ['wt-1', 'wt-2', 'folder:folder-1']) { + forgetWorktreeSleepIntent(id) + } + mocks.state.activeWorktreeId = null + mocks.state.activeWorkspaceExecutionHostId = null + mocks.state.folderWorkspaces = [] + mocks.state.projectGroups = [] mocks.state.setActiveWorktree.mockClear() mocks.state.shutdownWorktreeBrowsers.mockClear().mockResolvedValue(undefined) mocks.state.shutdownWorktreeTerminals.mockClear().mockResolvedValue(undefined) - mocks.state.suppressPtyExit.mockClear() - mocks.state.consumeSuppressedPtyExit.mockClear() - mocks.suspendWorkspace.mockClear().mockResolvedValue(null) - mocks.markWorktreeSleepIntent.mockClear() - mocks.clearWorktreeSleepIntent.mockClear() - mocks.toastError.mockClear() - mocks.state.activeWorktreeId = null - mocks.state.activeWorkspaceExecutionHostId = null - mocks.subscribers.clear() - mocks.state.tabsByWorktree = {} - mocks.state.ptyIdsByTabId = {} - mocks.state.unifiedTabsByWorktree = {} - mocks.state.worktreesByRepo = { repo: [{ id: 'wt-1', repoId: 'repo', hostId: 'local' }] } - mocks.state.repos = [{ id: 'repo', executionHostId: 'local' }] - mocks.sleepChat.mockClear().mockResolvedValue(undefined) - mocks.preflightChat.mockClear().mockResolvedValue(undefined) - }) - - const chatTab = { - id: 'chat-1', - entityId: 'session-1', - worktreeId: 'wt-1', - contentType: 'agent-session', - agentSessionAgent: 'claude' - } - - it("stops a workspace's chat agents", async () => { - mocks.state.unifiedTabsByWorktree = { 'wt-1': [chatTab] } - - await runSleepWorktree('wt-1') - - expect(mocks.sleepChat).toHaveBeenCalledWith({ kind: 'local' }, 'agentSession.sleep', { - sessionId: 'session-1' + mocks.call.mockClear().mockResolvedValue({ stoppedSessionIds: ['session-1'] }) + mocks.target.mockClear() + mocks.suspend.mockClear().mockResolvedValue(null) + mocks.toast.mockClear() + }) + + it('issues native stop before panels and keeps the chat tab identity outside teardown', async () => { + const native = deferred<{ stoppedSessionIds: string[] }>() + mocks.call.mockReturnValueOnce(native.promise) + const sleep = runSleepWorktree('wt-1', 'local') + expect(mocks.call).toHaveBeenCalledWith({ kind: 'local' }, 'agentSession.sleepWorkspace', { + workspaceId: 'wt-1' }) - expect(mocks.toastError).not.toHaveBeenCalled() - }) - - it('keeps the workspace awake when its host cannot stop chats', async () => { - mocks.state.unifiedTabsByWorktree = { 'wt-1': [chatTab] } - mocks.preflightChat.mockRejectedValue(new Error('agent_session_sleep_unsupported')) - - await runSleepWorktree('wt-1') - - expect(mocks.clearWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') expect(mocks.state.shutdownWorktreeBrowsers).not.toHaveBeenCalled() - expect(mocks.state.shutdownWorktreeTerminals).not.toHaveBeenCalled() - expect(mocks.toastError).toHaveBeenCalledWith( - 'Failed to sleep workspace', - expect.objectContaining({ - description: - "This workspace's host can't stop its chats yet. The workspace was kept open; update the host and try again." - }) + native.resolve({ stoppedSessionIds: ['session-1'] }) + await expect(sleep).resolves.toEqual([ + { + workspaceId: 'wt-1', + executionHostId: 'local', + stoppedSessionIds: ['session-1'], + completed: true + } + ]) + expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenCalledBefore( + mocks.state.shutdownWorktreeTerminals ) - }) - - it('reports prior teardown when a chat stop fails after preflight', async () => { - mocks.state.unifiedTabsByWorktree = { 'wt-1': [chatTab] } - mocks.sleepChat.mockRejectedValue(new Error('connection lost')) - - await runSleepWorktree('wt-1') - - expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenCalledWith('wt-1') - expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenCalledWith('wt-1', { - keepIdentifiers: true + expect(mocks.suspend).toHaveBeenCalledWith({ + workspaceId: 'wt-1', + executionHostId: 'local' }) - expect(mocks.toastError).toHaveBeenCalledWith( - 'Failed to sleep workspace', - expect.objectContaining({ - description: - 'The host could not confirm the chat stopped. Other panels may have closed; check the connection and try again.' - }) - ) }) - it('tears down browsers before terminals on the sleep path', async () => { - mocks.state.activeWorktreeId = 'wt-1' - - await runSleepWorktree('wt-1') - - // Why: browsers must run first so destroyPersistentWebview can unregister - // the Chromium guests while browserTabsByWorktree/browserPagesByWorkspace - // are still populated. If terminals ran first and kept its old - // browserTabsByWorktree delete, browsers would no-op and leak webviews. - expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenCalledWith('wt-1') - expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenCalledWith('wt-1', { - keepIdentifiers: true - }) - expect(mocks.suspendWorkspace).toHaveBeenCalledWith({ workspaceId: 'wt-1' }) - const browsersCallOrder = mocks.state.shutdownWorktreeBrowsers.mock.invocationCallOrder[0] - const terminalsCallOrder = mocks.state.shutdownWorktreeTerminals.mock.invocationCallOrder[0] - const suspendCallOrder = mocks.suspendWorkspace.mock.invocationCallOrder[0] - expect(browsersCallOrder).toBeLessThan(terminalsCallOrder) - expect(terminalsCallOrder).toBeLessThan(suspendCallOrder) + it('keeps successfully stopped terminals asleep on a native RPC failure', async () => { + mocks.call.mockRejectedValueOnce(new Error('method_not_found')) + const result = await runSleepWorktree('wt-1', 'local') + expect(result[0]).toMatchObject({ completed: false, stoppedSessionIds: [] }) + expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenCalled() + expect(mocks.suspend).toHaveBeenCalled() + expect(hasWorktreeSleepIntent('wt-1', 'local')).toBe(true) + expect(mocks.toast).toHaveBeenCalledTimes(1) }) - it('clears activeWorktreeId before teardown when the slept worktree is active', async () => { + it('does not run delayed panels or steal a newer selection after an explicit wake', async () => { mocks.state.activeWorktreeId = 'wt-1' - - await runSleepWorktree('wt-1') - + mocks.state.activeWorkspaceExecutionHostId = 'local' + const native = deferred<{ stoppedSessionIds: string[] }>() + mocks.call.mockReturnValueOnce(native.promise) + const sleep = runSleepWorktree('wt-1', 'local') expect(mocks.state.setActiveWorktree).toHaveBeenCalledWith(null) - const activeClear = mocks.state.setActiveWorktree.mock.invocationCallOrder[0] - const browsersCall = mocks.state.shutdownWorktreeBrowsers.mock.invocationCallOrder[0] - expect(activeClear).toBeLessThan(browsersCall) - }) - - it('marks sleep intent before clearing the active slept worktree and keeps it after teardown', async () => { - mocks.state.activeWorktreeId = 'wt-1' - - await runSleepWorktree('wt-1') - - expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') - const markCall = mocks.markWorktreeSleepIntent.mock.invocationCallOrder[0] - const activeClear = mocks.state.setActiveWorktree.mock.invocationCallOrder[0] - expect(markCall).toBeLessThan(activeClear) - // Why: the marker outlives a successful sleep so mounted panes stay cold until an explicit wake. - expect(mocks.clearWorktreeSleepIntent).not.toHaveBeenCalled() - }) - - it('preserves active row position through section-scoped sidebar row ids', async () => { - const requestAnimationFrame = vi.fn(() => 1) - const scroller = { - dispatchEvent: vi.fn(), - scrollHeight: 100, - scrollTop: 0 - } - const row = { - closest: (selector: string) => (selector === '[data-worktree-virtual-row]' ? row : null), - getBoundingClientRect: () => ({ top: 42 }) - } - const option = { - dataset: { worktreeId: 'wt-1' }, - closest: (selector: string) => (selector === '[data-worktree-virtual-row]' ? row : null), - querySelector: () => null - } - vi.stubGlobal('document', { - querySelector: (selector: string) => - selector === '[data-worktree-sidebar]' ? scroller : null, - querySelectorAll: (selector: string) => (selector === '[data-worktree-id]' ? [option] : []) - }) - vi.stubGlobal('window', { requestAnimationFrame }) - mocks.state.activeWorktreeId = 'wt-1' - - await runSleepWorktree('wt-1') - - expect(requestAnimationFrame).toHaveBeenCalledTimes(1) - }) - - it('anchors sleep restoration to the natural duplicate row when no primary row is marked', async () => { - const requestAnimationFrame = vi.fn(() => 1) - const scroller = { - dispatchEvent: vi.fn(), - scrollHeight: 100, - scrollTop: 0 - } - const pinnedGetBoundingClientRect = vi.fn(() => ({ top: 10 })) - const naturalGetBoundingClientRect = vi.fn(() => ({ top: 42 })) - const pinnedRow = { - getBoundingClientRect: pinnedGetBoundingClientRect - } - const naturalRow = { - getBoundingClientRect: naturalGetBoundingClientRect - } - const pinnedOption = { - dataset: { worktreeId: 'wt-1', worktreeRowKey: 'pinned:wt-1' }, - closest: (selector: string) => - selector === '[data-worktree-virtual-row]' ? pinnedRow : null, - querySelector: () => null - } - const naturalOption = { - dataset: { worktreeId: 'wt-1', worktreeRowKey: 'all:wt-1' }, - closest: (selector: string) => - selector === '[data-worktree-virtual-row]' ? naturalRow : null, - querySelector: () => null - } - vi.stubGlobal('document', { - querySelector: (selector: string) => - selector === '[data-worktree-sidebar]' ? scroller : null, - querySelectorAll: (selector: string) => - selector === '[data-worktree-id]' ? [pinnedOption, naturalOption] : [] - }) - vi.stubGlobal('window', { requestAnimationFrame }) - mocks.state.activeWorktreeId = 'wt-1' - - await runSleepWorktree('wt-1') - - expect(naturalGetBoundingClientRect).toHaveBeenCalled() - expect(pinnedGetBoundingClientRect).not.toHaveBeenCalled() - }) - - it('leaves activeWorktreeId alone and marks a background worktree slept', async () => { - mocks.state.activeWorktreeId = 'wt-other' - - await runSleepWorktree('wt-1') - - expect(mocks.state.setActiveWorktree).not.toHaveBeenCalled() - expect(mocks.state.suppressPtyExit).not.toHaveBeenCalled() - expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') - expect(mocks.clearWorktreeSleepIntent).not.toHaveBeenCalled() - }) - - it('leaves a worktree the user activated mid-batch awake', async () => { - let releaseFirst: () => void = () => {} - mocks.state.shutdownWorktreeBrowsers.mockImplementationOnce( - () => - new Promise((resolve) => { - releaseFirst = resolve - }) - ) - - const run = runSleepWorktrees(['wt-1', 'wt-2']) - await vi.waitFor(() => expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenCalled()) - // Why: the user clicked wt-2 while wt-1 was tearing down; sleeping it anyway - // must not leave the active workspace marked with no clear pending. + clearWorktreeSleepIntent('wt-1', 'local') mocks.state.activeWorktreeId = 'wt-2' - releaseFirst() - await run - - expect(mocks.clearWorktreeSleepIntent).toHaveBeenLastCalledWith('wt-2') - }) - - it('does not stop a chat after its workspace is reopened during browser teardown', async () => { - mocks.state.activeWorktreeId = 'wt-1' - mocks.state.unifiedTabsByWorktree = { 'wt-1': [chatTab] } - let releaseBrowser: () => void = () => {} - mocks.state.shutdownWorktreeBrowsers.mockImplementationOnce( - () => - new Promise((resolve) => { - releaseBrowser = resolve - }) - ) - - const run = runSleepWorktree('wt-1') - await vi.waitFor(() => expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenCalled()) - const previous = { ...mocks.state } - mocks.state.activeWorktreeId = 'wt-1' - for (const subscriber of mocks.subscribers) { - subscriber(mocks.state, previous) - } - releaseBrowser() - await run - - expect(mocks.sleepChat).not.toHaveBeenCalled() - expect(mocks.state.shutdownWorktreeTerminals).not.toHaveBeenCalled() - expect(mocks.clearWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') - }) - - it('marks each worktree only when its own teardown starts', async () => { - let releaseFirst: () => void = () => {} - mocks.state.shutdownWorktreeBrowsers.mockImplementationOnce( - () => - new Promise((resolve) => { - releaseFirst = resolve - }) - ) - - const run = runSleepWorktrees(['wt-1', 'wt-2']) - await vi.waitFor(() => expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenCalled()) - - // Why: wt-2 is still awake while wt-1 tears down; marking it early would - // hold its panes cold and swallow its activity. - expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') - expect(mocks.markWorktreeSleepIntent).not.toHaveBeenCalledWith('wt-2') - releaseFirst() - await run - expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-2') - }) - - it('surfaces a toast and skips terminals when browsers throws', async () => { - mocks.state.activeWorktreeId = 'wt-1' - mocks.state.shutdownWorktreeBrowsers.mockRejectedValueOnce(new Error('boom')) - mocks.state.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } - mocks.state.ptyIdsByTabId = { 'tab-1': ['pty-1'] } - - await runSleepWorktree('wt-1') - - expect(mocks.state.shutdownWorktreeTerminals).not.toHaveBeenCalled() - expect(mocks.suspendWorkspace).not.toHaveBeenCalled() - expect(mocks.clearWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') - expect(mocks.state.setActiveWorktree).toHaveBeenLastCalledWith('wt-1') - expect(mocks.toastError).toHaveBeenCalledWith( - 'Failed to sleep workspace', - expect.objectContaining({ - description: - 'The workspace was kept open. Try again; if the problem continues, check the host connection.' - }) - ) - }) - - it('restores the active workspace when terminal convergence fails', async () => { - mocks.state.activeWorktreeId = 'wt-1' - mocks.state.shutdownWorktreeTerminals.mockRejectedValueOnce( - new Error('terminal_worktree_sleep_still_live') - ) - - await runSleepWorktree('wt-1') - - expect(mocks.state.setActiveWorktree.mock.calls).toEqual([[null], ['wt-1']]) - expect(mocks.suspendWorkspace).not.toHaveBeenCalled() - expect(mocks.toastError).toHaveBeenCalledWith( - 'Failed to sleep workspace', - expect.objectContaining({ - description: - 'The host could not confirm terminal shutdown. The workspace was kept open; check the connection and try again.' - }) - ) - }) - - it('continues sleeping later worktrees when one selected worktree fails', async () => { - mocks.state.shutdownWorktreeBrowsers.mockImplementation((worktreeId: string) => { - if (worktreeId === 'wt-1') { - return Promise.reject(new Error('first failed')) + native.resolve({ stoppedSessionIds: ['session-1'] }) + expect((await sleep)[0].completed).toBe(false) + expect(mocks.state.shutdownWorktreeBrowsers).not.toHaveBeenCalled() + expect(mocks.state.activeWorktreeId).toBe('wt-2') + }) + + it('issues every host call before a slow sibling permits panel teardown', async () => { + const first = deferred<{ stoppedSessionIds: string[] }>() + const second = deferred<{ stoppedSessionIds: string[] }>() + mocks.call.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + const sleep = runSleepWorktrees([ + { id: 'wt-1', executionHostId: 'local' }, + { id: 'wt-2', executionHostId: 'local' } + ]) + expect(mocks.call).toHaveBeenCalledTimes(2) + expect(mocks.state.shutdownWorktreeBrowsers).not.toHaveBeenCalled() + first.resolve({ stoppedSessionIds: ['one'] }) + second.resolve({ stoppedSessionIds: ['two'] }) + expect((await sleep).map((result) => result.stoppedSessionIds)).toEqual([['one'], ['two']]) + }) + + it('routes an inherited folder lane to the host rendered for its project group', async () => { + mocks.state.folderWorkspaces = [ + { + id: 'folder-1', + projectGroupId: 'group-1', + connectionId: null, + executionHostId: null } - return Promise.resolve() - }) - - await runSleepWorktrees(['wt-1', 'wt-2']) - - expect(mocks.state.shutdownWorktreeTerminals).not.toHaveBeenCalledWith('wt-1', { - keepIdentifiers: true - }) - expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenCalledWith('wt-2') - expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenCalledWith('wt-2', { - keepIdentifiers: true - }) - expect(mocks.suspendWorkspace).toHaveBeenCalledWith({ workspaceId: 'wt-2' }) - expect(mocks.toastError).toHaveBeenCalledWith( - 'Failed to sleep some workspaces', - expect.objectContaining({ - description: - 'The workspace was kept open. Try again; if the problem continues, check the host connection.' - }) - ) - }) - - it('sleeps multiple worktrees and clears active only once when included', async () => { - mocks.state.activeWorktreeId = 'wt-2' - - await runSleepWorktrees(['wt-1', 'wt-2']) - - expect(mocks.state.setActiveWorktree).toHaveBeenCalledTimes(1) - expect(mocks.state.setActiveWorktree).toHaveBeenCalledWith(null) - expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenNthCalledWith(1, 'wt-1') - expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenNthCalledWith(1, 'wt-1', { - keepIdentifiers: true - }) - expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenNthCalledWith(2, 'wt-2') - expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenNthCalledWith(2, 'wt-2', { - keepIdentifiers: true - }) - expect(mocks.suspendWorkspace).toHaveBeenNthCalledWith(1, { workspaceId: 'wt-1' }) - expect(mocks.suspendWorkspace).toHaveBeenNthCalledWith(2, { workspaceId: 'wt-2' }) + ] + mocks.state.projectGroups = [ + { + id: 'group-1', + connectionId: null, + executionHostId: 'runtime:env-1' + } + ] + const result = await runSleepWorktrees([{ id: 'folder:folder-1', hostId: 'local' }]) + expect(result[0].executionHostId).toBe('runtime:env-1') + expect(mocks.target).toHaveBeenCalledWith('runtime:env-1') }) }) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index 9bcd3d496acc..93877de1e513 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -1,19 +1,44 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { + beginWorktreeSleepTeardown, clearWorktreeSleepIntent, + isCurrentWorktreeSleepIntent, markWorktreeSleepIntent, withWorktreeSleepTeardown } from '@/lib/worktree-sleep-intent' -import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor' +import { preserveSidebarWorktreePosition } from './sleep-worktree-sidebar-position' import { translate } from '@/i18n/i18n' -import { AGENT_SESSION_SLEEP_UNSUPPORTED } from '@/runtime/structured-agent-session-close' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' import { - preflightWorkspaceChatSleep, - resolveWorkspaceChatSleeps, - sleepWorkspaceChats, - type WorkspaceSleepTarget -} from './sleep-workspace-chats' + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route' +import { findIndexedFolderWorkspaceOwner } from '@/lib/worktree-runtime-owner-index' +import { getFolderWorkspaceHostId } from './folder-workspace-host-id' +import type { AppState } from '@/store/types' + +export type WorkspaceSleepTarget = + | string + | { + id: string + repoId?: string + executionHostId?: ExecutionHostId + hostId?: ExecutionHostId + runtimeOwnerEnvironmentId?: string + } + +export type WorkspaceSleepResult = { + workspaceId: string + executionHostId: ExecutionHostId + stoppedSessionIds: string[] + completed: boolean +} /** * Shared "sleep worktree" flow (close all panels to free memory / CPU) @@ -28,103 +53,15 @@ import { * Centralizing the sequence here keeps that safety invariant in one place so * a new caller can't accidentally skip it. */ -export async function runSleepWorktree(worktreeId: string): Promise { - await runSleepWorktrees([worktreeId]) -} - -function getSidebarWorktreeOptions(worktreeId: string): HTMLElement[] { - return Array.from(document.querySelectorAll('[data-worktree-id]')).filter( - (element) => element.dataset.worktreeId === worktreeId - ) -} - -function isPinnedSidebarWorktreeOption(element: HTMLElement): boolean { - // Why: duplicated pinned rows share a worktree id; the row-key prefix is the - // stable row-scoped signal that distinguishes the pinned copy from natural rows. - return element.dataset.worktreeRowKey?.startsWith('pinned:') === true -} - -function findPrimarySidebarWorktreeOption(worktreeId: string): HTMLElement | null { - const options = getSidebarWorktreeOptions(worktreeId) - return ( - options.find((element) => - element.querySelector('[data-worktree-card-active="primary"]') - ) ?? - options.find((element) => !isPinnedSidebarWorktreeOption(element)) ?? - options[0] ?? - null - ) -} - -function findSidebarWorktreeRow(worktreeId: string, rowKey?: string): HTMLElement | null { - const options = getSidebarWorktreeOptions(worktreeId) - const option = rowKey - ? (options.find((element) => element.dataset.worktreeRowKey === rowKey) ?? null) - : (findPrimarySidebarWorktreeOption(worktreeId) ?? null) - return option?.closest('[data-worktree-virtual-row]') ?? null -} - -function preserveSidebarWorktreePosition(worktreeId: string): () => void { - if (typeof document === 'undefined') { - return () => {} - } - const getScroller = (): HTMLElement | null => - document.querySelector('[data-worktree-sidebar]') - const scroller = getScroller() - const activeOption = findPrimarySidebarWorktreeOption(worktreeId) - const activeRowKey = activeOption?.dataset.worktreeRowKey - const row = activeOption?.closest('[data-worktree-virtual-row]') ?? null - if (!scroller || !row) { - return () => {} - } - scroller.dispatchEvent(new Event(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT)) - const previousScrollTop = scroller.scrollTop - const previousScrollHeight = scroller.scrollHeight - const previousTop = row.getBoundingClientRect().top - - return () => { - let attempts = 0 - const restore = (): void => { - const currentScroller = getScroller() - if (!currentScroller) { - attempts += 1 - if (attempts < 12) { - window.requestAnimationFrame(restore) - } - return - } - const nextRow = findSidebarWorktreeRow(worktreeId, activeRowKey) - if (!nextRow) { - // Why: a remount can first render the wrong virtual window. Put the - // scroller near the same content after height changes so the row - // mounts, then retry and correct by actual DOM position. - currentScroller.scrollTop = Math.max( - 0, - previousScrollTop + currentScroller.scrollHeight - previousScrollHeight - ) - } else { - const delta = nextRow.getBoundingClientRect().top - previousTop - if (Math.abs(delta) > 1) { - currentScroller.scrollTop += delta - } - } - attempts += 1 - if (attempts < 12) { - window.requestAnimationFrame(restore) - } - } - window.requestAnimationFrame(restore) - } +export async function runSleepWorktree( + worktreeId: string, + executionHostId?: ExecutionHostId +): Promise { + return runSleepWorktrees([executionHostId ? { id: worktreeId, executionHostId } : worktreeId]) } function describeSleepFailure(error: unknown): string { const detail = error instanceof Error ? error.message : String(error) - if (detail === AGENT_SESSION_SLEEP_UNSUPPORTED) { - return translate( - 'auto.components.sidebar.sleep.worktree.flow.chat.unsupported', - "This workspace's host can't stop its chats yet. The workspace was kept open; update the host and try again." - ) - } if (detail.includes('legacy')) { return translate( 'auto.components.sidebar.sleep.worktree.flow.legacy.unverified', @@ -155,138 +92,170 @@ function describeChatStopFailure(): string { ) } -export async function runSleepWorktrees(targets: readonly WorkspaceSleepTarget[]): Promise { +function resolveSleepTarget( + state: AppState, + target: WorkspaceSleepTarget +): { workspaceId: string; executionHostId: ExecutionHostId } | null { + const workspaceId = typeof target === 'string' ? target : target.id + const explicit = typeof target === 'string' ? undefined : target.executionHostId + if (explicit) { + const host = parseExecutionHostId(explicit) + return host ? { workspaceId, executionHostId: host.id } : null + } + const scope = parseWorkspaceKey(workspaceId) + if (scope?.type === 'folder') { + const folder = findIndexedFolderWorkspaceOwner(state.folderWorkspaces, scope.folderWorkspaceId) + if (!folder) { + return null + } + const group = state.projectGroups.find((candidate) => candidate.id === folder.projectGroupId) + const executionHostId = getFolderWorkspaceHostId( + folder, + group ?? { connectionId: null, executionHostId: null }, + parseExecutionHostId(state.restoredRuntimeHostIdByWorkspaceSessionKey?.[workspaceId])?.id ?? + LOCAL_EXECUTION_HOST_ID + ) + return { workspaceId, executionHostId } + } + if (typeof target !== 'string') { + const runtimeOwner = target.runtimeOwnerEnvironmentId?.trim() + if (runtimeOwner) { + return { workspaceId, executionHostId: toRuntimeExecutionHostId(runtimeOwner) } + } + const stamped = parseExecutionHostId(target.hostId) + if (stamped) { + return { workspaceId, executionHostId: stamped.id } + } + } + const route = resolveWorktreeOperationRouteResult(state, workspaceId) + return route.kind === 'resolved' && route.route.executionHostId + ? { workspaceId, executionHostId: route.route.executionHostId } + : null +} + +export async function runSleepWorktrees( + targets: readonly WorkspaceSleepTarget[] +): Promise { if (targets.length === 0) { - return + return [] } - const worktreeIds = targets.map((target) => (typeof target === 'string' ? target : target.id)) const state = useAppStore.getState() - const { - activeWorktreeId, - setActiveWorktree, - shutdownWorktreeBrowsers, - shutdownWorktreeTerminals - } = state - const sleepTargets = targets.map((target) => ({ - worktreeId: typeof target === 'string' ? target : target.id, - chats: resolveWorkspaceChatSleeps(state, target) + const errors: string[] = [] + const seen = new Set() + const resolved = targets.flatMap((target) => { + const selected = resolveSleepTarget(state, target) + if (!selected) { + errors.push(describeSleepFailure(new Error('workspace owner is unavailable'))) + return [] + } + const key = `${selected.executionHostId}\0${selected.workspaceId}` + if (seen.has(key)) { + return [] + } + seen.add(key) + return [selected] + }) + const marked = resolved.map((target) => ({ + ...target, + token: markWorktreeSleepIntent(target.workspaceId, target.executionHostId) })) - const sleptActiveWorktreeId = - activeWorktreeId && worktreeIds.includes(activeWorktreeId) ? activeWorktreeId : null - if (sleptActiveWorktreeId) { - const restoreSidebarPosition = preserveSidebarWorktreePosition(sleptActiveWorktreeId) - // Why: clearing the active workspace can unmount TerminalPanes before - // shutdownWorktreeTerminals writes PTY suppressions; mark first so those - // exits do not stamp activity. Kept off the store so it cannot disturb the - // sidebar's scroll restoration. - markWorktreeSleepIntent(sleptActiveWorktreeId) - setActiveWorktree(null) + const finishTeardowns = marked.map((target) => + beginWorktreeSleepTeardown(target.workspaceId, target.executionHostId) + ) + const nativeCalls = marked.map(({ workspaceId, executionHostId }) => { + const nativeTarget = structuredAgentSessionTargetForHost(executionHostId) + return nativeTarget + ? callStructuredAgentSession<{ stoppedSessionIds: string[] }>( + nativeTarget, + 'agentSession.sleepWorkspace', + { workspaceId } + ).then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error }) + ) + : Promise.resolve({ ok: true as const, value: { stoppedSessionIds: [] } }) + }) + const selected = marked.find( + (target) => + state.activeWorktreeId === target.workspaceId && + state.activeWorkspaceExecutionHostId === target.executionHostId + ) + if (selected) { + const restoreSidebarPosition = preserveSidebarWorktreePosition(selected.workspaceId) + state.setActiveWorktree(null) restoreSidebarPosition() } - const reactivatedWorktreeIds = new Set() - const wasReactivated = (worktreeId: string): boolean => - reactivatedWorktreeIds.has(worktreeId) || useAppStore.getState().activeWorktreeId === worktreeId - const unsubscribe = useAppStore.subscribe((next, previous) => { - if ( - next.activeWorktreeId && - (next.activeWorktreeId !== previous.activeWorktreeId || - next.activeWorkspaceExecutionHostId !== previous.activeWorkspaceExecutionHostId) - ) { - reactivatedWorktreeIds.add(next.activeWorktreeId) - } - }) - const errors: string[] = [] - const failedWorktreeIds = new Set() - try { - for (const { worktreeId, chats } of sleepTargets) { - // Why: the marker outlives teardown so the panes left mounted stay cold - // until an explicit wake (#10205); mark per workspace so an earlier - // slow teardown never leaves a later, still-awake one marked. - markWorktreeSleepIntent(worktreeId) - try { - await preflightWorkspaceChatSleep(chats) - } catch (err) { - console.error('[sleep-worktree] chat sleep preflight failed', { worktreeId, error: err }) - failedWorktreeIds.add(worktreeId) - errors.push(describeSleepFailure(err)) - continue - } - if (wasReactivated(worktreeId)) { - clearWorktreeSleepIntent(worktreeId) - continue - } - try { - // Why: sleep mirrors removeWorktree's shutdown sequence — browsers first - // so destroyPersistentWebview unregisters the Chromium guests before any - // other teardown runs, terminals second so the PTY kill uses the same - // ordering on both paths. Without the browser thunk here, sleep leaks - // browserPagesByWorkspace entries and live webviews for the slept worktree. - await withWorktreeSleepTeardown(worktreeId, () => shutdownWorktreeBrowsers(worktreeId)) - } catch (err) { - console.error('[sleep-worktree] browser shutdown failed', { worktreeId, error: err }) - failedWorktreeIds.add(worktreeId) - errors.push(describeSleepFailure(err)) - continue - } - if (wasReactivated(worktreeId)) { - clearWorktreeSleepIntent(worktreeId) - continue - } - let chatStopFailed = false - try { - // Why: sleep is reversible — the tab record stays in tabsByWorktree, the - // layout stays in terminalLayoutsByTabId, only the live PTY processes are - // released. keepIdentifiers preserves tab.ptyId / ptyIdsByLeafId / - // lastKnownRelayPtyIdByTabId so wake re-spawns against the same on-disk - // history dir (local) or relay session id (SSH); it also captures - // serializer buffers into buffersByLeafId for SSH wake to reseed - // scrollback. See DESIGN_DOC_TERMINAL_HISTORY_FIX_V2.md §3.3.c. - await withWorktreeSleepTeardown(worktreeId, async () => { - await shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true }) - if (wasReactivated(worktreeId)) { - return + const results = await Promise.all( + marked.map((target, index) => + withWorktreeSleepTeardown( + target.workspaceId, + async (): Promise => { + const { workspaceId, executionHostId, token } = target + const current = (): boolean => + isCurrentWorktreeSleepIntent(workspaceId, executionHostId, token) + let stoppedSessionIds: string[] = [] + let nativeCompleted = true + let panelsCompleted = true + let terminalsStopped = false + const native = await nativeCalls[index] + if (native.ok) { + stoppedSessionIds = native.value.stoppedSessionIds + } else { + console.error('[sleep-worktree] native chat stop failed', { + workspaceId, + error: native.error + }) + nativeCompleted = false + errors.push(describeChatStopFailure()) } - // Each chat keeps its tab and transcript; its next send starts a new agent. - try { - await sleepWorkspaceChats(chats) - } catch (error) { - chatStopFailed = true - throw error + if (current()) { + try { + await state.shutdownWorktreeBrowsers(workspaceId) + } catch (error) { + console.error('[sleep-worktree] browser shutdown failed', { workspaceId, error }) + panelsCompleted = false + errors.push(describeSleepFailure(error)) + } + } else { + panelsCompleted = false } - if (wasReactivated(worktreeId)) { - return + if (current() && panelsCompleted) { + try { + await state.shutdownWorktreeTerminals(workspaceId, { keepIdentifiers: true }) + terminalsStopped = true + } catch (error) { + console.error('[sleep-worktree] terminal shutdown failed', { workspaceId, error }) + panelsCompleted = false + errors.push(describeSleepFailure(error)) + } } - if (typeof window !== 'undefined' && window.api?.ephemeralVm?.suspendWorkspace) { - await window.api.ephemeralVm.suspendWorkspace({ workspaceId: worktreeId }) + if ( + current() && + terminalsStopped && + typeof window !== 'undefined' && + window.api?.ephemeralVm?.suspendWorkspace + ) { + try { + await window.api.ephemeralVm.suspendWorkspace({ workspaceId, executionHostId }) + } catch (error) { + console.error('[sleep-worktree] host suspension failed', { workspaceId, error }) + panelsCompleted = false + errors.push(describeSleepFailure(error)) + } } - }) - // Why: a workspace the user activated during the batch is awake by their choice. - if (useAppStore.getState().activeWorktreeId === worktreeId) { - clearWorktreeSleepIntent(worktreeId) - } - } catch (err) { - console.error('[sleep-worktree] terminal or host suspension failed', { - worktreeId, - error: err - }) - failedWorktreeIds.add(worktreeId) - errors.push(chatStopFailed ? describeChatStopFailure() : describeSleepFailure(err)) - } - } - } finally { - unsubscribe() - // Why: a failed sleep leaves the workspace awake and retryable. - for (const worktreeId of failedWorktreeIds) { - clearWorktreeSleepIntent(worktreeId) - } - if (sleptActiveWorktreeId && failedWorktreeIds.has(sleptActiveWorktreeId)) { - setActiveWorktree(sleptActiveWorktreeId) - } - } + const completed = nativeCompleted && panelsCompleted && terminalsStopped && current() + if (!completed && !terminalsStopped && current()) { + clearWorktreeSleepIntent(workspaceId, executionHostId) + } + return { workspaceId, executionHostId, stoppedSessionIds, completed } + }, + target.executionHostId + ) + ) + ).finally(() => finishTeardowns.forEach((finish) => finish())) if (errors.length > 0) { - // Why: callers are fire-and-forget; surface actionable teardown failures after restoring retryable UI state. toast.error( - worktreeIds.length === 1 + resolved.length === 1 ? translate( 'auto.components.sidebar.sleep.worktree.flow.8bc3fc0671', 'Failed to sleep workspace' @@ -295,9 +264,8 @@ export async function runSleepWorktrees(targets: readonly WorkspaceSleepTarget[] 'auto.components.sidebar.sleep.worktree.flow.c460fecc4a', 'Failed to sleep some workspaces' ), - { - description: errors.join('\n') - } + { description: [...new Set(errors)].join('\n') } ) } + return results } diff --git a/src/renderer/src/components/sidebar/sleep-worktree-sidebar-position.ts b/src/renderer/src/components/sidebar/sleep-worktree-sidebar-position.ts new file mode 100644 index 000000000000..f36e98c67321 --- /dev/null +++ b/src/renderer/src/components/sidebar/sleep-worktree-sidebar-position.ts @@ -0,0 +1,86 @@ +import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor' + +function getSidebarWorktreeOptions(worktreeId: string): HTMLElement[] { + return Array.from(document.querySelectorAll('[data-worktree-id]')).filter( + (element) => element.dataset.worktreeId === worktreeId + ) +} + +function isPinnedSidebarWorktreeOption(element: HTMLElement): boolean { + // Why: duplicated pinned rows share a worktree id; the row-key prefix is the + // stable row-scoped signal that distinguishes the pinned copy from natural rows. + return element.dataset.worktreeRowKey?.startsWith('pinned:') === true +} + +function findPrimarySidebarWorktreeOption(worktreeId: string): HTMLElement | null { + const options = getSidebarWorktreeOptions(worktreeId) + return ( + options.find((element) => + element.querySelector('[data-worktree-card-active="primary"]') + ) ?? + options.find((element) => !isPinnedSidebarWorktreeOption(element)) ?? + options[0] ?? + null + ) +} + +function findSidebarWorktreeRow(worktreeId: string, rowKey?: string): HTMLElement | null { + const options = getSidebarWorktreeOptions(worktreeId) + const option = rowKey + ? (options.find((element) => element.dataset.worktreeRowKey === rowKey) ?? null) + : (findPrimarySidebarWorktreeOption(worktreeId) ?? null) + return option?.closest('[data-worktree-virtual-row]') ?? null +} + +export function preserveSidebarWorktreePosition(worktreeId: string): () => void { + if (typeof document === 'undefined') { + return () => {} + } + const getScroller = (): HTMLElement | null => + document.querySelector('[data-worktree-sidebar]') + const scroller = getScroller() + const activeOption = findPrimarySidebarWorktreeOption(worktreeId) + const activeRowKey = activeOption?.dataset.worktreeRowKey + const row = activeOption?.closest('[data-worktree-virtual-row]') ?? null + if (!scroller || !row) { + return () => {} + } + scroller.dispatchEvent(new Event(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT)) + const previousScrollTop = scroller.scrollTop + const previousScrollHeight = scroller.scrollHeight + const previousTop = row.getBoundingClientRect().top + + return () => { + let attempts = 0 + const restore = (): void => { + const currentScroller = getScroller() + if (!currentScroller) { + attempts += 1 + if (attempts < 12) { + window.requestAnimationFrame(restore) + } + return + } + const nextRow = findSidebarWorktreeRow(worktreeId, activeRowKey) + if (!nextRow) { + // Why: a remount can first render the wrong virtual window. Put the + // scroller near the same content after height changes so the row + // mounts, then retry and correct by actual DOM position. + currentScroller.scrollTop = Math.max( + 0, + previousScrollTop + currentScroller.scrollHeight - previousScrollHeight + ) + } else { + const delta = nextRow.getBoundingClientRect().top - previousTop + if (Math.abs(delta) > 1) { + currentScroller.scrollTop += delta + } + } + attempts += 1 + if (attempts < 12) { + window.requestAnimationFrame(restore) + } + } + window.requestAnimationFrame(restore) + } +} diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts index 586c33c432a0..175f4702dedb 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts @@ -5,7 +5,13 @@ import type { Worktree } from '../../../../shared/worktree/types' import type { Tab } from '../../../../shared/tab-types' import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner-state' import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' -import { resolveWorkspaceChatSleeps, type WorkspaceSleepTarget } from './sleep-workspace-chats' +import { isStructuredTab } from '@/components/native-chat/structured-agent-session-tabs' +import type { WorkspaceSleepTarget } from './sleep-worktree-flow' +import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route' +import { findIndexedFolderWorkspaceOwner } from '@/lib/worktree-runtime-owner-index' +import { getFolderWorkspaceHostId } from './folder-workspace-host-id' type WorkspaceActivityMaps = { tabsByWorktree: Record @@ -33,11 +39,41 @@ export function hasSleepableWorkspaceActivity( ): boolean { const worktreeId = typeof target === 'string' ? target : target.id const tabs = tabsByWorktree[worktreeId] ?? [] + const scope = parseWorkspaceKey(worktreeId) + const explicitHost = + typeof target === 'string' + ? undefined + : (target.executionHostId ?? (scope?.type === 'folder' ? undefined : target.hostId)) + const route = resolveWorktreeOperationRouteResult(chatOwnerState, worktreeId) + const folder = + scope?.type === 'folder' + ? findIndexedFolderWorkspaceOwner(chatOwnerState.folderWorkspaces, scope.folderWorkspaceId) + : null + const group = folder + ? chatOwnerState.projectGroups?.find((candidate) => candidate.id === folder.projectGroupId) + : null + const selectedHost = + explicitHost ?? + (scope?.type === 'folder' + ? folder + ? getFolderWorkspaceHostId( + folder, + group ?? { connectionId: null, executionHostId: null }, + parseExecutionHostId( + chatOwnerState.restoredRuntimeHostIdByWorkspaceSessionKey?.[worktreeId] + )?.id ?? LOCAL_EXECUTION_HOST_ID + ) + : null + : route.kind === 'resolved' + ? route.route.executionHostId + : null) + const hostId = parseExecutionHostId(selectedHost)?.id return ( tabs.some((tab) => tabHasLivePty(ptyIdsByTabId, tab.id)) || (browserTabsByWorktree[worktreeId] ?? []).length > 0 || - resolveWorkspaceChatSleeps({ ...chatOwnerState, unifiedTabsByWorktree }, target).some( - (chat) => chat.target !== null + (unifiedTabsByWorktree[worktreeId] ?? []).some( + (tab) => + isStructuredTab(tab) && !!hostId && (!tab.executionHostId || tab.executionHostId === hostId) ) ) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts b/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts index d8f510ded487..24d7aa5c9db8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts @@ -4,7 +4,11 @@ import { isWorktreeRemovalFenceError } from '../../../../../shared/worktree/remo import { safeFit } from '@/lib/pane-manager/pane-tree-ops' import { useAppStore } from '@/store' import { createCodexBackfillErrorDetector } from '../codex-backfill-error-detector' -import { hasWorktreeSleepIntent, onWorktreeSleepIntentCleared } from '@/lib/worktree-sleep-intent' +import { + clearWorktreeSleepIntent, + hasWorktreeSleepIntent, + onWorktreeSleepIntentCleared +} from '@/lib/worktree-sleep-intent' import { recordPtyConnectDiagnostic } from './pty-connect-limits' import { findTerminalTabForPane } from './terminal-tab-id' @@ -35,10 +39,15 @@ export function installRunDeferredConnect(session: ConnectPanePtySession): void if (session.connectStarted) { return } + const executionHostId = + session.transport.getExecutionHostId?.() ?? session.tab?.executionHostId ?? undefined + if (session.paneStartup) { + clearWorktreeSleepIntent(session.deps.worktreeId, executionHostId) + } // Why: a deliberately slept workspace keeps its panes mounted, so connecting // would reattach the retained session id and respawn the shell (#10205). // Wait for the wake instead; a queued startup is an explicit launch. - if (hasWorktreeSleepIntent(session.deps.worktreeId) && !session.paneStartup) { + if (hasWorktreeSleepIntent(session.deps.worktreeId, executionHostId) && !session.paneStartup) { session.cancelScheduledConnectFrame() if (session.connectFallbackTimer !== null) { clearTimeout(session.connectFallbackTimer) @@ -49,23 +58,27 @@ export function installRunDeferredConnect(session: ConnectPanePtySession): void recordPtyConnectDiagnostic( `pane=${session.pane.id} tab=${session.deps.tabId} -> WAIT FOR WAKE (deliberate sleep)` ) - const unsubscribe = onWorktreeSleepIntentCleared(session.deps.worktreeId, () => { - wakeWaitStarted = false - const index = session.waitTeardowns.indexOf(unsubscribe) - if (index !== -1) { - session.waitTeardowns.splice(index, 1) - } - // Why: an activation wake bumps the tab generation in the same tick and the - // remounted pane connects on its own; a stale generation must not connect too. - const currentTab = findTerminalTabForPane( - useAppStore.getState(), - session.deps.worktreeId, - session.deps.tabId - ) - if (!session.disposed && (currentTab?.generation ?? 0) === session.tabGeneration) { - session.runDeferredConnect() - } - }) + const unsubscribe = onWorktreeSleepIntentCleared( + session.deps.worktreeId, + () => { + wakeWaitStarted = false + const index = session.waitTeardowns.indexOf(unsubscribe) + if (index !== -1) { + session.waitTeardowns.splice(index, 1) + } + // Why: an activation wake bumps the tab generation in the same tick and the + // remounted pane connects on its own; a stale generation must not connect too. + const currentTab = findTerminalTabForPane( + useAppStore.getState(), + session.deps.worktreeId, + session.deps.tabId + ) + if (!session.disposed && (currentTab?.generation ?? 0) === session.tabGeneration) { + session.runDeferredConnect() + } + }, + executionHostId + ) // Why: disposal unsubscribes so a torn-down pane never connects on a later wake. session.waitTeardowns.push(unsubscribe) } diff --git a/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts index 2130c5e2b6a0..fc8e03407acf 100644 --- a/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts @@ -131,8 +131,8 @@ export function registerMobileAndTerminalCloseIpcBridge( } unsubs.push( - window.api.ui.onSleepWorktree(({ worktreeId }) => { - void runSleepWorktree(worktreeId) + window.api.ui.onSleepWorktree(({ worktreeId, executionHostId }) => { + void runSleepWorktree(worktreeId, executionHostId) }) ) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 03539b07d65a..b43ba1c65682 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6291,7 +6291,6 @@ }, "retry": "The workspace was kept open. Try again; if the problem continues, check the host connection.", "chat": { - "unsupported": "This workspace's host can't stop its chats yet. The workspace was kept open; update the host and try again.", "partial": "The host could not confirm the chat stopped. Other panels may have closed; check the connection and try again." } } diff --git a/src/renderer/src/lib/ephemeral-vm-runtime-cleanup.ts b/src/renderer/src/lib/ephemeral-vm-runtime-cleanup.ts index 6d435b25f4b2..efd0ab4701cb 100644 --- a/src/renderer/src/lib/ephemeral-vm-runtime-cleanup.ts +++ b/src/renderer/src/lib/ephemeral-vm-runtime-cleanup.ts @@ -1,10 +1,5 @@ -import { - isRuntimeOwnedSshTargetId, - toRuntimeExecutionHostId, - toSshExecutionHostId, - type ExecutionHostId -} from '../../../shared/execution-host' -import { composeWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' +import { isRuntimeOwnedSshTargetId, type ExecutionHostId } from '../../../shared/execution-host' +import { ephemeralVmMatchesWorkspaceHost } from '../../../shared/ephemeral-vm-runtime-host-match' /** * Tear down the ephemeral-VM runtimes backing a set of deleted workspaces (and, @@ -41,31 +36,14 @@ export async function cleanupEphemeralVmRuntimesForDeleted(args: { const sshTargetIdSet = new Set( (args.runtimeOwnedSshTargetIds ?? []).filter((id) => isRuntimeOwnedSshTargetId(id)) ) - const hostScopedWorkspaceIdentities = new Set( - (args.hostScopedWorkspaces ?? []).map((target) => - composeWorktreeHostIdentity(target.executionHostId, target.workspaceId) - ) - ) const runtimes = await window.api.ephemeralVm.listRuntimes() const matchingRuntimes = runtimes.filter( (runtime) => (runtime.cleanupStatus !== 'succeeded' || runtime.sshTargetId !== undefined) && ((runtime.workspaceId !== undefined && workspaceIdSet.has(runtime.workspaceId)) || - (runtime.workspaceId !== undefined && - ((runtime.runtimeEnvironmentId !== undefined && - hostScopedWorkspaceIdentities.has( - composeWorktreeHostIdentity( - toRuntimeExecutionHostId(runtime.runtimeEnvironmentId), - runtime.workspaceId - ) - )) || - (runtime.sshTargetId !== undefined && - hostScopedWorkspaceIdentities.has( - composeWorktreeHostIdentity( - toSshExecutionHostId(runtime.sshTargetId), - runtime.workspaceId - ) - )))) || + (args.hostScopedWorkspaces ?? []).some((target) => + ephemeralVmMatchesWorkspaceHost(runtime, target.workspaceId, target.executionHostId) + ) || (runtime.sshTargetId !== undefined && sshTargetIdSet.has(runtime.sshTargetId))) ) for (const runtime of matchingRuntimes) { diff --git a/src/renderer/src/lib/sidebar-worktree-activation.test.ts b/src/renderer/src/lib/sidebar-worktree-activation.test.ts index a12ca8affa81..30c29475a29d 100644 --- a/src/renderer/src/lib/sidebar-worktree-activation.test.ts +++ b/src/renderer/src/lib/sidebar-worktree-activation.test.ts @@ -68,6 +68,18 @@ describe('sidebar worktree activation', () => { await activation }) + it('passes the clicked host to the VM wake', async () => { + const resumeWorkspace = vi.fn().mockResolvedValue(null) + vi.stubGlobal('window', { api: { ephemeralVm: { resumeWorkspace } } }) + + await activateWorktreeFromSidebar('wt-vm', 'runtime:env-a') + + expect(resumeWorkspace).toHaveBeenCalledWith({ + workspaceId: 'wt-vm', + executionHostId: 'runtime:env-a' + }) + }) + it('routes folder workspace activation through the guarded folder path', async () => { await activateWorktreeFromSidebar('folder:folder-workspace-1') diff --git a/src/renderer/src/lib/sidebar-worktree-activation.ts b/src/renderer/src/lib/sidebar-worktree-activation.ts index 01bb94ad11b8..656984f2aa89 100644 --- a/src/renderer/src/lib/sidebar-worktree-activation.ts +++ b/src/renderer/src/lib/sidebar-worktree-activation.ts @@ -28,7 +28,10 @@ export async function activateWorktreeFromSidebar( if (typeof window !== 'undefined' && window.api?.ephemeralVm?.resumeWorkspace) { try { - const runtime = await window.api.ephemeralVm.resumeWorkspace({ workspaceId: worktreeId }) + const runtime = await window.api.ephemeralVm.resumeWorkspace({ + workspaceId: worktreeId, + ...(executionHostId ? { executionHostId } : {}) + }) if (runtime?.runtimeEnvironmentId) { const store = (await import('@/store')).useAppStore store.getState().setRuntimeEnvironments(await window.api.runtimeEnvironments.list()) diff --git a/src/renderer/src/lib/worktree-sleep-intent.ts b/src/renderer/src/lib/worktree-sleep-intent.ts index 6512abec692e..09f9a919ab04 100644 --- a/src/renderer/src/lib/worktree-sleep-intent.ts +++ b/src/renderer/src/lib/worktree-sleep-intent.ts @@ -1,69 +1,135 @@ -// Why: a slept workspace keeps its panes mounted with only dead PTYs behind them. -// Any pane connect that runs while the marker is set waits here, and the clear -// that marks the workspace awake resumes every waiting connect. -const sleepingWorktreeIds = new Set() -const tearingDownWorktreeIds = new Set() -const wakeListenersByWorktreeId = new Map void>>() +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' -export function markWorktreeSleepIntent(worktreeId: string): void { - sleepingWorktreeIds.add(worktreeId) +type SleepIntent = { worktreeId: string; hostId: ExecutionHostId; token: object } +const intents = new Map() +const teardownCounts = new Map() +const listeners = new Map void>>() +const keyFor = (worktreeId: string, hostId: ExecutionHostId): string => `${hostId}\0${worktreeId}` + +function matchingKeys(worktreeId: string, hostId?: ExecutionHostId): string[] { + return hostId + ? [keyFor(worktreeId, hostId)] + : [...intents].filter(([, intent]) => intent.worktreeId === worktreeId).map(([key]) => key) +} + +function notifyCleared(key: string, worktreeId: string): void { + for (const listener of [...(listeners.get(key) ?? []), ...(listeners.get(worktreeId) ?? [])]) { + try { + listener() + } catch (error) { + console.error('[sleep-intent] wake listener failed', { worktreeId, error }) + } + } + listeners.delete(key) + if (!matchingKeys(worktreeId).length) { + listeners.delete(worktreeId) + } +} + +export function markWorktreeSleepIntent( + worktreeId: string, + hostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID +): object { + const key = keyFor(worktreeId, hostId) + const token = {} + intents.set(key, { worktreeId, hostId, token }) + return token +} + +export function isCurrentWorktreeSleepIntent( + worktreeId: string, + hostId: ExecutionHostId, + token: object +): boolean { + return intents.get(keyFor(worktreeId, hostId))?.token === token } -/** - * Why: a spawn that resolves while the sleep teardown is still awaiting its host - * would bind a PTY and clear the marker, waking every waiting pane mid-sleep. - * Binds during the teardown window are not wakes. - */ export async function withWorktreeSleepTeardown( worktreeId: string, - teardown: () => Promise + teardown: () => Promise, + hostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID ): Promise { - tearingDownWorktreeIds.add(worktreeId) + const finish = beginWorktreeSleepTeardown(worktreeId, hostId) try { return await teardown() } finally { - tearingDownWorktreeIds.delete(worktreeId) + finish() } } -export function clearWorktreeSleepIntent(worktreeId: string | null): void { - if (!worktreeId || tearingDownWorktreeIds.has(worktreeId)) { - return +export function beginWorktreeSleepTeardown( + worktreeId: string, + hostId: ExecutionHostId +): () => void { + const key = keyFor(worktreeId, hostId) + teardownCounts.set(key, (teardownCounts.get(key) ?? 0) + 1) + let finished = false + return () => { + if (finished) { + return + } + finished = true + const remaining = (teardownCounts.get(key) ?? 1) - 1 + if (remaining > 0) { + teardownCounts.set(key, remaining) + } else { + teardownCounts.delete(key) + if (!intents.has(key)) { + notifyCleared(key, worktreeId) + } + } } - if (!sleepingWorktreeIds.delete(worktreeId)) { +} + +export function clearWorktreeSleepIntent( + worktreeId: string | null, + hostId?: ExecutionHostId, + source: 'wake' | 'bind' = 'wake' +): void { + if (!worktreeId) { return } - const listeners = wakeListenersByWorktreeId.get(worktreeId) - wakeListenersByWorktreeId.delete(worktreeId) - for (const listener of listeners ?? []) { - try { - listener() - } catch (error) { - // Why: one pane's connect failure must not strand its siblings or throw out of a store action. - console.error('[sleep-intent] wake listener failed', { worktreeId, error }) + for (const key of matchingKeys(worktreeId, hostId)) { + const intent = intents.get(key) + if (!intent || (source === 'bind' && (teardownCounts.get(key) ?? 0) > 0)) { + continue + } + intents.delete(key) + if (!teardownCounts.has(key)) { + notifyCleared(key, worktreeId) } } } -// Why: a purged worktree must not wake its panes; they are being unmounted. export function forgetWorktreeSleepIntent(worktreeId: string): void { - sleepingWorktreeIds.delete(worktreeId) - tearingDownWorktreeIds.delete(worktreeId) - wakeListenersByWorktreeId.delete(worktreeId) + for (const key of matchingKeys(worktreeId)) { + intents.delete(key) + teardownCounts.delete(key) + listeners.delete(key) + } + listeners.delete(worktreeId) } -export function hasWorktreeSleepIntent(worktreeId: string | null): boolean { - return worktreeId !== null && sleepingWorktreeIds.has(worktreeId) +export function hasWorktreeSleepIntent( + worktreeId: string | null, + hostId?: ExecutionHostId +): boolean { + return worktreeId !== null && matchingKeys(worktreeId, hostId).some((key) => intents.has(key)) } -export function onWorktreeSleepIntentCleared(worktreeId: string, listener: () => void): () => void { - const listeners = wakeListenersByWorktreeId.get(worktreeId) ?? new Set<() => void>() - listeners.add(listener) - wakeListenersByWorktreeId.set(worktreeId, listeners) +export function onWorktreeSleepIntentCleared( + worktreeId: string, + listener: () => void, + hostId?: ExecutionHostId +): () => void { + const key = hostId ? keyFor(worktreeId, hostId) : worktreeId + const group = listeners.get(key) ?? new Set<() => void>() + group.add(listener) + listeners.set(key, group) return () => { - listeners.delete(listener) - if (listeners.size === 0 && wakeListenersByWorktreeId.get(worktreeId) === listeners) { - wakeListenersByWorktreeId.delete(worktreeId) + group.delete(listener) + if (group.size === 0 && listeners.get(key) === group) { + listeners.delete(key) } } } diff --git a/src/renderer/src/runtime/structured-agent-session-client.ts b/src/renderer/src/runtime/structured-agent-session-client.ts index eb12f827567d..0775258bc03e 100644 --- a/src/renderer/src/runtime/structured-agent-session-client.ts +++ b/src/renderer/src/runtime/structured-agent-session-client.ts @@ -97,7 +97,8 @@ const STRUCTURED_AGENT_SESSION_METHOD_TIMEOUT_MS: ReadonlyMap = ['agentSession.rewind', 195_000], // A waiting catalog read lasts as long as the host's listing: Claude's is 60 s, after up to 15 s // for an account switch to settle and 5 s of login-shell environment. - ['agentSession.modelCatalog', 90_000] + ['agentSession.modelCatalog', 90_000], + ['agentSession.sleepWorkspace', 120_000] ]) export async function callStructuredAgentSession( diff --git a/src/renderer/src/runtime/structured-agent-session-close.ts b/src/renderer/src/runtime/structured-agent-session-close.ts index a13c1359f0ce..a5f750980945 100644 --- a/src/renderer/src/runtime/structured-agent-session-close.ts +++ b/src/renderer/src/runtime/structured-agent-session-close.ts @@ -1,5 +1,4 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' -import { STRUCTURED_AGENT_SESSION_SLEEP_RUNTIME_CAPABILITY } from '../../../shared/structured-agent-session-surface-capabilities' import { runtimeEnvironmentSupportsCapability } from './runtime-rpc-client' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import { callStructuredAgentSession } from './structured-agent-session-client' @@ -20,20 +19,3 @@ export async function closeStructuredAgentSession( await callStructuredAgentSession(target, 'agentSession.close', { sessionId }) return 'closed' } - -/** Refuse Sleep before teardown when a paired host cannot stop native chats. */ -export async function assertStructuredAgentSessionSleepSupported( - target: RuntimeClientTarget -): Promise { - if ( - target.kind === 'environment' && - !(await runtimeEnvironmentSupportsCapability( - target.environmentId, - STRUCTURED_AGENT_SESSION_SLEEP_RUNTIME_CAPABILITY - )) - ) { - throw new Error(AGENT_SESSION_SLEEP_UNSUPPORTED) - } -} - -export const AGENT_SESSION_SLEEP_UNSUPPORTED = 'agent_session_sleep_unsupported' diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts index eafdd0e40fea..9fb30d178437 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts @@ -166,7 +166,7 @@ export function createSetActiveWorktree( // Why: any activation is an explicit wake (null is the sleep flow clearing selection). // Cleared after the set() above so a pane still waiting on the marker connects once, // in the remounted generation, instead of connecting and then being remounted. - clearWorktreeSleepIntent(worktreeId) + clearWorktreeSleepIntent(worktreeId, executionHostId ?? undefined) if (worktreeId && shouldPrepareTerminalTabs) { const prepareTerminalTabs = (): void => { diff --git a/src/renderer/src/store/terminals/terminal-pty-bindings.ts b/src/renderer/src/store/terminals/terminal-pty-bindings.ts index 45a28690371c..6a83e389b9c2 100644 --- a/src/renderer/src/store/terminals/terminal-pty-bindings.ts +++ b/src/renderer/src/store/terminals/terminal-pty-bindings.ts @@ -9,6 +9,7 @@ import { import { omitUnverifiedPtyLossTabIds } from './terminal-unverified-pty-loss' import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { omitDisownedPtyIds } from './terminal-disowned-pty-sources' +import { resolveTerminalWorktreeSleepOwner } from './terminal-worktree-sleep-owner' export function createTerminalPtyBindingActions( set: TerminalStoreSet, @@ -191,7 +192,10 @@ export function createTerminalPtyBindingActions( } }) // Why: a bound PTY means the workspace is awake by any route (CLI, automation, client wake), not only activation. - clearWorktreeSleepIntent(worktreeId) + const owner = resolveTerminalWorktreeSleepOwner(get(), worktreeId, ptyId) + if (owner) { + clearWorktreeSleepIntent(worktreeId, owner, 'bind') + } // Why: activation spawns come from clicking a worktree, not work in it — skip the lastActivityAt stamp and sortEpoch bump; other spawn reasons still bump. if (worktreeId && !wasActivationSpawn && !isRemoteRuntimeMirror) { get().bumpWorktreeActivity(worktreeId) diff --git a/src/renderer/src/store/terminals/terminal-pty-release.ts b/src/renderer/src/store/terminals/terminal-pty-release.ts index ac67488df9de..9a4434444c91 100644 --- a/src/renderer/src/store/terminals/terminal-pty-release.ts +++ b/src/renderer/src/store/terminals/terminal-pty-release.ts @@ -5,6 +5,7 @@ import { consumePendingActivationSpawn, getPendingActivationSpawnCount } from './terminal-pty-identities' +import { resolveTerminalWorktreeSleepOwner } from './terminal-worktree-sleep-owner' export function createTerminalPtyReleaseActions( set: TerminalStoreSet, @@ -107,7 +108,10 @@ export function createTerminalPtyReleaseActions( worktreeId && !wasActivationSpawn && !isRemoteRuntimeMirror && - !hasWorktreeSleepIntent(worktreeId) && + !hasWorktreeSleepIntent( + worktreeId, + resolveTerminalWorktreeSleepOwner(get(), worktreeId, ptyId) ?? undefined + ) && !(ptyId && get().suppressedPtyExitIds[ptyId]) ) { get().bumpWorktreeActivity(worktreeId) diff --git a/src/renderer/src/store/terminals/terminal-tab-creation.ts b/src/renderer/src/store/terminals/terminal-tab-creation.ts index de11a62f06f1..8f50289482c8 100644 --- a/src/renderer/src/store/terminals/terminal-tab-creation.ts +++ b/src/renderer/src/store/terminals/terminal-tab-creation.ts @@ -27,6 +27,7 @@ import { resolveCreatedTabShellOverride, worktreeUsesWslPath } from './terminal-workspace-routing' +import { resolveTerminalWorktreeSleepOwner } from './terminal-worktree-sleep-owner' type TabStartupCommand = TerminalSlice['pendingStartupByTabId'][string] function normalizeTabStartupCommand(startup: TabStartupCommand): TabStartupCommand { @@ -279,7 +280,10 @@ export function createTerminalTabCreationActions( }) if (options?.initialPtyId) { // Why: a tab born with a live PTY (CLI/runtime create) wakes the workspace like any other bind. - clearWorktreeSleepIntent(worktreeId) + const owner = resolveTerminalWorktreeSleepOwner(get(), worktreeId, options.initialPtyId) + if (owner) { + clearWorktreeSleepIntent(worktreeId, owner, 'bind') + } } const shouldRecordInteraction = options?.recordInteraction ?? (!options?.pendingActivationSpawn && !options?.initialPtyId) diff --git a/src/renderer/src/store/terminals/terminal-worktree-sleep-owner.test.ts b/src/renderer/src/store/terminals/terminal-worktree-sleep-owner.test.ts new file mode 100644 index 000000000000..98beb6876592 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-worktree-sleep-owner.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' +import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route' +import { resolveTerminalWorktreeSleepOwner } from './terminal-worktree-sleep-owner' + +describe('terminal workspace sleep owner', () => { + it('uses the PTY owner when another host with the same workspace id is selected', () => { + const state = { + activeWorktreeId: 'same-workspace', + activeWorkspaceExecutionHostId: 'runtime:env-b' + } satisfies WorktreeOperationRouteState + expect( + resolveTerminalWorktreeSleepOwner( + state, + 'same-workspace', + toRemoteRuntimePtyId('pty-1', 'env-a') + ) + ).toBe('runtime:env-a') + expect( + resolveTerminalWorktreeSleepOwner(state, 'same-workspace', toRemoteRuntimePtyId('pty-1')) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-worktree-sleep-owner.ts b/src/renderer/src/store/terminals/terminal-worktree-sleep-owner.ts new file mode 100644 index 000000000000..a3f920e6ac2b --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-worktree-sleep-owner.ts @@ -0,0 +1,25 @@ +import { + isRemoteRuntimePtyId, + parseRemoteRuntimePtyId +} from '../../../../shared/remote-runtime-pty-id' +import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { + resolveWorktreeOperationRouteResult, + type WorktreeOperationRouteState +} from '@/lib/worktree-operation-route' + +export function resolveTerminalWorktreeSleepOwner( + state: WorktreeOperationRouteState, + worktreeId: string | null, + ptyId: string | null | undefined +): ExecutionHostId | null { + if (!worktreeId) { + return null + } + if (ptyId && isRemoteRuntimePtyId(ptyId)) { + const environmentId = parseRemoteRuntimePtyId(ptyId)?.environmentId + return environmentId ? toRuntimeExecutionHostId(environmentId) : null + } + const resolution = resolveWorktreeOperationRouteResult(state, worktreeId) + return resolution.kind === 'resolved' ? resolution.route.executionHostId : null +} diff --git a/src/shared/dashboard-snapshot.ts b/src/shared/dashboard-snapshot.ts index bfd41984f814..461462c02a80 100644 --- a/src/shared/dashboard-snapshot.ts +++ b/src/shared/dashboard-snapshot.ts @@ -233,4 +233,5 @@ export type DashboardSpawnAgentArgs = { * teardown sequence, so the pop-out only names the target. */ export type DashboardSleepWorkspaceArgs = { worktreeId: string + executionHostId?: ExecutionHostId } diff --git a/src/shared/ephemeral-vm-runtime-host-match.ts b/src/shared/ephemeral-vm-runtime-host-match.ts new file mode 100644 index 000000000000..e6ffd16706fd --- /dev/null +++ b/src/shared/ephemeral-vm-runtime-host-match.ts @@ -0,0 +1,21 @@ +import { + toRuntimeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from './execution-host' +import type { EphemeralVmRuntimeRecord } from './ephemeral-vm-runtimes' + +/** VM records identify their execution host through the environment or managed SSH target. */ +export function ephemeralVmMatchesWorkspaceHost( + runtime: EphemeralVmRuntimeRecord, + workspaceId: string, + executionHostId: ExecutionHostId +): boolean { + return ( + runtime.workspaceId === workspaceId && + ((runtime.runtimeEnvironmentId !== undefined && + toRuntimeExecutionHostId(runtime.runtimeEnvironmentId) === executionHostId) || + (runtime.sshTargetId !== undefined && + toSshExecutionHostId(runtime.sshTargetId) === executionHostId)) + ) +} diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index f0bf5007d5f8..0972753b63e3 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -537,6 +537,7 @@ import { RewindParams, SendParams, SetOptionParams, + SleepWorkspaceParams, SubscribeParams, SubscribeTurnCompletionsParams, ThreadGoalParams, @@ -601,6 +602,7 @@ import { WorktreeResolvePrBase, WorktreeSelector as WorktreeSelectorOfWorktreeParams, WorktreeSet, + WorktreeSleep, WorktreeSortOrder, WorktreeTeardownMissingTerminalsParams } from './worktree-params' @@ -681,7 +683,7 @@ export const RPC_PARAMS_BY_METHOD = { 'agentSession.rewind': RewindParams, 'agentSession.send': SendParams, 'agentSession.setOption': SetOptionParams, - 'agentSession.sleep': OptionsParams, + 'agentSession.sleepWorkspace': SleepWorkspaceParams, 'agentSession.subscribe': SubscribeParams, 'agentSession.subscribeStatus': null, 'agentSession.subscribeTurnCompletions': SubscribeTurnCompletionsParams, @@ -1328,7 +1330,7 @@ export const RPC_PARAMS_BY_METHOD = { 'worktree.rm': WorktreeRemove, 'worktree.set': WorktreeSet, 'worktree.show': WorktreeSelectorOfWorktreeParams, - 'worktree.sleep': WorktreeSelectorOfWorktreeParams, + 'worktree.sleep': WorktreeSleep, 'worktree.teardownMissingTerminals': WorktreeTeardownMissingTerminalsParams } as const diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index 21a6c6821203..3164410c8841 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -270,6 +270,7 @@ export const SetOptionParams = z .strict() export const OptionsParams = z.object({ sessionId: SessionId }).strict() +export const SleepWorkspaceParams = z.object({ workspaceId: z.string().min(1) }).strict() export const AcknowledgeAttentionParams = OptionsParams.extend({ observedCursor: JournalCursor }) /** `agentSession.agents` takes nothing: the list is the host's, whichever client asks. */ diff --git a/src/shared/rpc-contract/worktree-params.ts b/src/shared/rpc-contract/worktree-params.ts index bf8d79b0ddc4..301e2da1ba53 100644 --- a/src/shared/rpc-contract/worktree-params.ts +++ b/src/shared/rpc-contract/worktree-params.ts @@ -86,6 +86,8 @@ export const WorktreeSelector = z.object({ .pipe(z.string().min(1, 'Missing worktree selector')) }) +export const WorktreeSleep = WorktreeSelector.extend({ hostId: OptionalExecutionHostId }) + export const WorktreeActivate = WorktreeSelector.extend({ notifyClients: OptionalBoolean, navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional() diff --git a/src/shared/structured-agent-session-surface-capabilities.ts b/src/shared/structured-agent-session-surface-capabilities.ts index 29446ff9a5ca..4aba07206f7c 100644 --- a/src/shared/structured-agent-session-surface-capabilities.ts +++ b/src/shared/structured-agent-session-surface-capabilities.ts @@ -25,10 +25,6 @@ export const STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY = // adopted the conversation or quietly started a blank one. Negotiate before offering the action. export const STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY = 'agent-session.structured.resume-history.v1' as const -// Why: `agentSession.sleep` stops a chat's agent but keeps its tab; an older host has only the close -// that hides the tab, so a client must learn during negotiation that Sleep cannot stop its chats. -export const STRUCTURED_AGENT_SESSION_SLEEP_RUNTIME_CAPABILITY = - 'agent-session.structured.sleep.v1' as const // Why: `agentSession.modelCatalog` takes `savedOnly`, an answer from what the host saved that starts // no listing. Its params are strict, so an older host refuses the key; a client preloads every agent @@ -42,6 +38,5 @@ export const STRUCTURED_AGENT_SESSION_SURFACE_RUNTIME_CAPABILITIES = [ STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_SLEEP_RUNTIME_CAPABILITY, AGENT_SESSION_MODEL_CATALOG_SAVED_ONLY_RUNTIME_CAPABILITY ] as const From 3ce1b309bee1d868a6e7e5c17b0fbc963467fcb9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:32:45 -0700 Subject: [PATCH 04/30] Fix sleep teardown wake and host-qualified receipt races --- .../structured-agent-session-host-delivery.ts | 4 +- ...red-agent-session-surface-lifetime.test.ts | 20 ++ .../structured-agent-session-task-queue.ts | 19 +- ...tructured-agent-session-workspace-sleep.ts | 1 - .../sidebar/sleep-worktree-flow.test.ts | 81 +++++++- .../components/sidebar/sleep-worktree-flow.ts | 174 ++++++++++-------- .../workspace-lineage-menu-actions.test.ts | 47 +++++ .../sidebar/workspace-lineage-menu-actions.ts | 54 ++++-- ...-connection-deliberate-sleep-guard.test.ts | 39 +++- .../pty-connection/run-deferred-connect.ts | 8 +- src/renderer/src/lib/worktree-sleep-intent.ts | 25 ++- 11 files changed, 354 insertions(+), 118 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index b59b15b9f45c..9ddd9be77144 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -170,7 +170,9 @@ export function createStructuredAgentSessionConversationDelivery(input: { // A chat open before its owner's death was proven revises what its open settled. Queued, never // awaited: the writer can hold this session's serialize (an attach recovering its lease). const stopResettling = deps.store.onDeathEvidence((sessionId) => { - if (sessions.has(sessionId)) { + const session = sessions.get(sessionId) + // A user close already settles and retires this handle; its next open re-derives any proof. + if (session && session.child?.close?.cause !== 'user-close') { void input .trackStart( input.serialize(sessionId, () => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 970a542d69aa..b2339d450763 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -269,6 +269,26 @@ describe('a chat that closes', () => { expect(store.getRecord(SESSION)).not.toBeNull() }) + it('does not count a send admitted after the stop as a stopped chat', async () => { + await attach() + await vi.waitFor(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) + const close = host['lifetime'].closeForWorkspaceSleep + let newerSend: ReturnType | null = null + vi.spyOn(host['lifetime'], 'closeForWorkspaceSleep').mockImplementation(async (...args) => { + const result = await close(...args) + const body = hostTestMessage('newer send') + newerSend = host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + return result + }) + + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [] }) + expect(newerSend).not.toBeNull() + expect(await newerSend).toMatchObject({ ok: true }) + }) + it('stops the provider child it started', async () => { await attach() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts index 1121c4bc6770..acafdea21aff 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts @@ -1,7 +1,4 @@ -import { - getKeyedSerializedQueueTail, - runKeyedSerializedOperation -} from '../../cli/keyed-promise-queue' +import { runKeyedSerializedOperation } from '../../cli/keyed-promise-queue' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' export class StructuredAgentSessionTaskQueue { @@ -16,7 +13,15 @@ export class StructuredAgentSessionTaskQueue { >() serialize(sessionId: string, task: () => Promise): Promise { - return runKeyedSerializedOperation(this.chains, sessionId, task) + const result = runKeyedSerializedOperation(this.chains, sessionId, task) + const ownTail = this.chains.get(sessionId) + // Settle this queue entry before its caller checks for work admitted afterward. + return ownTail + ? result.then(async (value) => { + await ownTail + return value + }) + : result } trackAttach( @@ -55,10 +60,6 @@ export class StructuredAgentSessionTaskQueue { ) } - waitForCurrent(sessionId: string): Promise { - return getKeyedSerializedQueueTail(this.chains, sessionId) - } - async drainAttaches(): Promise { while (this.attaching.size > 0) { await Promise.allSettled(this.attaching) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts index 9a315c8dbf13..3d859f003bbc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts @@ -43,7 +43,6 @@ export async function sleepStructuredAgentSessionWorkspace( .then((result) => ({ sessionId, result })) ) ) - await Promise.all([...candidates].map((sessionId) => host.tasks.waitForCurrent(sessionId))) const stoppedSessionIds = outcomes.flatMap((outcome) => { if (outcome.status !== 'fulfilled' || !outcome.value.result?.stopped) { return [] diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index bef242477d64..48056ffcfbf0 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -6,10 +6,11 @@ type SleepTestState = { folderWorkspaces: { id: string projectGroupId: string - connectionId: null - executionHostId: null + connectionId: string | null + executionHostId: string | null }[] - projectGroups: { id: string; connectionId: null; executionHostId: string }[] + projectGroups: { id: string; connectionId: string | null; executionHostId: string | null }[] + worktreesByRepo?: Record setActiveWorktree: ReturnType shutdownWorktreeBrowsers: ReturnType shutdownWorktreeTerminals: ReturnType @@ -75,6 +76,7 @@ describe('workspace sleep flow', () => { mocks.state.activeWorkspaceExecutionHostId = null mocks.state.folderWorkspaces = [] mocks.state.projectGroups = [] + mocks.state.worktreesByRepo = undefined mocks.state.setActiveWorktree.mockClear() mocks.state.shutdownWorktreeBrowsers.mockClear().mockResolvedValue(undefined) mocks.state.shutdownWorktreeTerminals.mockClear().mockResolvedValue(undefined) @@ -170,4 +172,77 @@ describe('workspace sleep flow', () => { expect(result[0].executionHostId).toBe('runtime:env-1') expect(mocks.target).toHaveBeenCalledWith('runtime:env-1') }) + + it('uses the selected folder owner when two hosts publish the same folder id', async () => { + mocks.state.folderWorkspaces = [ + { + id: 'folder-1', + projectGroupId: 'local-group', + connectionId: null, + executionHostId: 'local' + }, + { + id: 'folder-1', + projectGroupId: 'remote-group', + connectionId: null, + executionHostId: 'runtime:env-1' + } + ] + mocks.state.projectGroups = [ + { id: 'local-group', connectionId: null, executionHostId: 'local' }, + { id: 'remote-group', connectionId: null, executionHostId: 'runtime:env-1' } + ] + + const result = await runSleepWorktrees([{ id: 'folder:folder-1', hostId: 'runtime:env-1' }]) + expect(result[0].executionHostId).toBe('runtime:env-1') + expect(mocks.target).toHaveBeenCalledWith('runtime:env-1') + }) + + it('clears a matching legacy selection before terminal shutdown', async () => { + mocks.state.activeWorktreeId = 'wt-1' + mocks.state.activeWorkspaceExecutionHostId = null + mocks.state.worktreesByRepo = { + 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', hostId: 'local' }] + } + mocks.state.shutdownWorktreeTerminals.mockImplementationOnce(async () => { + expect(mocks.state.activeWorktreeId).toBeNull() + }) + + await runSleepWorktree('wt-1', 'local') + expect(mocks.state.setActiveWorktree).toHaveBeenCalledWith(null) + }) + + it('invalidates an early completion when the workspace wakes during a slow sibling', async () => { + const slow = deferred<{ stoppedSessionIds: string[] }>() + mocks.call + .mockResolvedValueOnce({ stoppedSessionIds: ['first'] }) + .mockReturnValueOnce(slow.promise) + const sleep = runSleepWorktrees([ + { id: 'wt-1', executionHostId: 'local' }, + { id: 'wt-2', executionHostId: 'local' } + ]) + await vi.waitFor(() => + expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenCalledWith('wt-1', { + keepIdentifiers: true + }) + ) + clearWorktreeSleepIntent('wt-1', 'local') + mocks.state.activeWorktreeId = 'wt-2' + slow.resolve({ stoppedSessionIds: ['second'] }) + + expect(await sleep).toEqual([ + { + workspaceId: 'wt-1', + executionHostId: 'local', + stoppedSessionIds: ['first'], + completed: false + }, + { + workspaceId: 'wt-2', + executionHostId: 'local', + stoppedSessionIds: ['second'], + completed: true + } + ]) + }) }) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index 93877de1e513..c1a23b7341f7 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -1,7 +1,6 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { - beginWorktreeSleepTeardown, clearWorktreeSleepIntent, isCurrentWorktreeSleepIntent, markWorktreeSleepIntent, @@ -19,7 +18,10 @@ import { } from '../../../../shared/execution-host' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route' -import { findIndexedFolderWorkspaceOwner } from '@/lib/worktree-runtime-owner-index' +import { + findIndexedFolderWorkspaceOwner, + findIndexedProjectGroupOwner +} from '@/lib/worktree-runtime-owner-index' import { getFolderWorkspaceHostId } from './folder-workspace-host-id' import type { AppState } from '@/store/types' @@ -104,11 +106,24 @@ function resolveSleepTarget( } const scope = parseWorkspaceKey(workspaceId) if (scope?.type === 'folder') { - const folder = findIndexedFolderWorkspaceOwner(state.folderWorkspaces, scope.folderWorkspaceId) + const selectedHost = + typeof target === 'string' + ? undefined + : target.runtimeOwnerEnvironmentId?.trim() + ? toRuntimeExecutionHostId(target.runtimeOwnerEnvironmentId.trim()) + : parseExecutionHostId(target.hostId)?.id + const folder = findIndexedFolderWorkspaceOwner( + state.folderWorkspaces, + scope.folderWorkspaceId, + selectedHost + ) if (!folder) { return null } - const group = state.projectGroups.find((candidate) => candidate.id === folder.projectGroupId) + const group = + (selectedHost + ? findIndexedProjectGroupOwner(state.projectGroups, folder.projectGroupId, selectedHost) + : null) ?? findIndexedProjectGroupOwner(state.projectGroups, folder.projectGroupId) const executionHostId = getFolderWorkspaceHostId( folder, group ?? { connectionId: null, executionHostId: null }, @@ -159,9 +174,6 @@ export async function runSleepWorktrees( ...target, token: markWorktreeSleepIntent(target.workspaceId, target.executionHostId) })) - const finishTeardowns = marked.map((target) => - beginWorktreeSleepTeardown(target.workspaceId, target.executionHostId) - ) const nativeCalls = marked.map(({ workspaceId, executionHostId }) => { const nativeTarget = structuredAgentSessionTargetForHost(executionHostId) return nativeTarget @@ -175,10 +187,15 @@ export async function runSleepWorktrees( ) : Promise.resolve({ ok: true as const, value: { stoppedSessionIds: [] } }) }) + const activeRoute = state.activeWorktreeId + ? resolveWorktreeOperationRouteResult(state, state.activeWorktreeId) + : null + const activeHost = + state.activeWorkspaceExecutionHostId ?? + (activeRoute?.kind === 'resolved' ? activeRoute.route.executionHostId : null) const selected = marked.find( (target) => - state.activeWorktreeId === target.workspaceId && - state.activeWorkspaceExecutionHostId === target.executionHostId + state.activeWorktreeId === target.workspaceId && activeHost === target.executionHostId ) if (selected) { const restoreSidebarPosition = preserveSidebarWorktreePosition(selected.workspaceId) @@ -186,73 +203,71 @@ export async function runSleepWorktrees( restoreSidebarPosition() } const results = await Promise.all( - marked.map((target, index) => - withWorktreeSleepTeardown( - target.workspaceId, - async (): Promise => { - const { workspaceId, executionHostId, token } = target - const current = (): boolean => - isCurrentWorktreeSleepIntent(workspaceId, executionHostId, token) - let stoppedSessionIds: string[] = [] - let nativeCompleted = true - let panelsCompleted = true - let terminalsStopped = false - const native = await nativeCalls[index] - if (native.ok) { - stoppedSessionIds = native.value.stoppedSessionIds - } else { - console.error('[sleep-worktree] native chat stop failed', { - workspaceId, - error: native.error - }) - nativeCompleted = false - errors.push(describeChatStopFailure()) - } - if (current()) { - try { - await state.shutdownWorktreeBrowsers(workspaceId) - } catch (error) { - console.error('[sleep-worktree] browser shutdown failed', { workspaceId, error }) - panelsCompleted = false - errors.push(describeSleepFailure(error)) - } - } else { - panelsCompleted = false - } - if (current() && panelsCompleted) { - try { - await state.shutdownWorktreeTerminals(workspaceId, { keepIdentifiers: true }) - terminalsStopped = true - } catch (error) { - console.error('[sleep-worktree] terminal shutdown failed', { workspaceId, error }) - panelsCompleted = false - errors.push(describeSleepFailure(error)) - } - } - if ( - current() && - terminalsStopped && - typeof window !== 'undefined' && - window.api?.ephemeralVm?.suspendWorkspace - ) { - try { - await window.api.ephemeralVm.suspendWorkspace({ workspaceId, executionHostId }) - } catch (error) { - console.error('[sleep-worktree] host suspension failed', { workspaceId, error }) - panelsCompleted = false - errors.push(describeSleepFailure(error)) - } - } - const completed = nativeCompleted && panelsCompleted && terminalsStopped && current() - if (!completed && !terminalsStopped && current()) { - clearWorktreeSleepIntent(workspaceId, executionHostId) - } - return { workspaceId, executionHostId, stoppedSessionIds, completed } - }, - target.executionHostId - ) - ) - ).finally(() => finishTeardowns.forEach((finish) => finish())) + marked.map(async (target, index): Promise => { + const { workspaceId, executionHostId, token } = target + const current = (): boolean => + isCurrentWorktreeSleepIntent(workspaceId, executionHostId, token) + let stoppedSessionIds: string[] = [] + let nativeCompleted = true + let panelsCompleted = true + let terminalsStopped = false + const native = await nativeCalls[index] + if (native.ok) { + stoppedSessionIds = native.value.stoppedSessionIds + } else { + console.error('[sleep-worktree] native chat stop failed', { + workspaceId, + error: native.error + }) + nativeCompleted = false + errors.push(describeChatStopFailure()) + } + if (current()) { + try { + await state.shutdownWorktreeBrowsers(workspaceId) + } catch (error) { + console.error('[sleep-worktree] browser shutdown failed', { workspaceId, error }) + panelsCompleted = false + errors.push(describeSleepFailure(error)) + } + } else { + panelsCompleted = false + } + if (current() && panelsCompleted) { + try { + await withWorktreeSleepTeardown( + workspaceId, + () => state.shutdownWorktreeTerminals(workspaceId, { keepIdentifiers: true }), + executionHostId + ) + terminalsStopped = true + } catch (error) { + console.error('[sleep-worktree] terminal shutdown failed', { workspaceId, error }) + panelsCompleted = false + errors.push(describeSleepFailure(error)) + } + } + if ( + current() && + terminalsStopped && + typeof window !== 'undefined' && + window.api?.ephemeralVm?.suspendWorkspace + ) { + try { + await window.api.ephemeralVm.suspendWorkspace({ workspaceId, executionHostId }) + } catch (error) { + console.error('[sleep-worktree] host suspension failed', { workspaceId, error }) + panelsCompleted = false + errors.push(describeSleepFailure(error)) + } + } + const completed = nativeCompleted && panelsCompleted && terminalsStopped && current() + if (!completed && !terminalsStopped && current()) { + clearWorktreeSleepIntent(workspaceId, executionHostId) + } + return { workspaceId, executionHostId, stoppedSessionIds, completed } + }) + ) if (errors.length > 0) { toast.error( resolved.length === 1 @@ -267,5 +282,10 @@ export async function runSleepWorktrees( { description: [...new Set(errors)].join('\n') } ) } - return results + return results.map((result, index) => ({ + ...result, + completed: + result.completed && + isCurrentWorktreeSleepIntent(result.workspaceId, result.executionHostId, marked[index].token) + })) } diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts index d86522df1257..5d4d5f27bb61 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts @@ -179,4 +179,51 @@ describe('hasSleepableWorkspaceActivity', () => { ) ).toBe(false) }) + + it('offers Sleep for the selected host of a duplicate chat-only folder', () => { + const chat: Tab = { + id: 'chat-folder', + entityId: 'session-folder', + groupId: 'group-1', + worktreeId: 'folder:folder-1', + contentType: 'agent-session', + agentSessionAgent: 'claude', + executionHostId: 'runtime:env-1', + label: 'Claude', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + expect( + hasSleepableWorkspaceActivity( + { id: 'folder:folder-1', hostId: 'runtime:env-1' }, + { + tabsByWorktree: {}, + ptyIdsByTabId: {}, + browserTabsByWorktree: {}, + unifiedTabsByWorktree: { 'folder:folder-1': [chat] }, + chatOwnerState: { + folderWorkspaces: [ + { + id: 'folder-1', + projectGroupId: 'local-group', + connectionId: null, + executionHostId: 'local' + }, + { + id: 'folder-1', + projectGroupId: 'remote-group', + connectionId: null, + executionHostId: 'runtime:env-1' + } + ], + projectGroups: [ + { id: 'remote-group', connectionId: null, executionHostId: 'runtime:env-1' } + ] + } + } + ) + ).toBe(true) + }) }) diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts index 175f4702dedb..9ede020c799c 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts @@ -7,10 +7,17 @@ import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner-sta import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' import { isStructuredTab } from '@/components/native-chat/structured-agent-session-tabs' import type { WorkspaceSleepTarget } from './sleep-worktree-flow' -import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../../shared/execution-host' +import { + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + toRuntimeExecutionHostId +} from '../../../../shared/execution-host' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route' -import { findIndexedFolderWorkspaceOwner } from '@/lib/worktree-runtime-owner-index' +import { + findIndexedFolderWorkspaceOwner, + findIndexedProjectGroupOwner +} from '@/lib/worktree-runtime-owner-index' import { getFolderWorkspaceHostId } from './folder-workspace-host-id' type WorkspaceActivityMaps = { @@ -43,30 +50,45 @@ export function hasSleepableWorkspaceActivity( const explicitHost = typeof target === 'string' ? undefined - : (target.executionHostId ?? (scope?.type === 'folder' ? undefined : target.hostId)) + : (target.executionHostId ?? + (scope?.type === 'folder' && target.runtimeOwnerEnvironmentId?.trim() + ? toRuntimeExecutionHostId(target.runtimeOwnerEnvironmentId.trim()) + : target.hostId)) const route = resolveWorktreeOperationRouteResult(chatOwnerState, worktreeId) const folder = scope?.type === 'folder' - ? findIndexedFolderWorkspaceOwner(chatOwnerState.folderWorkspaces, scope.folderWorkspaceId) + ? findIndexedFolderWorkspaceOwner( + chatOwnerState.folderWorkspaces, + scope.folderWorkspaceId, + parseExecutionHostId(explicitHost)?.id + ) : null const group = folder - ? chatOwnerState.projectGroups?.find((candidate) => candidate.id === folder.projectGroupId) + ? ((explicitHost + ? findIndexedProjectGroupOwner( + chatOwnerState.projectGroups, + folder.projectGroupId, + parseExecutionHostId(explicitHost)?.id + ) + : null) ?? + findIndexedProjectGroupOwner(chatOwnerState.projectGroups, folder.projectGroupId)) : null const selectedHost = - explicitHost ?? - (scope?.type === 'folder' + scope?.type === 'folder' ? folder - ? getFolderWorkspaceHostId( - folder, - group ?? { connectionId: null, executionHostId: null }, - parseExecutionHostId( - chatOwnerState.restoredRuntimeHostIdByWorkspaceSessionKey?.[worktreeId] - )?.id ?? LOCAL_EXECUTION_HOST_ID - ) + ? target && typeof target !== 'string' && target.executionHostId + ? target.executionHostId + : getFolderWorkspaceHostId( + folder, + group ?? { connectionId: null, executionHostId: null }, + parseExecutionHostId( + chatOwnerState.restoredRuntimeHostIdByWorkspaceSessionKey?.[worktreeId] + )?.id ?? LOCAL_EXECUTION_HOST_ID + ) : null : route.kind === 'resolved' - ? route.route.executionHostId - : null) + ? (explicitHost ?? route.route.executionHostId) + : (explicitHost ?? null) const hostId = parseExecutionHostId(selectedHost)?.id return ( tabs.some((tab) => tabHasLivePty(ptyIdsByTabId, tab.id)) || diff --git a/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts index 71cd170be084..833890ff5c9d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts @@ -1,5 +1,6 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' import { flushAsyncTicks } from './pty-connection-test-async' import { createMockTransport, @@ -185,9 +186,9 @@ describe('deliberate sleep keeps mounted panes cold', () => { expect(transport.connect).not.toHaveBeenCalled() }) - it('connects a waiting pane once the workspace is woken', async () => { + it('connects a pane mounted after wake only when issued teardown settles', async () => { const { connectPanePty } = await import('./pty-connection') - const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = + const { beginWorktreeSleepTeardown, clearWorktreeSleepIntent, markWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') const transport = createMockTransport() transportFactoryQueue.push(transport) @@ -197,6 +198,8 @@ describe('deliberate sleep keeps mounted panes cold', () => { tabsByWorktree: { 'wt-1': [{ id: 'tab-woken', ptyId: 'wt-1@@dead' }] } } markWorktreeSleepIntent('wt-1') + const finish = beginWorktreeSleepTeardown('wt-1', 'local') + clearWorktreeSleepIntent('wt-1') const deps = createDeps({ tabId: 'tab-woken', restoredLeafId: LEAF_1, @@ -208,12 +211,34 @@ describe('deliberate sleep keeps mounted panes cold', () => { await flushAsyncTicks() expect(transport.connect).not.toHaveBeenCalled() - clearWorktreeSleepIntent('wt-1') + finish() await flushAsyncTicks() expect(transport.connect).toHaveBeenCalledTimes(1) }) + it('waits for both same-id host teardowns before connecting an unqualified pane', async () => { + const { connectPanePty } = await import('./pty-connection') + const { beginWorktreeSleepTeardown } = await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const finishA = beginWorktreeSleepTeardown('wt-1', toRuntimeExecutionHostId('host-a')) + const finishB = beginWorktreeSleepTeardown('wt-1', toRuntimeExecutionHostId('host-b')) + const deps = createDeps({ tabId: 'tab-overlap', isVisibleRef: { current: false } }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + expect(transport.connect).not.toHaveBeenCalled() + + finishA() + await flushAsyncTicks() + expect(transport.connect).not.toHaveBeenCalled() + + finishB() + await flushAsyncTicks() + expect(transport.connect).toHaveBeenCalledTimes(1) + }) + it('does not connect a waiting pane whose tab was remounted by the wake', async () => { const { connectPanePty } = await import('./pty-connection') const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = @@ -327,10 +352,12 @@ describe('deliberate sleep keeps mounted panes cold', () => { it('still connects a slept pane that carries a queued startup', async () => { const { connectPanePty } = await import('./pty-connection') - const { markWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') + const { beginWorktreeSleepTeardown, markWorktreeSleepIntent } = + await import('@/lib/worktree-sleep-intent') const transport = createMockTransport() transportFactoryQueue.push(transport) markWorktreeSleepIntent('wt-1') + const finish = beginWorktreeSleepTeardown('wt-1', 'local') const deps = createDeps({ tabId: 'tab-slept-startup', isVisibleRef: { current: false }, @@ -340,6 +367,10 @@ describe('deliberate sleep keeps mounted panes cold', () => { connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) await flushAsyncTicks() + expect(transport.connect).not.toHaveBeenCalled() + finish() + await flushAsyncTicks() + expect(transport.connect).toHaveBeenCalledTimes(1) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts b/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts index 24d7aa5c9db8..d55baff612fa 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts @@ -7,6 +7,7 @@ import { createCodexBackfillErrorDetector } from '../codex-backfill-error-detect import { clearWorktreeSleepIntent, hasWorktreeSleepIntent, + hasWorktreeSleepTeardown, onWorktreeSleepIntentCleared } from '@/lib/worktree-sleep-intent' @@ -47,7 +48,10 @@ export function installRunDeferredConnect(session: ConnectPanePtySession): void // Why: a deliberately slept workspace keeps its panes mounted, so connecting // would reattach the retained session id and respawn the shell (#10205). // Wait for the wake instead; a queued startup is an explicit launch. - if (hasWorktreeSleepIntent(session.deps.worktreeId, executionHostId) && !session.paneStartup) { + if ( + (hasWorktreeSleepIntent(session.deps.worktreeId, executionHostId) && !session.paneStartup) || + hasWorktreeSleepTeardown(session.deps.worktreeId, executionHostId) + ) { session.cancelScheduledConnectFrame() if (session.connectFallbackTimer !== null) { clearTimeout(session.connectFallbackTimer) @@ -56,7 +60,7 @@ export function installRunDeferredConnect(session: ConnectPanePtySession): void if (!wakeWaitStarted) { wakeWaitStarted = true recordPtyConnectDiagnostic( - `pane=${session.pane.id} tab=${session.deps.tabId} -> WAIT FOR WAKE (deliberate sleep)` + `pane=${session.pane.id} tab=${session.deps.tabId} -> WAIT FOR SLEEP TEARDOWN OR WAKE` ) const unsubscribe = onWorktreeSleepIntentCleared( session.deps.worktreeId, diff --git a/src/renderer/src/lib/worktree-sleep-intent.ts b/src/renderer/src/lib/worktree-sleep-intent.ts index 09f9a919ab04..d1a11c35c920 100644 --- a/src/renderer/src/lib/worktree-sleep-intent.ts +++ b/src/renderer/src/lib/worktree-sleep-intent.ts @@ -13,17 +13,20 @@ function matchingKeys(worktreeId: string, hostId?: ExecutionHostId): string[] { } function notifyCleared(key: string, worktreeId: string): void { - for (const listener of [...(listeners.get(key) ?? []), ...(listeners.get(worktreeId) ?? [])]) { + const keyListeners = [...(listeners.get(key) ?? [])] + listeners.delete(key) + const allHostsCleared = !matchingKeys(worktreeId).length && !hasWorktreeSleepTeardown(worktreeId) + const worktreeListeners = allHostsCleared ? [...(listeners.get(worktreeId) ?? [])] : [] + if (allHostsCleared) { + listeners.delete(worktreeId) + } + for (const listener of [...keyListeners, ...worktreeListeners]) { try { listener() } catch (error) { console.error('[sleep-intent] wake listener failed', { worktreeId, error }) } } - listeners.delete(key) - if (!matchingKeys(worktreeId).length) { - listeners.delete(worktreeId) - } } export function markWorktreeSleepIntent( @@ -117,6 +120,18 @@ export function hasWorktreeSleepIntent( return worktreeId !== null && matchingKeys(worktreeId, hostId).some((key) => intents.has(key)) } +export function hasWorktreeSleepTeardown( + worktreeId: string | null, + hostId?: ExecutionHostId +): boolean { + if (worktreeId === null) { + return false + } + return hostId + ? teardownCounts.has(keyFor(worktreeId, hostId)) + : [...teardownCounts.keys()].some((key) => key.endsWith(`\0${worktreeId}`)) +} + export function onWorktreeSleepIntentCleared( worktreeId: string, listener: () => void, From 402f595a9bd5576e31585252ae64eb9ec3bd3864 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:27:03 -0700 Subject: [PATCH 05/30] Verify mobile Sleep sends the selected desktop host --- mobile/src/host-screen/host-server-workspaces-wiring.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mobile/src/host-screen/host-server-workspaces-wiring.test.ts b/mobile/src/host-screen/host-server-workspaces-wiring.test.ts index 5256ac4d1f67..f9fd4d06e422 100644 --- a/mobile/src/host-screen/host-server-workspaces-wiring.test.ts +++ b/mobile/src/host-screen/host-server-workspaces-wiring.test.ts @@ -385,7 +385,10 @@ describe("a desktop's server workspaces on the phone", () => { const client = desktop() const screen = await mountScreen(client, RELAYS) screen.sleep('mac-wt') - expect(client.sendRequest).toHaveBeenCalledWith('worktree.sleep', { worktree: 'id:mac-wt' }) + expect(client.sendRequest).toHaveBeenCalledWith('worktree.sleep', { + worktree: 'id:mac-wt', + hostId: 'local' + }) expect(screen.slept()?.size).toBe(1) }) From 855fefb114ba20d0ce55d693448023c47917236c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:37:19 -0700 Subject: [PATCH 06/30] Keep worktree Sleep RPC within method file limit --- .../runtime/rpc/methods/worktree-sleep-method.ts | 14 ++++++++++++++ src/main/runtime/rpc/methods/worktree.ts | 13 ++----------- 2 files changed, 16 insertions(+), 11 deletions(-) create mode 100644 src/main/runtime/rpc/methods/worktree-sleep-method.ts diff --git a/src/main/runtime/rpc/methods/worktree-sleep-method.ts b/src/main/runtime/rpc/methods/worktree-sleep-method.ts new file mode 100644 index 000000000000..d7fa40c7c877 --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-sleep-method.ts @@ -0,0 +1,14 @@ +import { WorktreeSleep } from '../../../../shared/rpc-contract/worktree-params' +import { defineMethod } from '../core' +import { resolvePairedCallerHostId } from './paired-caller-host-id' + +export const WORKTREE_SLEEP_METHOD = defineMethod({ + name: 'worktree.sleep', + permission: 'workspace', + params: WorktreeSleep, + handler: async (params, { runtime }) => + runtime.sleepManagedWorktree( + params.worktree, + resolvePairedCallerHostId(() => runtime.listRepos(), params.worktree, params.hostId) + ) +}) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 89062fa3c3a9..8a4624bc8579 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -13,6 +13,7 @@ import { createWorktreeWithStartupAgent } from '../../../agent-launch/startup-ag import { defineMethod } from '../core' import { buildManagedWorktreeCreateArgs } from './worktree-create-args' import { resolvePairedCallerHostId } from './paired-caller-host-id' +import { WORKTREE_SLEEP_METHOD } from './worktree-sleep-method' import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context' import { @@ -26,7 +27,6 @@ import { WorktreeResolveMrBase, WorktreeResolvePrBase, WorktreeSelector, - WorktreeSleep, WorktreeSet, WorktreeSortOrder, WorktreeTeardownMissingTerminalsParams @@ -65,16 +65,7 @@ export const WORKTREE_METHODS = [ worktree: await runtime.showManagedWorktree(params.worktree) }) }), - defineMethod({ - name: 'worktree.sleep', - permission: 'workspace', - params: WorktreeSleep, - handler: async (params, { runtime }) => - runtime.sleepManagedWorktree( - params.worktree, - resolvePairedCallerHostId(() => runtime.listRepos(), params.worktree, params.hostId) - ) - }), + WORKTREE_SLEEP_METHOD, defineMethod({ name: 'worktree.activate', permission: 'workspace', From 7ed4cadc9c18711879d698728683633d2f789ac7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:46:25 -0700 Subject: [PATCH 07/30] Use sleep intent as attempt identity --- src/renderer/src/lib/worktree-sleep-intent.ts | 14 +++++++------- .../workspace-activation-preparation.test.ts | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/lib/worktree-sleep-intent.ts b/src/renderer/src/lib/worktree-sleep-intent.ts index d1a11c35c920..93bd1dc84dea 100644 --- a/src/renderer/src/lib/worktree-sleep-intent.ts +++ b/src/renderer/src/lib/worktree-sleep-intent.ts @@ -1,6 +1,6 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' -type SleepIntent = { worktreeId: string; hostId: ExecutionHostId; token: object } +type SleepIntent = { worktreeId: string; hostId: ExecutionHostId } const intents = new Map() const teardownCounts = new Map() const listeners = new Map void>>() @@ -32,19 +32,19 @@ function notifyCleared(key: string, worktreeId: string): void { export function markWorktreeSleepIntent( worktreeId: string, hostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID -): object { +): SleepIntent { const key = keyFor(worktreeId, hostId) - const token = {} - intents.set(key, { worktreeId, hostId, token }) - return token + const intent = { worktreeId, hostId } + intents.set(key, intent) + return intent } export function isCurrentWorktreeSleepIntent( worktreeId: string, hostId: ExecutionHostId, - token: object + intent: SleepIntent ): boolean { - return intents.get(keyFor(worktreeId, hostId))?.token === token + return intents.get(keyFor(worktreeId, hostId)) === intent } export async function withWorktreeSleepTeardown( diff --git a/src/renderer/src/store/slices/workspace-activation-preparation.test.ts b/src/renderer/src/store/slices/workspace-activation-preparation.test.ts index 141cbaa3a898..5b94df8bacff 100644 --- a/src/renderer/src/store/slices/workspace-activation-preparation.test.ts +++ b/src/renderer/src/store/slices/workspace-activation-preparation.test.ts @@ -55,7 +55,7 @@ it.each([ ['git', 'ssh:remote'] ] as const)('wakes a slept %s on %s and restores its explorer mode atomically', (kind, hostId) => { const { store, workspaceId, activate } = seedWorkspace(kind, hostId) - markWorktreeSleepIntent(workspaceId) + markWorktreeSleepIntent(workspaceId, hostId) const wake = vi.fn(() => { expect(store.getState().activeWorktreeId).toBe(workspaceId) expect(store.getState().tabsByWorktree[workspaceId][0].generation).toBe(4) From 3f68a04c9a40abeb59125c5ce03c2432e4472d2e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:14:46 -0700 Subject: [PATCH 08/30] Gate workspace sleep on structured client support --- ...uctured-agent-session-gate-classification.test-fixture.ts | 5 +++++ .../runtime/rpc/methods/structured-agent-session.test.ts | 4 ++-- src/main/runtime/rpc/methods/structured-agent-session.ts | 1 + .../structured-agent-session-host-fixture.ts | 1 + .../structured-agent-session-surface-calls.ts | 5 +++++ .../structured-agent-session-surface-manifest.ts | 2 ++ 6 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts index 12813a8f66ea..f0e68d1ca2f1 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts @@ -16,6 +16,11 @@ export const CLEANUP_METHODS = [ params: { sessionId: SESSION }, hostCall: 'close' }, + { + method: 'agentSession.sleepWorkspace', + params: { workspaceId: 'workspace-1' }, + hostCall: 'sleepWorkspace' + }, { method: 'agentSession.cancel', params: { envelope: envelope(), turnId: 'turn-1' }, diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 5fe6abe0c044..bf052115ace5 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -47,7 +47,7 @@ it('stops a workspace through its host without a dedicated sleep capability', as const response = await call( 'agentSession.sleepWorkspace', { workspaceId: 'workspace-1' }, - { clientKind: 'runtime', clientCapabilities: [] } + { clientKind: 'runtime', clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] } ) expect(response).toMatchObject({ ok: true, result: { stoppedSessionIds: [SESSION] } }) expect(hostCalls.sleepWorkspace).toHaveBeenCalledWith('workspace-1') @@ -346,7 +346,7 @@ describe('capability gating', () => { } ) - it.each(CLEANUP_METHODS)( + it.each(CLEANUP_METHODS.filter(({ method }) => method !== 'agentSession.sleepWorkspace'))( 'does not install a host for cleanup-only method $method', async ({ method, params }) => { const ensureHost = vi.fn() diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 54cce9e7a91b..b615a74a2e17 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -150,6 +150,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ permission: 'workspace', params: SleepWorkspaceParams, handler: async (params, ctx) => { + requireStructuredCapability(ctx) const installed = getStructuredAgentSessionHost() if (installed) { return installed.sleepWorkspace(params.workspaceId) diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index dc297fcac679..5e1d84bd9a5b 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -72,6 +72,7 @@ export function structuredHostStub( value: { itemId: 'item-1', epoch: 'rewound-epoch' } })), close: vi.fn(async () => undefined), + sleepWorkspace: vi.fn(async () => ({ stoppedSessionIds: [] })), revealSession: vi.fn(async () => ({ sessionId, workspaceId, diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts index d07919e299fe..7181f872befb 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts @@ -77,6 +77,11 @@ export const STRUCTURED_CALLS: { result: { ok: true, replayed: false, value: { itemId: 'item-1', epoch: 'rewound-epoch' } } }, { method: 'agentSession.close', hostMethod: 'close', result: { ok: true } }, + { + method: 'agentSession.sleepWorkspace', + hostMethod: 'sleepWorkspace', + result: { stoppedSessionIds: [] } + }, { method: 'agentSession.respondToApproval', hostMethod: 'respondToPrompt', diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts index 3fd520c5c3a5..6e5e4ab02faf 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts @@ -178,6 +178,8 @@ export function paramsFor(method: string): unknown { } case 'agentSession.history': return { sessionId: SESSION, direction: 'tail' } + case 'agentSession.sleepWorkspace': + return { workspaceId: WORKSPACE } case 'agentSession.acknowledgeAttention': return { ...ATTENTION_READ, observedCursor: { ...ATTENTION_READ.observedCursor } } case 'agentSession.modelCatalog': From 38ffa7012ec1ed46a7d8c0518a368e47d507ab8d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:53:02 -0700 Subject: [PATCH 09/30] fix(native-chat): preserve sleep stop receipts and send timing --- .../codex/codex-app-server-connection.test.ts | 20 +-- .../structured-agent-session-child-exit.ts | 13 +- ...red-agent-session-conversation-lifetime.ts | 125 ++++++++++-------- ...structured-agent-session-mutation-plans.ts | 2 +- ...ctured-agent-session-queued-send-answer.ts | 4 +- ...red-agent-session-surface-lifetime.test.ts | 10 +- .../structured-agent-session-task-queue.ts | 19 ++- .../structured-agent-session-turns.ts | 4 +- ...tructured-agent-session-workspace-sleep.ts | 56 +++++--- .../rpc/methods/structured-agent-session.ts | 29 ++-- ...reeContextMenu.copy-worktree-name.test.tsx | 1 + ...rktreeContextMenu.delete-shortcut.test.tsx | 1 + ...reeContextMenu.react185-bystander.test.tsx | 1 + .../WorktreeDeveloperMenuReveal.test.tsx | 1 + .../sidebar/sleep-worktree-flow.test.ts | 12 ++ .../components/sidebar/sleep-worktree-flow.ts | 14 +- src/shared/agent-session-workspace-sleep.ts | 7 + 17 files changed, 195 insertions(+), 124 deletions(-) create mode 100644 src/shared/agent-session-workspace-sleep.ts diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index 3f90182acd41..5090001308c0 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -796,18 +796,14 @@ describe('openCodexAppServerConnection', () => { }, spawnImpl ) - child.kill.mockImplementation(() => { - child.emit('exit', null, 'SIGKILL') - return true - }) - const inFlight = rejection(connection.request('turn/start')) child.stdout.write(`${JSON.stringify(frame)}\n`) expect((await inFlight).message).toContain('structured sink failed') - expect(exits).toEqual([expect.stringContaining('structured sink failed')]) + expect(exits).toHaveLength(0) expect(connection.closed).toBe(true) - await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGKILL')) + child.emit('exit', null, 'SIGKILL') + expect(exits).toEqual([expect.stringContaining('structured sink failed')]) await connection.close() }) @@ -869,20 +865,16 @@ describe('openCodexAppServerConnection', () => { { onExit: (error) => exits.push(error.message) }, spawnImpl ) - child.kill.mockImplementation(() => { - child.emit('exit', null, 'SIGKILL') - return true - }) - const inFlight = rejection(connection.request('turn/start')) child.stdin.emit('error', new Error('write EPIPE')) expect((await inFlight).message).toContain('EPIPE') - expect(exits).toHaveLength(1) + expect(exits).toHaveLength(0) // A child nobody can write to is not a live session: the owner must see the // connection as gone rather than keep issuing calls that can only time out. expect(connection.closed).toBe(true) - await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGKILL')) + child.emit('exit', null, 'SIGKILL') + expect(exits).toHaveLength(1) expect((await rejection(connection.request('turn/start'))).message).toContain('EPIPE') await connection.close() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index e9fcdf063094..568e26af3f9d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -8,6 +8,7 @@ import { type SubmissionRejectionFact } from '../../../shared/agent-session-failure' import { PROVIDER_EXIT_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter' @@ -177,11 +178,19 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< } context.publishStatus?.(sessionId) } + const wakeQueuedDelivery = (): void => { + if ( + !session.journal.submissions || + session.journal.submissions().some(isQueuedAgentJournalSubmission) + ) { + context.wakeDelivery?.(sessionId) + } + } const record = context.store.getRecord(sessionId) if (!record || record.lease.handoffStage !== null) { // An acquisition or recovery already owns this lease's transition. endChild() - context.wakeDelivery?.(sessionId) + wakeQueuedDelivery() return } try { @@ -276,7 +285,7 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< // Stop's own) issued at the fence before. Either way every reader learns the generation ended // and the queued-card drain runs, whether or not the release or the settlement was written. context.generationEnded(sessionId, { restate: released && !expected }) - context.wakeDelivery?.(sessionId) + wakeQueuedDelivery() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index f654e33cf0bd..dd5d60aec63c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -87,6 +87,29 @@ export function createStructuredAgentSessionConversationLifetime(host: { const providerHoldsDispatch = (sessionId: string): boolean => deps().adapter.holdsDispatch?.(sessionId) === true + const holdStopClose = async ( + sessionId: string, + stop: (held: boolean) => Promise, + onCloseError?: (error: unknown) => void + ): Promise => { + const session = sessions.get(sessionId) + const held = session + ? await holdClosedStructuredAgentSessionSends(deps(), sessionId, session.journal, { + mark: 'always' + }) + : true + const result = await stop(held) + try { + await closeConversation(sessionId) + } catch (error) { + if (!onCloseError) { + throw error + } + onCloseError(error) + } + return result + } + const closeForWorkspaceSleep = ( sessionId: string, workspaceId: string, @@ -113,53 +136,52 @@ export function createStructuredAgentSessionConversationLifetime(host: { const session = sessions.get(sessionId) const child = session?.child const owned = deps().adapter.hasOwnedAcquisition?.(sessionId) === true - if (session) { - await holdClosedStructuredAgentSessionSends(deps(), sessionId, session.journal, { - mark: 'always' - }) - } - let ownedStopped = false - try { - if (child) { - await stopAgent(sessionId, { cause: 'user-close' }) - } else if (owned) { - ownedStopped = await stopAgentSessionProviderRoot( - () => deps().adapter.releaseAcquisition?.({ sessionId }) ?? Promise.resolve(false) + const stopped = await holdStopClose( + sessionId, + async (held) => { + let ownedStopped = false + try { + if (child) { + await stopAgent(sessionId, { cause: 'user-close' }) + } else if (owned) { + ownedStopped = await stopAgentSessionProviderRoot( + () => deps().adapter.releaseAcquisition?.({ sessionId }) ?? Promise.resolve(false) + ) + } + } catch (error) { + deps().logger.warn('workspace chat stop was not proven', { + scope: 'workspace-chat-stop', + sessionId, + error + }) + } + const ended = sessions.get(sessionId)?.lastEndedChild + const after = deps().store.getRecord(sessionId) + const childStopped = + !!child && + !sessions.get(sessionId)?.child && + ended?.rootGone === true && + sameProviderChild(ended, child) + const durableStopped = + (child || owned) && + after?.lease.runtimeFence === fence && + after.lease.claimStatus === 'released' && + !!after.lease.deathEvidence + return ( + held && + (childStopped || + (owned && ownedStopped) || + (!child && !owned && !!after && structuredSessionNeverStarted(after)) || + durableStopped) ) - } - } catch (error) { - deps().logger.warn('workspace chat stop was not proven', { - scope: 'workspace-chat-stop', - sessionId, - error - }) - } - const ended = sessions.get(sessionId)?.lastEndedChild - const after = deps().store.getRecord(sessionId) - const childStopped = - !!child && - !sessions.get(sessionId)?.child && - ended?.rootGone === true && - sameProviderChild(ended, child) - const durableStopped = - (child || owned) && - after?.lease.runtimeFence === fence && - after.lease.claimStatus === 'released' && - !!after.lease.deathEvidence - const stopped = - childStopped || - (owned && ownedStopped) || - (!child && !owned && !!after && structuredSessionNeverStarted(after)) || - durableStopped - try { - await closeConversation(sessionId) - } catch (error) { - deps().logger.warn('closing a stopped workspace chat handle failed', { - scope: 'workspace-chat-close', - sessionId, - error - }) - } + }, + (error) => + deps().logger.warn('closing a stopped workspace chat handle failed', { + scope: 'workspace-chat-close', + sessionId, + error + }) + ) // Lease release advances the fence; preserve the value after this close for the caller's // final identity check against a later restart. return { fence: deps().store.getRecord(sessionId)?.lease.runtimeFence ?? fence, stopped } @@ -281,16 +303,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') return serialize(sessionId, async () => { readRefusals.forget(sessionId) - const session = sessions.get(sessionId) - if (session) { - // Settled and marked before the stop, so no start delivers it, and nothing it closed with - // sends by itself, even if the handle stays open. - await holdClosedStructuredAgentSessionSends(deps(), sessionId, session.journal, { - mark: 'always' - }) - } - await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) - await closeConversation(sessionId) + await holdStopClose(sessionId, () => stopAgent(sessionId, { cause })) }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index ed578ec8025b..183e27db4f77 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -188,7 +188,7 @@ export function sendPlan( .submissions() .find((entry) => entry.clientMessageId === clientMessageId) if (submission) { - return { clientMessageId, submission } + return { clientMessageId, submission: { ...submission } } } // A pending row wrote nothing, so the send runs for the first time. Succeeded: accepted, // then a new epoch dropped its row. Unknown: only builds before this one wrote that. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts index e0eb83e0a0b5..539b1eb3cd06 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts @@ -19,7 +19,7 @@ export function queuedSendAnswer( ? journal.submission(draft.consumedAs) : undefined return consumed - ? { clientMessageId, submission: consumed } + ? { clientMessageId, submission: { ...consumed } } : { clientMessageId, queued: { messageId: draft.messageId, position: draft.position, state: draft.state } @@ -38,5 +38,5 @@ export function queuedSendAnswer( queued: { messageId: clientMessageId, position: 0, state: 'withdrawn' } } } - return handoff ? { clientMessageId, submission: handoff } : null + return handoff ? { clientMessageId, submission: { ...handoff } } : null } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index b2339d450763..1e9cae15f19a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -265,7 +265,10 @@ describe('a chat that closes', () => { await attach() closeSession.mockResolvedValueOnce(false) - expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [] }) + expect(await host.sleepWorkspace('workspace-1')).toEqual({ + stoppedSessionIds: [], + failures: [{ sessionId: SESSION, reason: 'stop-unverifiable' }] + }) expect(store.getRecord(SESSION)).not.toBeNull() }) @@ -284,7 +287,10 @@ describe('a chat that closes', () => { return result }) - expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [] }) + expect(await host.sleepWorkspace('workspace-1')).toEqual({ + stoppedSessionIds: [], + failures: [{ sessionId: SESSION, reason: 'superseded' }] + }) expect(newerSend).not.toBeNull() expect(await newerSend).toMatchObject({ ok: true }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts index acafdea21aff..1121c4bc6770 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts @@ -1,4 +1,7 @@ -import { runKeyedSerializedOperation } from '../../cli/keyed-promise-queue' +import { + getKeyedSerializedQueueTail, + runKeyedSerializedOperation +} from '../../cli/keyed-promise-queue' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' export class StructuredAgentSessionTaskQueue { @@ -13,15 +16,7 @@ export class StructuredAgentSessionTaskQueue { >() serialize(sessionId: string, task: () => Promise): Promise { - const result = runKeyedSerializedOperation(this.chains, sessionId, task) - const ownTail = this.chains.get(sessionId) - // Settle this queue entry before its caller checks for work admitted afterward. - return ownTail - ? result.then(async (value) => { - await ownTail - return value - }) - : result + return runKeyedSerializedOperation(this.chains, sessionId, task) } trackAttach( @@ -60,6 +55,10 @@ export class StructuredAgentSessionTaskQueue { ) } + waitForCurrent(sessionId: string): Promise { + return getKeyedSerializedQueueTail(this.chains, sessionId) + } + async drainAttaches(): Promise { while (this.attaching.size > 0) { await Promise.allSettled(this.attaching) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 0c0e50bda6da..34ca98f9f5aa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -171,7 +171,7 @@ export async function performSend( if (existing) { return { ok: true, - value: { clientMessageId: input.clientMessageId, submission: existing } + value: { clientMessageId: input.clientMessageId, submission: { ...existing } } } } try { @@ -208,7 +208,7 @@ export async function performSend( ok: true, value: { clientMessageId: input.clientMessageId, - submission: requireSubmission(ctx, input.clientMessageId) + submission: { ...requireSubmission(ctx, input.clientMessageId) } } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts index 3d859f003bbc..940e55128edc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts @@ -1,4 +1,5 @@ import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { AgentSessionWorkspaceSleepResult } from '../../../shared/agent-session-workspace-sleep' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import type { StructuredAgentSessionConversationLifetime } from './structured-agent-session-conversation-lifetime' @@ -18,7 +19,7 @@ type WorkspaceSleepHost = { export async function sleepStructuredAgentSessionWorkspace( host: WorkspaceSleepHost, workspaceId: string -): Promise<{ stoppedSessionIds: string[] }> { +): Promise { let records: ReturnType try { records = host.deps.store.listRecords() @@ -28,7 +29,7 @@ export async function sleepStructuredAgentSessionWorkspace( workspaceId, error }) - return { stoppedSessionIds: [] } + return { stoppedSessionIds: [], failures: [{ reason: 'inventory-unavailable' }] } } const belongs = (location: AgentSessionExecutionLocation): boolean => location.workspaceId === workspaceId && location.executionHostId === LOCAL_EXECUTION_HOST_ID @@ -36,25 +37,39 @@ export async function sleepStructuredAgentSessionWorkspace( ...records.filter((record) => belongs(record.location)).map((record) => record.sessionId), ...host.tasks.pendingMembers(workspaceId, LOCAL_EXECUTION_HOST_ID) ]) - const outcomes = await Promise.allSettled( - [...candidates].map((sessionId) => - host.lifetime - .closeForWorkspaceSleep(sessionId, workspaceId, LOCAL_EXECUTION_HOST_ID) - .then((result) => ({ sessionId, result })) - ) + const admitted = [...candidates].map((sessionId) => { + let close: ReturnType + try { + close = host.lifetime.closeForWorkspaceSleep(sessionId, workspaceId, LOCAL_EXECUTION_HOST_ID) + } catch (error) { + close = Promise.reject(error) + } + return { sessionId, close, tail: host.tasks.waitForCurrent(sessionId) } + }) + const outcomes = await Promise.all( + admitted.map(async ({ sessionId, close, tail }) => { + const [stop] = await Promise.allSettled([close, tail]) + return { sessionId, stop } + }) ) - const stoppedSessionIds = outcomes.flatMap((outcome) => { - if (outcome.status !== 'fulfilled' || !outcome.value.result?.stopped) { - return [] + const stoppedSessionIds: string[] = [] + const failures: NonNullable = [] + for (const { sessionId, stop } of outcomes) { + if (stop.status === 'rejected' || !stop.value?.stopped) { + failures.push({ + sessionId, + reason: stop.status === 'fulfilled' && !stop.value ? 'superseded' : 'stop-unverifiable' + }) + continue } - const { sessionId, result } = outcome.value try { const record = host.deps.store.getRecord(sessionId) const session = host.sessions.get(sessionId) + if (!record || !belongs(record.location) || record.lease.runtimeFence !== stop.value.fence) { + failures.push({ sessionId, reason: 'superseded' }) + continue + } if ( - !record || - !belongs(record.location) || - record.lease.runtimeFence !== result.fence || session?.child || host.tasks.hasPending(sessionId) || host.deliveryRunning(sessionId) || @@ -62,17 +77,18 @@ export async function sleepStructuredAgentSessionWorkspace( session?.journal.pendingSubmissions().length || host.deps.hasOpenDispatch?.(record) ) { - return [] + failures.push({ sessionId, reason: 'superseded' }) + continue } - return [sessionId] + stoppedSessionIds.push(sessionId) } catch (error) { host.deps.logger.warn('workspace chat stop receipt could not be checked', { scope: 'workspace-chat-receipt', sessionId, error }) - return [] + failures.push({ sessionId, reason: 'stop-unverifiable' }) } - }) - return { stoppedSessionIds } + } + return failures.length > 0 ? { stoppedSessionIds, failures } : { stoppedSessionIds } } diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index b615a74a2e17..c7c1ace1f215 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -164,27 +164,30 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ return appeared.sleepWorkspace(params.workspaceId) } console.warn('[agent-session] workspace sleep record inventory unavailable', error) - return { stoppedSessionIds: [] } + return { stoppedSessionIds: [], failures: [{ reason: 'inventory-unavailable' }] } } const appeared = getStructuredAgentSessionHost() if (appeared) { return appeared.sleepWorkspace(params.workspaceId) } try { - return { - stoppedSessionIds: store - .listRecords() - .filter( - (record) => - record.location.workspaceId === params.workspaceId && - record.location.executionHostId === LOCAL_EXECUTION_HOST_ID && - structuredSessionNeverStarted(record) - ) - .map((record) => record.sessionId) - } + const records = store + .listRecords() + .filter( + (record) => + record.location.workspaceId === params.workspaceId && + record.location.executionHostId === LOCAL_EXECUTION_HOST_ID + ) + const stoppedSessionIds = records + .filter(structuredSessionNeverStarted) + .map((record) => record.sessionId) + const failures = records + .filter((record) => !structuredSessionNeverStarted(record)) + .map((record) => ({ sessionId: record.sessionId, reason: 'stop-unverifiable' as const })) + return failures.length > 0 ? { stoppedSessionIds, failures } : { stoppedSessionIds } } catch (error) { console.warn('[agent-session] workspace sleep record inventory failed', error) - return { stoppedSessionIds: [] } + return { stoppedSessionIds: [], failures: [{ reason: 'inventory-unavailable' }] } } } }), diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.copy-worktree-name.test.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.copy-worktree-name.test.tsx index fbca904c4dc5..220585d567f7 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.copy-worktree-name.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.copy-worktree-name.test.tsx @@ -12,6 +12,7 @@ import WorktreeContextMenu from './WorktreeContextMenu' globalThis.IS_REACT_ACT_ENVIRONMENT = true const state = { + unifiedTabsByWorktree: {}, updateWorktreeMeta: vi.fn(), setWorktreesPinnedAndReveal: vi.fn(), workspaceStatuses: [], diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.delete-shortcut.test.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.delete-shortcut.test.tsx index 8bdac0ca1437..80b11f02c9b8 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.delete-shortcut.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.delete-shortcut.test.tsx @@ -84,6 +84,7 @@ vi.mock('@/i18n/i18n', () => ({ })) const defaultStoreState = { + unifiedTabsByWorktree: {}, updateWorktreeMeta: vi.fn(), setWorktreesPinnedAndReveal: vi.fn(), workspaceStatuses: [], diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.react185-bystander.test.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.react185-bystander.test.tsx index da48eb26ae0b..141304c66c13 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.react185-bystander.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.react185-bystander.test.tsx @@ -22,6 +22,7 @@ import WorktreeContextMenu from './WorktreeContextMenu' globalThis.IS_REACT_ACT_ENVIRONMENT = true const state = { + unifiedTabsByWorktree: {}, updateWorktreeMeta: vi.fn(), setWorktreesPinnedAndReveal: vi.fn(), workspaceStatuses: [ diff --git a/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx b/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx index 4eed6feea33e..55bee52aa0b5 100644 --- a/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx @@ -12,6 +12,7 @@ import type { Worktree } from '../../../../shared/worktree/types' vi.mock('@/store', () => { const state = { + unifiedTabsByWorktree: {}, tabsByWorktree: {}, ptyIdsByTabId: {}, browserTabsByWorktree: {}, diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index 48056ffcfbf0..6ead85ecd11d 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -122,6 +122,18 @@ describe('workspace sleep flow', () => { expect(mocks.toast).toHaveBeenCalledTimes(1) }) + it('keeps proved chat stops but leaves the workspace incomplete when another stop is unverified', async () => { + mocks.call.mockResolvedValueOnce({ + stoppedSessionIds: ['session-1'], + failures: [{ sessionId: 'session-2', reason: 'stop-unverifiable' }] + }) + + const result = await runSleepWorktree('wt-1', 'local') + expect(result[0]).toMatchObject({ completed: false, stoppedSessionIds: ['session-1'] }) + expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenCalled() + expect(mocks.toast).toHaveBeenCalledTimes(1) + }) + it('does not run delayed panels or steal a newer selection after an explicit wake', async () => { mocks.state.activeWorktreeId = 'wt-1' mocks.state.activeWorkspaceExecutionHostId = 'local' diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index c1a23b7341f7..9b92ceaf7056 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -1,4 +1,5 @@ import { toast } from 'sonner' +import type { AgentSessionWorkspaceSleepResult } from '../../../../shared/agent-session-workspace-sleep' import { useAppStore } from '@/store' import { clearWorktreeSleepIntent, @@ -174,10 +175,11 @@ export async function runSleepWorktrees( ...target, token: markWorktreeSleepIntent(target.workspaceId, target.executionHostId) })) + const noNativeSessions: AgentSessionWorkspaceSleepResult = { stoppedSessionIds: [] } const nativeCalls = marked.map(({ workspaceId, executionHostId }) => { const nativeTarget = structuredAgentSessionTargetForHost(executionHostId) return nativeTarget - ? callStructuredAgentSession<{ stoppedSessionIds: string[] }>( + ? callStructuredAgentSession( nativeTarget, 'agentSession.sleepWorkspace', { workspaceId } @@ -185,7 +187,7 @@ export async function runSleepWorktrees( (value) => ({ ok: true as const, value }), (error: unknown) => ({ ok: false as const, error }) ) - : Promise.resolve({ ok: true as const, value: { stoppedSessionIds: [] } }) + : Promise.resolve({ ok: true as const, value: noNativeSessions }) }) const activeRoute = state.activeWorktreeId ? resolveWorktreeOperationRouteResult(state, state.activeWorktreeId) @@ -214,6 +216,14 @@ export async function runSleepWorktrees( const native = await nativeCalls[index] if (native.ok) { stoppedSessionIds = native.value.stoppedSessionIds + if (native.value.failures?.length) { + console.error('[sleep-worktree] native chat stop incomplete', { + workspaceId, + failures: native.value.failures + }) + nativeCompleted = false + errors.push(describeChatStopFailure()) + } } else { console.error('[sleep-worktree] native chat stop failed', { workspaceId, diff --git a/src/shared/agent-session-workspace-sleep.ts b/src/shared/agent-session-workspace-sleep.ts new file mode 100644 index 000000000000..e3692612dc05 --- /dev/null +++ b/src/shared/agent-session-workspace-sleep.ts @@ -0,0 +1,7 @@ +export type AgentSessionWorkspaceSleepResult = { + stoppedSessionIds: string[] + failures?: { + sessionId?: string + reason: 'inventory-unavailable' | 'stop-unverifiable' | 'superseded' + }[] +} From d6adfbb827e177e8ffa5fe2f4064feea720d554a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:07:51 -0700 Subject: [PATCH 10/30] fix(native-chat): keep superseded workspace sleeps quiet --- ...red-agent-session-conversation-lifetime.ts | 25 +++++++++---------- ...red-agent-session-surface-lifetime.test.ts | 5 +--- ...tructured-agent-session-workspace-sleep.ts | 23 +++++++++-------- .../sidebar/sleep-worktree-flow.test.ts | 9 +++++++ src/shared/agent-session-workspace-sleep.ts | 2 +- 5 files changed, 35 insertions(+), 29 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index dd5d60aec63c..0e36d7a1dd74 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -89,16 +89,16 @@ export function createStructuredAgentSessionConversationLifetime(host: { const holdStopClose = async ( sessionId: string, - stop: (held: boolean) => Promise, + stop: () => Promise, onCloseError?: (error: unknown) => void ): Promise => { const session = sessions.get(sessionId) - const held = session - ? await holdClosedStructuredAgentSessionSends(deps(), sessionId, session.journal, { - mark: 'always' - }) - : true - const result = await stop(held) + if (session) { + await holdClosedStructuredAgentSessionSends(deps(), sessionId, session.journal, { + mark: 'always' + }) + } + const result = await stop() try { await closeConversation(sessionId) } catch (error) { @@ -138,7 +138,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { const owned = deps().adapter.hasOwnedAcquisition?.(sessionId) === true const stopped = await holdStopClose( sessionId, - async (held) => { + async () => { let ownedStopped = false try { if (child) { @@ -168,11 +168,10 @@ export function createStructuredAgentSessionConversationLifetime(host: { after.lease.claimStatus === 'released' && !!after.lease.deathEvidence return ( - held && - (childStopped || - (owned && ownedStopped) || - (!child && !owned && !!after && structuredSessionNeverStarted(after)) || - durableStopped) + childStopped || + (owned && ownedStopped) || + (!child && !owned && !!after && structuredSessionNeverStarted(after)) || + durableStopped ) }, (error) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 1e9cae15f19a..7878caba1e53 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -287,10 +287,7 @@ describe('a chat that closes', () => { return result }) - expect(await host.sleepWorkspace('workspace-1')).toEqual({ - stoppedSessionIds: [], - failures: [{ sessionId: SESSION, reason: 'superseded' }] - }) + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [] }) expect(newerSend).not.toBeNull() expect(await newerSend).toMatchObject({ ok: true }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts index 940e55128edc..684e47d99dae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts @@ -38,13 +38,16 @@ export async function sleepStructuredAgentSessionWorkspace( ...host.tasks.pendingMembers(workspaceId, LOCAL_EXECUTION_HOST_ID) ]) const admitted = [...candidates].map((sessionId) => { - let close: ReturnType try { - close = host.lifetime.closeForWorkspaceSleep(sessionId, workspaceId, LOCAL_EXECUTION_HOST_ID) + const close = host.lifetime.closeForWorkspaceSleep( + sessionId, + workspaceId, + LOCAL_EXECUTION_HOST_ID + ) + return { sessionId, close, tail: host.tasks.waitForCurrent(sessionId) } } catch (error) { - close = Promise.reject(error) + return { sessionId, close: Promise.reject(error), tail: Promise.resolve() } } - return { sessionId, close, tail: host.tasks.waitForCurrent(sessionId) } }) const outcomes = await Promise.all( admitted.map(async ({ sessionId, close, tail }) => { @@ -55,18 +58,17 @@ export async function sleepStructuredAgentSessionWorkspace( const stoppedSessionIds: string[] = [] const failures: NonNullable = [] for (const { sessionId, stop } of outcomes) { - if (stop.status === 'rejected' || !stop.value?.stopped) { - failures.push({ - sessionId, - reason: stop.status === 'fulfilled' && !stop.value ? 'superseded' : 'stop-unverifiable' - }) + if (stop.status === 'rejected' || (stop.value && !stop.value.stopped)) { + failures.push({ sessionId, reason: 'stop-unverifiable' }) + continue + } + if (!stop.value) { continue } try { const record = host.deps.store.getRecord(sessionId) const session = host.sessions.get(sessionId) if (!record || !belongs(record.location) || record.lease.runtimeFence !== stop.value.fence) { - failures.push({ sessionId, reason: 'superseded' }) continue } if ( @@ -77,7 +79,6 @@ export async function sleepStructuredAgentSessionWorkspace( session?.journal.pendingSubmissions().length || host.deps.hasOpenDispatch?.(record) ) { - failures.push({ sessionId, reason: 'superseded' }) continue } stoppedSessionIds.push(sessionId) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index 6ead85ecd11d..83421bbf1440 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -134,6 +134,15 @@ describe('workspace sleep flow', () => { expect(mocks.toast).toHaveBeenCalledTimes(1) }) + it('keeps a superseded chat quiet while completing other workspace cleanup', async () => { + mocks.call.mockResolvedValueOnce({ stoppedSessionIds: [] }) + + const result = await runSleepWorktree('wt-1', 'local') + expect(result[0]).toMatchObject({ completed: true, stoppedSessionIds: [] }) + expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenCalled() + expect(mocks.toast).not.toHaveBeenCalled() + }) + it('does not run delayed panels or steal a newer selection after an explicit wake', async () => { mocks.state.activeWorktreeId = 'wt-1' mocks.state.activeWorkspaceExecutionHostId = 'local' diff --git a/src/shared/agent-session-workspace-sleep.ts b/src/shared/agent-session-workspace-sleep.ts index e3692612dc05..ac275d90c069 100644 --- a/src/shared/agent-session-workspace-sleep.ts +++ b/src/shared/agent-session-workspace-sleep.ts @@ -2,6 +2,6 @@ export type AgentSessionWorkspaceSleepResult = { stoppedSessionIds: string[] failures?: { sessionId?: string - reason: 'inventory-unavailable' | 'stop-unverifiable' | 'superseded' + reason: 'inventory-unavailable' | 'stop-unverifiable' }[] } From 6ce1bfd622ff682399e7929414fef00d7e4adecf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:34:18 -0700 Subject: [PATCH 11/30] fix(native-chat): ignore superseded failed sleep stops --- ...t-session-workspace-sleep-lifetime.test.ts | 56 +++++++++++++++++++ ...tructured-agent-session-workspace-sleep.ts | 8 ++- 2 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts new file mode 100644 index 000000000000..fd088fc3e1e6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts @@ -0,0 +1,56 @@ +import { expect, it, vi } from 'vitest' +import { hostTestMessage } from './structured-agent-session-host-test-data' +import { + attach, + CALLER, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' + +it('omits an unverified stop superseded by a newer send while retaining a sibling receipt', async () => { + const { host, store, acquire } = hostTestState() + const record = await attach() + if (!record) { + throw new Error('attach did not produce a record') + } + const sibling = 'session-beta' + const siblingRecord = { ...record, sessionId: sibling } + const getRecord = store.getRecord.bind(store) + vi.spyOn(store, 'listRecords').mockReturnValue([record, siblingRecord]) + vi.spyOn(store, 'getRecord').mockImplementation((id) => + id === sibling ? siblingRecord : getRecord(id) + ) + host.deps.adapter.closeSession = vi.fn().mockResolvedValueOnce(false).mockResolvedValue(true) + + let failedStopDone = () => undefined + const failedStop = new Promise((resolve) => { + failedStopDone = resolve + }) + let releaseSibling = () => undefined + const slowSibling = new Promise((resolve) => { + releaseSibling = resolve + }) + const close = host['lifetime'].closeForWorkspaceSleep + vi.spyOn(host['lifetime'], 'closeForWorkspaceSleep').mockImplementation(async (...args) => { + if (args[0] === sibling) { + await slowSibling + return { fence: siblingRecord.lease.runtimeFence, stopped: true } + } + const result = await close(...args) + failedStopDone() + return result + }) + + const sleeping = host.sleepWorkspace('workspace-1') + try { + await failedStop + const body = hostTestMessage('resume after unverified stop') + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(acquire).toHaveBeenCalledTimes(2)) + } finally { + releaseSibling() + } + expect(await sleeping).toEqual({ stoppedSessionIds: [sibling] }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts index 684e47d99dae..8ee12a54d0f6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts @@ -58,7 +58,7 @@ export async function sleepStructuredAgentSessionWorkspace( const stoppedSessionIds: string[] = [] const failures: NonNullable = [] for (const { sessionId, stop } of outcomes) { - if (stop.status === 'rejected' || (stop.value && !stop.value.stopped)) { + if (stop.status === 'rejected') { failures.push({ sessionId, reason: 'stop-unverifiable' }) continue } @@ -67,10 +67,14 @@ export async function sleepStructuredAgentSessionWorkspace( } try { const record = host.deps.store.getRecord(sessionId) - const session = host.sessions.get(sessionId) if (!record || !belongs(record.location) || record.lease.runtimeFence !== stop.value.fence) { continue } + if (!stop.value.stopped) { + failures.push({ sessionId, reason: 'stop-unverifiable' }) + continue + } + const session = host.sessions.get(sessionId) if ( session?.child || host.tasks.hasPending(sessionId) || From 0636db26b2efec57876cc7d4cca58446f63106f2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:12:18 -0700 Subject: [PATCH 12/30] Fix sleep lifetime test resolver types --- .../structured-agent-session-workspace-sleep-lifetime.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts index fd088fc3e1e6..2cac132a3367 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts @@ -22,11 +22,11 @@ it('omits an unverified stop superseded by a newer send while retaining a siblin ) host.deps.adapter.closeSession = vi.fn().mockResolvedValueOnce(false).mockResolvedValue(true) - let failedStopDone = () => undefined + let failedStopDone: () => void = () => undefined const failedStop = new Promise((resolve) => { failedStopDone = resolve }) - let releaseSibling = () => undefined + let releaseSibling: () => void = () => undefined const slowSibling = new Promise((resolve) => { releaseSibling = resolve }) From 099f406856a4dfb549498b81413c3d418fcdc84e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:06:38 -0700 Subject: [PATCH 13/30] Accept workspace Sleep for already ended chats --- ...red-agent-session-conversation-lifetime.ts | 26 +++++++++++-------- ...red-agent-session-surface-lifetime.test.ts | 14 +++++++++- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 0e36d7a1dd74..7d0d7f248e76 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -27,6 +27,10 @@ import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation- import { deferredStructuredAgentSessionLogger } from './structured-agent-session-logger' import { sameProviderChild } from './structured-agent-session-provider-child' import type { ExecutionHostId } from '../../../shared/execution-host' +import { + agentSessionLeaseIsReleased, + agentSessionLeaseOwnerVerdict +} from '../../../shared/agent-session-lease-adjudication' import { structuredSessionNeverStarted } from '../../runtime/agent-session-record-founding' import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' import type { StructuredAgentSessionExpiredStartup } from './structured-agent-session-startup-attempt' @@ -162,17 +166,17 @@ export function createStructuredAgentSessionConversationLifetime(host: { !sessions.get(sessionId)?.child && ended?.rootGone === true && sameProviderChild(ended, child) - const durableStopped = - (child || owned) && - after?.lease.runtimeFence === fence && - after.lease.claimStatus === 'released' && - !!after.lease.deathEvidence - return ( - childStopped || - (owned && ownedStopped) || - (!child && !owned && !!after && structuredSessionNeverStarted(after)) || - durableStopped - ) + // A released reservation may still have an unproven root after restart; only + // exit evidence or a never-started record proves an unheld chat is stopped. + const alreadyStopped = + !!after && + after.lease.runtimeFence === fence && + agentSessionLeaseIsReleased(after.lease) && + (agentSessionLeaseOwnerVerdict(after.lease) === 'exited' || + structuredSessionNeverStarted(after)) && + !sessions.get(sessionId)?.child && + deps().adapter.hasOwnedAcquisition?.(sessionId) !== true + return childStopped || ownedStopped || alreadyStopped }, (error) => deps().logger.warn('closing a stopped workspace chat handle failed', { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 7878caba1e53..163bc2c34187 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -409,7 +409,7 @@ describe('a chat that closes', () => { }) describe('a session with a turn in flight', () => { - it('is not stopped while the turn runs, and is once it ends and idles', async () => { + it('keeps a running turn and sleeps only proven ended chats after idle eviction or restart', async () => { await attach() emitTurnLifecycle('running', 1) await host.flushStreamedEvents(SESSION) @@ -425,6 +425,18 @@ describe('a session with a turn in flight', () => { clock += IDLE_MS await waitForEviction() + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) + await abandonStructuredAgentSessionHost(host) + await reboot() + await host.restoreReadableSessions() + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) + const getRecord = store.getRecord.bind(store) + vi.spyOn(store, 'getRecord').mockImplementation((id) => { + const record = getRecord(id) + return record && { ...record, lease: { ...record.lease, deathEvidence: null } } + }) + const receipt = await host.sleepWorkspace('workspace-1') + expect(receipt).toMatchObject({ failures: [{ reason: 'stop-unverifiable' }] }) }) // Codex settles an admitted send only on its echo, which may never come; the stop retires it. From 9a6e4cfe545d26f4a52c423665ac93eb2e3d39d6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:31:48 -0700 Subject: [PATCH 14/30] Sleep native chats through the shared no-toast sleep core Main split runSleepWorktrees into sleepWorktreesReportingFailures (no toast) and a toasting wrapper. Move the native chat stop, host routing and typed failures into the shared core so the WSL move's Sleep request also stops chats, and report every workspace not verifiably slept (an unconfirmed chat stop included) in failedWorktreeIds. Fold one ACP adapter field onto a line so main's file plus this PR's ownership check stays within the file length limit. --- .../acp/acp-structured-session-adapter.ts | 4 +- .../sidebar/sleep-worktree-flow.test.ts | 24 +++++++- .../components/sidebar/sleep-worktree-flow.ts | 56 +++++++++++++------ 3 files changed, 63 insertions(+), 21 deletions(-) diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 8505932e2723..382e905a6654 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -244,9 +244,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte input ) => stopAcpChildren(this.live(input.sessionId), input.fence, input.taskIds, input.acknowledge) - backgroundTaskStops: NonNullable = ( - sessionId - ) => acpChildStopCapabilities(this.sessions.get(sessionId)) + backgroundTaskStops = (id: string) => acpChildStopCapabilities(this.sessions.get(id)) closeSession = (sessionId: string): Promise => this.close(sessionId) disposeSession = (sessionId: string): Promise => this.close(sessionId) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index 83421bbf1440..b2b6b1466c16 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -49,7 +49,11 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: mocks.call })) -import { runSleepWorktree, runSleepWorktrees } from './sleep-worktree-flow' +import { + runSleepWorktree, + runSleepWorktrees, + sleepWorktreesReportingFailures +} from './sleep-worktree-flow' import { clearWorktreeSleepIntent, forgetWorktreeSleepIntent, @@ -134,6 +138,24 @@ describe('workspace sleep flow', () => { expect(mocks.toast).toHaveBeenCalledTimes(1) }) + it('reports an unverified chat stop to a caller that answers for it, without a toast', async () => { + mocks.call + .mockResolvedValueOnce({ + stoppedSessionIds: [], + failures: [{ sessionId: 'session-1', reason: 'stop-unverifiable' }] + }) + .mockResolvedValueOnce({ stoppedSessionIds: ['session-2'] }) + + const outcome = await sleepWorktreesReportingFailures([ + { id: 'wt-1', executionHostId: 'local' }, + { id: 'wt-2', executionHostId: 'local' } + ]) + + expect(outcome.failedWorktreeIds).toEqual(['wt-1']) + expect(outcome.errors).toHaveLength(1) + expect(mocks.toast).not.toHaveBeenCalled() + }) + it('keeps a superseded chat quiet while completing other workspace cleanup', async () => { mocks.call.mockResolvedValueOnce({ stoppedSessionIds: [] }) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index 9b92ceaf7056..79bb893cf74c 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -152,15 +152,47 @@ function resolveSleepTarget( export async function runSleepWorktrees( targets: readonly WorkspaceSleepTarget[] ): Promise { + const { errors, results } = await sleepWorktreesReportingFailures(targets) + if (errors.length > 0) { + // Why: callers are fire-and-forget; surface actionable teardown failures after restoring retryable UI state. + toast.error( + results.length === 1 + ? translate( + 'auto.components.sidebar.sleep.worktree.flow.8bc3fc0671', + 'Failed to sleep workspace' + ) + : translate( + 'auto.components.sidebar.sleep.worktree.flow.c460fecc4a', + 'Failed to sleep some workspaces' + ), + { description: errors.join('\n') } + ) + } + return results +} + +export type WorktreeSleepOutcome = { + /** Every workspace this call did not verifiably sleep, including an unconfirmed chat stop. */ + failedWorktreeIds: string[] + errors: string[] + results: WorkspaceSleepResult[] +} + +/** The sleep without the toast, for callers that report failures themselves. */ +export async function sleepWorktreesReportingFailures( + targets: readonly WorkspaceSleepTarget[] +): Promise { if (targets.length === 0) { - return [] + return { failedWorktreeIds: [], errors: [], results: [] } } const state = useAppStore.getState() const errors: string[] = [] + const failedWorktreeIds = new Set() const seen = new Set() const resolved = targets.flatMap((target) => { const selected = resolveSleepTarget(state, target) if (!selected) { + failedWorktreeIds.add(typeof target === 'string' ? target : target.id) errors.push(describeSleepFailure(new Error('workspace owner is unavailable'))) return [] } @@ -204,7 +236,7 @@ export async function runSleepWorktrees( state.setActiveWorktree(null) restoreSidebarPosition() } - const results = await Promise.all( + const attempts = await Promise.all( marked.map(async (target, index): Promise => { const { workspaceId, executionHostId, token } = target const current = (): boolean => @@ -278,24 +310,14 @@ export async function runSleepWorktrees( return { workspaceId, executionHostId, stoppedSessionIds, completed } }) ) - if (errors.length > 0) { - toast.error( - resolved.length === 1 - ? translate( - 'auto.components.sidebar.sleep.worktree.flow.8bc3fc0671', - 'Failed to sleep workspace' - ) - : translate( - 'auto.components.sidebar.sleep.worktree.flow.c460fecc4a', - 'Failed to sleep some workspaces' - ), - { description: [...new Set(errors)].join('\n') } - ) - } - return results.map((result, index) => ({ + const results = attempts.map((result, index) => ({ ...result, completed: result.completed && isCurrentWorktreeSleepIntent(result.workspaceId, result.executionHostId, marked[index].token) })) + for (const result of results.filter((candidate) => !candidate.completed)) { + failedWorktreeIds.add(result.workspaceId) + } + return { failedWorktreeIds: [...failedWorktreeIds], errors: [...new Set(errors)], results } } From 481becbc756b10460439c5dc3fed4e1d7d6d9abc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:36:04 -0700 Subject: [PATCH 15/30] Import isStructuredTab from its shared module --- .../src/components/sidebar/workspace-lineage-menu-actions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts index 9ede020c799c..5a85793ea6ca 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts @@ -5,7 +5,7 @@ import type { Worktree } from '../../../../shared/worktree/types' import type { Tab } from '../../../../shared/tab-types' import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner-state' import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' -import { isStructuredTab } from '@/components/native-chat/structured-agent-session-tabs' +import { isStructuredTab } from '../../../../shared/structured-agent-session-tab' import type { WorkspaceSleepTarget } from './sleep-worktree-flow' import { LOCAL_EXECUTION_HOST_ID, From 93d3ca07437c16fbecba79c6c151be3b109ddbba Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:35:13 -0700 Subject: [PATCH 16/30] fix(native-chat): leave chat rows to their host through Sleep Sleep's worktree status drop deleted the chat's row, which the host feed then rewrote, so the outcome depended on which write landed last. Open chat tabs own their rows (same rule as terminal-tab close), and a chat only makes a workspace sleepable while its host holds the agent's process. --- ...tructuredAgentSessionStatusBridge.test.tsx | 33 +++++++++++++++++++ .../use-worktree-context-menu-model.tsx | 7 ++++ .../workspace-lineage-menu-actions.test.ts | 15 ++++++++- .../sidebar/workspace-lineage-menu-actions.ts | 13 +++++++- .../sidebar/worktree-context-menu-policy.ts | 1 + .../agent-status-pane-key-tab-binding.ts | 25 ++++++++++---- .../agent-status-worktree-drop-actions.ts | 17 ++++++---- 7 files changed, 96 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index beeb27600cbb..53a795500ecf 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -7,6 +7,7 @@ import type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire' import { buildSubagentChildRows } from '../sidebar/worktree-subagent-child-rows' +import { hasSleepableWorkspaceActivity } from '../sidebar/workspace-lineage-menu-actions' import { resolveAttention } from '../sidebar/smart-attention' import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' @@ -70,6 +71,7 @@ import { useStructuredAgentSessionHostStopping } from './StructuredAgentSessionStatusBridge' import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' +import { useAppStore } from '@/store' const structuredTab = { id: 'structured-tab-1', @@ -651,6 +653,37 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(mocks.setAgentStatus).toHaveBeenCalledTimes(calls) }) + it('keeps the host-fed chat row through Sleep and stops offering Sleep once the host lets go', async () => { + const state = (): AppState => useAppStore.getState() + const sleepable = (): boolean => + hasSleepableWorkspaceActivity( + { id: 'wt-1', hostId: 'local' }, + { ...state(), chatOwnerState: {} } + ) + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + act(() => feed().emit({ type: 'status', session: summary({ status: 'idle', updatedAt: 2 }) })) + const paneKey = statuses()[0].paneKey + mocks.store?.setState({ acknowledgedAgentsByPaneKey: { [paneKey]: 3 } }) + expect(sleepable()).toBe(true) + + // Sleep's terminal teardown drops the worktree's rows before the host's revoke reaches the feed. + act(() => state().dropAgentStatusByWorktree('wt-1', { shutdownReason: 'manual-sleep' })) + expect(state().agentStatusByPaneKey[paneKey]).toEqual( + expect.objectContaining({ state: 'done', structuredHostOwned: true }) + ) + act(() => + feed().emit({ + type: 'status', + session: summary({ status: 'idle', updatedAt: 2, hostExecutionOwned: undefined }) + }) + ) + expect(statuses()).toEqual([expect.objectContaining({ state: 'done' })]) + expect(statuses()[0].structuredHostOwned).toBeUndefined() + expect(state().acknowledgedAgentsByPaneKey).toEqual({ [paneKey]: 3 }) + expect(sleepable()).toBe(false) + }) + it('drops the status and the feed when the last structured tab closes', async () => { render() await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) diff --git a/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx b/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx index a3d17fc813da..3b20734deade 100644 --- a/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx @@ -15,6 +15,7 @@ import { import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match' import { + EMPTY_AGENT_STATUS_BY_PANE_KEY, EMPTY_BROWSER_TABS_BY_WORKTREE, EMPTY_CYCLIC_LINEAGE_IDS, EMPTY_DELETE_STATE_BY_WORKTREE_ID, @@ -125,6 +126,9 @@ export function useWorktreeContextMenuModel({ const unifiedTabsByWorktree = useAppStore((s) => selectMenuScopedMap(menuOpen, s.unifiedTabsByWorktree, EMPTY_UNIFIED_TABS_BY_WORKTREE) ) + const agentStatusByPaneKey = useAppStore((s) => + selectMenuScopedMap(menuOpen, s.agentStatusByPaneKey, EMPTY_AGENT_STATUS_BY_PANE_KEY) + ) const deleteStateByWorktreeId = useAppStore((s) => selectMenuScopedMap(menuOpen, s.deleteStateByWorktreeId, EMPTY_DELETE_STATE_BY_WORKTREE_ID) ) @@ -143,11 +147,13 @@ export function useWorktreeContextMenuModel({ ptyIdsByTabId, browserTabsByWorktree, unifiedTabsByWorktree, + agentStatusByPaneKey, chatOwnerState: useAppStore.getState() }) ), [ activeContextWorktrees, + agentStatusByPaneKey, browserTabsByWorktree, ptyIdsByTabId, tabsByWorktree, @@ -164,6 +170,7 @@ export function useWorktreeContextMenuModel({ ptyIdsByTabId, browserTabsByWorktree, unifiedTabsByWorktree, + agentStatusByPaneKey, chatOwnerState: useAppStore.getState() } }) diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts index 5d4d5f27bb61..a46a8953159c 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { Tab } from '../../../../shared/tab-types' import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' import type { Worktree } from '../../../../shared/worktree/types' +import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import { getWorkspaceLineageMenuActions, hasSleepableWorkspaceActivity @@ -69,6 +70,7 @@ describe('workspace lineage menu actions', () => { [grandchild.id]: [{ id: 'grandchild-browser' }] }, unifiedTabsByWorktree: {}, + agentStatusByPaneKey: {}, chatOwnerState: {} } }) @@ -96,6 +98,7 @@ describe('workspace lineage menu actions', () => { ptyIdsByTabId: { 'child-tab': ['child-pty'] }, browserTabsByWorktree: {}, unifiedTabsByWorktree: {}, + agentStatusByPaneKey: {}, chatOwnerState: {} } }) @@ -105,6 +108,10 @@ describe('workspace lineage menu actions', () => { }) }) +function hostHeld(tab: Tab): Record { + return { [structuredAgentSessionPaneKey(tab.id, tab.entityId)]: { structuredHostOwned: true } } +} + describe('hasSleepableWorkspaceActivity', () => { it('treats preserved empty PTY arrays as slept, not live', () => { expect( @@ -113,6 +120,7 @@ describe('hasSleepableWorkspaceActivity', () => { ptyIdsByTabId: { 'tab-1': [] }, browserTabsByWorktree: {}, unifiedTabsByWorktree: {}, + agentStatusByPaneKey: {}, chatOwnerState: {} }) ).toBe(false) @@ -125,6 +133,7 @@ describe('hasSleepableWorkspaceActivity', () => { ptyIdsByTabId: { 'tab-1': ['pty-1'] }, browserTabsByWorktree: {}, unifiedTabsByWorktree: {}, + agentStatusByPaneKey: {}, chatOwnerState: {} }) ).toBe(true) @@ -134,12 +143,13 @@ describe('hasSleepableWorkspaceActivity', () => { ptyIdsByTabId: {}, browserTabsByWorktree: { 'wt-1': [{ id: 'browser-1' }] }, unifiedTabsByWorktree: {}, + agentStatusByPaneKey: {}, chatOwnerState: {} }) ).toBe(true) }) - it('offers Sleep for a chat-only workspace', () => { + it('offers Sleep for a chat-only workspace while its host holds the chat', () => { const chat: Tab = { id: 'chat-1', entityId: 'session-1', @@ -159,6 +169,7 @@ describe('hasSleepableWorkspaceActivity', () => { ptyIdsByTabId: {}, browserTabsByWorktree: {}, unifiedTabsByWorktree: { 'wt-1': [chat] }, + agentStatusByPaneKey: hostHeld(chat), chatOwnerState: { worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', hostId: 'local' }] @@ -174,6 +185,7 @@ describe('hasSleepableWorkspaceActivity', () => { ptyIdsByTabId: {}, browserTabsByWorktree: {}, unifiedTabsByWorktree: { 'wt-1': [{ ...chat, executionHostId: 'runtime:remote' }] }, + agentStatusByPaneKey: hostHeld(chat), chatOwnerState: {} } ) @@ -203,6 +215,7 @@ describe('hasSleepableWorkspaceActivity', () => { ptyIdsByTabId: {}, browserTabsByWorktree: {}, unifiedTabsByWorktree: { 'folder:folder-1': [chat] }, + agentStatusByPaneKey: hostHeld(chat), chatOwnerState: { folderWorkspaces: [ { diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts index 5a85793ea6ca..445e05d6755a 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts @@ -6,6 +6,7 @@ import type { Tab } from '../../../../shared/tab-types' import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner-state' import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' import { isStructuredTab } from '../../../../shared/structured-agent-session-tab' +import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import type { WorkspaceSleepTarget } from './sleep-worktree-flow' import { LOCAL_EXECUTION_HOST_ID, @@ -25,6 +26,7 @@ type WorkspaceActivityMaps = { ptyIdsByTabId: Record browserTabsByWorktree: Record unifiedTabsByWorktree: Record + agentStatusByPaneKey: Record chatOwnerState: WorktreeRuntimeOwnerState } @@ -41,6 +43,7 @@ export function hasSleepableWorkspaceActivity( ptyIdsByTabId, browserTabsByWorktree, unifiedTabsByWorktree, + agentStatusByPaneKey, chatOwnerState }: WorkspaceActivityMaps ): boolean { @@ -93,9 +96,14 @@ export function hasSleepableWorkspaceActivity( return ( tabs.some((tab) => tabHasLivePty(ptyIdsByTabId, tab.id)) || (browserTabsByWorktree[worktreeId] ?? []).length > 0 || + // A chat counts only while its host holds its process; a stopped one has nothing to sleep. (unifiedTabsByWorktree[worktreeId] ?? []).some( (tab) => - isStructuredTab(tab) && !!hostId && (!tab.executionHostId || tab.executionHostId === hostId) + isStructuredTab(tab) && + !!hostId && + (!tab.executionHostId || tab.executionHostId === hostId) && + agentStatusByPaneKey[structuredAgentSessionPaneKey(tab.id, tab.entityId)] + ?.structuredHostOwned === true ) ) } @@ -132,6 +140,7 @@ export function useWorkspaceLineageMenuActions( ptyIdsByTabId, browserTabsByWorktree, unifiedTabsByWorktree, + agentStatusByPaneKey, chatOwnerState } = activity return useMemo( @@ -146,11 +155,13 @@ export function useWorkspaceLineageMenuActions( ptyIdsByTabId, browserTabsByWorktree, unifiedTabsByWorktree, + agentStatusByPaneKey, chatOwnerState } }) : EMPTY_LINEAGE_MENU_ACTIONS, [ + agentStatusByPaneKey, browserTabsByWorktree, enabled, lineageById, diff --git a/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts b/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts index eb845dead13d..09e0fa6c61e3 100644 --- a/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts +++ b/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts @@ -28,6 +28,7 @@ export const EMPTY_TABS_BY_WORKTREE: AppState['tabsByWorktree'] = {} export const EMPTY_PTY_IDS_BY_TAB_ID: AppState['ptyIdsByTabId'] = {} export const EMPTY_BROWSER_TABS_BY_WORKTREE: AppState['browserTabsByWorktree'] = {} export const EMPTY_UNIFIED_TABS_BY_WORKTREE: AppState['unifiedTabsByWorktree'] = {} +export const EMPTY_AGENT_STATUS_BY_PANE_KEY: AppState['agentStatusByPaneKey'] = {} export const EMPTY_DELETE_STATE_BY_WORKTREE_ID: AppState['deleteStateByWorktreeId'] = {} export const EMPTY_WORKTREE_LINEAGE_BY_ID: AppState['worktreeLineageById'] = {} export const EMPTY_WORKSPACE_LINEAGE_BY_CHILD_KEY: AppState['workspaceLineageByChildKey'] = {} diff --git a/src/renderer/src/store/slices/agent-status-pane-key-tab-binding.ts b/src/renderer/src/store/slices/agent-status-pane-key-tab-binding.ts index f25478b6025b..e6074b694821 100644 --- a/src/renderer/src/store/slices/agent-status-pane-key-tab-binding.ts +++ b/src/renderer/src/store/slices/agent-status-pane-key-tab-binding.ts @@ -66,6 +66,22 @@ export function getLeafIdFromPaneKey(paneKey: string): string | null { return leafId.length > 0 ? leafId : null } +/** Open non-terminal tabs (chats) whose rows their own projection writes and removes, so terminal + * cleanup must leave them alone. Terminal owners must still exist in the terminal inventory + * instead; unified metadata can lag a close. */ +export function openNonTerminalTabIds( + state: Pick, + worktreeId: string +): Set { + const tabIds = new Set() + for (const tab of state.unifiedTabsByWorktree[worktreeId] ?? []) { + if (tab.contentType !== 'terminal') { + tabIds.add(tab.id) + } + } + return tabIds +} + export function findCompletedOrphanPaneKeysForTabClose( state: AgentStatusTabPrefixDropState, worktreeId: string | undefined, @@ -74,12 +90,9 @@ export function findCompletedOrphanPaneKeysForTabClose( if (!worktreeId) { return [] } - const openTabIds = new Set((state.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)) - // Terminal owners must still exist in the terminal inventory; unified metadata can lag a close. - for (const tab of state.unifiedTabsByWorktree[worktreeId] ?? []) { - if (tab.contentType !== 'terminal') { - openTabIds.add(tab.id) - } + const openTabIds = openNonTerminalTabIds(state, worktreeId) + for (const tab of state.tabsByWorktree[worktreeId] ?? []) { + openTabIds.add(tab.id) } const paneKeys: string[] = [] for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { diff --git a/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts b/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts index 71a373ecae9b..e7aa681a767f 100644 --- a/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts +++ b/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts @@ -3,7 +3,9 @@ import type { AgentStatusSlice } from './agent-status-slice-contract' import type { AgentStatusRuntime } from './agent-status-runtime' import { pruneMigrationUnsupportedEntries } from './agent-status-migration-unsupported-entries' import { + getTabIdFromPaneKey, normalizePaneKeySet, + openNonTerminalTabIds, paneKeyMatchesAnyTabPrefix, retainedAgentEntryFromLive, shouldReplaceRetainedWithLive @@ -20,9 +22,13 @@ export function createAgentStatusWorktreeDropActions( let hadLive = false set((s) => { const tabPrefixes = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) - const liveEntries = Object.entries(s.agentStatusByPaneKey).filter( - ([paneKey, entry]) => - entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) + // An open chat's row is its host's: the chat tab's projection removes it on close. + const chatTabIds = openNonTerminalTabIds(s, worktreeId) + const ownedByWorktree = (paneKey: string, entryWorktreeId: string | undefined): boolean => + !chatTabIds.has(getTabIdFromPaneKey(paneKey) ?? '') && + (entryWorktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes)) + const liveEntries = Object.entries(s.agentStatusByPaneKey).filter(([paneKey, entry]) => + ownedByWorktree(paneKey, entry.worktreeId) ) const liveKeys = liveEntries.map(([paneKey]) => paneKey) const liveKeySet = new Set(liveKeys) @@ -30,10 +36,7 @@ export function createAgentStatusWorktreeDropActions( (paneKey) => paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) || liveKeySet.has(paneKey) ) const retainedKeys = Object.entries(s.retainedAgentsByPaneKey) - .filter( - ([paneKey, retained]) => - retained.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) - ) + .filter(([paneKey, retained]) => ownedByWorktree(paneKey, retained.worktreeId)) .map(([paneKey]) => paneKey) const retainedKeySet = new Set(retainedKeys) const migrationUnsupported = pruneMigrationUnsupportedEntries( From 883c17437b682176e6970ef131d5f1b01025264e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:49:14 -0700 Subject: [PATCH 17/30] fix(native-chat): judge the Sleep receipt by facts, not lane occupancy A running chat's stop queues the provider's own requested-close end report onto the chat's lane, so the lane-occupancy check read every proven stop as superseded and dropped its receipt. Later work now counts only once it exists: a moved fence, a child, delivery, child work, a pending submission, a pending create or attach, or an open dispatch. --- ...red-agent-session-surface-lifetime.test.ts | 34 +++++++++++++------ .../structured-agent-session-task-queue.ts | 17 +++------- ...tructured-agent-session-workspace-sleep.ts | 29 ++++++---------- 3 files changed, 37 insertions(+), 43 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 163bc2c34187..6bc09008b291 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -233,7 +233,21 @@ beforeEach(async () => { } } }) - closeSession = vi.fn(async () => true) + // Like real adapters, the close reports its own end onto the chat's lane before it returns. + closeSession = vi.fn(async (sessionId: string) => { + const child = host['sessions'].get(sessionId)?.child + if (child) { + void host.handleAdapterEvent({ + type: 'ended', + sessionId, + reason: 'closed', + cause: 'requested-close', + fence: child.fence, + acquisitionGeneration: child.generation + }) + } + return true + }) dispatch = vi.fn(async () => ({ state: 'rejected' as const, ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { @@ -250,12 +264,13 @@ afterEach(async () => { }) describe('a chat that closes', () => { - it('sleeps a workspace child and retains its conversation with a positive stop receipt', async () => { + it('returns the receipt for a stopped child whose provider reports its own end', async () => { await attach() await host['tasks'].drainAttaches() + const reported = vi.spyOn(host, 'handleAdapterEvent') expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) - expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(reported).toHaveBeenCalledWith(expect.objectContaining({ cause: 'requested-close' })) expect(store.getRecord(SESSION)).not.toBeNull() expect(host.hasSession(SESSION)).toBe(false) expect(await host.sleepWorkspace('other-workspace')).toEqual({ stoppedSessionIds: [] }) @@ -272,24 +287,21 @@ describe('a chat that closes', () => { expect(store.getRecord(SESSION)).not.toBeNull() }) - it('does not count a send admitted after the stop as a stopped chat', async () => { + it('does not count a chat whose send was accepted after the stop as stopped', async () => { await attach() await vi.waitFor(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) const close = host['lifetime'].closeForWorkspaceSleep - let newerSend: ReturnType | null = null vi.spyOn(host['lifetime'], 'closeForWorkspaceSleep').mockImplementation(async (...args) => { const result = await close(...args) const body = hostTestMessage('newer send') - newerSend = host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body - }) + // The later work exists once the send is accepted, not merely once it is queued. + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) return result }) expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [] }) - expect(newerSend).not.toBeNull() - expect(await newerSend).toMatchObject({ ok: true }) }) it('stops the provider child it started', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts index 1121c4bc6770..79c6234a47f3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts @@ -1,7 +1,4 @@ -import { - getKeyedSerializedQueueTail, - runKeyedSerializedOperation -} from '../../cli/keyed-promise-queue' +import { runKeyedSerializedOperation } from '../../cli/keyed-promise-queue' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' export class StructuredAgentSessionTaskQueue { @@ -48,15 +45,9 @@ export class StructuredAgentSessionTaskQueue { .map(({ sessionId }) => sessionId) } - hasPending(sessionId: string): boolean { - return ( - this.chains.has(sessionId) || - [...this.pendingLocations.values()].some((pending) => pending.sessionId === sessionId) - ) - } - - waitForCurrent(sessionId: string): Promise { - return getKeyedSerializedQueueTail(this.chains, sessionId) + /** A create or attach for this session that has not settled yet. */ + hasPendingAttach(sessionId: string): boolean { + return [...this.pendingLocations.values()].some((pending) => pending.sessionId === sessionId) } async drainAttaches(): Promise { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts index 8ee12a54d0f6..5ebbe337fcf3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts @@ -37,27 +37,17 @@ export async function sleepStructuredAgentSessionWorkspace( ...records.filter((record) => belongs(record.location)).map((record) => record.sessionId), ...host.tasks.pendingMembers(workspaceId, LOCAL_EXECUTION_HOST_ID) ]) - const admitted = [...candidates].map((sessionId) => { - try { - const close = host.lifetime.closeForWorkspaceSleep( - sessionId, - workspaceId, - LOCAL_EXECUTION_HOST_ID - ) - return { sessionId, close, tail: host.tasks.waitForCurrent(sessionId) } - } catch (error) { - return { sessionId, close: Promise.reject(error), tail: Promise.resolve() } - } - }) - const outcomes = await Promise.all( - admitted.map(async ({ sessionId, close, tail }) => { - const [stop] = await Promise.allSettled([close, tail]) - return { sessionId, stop } - }) + const sessionIds = [...candidates] + // Admits every close synchronously; `async` turns a synchronous throw into a rejection. + const stops = await Promise.allSettled( + sessionIds.map(async (sessionId) => + host.lifetime.closeForWorkspaceSleep(sessionId, workspaceId, LOCAL_EXECUTION_HOST_ID) + ) ) const stoppedSessionIds: string[] = [] const failures: NonNullable = [] - for (const { sessionId, stop } of outcomes) { + for (const [index, stop] of stops.entries()) { + const sessionId = sessionIds[index] if (stop.status === 'rejected') { failures.push({ sessionId, reason: 'stop-unverifiable' }) continue @@ -74,10 +64,11 @@ export async function sleepStructuredAgentSessionWorkspace( failures.push({ sessionId, reason: 'stop-unverifiable' }) continue } + // Judge later work by facts, not lane occupancy: the stop queues its own end report. const session = host.sessions.get(sessionId) if ( session?.child || - host.tasks.hasPending(sessionId) || + host.tasks.hasPendingAttach(sessionId) || host.deliveryRunning(sessionId) || host.childWorkIncomplete(sessionId) || session?.journal.pendingSubmissions().length || From ba9385af131d756a537cb935b88073c6d87c1760 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:00:04 -0700 Subject: [PATCH 18/30] test(native-chat): report a fake close's end only for an acquired child --- .../structured-agent-session-surface-lifetime.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 6bc09008b291..50d1d9f85ce8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -236,7 +236,7 @@ beforeEach(async () => { // Like real adapters, the close reports its own end onto the chat's lane before it returns. closeSession = vi.fn(async (sessionId: string) => { const child = host['sessions'].get(sessionId)?.child - if (child) { + if (child?.generation) { void host.handleAdapterEvent({ type: 'ended', sessionId, From 34b4aa92afc3ddc4fbf5d75584f19c61358f6cdb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:03:22 -0700 Subject: [PATCH 19/30] Preserve canonical workspace identity when sleeping --- .../runtime-construction-services.ts | 5 +++- .../orca-runtime-workspace-ports-host.test.ts | 22 ++++++++++++++ .../managed-workspace-operations.ts | 14 +-------- .../runtime/workspaces/workspace-selectors.ts | 29 +++++++++++++++---- .../sidebar/sleep-worktree-flow.test.ts | 29 ++++++++++++++++++- .../components/sidebar/sleep-worktree-flow.ts | 24 ++++++--------- 6 files changed, 87 insertions(+), 36 deletions(-) diff --git a/src/main/runtime/construction/runtime-construction-services.ts b/src/main/runtime/construction/runtime-construction-services.ts index ff1bdf858278..f6256bde2b12 100644 --- a/src/main/runtime/construction/runtime-construction-services.ts +++ b/src/main/runtime/construction/runtime-construction-services.ts @@ -250,7 +250,10 @@ export abstract class RuntimeConstructionServices { protected abstract getTrackedDisplayTitleForPty(ptyId: string): string | null protected abstract getLeavesForPty(ptyId: string): RuntimeLeafRecord[] abstract notifyMessageArrived(handle: string, messageType?: string): void - protected abstract resolveWorktreeSelector(selector: string): Promise + protected abstract resolveWorktreeSelector( + selector: string, + requiredHostId?: ExecutionHostId + ): Promise protected abstract refreshPtyWorktreeRecordsWithControllerInventory( resolvedWorktrees: ResolvedWorktree[], targetWorktreeId?: string | null, diff --git a/src/main/runtime/orca-runtime-workspace-ports-host.test.ts b/src/main/runtime/orca-runtime-workspace-ports-host.test.ts index 46378708d0e2..df6b255c7bdb 100644 --- a/src/main/runtime/orca-runtime-workspace-ports-host.test.ts +++ b/src/main/runtime/orca-runtime-workspace-ports-host.test.ts @@ -1,6 +1,7 @@ import './rpc/unused-default-rpc-methods.test-fixture' import { afterEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeWithListManagedWorktrees } from './workspaces/managed-workspace-operations' +import { RuntimeWorkspaceSelectors } from './workspaces/workspace-selectors' const scanWorkspacePortsMock = vi.hoisted(() => vi.fn()) @@ -25,6 +26,27 @@ function runtimeResolvingTo(executionHostId: string) { } describe('host-scoped workspace port scans on the server', () => { + it('sleeps a host-qualified Windows alias without choosing a foreign or ambiguous row', async () => { + const id = 'repo::D:/Agentic/game2/battle-core' + const alias = 'repo::D:\\Agentic\\game2\\battle-core' + const selected = { id, repoId: 'repo', hostId: 'runtime:hub' } + let rows = [selected, { ...selected, id: alias, hostId: 'local' }] + const selectors = RuntimeWorkspaceSelectors.prototype + const host = { + store: { getRepo: () => undefined }, + notifier: { sleepWorktree: vi.fn() }, + hasFreshResolvedWorktreeCache: () => true, + getValidatedExplicitWorktreeIdSelector: selectors['getValidatedExplicitWorktreeIdSelector'], + resolveWorktreeSelector: selectors['resolveWorktreeSelector'], + listResolvedWorktrees: async () => rows + } + const sleep = () => proto.sleepManagedWorktree.call(host, `id:${alias}`, 'runtime:hub') + await expect(sleep()).resolves.toEqual({ worktreeId: id, executionHostId: 'runtime:hub' }) + expect(host.notifier.sleepWorktree).toHaveBeenCalledWith(id, 'runtime:hub') + rows = [selected, { ...selected, id: `${id}/` }] + await expect(sleep()).rejects.toThrow('selector_ambiguous') + }) + afterEach(() => { scanWorkspacePortsMock.mockReset() vi.restoreAllMocks() diff --git a/src/main/runtime/workspaces/managed-workspace-operations.ts b/src/main/runtime/workspaces/managed-workspace-operations.ts index 26aa7609bc89..84a6aab747e3 100644 --- a/src/main/runtime/workspaces/managed-workspace-operations.ts +++ b/src/main/runtime/workspaces/managed-workspace-operations.ts @@ -218,19 +218,7 @@ export abstract class OrcaRuntimeWithListManagedWorktrees extends OrcaRuntimeWit worktreeSelector: string, hostId?: ExecutionHostId ): Promise<{ worktreeId: string; executionHostId: ExecutionHostId }> { - const explicitId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) - const worktree = - hostId && explicitId - ? (await this.listResolvedWorktrees()).find( - (candidate) => - candidate.id === explicitId && - getWorktreeExecutionHostId(candidate, this.store?.getRepo(candidate.repoId)) === - hostId - ) - : await this.resolveWorktreeSelector(worktreeSelector) - if (!worktree) { - throw new Error('selector_not_found') - } + const worktree = await this.resolveWorktreeSelector(worktreeSelector, hostId) const executionHostId = getWorktreeExecutionHostId( worktree, this.store?.getRepo(worktree.repoId) diff --git a/src/main/runtime/workspaces/workspace-selectors.ts b/src/main/runtime/workspaces/workspace-selectors.ts index 0468d7115a9e..b0593e1d66dd 100644 --- a/src/main/runtime/workspaces/workspace-selectors.ts +++ b/src/main/runtime/workspaces/workspace-selectors.ts @@ -2,7 +2,11 @@ import { OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree } from '.. import type { ResolvedWorktree } from '../runtime-worktree-path-identity' import { splitWorktreeIdForFilesystem, worktreeIdComparisonKey } from '../../../shared/worktree/id' import { branchSelectorMatches, runtimePathsEqual } from '../runtime-worktree-path-identity' -import { getRepoExecutionHostId, getWorktreeExecutionHostId } from '../../../shared/execution-host' +import { + getRepoExecutionHostId, + getWorktreeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' import type { WorktreeLineageInput, WorktreeLineageResolution @@ -29,13 +33,18 @@ export abstract class RuntimeWorkspaceSelectors extends OrcaRuntimeWithResolveBr ...args: Parameters ): ReturnType - protected async resolveWorktreeSelector(selector: string): Promise { + protected async resolveWorktreeSelector( + selector: string, + requiredHostId?: ExecutionHostId + ): Promise { const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(selector) // Why only `id:`: every other selector kind is matched across the whole fleet, and their // `selector_ambiguous` contract is defined over all repos. Scoping those would silently pick a // winner where today they correctly refuse. An `id:` selector already names its repo. if (explicitWorktreeId && !this.hasFreshResolvedWorktreeCache()) { - const scoped = await this.resolveExplicitWorktreeIdScoped(explicitWorktreeId) + const scoped = requiredHostId + ? await this.resolveExplicitWorktreeIdScoped(explicitWorktreeId, requiredHostId) + : await this.resolveExplicitWorktreeIdScoped(explicitWorktreeId) if (scoped) { return scoped } @@ -52,11 +61,19 @@ export abstract class RuntimeWorkspaceSelectors extends OrcaRuntimeWithResolveBr candidates = worktrees.filter((worktree) => worktree.identity?.key === identityKey) } else if (selector.startsWith('id:')) { const worktreeId = explicitWorktreeId ?? selector.slice(3) - candidates = worktrees.filter((worktree) => worktree.id === worktreeId) + const matchesRequiredHost = (worktree: ResolvedWorktree): boolean => + !requiredHostId || + getWorktreeExecutionHostId(worktree, this.store?.getRepo(worktree.repoId)) === + requiredHostId + // Narrow ownership before exact preference so a foreign spelling cannot hide an alias. + const ownedWorktrees = worktrees.filter(matchesRequiredHost) + candidates = ownedWorktrees.filter((worktree) => worktree.id === worktreeId) if (candidates.length === 0) { const comparisonKey = worktreeIdComparisonKey(worktreeId) candidates = comparisonKey - ? worktrees.filter((worktree) => worktreeIdComparisonKey(worktree.id) === comparisonKey) + ? ownedWorktrees.filter( + (worktree) => worktreeIdComparisonKey(worktree.id) === comparisonKey + ) : candidates } if (candidates.length === 0) { @@ -66,7 +83,7 @@ export abstract class RuntimeWorkspaceSelectors extends OrcaRuntimeWithResolveBr repo?.connectionId && this.store?.getWorktreeMeta(worktreeId) ? this.buildResolvedWorktreeFromId(worktreeId) : null - if (fallback !== null) { + if (fallback !== null && matchesRequiredHost(fallback)) { candidates = [fallback] } } diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index b2b6b1466c16..60c82622a169 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -1,4 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { worktree } from './worktree-list-groups-test-fixtures' +import { withRepoHostOwnership } from '@/store/slices/worktrees/listing/worktree-host-ownership' +import type * as Owner from '@/runtime/structured-agent-session-owner' type SleepTestState = { activeWorktreeId: string | null @@ -34,7 +37,7 @@ const mocks = vi.hoisted(() => { return { state, call: vi.fn().mockResolvedValue({ stoppedSessionIds: ['session-1'] }), - target: vi.fn(() => ({ kind: 'local' })), + target: vi.fn<(host: string) => unknown>(() => ({ kind: 'local' })), suspend: vi.fn().mockResolvedValue(null), toast: vi.fn() } @@ -57,6 +60,7 @@ import { import { clearWorktreeSleepIntent, forgetWorktreeSleepIntent, + hasWorktreeSleepTeardown, hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' @@ -69,6 +73,29 @@ function deferred() { } describe('workspace sleep flow', () => { + it('keeps paired SSH execution identity through selection, intent and teardown', async () => { + const owner = await vi.importActual('@/runtime/structured-agent-session-owner') + mocks.target.mockImplementationOnce(owner.structuredAgentSessionTargetForHost) + const target = withRepoHostOwnership( + { ...worktree, hostId: 'ssh:target' as const }, + 'runtime:hub' + ) + mocks.state.activeWorktreeId = target.id + mocks.state.activeWorkspaceExecutionHostId = 'ssh:target' + mocks.state.shutdownWorktreeTerminals.mockImplementationOnce(async () => { + expect(mocks.state.activeWorktreeId).toBeNull() + expect(hasWorktreeSleepTeardown(target.id, 'ssh:target')).toBe(true) + }) + const result = await runSleepWorktrees([target]) + expect(result[0]).toMatchObject({ + executionHostId: 'ssh:target', + completed: true + }) + expect(hasWorktreeSleepIntent(target.id, 'ssh:target')).toBe(true) + expect(hasWorktreeSleepIntent(target.id, 'runtime:hub')).toBe(false) + expect(mocks.call).not.toHaveBeenCalled() + }) + beforeEach(() => { vi.stubGlobal('window', { api: { ephemeralVm: { suspendWorkspace: mocks.suspend } } diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index 79bb893cf74c..58bdf2e039d4 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -19,6 +19,7 @@ import { } from '../../../../shared/execution-host' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route' +import { routeForOwner } from '@/lib/worktree-owner-route' import { findIndexedFolderWorkspaceOwner, findIndexedProjectGroupOwner @@ -105,14 +106,14 @@ function resolveSleepTarget( const host = parseExecutionHostId(explicit) return host ? { workspaceId, executionHostId: host.id } : null } + const ownerRoute = typeof target === 'string' ? null : routeForOwner(target) + const selectedHost = + parseExecutionHostId(ownerRoute?.executionHostId)?.id ?? + (ownerRoute?.runtimeEnvironmentId + ? toRuntimeExecutionHostId(ownerRoute.runtimeEnvironmentId) + : undefined) const scope = parseWorkspaceKey(workspaceId) if (scope?.type === 'folder') { - const selectedHost = - typeof target === 'string' - ? undefined - : target.runtimeOwnerEnvironmentId?.trim() - ? toRuntimeExecutionHostId(target.runtimeOwnerEnvironmentId.trim()) - : parseExecutionHostId(target.hostId)?.id const folder = findIndexedFolderWorkspaceOwner( state.folderWorkspaces, scope.folderWorkspaceId, @@ -133,15 +134,8 @@ function resolveSleepTarget( ) return { workspaceId, executionHostId } } - if (typeof target !== 'string') { - const runtimeOwner = target.runtimeOwnerEnvironmentId?.trim() - if (runtimeOwner) { - return { workspaceId, executionHostId: toRuntimeExecutionHostId(runtimeOwner) } - } - const stamped = parseExecutionHostId(target.hostId) - if (stamped) { - return { workspaceId, executionHostId: stamped.id } - } + if (selectedHost) { + return { workspaceId, executionHostId: selectedHost } } const route = resolveWorktreeOperationRouteResult(state, workspaceId) return route.kind === 'resolved' && route.route.executionHostId From bb19e0970c4bbe0bbac6cbe635e35ef6160184b0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:09:32 -0700 Subject: [PATCH 20/30] Propagate optional sleep host through file command contract --- src/main/runtime/construction/runtime-file-command-state.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/construction/runtime-file-command-state.ts b/src/main/runtime/construction/runtime-file-command-state.ts index d5152df6abdd..990ab5b30ef6 100644 --- a/src/main/runtime/construction/runtime-file-command-state.ts +++ b/src/main/runtime/construction/runtime-file-command-state.ts @@ -91,7 +91,10 @@ export abstract class OrcaRuntimeWithFileCommands extends OrcaRuntimeWithPreserv abstract requireStore(): RuntimeStore - protected abstract resolveWorktreeSelector(selector: string): Promise + protected abstract resolveWorktreeSelector( + selector: string, + requiredHostId?: ExecutionHostId + ): Promise protected abstract resolveRepoSelector(selector: string): Promise From 17abfdf9a33e6f93b4a903c9edac1c0daededf0d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:33:06 -0700 Subject: [PATCH 21/30] Move the deliberate-sleep PTY tests to main's spec file Main grouped the PTY connection suites (#29193) and moved this file to a .spec.ts registered from pty-connection-spawn-and-startup.test.ts. Taking main's copy here keeps the following main merge free of a modify/delete conflict; this PR's test changes are reapplied after the merge. --- ...-connection-deliberate-sleep-guard.spec.ts | 279 +++++++++++++ ...-connection-deliberate-sleep-guard.test.ts | 393 ------------------ 2 files changed, 279 insertions(+), 393 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.spec.ts new file mode 100644 index 000000000000..c1495a3ee632 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.spec.ts @@ -0,0 +1,279 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + createMockTransport, + createPane, + createManager, + LEAF_1, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionDeliberateSleepGuardTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('deliberate sleep keeps mounted panes cold', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + const { clearWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') + clearWorktreeSleepIntent('wt-1') + await restoreTerminalTestGlobals() + }) + + // Why: manual sleep keeps tab.ptyId as a wake hint, so a remount would take the + // REATTACH arm and the daemon would respawn a shell for the dead id (#10205). + it('does not reattach a slept pane through its retained session id', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + activeWorktreeId: 'wt-other', + tabsByWorktree: { 'wt-1': [{ id: 'tab-slept', ptyId: 'wt-1@@dead' }] } + } + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ + tabId: 'tab-slept', + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'wt-1@@dead' }, + isVisibleRef: { current: false } + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + expect(transport.connect).not.toHaveBeenCalled() + }) + + it('does not fresh-spawn a slept pane that has no session id', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { ...mockStoreState, activeWorktreeId: 'wt-other' } + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ tabId: 'tab-slept-bare', isVisibleRef: { current: false } }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + expect(transport.connect).not.toHaveBeenCalled() + }) + + it('connects a waiting pane once the workspace is woken', async () => { + const { connectPanePty } = await import('./pty-connection') + const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = + await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + activeWorktreeId: 'wt-other', + tabsByWorktree: { 'wt-1': [{ id: 'tab-woken', ptyId: 'wt-1@@dead' }] } + } + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ + tabId: 'tab-woken', + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'wt-1@@dead' }, + isVisibleRef: { current: false } + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + expect(transport.connect).not.toHaveBeenCalled() + + clearWorktreeSleepIntent('wt-1') + await flushAsyncTicks() + + expect(transport.connect).toHaveBeenCalledTimes(1) + }) + + it('does not connect a waiting pane whose tab was remounted by the wake', async () => { + const { connectPanePty } = await import('./pty-connection') + const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = + await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + activeWorktreeId: 'wt-other', + tabsByWorktree: { 'wt-1': [{ id: 'tab-remounted', ptyId: 'wt-1@@dead', generation: 0 }] } + } + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ + tabId: 'tab-remounted', + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'wt-1@@dead' }, + isVisibleRef: { current: false } + }) + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + // Why: activation bumps generation in the same set() that precedes the clear. + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-remounted', ptyId: 'wt-1@@dead', generation: 1 }] } + } + clearWorktreeSleepIntent('wt-1') + await flushAsyncTicks() + + expect(transport.connect).not.toHaveBeenCalled() + }) + + it('resumes a waiting pane mounted under a unified tab id', async () => { + const { connectPanePty } = await import('./pty-connection') + const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = + await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + activeWorktreeId: 'wt-other', + tabsByWorktree: { 'wt-1': [{ id: 'tab-entity', ptyId: null, generation: 3 }] }, + getTab: (id: string) => + id === 'unified-1' ? { id, contentType: 'terminal', entityId: 'tab-entity' } : null + } as never + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ tabId: 'unified-1', isVisibleRef: { current: false } }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + expect(transport.connect).not.toHaveBeenCalled() + + clearWorktreeSleepIntent('wt-1') + await flushAsyncTicks() + + expect(transport.connect).toHaveBeenCalledTimes(1) + }) + + it('re-arms after a wake so a second sleep can hold the pane again', async () => { + const { connectPanePty } = await import('./pty-connection') + const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = + await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + let releaseCwd: (cwd: string) => void = () => {} + const cwdPromise = new Promise((resolve) => { + releaseCwd = resolve + }) + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ + tabId: 'tab-resleep', + isVisibleRef: { current: false }, + cwdPromise + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + // Wake: the pane leaves the sleep gate and parks on the cwd gate. + clearWorktreeSleepIntent('wt-1') + await flushAsyncTicks() + // Sleep again before the cwd settles, then wake again. + markWorktreeSleepIntent('wt-1') + releaseCwd('/cwd') + await flushAsyncTicks() + expect(transport.connect).not.toHaveBeenCalled() + clearWorktreeSleepIntent('wt-1') + await flushAsyncTicks() + + expect(transport.connect).toHaveBeenCalledTimes(1) + }) + + it('drops the wake listener when a waiting pane is disposed', async () => { + const { connectPanePty } = await import('./pty-connection') + const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = + await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ tabId: 'tab-disposed', isVisibleRef: { current: false } }) + + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + deps as never + ) + await flushAsyncTicks() + binding.dispose() + // Why: the connect body already refuses a disposed session, so prove the + // listener itself is gone: a wake after dispose reaches no subscriber. + const wakeCalls: number[] = [] + const { onWorktreeSleepIntentCleared } = await import('@/lib/worktree-sleep-intent') + onWorktreeSleepIntentCleared('wt-1', () => wakeCalls.push(1)) + clearWorktreeSleepIntent('wt-1') + await flushAsyncTicks() + + expect(transport.connect).not.toHaveBeenCalled() + expect(wakeCalls).toHaveLength(1) + }) + + it('still connects a slept pane that carries a queued startup', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ + tabId: 'tab-slept-startup', + isVisibleRef: { current: false }, + startup: { command: 'printf wake', launchAgent: undefined } + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + expect(transport.connect).toHaveBeenCalledTimes(1) + }) + + it('connects normally once the marker is released', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const deps = createDeps({ + tabId: 'tab-awake', + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'wt-1@@live' }, + isVisibleRef: { current: false } + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + expect(transport.connect).toHaveBeenCalledTimes(1) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts deleted file mode 100644 index 833890ff5c9d..000000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts +++ /dev/null @@ -1,393 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - createMockTransport, - createPane, - createManager, - LEAF_1, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('deliberate sleep keeps mounted panes cold', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - const { clearWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') - clearWorktreeSleepIntent('wt-1') - await restoreTerminalTestGlobals() - }) - - // Why: manual sleep keeps tab.ptyId as a wake hint, so a remount would take the - // REATTACH arm and the daemon would respawn a shell for the dead id (#10205). - it('does not reattach a slept pane through its retained session id', async () => { - const { connectPanePty } = await import('./pty-connection') - const { markWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - activeWorktreeId: 'wt-other', - tabsByWorktree: { 'wt-1': [{ id: 'tab-slept', ptyId: 'wt-1@@dead' }] } - } - markWorktreeSleepIntent('wt-1') - const deps = createDeps({ - tabId: 'tab-slept', - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'wt-1@@dead' }, - isVisibleRef: { current: false } - }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - - expect(transport.connect).not.toHaveBeenCalled() - }) - - it('does not fresh-spawn a slept pane that has no session id', async () => { - const { connectPanePty } = await import('./pty-connection') - const { markWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - mockStoreState = { ...mockStoreState, activeWorktreeId: 'wt-other' } - markWorktreeSleepIntent('wt-1') - const deps = createDeps({ tabId: 'tab-slept-bare', isVisibleRef: { current: false } }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - - expect(transport.connect).not.toHaveBeenCalled() - }) - - it('connects a pane mounted after wake only when issued teardown settles', async () => { - const { connectPanePty } = await import('./pty-connection') - const { beginWorktreeSleepTeardown, clearWorktreeSleepIntent, markWorktreeSleepIntent } = - await import('@/lib/worktree-sleep-intent') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - activeWorktreeId: 'wt-other', - tabsByWorktree: { 'wt-1': [{ id: 'tab-woken', ptyId: 'wt-1@@dead' }] } - } - markWorktreeSleepIntent('wt-1') - const finish = beginWorktreeSleepTeardown('wt-1', 'local') - clearWorktreeSleepIntent('wt-1') - const deps = createDeps({ - tabId: 'tab-woken', - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'wt-1@@dead' }, - isVisibleRef: { current: false } - }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - expect(transport.connect).not.toHaveBeenCalled() - - finish() - await flushAsyncTicks() - - expect(transport.connect).toHaveBeenCalledTimes(1) - }) - - it('waits for both same-id host teardowns before connecting an unqualified pane', async () => { - const { connectPanePty } = await import('./pty-connection') - const { beginWorktreeSleepTeardown } = await import('@/lib/worktree-sleep-intent') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - const finishA = beginWorktreeSleepTeardown('wt-1', toRuntimeExecutionHostId('host-a')) - const finishB = beginWorktreeSleepTeardown('wt-1', toRuntimeExecutionHostId('host-b')) - const deps = createDeps({ tabId: 'tab-overlap', isVisibleRef: { current: false } }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - expect(transport.connect).not.toHaveBeenCalled() - - finishA() - await flushAsyncTicks() - expect(transport.connect).not.toHaveBeenCalled() - - finishB() - await flushAsyncTicks() - expect(transport.connect).toHaveBeenCalledTimes(1) - }) - - it('does not connect a waiting pane whose tab was remounted by the wake', async () => { - const { connectPanePty } = await import('./pty-connection') - const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = - await import('@/lib/worktree-sleep-intent') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - activeWorktreeId: 'wt-other', - tabsByWorktree: { 'wt-1': [{ id: 'tab-remounted', ptyId: 'wt-1@@dead', generation: 0 }] } - } - markWorktreeSleepIntent('wt-1') - const deps = createDeps({ - tabId: 'tab-remounted', - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'wt-1@@dead' }, - isVisibleRef: { current: false } - }) - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - - // Why: activation bumps generation in the same set() that precedes the clear. - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-remounted', ptyId: 'wt-1@@dead', generation: 1 }] } - } - clearWorktreeSleepIntent('wt-1') - await flushAsyncTicks() - - expect(transport.connect).not.toHaveBeenCalled() - }) - - it('resumes a waiting pane mounted under a unified tab id', async () => { - const { connectPanePty } = await import('./pty-connection') - const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = - await import('@/lib/worktree-sleep-intent') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - activeWorktreeId: 'wt-other', - tabsByWorktree: { 'wt-1': [{ id: 'tab-entity', ptyId: null, generation: 3 }] }, - getTab: (id: string) => - id === 'unified-1' ? { id, contentType: 'terminal', entityId: 'tab-entity' } : null - } as never - markWorktreeSleepIntent('wt-1') - const deps = createDeps({ tabId: 'unified-1', isVisibleRef: { current: false } }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - expect(transport.connect).not.toHaveBeenCalled() - - clearWorktreeSleepIntent('wt-1') - await flushAsyncTicks() - - expect(transport.connect).toHaveBeenCalledTimes(1) - }) - - it('re-arms after a wake so a second sleep can hold the pane again', async () => { - const { connectPanePty } = await import('./pty-connection') - const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = - await import('@/lib/worktree-sleep-intent') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - let releaseCwd: (cwd: string) => void = () => {} - const cwdPromise = new Promise((resolve) => { - releaseCwd = resolve - }) - markWorktreeSleepIntent('wt-1') - const deps = createDeps({ tabId: 'tab-resleep', isVisibleRef: { current: false }, cwdPromise }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - // Wake: the pane leaves the sleep gate and parks on the cwd gate. - clearWorktreeSleepIntent('wt-1') - await flushAsyncTicks() - // Sleep again before the cwd settles, then wake again. - markWorktreeSleepIntent('wt-1') - releaseCwd('/cwd') - await flushAsyncTicks() - expect(transport.connect).not.toHaveBeenCalled() - clearWorktreeSleepIntent('wt-1') - await flushAsyncTicks() - - expect(transport.connect).toHaveBeenCalledTimes(1) - }) - - it('drops the wake listener when a waiting pane is disposed', async () => { - const { connectPanePty } = await import('./pty-connection') - const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = - await import('@/lib/worktree-sleep-intent') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - markWorktreeSleepIntent('wt-1') - const deps = createDeps({ tabId: 'tab-disposed', isVisibleRef: { current: false } }) - - const binding = connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - binding.dispose() - // Why: the connect body already refuses a disposed session, so prove the - // listener itself is gone: a wake after dispose reaches no subscriber. - const wakeCalls: number[] = [] - const { onWorktreeSleepIntentCleared } = await import('@/lib/worktree-sleep-intent') - onWorktreeSleepIntentCleared('wt-1', () => wakeCalls.push(1)) - clearWorktreeSleepIntent('wt-1') - await flushAsyncTicks() - - expect(transport.connect).not.toHaveBeenCalled() - expect(wakeCalls).toHaveLength(1) - }) - - it('still connects a slept pane that carries a queued startup', async () => { - const { connectPanePty } = await import('./pty-connection') - const { beginWorktreeSleepTeardown, markWorktreeSleepIntent } = - await import('@/lib/worktree-sleep-intent') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - markWorktreeSleepIntent('wt-1') - const finish = beginWorktreeSleepTeardown('wt-1', 'local') - const deps = createDeps({ - tabId: 'tab-slept-startup', - isVisibleRef: { current: false }, - startup: { command: 'printf wake', launchAgent: undefined } - }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - - expect(transport.connect).not.toHaveBeenCalled() - finish() - await flushAsyncTicks() - - expect(transport.connect).toHaveBeenCalledTimes(1) - }) - - it('connects normally once the marker is released', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - const deps = createDeps({ - tabId: 'tab-awake', - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'wt-1@@live' }, - isVisibleRef: { current: false } - }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - - expect(transport.connect).toHaveBeenCalledTimes(1) - }) -}) From 7a13e60c8d56df7195b8d576a9a23493b6774607 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:33:41 -0700 Subject: [PATCH 22/30] Reapply Sleep's teardown waits to the moved PTY spec; keep acquisition under its line limit The deliberate-sleep spec moved in #29193; this carries the PR's changes into it: a pane mounted after wake waits for issued teardown, an unqualified pane waits for every same-id host's teardown, and a queued startup waits too. The merge left claude-structured-session-acquisition.ts over 300 lines. resolveClaudeAcquisitionError, which already builds the exit-unproven error, now decides whether to release the acquisition. --- .../claude-structured-session-acquisition.ts | 7 +--- .../claude/claude-structured-session-close.ts | 5 +++ ...-connection-deliberate-sleep-guard.spec.ts | 39 +++++++++++++++++-- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 8d395b7c0716..8dbf0c5d0dbe 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -2,7 +2,6 @@ import type { AgentSessionAcquisition, StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { AgentSessionAcquisitionExitUnprovenError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { openClaudeStreamJsonConnection } from './claude-stream-json-connection' import { buildClaudePermissionCallbacks } from './claude-structured-inbound-control' import { resolveClaudeReplayTurn } from './claude-replay-turn-resolution' @@ -317,11 +316,9 @@ export async function acquireClaudeSession({ sessions, attempt, translator, - prompts + prompts, + releaseAcquisition: () => acquisitions.deleteIfCurrent(sessionId, attempt) }) - if (!(acquisitionError instanceof AgentSessionAcquisitionExitUnprovenError)) { - acquisitions.deleteIfCurrent(sessionId, attempt) - } throw acquisitionError } finally { attempt.finish() diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index f6b4b0a8c9ef..5b34c64ccd3f 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -43,6 +43,7 @@ export function claudeAcquisitionCleanupError( : new AgentSessionAcquisitionExitUnprovenError(cause) } +/** An unproven exit keeps the acquisition so a later stop can still prove its root gone. */ export async function resolveClaudeAcquisitionError(input: { error: unknown sessionId: string @@ -50,6 +51,7 @@ export async function resolveClaudeAcquisitionError(input: { attempt: ClaudeAcquisitionAttempt translator: ClaudeJournalTranslator | null prompts: ClaudePromptRegistry + releaseAcquisition: () => void }): Promise { let acquisitionError = input.error if (input.sessions.get(input.sessionId)?.connection !== input.attempt.connection) { @@ -64,6 +66,9 @@ export async function resolveClaudeAcquisitionError(input: { acquisitionError = claudeAcquisitionCleanupError(input.attempt.connection, input.error) } } + if (!(acquisitionError instanceof AgentSessionAcquisitionExitUnprovenError)) { + input.releaseAcquisition() + } return acquisitionError } diff --git a/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.spec.ts index c1495a3ee632..e46db3997bcf 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.spec.ts @@ -1,5 +1,6 @@ import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' import { flushAsyncTicks } from './pty-connection-test-async' import { createMockTransport, @@ -93,9 +94,9 @@ export function registerPtyConnectionDeliberateSleepGuardTests(): void { expect(transport.connect).not.toHaveBeenCalled() }) - it('connects a waiting pane once the workspace is woken', async () => { + it('connects a pane mounted after wake only when issued teardown settles', async () => { const { connectPanePty } = await import('./pty-connection') - const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = + const { beginWorktreeSleepTeardown, clearWorktreeSleepIntent, markWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') const transport = createMockTransport() transportFactoryQueue.push(transport) @@ -105,6 +106,8 @@ export function registerPtyConnectionDeliberateSleepGuardTests(): void { tabsByWorktree: { 'wt-1': [{ id: 'tab-woken', ptyId: 'wt-1@@dead' }] } } markWorktreeSleepIntent('wt-1') + const finish = beginWorktreeSleepTeardown('wt-1', 'local') + clearWorktreeSleepIntent('wt-1') const deps = createDeps({ tabId: 'tab-woken', restoredLeafId: LEAF_1, @@ -116,12 +119,34 @@ export function registerPtyConnectionDeliberateSleepGuardTests(): void { await flushAsyncTicks() expect(transport.connect).not.toHaveBeenCalled() - clearWorktreeSleepIntent('wt-1') + finish() await flushAsyncTicks() expect(transport.connect).toHaveBeenCalledTimes(1) }) + it('waits for both same-id host teardowns before connecting an unqualified pane', async () => { + const { connectPanePty } = await import('./pty-connection') + const { beginWorktreeSleepTeardown } = await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const finishA = beginWorktreeSleepTeardown('wt-1', toRuntimeExecutionHostId('host-a')) + const finishB = beginWorktreeSleepTeardown('wt-1', toRuntimeExecutionHostId('host-b')) + const deps = createDeps({ tabId: 'tab-overlap', isVisibleRef: { current: false } }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + expect(transport.connect).not.toHaveBeenCalled() + + finishA() + await flushAsyncTicks() + expect(transport.connect).not.toHaveBeenCalled() + + finishB() + await flushAsyncTicks() + expect(transport.connect).toHaveBeenCalledTimes(1) + }) + it('does not connect a waiting pane whose tab was remounted by the wake', async () => { const { connectPanePty } = await import('./pty-connection') const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = @@ -243,10 +268,12 @@ export function registerPtyConnectionDeliberateSleepGuardTests(): void { it('still connects a slept pane that carries a queued startup', async () => { const { connectPanePty } = await import('./pty-connection') - const { markWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') + const { beginWorktreeSleepTeardown, markWorktreeSleepIntent } = + await import('@/lib/worktree-sleep-intent') const transport = createMockTransport() transportFactoryQueue.push(transport) markWorktreeSleepIntent('wt-1') + const finish = beginWorktreeSleepTeardown('wt-1', 'local') const deps = createDeps({ tabId: 'tab-slept-startup', isVisibleRef: { current: false }, @@ -256,6 +283,10 @@ export function registerPtyConnectionDeliberateSleepGuardTests(): void { connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) await flushAsyncTicks() + expect(transport.connect).not.toHaveBeenCalled() + finish() + await flushAsyncTicks() + expect(transport.connect).toHaveBeenCalledTimes(1) }) From 07fff323014626ac0ae28e83a82f06389dd236dd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:47:03 -0700 Subject: [PATCH 23/30] test: keep the Sleep receipt checks on the closed handle and kept record --- .../structured-agent-session-surface-lifetime.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 13e1de3dbb6a..0d3144dd76fe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -45,6 +45,7 @@ import { codexProviderHandle } from '../../../shared/agent-session-provider-hand import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' +const REQUESTED_CLOSE_END = { type: 'ended', reason: 'closed', cause: 'requested-close' } as const const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'Codex stopped while this response was in progress. You can continue in this conversation.' @@ -243,10 +244,8 @@ beforeEach(async () => { const child = host['sessions'].get(sessionId)?.child if (child?.generation) { void host.handleAdapterEvent({ - type: 'ended', + ...REQUESTED_CLOSE_END, sessionId, - reason: 'closed', - cause: 'requested-close', fence: child.fence, acquisitionGeneration: child.generation }) @@ -276,7 +275,8 @@ describe('a chat that closes', () => { expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) expect(reported).toHaveBeenCalledWith(expect.objectContaining({ cause: 'requested-close' })) - expect(store.getRecord(SESSION)).not.toBeNull() + // History stays saved while the conversation handle closes. + expect([store.getRecord(SESSION) !== null, host.hasSession(SESSION)]).toEqual([true, false]) expect(await host.sleepWorkspace('other-workspace')).toEqual({ stoppedSessionIds: [] }) }) @@ -288,6 +288,7 @@ describe('a chat that closes', () => { stoppedSessionIds: [], failures: [{ sessionId: SESSION, reason: 'stop-unverifiable' }] }) + expect(store.getRecord(SESSION)).not.toBeNull() }) it('does not count a chat whose send was accepted after the stop as stopped', async () => { From a76c41b591141241ce5cc3702b2515c6b929cf94 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:59:56 -0700 Subject: [PATCH 24/30] fix(sidebar): a single-workspace Sleep failure says workspace, not workspaces --- src/renderer/src/components/sidebar/sleep-worktree-flow.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index 58bdf2e039d4..3647fa8af82e 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -150,7 +150,7 @@ export async function runSleepWorktrees( if (errors.length > 0) { // Why: callers are fire-and-forget; surface actionable teardown failures after restoring retryable UI state. toast.error( - results.length === 1 + targets.length === 1 ? translate( 'auto.components.sidebar.sleep.worktree.flow.8bc3fc0671', 'Failed to sleep workspace' From 769d3fe1b3843bd1244951c3f78e7975960a8311 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:07:21 -0700 Subject: [PATCH 25/30] fix(sidebar): Sleep stops a workspace's chats on every host that has a copy --- .../host-server-workspaces-wiring.test.ts | 5 +- .../host-screen/use-host-worktree-actions.ts | 4 +- src/main/ipc/dashboard-payload-validation.ts | 10 +- ...hemeral-vm-runtime-handler-cleanup.test.ts | 46 +- src/main/ipc/ephemeral-vm-runtime-handlers.ts | 26 +- .../runtime-construction-services.ts | 5 +- .../runtime-file-command-state.ts | 5 +- .../rpc/methods/worktree-sleep-method.ts | 14 - src/main/runtime/rpc/methods/worktree.ts | 8 +- src/main/runtime/runtime-notifier-contract.ts | 3 +- .../managed-workspace-operations.ts | 24 +- .../runtime/workspaces/workspace-selectors.ts | 27 +- src/main/window/runtime-window-lifecycle.ts | 3 +- src/preload/api/ephemeral-vm-api.ts | 11 +- ...ui-bridge-clipboard-and-window-controls.ts | 5 +- src/preload/api/ui-command-event-api.ts | 5 +- .../dashboard/useDashboardPopoutBridge.ts | 4 +- ...tructuredAgentSessionStatusBridge.test.tsx | 6 +- .../sidebar/sleep-workspace-chat-stop.ts | 84 +++ .../sleep-worktree-activation-race.test.ts | 42 +- .../sidebar/sleep-worktree-flow.test.ts | 601 ++++++++++-------- .../components/sidebar/sleep-worktree-flow.ts | 331 ++++------ .../use-worktree-context-menu-model.tsx | 8 +- .../workspace-lineage-menu-actions.test.ts | 88 +-- .../sidebar/workspace-lineage-menu-actions.ts | 94 +-- ...-connection-deliberate-sleep-guard.spec.ts | 39 +- .../pty-connection/run-deferred-connect.ts | 57 +- .../mobile-terminal-close-ipc-bridge.ts | 4 +- .../src/lib/ephemeral-vm-runtime-cleanup.ts | 32 +- .../lib/sidebar-worktree-activation.test.ts | 12 - .../src/lib/sidebar-worktree-activation.ts | 5 +- src/renderer/src/lib/worktree-sleep-intent.ts | 163 ++--- .../workspace-activation-preparation.test.ts | 2 +- .../worktrees/session/set-active-worktree.ts | 2 +- .../teardown/host-twin-worktree-purge.ts | 5 + .../store/terminals/terminal-pty-bindings.ts | 6 +- .../store/terminals/terminal-pty-release.ts | 6 +- .../src/store/terminals/terminal-shutdown.ts | 22 +- .../store/terminals/terminal-tab-creation.ts | 6 +- .../terminal-worktree-sleep-owner.test.ts | 23 - .../terminal-worktree-sleep-owner.ts | 25 - src/shared/dashboard-snapshot.ts | 1 - src/shared/ephemeral-vm-runtime-host-match.ts | 21 - .../rpc-params-catalog.generated.ts | 3 +- src/shared/rpc-contract/worktree-params.ts | 2 - 45 files changed, 742 insertions(+), 1153 deletions(-) delete mode 100644 src/main/runtime/rpc/methods/worktree-sleep-method.ts create mode 100644 src/renderer/src/components/sidebar/sleep-workspace-chat-stop.ts delete mode 100644 src/renderer/src/store/terminals/terminal-worktree-sleep-owner.test.ts delete mode 100644 src/renderer/src/store/terminals/terminal-worktree-sleep-owner.ts delete mode 100644 src/shared/ephemeral-vm-runtime-host-match.ts diff --git a/mobile/src/host-screen/host-server-workspaces-wiring.test.ts b/mobile/src/host-screen/host-server-workspaces-wiring.test.ts index f9fd4d06e422..5256ac4d1f67 100644 --- a/mobile/src/host-screen/host-server-workspaces-wiring.test.ts +++ b/mobile/src/host-screen/host-server-workspaces-wiring.test.ts @@ -385,10 +385,7 @@ describe("a desktop's server workspaces on the phone", () => { const client = desktop() const screen = await mountScreen(client, RELAYS) screen.sleep('mac-wt') - expect(client.sendRequest).toHaveBeenCalledWith('worktree.sleep', { - worktree: 'id:mac-wt', - hostId: 'local' - }) + expect(client.sendRequest).toHaveBeenCalledWith('worktree.sleep', { worktree: 'id:mac-wt' }) expect(screen.slept()?.size).toBe(1) }) diff --git a/mobile/src/host-screen/use-host-worktree-actions.ts b/mobile/src/host-screen/use-host-worktree-actions.ts index b89c2b2aba2b..66b4b2ce6c25 100644 --- a/mobile/src/host-screen/use-host-worktree-actions.ts +++ b/mobile/src/host-screen/use-host-worktree-actions.ts @@ -165,9 +165,7 @@ export function useHostWorktreeActions(args: { return } setSleptIds((prev) => new Set(prev).add(getWorktreeRowIdentity(item))) - void worktreeSleep - .request(client, { worktree: `id:${item.worktreeId}`, hostId: item.hostId }) - .catch(() => null) + void worktreeSleep.request(client, { worktree: `id:${item.worktreeId}` }).catch(() => null) }, [client, setSleptIds] ) diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index 859336f26be5..8463be64bba5 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -93,15 +93,7 @@ export function isDashboardSleepWorkspaceArgs( if (!value || typeof value !== 'object' || Array.isArray(value)) { return false } - if (!('worktreeId' in value)) { - return false - } - const hostId = 'executionHostId' in value ? value.executionHostId : undefined - return ( - isBoundedString(value.worktreeId, MAX_ID_LENGTH) && - (hostId === undefined || - (typeof hostId === 'string' && normalizeExecutionHostId(hostId) !== null)) - ) + return isBoundedString((value as Record).worktreeId, MAX_ID_LENGTH) } export function isDashboardSnapshot(value: unknown): value is DashboardSnapshot { diff --git a/src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts b/src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts index b3e349e9d7d4..adfabdd35bf0 100644 --- a/src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts +++ b/src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts @@ -3,10 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { upsertEphemeralVmRuntime } from '../../shared/ephemeral-vm-runtime-store' -import type { ExecutionHostId } from '../../shared/execution-host' -type HandlerArgs = { runtimeId?: string; workspaceId?: string; executionHostId?: ExecutionHostId } -const handlers = new Map unknown>() +const handlers = new Map unknown>() const { getPathMock, handleMock, removeRuntimeOwnedSshTargetMock, removeHandlerMock } = vi.hoisted( () => ({ getPathMock: vi.fn(), @@ -41,7 +39,7 @@ beforeEach(() => { removeRuntimeOwnedSshTargetMock.mockReset().mockResolvedValue(undefined) removeHandlerMock.mockReset() handleMock.mockImplementation( - (channel: string, handler: (_event: unknown, args: HandlerArgs) => unknown) => { + (channel: string, handler: (_event: unknown, args: { runtimeId: string }) => unknown) => { handlers.set(channel, handler) } ) @@ -53,46 +51,6 @@ afterEach(() => { } }) -it('does not resume a same-id VM on another host or guess between legacy matches', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-vm-runtime-handler-')) - tempDirs.push(userDataPath) - getPathMock.mockReturnValue(userDataPath) - for (const environmentId of ['env-a', 'env-b']) { - upsertEphemeralVmRuntime(userDataPath, { - id: `runtime-${environmentId}`, - recipeId: 'cloud-sandbox', - repoId: 'repo-1', - workspaceId: 'same-workspace', - runtimeEnvironmentId: environmentId, - status: 'suspended', - cleanupStatus: 'not_started', - createdAt: 1, - updatedAt: 1, - recipeResult: { - schemaVersion: 1, - connection: { - type: 'ssh', - projectRoot: '/workspace/repo', - target: { label: 'VM', host: 'host', port: 22, username: 'orca' } - } - } - }) - } - registerEphemeralVmRuntimeHandlers({ getRepo: vi.fn() } as never) - - expect( - await handlers.get('ephemeralVm:resumeWorkspace')?.(null, { - workspaceId: 'same-workspace', - executionHostId: 'runtime:other' - }) - ).toBeNull() - expect( - await handlers.get('ephemeralVm:resumeWorkspace')?.(null, { - workspaceId: 'same-workspace' - }) - ).toBeNull() -}) - it('removes the hidden SSH target when provider cleanup cannot start', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-vm-runtime-handler-')) tempDirs.push(userDataPath) diff --git a/src/main/ipc/ephemeral-vm-runtime-handlers.ts b/src/main/ipc/ephemeral-vm-runtime-handlers.ts index d4619ae0656a..f6210ef7e390 100644 --- a/src/main/ipc/ephemeral-vm-runtime-handlers.ts +++ b/src/main/ipc/ephemeral-vm-runtime-handlers.ts @@ -30,8 +30,6 @@ import { import { getRuntimeRecipeContext } from './ephemeral-vm-recipe-context' import { invalidateRuntimeEnvironmentTransport } from './runtime-environments/runtime-environments' import { attachEphemeralVmRuntimeToWorkspace } from '../ephemeral-vm-runtime-attachment' -import { ephemeralVmMatchesWorkspaceHost } from '../../shared/ephemeral-vm-runtime-host-match' -import type { ExecutionHostId } from '../../shared/execution-host' export type EphemeralVmCleanupCommandResult = { runtimeId: string @@ -155,20 +153,14 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { ipcMain.handle( 'ephemeralVm:suspendWorkspace', - async ( - _event, - args: { workspaceId: string; executionHostId?: ExecutionHostId } - ): Promise => { + async (_event, args: { workspaceId: string }): Promise => { const userDataPath = app.getPath('userData') - const candidates = listEphemeralVmRuntimes(userDataPath).filter( + const runtime = listEphemeralVmRuntimes(userDataPath).find( (entry) => entry.workspaceId === args.workspaceId && entry.status !== 'cleaned' && - entry.status !== 'cleanup_pending' && - (!args.executionHostId || - ephemeralVmMatchesWorkspaceHost(entry, args.workspaceId, args.executionHostId)) + entry.status !== 'cleanup_pending' ) - const runtime = candidates.length === 1 ? candidates[0] : null if (!runtime?.repoId) { return null } @@ -197,20 +189,14 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { ipcMain.handle( 'ephemeralVm:resumeWorkspace', - async ( - _event, - args: { workspaceId: string; executionHostId?: ExecutionHostId } - ): Promise => { + async (_event, args: { workspaceId: string }): Promise => { const userDataPath = app.getPath('userData') - const candidates = listEphemeralVmRuntimes(userDataPath).filter( + const runtime = listEphemeralVmRuntimes(userDataPath).find( (entry) => entry.workspaceId === args.workspaceId && entry.status !== 'cleaned' && - entry.status !== 'cleanup_pending' && - (!args.executionHostId || - ephemeralVmMatchesWorkspaceHost(entry, args.workspaceId, args.executionHostId)) + entry.status !== 'cleanup_pending' ) - const runtime = candidates.length === 1 ? candidates[0] : null if (!runtime?.repoId) { return null } diff --git a/src/main/runtime/construction/runtime-construction-services.ts b/src/main/runtime/construction/runtime-construction-services.ts index 7916f745d9c4..c7fe2510fb8f 100644 --- a/src/main/runtime/construction/runtime-construction-services.ts +++ b/src/main/runtime/construction/runtime-construction-services.ts @@ -254,10 +254,7 @@ export abstract class RuntimeConstructionServices { protected abstract getTrackedDisplayTitleForPty(ptyId: string): string | null protected abstract getLeavesForPty(ptyId: string): RuntimeLeafRecord[] abstract notifyMessageArrived(handle: string, messageType?: string): void - protected abstract resolveWorktreeSelector( - selector: string, - requiredHostId?: ExecutionHostId - ): Promise + protected abstract resolveWorktreeSelector(selector: string): Promise protected abstract refreshPtyWorktreeRecordsWithControllerInventory( resolvedWorktrees: ResolvedWorktree[], targetWorktreeId?: string | null, diff --git a/src/main/runtime/construction/runtime-file-command-state.ts b/src/main/runtime/construction/runtime-file-command-state.ts index bfec2dcb9790..69a74f105628 100644 --- a/src/main/runtime/construction/runtime-file-command-state.ts +++ b/src/main/runtime/construction/runtime-file-command-state.ts @@ -91,10 +91,7 @@ export abstract class OrcaRuntimeWithFileCommands extends OrcaRuntimeWithPreserv abstract requireStore(): RuntimeStore - protected abstract resolveWorktreeSelector( - selector: string, - requiredHostId?: ExecutionHostId - ): Promise + protected abstract resolveWorktreeSelector(selector: string): Promise protected abstract resolveRepoSelector(selector: string): Promise diff --git a/src/main/runtime/rpc/methods/worktree-sleep-method.ts b/src/main/runtime/rpc/methods/worktree-sleep-method.ts deleted file mode 100644 index d7fa40c7c877..000000000000 --- a/src/main/runtime/rpc/methods/worktree-sleep-method.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { WorktreeSleep } from '../../../../shared/rpc-contract/worktree-params' -import { defineMethod } from '../core' -import { resolvePairedCallerHostId } from './paired-caller-host-id' - -export const WORKTREE_SLEEP_METHOD = defineMethod({ - name: 'worktree.sleep', - permission: 'workspace', - params: WorktreeSleep, - handler: async (params, { runtime }) => - runtime.sleepManagedWorktree( - params.worktree, - resolvePairedCallerHostId(() => runtime.listRepos(), params.worktree, params.hostId) - ) -}) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 6fa6d4860824..fc88c59b5055 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -13,7 +13,6 @@ import { createWorktreeWithStartupAgent } from '../../../agent-launch/startup-ag import { defineMethod } from '../core' import { buildManagedWorktreeCreateArgs } from './worktree-create-args' import { resolvePairedCallerHostId } from './paired-caller-host-id' -import { WORKTREE_SLEEP_METHOD } from './worktree-sleep-method' import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context' import { @@ -65,7 +64,12 @@ export const WORKTREE_METHODS = [ worktree: await runtime.showManagedWorktree(params.worktree) }) }), - WORKTREE_SLEEP_METHOD, + defineMethod({ + name: 'worktree.sleep', + permission: 'workspace', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.sleepManagedWorktree(params.worktree) + }), defineMethod({ name: 'worktree.activate', permission: 'workspace', diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index 352fe3814e82..c53b9d23ffa4 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -10,7 +10,6 @@ import type { TerminalPaneSplitSource } from '../../shared/feature-education-tel import type { TerminalRevealIdentity } from '../../shared/terminal-reveal-identity' import type { TerminalPaneRevealTarget } from '../../shared/terminal-pane-reveal' import type { TuiAgent } from '../../shared/tui-agent' -import type { ExecutionHostId } from '../../shared/execution-host' import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' import type { @@ -161,7 +160,7 @@ export type RuntimeNotifier = { tabId: string, options?: { localPtyTeardownOwnedExternally?: boolean; force?: boolean } ): Promise - sleepWorktree(worktreeId: string, executionHostId?: ExecutionHostId): void + sleepWorktree(worktreeId: string): void // Why: a phone opening a worktree wakes its slept agents by asking the host // renderer to run its own navigation-free wake (experimental agent sleep); // the runtime has no in-memory sleeping records or wake authority. Optional to diff --git a/src/main/runtime/workspaces/managed-workspace-operations.ts b/src/main/runtime/workspaces/managed-workspace-operations.ts index 84a6aab747e3..6c7b4dc592b8 100644 --- a/src/main/runtime/workspaces/managed-workspace-operations.ts +++ b/src/main/runtime/workspaces/managed-workspace-operations.ts @@ -28,11 +28,7 @@ import { type WorkspacePortExecutionHostDeps } from '../../ports/workspace-port-execution-host' import { resolveWorktreeHostRouting } from '../worktree-launch-host-repo' -import { - getWorktreeExecutionHostId, - LOCAL_EXECUTION_HOST_ID, - type ExecutionHostId -} from '../../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' export abstract class OrcaRuntimeWithListManagedWorktrees extends OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce { protected abstract resolveTerminalWorkspaceLaunchTarget( @@ -214,22 +210,12 @@ export abstract class OrcaRuntimeWithListManagedWorktrees extends OrcaRuntimeWit ) } - async sleepManagedWorktree( - worktreeSelector: string, - hostId?: ExecutionHostId - ): Promise<{ worktreeId: string; executionHostId: ExecutionHostId }> { - const worktree = await this.resolveWorktreeSelector(worktreeSelector, hostId) - const executionHostId = getWorktreeExecutionHostId( - worktree, - this.store?.getRepo(worktree.repoId) - ) - if (hostId && hostId !== executionHostId) { - throw new Error('selector_not_found') - } + async sleepManagedWorktree(worktreeSelector: string): Promise<{ worktreeId: string }> { + const worktree = await this.resolveWorktreeSelector(worktreeSelector) // Why: sleep is renderer-initiated on desktop (it tears down tab state // before killing PTYs). The notifier tells the renderer to run its own // sleep flow so all cleanup happens in the correct order. - this.notifier?.sleepWorktree(worktree.id, executionHostId) - return { worktreeId: worktree.id, executionHostId } + this.notifier?.sleepWorktree(worktree.id) + return { worktreeId: worktree.id } } } diff --git a/src/main/runtime/workspaces/workspace-selectors.ts b/src/main/runtime/workspaces/workspace-selectors.ts index f06a35eb4244..57c67b33a8e1 100644 --- a/src/main/runtime/workspaces/workspace-selectors.ts +++ b/src/main/runtime/workspaces/workspace-selectors.ts @@ -2,11 +2,7 @@ import { OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree } from '.. import type { ResolvedWorktree } from '../runtime-worktree-path-identity' import { splitWorktreeIdForFilesystem, worktreeIdentityKey } from '../../../shared/worktree/id' import { branchSelectorMatches, runtimePathsEqual } from '../runtime-worktree-path-identity' -import { - getRepoExecutionHostId, - getWorktreeExecutionHostId, - type ExecutionHostId -} from '../../../shared/execution-host' +import { getRepoExecutionHostId, getWorktreeExecutionHostId } from '../../../shared/execution-host' import type { WorktreeLineageInput, WorktreeLineageResolution @@ -34,18 +30,13 @@ export abstract class RuntimeWorkspaceSelectors extends OrcaRuntimeWithResolveBr ...args: Parameters ): ReturnType - protected async resolveWorktreeSelector( - selector: string, - requiredHostId?: ExecutionHostId - ): Promise { + protected async resolveWorktreeSelector(selector: string): Promise { const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(selector) // Why only `id:`: every other selector kind is matched across the whole fleet, and their // `selector_ambiguous` contract is defined over all repos. Scoping those would silently pick a // winner where today they correctly refuse. An `id:` selector already names its repo. if (explicitWorktreeId && !this.hasFreshResolvedWorktreeCache()) { - const scoped = requiredHostId - ? await this.resolveExplicitWorktreeIdScoped(explicitWorktreeId, requiredHostId) - : await this.resolveExplicitWorktreeIdScoped(explicitWorktreeId) + const scoped = await this.resolveExplicitWorktreeIdScoped(explicitWorktreeId) if (scoped) { return scoped } @@ -62,16 +53,10 @@ export abstract class RuntimeWorkspaceSelectors extends OrcaRuntimeWithResolveBr candidates = worktrees.filter((worktree) => worktree.identity?.key === identityKey) } else if (selector.startsWith('id:')) { const worktreeId = explicitWorktreeId ?? selector.slice(3) - const matchesRequiredHost = (worktree: ResolvedWorktree): boolean => - !requiredHostId || - getWorktreeExecutionHostId(worktree, this.store?.getRepo(worktree.repoId)) === - requiredHostId - // Narrow ownership before exact preference so a foreign spelling cannot hide an alias. - const ownedWorktrees = worktrees.filter(matchesRequiredHost) - candidates = ownedWorktrees.filter((worktree) => worktree.id === worktreeId) + candidates = worktrees.filter((worktree) => worktree.id === worktreeId) if (candidates.length === 0) { const comparisonKey = worktreeIdentityKey(worktreeId) - candidates = ownedWorktrees.filter( + candidates = worktrees.filter( (worktree) => worktreeIdentityKey(worktree.id) === comparisonKey ) } @@ -82,7 +67,7 @@ export abstract class RuntimeWorkspaceSelectors extends OrcaRuntimeWithResolveBr repo?.connectionId && this.store?.getWorktreeMeta(worktreeId) ? this.buildResolvedWorktreeFromId(worktreeId) : null - if (fallback !== null && matchesRequiredHost(fallback)) { + if (fallback !== null) { candidates = [fallback] } } diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 9bc8891a846a..de4ca5451cdc 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -220,8 +220,7 @@ export function registerRuntimeWindowLifecycle( closeTerminalPane: (tabId, leafId) => send('ui:closeTerminal', { kind: 'pane', tabId, leafId }), closeTerminalTab: (tabId, options) => requestTerminalTabCloseFromRenderer(mainWindow, tabId, options), - sleepWorktree: (worktreeId, executionHostId) => - send('ui:sleepWorktree', { worktreeId, executionHostId }), + sleepWorktree: (worktreeId) => send('ui:sleepWorktree', { worktreeId }), resumeSleepingAgents: (worktreeId) => send('ui:resumeSleepingAgents', { worktreeId }), terminalFitOverrideChanged: (ptyId, mode, cols, rows) => send('runtime:terminalFitOverrideChanged', { ptyId, mode, cols, rows }), diff --git a/src/preload/api/ephemeral-vm-api.ts b/src/preload/api/ephemeral-vm-api.ts index 704dfce9460d..bacfb239f350 100644 --- a/src/preload/api/ephemeral-vm-api.ts +++ b/src/preload/api/ephemeral-vm-api.ts @@ -3,7 +3,6 @@ import type { PublicKnownRuntimeEnvironment } from '../../shared/runtime-environ import type { EphemeralVmRecipeDoctorResult } from '../../shared/ephemeral-vm-recipes' import type { EphemeralVmRecipeResultWarning } from '../../shared/ephemeral-vm-recipe-diagnostics' import type { EphemeralVmRuntimeRecord } from '../../shared/ephemeral-vm-runtimes' -import type { ExecutionHostId } from '../../shared/execution-host' export type EphemeralVmApi = { listRecipes: (args: { repoId: string }) => Promise<{ @@ -63,14 +62,8 @@ export type EphemeralVmApi = { runtimeId: string workspaceId: string }) => Promise - suspendWorkspace: (args: { - workspaceId: string - executionHostId?: ExecutionHostId - }) => Promise - resumeWorkspace: (args: { - workspaceId: string - executionHostId?: ExecutionHostId - }) => Promise + suspendWorkspace: (args: { workspaceId: string }) => Promise + resumeWorkspace: (args: { workspaceId: string }) => Promise cleanup: (args: { runtimeId: string }) => Promise stopCleanup: (args: { runtimeId: string }) => Promise getCleanupCommand: (args: { runtimeId: string }) => Promise<{ diff --git a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts index 5f63cf53abe4..333ebd8a3bb7 100644 --- a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -1,6 +1,5 @@ import type { AgentSessionAttachmentClipboardTarget } from '../../shared/agent-session-attachments' import { ipcRenderer, webFrame } from 'electron' -import type { ExecutionHostId } from '../../shared/execution-host' import type { RuntimeMobileMarkdownRequest, RuntimeMobileMarkdownResponse @@ -68,9 +67,7 @@ export const uiClipboardAndWindowControlsApi = { respondTerminalTabClose: (response) => { ipcRenderer.send('ui:terminalTabCloseResponse', response) }, - onSleepWorktree: ( - callback: (data: { worktreeId: string; executionHostId?: ExecutionHostId }) => void - ): (() => void) => { + onSleepWorktree: (callback: (data: { worktreeId: string }) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => callback(data) ipcRenderer.on('ui:sleepWorktree', listener) diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index 8eaf4600a7f8..80c46720b6d7 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -4,7 +4,6 @@ import type { JiraSavedFilterState, JiraSavedFilterMutation } from '../../shared import type { MarkdownDocument } from '../../shared/filesystem-entry-types' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { TuiAgent } from '../../shared/tui-agent' -import type { ExecutionHostId } from '../../shared/execution-host' import type { AgentLaunchTabPublishReply, AgentLaunchTabViewerRule, @@ -285,9 +284,7 @@ export type UiCommandEventApi = { onCloseTerminal: (callback: (target: TerminalSurfaceCloseTarget) => void) => () => void onTerminalTabCloseRequest: (callback: (request: TerminalTabCloseRequest) => void) => () => void respondTerminalTabClose: (response: TerminalTabCloseResponse) => void - onSleepWorktree: ( - callback: (data: { worktreeId: string; executionHostId?: ExecutionHostId }) => void - ) => () => void + onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void onWorktreeSleepRequest: (callback: (request: WorktreeSleepRequest) => void) => () => void respondWorktreeSleep: (response: WorktreeSleepResponse) => void onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void) => () => void diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts index 2019d7f62ae9..98bc30b7c4a6 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts @@ -123,8 +123,8 @@ export function useDashboardPopoutBridge(enabled: boolean): void { if (!enabled) { return } - return window.api.dashboard.onSleepWorkspace?.(({ worktreeId, executionHostId }) => { - void runSleepWorktree(worktreeId, executionHostId) + return window.api.dashboard.onSleepWorkspace?.(({ worktreeId }) => { + void runSleepWorktree(worktreeId) }) }, [enabled]) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index 34160a817711..7a01d0a4918d 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -655,11 +655,7 @@ describe('StructuredAgentSessionStatusBridge', () => { it('keeps the host-fed chat row through Sleep and stops offering Sleep once the host lets go', async () => { const state = (): AppState => useAppStore.getState() - const sleepable = (): boolean => - hasSleepableWorkspaceActivity( - { id: 'wt-1', hostId: 'local' }, - { ...state(), chatOwnerState: {} } - ) + const sleepable = (): boolean => hasSleepableWorkspaceActivity('wt-1', state()) render() await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) act(() => feed().emit({ type: 'status', session: summary({ status: 'idle', updatedAt: 2 }) })) diff --git a/src/renderer/src/components/sidebar/sleep-workspace-chat-stop.ts b/src/renderer/src/components/sidebar/sleep-workspace-chat-stop.ts new file mode 100644 index 000000000000..e2fb53e477aa --- /dev/null +++ b/src/renderer/src/components/sidebar/sleep-workspace-chat-stop.ts @@ -0,0 +1,84 @@ +import type { AgentSessionWorkspaceSleepResult } from '../../../../shared/agent-session-workspace-sleep' +import { + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { authorityKey, SELF_LOCAL_AUTHORITY } from '../../../../shared/host-authority' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { resolveOwner } from '@/lib/resolve-owner' +import { runtimeRpcTargetForAuthorityKey } from '@/runtime/runtime-client-target' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { workspacePublishingHosts } from '@/store/slices/worktrees/teardown/host-twin-worktree-purge' +import type { AppState } from '@/store/types' + +export type WorkspaceChatStopReceipt = { + executionHostId: ExecutionHostId + stoppedSessionIds: string[] +} + +export type WorkspaceChatStop = { receipts: WorkspaceChatStopReceipt[]; failed: boolean } + +/** One runtime per copy of the workspace; with no listing row, the workspace's route. */ +function chatHostTargets( + state: AppState, + workspaceId: string +): Map { + const hosts = workspacePublishingHosts(state, workspaceId) + const targets = new Map() + for (const hostId of hosts.length > 0 ? hosts : [undefined]) { + const match = resolveOwner(state, hostId ? { workspaceId, hostId } : { workspaceId }) + const owner = + match.kind === 'resolved' + ? match.owner + : match.kind === 'missing' + ? SELF_LOCAL_AUTHORITY + : null + // Why null for a directly dialed SSH place: no native chat host runs there. + const target = owner ? runtimeRpcTargetForAuthorityKey(authorityKey(owner)) : null + if (target) { + const key = + target.kind === 'local' + ? LOCAL_EXECUTION_HOST_ID + : toRuntimeExecutionHostId(target.environmentId) + targets.set(key, target) + } + } + return targets +} + +/** Each host stops only the chats it runs, so every copy's host is asked. Never rejects. */ +export async function stopWorkspaceChats( + state: AppState, + workspaceId: string +): Promise { + const settled = await Promise.all( + [...chatHostTargets(state, workspaceId)].map(([executionHostId, target]) => + callStructuredAgentSession( + target, + 'agentSession.sleepWorkspace', + { workspaceId } + ).then( + (result) => ({ executionHostId, result, error: undefined }), + (error: unknown) => ({ executionHostId, result: null, error }) + ) + ) + ) + const receipts: WorkspaceChatStopReceipt[] = [] + let failed = false + for (const { executionHostId, result, error } of settled) { + if (!result || result.failures?.length) { + failed = true + console.error('[sleep-worktree] native chat stop incomplete', { + workspaceId, + executionHostId, + failures: result?.failures, + error + }) + } + if (result) { + receipts.push({ executionHostId, stoppedSessionIds: result.stoppedSessionIds }) + } + } + return { receipts, failed } +} diff --git a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts index 6011ef08d086..2459b81a45c3 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts @@ -1,25 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -type ActivationTestState = { - activeWorktreeId: string | null - activeWorkspaceExecutionHostId: string | null - setActiveWorktree: ReturnType - shutdownWorktreeBrowsers: ReturnType - shutdownWorktreeTerminals: ReturnType - unifiedTabsByWorktree: Record - tabsByWorktree: Record - ptyIdsByTabId: Record -} - const mocks = vi.hoisted(() => { - const state: ActivationTestState = { - activeWorktreeId: null, - activeWorkspaceExecutionHostId: 'local', + const state = { + activeWorktreeId: null as string | null, setActiveWorktree: vi.fn((worktreeId: string | null) => { state.activeWorktreeId = worktreeId - if (!worktreeId) { - state.activeWorkspaceExecutionHostId = null - } }), shutdownWorktreeBrowsers: vi.fn().mockResolvedValue(undefined), shutdownWorktreeTerminals: vi.fn(async (worktreeId: string) => { @@ -27,9 +12,10 @@ const mocks = vi.hoisted(() => { state.ptyIdsByTabId[tab.id] = [] } }), - unifiedTabsByWorktree: {}, - tabsByWorktree: {}, - ptyIdsByTabId: {} + tabsByWorktree: {} as Record, + ptyIdsByTabId: {} as Record, + worktreesByRepo: {}, + detectedWorktreesByRepo: {} } const activateAndRevealWorktree = vi.fn() const activateAndRevealFolderWorkspace = vi.fn() @@ -45,8 +31,7 @@ const mocks = vi.hoisted(() => { vi.mock('@/store', () => ({ useAppStore: { - getState: () => mocks.state, - subscribe: () => () => {} + getState: () => mocks.state } })) @@ -56,9 +41,6 @@ vi.mock('@/lib/worktree-activation', () => ({ })) vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) -vi.mock('@/runtime/structured-agent-session-owner', () => ({ - structuredAgentSessionTargetForHost: () => ({ kind: 'local' }) -})) vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: async () => ({ stoppedSessionIds: [] }) })) @@ -80,7 +62,6 @@ describe('sleep flow vs slept-workspace activation', () => { } }) mocks.state.activeWorktreeId = 'wt-parent' - mocks.state.activeWorkspaceExecutionHostId = 'local' mocks.state.setActiveWorktree.mockClear() mocks.state.shutdownWorktreeBrowsers.mockClear().mockResolvedValue(undefined) mocks.state.shutdownWorktreeTerminals.mockClear().mockImplementation(async (worktreeId) => { @@ -103,7 +84,7 @@ describe('sleep flow vs slept-workspace activation', () => { }) it('does not leave behind a delayed parent activation after sleeping children', async () => { - await runSleepWorktrees([{ id: 'wt-parent', executionHostId: 'local' }]) + await runSleepWorktrees(['wt-parent']) expect(mocks.state.activeWorktreeId).toBeNull() expect(mocks.state.ptyIdsByTabId['tab-parent']).toEqual([]) @@ -116,12 +97,7 @@ describe('sleep flow vs slept-workspace activation', () => { revealInSidebar: false }) - await runSleepWorktrees( - ['wt-child-1', 'wt-child-2', 'wt-child-3'].map((id) => ({ - id, - executionHostId: 'local' - })) - ) + await runSleepWorktrees(['wt-child-1', 'wt-child-2', 'wt-child-3']) expect(mocks.activateAndRevealWorktree).toHaveBeenCalledTimes(1) }) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index dc6afe7438be..5f645019af59 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -1,321 +1,418 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { worktree } from './worktree-list-groups-test-fixtures' -import { withRepoHostOwnership } from '@/store/slices/worktrees/listing/worktree-host-ownership' -import type * as Owner from '@/runtime/structured-agent-session-owner' - -type SleepTestState = { - activeWorktreeId: string | null - activeWorkspaceExecutionHostId: string | null - folderWorkspaces: { - id: string - projectGroupId: string - connectionId: string | null - executionHostId: string | null - }[] - projectGroups: { id: string; connectionId: string | null; executionHostId: string | null }[] - worktreesByRepo?: Record - setActiveWorktree: ReturnType - shutdownWorktreeBrowsers: ReturnType - shutdownWorktreeTerminals: ReturnType -} const mocks = vi.hoisted(() => { - const state: SleepTestState = { + const state: { + activeWorktreeId: string | null + setActiveWorktree: ReturnType + shutdownWorktreeBrowsers: ReturnType + shutdownWorktreeTerminals: ReturnType + suppressPtyExit: ReturnType + consumeSuppressedPtyExit: ReturnType + tabsByWorktree: Record + ptyIdsByTabId: Record + worktreesByRepo: Record + detectedWorktreesByRepo: Record + } = { activeWorktreeId: null, - activeWorkspaceExecutionHostId: null, - folderWorkspaces: [], - projectGroups: [], - setActiveWorktree: vi.fn((id: string | null) => { - state.activeWorktreeId = id - if (id === null) { - state.activeWorkspaceExecutionHostId = null - } + setActiveWorktree: vi.fn((worktreeId: string | null) => { + state.activeWorktreeId = worktreeId }), shutdownWorktreeBrowsers: vi.fn().mockResolvedValue(undefined), - shutdownWorktreeTerminals: vi.fn().mockResolvedValue(undefined) + shutdownWorktreeTerminals: vi.fn().mockResolvedValue(undefined), + suppressPtyExit: vi.fn(), + consumeSuppressedPtyExit: vi.fn(), + tabsByWorktree: {} as Record, + ptyIdsByTabId: {} as Record, + worktreesByRepo: {}, + detectedWorktreesByRepo: {} } + const suspendWorkspace = vi.fn().mockResolvedValue(null) + const toastError = vi.fn() + const markWorktreeSleepIntent = vi.fn() + const clearWorktreeSleepIntent = vi.fn() + const callChat = vi.fn() return { + callChat, + clearWorktreeSleepIntent, + markWorktreeSleepIntent, state, - call: vi.fn().mockResolvedValue({ stoppedSessionIds: ['session-1'] }), - target: vi.fn<(host: string) => unknown>(() => ({ kind: 'local' })), - suspend: vi.fn().mockResolvedValue(null), - toast: vi.fn() + suspendWorkspace, + toastError } }) -vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } })) -vi.mock('sonner', () => ({ toast: { error: mocks.toast } })) -vi.mock('@/runtime/structured-agent-session-owner', () => ({ - structuredAgentSessionTargetForHost: mocks.target +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mocks.state + } +})) + +vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) +vi.mock('@/lib/worktree-sleep-intent', () => ({ + clearWorktreeSleepIntent: mocks.clearWorktreeSleepIntent, + markWorktreeSleepIntent: mocks.markWorktreeSleepIntent, + hasWorktreeSleepIntent: () => true, + withWorktreeSleepTeardown: (_worktreeId: string, teardown: () => Promise) => teardown() })) vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mocks.call + callStructuredAgentSession: mocks.callChat })) +import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import { runSleepWorktree, runSleepWorktrees, sleepWorktreesReportingFailures } from './sleep-worktree-flow' -import { - clearWorktreeSleepIntent, - forgetWorktreeSleepIntent, - hasWorktreeSleepTeardown, - hasWorktreeSleepIntent -} from '@/lib/worktree-sleep-intent' - -function deferred() { - let resolve!: (value: T) => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - -describe('workspace sleep flow', () => { - it('keeps paired SSH execution identity through selection, intent and teardown', async () => { - const owner = await vi.importActual('@/runtime/structured-agent-session-owner') - mocks.target.mockImplementationOnce(owner.structuredAgentSessionTargetForHost) - const target = withRepoHostOwnership( - { ...worktree, hostId: 'ssh:target' as const }, - 'runtime:hub' - ) - mocks.state.activeWorktreeId = target.id - mocks.state.activeWorkspaceExecutionHostId = 'ssh:target' - mocks.state.shutdownWorktreeTerminals.mockImplementationOnce(async () => { - expect(mocks.state.activeWorktreeId).toBeNull() - expect(hasWorktreeSleepTeardown(target.id, 'ssh:target')).toBe(true) - }) - const result = await runSleepWorktrees([target]) - expect(result[0]).toMatchObject({ - executionHostId: 'ssh:target', - completed: true - }) - expect(hasWorktreeSleepIntent(target.id, 'ssh:target')).toBe(true) - expect(hasWorktreeSleepIntent(target.id, 'runtime:hub')).toBe(false) - expect(mocks.call).not.toHaveBeenCalled() - }) +describe('runSleepWorktree', () => { beforeEach(() => { + vi.unstubAllGlobals() vi.stubGlobal('window', { - api: { ephemeralVm: { suspendWorkspace: mocks.suspend } } + api: { + ephemeralVm: { + suspendWorkspace: mocks.suspendWorkspace + } + }, + requestAnimationFrame: vi.fn() }) - for (const id of ['wt-1', 'wt-2', 'folder:folder-1']) { - forgetWorktreeSleepIntent(id) - } - mocks.state.activeWorktreeId = null - mocks.state.activeWorkspaceExecutionHostId = null - mocks.state.folderWorkspaces = [] - mocks.state.projectGroups = [] - mocks.state.worktreesByRepo = undefined mocks.state.setActiveWorktree.mockClear() mocks.state.shutdownWorktreeBrowsers.mockClear().mockResolvedValue(undefined) mocks.state.shutdownWorktreeTerminals.mockClear().mockResolvedValue(undefined) - mocks.call.mockClear().mockResolvedValue({ stoppedSessionIds: ['session-1'] }) - mocks.target.mockClear() - mocks.suspend.mockClear().mockResolvedValue(null) - mocks.toast.mockClear() + mocks.state.suppressPtyExit.mockClear() + mocks.state.consumeSuppressedPtyExit.mockClear() + mocks.suspendWorkspace.mockClear().mockResolvedValue(null) + mocks.markWorktreeSleepIntent.mockClear() + mocks.clearWorktreeSleepIntent.mockClear() + mocks.toastError.mockClear() + mocks.state.activeWorktreeId = null + mocks.state.tabsByWorktree = {} + mocks.state.ptyIdsByTabId = {} + mocks.state.worktreesByRepo = {} + mocks.callChat.mockReset().mockResolvedValue({ stoppedSessionIds: [] }) }) - it('issues native stop before panels and keeps the chat tab identity outside teardown', async () => { - const native = deferred<{ stoppedSessionIds: string[] }>() - mocks.call.mockReturnValueOnce(native.promise) - const sleep = runSleepWorktree('wt-1', 'local') - expect(mocks.call).toHaveBeenCalledWith({ kind: 'local' }, 'agentSession.sleepWorkspace', { - workspaceId: 'wt-1' - }) - expect(mocks.state.shutdownWorktreeBrowsers).not.toHaveBeenCalled() - native.resolve({ stoppedSessionIds: ['session-1'] }) - await expect(sleep).resolves.toEqual([ - { - workspaceId: 'wt-1', - executionHostId: 'local', - stoppedSessionIds: ['session-1'], - completed: true - } - ]) - expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenCalledBefore( - mocks.state.shutdownWorktreeTerminals - ) - expect(mocks.suspend).toHaveBeenCalledWith({ - workspaceId: 'wt-1', - executionHostId: 'local' + it('tears down browsers before terminals on the sleep path', async () => { + mocks.state.activeWorktreeId = 'wt-1' + + await runSleepWorktree('wt-1') + + // Why: browsers must run first so destroyPersistentWebview can unregister + // the Chromium guests while browserTabsByWorktree/browserPagesByWorkspace + // are still populated. If terminals ran first and kept its old + // browserTabsByWorktree delete, browsers would no-op and leak webviews. + expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenCalledWith('wt-1') + expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenCalledWith('wt-1', { + keepIdentifiers: true }) + expect(mocks.suspendWorkspace).toHaveBeenCalledWith({ workspaceId: 'wt-1' }) + const browsersCallOrder = mocks.state.shutdownWorktreeBrowsers.mock.invocationCallOrder[0] + const terminalsCallOrder = mocks.state.shutdownWorktreeTerminals.mock.invocationCallOrder[0] + const suspendCallOrder = mocks.suspendWorkspace.mock.invocationCallOrder[0] + expect(browsersCallOrder).toBeLessThan(terminalsCallOrder) + expect(terminalsCallOrder).toBeLessThan(suspendCallOrder) }) - it('keeps successfully stopped terminals asleep on a native RPC failure', async () => { - mocks.call.mockRejectedValueOnce(new Error('method_not_found')) - const result = await runSleepWorktree('wt-1', 'local') - expect(result[0]).toMatchObject({ completed: false, stoppedSessionIds: [] }) - expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenCalled() - expect(mocks.suspend).toHaveBeenCalled() - expect(hasWorktreeSleepIntent('wt-1', 'local')).toBe(true) - expect(mocks.toast).toHaveBeenCalledTimes(1) + it('clears activeWorktreeId before teardown when the slept worktree is active', async () => { + mocks.state.activeWorktreeId = 'wt-1' + + await runSleepWorktree('wt-1') + + expect(mocks.state.setActiveWorktree).toHaveBeenCalledWith(null) + const activeClear = mocks.state.setActiveWorktree.mock.invocationCallOrder[0] + const browsersCall = mocks.state.shutdownWorktreeBrowsers.mock.invocationCallOrder[0] + expect(activeClear).toBeLessThan(browsersCall) }) - it('keeps proved chat stops but leaves the workspace incomplete when another stop is unverified', async () => { - mocks.call.mockResolvedValueOnce({ - stoppedSessionIds: ['session-1'], - failures: [{ sessionId: 'session-2', reason: 'stop-unverifiable' }] + it('marks sleep intent before clearing the active slept worktree and keeps it after teardown', async () => { + mocks.state.activeWorktreeId = 'wt-1' + + await runSleepWorktree('wt-1') + + expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') + const markCall = mocks.markWorktreeSleepIntent.mock.invocationCallOrder[0] + const activeClear = mocks.state.setActiveWorktree.mock.invocationCallOrder[0] + expect(markCall).toBeLessThan(activeClear) + // Why: the marker outlives a successful sleep so mounted panes stay cold until an explicit wake. + expect(mocks.clearWorktreeSleepIntent).not.toHaveBeenCalled() + }) + + it('preserves active row position through section-scoped sidebar row ids', async () => { + const requestAnimationFrame = vi.fn(() => 1) + const scroller = { + dispatchEvent: vi.fn(), + scrollHeight: 100, + scrollTop: 0 + } + const row = { + closest: (selector: string) => (selector === '[data-worktree-virtual-row]' ? row : null), + getBoundingClientRect: () => ({ top: 42 }) + } + const option = { + dataset: { worktreeId: 'wt-1' }, + closest: (selector: string) => (selector === '[data-worktree-virtual-row]' ? row : null), + querySelector: () => null + } + vi.stubGlobal('document', { + querySelector: (selector: string) => + selector === '[data-worktree-sidebar]' ? scroller : null, + querySelectorAll: (selector: string) => (selector === '[data-worktree-id]' ? [option] : []) }) + vi.stubGlobal('window', { requestAnimationFrame }) + mocks.state.activeWorktreeId = 'wt-1' - const result = await runSleepWorktree('wt-1', 'local') - expect(result[0]).toMatchObject({ completed: false, stoppedSessionIds: ['session-1'] }) - expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenCalled() - expect(mocks.toast).toHaveBeenCalledTimes(1) + await runSleepWorktree('wt-1') + + expect(requestAnimationFrame).toHaveBeenCalledTimes(1) }) - it('reports an unverified chat stop to a caller that answers for it, without a toast', async () => { - mocks.call - .mockResolvedValueOnce({ - stoppedSessionIds: [], - failures: [{ sessionId: 'session-1', reason: 'stop-unverifiable' }] - }) - .mockResolvedValueOnce({ stoppedSessionIds: ['session-2'] }) + it('anchors sleep restoration to the natural duplicate row when no primary row is marked', async () => { + const requestAnimationFrame = vi.fn(() => 1) + const scroller = { + dispatchEvent: vi.fn(), + scrollHeight: 100, + scrollTop: 0 + } + const pinnedGetBoundingClientRect = vi.fn(() => ({ top: 10 })) + const naturalGetBoundingClientRect = vi.fn(() => ({ top: 42 })) + const pinnedRow = { + getBoundingClientRect: pinnedGetBoundingClientRect + } + const naturalRow = { + getBoundingClientRect: naturalGetBoundingClientRect + } + const pinnedOption = { + dataset: { worktreeId: 'wt-1', worktreeRowKey: 'pinned:wt-1' }, + closest: (selector: string) => + selector === '[data-worktree-virtual-row]' ? pinnedRow : null, + querySelector: () => null + } + const naturalOption = { + dataset: { worktreeId: 'wt-1', worktreeRowKey: 'all:wt-1' }, + closest: (selector: string) => + selector === '[data-worktree-virtual-row]' ? naturalRow : null, + querySelector: () => null + } + vi.stubGlobal('document', { + querySelector: (selector: string) => + selector === '[data-worktree-sidebar]' ? scroller : null, + querySelectorAll: (selector: string) => + selector === '[data-worktree-id]' ? [pinnedOption, naturalOption] : [] + }) + vi.stubGlobal('window', { requestAnimationFrame }) + mocks.state.activeWorktreeId = 'wt-1' - const outcome = await sleepWorktreesReportingFailures([ - { id: 'wt-1', executionHostId: 'local' }, - { id: 'wt-2', executionHostId: 'local' } - ]) + await runSleepWorktree('wt-1') - expect(outcome.failedWorktreeIds).toEqual(['wt-1']) - expect(outcome.errors).toHaveLength(1) - expect(mocks.toast).not.toHaveBeenCalled() + expect(naturalGetBoundingClientRect).toHaveBeenCalled() + expect(pinnedGetBoundingClientRect).not.toHaveBeenCalled() }) - it('keeps a superseded chat quiet while completing other workspace cleanup', async () => { - mocks.call.mockResolvedValueOnce({ stoppedSessionIds: [] }) + it('leaves activeWorktreeId alone and marks a background worktree slept', async () => { + mocks.state.activeWorktreeId = 'wt-other' - const result = await runSleepWorktree('wt-1', 'local') - expect(result[0]).toMatchObject({ completed: true, stoppedSessionIds: [] }) - expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenCalled() - expect(mocks.toast).not.toHaveBeenCalled() + await runSleepWorktree('wt-1') + + expect(mocks.state.setActiveWorktree).not.toHaveBeenCalled() + expect(mocks.state.suppressPtyExit).not.toHaveBeenCalled() + expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') + expect(mocks.clearWorktreeSleepIntent).not.toHaveBeenCalled() }) - it('does not run delayed panels or steal a newer selection after an explicit wake', async () => { - mocks.state.activeWorktreeId = 'wt-1' - mocks.state.activeWorkspaceExecutionHostId = 'local' - const native = deferred<{ stoppedSessionIds: string[] }>() - mocks.call.mockReturnValueOnce(native.promise) - const sleep = runSleepWorktree('wt-1', 'local') - expect(mocks.state.setActiveWorktree).toHaveBeenCalledWith(null) - clearWorktreeSleepIntent('wt-1', 'local') + it('leaves a worktree the user activated mid-batch awake', async () => { + let releaseFirst: () => void = () => {} + mocks.state.shutdownWorktreeBrowsers.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirst = resolve + }) + ) + + const run = runSleepWorktrees(['wt-1', 'wt-2']) + await Promise.resolve() + // Why: the user clicked wt-2 while wt-1 was tearing down; sleeping it anyway + // must not leave the active workspace marked with no clear pending. mocks.state.activeWorktreeId = 'wt-2' - native.resolve({ stoppedSessionIds: ['session-1'] }) - expect((await sleep)[0].completed).toBe(false) - expect(mocks.state.shutdownWorktreeBrowsers).not.toHaveBeenCalled() - expect(mocks.state.activeWorktreeId).toBe('wt-2') - }) + releaseFirst() + await run - it('issues every host call before a slow sibling permits panel teardown', async () => { - const first = deferred<{ stoppedSessionIds: string[] }>() - const second = deferred<{ stoppedSessionIds: string[] }>() - mocks.call.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) - const sleep = runSleepWorktrees([ - { id: 'wt-1', executionHostId: 'local' }, - { id: 'wt-2', executionHostId: 'local' } - ]) - expect(mocks.call).toHaveBeenCalledTimes(2) - expect(mocks.state.shutdownWorktreeBrowsers).not.toHaveBeenCalled() - first.resolve({ stoppedSessionIds: ['one'] }) - second.resolve({ stoppedSessionIds: ['two'] }) - expect((await sleep).map((result) => result.stoppedSessionIds)).toEqual([['one'], ['two']]) + expect(mocks.clearWorktreeSleepIntent).toHaveBeenLastCalledWith('wt-2') }) - it('routes an inherited folder lane to the host rendered for its project group', async () => { - mocks.state.folderWorkspaces = [ - { - id: 'folder-1', - projectGroupId: 'group-1', - connectionId: null, - executionHostId: null - } - ] - mocks.state.projectGroups = [ - { - id: 'group-1', - connectionId: null, - executionHostId: 'runtime:env-1' - } - ] - const result = await runSleepWorktrees([{ id: 'folder:folder-1', hostId: 'local' }]) - expect(result[0].executionHostId).toBe('runtime:env-1') - expect(mocks.target).toHaveBeenCalledWith('runtime:env-1') + it('marks each worktree only when its own teardown starts', async () => { + let releaseFirst: () => void = () => {} + mocks.state.shutdownWorktreeBrowsers.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirst = resolve + }) + ) + + const run = runSleepWorktrees(['wt-1', 'wt-2']) + await Promise.resolve() + + // Why: wt-2 is still awake while wt-1 tears down; marking it early would + // hold its panes cold and swallow its activity. + expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') + expect(mocks.markWorktreeSleepIntent).not.toHaveBeenCalledWith('wt-2') + releaseFirst() + await run + expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-2') }) - it('uses the selected folder owner when two hosts publish the same folder id', async () => { - mocks.state.folderWorkspaces = [ - { - id: 'folder-1', - projectGroupId: 'local-group', - connectionId: null, - executionHostId: 'local' - }, - { - id: 'folder-1', - projectGroupId: 'remote-group', - connectionId: null, - executionHostId: 'runtime:env-1' - } - ] - mocks.state.projectGroups = [ - { id: 'local-group', connectionId: null, executionHostId: 'local' }, - { id: 'remote-group', connectionId: null, executionHostId: 'runtime:env-1' } - ] + it('surfaces a toast and skips terminals when browsers throws', async () => { + mocks.state.activeWorktreeId = 'wt-1' + mocks.state.shutdownWorktreeBrowsers.mockRejectedValueOnce(new Error('boom')) + mocks.state.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } + mocks.state.ptyIdsByTabId = { 'tab-1': ['pty-1'] } + + await runSleepWorktree('wt-1') - const result = await runSleepWorktrees([{ id: 'folder:folder-1', hostId: 'runtime:env-1' }]) - expect(result[0].executionHostId).toBe('runtime:env-1') - expect(mocks.target).toHaveBeenCalledWith('runtime:env-1') + expect(mocks.state.shutdownWorktreeTerminals).not.toHaveBeenCalled() + expect(mocks.suspendWorkspace).not.toHaveBeenCalled() + expect(mocks.clearWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') + expect(mocks.state.setActiveWorktree).toHaveBeenLastCalledWith('wt-1') + expect(mocks.toastError).toHaveBeenCalledWith( + 'Failed to sleep workspace', + expect.objectContaining({ + description: + 'The workspace was kept open. Try again; if the problem continues, check the host connection.' + }) + ) }) - it('clears a matching legacy selection before terminal shutdown', async () => { + it('restores the active workspace when terminal convergence fails', async () => { mocks.state.activeWorktreeId = 'wt-1' - mocks.state.activeWorkspaceExecutionHostId = null - mocks.state.worktreesByRepo = { - 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', hostId: 'local' }] - } - mocks.state.shutdownWorktreeTerminals.mockImplementationOnce(async () => { - expect(mocks.state.activeWorktreeId).toBeNull() - }) + mocks.state.shutdownWorktreeTerminals.mockRejectedValueOnce( + new Error('terminal_worktree_sleep_still_live') + ) - await runSleepWorktree('wt-1', 'local') - expect(mocks.state.setActiveWorktree).toHaveBeenCalledWith(null) + await runSleepWorktree('wt-1') + + expect(mocks.state.setActiveWorktree.mock.calls).toEqual([[null], ['wt-1']]) + expect(mocks.suspendWorkspace).not.toHaveBeenCalled() + expect(mocks.toastError).toHaveBeenCalledWith( + 'Failed to sleep workspace', + expect.objectContaining({ + description: + 'The host could not confirm terminal shutdown. The workspace was kept open; check the connection and try again.' + }) + ) }) - it('invalidates an early completion when the workspace wakes during a slow sibling', async () => { - const slow = deferred<{ stoppedSessionIds: string[] }>() - mocks.call - .mockResolvedValueOnce({ stoppedSessionIds: ['first'] }) - .mockReturnValueOnce(slow.promise) - const sleep = runSleepWorktrees([ - { id: 'wt-1', executionHostId: 'local' }, - { id: 'wt-2', executionHostId: 'local' } - ]) - await vi.waitFor(() => - expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenCalledWith('wt-1', { - keepIdentifiers: true + it('continues sleeping later worktrees when one selected worktree fails', async () => { + mocks.state.shutdownWorktreeBrowsers.mockImplementation((worktreeId: string) => { + if (worktreeId === 'wt-1') { + return Promise.reject(new Error('first failed')) + } + return Promise.resolve() + }) + + await runSleepWorktrees(['wt-1', 'wt-2']) + + expect(mocks.state.shutdownWorktreeTerminals).not.toHaveBeenCalledWith('wt-1', { + keepIdentifiers: true + }) + expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenCalledWith('wt-2') + expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenCalledWith('wt-2', { + keepIdentifiers: true + }) + expect(mocks.suspendWorkspace).toHaveBeenCalledWith({ workspaceId: 'wt-2' }) + expect(mocks.toastError).toHaveBeenCalledWith( + 'Failed to sleep some workspaces', + expect.objectContaining({ + description: + 'The workspace was kept open. Try again; if the problem continues, check the host connection.' }) ) - clearWorktreeSleepIntent('wt-1', 'local') + }) + + it('reports failed worktrees to a caller that answers for them, without a toast', async () => { + mocks.state.shutdownWorktreeBrowsers.mockImplementation((worktreeId: string) => + worktreeId === 'wt-1' ? Promise.reject(new Error('first failed')) : Promise.resolve() + ) + + const outcome = await sleepWorktreesReportingFailures(['wt-1', 'wt-2']) + + expect(outcome.failedWorktreeIds).toEqual(['wt-1']) + expect(outcome.errors).toHaveLength(1) + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('sleeps multiple worktrees and clears active only once when included', async () => { mocks.state.activeWorktreeId = 'wt-2' - slow.resolve({ stoppedSessionIds: ['second'] }) - - expect(await sleep).toEqual([ - { - workspaceId: 'wt-1', - executionHostId: 'local', - stoppedSessionIds: ['first'], - completed: false - }, - { - workspaceId: 'wt-2', - executionHostId: 'local', - stoppedSessionIds: ['second'], + + await runSleepWorktrees(['wt-1', 'wt-2']) + + expect(mocks.state.setActiveWorktree).toHaveBeenCalledTimes(1) + expect(mocks.state.setActiveWorktree).toHaveBeenCalledWith(null) + expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenNthCalledWith(1, 'wt-1') + expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenNthCalledWith(1, 'wt-1', { + keepIdentifiers: true + }) + expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenNthCalledWith(2, 'wt-2') + expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenNthCalledWith(2, 'wt-2', { + keepIdentifiers: true + }) + expect(mocks.suspendWorkspace).toHaveBeenNthCalledWith(1, { workspaceId: 'wt-1' }) + expect(mocks.suspendWorkspace).toHaveBeenNthCalledWith(2, { workspaceId: 'wt-2' }) + }) + + const twinId = 'repo-1::/wt' + const twinRows = { + 'repo-1': [ + { id: twinId, repoId: 'repo-1', hostId: 'local' }, + { id: twinId, repoId: 'repo-1', hostId: 'runtime:env-a' } + ] + } + + it("stops a workspace's chats on every host that publishes a copy", async () => { + mocks.state.worktreesByRepo = twinRows + mocks.state.tabsByWorktree = { [twinId]: [{ id: 'tab-1' }] } + mocks.state.ptyIdsByTabId = { 'tab-1': [toRemoteRuntimePtyId('pty-1', 'env-a')] } + mocks.callChat.mockImplementation(async (target: { kind: string }) => ({ + stoppedSessionIds: [`chat-${target.kind}`] + })) + + const results = await runSleepWorktrees([twinId]) + + expect(results).toEqual([ + expect.objectContaining({ executionHostId: 'local', stoppedSessionIds: ['chat-local'] }), + expect.objectContaining({ + executionHostId: 'runtime:env-a', + stoppedSessionIds: ['chat-environment'], completed: true - } + }) ]) }) + it('stops nothing when it cannot tell which copy runs the terminals', async () => { + mocks.state.worktreesByRepo = twinRows + + await runSleepWorktree(twinId) + + expect(mocks.callChat).not.toHaveBeenCalled() + expect(mocks.state.shutdownWorktreeBrowsers).not.toHaveBeenCalled() + expect(mocks.toastError).toHaveBeenCalledOnce() + }) + + it('reports an unconfirmed chat stop in one toast and keeps stopped terminals asleep', async () => { + mocks.state.activeWorktreeId = 'wt-1' + mocks.callChat.mockRejectedValue(new Error('host offline')) + + const results = await runSleepWorktree('wt-1') + + expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenCalled() + expect(mocks.clearWorktreeSleepIntent).not.toHaveBeenCalled() + expect(mocks.state.setActiveWorktree.mock.calls).toEqual([[null]]) + expect(results).toEqual([]) + expect(mocks.toastError).toHaveBeenCalledOnce() + expect(mocks.toastError).toHaveBeenCalledWith('Failed to sleep workspace', { + description: + 'The host could not confirm the chat stopped. Other panels may have closed; check the connection and try again.' + }) + }) + it('sleeps a workspace named by both copies of a twin once', async () => { await runSleepWorktrees(['wt-1', 'wt-1']) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index 452c04de778d..a04d5bd8c036 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -1,46 +1,23 @@ import { toast } from 'sonner' -import type { AgentSessionWorkspaceSleepResult } from '../../../../shared/agent-session-workspace-sleep' import { useAppStore } from '@/store' import { clearWorktreeSleepIntent, - isCurrentWorktreeSleepIntent, + hasWorktreeSleepIntent, markWorktreeSleepIntent, withWorktreeSleepTeardown } from '@/lib/worktree-sleep-intent' -import { preserveSidebarWorktreePosition } from './sleep-worktree-sidebar-position' import { translate } from '@/i18n/i18n' -import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' -import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' -import { - LOCAL_EXECUTION_HOST_ID, - parseExecutionHostId, - toRuntimeExecutionHostId, - type ExecutionHostId -} from '../../../../shared/execution-host' -import { parseWorkspaceKey } from '../../../../shared/workspace-scope' -import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route' -import { routeForOwner } from '@/lib/worktree-owner-route' -import { - findIndexedFolderWorkspaceOwner, - findIndexedProjectGroupOwner -} from '@/lib/worktree-runtime-owner-index' -import { getFolderWorkspaceHostId } from './folder-workspace-host-id' -import type { AppState } from '@/store/types' - -export type WorkspaceSleepTarget = - | string - | { - id: string - repoId?: string - executionHostId?: ExecutionHostId - hostId?: ExecutionHostId - runtimeOwnerEnvironmentId?: string - } +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { resolveWorktreeTerminalTeardownOwner } from '@/store/terminals/terminal-shutdown' +import { preserveSidebarWorktreePosition } from './sleep-worktree-sidebar-position' +import { stopWorkspaceChats } from './sleep-workspace-chat-stop' +/** One per host asked to stop the workspace's chats. */ export type WorkspaceSleepResult = { workspaceId: string executionHostId: ExecutionHostId stoppedSessionIds: string[] + /** The whole workspace slept and is still asleep. */ completed: boolean } @@ -57,11 +34,8 @@ export type WorkspaceSleepResult = { * Centralizing the sequence here keeps that safety invariant in one place so * a new caller can't accidentally skip it. */ -export async function runSleepWorktree( - worktreeId: string, - executionHostId?: ExecutionHostId -): Promise { - return runSleepWorktrees([executionHostId ? { id: worktreeId, executionHostId } : worktreeId]) +export async function runSleepWorktree(worktreeId: string): Promise { + return runSleepWorktrees([worktreeId]) } function describeSleepFailure(error: unknown): string { @@ -103,61 +77,14 @@ function describeChatStopFailure(): string { ) } -function resolveSleepTarget( - state: AppState, - target: WorkspaceSleepTarget -): { workspaceId: string; executionHostId: ExecutionHostId } | null { - const workspaceId = typeof target === 'string' ? target : target.id - const explicit = typeof target === 'string' ? undefined : target.executionHostId - if (explicit) { - const host = parseExecutionHostId(explicit) - return host ? { workspaceId, executionHostId: host.id } : null - } - const ownerRoute = typeof target === 'string' ? null : routeForOwner(target) - const selectedHost = - parseExecutionHostId(ownerRoute?.executionHostId)?.id ?? - (ownerRoute?.runtimeEnvironmentId - ? toRuntimeExecutionHostId(ownerRoute.runtimeEnvironmentId) - : undefined) - const scope = parseWorkspaceKey(workspaceId) - if (scope?.type === 'folder') { - const folder = findIndexedFolderWorkspaceOwner( - state.folderWorkspaces, - scope.folderWorkspaceId, - selectedHost - ) - if (!folder) { - return null - } - const group = - (selectedHost - ? findIndexedProjectGroupOwner(state.projectGroups, folder.projectGroupId, selectedHost) - : null) ?? findIndexedProjectGroupOwner(state.projectGroups, folder.projectGroupId) - const executionHostId = getFolderWorkspaceHostId( - folder, - group ?? { connectionId: null, executionHostId: null }, - parseExecutionHostId(state.restoredRuntimeHostIdByWorkspaceSessionKey?.[workspaceId])?.id ?? - LOCAL_EXECUTION_HOST_ID - ) - return { workspaceId, executionHostId } - } - if (selectedHost) { - return { workspaceId, executionHostId: selectedHost } - } - const route = resolveWorktreeOperationRouteResult(state, workspaceId) - return route.kind === 'resolved' && route.route.executionHostId - ? { workspaceId, executionHostId: route.route.executionHostId } - : null -} - export async function runSleepWorktrees( - targets: readonly WorkspaceSleepTarget[] + worktreeIds: readonly string[] ): Promise { - const { errors, results } = await sleepWorktreesReportingFailures(targets) + const { errors, results } = await sleepWorktreesReportingFailures(worktreeIds) if (errors.length > 0) { // Why: callers are fire-and-forget; surface actionable teardown failures after restoring retryable UI state. toast.error( - targets.length === 1 + new Set(worktreeIds).size === 1 ? translate( 'auto.components.sidebar.sleep.worktree.flow.8bc3fc0671', 'Failed to sleep workspace' @@ -166,14 +93,16 @@ export async function runSleepWorktrees( 'auto.components.sidebar.sleep.worktree.flow.c460fecc4a', 'Failed to sleep some workspaces' ), - { description: errors.join('\n') } + { + description: errors.join('\n') + } ) } return results } export type WorktreeSleepOutcome = { - /** Every workspace this call did not verifiably sleep, including an unconfirmed chat stop. */ + /** Every workspace not verifiably slept, including one whose chat stop went unconfirmed. */ failedWorktreeIds: string[] errors: string[] results: WorkspaceSleepResult[] @@ -181,144 +110,114 @@ export type WorktreeSleepOutcome = { /** The sleep without the toast, for callers that report failures themselves. */ export async function sleepWorktreesReportingFailures( - targets: readonly WorkspaceSleepTarget[] + requestedWorktreeIds: readonly string[] ): Promise { - if (targets.length === 0) { + // Why unique: both copies of a twin share one id, and sleeping it sleeps the whole workspace. + const worktreeIds = [...new Set(requestedWorktreeIds)] + if (worktreeIds.length === 0) { return { failedWorktreeIds: [], errors: [], results: [] } } - const state = useAppStore.getState() - const errors: string[] = [] - const failedWorktreeIds = new Set() - const seen = new Set() - const resolved = targets.flatMap((target) => { - const selected = resolveSleepTarget(state, target) - if (!selected) { - failedWorktreeIds.add(typeof target === 'string' ? target : target.id) - errors.push(describeSleepFailure(new Error('workspace owner is unavailable'))) - return [] - } - const key = `${selected.executionHostId}\0${selected.workspaceId}` - if (seen.has(key)) { - return [] - } - seen.add(key) - return [selected] - }) - const marked = resolved.map((target) => ({ - ...target, - token: markWorktreeSleepIntent(target.workspaceId, target.executionHostId) - })) - const noNativeSessions: AgentSessionWorkspaceSleepResult = { stoppedSessionIds: [] } - const nativeCalls = marked.map(({ workspaceId, executionHostId }) => { - const nativeTarget = structuredAgentSessionTargetForHost(executionHostId) - return nativeTarget - ? callStructuredAgentSession( - nativeTarget, - 'agentSession.sleepWorkspace', - { workspaceId } - ).then( - (value) => ({ ok: true as const, value }), - (error: unknown) => ({ ok: false as const, error }) - ) - : Promise.resolve({ ok: true as const, value: noNativeSessions }) - }) - const activeRoute = state.activeWorktreeId - ? resolveWorktreeOperationRouteResult(state, state.activeWorktreeId) - : null - const activeHost = - state.activeWorkspaceExecutionHostId ?? - (activeRoute?.kind === 'resolved' ? activeRoute.route.executionHostId : null) - const selected = marked.find( - (target) => - state.activeWorktreeId === target.workspaceId && activeHost === target.executionHostId - ) - if (selected) { - const restoreSidebarPosition = preserveSidebarWorktreePosition(selected.workspaceId) - state.setActiveWorktree(null) + const { + activeWorktreeId, + setActiveWorktree, + shutdownWorktreeBrowsers, + shutdownWorktreeTerminals + } = useAppStore.getState() + const sleptActiveWorktreeId = + activeWorktreeId && worktreeIds.includes(activeWorktreeId) ? activeWorktreeId : null + if (sleptActiveWorktreeId) { + const restoreSidebarPosition = preserveSidebarWorktreePosition(sleptActiveWorktreeId) + // Why: clearing the active workspace can unmount TerminalPanes before + // shutdownWorktreeTerminals writes PTY suppressions; mark first so those + // exits do not stamp activity. Kept off the store so it cannot disturb the + // sidebar's scroll restoration. + markWorktreeSleepIntent(sleptActiveWorktreeId) + setActiveWorktree(null) restoreSidebarPosition() } - const attempts = await Promise.all( - marked.map(async (target, index): Promise => { - const { workspaceId, executionHostId, token } = target - const current = (): boolean => - isCurrentWorktreeSleepIntent(workspaceId, executionHostId, token) - let stoppedSessionIds: string[] = [] - let nativeCompleted = true - let panelsCompleted = true - let terminalsStopped = false - const native = await nativeCalls[index] - if (native.ok) { - stoppedSessionIds = native.value.stoppedSessionIds - if (native.value.failures?.length) { - console.error('[sleep-worktree] native chat stop incomplete', { - workspaceId, - failures: native.value.failures - }) - nativeCompleted = false - errors.push(describeChatStopFailure()) - } - } else { - console.error('[sleep-worktree] native chat stop failed', { - workspaceId, - error: native.error - }) - nativeCompleted = false - errors.push(describeChatStopFailure()) - } - if (current()) { - try { - await state.shutdownWorktreeBrowsers(workspaceId) - } catch (error) { - console.error('[sleep-worktree] browser shutdown failed', { workspaceId, error }) - panelsCompleted = false - errors.push(describeSleepFailure(error)) - } - } else { - panelsCompleted = false - } - if (current() && panelsCompleted) { - try { - await withWorktreeSleepTeardown( - workspaceId, - () => state.shutdownWorktreeTerminals(workspaceId, { keepIdentifiers: true }), - executionHostId - ) - terminalsStopped = true - } catch (error) { - console.error('[sleep-worktree] terminal shutdown failed', { workspaceId, error }) - panelsCompleted = false - errors.push(describeSleepFailure(error)) - } - } + const errors: string[] = [] + const failedWorktreeIds = new Set() + // Why apart from failedWorktreeIds: stopped terminals stay stopped when only a chat stop fails. + const chatFailedIds = new Set() + const results: WorkspaceSleepResult[] = [] + try { + for (const worktreeId of worktreeIds) { + // Why: the marker outlives teardown so the panes left mounted stay cold + // until an explicit wake (#10205); mark per workspace so an earlier + // slow teardown never leaves a later, still-awake one marked. + markWorktreeSleepIntent(worktreeId) + // Why first: the terminal teardown would refuse later, after browsers and chats stopped. if ( - current() && - terminalsStopped && - typeof window !== 'undefined' && - window.api?.ephemeralVm?.suspendWorkspace + resolveWorktreeTerminalTeardownOwner(useAppStore.getState(), worktreeId).kind === 'refused' ) { - try { - await window.api.ephemeralVm.suspendWorkspace({ workspaceId, executionHostId }) - } catch (error) { - console.error('[sleep-worktree] host suspension failed', { workspaceId, error }) - panelsCompleted = false - errors.push(describeSleepFailure(error)) + failedWorktreeIds.add(worktreeId) + errors.push(describeSleepFailure(new Error('ambiguous_terminal_owner'))) + continue + } + const chatStop = stopWorkspaceChats(useAppStore.getState(), worktreeId) + try { + // Why: sleep mirrors removeWorktree's shutdown sequence — browsers first + // so destroyPersistentWebview unregisters the Chromium guests before any + // other teardown runs, terminals second so the PTY kill uses the same + // ordering on both paths. Without the browser thunk here, sleep leaks + // browserPagesByWorkspace entries and live webviews for the slept worktree. + await withWorktreeSleepTeardown(worktreeId, () => shutdownWorktreeBrowsers(worktreeId)) + } catch (err) { + console.error('[sleep-worktree] browser shutdown failed', { worktreeId, error: err }) + failedWorktreeIds.add(worktreeId) + errors.push(describeSleepFailure(err)) + continue + } + try { + // Why: sleep is reversible — the tab record stays in tabsByWorktree, the + // layout stays in terminalLayoutsByTabId, only the live PTY processes are + // released. keepIdentifiers preserves tab.ptyId / ptyIdsByLeafId / + // lastKnownRelayPtyIdByTabId so wake re-spawns against the same on-disk + // history dir (local) or relay session id (SSH); it also captures + // serializer buffers into buffersByLeafId for SSH wake to reseed + // scrollback. See DESIGN_DOC_TERMINAL_HISTORY_FIX_V2.md §3.3.c. + await withWorktreeSleepTeardown(worktreeId, async () => { + await shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true }) + if (typeof window !== 'undefined' && window.api?.ephemeralVm?.suspendWorkspace) { + await window.api.ephemeralVm.suspendWorkspace({ workspaceId: worktreeId }) + } + }) + // Why: a workspace the user activated during the batch is awake by their choice. + if (useAppStore.getState().activeWorktreeId === worktreeId) { + clearWorktreeSleepIntent(worktreeId) } + } catch (err) { + console.error('[sleep-worktree] terminal or host suspension failed', { + worktreeId, + error: err + }) + failedWorktreeIds.add(worktreeId) + errors.push(describeSleepFailure(err)) } - const completed = nativeCompleted && panelsCompleted && terminalsStopped && current() - if (!completed && !terminalsStopped && current()) { - clearWorktreeSleepIntent(workspaceId, executionHostId) + const { receipts, failed } = await chatStop + if (failed) { + chatFailedIds.add(worktreeId) + errors.push(describeChatStopFailure()) } - return { workspaceId, executionHostId, stoppedSessionIds, completed } - }) - ) - const results = attempts.map((result, index) => ({ - ...result, - completed: - result.completed && - isCurrentWorktreeSleepIntent(result.workspaceId, result.executionHostId, marked[index].token) - })) - for (const result of results.filter((candidate) => !candidate.completed)) { - failedWorktreeIds.add(result.workspaceId) + // Why re-read the marker: a wake during the chat wait keeps the workspace awake. + const completed = + !failed && !failedWorktreeIds.has(worktreeId) && hasWorktreeSleepIntent(worktreeId) + results.push( + ...receipts.map((receipt) => ({ workspaceId: worktreeId, ...receipt, completed })) + ) + } + } finally { + // Why: a failed sleep leaves the workspace awake and retryable. + for (const worktreeId of failedWorktreeIds) { + clearWorktreeSleepIntent(worktreeId) + } + if (sleptActiveWorktreeId && failedWorktreeIds.has(sleptActiveWorktreeId)) { + setActiveWorktree(sleptActiveWorktreeId) + } + } + return { + failedWorktreeIds: [...new Set([...failedWorktreeIds, ...chatFailedIds])], + errors: [...new Set(errors)], + results } - return { failedWorktreeIds: [...failedWorktreeIds], errors: [...new Set(errors)], results } } diff --git a/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx b/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx index 320ade725e4e..d7f4849ba314 100644 --- a/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx @@ -142,13 +142,12 @@ export function useWorktreeContextMenuModel({ const sleepableWorktrees = useMemo( () => activeContextWorktrees.filter((item) => - hasSleepableWorkspaceActivity(item, { + hasSleepableWorkspaceActivity(item.id, { tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree, unifiedTabsByWorktree, - agentStatusByPaneKey, - chatOwnerState: useAppStore.getState() + agentStatusByPaneKey }) ), [ @@ -170,8 +169,7 @@ export function useWorktreeContextMenuModel({ ptyIdsByTabId, browserTabsByWorktree, unifiedTabsByWorktree, - agentStatusByPaneKey, - chatOwnerState: useAppStore.getState() + agentStatusByPaneKey } }) const lineageDescendantCount = lineageMenuActions.descendants.length diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts index a46a8953159c..319880a34b82 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts @@ -70,8 +70,7 @@ describe('workspace lineage menu actions', () => { [grandchild.id]: [{ id: 'grandchild-browser' }] }, unifiedTabsByWorktree: {}, - agentStatusByPaneKey: {}, - chatOwnerState: {} + agentStatusByPaneKey: {} } }) @@ -98,8 +97,7 @@ describe('workspace lineage menu actions', () => { ptyIdsByTabId: { 'child-tab': ['child-pty'] }, browserTabsByWorktree: {}, unifiedTabsByWorktree: {}, - agentStatusByPaneKey: {}, - chatOwnerState: {} + agentStatusByPaneKey: {} } }) @@ -120,8 +118,7 @@ describe('hasSleepableWorkspaceActivity', () => { ptyIdsByTabId: { 'tab-1': [] }, browserTabsByWorktree: {}, unifiedTabsByWorktree: {}, - agentStatusByPaneKey: {}, - chatOwnerState: {} + agentStatusByPaneKey: {} }) ).toBe(false) }) @@ -133,8 +130,7 @@ describe('hasSleepableWorkspaceActivity', () => { ptyIdsByTabId: { 'tab-1': ['pty-1'] }, browserTabsByWorktree: {}, unifiedTabsByWorktree: {}, - agentStatusByPaneKey: {}, - chatOwnerState: {} + agentStatusByPaneKey: {} }) ).toBe(true) expect( @@ -143,8 +139,7 @@ describe('hasSleepableWorkspaceActivity', () => { ptyIdsByTabId: {}, browserTabsByWorktree: { 'wt-1': [{ id: 'browser-1' }] }, unifiedTabsByWorktree: {}, - agentStatusByPaneKey: {}, - chatOwnerState: {} + agentStatusByPaneKey: {} }) ).toBe(true) }) @@ -163,80 +158,15 @@ describe('hasSleepableWorkspaceActivity', () => { sortOrder: 0, createdAt: 0 } - expect( + const activity = (agentStatusByPaneKey: Record) => hasSleepableWorkspaceActivity('wt-1', { tabsByWorktree: {}, ptyIdsByTabId: {}, browserTabsByWorktree: {}, unifiedTabsByWorktree: { 'wt-1': [chat] }, - agentStatusByPaneKey: hostHeld(chat), - chatOwnerState: { - worktreesByRepo: { - 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', hostId: 'local' }] - } - } + agentStatusByPaneKey }) - ).toBe(true) - expect( - hasSleepableWorkspaceActivity( - { id: 'wt-1', repoId: 'repo-1', hostId: 'local' }, - { - tabsByWorktree: {}, - ptyIdsByTabId: {}, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: { 'wt-1': [{ ...chat, executionHostId: 'runtime:remote' }] }, - agentStatusByPaneKey: hostHeld(chat), - chatOwnerState: {} - } - ) - ).toBe(false) - }) - - it('offers Sleep for the selected host of a duplicate chat-only folder', () => { - const chat: Tab = { - id: 'chat-folder', - entityId: 'session-folder', - groupId: 'group-1', - worktreeId: 'folder:folder-1', - contentType: 'agent-session', - agentSessionAgent: 'claude', - executionHostId: 'runtime:env-1', - label: 'Claude', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 0 - } - expect( - hasSleepableWorkspaceActivity( - { id: 'folder:folder-1', hostId: 'runtime:env-1' }, - { - tabsByWorktree: {}, - ptyIdsByTabId: {}, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: { 'folder:folder-1': [chat] }, - agentStatusByPaneKey: hostHeld(chat), - chatOwnerState: { - folderWorkspaces: [ - { - id: 'folder-1', - projectGroupId: 'local-group', - connectionId: null, - executionHostId: 'local' - }, - { - id: 'folder-1', - projectGroupId: 'remote-group', - connectionId: null, - executionHostId: 'runtime:env-1' - } - ], - projectGroups: [ - { id: 'remote-group', connectionId: null, executionHostId: 'runtime:env-1' } - ] - } - } - ) - ).toBe(true) + expect(activity(hostHeld(chat))).toBe(true) + expect(activity({})).toBe(false) }) }) diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts index cd713c87ebd3..975c3b0338ab 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts @@ -3,23 +3,9 @@ import { tabHasLivePty } from '../../../../shared/tab-has-live-pty' import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' import type { Worktree } from '../../../../shared/worktree/types' import type { Tab } from '../../../../shared/tab-types' -import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner-state' -import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' import { isStructuredTab } from '../../../../shared/structured-agent-session-tab' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' -import type { WorkspaceSleepTarget } from './sleep-worktree-flow' -import { - LOCAL_EXECUTION_HOST_ID, - parseExecutionHostId, - toRuntimeExecutionHostId -} from '../../../../shared/execution-host' -import { parseWorkspaceKey } from '../../../../shared/workspace-scope' -import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route' -import { - findIndexedFolderWorkspaceOwner, - findIndexedProjectGroupOwner -} from '@/lib/worktree-runtime-owner-index' -import { getFolderWorkspaceHostId } from './folder-workspace-host-id' +import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' type WorkspaceActivityMaps = { tabsByWorktree: Record @@ -27,7 +13,6 @@ type WorkspaceActivityMaps = { browserTabsByWorktree: Record unifiedTabsByWorktree: Record agentStatusByPaneKey: Record - chatOwnerState: WorktreeRuntimeOwnerState } export type WorkspaceLineageMenuActions = { @@ -37,72 +22,18 @@ export type WorkspaceLineageMenuActions = { } export function hasSleepableWorkspaceActivity( - target: WorkspaceSleepTarget, - { - tabsByWorktree, - ptyIdsByTabId, - browserTabsByWorktree, - unifiedTabsByWorktree, - agentStatusByPaneKey, - chatOwnerState - }: WorkspaceActivityMaps + worktreeId: string, + activity: WorkspaceActivityMaps ): boolean { - const worktreeId = typeof target === 'string' ? target : target.id - const tabs = tabsByWorktree[worktreeId] ?? [] - const scope = parseWorkspaceKey(worktreeId) - const explicitHost = - typeof target === 'string' - ? undefined - : (target.executionHostId ?? - (scope?.type === 'folder' && target.runtimeOwnerEnvironmentId?.trim() - ? toRuntimeExecutionHostId(target.runtimeOwnerEnvironmentId.trim()) - : target.hostId)) - const route = resolveWorktreeOperationRouteResult(chatOwnerState, worktreeId) - const folder = - scope?.type === 'folder' - ? findIndexedFolderWorkspaceOwner( - chatOwnerState.folderWorkspaces, - scope.folderWorkspaceId, - parseExecutionHostId(explicitHost)?.id - ) - : null - const group = folder - ? ((explicitHost - ? findIndexedProjectGroupOwner( - chatOwnerState.projectGroups, - folder.projectGroupId, - parseExecutionHostId(explicitHost)?.id - ) - : null) ?? - findIndexedProjectGroupOwner(chatOwnerState.projectGroups, folder.projectGroupId)) - : null - const selectedHost = - scope?.type === 'folder' - ? folder - ? target && typeof target !== 'string' && target.executionHostId - ? target.executionHostId - : getFolderWorkspaceHostId( - folder, - group ?? { connectionId: null, executionHostId: null }, - parseExecutionHostId( - chatOwnerState.restoredRuntimeHostIdByWorkspaceSessionKey?.[worktreeId] - )?.id ?? LOCAL_EXECUTION_HOST_ID - ) - : null - : route.kind === 'resolved' - ? (explicitHost ?? route.route.executionHostId) - : (explicitHost ?? null) - const hostId = parseExecutionHostId(selectedHost)?.id + const tabs = activity.tabsByWorktree[worktreeId] ?? [] return ( - tabs.some((tab) => tabHasLivePty(ptyIdsByTabId, tab.id)) || - (browserTabsByWorktree[worktreeId] ?? []).length > 0 || + tabs.some((tab) => tabHasLivePty(activity.ptyIdsByTabId, tab.id)) || + (activity.browserTabsByWorktree[worktreeId] ?? []).length > 0 || // A chat counts only while its host holds its process; a stopped one has nothing to sleep. - (unifiedTabsByWorktree[worktreeId] ?? []).some( + (activity.unifiedTabsByWorktree[worktreeId] ?? []).some( (tab) => isStructuredTab(tab) && - !!hostId && - (!tab.executionHostId || tab.executionHostId === hostId) && - agentStatusByPaneKey[structuredAgentSessionPaneKey(tab.id, tab.entityId)] + activity.agentStatusByPaneKey[structuredAgentSessionPaneKey(tab.id, tab.entityId)] ?.structuredHostOwned === true ) ) @@ -120,7 +51,7 @@ export function getWorkspaceLineageMenuActions(args: { descendants, targets, sleepableTargets: targets.filter((target) => - hasSleepableWorkspaceActivity(target, args.activity) + hasSleepableWorkspaceActivity(target.id, args.activity) ) } } @@ -140,8 +71,7 @@ export function useWorkspaceLineageMenuActions( ptyIdsByTabId, browserTabsByWorktree, unifiedTabsByWorktree, - agentStatusByPaneKey, - chatOwnerState + agentStatusByPaneKey } = activity return useMemo( () => @@ -155,8 +85,7 @@ export function useWorkspaceLineageMenuActions( ptyIdsByTabId, browserTabsByWorktree, unifiedTabsByWorktree, - agentStatusByPaneKey, - chatOwnerState + agentStatusByPaneKey } }) : EMPTY_LINEAGE_MENU_ACTIONS, @@ -169,7 +98,6 @@ export function useWorkspaceLineageMenuActions( ptyIdsByTabId, tabsByWorktree, unifiedTabsByWorktree, - chatOwnerState, worktrees ] ) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.spec.ts index e46db3997bcf..c1495a3ee632 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.spec.ts @@ -1,6 +1,5 @@ import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' import { flushAsyncTicks } from './pty-connection-test-async' import { createMockTransport, @@ -94,9 +93,9 @@ export function registerPtyConnectionDeliberateSleepGuardTests(): void { expect(transport.connect).not.toHaveBeenCalled() }) - it('connects a pane mounted after wake only when issued teardown settles', async () => { + it('connects a waiting pane once the workspace is woken', async () => { const { connectPanePty } = await import('./pty-connection') - const { beginWorktreeSleepTeardown, clearWorktreeSleepIntent, markWorktreeSleepIntent } = + const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') const transport = createMockTransport() transportFactoryQueue.push(transport) @@ -106,8 +105,6 @@ export function registerPtyConnectionDeliberateSleepGuardTests(): void { tabsByWorktree: { 'wt-1': [{ id: 'tab-woken', ptyId: 'wt-1@@dead' }] } } markWorktreeSleepIntent('wt-1') - const finish = beginWorktreeSleepTeardown('wt-1', 'local') - clearWorktreeSleepIntent('wt-1') const deps = createDeps({ tabId: 'tab-woken', restoredLeafId: LEAF_1, @@ -119,31 +116,9 @@ export function registerPtyConnectionDeliberateSleepGuardTests(): void { await flushAsyncTicks() expect(transport.connect).not.toHaveBeenCalled() - finish() - await flushAsyncTicks() - - expect(transport.connect).toHaveBeenCalledTimes(1) - }) - - it('waits for both same-id host teardowns before connecting an unqualified pane', async () => { - const { connectPanePty } = await import('./pty-connection') - const { beginWorktreeSleepTeardown } = await import('@/lib/worktree-sleep-intent') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - const finishA = beginWorktreeSleepTeardown('wt-1', toRuntimeExecutionHostId('host-a')) - const finishB = beginWorktreeSleepTeardown('wt-1', toRuntimeExecutionHostId('host-b')) - const deps = createDeps({ tabId: 'tab-overlap', isVisibleRef: { current: false } }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - expect(transport.connect).not.toHaveBeenCalled() - - finishA() + clearWorktreeSleepIntent('wt-1') await flushAsyncTicks() - expect(transport.connect).not.toHaveBeenCalled() - finishB() - await flushAsyncTicks() expect(transport.connect).toHaveBeenCalledTimes(1) }) @@ -268,12 +243,10 @@ export function registerPtyConnectionDeliberateSleepGuardTests(): void { it('still connects a slept pane that carries a queued startup', async () => { const { connectPanePty } = await import('./pty-connection') - const { beginWorktreeSleepTeardown, markWorktreeSleepIntent } = - await import('@/lib/worktree-sleep-intent') + const { markWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') const transport = createMockTransport() transportFactoryQueue.push(transport) markWorktreeSleepIntent('wt-1') - const finish = beginWorktreeSleepTeardown('wt-1', 'local') const deps = createDeps({ tabId: 'tab-slept-startup', isVisibleRef: { current: false }, @@ -283,10 +256,6 @@ export function registerPtyConnectionDeliberateSleepGuardTests(): void { connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) await flushAsyncTicks() - expect(transport.connect).not.toHaveBeenCalled() - finish() - await flushAsyncTicks() - expect(transport.connect).toHaveBeenCalledTimes(1) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts b/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts index 474fc8954d4b..c46f0f12e87f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts @@ -4,12 +4,7 @@ import { isWorktreeRemovalFenceError } from '../../../../../shared/worktree/remo import { safeFit } from '@/lib/pane-manager/pane-tree-ops' import { useAppStore } from '@/store' import { createCodexBackfillErrorDetector } from '../codex-backfill-error-detector' -import { - clearWorktreeSleepIntent, - hasWorktreeSleepIntent, - hasWorktreeSleepTeardown, - onWorktreeSleepIntentCleared -} from '@/lib/worktree-sleep-intent' +import { hasWorktreeSleepIntent, onWorktreeSleepIntentCleared } from '@/lib/worktree-sleep-intent' import { recordPtyConnectDiagnostic } from './pty-connect-limits' import { findTerminalTabForPane } from './terminal-tab-id' @@ -40,18 +35,10 @@ export function installRunDeferredConnect(session: ConnectPanePtySession): void if (session.connectStarted) { return } - const executionHostId = - session.transport.getExecutionHostId?.() ?? session.tab?.executionHostId ?? undefined - if (session.paneStartup) { - clearWorktreeSleepIntent(session.deps.worktreeId, executionHostId) - } // Why: a deliberately slept workspace keeps its panes mounted, so connecting // would reattach the retained session id and respawn the shell (#10205). // Wait for the wake instead; a queued startup is an explicit launch. - if ( - (hasWorktreeSleepIntent(session.deps.worktreeId, executionHostId) && !session.paneStartup) || - hasWorktreeSleepTeardown(session.deps.worktreeId, executionHostId) - ) { + if (hasWorktreeSleepIntent(session.deps.worktreeId) && !session.paneStartup) { session.cancelScheduledConnectFrame() if (session.connectFallbackTimer !== null) { clearTimeout(session.connectFallbackTimer) @@ -60,29 +47,25 @@ export function installRunDeferredConnect(session: ConnectPanePtySession): void if (!wakeWaitStarted) { wakeWaitStarted = true recordPtyConnectDiagnostic( - `pane=${session.pane.id} tab=${session.deps.tabId} -> WAIT FOR SLEEP TEARDOWN OR WAKE` - ) - const unsubscribe = onWorktreeSleepIntentCleared( - session.deps.worktreeId, - () => { - wakeWaitStarted = false - const index = session.waitTeardowns.indexOf(unsubscribe) - if (index !== -1) { - session.waitTeardowns.splice(index, 1) - } - // Why: an activation wake bumps the tab generation in the same tick and the - // remounted pane connects on its own; a stale generation must not connect too. - const currentTab = findTerminalTabForPane( - useAppStore.getState(), - session.deps.worktreeId, - session.deps.tabId - ) - if (!session.disposed && (currentTab?.generation ?? 0) === session.tabGeneration) { - session.runDeferredConnect() - } - }, - executionHostId + `pane=${session.pane.id} tab=${session.deps.tabId} -> WAIT FOR WAKE (deliberate sleep)` ) + const unsubscribe = onWorktreeSleepIntentCleared(session.deps.worktreeId, () => { + wakeWaitStarted = false + const index = session.waitTeardowns.indexOf(unsubscribe) + if (index !== -1) { + session.waitTeardowns.splice(index, 1) + } + // Why: an activation wake bumps the tab generation in the same tick and the + // remounted pane connects on its own; a stale generation must not connect too. + const currentTab = findTerminalTabForPane( + useAppStore.getState(), + session.deps.worktreeId, + session.deps.tabId + ) + if (!session.disposed && (currentTab?.generation ?? 0) === session.tabGeneration) { + session.runDeferredConnect() + } + }) // Why: disposal unsubscribes so a torn-down pane never connects on a later wake. session.waitTeardowns.push(unsubscribe) } diff --git a/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts index fc8e03407acf..2130c5e2b6a0 100644 --- a/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts @@ -131,8 +131,8 @@ export function registerMobileAndTerminalCloseIpcBridge( } unsubs.push( - window.api.ui.onSleepWorktree(({ worktreeId, executionHostId }) => { - void runSleepWorktree(worktreeId, executionHostId) + window.api.ui.onSleepWorktree(({ worktreeId }) => { + void runSleepWorktree(worktreeId) }) ) diff --git a/src/renderer/src/lib/ephemeral-vm-runtime-cleanup.ts b/src/renderer/src/lib/ephemeral-vm-runtime-cleanup.ts index efd0ab4701cb..6d435b25f4b2 100644 --- a/src/renderer/src/lib/ephemeral-vm-runtime-cleanup.ts +++ b/src/renderer/src/lib/ephemeral-vm-runtime-cleanup.ts @@ -1,5 +1,10 @@ -import { isRuntimeOwnedSshTargetId, type ExecutionHostId } from '../../../shared/execution-host' -import { ephemeralVmMatchesWorkspaceHost } from '../../../shared/ephemeral-vm-runtime-host-match' +import { + isRuntimeOwnedSshTargetId, + toRuntimeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { composeWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' /** * Tear down the ephemeral-VM runtimes backing a set of deleted workspaces (and, @@ -36,14 +41,31 @@ export async function cleanupEphemeralVmRuntimesForDeleted(args: { const sshTargetIdSet = new Set( (args.runtimeOwnedSshTargetIds ?? []).filter((id) => isRuntimeOwnedSshTargetId(id)) ) + const hostScopedWorkspaceIdentities = new Set( + (args.hostScopedWorkspaces ?? []).map((target) => + composeWorktreeHostIdentity(target.executionHostId, target.workspaceId) + ) + ) const runtimes = await window.api.ephemeralVm.listRuntimes() const matchingRuntimes = runtimes.filter( (runtime) => (runtime.cleanupStatus !== 'succeeded' || runtime.sshTargetId !== undefined) && ((runtime.workspaceId !== undefined && workspaceIdSet.has(runtime.workspaceId)) || - (args.hostScopedWorkspaces ?? []).some((target) => - ephemeralVmMatchesWorkspaceHost(runtime, target.workspaceId, target.executionHostId) - ) || + (runtime.workspaceId !== undefined && + ((runtime.runtimeEnvironmentId !== undefined && + hostScopedWorkspaceIdentities.has( + composeWorktreeHostIdentity( + toRuntimeExecutionHostId(runtime.runtimeEnvironmentId), + runtime.workspaceId + ) + )) || + (runtime.sshTargetId !== undefined && + hostScopedWorkspaceIdentities.has( + composeWorktreeHostIdentity( + toSshExecutionHostId(runtime.sshTargetId), + runtime.workspaceId + ) + )))) || (runtime.sshTargetId !== undefined && sshTargetIdSet.has(runtime.sshTargetId))) ) for (const runtime of matchingRuntimes) { diff --git a/src/renderer/src/lib/sidebar-worktree-activation.test.ts b/src/renderer/src/lib/sidebar-worktree-activation.test.ts index 30c29475a29d..a12ca8affa81 100644 --- a/src/renderer/src/lib/sidebar-worktree-activation.test.ts +++ b/src/renderer/src/lib/sidebar-worktree-activation.test.ts @@ -68,18 +68,6 @@ describe('sidebar worktree activation', () => { await activation }) - it('passes the clicked host to the VM wake', async () => { - const resumeWorkspace = vi.fn().mockResolvedValue(null) - vi.stubGlobal('window', { api: { ephemeralVm: { resumeWorkspace } } }) - - await activateWorktreeFromSidebar('wt-vm', 'runtime:env-a') - - expect(resumeWorkspace).toHaveBeenCalledWith({ - workspaceId: 'wt-vm', - executionHostId: 'runtime:env-a' - }) - }) - it('routes folder workspace activation through the guarded folder path', async () => { await activateWorktreeFromSidebar('folder:folder-workspace-1') diff --git a/src/renderer/src/lib/sidebar-worktree-activation.ts b/src/renderer/src/lib/sidebar-worktree-activation.ts index 656984f2aa89..01bb94ad11b8 100644 --- a/src/renderer/src/lib/sidebar-worktree-activation.ts +++ b/src/renderer/src/lib/sidebar-worktree-activation.ts @@ -28,10 +28,7 @@ export async function activateWorktreeFromSidebar( if (typeof window !== 'undefined' && window.api?.ephemeralVm?.resumeWorkspace) { try { - const runtime = await window.api.ephemeralVm.resumeWorkspace({ - workspaceId: worktreeId, - ...(executionHostId ? { executionHostId } : {}) - }) + const runtime = await window.api.ephemeralVm.resumeWorkspace({ workspaceId: worktreeId }) if (runtime?.runtimeEnvironmentId) { const store = (await import('@/store')).useAppStore store.getState().setRuntimeEnvironments(await window.api.runtimeEnvironments.list()) diff --git a/src/renderer/src/lib/worktree-sleep-intent.ts b/src/renderer/src/lib/worktree-sleep-intent.ts index 93bd1dc84dea..6512abec692e 100644 --- a/src/renderer/src/lib/worktree-sleep-intent.ts +++ b/src/renderer/src/lib/worktree-sleep-intent.ts @@ -1,150 +1,69 @@ -import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' +// Why: a slept workspace keeps its panes mounted with only dead PTYs behind them. +// Any pane connect that runs while the marker is set waits here, and the clear +// that marks the workspace awake resumes every waiting connect. +const sleepingWorktreeIds = new Set() +const tearingDownWorktreeIds = new Set() +const wakeListenersByWorktreeId = new Map void>>() -type SleepIntent = { worktreeId: string; hostId: ExecutionHostId } -const intents = new Map() -const teardownCounts = new Map() -const listeners = new Map void>>() -const keyFor = (worktreeId: string, hostId: ExecutionHostId): string => `${hostId}\0${worktreeId}` - -function matchingKeys(worktreeId: string, hostId?: ExecutionHostId): string[] { - return hostId - ? [keyFor(worktreeId, hostId)] - : [...intents].filter(([, intent]) => intent.worktreeId === worktreeId).map(([key]) => key) -} - -function notifyCleared(key: string, worktreeId: string): void { - const keyListeners = [...(listeners.get(key) ?? [])] - listeners.delete(key) - const allHostsCleared = !matchingKeys(worktreeId).length && !hasWorktreeSleepTeardown(worktreeId) - const worktreeListeners = allHostsCleared ? [...(listeners.get(worktreeId) ?? [])] : [] - if (allHostsCleared) { - listeners.delete(worktreeId) - } - for (const listener of [...keyListeners, ...worktreeListeners]) { - try { - listener() - } catch (error) { - console.error('[sleep-intent] wake listener failed', { worktreeId, error }) - } - } -} - -export function markWorktreeSleepIntent( - worktreeId: string, - hostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID -): SleepIntent { - const key = keyFor(worktreeId, hostId) - const intent = { worktreeId, hostId } - intents.set(key, intent) - return intent -} - -export function isCurrentWorktreeSleepIntent( - worktreeId: string, - hostId: ExecutionHostId, - intent: SleepIntent -): boolean { - return intents.get(keyFor(worktreeId, hostId)) === intent +export function markWorktreeSleepIntent(worktreeId: string): void { + sleepingWorktreeIds.add(worktreeId) } +/** + * Why: a spawn that resolves while the sleep teardown is still awaiting its host + * would bind a PTY and clear the marker, waking every waiting pane mid-sleep. + * Binds during the teardown window are not wakes. + */ export async function withWorktreeSleepTeardown( worktreeId: string, - teardown: () => Promise, - hostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID + teardown: () => Promise ): Promise { - const finish = beginWorktreeSleepTeardown(worktreeId, hostId) + tearingDownWorktreeIds.add(worktreeId) try { return await teardown() } finally { - finish() + tearingDownWorktreeIds.delete(worktreeId) } } -export function beginWorktreeSleepTeardown( - worktreeId: string, - hostId: ExecutionHostId -): () => void { - const key = keyFor(worktreeId, hostId) - teardownCounts.set(key, (teardownCounts.get(key) ?? 0) + 1) - let finished = false - return () => { - if (finished) { - return - } - finished = true - const remaining = (teardownCounts.get(key) ?? 1) - 1 - if (remaining > 0) { - teardownCounts.set(key, remaining) - } else { - teardownCounts.delete(key) - if (!intents.has(key)) { - notifyCleared(key, worktreeId) - } - } +export function clearWorktreeSleepIntent(worktreeId: string | null): void { + if (!worktreeId || tearingDownWorktreeIds.has(worktreeId)) { + return } -} - -export function clearWorktreeSleepIntent( - worktreeId: string | null, - hostId?: ExecutionHostId, - source: 'wake' | 'bind' = 'wake' -): void { - if (!worktreeId) { + if (!sleepingWorktreeIds.delete(worktreeId)) { return } - for (const key of matchingKeys(worktreeId, hostId)) { - const intent = intents.get(key) - if (!intent || (source === 'bind' && (teardownCounts.get(key) ?? 0) > 0)) { - continue - } - intents.delete(key) - if (!teardownCounts.has(key)) { - notifyCleared(key, worktreeId) + const listeners = wakeListenersByWorktreeId.get(worktreeId) + wakeListenersByWorktreeId.delete(worktreeId) + for (const listener of listeners ?? []) { + try { + listener() + } catch (error) { + // Why: one pane's connect failure must not strand its siblings or throw out of a store action. + console.error('[sleep-intent] wake listener failed', { worktreeId, error }) } } } +// Why: a purged worktree must not wake its panes; they are being unmounted. export function forgetWorktreeSleepIntent(worktreeId: string): void { - for (const key of matchingKeys(worktreeId)) { - intents.delete(key) - teardownCounts.delete(key) - listeners.delete(key) - } - listeners.delete(worktreeId) + sleepingWorktreeIds.delete(worktreeId) + tearingDownWorktreeIds.delete(worktreeId) + wakeListenersByWorktreeId.delete(worktreeId) } -export function hasWorktreeSleepIntent( - worktreeId: string | null, - hostId?: ExecutionHostId -): boolean { - return worktreeId !== null && matchingKeys(worktreeId, hostId).some((key) => intents.has(key)) +export function hasWorktreeSleepIntent(worktreeId: string | null): boolean { + return worktreeId !== null && sleepingWorktreeIds.has(worktreeId) } -export function hasWorktreeSleepTeardown( - worktreeId: string | null, - hostId?: ExecutionHostId -): boolean { - if (worktreeId === null) { - return false - } - return hostId - ? teardownCounts.has(keyFor(worktreeId, hostId)) - : [...teardownCounts.keys()].some((key) => key.endsWith(`\0${worktreeId}`)) -} - -export function onWorktreeSleepIntentCleared( - worktreeId: string, - listener: () => void, - hostId?: ExecutionHostId -): () => void { - const key = hostId ? keyFor(worktreeId, hostId) : worktreeId - const group = listeners.get(key) ?? new Set<() => void>() - group.add(listener) - listeners.set(key, group) +export function onWorktreeSleepIntentCleared(worktreeId: string, listener: () => void): () => void { + const listeners = wakeListenersByWorktreeId.get(worktreeId) ?? new Set<() => void>() + listeners.add(listener) + wakeListenersByWorktreeId.set(worktreeId, listeners) return () => { - group.delete(listener) - if (group.size === 0 && listeners.get(key) === group) { - listeners.delete(key) + listeners.delete(listener) + if (listeners.size === 0 && wakeListenersByWorktreeId.get(worktreeId) === listeners) { + wakeListenersByWorktreeId.delete(worktreeId) } } } diff --git a/src/renderer/src/store/slices/workspace-activation-preparation.test.ts b/src/renderer/src/store/slices/workspace-activation-preparation.test.ts index 5b94df8bacff..141cbaa3a898 100644 --- a/src/renderer/src/store/slices/workspace-activation-preparation.test.ts +++ b/src/renderer/src/store/slices/workspace-activation-preparation.test.ts @@ -55,7 +55,7 @@ it.each([ ['git', 'ssh:remote'] ] as const)('wakes a slept %s on %s and restores its explorer mode atomically', (kind, hostId) => { const { store, workspaceId, activate } = seedWorkspace(kind, hostId) - markWorktreeSleepIntent(workspaceId, hostId) + markWorktreeSleepIntent(workspaceId) const wake = vi.fn(() => { expect(store.getState().activeWorktreeId).toBe(workspaceId) expect(store.getState().tabsByWorktree[workspaceId][0].generation).toBe(4) diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts index f23a094ddcfa..2b18b7bb21e1 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts @@ -166,7 +166,7 @@ export function createSetActiveWorktree( // Why: any activation is an explicit wake (null is the sleep flow clearing selection). // Cleared after the set() above so a pane still waiting on the marker connects once, // in the remounted generation, instead of connecting and then being remounted. - clearWorktreeSleepIntent(worktreeId, executionHostId ?? undefined) + clearWorktreeSleepIntent(worktreeId) if (worktreeId && shouldPrepareTerminalTabs) { const prepareTerminalTabs = (): void => { diff --git a/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts b/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts index c2efa2330efd..4e271dd0b217 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts @@ -95,6 +95,11 @@ function resolvePurgedPublishers( } } +/** Every host whose listing publishes this id; empty when no row names it. */ +export function workspacePublishingHosts(s: AppState, workspaceId: string): ExecutionHostId[] { + return resolvePurgedPublishers(s, workspaceId, new Set()).survivors +} + /** Per attributed host, its twin ids with the hosts that still publish them. */ export type HostTwinPurgeScopes = Map> diff --git a/src/renderer/src/store/terminals/terminal-pty-bindings.ts b/src/renderer/src/store/terminals/terminal-pty-bindings.ts index 683efdf8bcd5..b6ac61c8e9f7 100644 --- a/src/renderer/src/store/terminals/terminal-pty-bindings.ts +++ b/src/renderer/src/store/terminals/terminal-pty-bindings.ts @@ -9,7 +9,6 @@ import { import { omitUnverifiedPtyLossTabIds } from './terminal-unverified-pty-loss' import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { omitDisownedPtyIds } from './terminal-disowned-pty-sources' -import { resolveTerminalWorktreeSleepOwner } from './terminal-worktree-sleep-owner' export function createTerminalPtyBindingActions( set: TerminalStoreSet, @@ -192,10 +191,7 @@ export function createTerminalPtyBindingActions( } }) // Why: a bound PTY means the workspace is awake by any route (CLI, automation, client wake), not only activation. - const owner = resolveTerminalWorktreeSleepOwner(get(), worktreeId, ptyId) - if (owner) { - clearWorktreeSleepIntent(worktreeId, owner, 'bind') - } + clearWorktreeSleepIntent(worktreeId) // Why: activation spawns come from clicking a worktree, not work in it — skip the lastActivityAt stamp and sortEpoch bump; other spawn reasons still bump. if (worktreeId && !wasActivationSpawn && !isRemoteRuntimeMirror) { get().bumpWorktreeActivity(worktreeId) diff --git a/src/renderer/src/store/terminals/terminal-pty-release.ts b/src/renderer/src/store/terminals/terminal-pty-release.ts index ce311d80eb8d..3668458e2419 100644 --- a/src/renderer/src/store/terminals/terminal-pty-release.ts +++ b/src/renderer/src/store/terminals/terminal-pty-release.ts @@ -5,7 +5,6 @@ import { consumePendingActivationSpawn, getPendingActivationSpawnCount } from './terminal-pty-identities' -import { resolveTerminalWorktreeSleepOwner } from './terminal-worktree-sleep-owner' export function createTerminalPtyReleaseActions( set: TerminalStoreSet, @@ -108,10 +107,7 @@ export function createTerminalPtyReleaseActions( worktreeId && !wasActivationSpawn && !isRemoteRuntimeMirror && - !hasWorktreeSleepIntent( - worktreeId, - resolveTerminalWorktreeSleepOwner(get(), worktreeId, ptyId) ?? undefined - ) && + !hasWorktreeSleepIntent(worktreeId) && !(ptyId && get().suppressedPtyExitIds[ptyId]) ) { get().bumpWorktreeActivity(worktreeId) diff --git a/src/renderer/src/store/terminals/terminal-shutdown.ts b/src/renderer/src/store/terminals/terminal-shutdown.ts index 7ce850f344de..aa737ac09b64 100644 --- a/src/renderer/src/store/terminals/terminal-shutdown.ts +++ b/src/renderer/src/store/terminals/terminal-shutdown.ts @@ -8,9 +8,10 @@ import { type AgentStatusWorktreeShutdownReason } from '../slices/agent-status' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import type { AppState } from '../types' import { equalStringSets, sortedUniquePtyIds } from './terminal-pty-identities' import { resolveRuntimeOwnerForWorktree } from '@/lib/worktree-runtime-owner' -import { resolveTerminalTeardownOwner } from './terminal-teardown-owner' +import { resolveTerminalTeardownOwner, type TerminalTeardownOwner } from './terminal-teardown-owner' import { createTerminalShutdownGuardController } from './terminal-shutdown-guards' import { commitTerminalShutdownState } from './terminal-shutdown-state' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' @@ -23,6 +24,20 @@ type ExactTerminalStopResult = { postStopFailure?: string } +/** Which host must stop this worktree's terminals; Sleep reads it before stopping anything. */ +export function resolveWorktreeTerminalTeardownOwner( + state: AppState, + worktreeId: string +): TerminalTeardownOwner { + const ptyIds = (state.tabsByWorktree[worktreeId] ?? []).flatMap( + (tab) => state.ptyIdsByTabId[tab.id] ?? [] + ) + return resolveTerminalTeardownOwner( + resolveRuntimeOwnerForWorktree(state, worktreeId), + sortedUniquePtyIds(ptyIds) + ) +} + export function createTerminalShutdownActions( set: TerminalStoreSet, get: TerminalStoreGet @@ -38,10 +53,7 @@ export function createTerminalShutdownActions( ) const expectedRuntimePtyIds = sortedUniquePtyIds(opts?.expectedRuntimePtyIds) const backendOwnsPtyTeardown = opts?.backendOwnsPtyTeardown === true - const teardownOwner = resolveTerminalTeardownOwner( - resolveRuntimeOwnerForWorktree(get(), worktreeId), - rendererShutdownPtyIds - ) + const teardownOwner = resolveWorktreeTerminalTeardownOwner(get(), worktreeId) // Why: treating an unattributable owner as local skips the host stop yet still clears // bindings and writes resume records, so the agent runs on unseen and wake starts a twin. // A backend-owned teardown already stopped its PTYs, so it has nothing to misroute. diff --git a/src/renderer/src/store/terminals/terminal-tab-creation.ts b/src/renderer/src/store/terminals/terminal-tab-creation.ts index dff0b2b44013..905803f52145 100644 --- a/src/renderer/src/store/terminals/terminal-tab-creation.ts +++ b/src/renderer/src/store/terminals/terminal-tab-creation.ts @@ -33,7 +33,6 @@ import { resolveCreatedTabShellOverride, worktreeUsesWslPath } from './terminal-workspace-routing' -import { resolveTerminalWorktreeSleepOwner } from './terminal-worktree-sleep-owner' type TabStartupCommand = TerminalSlice['pendingStartupByTabId'][string] function normalizeTabStartupCommand(startup: TabStartupCommand): TabStartupCommand { @@ -294,10 +293,7 @@ export function createTerminalTabCreationActions( } if (options?.initialPtyId) { // Why: a tab born with a live PTY (CLI/runtime create) wakes the workspace like any other bind. - const owner = resolveTerminalWorktreeSleepOwner(get(), worktreeId, options.initialPtyId) - if (owner) { - clearWorktreeSleepIntent(worktreeId, owner, 'bind') - } + clearWorktreeSleepIntent(worktreeId) } const shouldRecordInteraction = options?.recordInteraction ?? (!options?.pendingActivationSpawn && !options?.initialPtyId) diff --git a/src/renderer/src/store/terminals/terminal-worktree-sleep-owner.test.ts b/src/renderer/src/store/terminals/terminal-worktree-sleep-owner.test.ts deleted file mode 100644 index 98beb6876592..000000000000 --- a/src/renderer/src/store/terminals/terminal-worktree-sleep-owner.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' -import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route' -import { resolveTerminalWorktreeSleepOwner } from './terminal-worktree-sleep-owner' - -describe('terminal workspace sleep owner', () => { - it('uses the PTY owner when another host with the same workspace id is selected', () => { - const state = { - activeWorktreeId: 'same-workspace', - activeWorkspaceExecutionHostId: 'runtime:env-b' - } satisfies WorktreeOperationRouteState - expect( - resolveTerminalWorktreeSleepOwner( - state, - 'same-workspace', - toRemoteRuntimePtyId('pty-1', 'env-a') - ) - ).toBe('runtime:env-a') - expect( - resolveTerminalWorktreeSleepOwner(state, 'same-workspace', toRemoteRuntimePtyId('pty-1')) - ).toBeNull() - }) -}) diff --git a/src/renderer/src/store/terminals/terminal-worktree-sleep-owner.ts b/src/renderer/src/store/terminals/terminal-worktree-sleep-owner.ts deleted file mode 100644 index a3f920e6ac2b..000000000000 --- a/src/renderer/src/store/terminals/terminal-worktree-sleep-owner.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { - isRemoteRuntimePtyId, - parseRemoteRuntimePtyId -} from '../../../../shared/remote-runtime-pty-id' -import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' -import { - resolveWorktreeOperationRouteResult, - type WorktreeOperationRouteState -} from '@/lib/worktree-operation-route' - -export function resolveTerminalWorktreeSleepOwner( - state: WorktreeOperationRouteState, - worktreeId: string | null, - ptyId: string | null | undefined -): ExecutionHostId | null { - if (!worktreeId) { - return null - } - if (ptyId && isRemoteRuntimePtyId(ptyId)) { - const environmentId = parseRemoteRuntimePtyId(ptyId)?.environmentId - return environmentId ? toRuntimeExecutionHostId(environmentId) : null - } - const resolution = resolveWorktreeOperationRouteResult(state, worktreeId) - return resolution.kind === 'resolved' ? resolution.route.executionHostId : null -} diff --git a/src/shared/dashboard-snapshot.ts b/src/shared/dashboard-snapshot.ts index 461462c02a80..bfd41984f814 100644 --- a/src/shared/dashboard-snapshot.ts +++ b/src/shared/dashboard-snapshot.ts @@ -233,5 +233,4 @@ export type DashboardSpawnAgentArgs = { * teardown sequence, so the pop-out only names the target. */ export type DashboardSleepWorkspaceArgs = { worktreeId: string - executionHostId?: ExecutionHostId } diff --git a/src/shared/ephemeral-vm-runtime-host-match.ts b/src/shared/ephemeral-vm-runtime-host-match.ts deleted file mode 100644 index e6ffd16706fd..000000000000 --- a/src/shared/ephemeral-vm-runtime-host-match.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { - toRuntimeExecutionHostId, - toSshExecutionHostId, - type ExecutionHostId -} from './execution-host' -import type { EphemeralVmRuntimeRecord } from './ephemeral-vm-runtimes' - -/** VM records identify their execution host through the environment or managed SSH target. */ -export function ephemeralVmMatchesWorkspaceHost( - runtime: EphemeralVmRuntimeRecord, - workspaceId: string, - executionHostId: ExecutionHostId -): boolean { - return ( - runtime.workspaceId === workspaceId && - ((runtime.runtimeEnvironmentId !== undefined && - toRuntimeExecutionHostId(runtime.runtimeEnvironmentId) === executionHostId) || - (runtime.sshTargetId !== undefined && - toSshExecutionHostId(runtime.sshTargetId) === executionHostId)) - ) -} diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index b085362f4b83..7c9fed7d7cec 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -623,7 +623,6 @@ import { WorktreeResolvePrBase, WorktreeSelector as WorktreeSelectorOfWorktreeParams, WorktreeSet, - WorktreeSleep, WorktreeSortOrder, WorktreeTeardownMissingTerminalsParams } from './worktree-params' @@ -1381,7 +1380,7 @@ export const RPC_PARAMS_BY_METHOD = { 'worktree.rm': WorktreeRemove, 'worktree.set': WorktreeSet, 'worktree.show': WorktreeSelectorOfWorktreeParams, - 'worktree.sleep': WorktreeSleep, + 'worktree.sleep': WorktreeSelectorOfWorktreeParams, 'worktree.teardownMissingTerminals': WorktreeTeardownMissingTerminalsParams } as const diff --git a/src/shared/rpc-contract/worktree-params.ts b/src/shared/rpc-contract/worktree-params.ts index 301e2da1ba53..bf8d79b0ddc4 100644 --- a/src/shared/rpc-contract/worktree-params.ts +++ b/src/shared/rpc-contract/worktree-params.ts @@ -86,8 +86,6 @@ export const WorktreeSelector = z.object({ .pipe(z.string().min(1, 'Missing worktree selector')) }) -export const WorktreeSleep = WorktreeSelector.extend({ hostId: OptionalExecutionHostId }) - export const WorktreeActivate = WorktreeSelector.extend({ notifyClients: OptionalBoolean, navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional() From 01a6eabab9b7e09cc778ed6df1835d3d752d8dad Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:17:55 -0700 Subject: [PATCH 26/30] test: drop redundant casts from the Sleep flow test state --- .../sidebar/sleep-worktree-activation-race.test.ts | 6 +++--- .../src/components/sidebar/sleep-worktree-flow.test.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts index 2459b81a45c3..d5f0a8d6d56e 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts @@ -12,10 +12,10 @@ const mocks = vi.hoisted(() => { state.ptyIdsByTabId[tab.id] = [] } }), - tabsByWorktree: {} as Record, - ptyIdsByTabId: {} as Record, worktreesByRepo: {}, - detectedWorktreesByRepo: {} + detectedWorktreesByRepo: {}, + tabsByWorktree: {} as Record, + ptyIdsByTabId: {} as Record } const activateAndRevealWorktree = vi.fn() const activateAndRevealFolderWorkspace = vi.fn() diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index 5f645019af59..9b7627cde019 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -21,8 +21,8 @@ const mocks = vi.hoisted(() => { shutdownWorktreeTerminals: vi.fn().mockResolvedValue(undefined), suppressPtyExit: vi.fn(), consumeSuppressedPtyExit: vi.fn(), - tabsByWorktree: {} as Record, - ptyIdsByTabId: {} as Record, + tabsByWorktree: {}, + ptyIdsByTabId: {}, worktreesByRepo: {}, detectedWorktreesByRepo: {} } From 06ccbe2692a2a2fd33e4395fe1ee63f7cde188c3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:27:00 -0700 Subject: [PATCH 27/30] fix(sidebar): stop a workspace's chats after its terminals, leaving main's teardown owner check as is --- .../orca-runtime-workspace-ports-host.test.ts | 22 ------------------- .../sidebar/sleep-worktree-flow.test.ts | 6 +++-- .../components/sidebar/sleep-worktree-flow.ts | 14 +++--------- .../src/store/terminals/terminal-shutdown.ts | 22 +++++-------------- 4 files changed, 12 insertions(+), 52 deletions(-) diff --git a/src/main/runtime/orca-runtime-workspace-ports-host.test.ts b/src/main/runtime/orca-runtime-workspace-ports-host.test.ts index df6b255c7bdb..46378708d0e2 100644 --- a/src/main/runtime/orca-runtime-workspace-ports-host.test.ts +++ b/src/main/runtime/orca-runtime-workspace-ports-host.test.ts @@ -1,7 +1,6 @@ import './rpc/unused-default-rpc-methods.test-fixture' import { afterEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeWithListManagedWorktrees } from './workspaces/managed-workspace-operations' -import { RuntimeWorkspaceSelectors } from './workspaces/workspace-selectors' const scanWorkspacePortsMock = vi.hoisted(() => vi.fn()) @@ -26,27 +25,6 @@ function runtimeResolvingTo(executionHostId: string) { } describe('host-scoped workspace port scans on the server', () => { - it('sleeps a host-qualified Windows alias without choosing a foreign or ambiguous row', async () => { - const id = 'repo::D:/Agentic/game2/battle-core' - const alias = 'repo::D:\\Agentic\\game2\\battle-core' - const selected = { id, repoId: 'repo', hostId: 'runtime:hub' } - let rows = [selected, { ...selected, id: alias, hostId: 'local' }] - const selectors = RuntimeWorkspaceSelectors.prototype - const host = { - store: { getRepo: () => undefined }, - notifier: { sleepWorktree: vi.fn() }, - hasFreshResolvedWorktreeCache: () => true, - getValidatedExplicitWorktreeIdSelector: selectors['getValidatedExplicitWorktreeIdSelector'], - resolveWorktreeSelector: selectors['resolveWorktreeSelector'], - listResolvedWorktrees: async () => rows - } - const sleep = () => proto.sleepManagedWorktree.call(host, `id:${alias}`, 'runtime:hub') - await expect(sleep()).resolves.toEqual({ worktreeId: id, executionHostId: 'runtime:hub' }) - expect(host.notifier.sleepWorktree).toHaveBeenCalledWith(id, 'runtime:hub') - rows = [selected, { ...selected, id: `${id}/` }] - await expect(sleep()).rejects.toThrow('selector_ambiguous') - }) - afterEach(() => { scanWorkspacePortsMock.mockReset() vi.restoreAllMocks() diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index 9b7627cde019..c25e6ecf069f 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -386,13 +386,15 @@ describe('runSleepWorktree', () => { ]) }) - it('stops nothing when it cannot tell which copy runs the terminals', async () => { + it('stops no chat when it cannot tell which copy runs the terminals', async () => { mocks.state.worktreesByRepo = twinRows + mocks.state.shutdownWorktreeTerminals.mockRejectedValueOnce( + new Error('ambiguous_terminal_owner') + ) await runSleepWorktree(twinId) expect(mocks.callChat).not.toHaveBeenCalled() - expect(mocks.state.shutdownWorktreeBrowsers).not.toHaveBeenCalled() expect(mocks.toastError).toHaveBeenCalledOnce() }) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index a04d5bd8c036..9d271df25473 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -8,7 +8,6 @@ import { } from '@/lib/worktree-sleep-intent' import { translate } from '@/i18n/i18n' import type { ExecutionHostId } from '../../../../shared/execution-host' -import { resolveWorktreeTerminalTeardownOwner } from '@/store/terminals/terminal-shutdown' import { preserveSidebarWorktreePosition } from './sleep-worktree-sidebar-position' import { stopWorkspaceChats } from './sleep-workspace-chat-stop' @@ -146,15 +145,6 @@ export async function sleepWorktreesReportingFailures( // until an explicit wake (#10205); mark per workspace so an earlier // slow teardown never leaves a later, still-awake one marked. markWorktreeSleepIntent(worktreeId) - // Why first: the terminal teardown would refuse later, after browsers and chats stopped. - if ( - resolveWorktreeTerminalTeardownOwner(useAppStore.getState(), worktreeId).kind === 'refused' - ) { - failedWorktreeIds.add(worktreeId) - errors.push(describeSleepFailure(new Error('ambiguous_terminal_owner'))) - continue - } - const chatStop = stopWorkspaceChats(useAppStore.getState(), worktreeId) try { // Why: sleep mirrors removeWorktree's shutdown sequence — browsers first // so destroyPersistentWebview unregisters the Chromium guests before any @@ -193,8 +183,10 @@ export async function sleepWorktreesReportingFailures( }) failedWorktreeIds.add(worktreeId) errors.push(describeSleepFailure(err)) + continue } - const { receipts, failed } = await chatStop + // Why after terminals: a teardown refused for an unattributable twin then stops no chat. + const { receipts, failed } = await stopWorkspaceChats(useAppStore.getState(), worktreeId) if (failed) { chatFailedIds.add(worktreeId) errors.push(describeChatStopFailure()) diff --git a/src/renderer/src/store/terminals/terminal-shutdown.ts b/src/renderer/src/store/terminals/terminal-shutdown.ts index aa737ac09b64..7ce850f344de 100644 --- a/src/renderer/src/store/terminals/terminal-shutdown.ts +++ b/src/renderer/src/store/terminals/terminal-shutdown.ts @@ -8,10 +8,9 @@ import { type AgentStatusWorktreeShutdownReason } from '../slices/agent-status' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' -import type { AppState } from '../types' import { equalStringSets, sortedUniquePtyIds } from './terminal-pty-identities' import { resolveRuntimeOwnerForWorktree } from '@/lib/worktree-runtime-owner' -import { resolveTerminalTeardownOwner, type TerminalTeardownOwner } from './terminal-teardown-owner' +import { resolveTerminalTeardownOwner } from './terminal-teardown-owner' import { createTerminalShutdownGuardController } from './terminal-shutdown-guards' import { commitTerminalShutdownState } from './terminal-shutdown-state' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' @@ -24,20 +23,6 @@ type ExactTerminalStopResult = { postStopFailure?: string } -/** Which host must stop this worktree's terminals; Sleep reads it before stopping anything. */ -export function resolveWorktreeTerminalTeardownOwner( - state: AppState, - worktreeId: string -): TerminalTeardownOwner { - const ptyIds = (state.tabsByWorktree[worktreeId] ?? []).flatMap( - (tab) => state.ptyIdsByTabId[tab.id] ?? [] - ) - return resolveTerminalTeardownOwner( - resolveRuntimeOwnerForWorktree(state, worktreeId), - sortedUniquePtyIds(ptyIds) - ) -} - export function createTerminalShutdownActions( set: TerminalStoreSet, get: TerminalStoreGet @@ -53,7 +38,10 @@ export function createTerminalShutdownActions( ) const expectedRuntimePtyIds = sortedUniquePtyIds(opts?.expectedRuntimePtyIds) const backendOwnsPtyTeardown = opts?.backendOwnsPtyTeardown === true - const teardownOwner = resolveWorktreeTerminalTeardownOwner(get(), worktreeId) + const teardownOwner = resolveTerminalTeardownOwner( + resolveRuntimeOwnerForWorktree(get(), worktreeId), + rendererShutdownPtyIds + ) // Why: treating an unattributable owner as local skips the host stop yet still clears // bindings and writes resume records, so the agent runs on unseen and wake starts a twin. // A backend-owned teardown already stopped its PTYs, so it has nothing to misroute. From 6f1266b5ee549167383511808330c77d5caab888 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:17:38 -0700 Subject: [PATCH 28/30] fix: admit workspace chat stops before teardown waits --- ...uctured-agent-session-acquisition-route.ts | 114 +++++++++++++ ...structured-agent-session-adapter-router.ts | 80 +++------ .../structured-agent-session-adapter.ts | 13 +- ...red-agent-session-conversation-lifetime.ts | 3 +- ...-agent-session-startup-attempt-contract.ts | 14 +- ...t-session-workspace-sleep-lifetime.test.ts | 105 +++++++++++- ...ured-agent-session-workspace-sleep-stop.ts | 23 +++ ...ent-session-acquisition-exit-transition.ts | 55 +++++++ .../sidebar/sleep-worktree-admission.test.ts | 152 ++++++++++++++++++ .../sidebar/sleep-worktree-flow.test.ts | 85 ++++------ .../components/sidebar/sleep-worktree-flow.ts | 105 ++++++------ .../slices/browser/browser-close-actions.ts | 2 +- .../slices/browser/browser-slice-contract.ts | 2 +- .../src/store/terminals/terminal-actions.ts | 1 + .../src/store/terminals/terminal-shutdown.ts | 1 + 15 files changed, 588 insertions(+), 167 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-route.ts create mode 100644 src/main/runtime/agent-session-acquisition-exit-transition.ts create mode 100644 src/renderer/src/components/sidebar/sleep-worktree-admission.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-route.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-route.ts new file mode 100644 index 000000000000..8099ad8093a0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-route.ts @@ -0,0 +1,114 @@ +import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' +import type { StructuredAgentSessionAcquisitionReceipt } from './structured-agent-session-startup-attempt-contract' +import type { StructuredAgentRegistration } from './structured-agent-registry' +import type { + StructuredAgentSessionAcquireInput, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' + +export type SessionRoute = { + registration: StructuredAgentRegistration + state: 'acquiring' | 'live' | 'stopped' + acquisition?: StructuredAgentSessionAcquisitionReceipt +} + +/** The route retains the actual startup identity until its exit is durably acknowledged. */ +export async function acquireSessionRoute( + input: StructuredAgentSessionAcquireInput, + registration: StructuredAgentRegistration, + routes: Map, + isClosed: () => boolean +) { + const route: SessionRoute = { + registration, + state: 'acquiring', + ...(input.attemptId && input.launch + ? { + acquisition: { + attemptId: input.attemptId, + fence: input.fence, + spawnToken: input.spawnToken, + location: input.launch.location + } + } + : {}) + } + routes.set(input.identity.sessionId, route) + try { + const acquired = await registration.adapter.acquire({ + ...input, + onSpawned: async (process) => { + if (route.acquisition) { + route.acquisition.process = process + } + await input.onSpawned?.(process) + } + }) + if (route.acquisition) { + route.acquisition.process = acquired.process + } + if (isClosed()) { + throw new Error('structured session adapter router is closed') + } + route.state = 'live' + return acquired + } catch (error) { + if ( + routes.get(input.identity.sessionId) === route && + !registration.adapter.hasOwnedAcquisition?.(input.identity.sessionId) + ) { + routes.delete(input.identity.sessionId) + } + throw error + } +} + +/** Releases only the actual routed acquisition, retaining its observed exit until acknowledged. */ +export async function releaseSessionRoute( + input: { sessionId: string }, + routes: Map, + adapters: () => StructuredAgentSessionAdapter[] +): Promise { + const route = routes.get(input.sessionId) + if (route) { + if (route.state === 'stopped') { + return true + } + if (!route.registration.adapter.hasOwnedAcquisition?.(input.sessionId)) { + if (routes.get(input.sessionId) === route) { + routes.delete(input.sessionId) + } + return false + } + try { + let exitReport: Error | undefined + const stopped = await stopAgentSessionProviderRoot( + () => route.registration.adapter.releaseAcquisition?.(input) ?? Promise.resolve(false), + (error) => { + exitReport = error + } + ) + if (stopped && routes.get(input.sessionId) === route) { + route.state = 'stopped' + } + if (exitReport) { + throw exitReport + } + return stopped + } catch (error) { + if ( + routes.get(input.sessionId) === route && + route.state !== 'stopped' && + !route.registration.adapter.hasOwnedAcquisition?.(input.sessionId) + ) { + routes.delete(input.sessionId) + } + throw error + } + } + let released = false + for (const candidate of adapters()) { + released = (await candidate.releaseAcquisition?.(input)) === true || released + } + return released +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 69d1001d6964..46c447319456 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -5,16 +5,18 @@ import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionAcquisitionReceipt } from './structured-agent-session-startup-attempt-contract' import { readNativeSessionOptions } from './structured-agent-session-option-restoration' import type { StructuredAgentRegistration, StructuredAgentRegistry } from './structured-agent-registry' -type SessionRoute = { - registration: StructuredAgentRegistration - state: 'acquiring' | 'live' | 'stopped' -} +import { + acquireSessionRoute, + releaseSessionRoute, + type SessionRoute +} from './structured-agent-session-acquisition-route' /** Routes each session to the adapter its agent is registered with. Adding an agent is one more * registration; nothing here names one. */ @@ -44,56 +46,11 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi throw new Error('structured session adapter router is closed') } const registration = this.requireAgent(input.identity) - const route: SessionRoute = { registration, state: 'acquiring' } - this.routes.set(input.identity.sessionId, route) - try { - const acquired = await registration.adapter.acquire(input) - if (this.allAdaptersClosed) { - throw new Error('structured session adapter router is closed') - } - route.state = 'live' - return acquired - } catch (error) { - if ( - this.routes.get(input.identity.sessionId) === route && - !registration.adapter.hasOwnedAcquisition?.(input.identity.sessionId) - ) { - this.routes.delete(input.identity.sessionId) - } - throw error - } + return acquireSessionRoute(input, registration, this.routes, () => this.allAdaptersClosed) } async releaseAcquisition(input: { sessionId: string }): Promise { - const route = this.routes.get(input.sessionId) - if (route) { - if (!route.registration.adapter.hasOwnedAcquisition?.(input.sessionId)) { - if (this.routes.get(input.sessionId) === route) { - this.routes.delete(input.sessionId) - } - return false - } - try { - const stopped = (await route.registration.adapter.releaseAcquisition?.(input)) === true - if (stopped && this.routes.get(input.sessionId) === route) { - this.routes.delete(input.sessionId) - } - return stopped - } catch (error) { - if ( - this.routes.get(input.sessionId) === route && - !route.registration.adapter.hasOwnedAcquisition?.(input.sessionId) - ) { - this.routes.delete(input.sessionId) - } - throw error - } - } - let released = false - for (const candidate of this.adapters()) { - released = (await candidate.releaseAcquisition?.(input)) === true || released - } - return released + return releaseSessionRoute(input, this.routes, () => this.adapters()) } open: NonNullable = (input) => { @@ -285,7 +242,17 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi hasOwnedAcquisition = (sessionId: string): boolean => { const route = this.routes.get(sessionId) - return route?.registration.adapter.hasOwnedAcquisition?.(sessionId) === true + return ( + (route?.state === 'stopped' && !!route.acquisition) || + route?.registration.adapter.hasOwnedAcquisition?.(sessionId) === true + ) + } + + stoppedAcquisition = ( + sessionId: string + ): StructuredAgentSessionAcquisitionReceipt | undefined => { + const route = this.routes.get(sessionId) + return route?.state === 'stopped' ? route.acquisition : undefined } async closeAll(): Promise { @@ -314,8 +281,13 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi } /** Drops a per-session stop receipt after the host releases its durable owner. */ - acknowledgeSessionRelease = (sessionId: string): void => { - this.routes.delete(sessionId) + acknowledgeSessionRelease = ( + sessionId: string, + acquisition?: StructuredAgentSessionAcquisitionReceipt + ): void => { + if (!acquisition || this.stoppedAcquisition(sessionId) === acquisition) { + this.routes.delete(sessionId) + } } private owner(sessionId: string): StructuredAgentSessionAdapter { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 9990958af3a0..be3705465535 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -54,7 +54,10 @@ export type { import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' -import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract' +import type { + StructuredAgentSessionStartupAttempt, + StructuredAgentSessionAcquisitionReceipt +} from './structured-agent-session-startup-attempt-contract' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' import type { StructuredAgentSessionProviderOpen } from './structured-agent-session-provider-open-contract' @@ -285,8 +288,9 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & * `AgentSessionAcquisitionRootExitObservedError` when the provider root's own * exit was observed first-hand but its descendants were not proven gone. */ releaseAcquisition?(input: { sessionId: string }): Promise - /** Whether this adapter still owns a current child or failed acquisition for this session. */ + /** Whether this adapter owns a child, failed acquisition, or unacknowledged acquisition receipt. */ hasOwnedAcquisition?(sessionId: string): boolean + stoppedAcquisition?(sessionId: string): StructuredAgentSessionAcquisitionReceipt | undefined dispatch(input: { sessionId: string clientMessageId: string @@ -448,5 +452,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & /** Stops a provider child for teardown without requiring a future-resume cursor. */ disposeSession?(sessionId: string): Promise /** Host acknowledgement that the proven-dead child, lease and journal owner are released. */ - acknowledgeSessionRelease?(sessionId: string): void + acknowledgeSessionRelease?( + sessionId: string, + acquisition?: StructuredAgentSessionAcquisitionReceipt + ): void } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 66e7c6fefb0d..da63a25dee2c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -150,7 +150,8 @@ export function createStructuredAgentSessionConversationLifetime(host: { sessionId, fence, child, - owned + owned, + now: () => host.context().now() }), (error) => deps().logger.warn('closing a stopped workspace chat handle failed', { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts index 14767b058d1f..4d3c509f98f6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts @@ -2,7 +2,11 @@ // module, so it must not pull in the host or Node-only process code. import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionRecord, + AgentSessionExecutionLocation, + AgentSessionProcessIdentity +} from '../../../shared/agent-session-record' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' @@ -42,3 +46,11 @@ export type StructuredAgentSessionStartupAttempt = { /** Any stdout frame or stderr line from the child: proof the start is still moving. */ readonly onOutput?: () => void } + +export type StructuredAgentSessionAcquisitionReceipt = Pick< + StructuredAgentSessionStartupAttempt, + 'attemptId' | 'fence' | 'spawnToken' +> & { + location: AgentSessionExecutionLocation + process?: AgentSessionProcessIdentity +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts index 2cac132a3367..8c534fc6f976 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts @@ -1,10 +1,21 @@ +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { + adapterAtPublishFor, + fakeClaude, + PROVIDER_SESSION_ID +} from '../../claude/claude-structured-session-test-support' +import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' +import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' +import { StructuredAgentSessionHost } from './structured-agent-session-host' import { expect, it, vi } from 'vitest' import { hostTestMessage } from './structured-agent-session-host-test-data' import { attach, CALLER, envelope, - hostTestState + hostTestState, + replaceHostTestState, + attachParams } from './structured-agent-session-host-test-harness' it('omits an unverified stop superseded by a newer send while retaining a sibling receipt', async () => { @@ -54,3 +65,95 @@ it('omits an unverified stop superseded by a newer send while retaining a siblin } expect(await sleeping).toEqual({ stoppedSessionIds: [sibling] }) }) + +it.each(['persist', 'root-exit', 'retry', 'successor'] as const)( + 'retains failed-acquisition exit proof across %s', + async (mode) => { + const { host: original, store, root } = hostTestState() + const claude = fakeClaude({ + unprovenCloseVerdict: { root: 'unverifiable', tree: 'unverifiable' } + }) + const open = claude.openConnection + claude.openConnection = async (...args) => { + const connection = await open(...args) + claude.connections[0].closed = true + return connection + } + const adapter = adapterAtPublishFor(claude) + const agents = claudeAndCodexAgents(adapter) + const router = new StructuredAgentSessionAdapterRouter(agents, async () => {}) + const host = new StructuredAgentSessionHost({ ...original.deps, agents, adapter: router }) + replaceHostTestState({ host, store }) + await expect( + host.attach( + CALLER, + attachParams({ + agent: 'claude', + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/accounts/claude' }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + ) + ).rejects.toThrow('agent_session_acquisition_exit_unproven') + expect(router.hasOwnedAcquisition('session-alpha')).toBe(true) + expect(store.getRecord('session-alpha')?.lease.deathEvidence).toBeNull() + const connection = claude.connections[0] + connection.close = vi.fn(async () => mode !== 'root-exit') + if (mode === 'root-exit') { + connection.exitVerdict = { root: 'exited', tree: 'unverifiable' } + } + if (mode === 'retry' || mode === 'successor') { + vi.spyOn(store, 'transitionHandoff').mockRejectedValueOnce(new Error('evidence write failed')) + } + expect(await host.sleepWorkspace('workspace-1')).toEqual({ + stoppedSessionIds: ['session-alpha'] + }) + if (mode === 'retry' || mode === 'successor') { + expect(store.getRecord('session-alpha')?.lease.deathEvidence).toBeNull() + expect(router.stoppedAcquisition('session-alpha')).toBeDefined() + } + if (mode === 'successor') { + const body = hostTestMessage('send after failed exit bookkeeping') + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(claude.connections).toHaveLength(2)) + claude.connections[1].close = async () => true + expect(router.stoppedAcquisition('session-alpha')).toBeUndefined() + expect(store.getRecord('session-alpha')?.lease.deathEvidence).toBeNull() + return + } + expect(await host.sleepWorkspace('workspace-1')).toEqual({ + stoppedSessionIds: ['session-alpha'] + }) + expect(connection.close).toHaveBeenCalledOnce() + expect(router.stoppedAcquisition('session-alpha')).toBeUndefined() + const reopened = await openTestAgentSessionRecordStore(root) + const restarted = new StructuredAgentSessionHost({ ...host.deps, store: reopened }) + expect(reopened.getRecord('session-alpha')?.lease.deathEvidence).toMatchObject({ + kind: 'exit-observed', + ownerFence: 1 + }) + await restarted.restoreReadableSessions() + expect(await restarted.sleepWorkspace('workspace-1')).toEqual({ + stoppedSessionIds: ['session-alpha'] + }) + } +) + +it('keeps a send admitted after the host Sleep close alive', async () => { + const { host, acquire, dispatch } = hostTestState() + await attach() + const close = vi.fn(async () => true) + host.deps.adapter.closeSession = close + const sleeping = host.sleepWorkspace('workspace-1') + const body = hostTestMessage('phone send after Sleep admission') + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + await sleeping + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(acquire).toHaveBeenCalledTimes(2) + expect(close).toHaveBeenCalledOnce() + expect(host.hasSession('session-alpha')).toBe(true) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts index 533323952b69..cb7b9a43f54d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts @@ -1,3 +1,4 @@ +import { recordAgentSessionAcquisitionExit } from '../../runtime/agent-session-acquisition-exit-transition' import { agentSessionLeaseIsReleased, agentSessionLeaseOwnerVerdict @@ -21,6 +22,7 @@ export async function stopWorkspaceSleepChat(input: { fence: number child: StructuredAgentSessionProviderChild | null | undefined owned: boolean + now: () => number }): Promise { const { deps, sessions, sessionId, child } = input let ownedStopped = false @@ -31,6 +33,27 @@ export async function stopWorkspaceSleepChat(input: { ownedStopped = await stopAgentSessionProviderRoot( () => deps.adapter.releaseAcquisition?.({ sessionId }) ?? Promise.resolve(false) ) + const receipt = deps.adapter.stoppedAcquisition?.(sessionId) + if (ownedStopped && receipt) { + try { + await deps.store.transitionHandoff(sessionId, (record) => { + if ( + deps.adapter.stoppedAcquisition?.(sessionId) !== receipt || + sessions.get(sessionId)?.child + ) { + throw new Error('agent_session_ownership_unknown') + } + return recordAgentSessionAcquisitionExit(record, receipt, input.fence, input.now()) + }) + deps.adapter.acknowledgeSessionRelease?.(sessionId, receipt) + } catch (error) { + deps.logger.warn('saving failed acquisition exit evidence failed', { + scope: 'workspace-chat-stop', + sessionId, + error + }) + } + } } } catch (error) { deps.logger.warn('workspace chat stop was not proven', { diff --git a/src/main/runtime/agent-session-acquisition-exit-transition.ts b/src/main/runtime/agent-session-acquisition-exit-transition.ts new file mode 100644 index 000000000000..a88ead3026c9 --- /dev/null +++ b/src/main/runtime/agent-session-acquisition-exit-transition.ts @@ -0,0 +1,55 @@ +import { isDeepStrictEqual } from 'node:util' +import { + agentSessionExecutionLocationsEqual, + type AgentSessionRecord +} from '../../shared/agent-session-record' +import { agentSessionLeaseIsReleased } from '../../shared/agent-session-lease-adjudication' +import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' +import type { StructuredAgentSessionAcquisitionReceipt } from '../native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract' +import { assertFence, withLease } from './agent-session-lease-transitions' + +/** An unpublished start's observed exit belongs to its reservation, never a later owner. */ +export function recordAgentSessionAcquisitionExit( + record: AgentSessionRecord, + receipt: StructuredAgentSessionAcquisitionReceipt, + expectedFence: number, + now: number +): AgentSessionRecord { + assertFence(record.lease, expectedFence) + const lease = record.lease + const released = + agentSessionLeaseIsReleased(lease) && + lease.ownerProcess === null && + lease.runtimeFence === nextAgentSessionFence({ ...lease, runtimeFence: receipt.fence }) + const owner = lease.ownerProcess + const recovering = + lease.handoffStage === 'recovering' && + lease.handoffOperationId === null && + lease.runtimeFence === receipt.fence && + lease.reservedSpawnToken === receipt.spawnToken && + owner !== null && + receipt.process !== undefined && + isDeepStrictEqual({ ...owner, runtime: undefined }, { ...receipt.process, runtime: undefined }) + if ( + !agentSessionExecutionLocationsEqual(record.location, receipt.location) || + (!released && !recovering) + ) { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...lease, + runtimeFence: released ? lease.runtimeFence : nextAgentSessionFence(lease), + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released', + handoffStage: null, + handoffOperationId: null, + lastRenewedAt: now, + deathEvidence: { + kind: 'exit-observed', + detail: 'the failed acquisition provider root exited', + ownerFence: receipt.fence, + observedAt: now + } + }) +} diff --git a/src/renderer/src/components/sidebar/sleep-worktree-admission.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-admission.test.ts new file mode 100644 index 000000000000..b753e677bd8e --- /dev/null +++ b/src/renderer/src/components/sidebar/sleep-worktree-admission.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { + createTestStore, + makeRuntimeOwnedWorktree, + makeTab, + makeWorktree, + seedStore +} from '@/store/slices/store-test-helpers' +import { + clearWorktreeSleepIntent, + forgetWorktreeSleepIntent, + hasWorktreeSleepIntent +} from '@/lib/worktree-sleep-intent' + +const mocks = vi.hoisted(() => ({ + chat: vi.fn(), + terminal: vi.fn(), + suspend: vi.fn(), + toast: vi.fn() +})) +let store: ReturnType +vi.mock('@/store', () => ({ useAppStore: { getState: () => store.getState() } })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.chat +})) +vi.mock('@/runtime/remote-worktree-sleep', () => ({ requestRemoteWorktreeSleep: mocks.terminal })) +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + unregisterPtyDataHandlers: () => [], + restorePtyDataHandlersAfterFailedShutdown: vi.fn() +})) +vi.mock('sonner', () => ({ toast: { error: mocks.toast } })) +import { runSleepWorktrees } from './sleep-worktree-flow' + +const ids = ['repo1::/one', 'repo1::/two'] +beforeEach(() => { + vi.clearAllMocks() + ids.forEach(forgetWorktreeSleepIntent) + store = createTestStore() + seedStore(store, { + worktreesByRepo: { + repo1: ids.map((id) => + makeRuntimeOwnedWorktree({ id, repoId: 'repo1', hostId: 'runtime:env-a' }, 'env-a') + ) + }, + tabsByWorktree: Object.fromEntries(ids.map((id) => [id, [makeTab({ id, worktreeId: id })]])), + ptyIdsByTabId: Object.fromEntries(ids.map((id) => [id, [`remote:env-a@@${id}`]])) + }) + mocks.chat.mockResolvedValue({ stoppedSessionIds: ['chat'] }) + mocks.terminal.mockResolvedValue(undefined) + mocks.suspend.mockResolvedValue(null) + vi.stubGlobal('window', { + api: { ephemeralVm: { suspendWorkspace: mocks.suspend }, pty: { kill: vi.fn() } } + }) +}) + +it('admits both real terminal actions and chat stops before a held first terminal, then honors wake', async () => { + const browser = store.getState().shutdownWorktreeBrowsers + store.setState({ + shutdownWorktreeBrowsers: (id) => { + browser(id) + if (id === ids[1]) { + store.setState({ + ptyIdsByTabId: { + ...store.getState().ptyIdsByTabId, + [id]: [`remote:env-a@@replacement-${id}`] + } + }) + } + } + }) + const first = Promise.withResolvers() + mocks.terminal.mockImplementation(({ worktreeId }: { worktreeId: string }) => + worktreeId === ids[0] ? first.promise : Promise.resolve() + ) + const sleeping = runSleepWorktrees(ids) + try { + expect(mocks.terminal).toHaveBeenCalledTimes(2) + expect(mocks.terminal).toHaveBeenLastCalledWith({ worktreeId: ids[1], environmentId: 'env-a' }) + expect(mocks.chat.mock.calls.map(([, , params]) => params.workspaceId)).toEqual(ids) + await vi.waitFor(() => expect(store.getState().ptyIdsByTabId[ids[1]]).toEqual([])) + expect(store.getState().suppressedPtyExitIds[`remote:env-a@@replacement-${ids[1]}`]).toBe(true) + clearWorktreeSleepIntent(ids[1]) + expect(hasWorktreeSleepIntent(ids[1])).toBe(false) + expect(mocks.suspend).not.toHaveBeenCalled() + } finally { + first.resolve() + } + const results = await sleeping + expect(mocks.chat).toHaveBeenCalledTimes(2) + expect(mocks.suspend).toHaveBeenCalledExactlyOnceWith({ workspaceId: ids[0] }) + expect(results.find((result) => result.workspaceId === ids[1])?.completed).toBe(false) +}) + +it('joins every admitted chat before VM suspend even when one terminal fails', async () => { + const chat = Promise.withResolvers<{ stoppedSessionIds: string[] }>() + mocks.chat.mockImplementation((_target, _method, { workspaceId }) => + workspaceId === ids[0] + ? chat.promise + : Promise.resolve({ + stoppedSessionIds: ['second'], + failures: [{ reason: 'stop-unverifiable' }] + }) + ) + mocks.terminal.mockRejectedValueOnce(new Error('terminal_failed')) + const sleeping = runSleepWorktrees(ids) + try { + await vi.waitFor(() => expect(mocks.terminal).toHaveBeenCalledTimes(2)) + expect(mocks.chat).toHaveBeenCalledTimes(2) + expect(mocks.suspend).not.toHaveBeenCalled() + } finally { + chat.resolve({ stoppedSessionIds: ['first'] }) + } + const results = await sleeping + expect( + results.map(({ stoppedSessionIds, completed }) => ({ stoppedSessionIds, completed })) + ).toEqual([ + { stoppedSessionIds: ['first'], completed: false }, + { stoppedSessionIds: ['second'], completed: false } + ]) + expect(mocks.suspend).toHaveBeenCalledExactlyOnceWith({ workspaceId: ids[1] }) + expect(mocks.toast).toHaveBeenCalledOnce() +}) + +it('keeps the real chat-only twin refusal after synchronous browser cleanup', async () => { + const browser = store.getState().shutdownWorktreeBrowsers + store.setState({ + shutdownWorktreeBrowsers: (id) => { + browser(id) + seedTwin() + } + }) + const seedTwin = () => + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ id: ids[0], hostId: 'local' }), + makeRuntimeOwnedWorktree( + { id: ids[0], repoId: 'repo1', hostId: 'runtime:env-a' }, + 'env-a' + ) + ] + }, + tabsByWorktree: {}, + ptyIdsByTabId: {} + }) + store.setState({ browserTabsByWorktree: { [ids[0]]: [] } }) + await runSleepWorktrees([ids[0]]) + expect(store.getState().browserTabsByWorktree[ids[0]]).toBeUndefined() + expect(mocks.chat).not.toHaveBeenCalled() + expect(mocks.terminal).not.toHaveBeenCalled() + expect(mocks.toast).toHaveBeenCalledOnce() +}) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index c25e6ecf069f..865541279fe3 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' const mocks = vi.hoisted(() => { const state: { @@ -17,7 +18,7 @@ const mocks = vi.hoisted(() => { setActiveWorktree: vi.fn((worktreeId: string | null) => { state.activeWorktreeId = worktreeId }), - shutdownWorktreeBrowsers: vi.fn().mockResolvedValue(undefined), + shutdownWorktreeBrowsers: vi.fn(), shutdownWorktreeTerminals: vi.fn().mockResolvedValue(undefined), suppressPtyExit: vi.fn(), consumeSuppressedPtyExit: vi.fn(), @@ -77,8 +78,14 @@ describe('runSleepWorktree', () => { requestAnimationFrame: vi.fn() }) mocks.state.setActiveWorktree.mockClear() - mocks.state.shutdownWorktreeBrowsers.mockClear().mockResolvedValue(undefined) - mocks.state.shutdownWorktreeTerminals.mockClear().mockResolvedValue(undefined) + mocks.state.shutdownWorktreeBrowsers.mockReset() + mocks.state.shutdownWorktreeTerminals + .mockReset() + .mockImplementation( + async (_id: string, opts: Parameters[1]) => { + opts?.onAdmitted?.() + } + ) mocks.state.suppressPtyExit.mockClear() mocks.state.consumeSuppressedPtyExit.mockClear() mocks.suspendWorkspace.mockClear().mockResolvedValue(null) @@ -103,7 +110,8 @@ describe('runSleepWorktree', () => { // browserTabsByWorktree delete, browsers would no-op and leak webviews. expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenCalledWith('wt-1') expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenCalledWith('wt-1', { - keepIdentifiers: true + keepIdentifiers: true, + onAdmitted: expect.any(Function) }) expect(mocks.suspendWorkspace).toHaveBeenCalledWith({ workspaceId: 'wt-1' }) const browsersCallOrder = mocks.state.shutdownWorktreeBrowsers.mock.invocationCallOrder[0] @@ -219,50 +227,11 @@ describe('runSleepWorktree', () => { expect(mocks.clearWorktreeSleepIntent).not.toHaveBeenCalled() }) - it('leaves a worktree the user activated mid-batch awake', async () => { - let releaseFirst: () => void = () => {} - mocks.state.shutdownWorktreeBrowsers.mockImplementationOnce( - () => - new Promise((resolve) => { - releaseFirst = resolve - }) - ) - - const run = runSleepWorktrees(['wt-1', 'wt-2']) - await Promise.resolve() - // Why: the user clicked wt-2 while wt-1 was tearing down; sleeping it anyway - // must not leave the active workspace marked with no clear pending. - mocks.state.activeWorktreeId = 'wt-2' - releaseFirst() - await run - - expect(mocks.clearWorktreeSleepIntent).toHaveBeenLastCalledWith('wt-2') - }) - - it('marks each worktree only when its own teardown starts', async () => { - let releaseFirst: () => void = () => {} - mocks.state.shutdownWorktreeBrowsers.mockImplementationOnce( - () => - new Promise((resolve) => { - releaseFirst = resolve - }) - ) - - const run = runSleepWorktrees(['wt-1', 'wt-2']) - await Promise.resolve() - - // Why: wt-2 is still awake while wt-1 tears down; marking it early would - // hold its panes cold and swallow its activity. - expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') - expect(mocks.markWorktreeSleepIntent).not.toHaveBeenCalledWith('wt-2') - releaseFirst() - await run - expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-2') - }) - it('surfaces a toast and skips terminals when browsers throws', async () => { mocks.state.activeWorktreeId = 'wt-1' - mocks.state.shutdownWorktreeBrowsers.mockRejectedValueOnce(new Error('boom')) + mocks.state.shutdownWorktreeBrowsers.mockImplementationOnce(() => { + throw new Error('boom') + }) mocks.state.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } mocks.state.ptyIdsByTabId = { 'tab-1': ['pty-1'] } @@ -303,19 +272,21 @@ describe('runSleepWorktree', () => { it('continues sleeping later worktrees when one selected worktree fails', async () => { mocks.state.shutdownWorktreeBrowsers.mockImplementation((worktreeId: string) => { if (worktreeId === 'wt-1') { - return Promise.reject(new Error('first failed')) + throw new Error('first failed') } - return Promise.resolve() + return undefined }) await runSleepWorktrees(['wt-1', 'wt-2']) expect(mocks.state.shutdownWorktreeTerminals).not.toHaveBeenCalledWith('wt-1', { - keepIdentifiers: true + keepIdentifiers: true, + onAdmitted: expect.any(Function) }) expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenCalledWith('wt-2') expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenCalledWith('wt-2', { - keepIdentifiers: true + keepIdentifiers: true, + onAdmitted: expect.any(Function) }) expect(mocks.suspendWorkspace).toHaveBeenCalledWith({ workspaceId: 'wt-2' }) expect(mocks.toastError).toHaveBeenCalledWith( @@ -328,9 +299,11 @@ describe('runSleepWorktree', () => { }) it('reports failed worktrees to a caller that answers for them, without a toast', async () => { - mocks.state.shutdownWorktreeBrowsers.mockImplementation((worktreeId: string) => - worktreeId === 'wt-1' ? Promise.reject(new Error('first failed')) : Promise.resolve() - ) + mocks.state.shutdownWorktreeBrowsers.mockImplementation((worktreeId: string) => { + if (worktreeId === 'wt-1') { + throw new Error('first failed') + } + }) const outcome = await sleepWorktreesReportingFailures(['wt-1', 'wt-2']) @@ -348,11 +321,13 @@ describe('runSleepWorktree', () => { expect(mocks.state.setActiveWorktree).toHaveBeenCalledWith(null) expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenNthCalledWith(1, 'wt-1') expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenNthCalledWith(1, 'wt-1', { - keepIdentifiers: true + keepIdentifiers: true, + onAdmitted: expect.any(Function) }) expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenNthCalledWith(2, 'wt-2') expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenNthCalledWith(2, 'wt-2', { - keepIdentifiers: true + keepIdentifiers: true, + onAdmitted: expect.any(Function) }) expect(mocks.suspendWorkspace).toHaveBeenNthCalledWith(1, { workspaceId: 'wt-1' }) expect(mocks.suspendWorkspace).toHaveBeenNthCalledWith(2, { workspaceId: 'wt-2' }) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index 9d271df25473..3959cbc1039a 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -9,7 +9,7 @@ import { import { translate } from '@/i18n/i18n' import type { ExecutionHostId } from '../../../../shared/execution-host' import { preserveSidebarWorktreePosition } from './sleep-worktree-sidebar-position' -import { stopWorkspaceChats } from './sleep-workspace-chat-stop' +import { stopWorkspaceChats, type WorkspaceChatStop } from './sleep-workspace-chat-stop' /** One per host asked to stop the workspace's chats. */ export type WorkspaceSleepResult = { @@ -138,64 +138,63 @@ export async function sleepWorktreesReportingFailures( const failedWorktreeIds = new Set() // Why apart from failedWorktreeIds: stopped terminals stay stopped when only a chat stop fails. const chatFailedIds = new Set() - const results: WorkspaceSleepResult[] = [] - try { - for (const worktreeId of worktreeIds) { - // Why: the marker outlives teardown so the panes left mounted stay cold - // until an explicit wake (#10205); mark per workspace so an earlier - // slow teardown never leaves a later, still-awake one marked. - markWorktreeSleepIntent(worktreeId) - try { - // Why: sleep mirrors removeWorktree's shutdown sequence — browsers first - // so destroyPersistentWebview unregisters the Chromium guests before any - // other teardown runs, terminals second so the PTY kill uses the same - // ordering on both paths. Without the browser thunk here, sleep leaks - // browserPagesByWorkspace entries and live webviews for the slept worktree. - await withWorktreeSleepTeardown(worktreeId, () => shutdownWorktreeBrowsers(worktreeId)) - } catch (err) { - console.error('[sleep-worktree] browser shutdown failed', { worktreeId, error: err }) - failedWorktreeIds.add(worktreeId) - errors.push(describeSleepFailure(err)) - continue - } - try { - // Why: sleep is reversible — the tab record stays in tabsByWorktree, the - // layout stays in terminalLayoutsByTabId, only the live PTY processes are - // released. keepIdentifiers preserves tab.ptyId / ptyIdsByLeafId / - // lastKnownRelayPtyIdByTabId so wake re-spawns against the same on-disk - // history dir (local) or relay session id (SSH); it also captures - // serializer buffers into buffersByLeafId for SSH wake to reseed - // scrollback. See DESIGN_DOC_TERMINAL_HISTORY_FIX_V2.md §3.3.c. - await withWorktreeSleepTeardown(worktreeId, async () => { - await shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true }) - if (typeof window !== 'undefined' && window.api?.ephemeralVm?.suspendWorkspace) { - await window.api.ephemeralVm.suspendWorkspace({ workspaceId: worktreeId }) - } - }) - // Why: a workspace the user activated during the batch is awake by their choice. + const attempts = worktreeIds.map((worktreeId) => { + markWorktreeSleepIntent(worktreeId) + let chats: Promise = Promise.resolve({ receipts: [], failed: false }) + // Browser cleanup and the authoritative terminal admission run before any batch wait. + const panels = withWorktreeSleepTeardown(worktreeId, async () => { + shutdownWorktreeBrowsers(worktreeId) + await shutdownWorktreeTerminals(worktreeId, { + keepIdentifiers: true, + onAdmitted: () => { + chats = stopWorkspaceChats(useAppStore.getState(), worktreeId).catch((error: unknown) => { + console.error('[sleep-worktree] native chat stop failed', { worktreeId, error }) + return { receipts: [], failed: true } + }) + } + }) + }).then( + () => { if (useAppStore.getState().activeWorktreeId === worktreeId) { clearWorktreeSleepIntent(worktreeId) } - } catch (err) { - console.error('[sleep-worktree] terminal or host suspension failed', { - worktreeId, - error: err - }) + }, + (error: unknown) => { + console.error('[sleep-worktree] panel shutdown failed', { worktreeId, error }) failedWorktreeIds.add(worktreeId) - errors.push(describeSleepFailure(err)) - continue + errors.push(describeSleepFailure(error)) } - // Why after terminals: a teardown refused for an unattributable twin then stops no chat. - const { receipts, failed } = await stopWorkspaceChats(useAppStore.getState(), worktreeId) + ) + return { worktreeId, settled: panels.then(() => chats) } + }) + const results: WorkspaceSleepResult[] = [] + try { + // A VM may carry another workspace's host: settle the entire batch before suspending any. + const chatStops = await Promise.all(attempts.map(({ settled }) => settled)) + for (const [index, { worktreeId }] of attempts.entries()) { + const { receipts, failed } = chatStops[index] if (failed) { chatFailedIds.add(worktreeId) errors.push(describeChatStopFailure()) } - // Why re-read the marker: a wake during the chat wait keeps the workspace awake. - const completed = - !failed && !failedWorktreeIds.has(worktreeId) && hasWorktreeSleepIntent(worktreeId) + if (!failedWorktreeIds.has(worktreeId) && hasWorktreeSleepIntent(worktreeId)) { + try { + await withWorktreeSleepTeardown(worktreeId, async () => { + if (typeof window !== 'undefined' && window.api?.ephemeralVm?.suspendWorkspace) { + await window.api.ephemeralVm.suspendWorkspace({ workspaceId: worktreeId }) + } + }) + if (useAppStore.getState().activeWorktreeId === worktreeId) { + clearWorktreeSleepIntent(worktreeId) + } + } catch (error) { + console.error('[sleep-worktree] host suspension failed', { worktreeId, error }) + failedWorktreeIds.add(worktreeId) + errors.push(describeSleepFailure(error)) + } + } results.push( - ...receipts.map((receipt) => ({ workspaceId: worktreeId, ...receipt, completed })) + ...receipts.map((receipt) => ({ workspaceId: worktreeId, ...receipt, completed: false })) ) } } finally { @@ -210,6 +209,12 @@ export async function sleepWorktreesReportingFailures( return { failedWorktreeIds: [...new Set([...failedWorktreeIds, ...chatFailedIds])], errors: [...new Set(errors)], - results + results: results.map((result) => ({ + ...result, + completed: + !failedWorktreeIds.has(result.workspaceId) && + !chatFailedIds.has(result.workspaceId) && + hasWorktreeSleepIntent(result.workspaceId) + })) } } diff --git a/src/renderer/src/store/slices/browser/browser-close-actions.ts b/src/renderer/src/store/slices/browser/browser-close-actions.ts index 016a217f2ace..49096e3a5a25 100644 --- a/src/renderer/src/store/slices/browser/browser-close-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-close-actions.ts @@ -237,7 +237,7 @@ export function createBrowserCloseActions( } }, - shutdownWorktreeBrowsers: async (worktreeId) => { + shutdownWorktreeBrowsers: (worktreeId) => { const workspaces = get().browserTabsByWorktree[worktreeId] ?? [] // Why: snapshot before the loop — closeBrowserTab empties the array, so set() below couldn't recompute hadBrowserTabs. const hadBrowserTabs = workspaces.length > 0 diff --git a/src/renderer/src/store/slices/browser/browser-slice-contract.ts b/src/renderer/src/store/slices/browser/browser-slice-contract.ts index 40175ef8a32f..3ace125b1429 100644 --- a/src/renderer/src/store/slices/browser/browser-slice-contract.ts +++ b/src/renderer/src/store/slices/browser/browser-slice-contract.ts @@ -149,7 +149,7 @@ export type BrowserSlice = { profileId: string | null ) => Promise closeBrowserTab: (tabId: string, options?: { reason?: 'cleanup' }) => void - shutdownWorktreeBrowsers: (worktreeId: string) => Promise + shutdownWorktreeBrowsers: (worktreeId: string) => void reopenClosedBrowserTab: (worktreeId: string) => BrowserWorkspace | null /** Activation uses the tab's workspace unless a target is supplied; global selection * moves only when that workspace is active. */ diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 5ebd99bbd597..43c63c9883a6 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -148,6 +148,7 @@ export type TerminalActions = { sleepingPaneKeys?: string[] expectedRuntimePtyIds?: string[] backendOwnsPtyTeardown?: boolean + onAdmitted?: () => void } ) => Promise /** Captures resumable pane evidence and publishes suppression before exact physical teardown. */ diff --git a/src/renderer/src/store/terminals/terminal-shutdown.ts b/src/renderer/src/store/terminals/terminal-shutdown.ts index 7ce850f344de..926170fe5db0 100644 --- a/src/renderer/src/store/terminals/terminal-shutdown.ts +++ b/src/renderer/src/store/terminals/terminal-shutdown.ts @@ -101,6 +101,7 @@ export function createTerminalShutdownActions( guards.markShutdownPending() guards.unregisterHandlers() try { + opts?.onAdmitted?.() // Backend ownership is opt-in; the default owner RPC prevents remote PTY leaks. if (runtimeEnvironmentId && !backendOwnsPtyTeardown) { await (shutdownReason === 'manual-sleep' From ca38582cf1e9ce907c2e73d723ab5d51ea2b2af8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:21:32 -0700 Subject: [PATCH 29/30] fix: retain browser teardown caller sequencing --- .../src/store/folder-workspaces/folder-workspace-mutations.ts | 2 +- .../worktrees/teardown/removed-worktree-renderer-teardown.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts b/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts index db85a02f50de..25ab4f22eacd 100644 --- a/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts +++ b/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts @@ -291,7 +291,7 @@ export function createFolderWorkspaceMutationActions( if (!get().folderWorkspaces.some((workspace) => workspace.id === folderWorkspaceId)) { // Folder workspaces use the same browser registry key as worktrees; // tear down Chromium guests before purging the remaining renderer state. - await get().shutdownWorktreeBrowsers(workspaceKey) + await Promise.resolve(get().shutdownWorktreeBrowsers(workspaceKey)) get().purgeWorktreeTerminalState([workspaceKey]) deleteWorkspaceChatDrafts(chatDraftKeys) } diff --git a/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts index 4a4c0a4a2e6f..02f1bb57f296 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts @@ -58,7 +58,7 @@ export async function tearDownRemovedWorktreeRendererState(args: { } // Why: renderer state follows the successful backend result, so blocked dirty deletes keep their terminals intact. // Why browsers first: unregister Chromium guests before other teardown can intercept them (avoids a browser-state race). - await get().shutdownWorktreeBrowsers(worktreeId) + await Promise.resolve(get().shutdownWorktreeBrowsers(worktreeId)) await get().shutdownWorktreeTerminals(worktreeId, { shutdownReason: 'remove-worktree', // The backend removal above already killed the workspace's PTYs. From 4af4f66da9de0378b4fb9f84714c52514fe76ae9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:35:20 -0700 Subject: [PATCH 30/30] fix: retire failed acquisitions after observed root exit --- .../claude/claude-structured-session-close.ts | 4 ++++ ...gent-session-workspace-sleep-lifetime.test.ts | 16 +++++++++++----- .../sidebar/sleep-worktree-admission.test.ts | 2 +- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index e8c2e6155480..c7808390a836 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -292,6 +292,10 @@ export async function closeClaudeSession(input: { ) // Why: cancellation must preserve the same actionable verdict as published-session close. if (!(cleanupError instanceof AgentSessionAcquisitionExitUnprovenError)) { + // A proved-dead attempt cannot remain the owner blocking the next start. + if (attempt) { + input.acquisitions.deleteIfCurrent(input.sessionId, attempt) + } throw cleanupError } return false diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts index 8c534fc6f976..5b2d81155225 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts @@ -66,7 +66,7 @@ it('omits an unverified stop superseded by a newer send while retaining a siblin expect(await sleeping).toEqual({ stoppedSessionIds: [sibling] }) }) -it.each(['persist', 'root-exit', 'retry', 'successor'] as const)( +it.each(['persist', 'root-exit', 'retry', 'successor', 'root-successor'] as const)( 'retains failed-acquisition exit proof across %s', async (mode) => { const { host: original, store, root } = hostTestState() @@ -98,8 +98,8 @@ it.each(['persist', 'root-exit', 'retry', 'successor'] as const)( expect(router.hasOwnedAcquisition('session-alpha')).toBe(true) expect(store.getRecord('session-alpha')?.lease.deathEvidence).toBeNull() const connection = claude.connections[0] - connection.close = vi.fn(async () => mode !== 'root-exit') - if (mode === 'root-exit') { + connection.close = vi.fn(async () => mode !== 'root-exit' && mode !== 'root-successor') + if (mode === 'root-exit' || mode === 'root-successor') { connection.exitVerdict = { root: 'exited', tree: 'unverifiable' } } if (mode === 'retry' || mode === 'successor') { @@ -112,12 +112,18 @@ it.each(['persist', 'root-exit', 'retry', 'successor'] as const)( expect(store.getRecord('session-alpha')?.lease.deathEvidence).toBeNull() expect(router.stoppedAcquisition('session-alpha')).toBeDefined() } - if (mode === 'successor') { + if (mode === 'successor' || mode === 'root-successor') { const body = hostTestMessage('send after failed exit bookkeeping') expect( await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) ).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(claude.connections).toHaveLength(2)) + await vi + .waitFor(() => expect(claude.connections).toHaveLength(2)) + .finally(() => { + for (const opened of claude.connections) { + opened.close = async () => true + } + }) claude.connections[1].close = async () => true expect(router.stoppedAcquisition('session-alpha')).toBeUndefined() expect(store.getRecord('session-alpha')?.lease.deathEvidence).toBeNull() diff --git a/src/renderer/src/components/sidebar/sleep-worktree-admission.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-admission.test.ts index b753e677bd8e..d6f28753d448 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-admission.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-admission.test.ts @@ -133,7 +133,7 @@ it('keeps the real chat-only twin refusal after synchronous browser cleanup', as seedStore(store, { worktreesByRepo: { repo1: [ - makeWorktree({ id: ids[0], hostId: 'local' }), + makeWorktree({ id: ids[0], repoId: 'repo1', hostId: 'local' }), makeRuntimeOwnedWorktree( { id: ids[0], repoId: 'repo1', hostId: 'runtime:env-a' }, 'env-a'