diff --git a/src/renderer/src/components/TerminalSurface.tsx b/src/renderer/src/components/TerminalSurface.tsx index 023f19f7311..e352bbed614 100644 --- a/src/renderer/src/components/TerminalSurface.tsx +++ b/src/renderer/src/components/TerminalSurface.tsx @@ -5,6 +5,7 @@ import { TerminalSplitWorkspaceSurfaces } from './TerminalSplitWorkspaceSurfaces import { TerminalLegacyWorkspaceSurface } from './TerminalLegacyWorkspaceSurface' import { TerminalWorkspaceDialogs } from './TerminalWorkspaceDialogs' import type { TerminalController } from './use-terminal-controller' +import { WorkspaceActivationRecoverySurface } from './WorkspaceActivationRecoverySurface' export function TerminalSurface({ controller @@ -17,7 +18,7 @@ export function TerminalSurface({
+ {renderedActiveWorktreeId ? ( + + ) : null}
) diff --git a/src/renderer/src/components/WorkspaceActivationRecoverySurface.test.tsx b/src/renderer/src/components/WorkspaceActivationRecoverySurface.test.tsx new file mode 100644 index 00000000000..aa70649e480 --- /dev/null +++ b/src/renderer/src/components/WorkspaceActivationRecoverySurface.test.tsx @@ -0,0 +1,88 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WorkspaceActivationRecoverySurface } from './WorkspaceActivationRecoverySurface' +import { + publishWorkspaceActivationRecoveryPresentation, + resetWorkspaceActivationRecoveryPresentationsForTests +} from '@/lib/workspace-activation-recovery-presentation' + +const mocks = vi.hoisted(() => { + const state: { unifiedTabsByWorktree: Record } = { + unifiedTabsByWorktree: {} + } + return { state } +}) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state) +})) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getExecutionHostIdForWorktree: () => 'local' +})) + +const WORKSPACE_KEY = 'worktree-1' + +afterEach(() => { + cleanup() + resetWorkspaceActivationRecoveryPresentationsForTests() + mocks.state.unifiedTabsByWorktree = {} +}) + +describe('WorkspaceActivationRecoverySurface', () => { + it('renders a target-scoped actionable failure without adding a tab', () => { + const retry = vi.fn() + publishWorkspaceActivationRecoveryPresentation({ + workspaceKey: WORKSPACE_KEY, + executionHostId: 'local', + attemptId: 'failure-1', + kind: 'producer-failed', + detail: 'Agent executable was not found.', + retry + }) + + render() + + expect(screen.getByRole('alert').getAttribute('data-workspace-activation-recovery')).toBe( + 'producer-failed' + ) + expect(screen.getByText('Agent executable was not found.')).not.toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(retry).toHaveBeenCalledOnce() + expect(mocks.state.unifiedTabsByWorktree[WORKSPACE_KEY]).toBeUndefined() + }) + + it('renders bounded recovery progress as non-tab workspace content', () => { + publishWorkspaceActivationRecoveryPresentation({ + workspaceKey: WORKSPACE_KEY, + executionHostId: 'local', + attemptId: 'progress-1', + kind: 'recovering', + retry: vi.fn() + }) + + render() + + expect(screen.getByRole('status').getAttribute('data-workspace-activation-recovery')).toBe( + 'recovering' + ) + expect(screen.queryByRole('tab')).toBeNull() + }) + + it.each([ + ['blocked', 'Workspace recovery is paused'], + ['unexpected', 'Workspace recovery failed'] + ] as const)('distinguishes %s recovery copy', (kind, title) => { + publishWorkspaceActivationRecoveryPresentation({ + workspaceKey: WORKSPACE_KEY, + executionHostId: 'local', + attemptId: `${kind}-1`, + kind, + retry: vi.fn() + }) + + render() + + expect(screen.getByRole('heading', { name: title })).not.toBeNull() + }) +}) diff --git a/src/renderer/src/components/WorkspaceActivationRecoverySurface.tsx b/src/renderer/src/components/WorkspaceActivationRecoverySurface.tsx new file mode 100644 index 00000000000..e0efb2295d8 --- /dev/null +++ b/src/renderer/src/components/WorkspaceActivationRecoverySurface.tsx @@ -0,0 +1,122 @@ +import { useSyncExternalStore } from 'react' +import { CircleAlert, Loader2, RefreshCw } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + readWorkspaceActivationRecoveryPresentation, + subscribeWorkspaceActivationRecoveryPresentation, + type WorkspaceActivationRecoveryPresentation +} from '@/lib/workspace-activation-recovery-presentation' + +function presentationCopy(presentation: WorkspaceActivationRecoveryPresentation): { + title: string + description: string +} { + if (presentation.kind === 'blocked') { + return { + title: translate( + 'auto.components.workspace.activation.recovery.blocked.title', + 'Workspace recovery is paused' + ), + description: + presentation.detail ?? + translate( + 'auto.components.workspace.activation.recovery.blocked.description', + 'Orca could not safely determine whether this workspace already owns a running surface.' + ) + } + } + if (presentation.kind === 'producer-failed') { + return { + title: translate( + 'auto.components.workspace.activation.recovery.producerFailed.title', + 'The requested surface did not open' + ), + description: + presentation.detail ?? + translate( + 'auto.components.workspace.activation.recovery.producerFailed.description', + 'The surface producer reported a failure. Retry after correcting the problem.' + ) + } + } + if (presentation.kind === 'unverifiable') { + return { + title: translate( + 'auto.components.workspace.activation.recovery.unverifiable.title', + 'Reconnect to continue' + ), + description: + presentation.detail ?? + translate( + 'auto.components.workspace.activation.recovery.unverifiable.description', + 'Orca cannot verify the execution host, so it will not start another process.' + ) + } + } + return { + title: translate( + 'auto.components.workspace.activation.recovery.unexpected.title', + 'Workspace recovery failed' + ), + description: + presentation.detail ?? + translate( + 'auto.components.workspace.activation.recovery.unexpected.description', + 'An unexpected error interrupted workspace recovery.' + ) + } +} + +export function WorkspaceActivationRecoverySurface({ + worktreeId +}: { + worktreeId: string +}): React.JSX.Element | null { + const executionHostId = useAppStore((state) => getExecutionHostIdForWorktree(state, worktreeId)) + const presentation = useSyncExternalStore( + subscribeWorkspaceActivationRecoveryPresentation, + () => readWorkspaceActivationRecoveryPresentation(worktreeId, executionHostId), + () => null + ) + if (!presentation) { + return null + } + if (presentation.kind === 'recovering') { + return ( +
+
+
+
+ ) + } + const copy = presentationCopy(presentation) + return ( +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts b/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts index a207244cdb9..1c082fe5812 100644 --- a/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts +++ b/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts @@ -1,27 +1,48 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ + activateAndRevealWorktree: vi.fn(), getKnownWorktreeById: vi.fn(), - setActiveWorktree: vi.fn(), launchAgentInNewTab: vi.fn(), - getExecutionHostIdForWorktree: vi.fn() + getExecutionHostIdForWorktree: vi.fn(), + registerWorkspaceSurfaceProducer: vi.fn(), + surfaceProducer: { + attempt: { + id: 'dashboard-attempt', + workspaceKey: 'folder:docs', + executionHostId: 'ssh:docs', + result: Promise.resolve({ + kind: 'materialized' as const, + surface: { kind: 'tab' as const, id: 'tab-1' } + }) + }, + materialized: vi.fn(), + declined: vi.fn(), + failed: vi.fn(), + unverifiable: vi.fn() + } })) vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ settings: null, - getKnownWorktreeById: mocks.getKnownWorktreeById, - setActiveWorktree: mocks.setActiveWorktree + getKnownWorktreeById: mocks.getKnownWorktreeById }) } })) +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: mocks.activateAndRevealWorktree +})) vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: mocks.launchAgentInNewTab })) vi.mock('@/lib/worktree-runtime-owner', () => ({ getExecutionHostIdForWorktree: mocks.getExecutionHostIdForWorktree })) +vi.mock('@/lib/workspace-surface-production', () => ({ + registerWorkspaceSurfaceProducer: mocks.registerWorkspaceSurfaceProducer +})) import { launchDashboardAgent } from './launch-dashboard-agent' @@ -30,17 +51,25 @@ describe('launchDashboardAgent', () => { vi.clearAllMocks() mocks.getExecutionHostIdForWorktree.mockReturnValue('ssh:docs') mocks.getKnownWorktreeById.mockReturnValue({ id: 'folder:docs' }) + mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: null }) mocks.launchAgentInNewTab.mockReturnValue({ tabId: 'tab-1' }) + mocks.registerWorkspaceSurfaceProducer.mockReturnValue(mocks.surfaceProducer) }) it('activates a folder or git workspace on its execution host before launching', () => { expect(launchDashboardAgent({ worktreeId: 'folder:docs', agent: 'codex' })).toBe(true) expect(mocks.getKnownWorktreeById).toHaveBeenCalledWith('folder:docs', 'ssh:docs') - expect(mocks.setActiveWorktree).toHaveBeenCalledWith('folder:docs', 'ssh:docs') + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('folder:docs', { + executionHostId: 'ssh:docs' + }) expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith({ agent: 'codex', worktreeId: 'folder:docs', launchSource: 'unknown' }) + expect(mocks.surfaceProducer.materialized).toHaveBeenCalledWith({ + kind: 'tab', + id: 'tab-1' + }) }) }) diff --git a/src/renderer/src/components/dashboard/launch-dashboard-agent.ts b/src/renderer/src/components/dashboard/launch-dashboard-agent.ts index be3b644e5a6..c6cf6bea9ad 100644 --- a/src/renderer/src/components/dashboard/launch-dashboard-agent.ts +++ b/src/renderer/src/components/dashboard/launch-dashboard-agent.ts @@ -3,6 +3,9 @@ import { useAppStore } from '@/store' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import type { DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production' +import { settleStructuredAgentSurfaceProducer } from '@/lib/structured-agent-surface-production' /** Starts the requested agent through the same host-aware tab path as Quick Launch. */ export function launchDashboardAgent({ worktreeId, agent }: DashboardSpawnAgentArgs): boolean { @@ -12,12 +15,34 @@ export function launchDashboardAgent({ worktreeId, agent }: DashboardSpawnAgentA if (!worktree || !isTuiAgentEnabled(agent, state.settings?.disabledTuiAgents)) { return false } - state.setActiveWorktree(worktreeId, executionHostId) - return ( - launchAgentInNewTab({ + const producer = registerWorkspaceSurfaceProducer({ workspaceKey: worktreeId, executionHostId }) + try { + if (activateAndRevealWorktree(worktreeId, { executionHostId }) === false) { + producer.failed('The workspace is no longer available.') + return false + } + const result = launchAgentInNewTab({ agent, worktreeId, launchSource: 'unknown' - }) !== null - ) + }) + if (!result) { + producer.failed('The agent launch did not start.') + return false + } + if (result.tabId) { + producer.materialized({ kind: 'tab', id: result.tabId }) + } else if (result.structuredSettlement) { + void result.structuredSettlement.then( + (settlement) => settleStructuredAgentSurfaceProducer(producer, worktreeId, settlement), + (error: unknown) => producer.failed(error) + ) + } else { + producer.failed('The agent launch did not publish a surface.') + } + return true + } catch (error) { + producer.failed(error) + return false + } } diff --git a/src/renderer/src/components/editor/check-annotation-open.test.ts b/src/renderer/src/components/editor/check-annotation-open.test.ts index 97f0fbf09b2..610df5aa4c7 100644 --- a/src/renderer/src/components/editor/check-annotation-open.test.ts +++ b/src/renderer/src/components/editor/check-annotation-open.test.ts @@ -23,7 +23,7 @@ afterEach(() => { }) describe('openAnnotationLocation', () => { - it('activates as a surface-providing caller before opening the editor', () => { + it('activates before opening the editor through its concrete producer', () => { vi.stubGlobal('requestAnimationFrame', vi.fn().mockReturnValue(1)) vi.stubGlobal('cancelAnimationFrame', vi.fn()) @@ -35,11 +35,7 @@ describe('openAnnotationLocation', () => { revealInnerRafRef: { current: null } }) - // Why: the annotation's editor file is the surface — the jump must not re-seed - // a shell into a workspace whose last terminal the user closed. - expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { - providesInitialSurface: true - }) + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1') expect(mocks.state.openFile).toHaveBeenCalledWith( expect.objectContaining({ worktreeId: 'wt-1', relativePath: 'src/a.ts' }), { forceContentReload: true } diff --git a/src/renderer/src/components/editor/check-annotation-open.ts b/src/renderer/src/components/editor/check-annotation-open.ts index afcb3600efa..61ee5142e78 100644 --- a/src/renderer/src/components/editor/check-annotation-open.ts +++ b/src/renderer/src/components/editor/check-annotation-open.ts @@ -6,6 +6,8 @@ import { getOpenableAnnotationLine, resolveAnnotationPathInsideWorktree } from './check-annotation-path' +import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' export { getOpenableAnnotationLine } @@ -27,24 +29,36 @@ export function openAnnotationLocation(params: { return } const { absolutePath, relativePath } = resolvedPath + cancelAnnotationRevealFrame(revealRafRef) + cancelAnnotationRevealFrame(revealInnerRafRef) // Why: reuse the shared activation path so an annotation jump lands in the // same history stack as sidebar, palette, and terminal-link navigation. - activateAndRevealWorktree(worktreeId, { providesInitialSurface: true }) - - store.openFile( - { - filePath: absolutePath, - relativePath, - worktreeId, - language: detectLanguage(relativePath), - mode: 'edit' - }, - { forceContentReload: true } - ) - - cancelAnnotationRevealFrame(revealRafRef) - cancelAnnotationRevealFrame(revealInnerRafRef) + const producer = registerWorkspaceSurfaceProducer({ + workspaceKey: worktreeId, + executionHostId: getExecutionHostIdForWorktree(store, worktreeId) + }) + try { + const activation = activateAndRevealWorktree(worktreeId) + if (activation === false) { + producer.failed('The workspace is no longer available.') + return + } + const surfaceId = store.openFile( + { + filePath: absolutePath, + relativePath, + worktreeId, + language: detectLanguage(relativePath), + mode: 'edit' + }, + { forceContentReload: true } + ) + producer.materialized({ kind: 'tab', id: surfaceId }) + } catch (error) { + producer.failed(error) + return + } store.setPendingEditorReveal(null) // Why: opening can replace the active tab and mount Monaco asynchronously. diff --git a/src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx b/src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx index 4567f75932e..684d5b3a3b9 100644 --- a/src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx +++ b/src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx @@ -17,6 +17,8 @@ import { useSetupTargetWorktree } from './FeatureWallSetupWorkflowActions' import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy' +import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' export function BrowserAction(props: { done: boolean }): React.JSX.Element { const targetWorktree = useSetupTargetWorktree() @@ -37,18 +39,49 @@ export function BrowserAction(props: { done: boolean }): React.JSX.Element { return } closeModal() - activateAndRevealWorktree(targetWorktree.id, { providesInitialSurface: true }) const state = useAppStore.getState() + const producer = registerWorkspaceSurfaceProducer({ + workspaceKey: targetWorktree.id, + executionHostId: getExecutionHostIdForWorktree(state, targetWorktree.id) + }) + try { + if (activateAndRevealWorktree(targetWorktree.id) === false) { + producer.failed('The workspace is no longer available.') + return + } + } catch (error) { + producer.failed(error) + toast.error(error instanceof Error ? error.message : String(error)) + return + } // Why: open the browser into the worktree's active group so it lands beside // the user's current work rather than spawning a detached surface. const groupId = state.activeGroupIdByWorktree[targetWorktree.id] ?? state.groupsByWorktree[targetWorktree.id]?.[0]?.id if (groupId) { - void openNewBrowserTabInActiveWorkspace(groupId).catch((error) => { - toast.error(error instanceof Error ? error.message : String(error)) - }) + const existingSurfaceIds = new Set( + (useAppStore.getState().unifiedTabsByWorktree[targetWorktree.id] ?? []).map((tab) => tab.id) + ) + void openNewBrowserTabInActiveWorkspace(groupId) + .then(() => { + const settledState = useAppStore.getState() + settledState.reconcileWorktreeTabModel(targetWorktree.id) + const surfaceId = ( + useAppStore.getState().unifiedTabsByWorktree[targetWorktree.id] ?? [] + ).find((tab) => !existingSurfaceIds.has(tab.id))?.id + if (surfaceId) { + producer.materialized({ kind: 'tab', id: surfaceId }) + return + } + producer.failed('The browser did not publish a workspace surface.') + }) + .catch((error) => { + producer.failed(error) + toast.error(error instanceof Error ? error.message : String(error)) + }) } else { + producer.failed('No workspace group is available for this worktree yet.') toast.warning( translate( 'auto.components.feature.wall.FeatureWallBrowserAction.5022c43a88', diff --git a/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx b/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx index 1121a05ae64..386be815753 100644 --- a/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx +++ b/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx @@ -350,9 +350,8 @@ describe('PortsPanel runtime routing', () => { ).resolves.toEqual({ ok: true }) expect(activateAndRevealWorktreeMock).toHaveBeenCalledTimes(2) - // Why: the browser tab is the surface — port opens must not re-seed a shell. expect(activateAndRevealWorktreeMock).toHaveBeenCalledWith('repo::/workspace/app', { - providesInitialSurface: true + executionHostId: 'runtime:env-1' }) expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([ 'status.get', diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts index a84b8323d03..7eb0722f555 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts @@ -359,12 +359,8 @@ describe('delete worktree flow', () => { const toastOptions = vi.mocked(showDeleteWorktreeFailureToast).mock.calls[0]?.[0] toastOptions?.onViewChanges() - // Why: the Source Control panel is the surface; seeding a shell would repopulate a - // workspace the user is trying to delete and erase its closed-last-terminal tombstone. const { activateAndRevealWorktree } = await import('@/lib/worktree-activation') - expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { - providesInitialSurface: true - }) + expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', {}) expect(mocks.state.setRightSidebarTab).toHaveBeenCalledWith('source-control') expect(mocks.state.setRightSidebarOpen).toHaveBeenCalledWith(true) }) diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 77ff1c193b8..e31b518e36c 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -26,6 +26,12 @@ import { getFolderWorkspaceAgentLaunchPlatform, resolveFolderWorkspaceLaunchDraft } from './folder-workspace-agent-startup' +import { + registerWorkspaceSurfaceProducer, + type WorkspaceSurfaceProducer +} from '@/lib/workspace-surface-production' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { settleStructuredAgentSurfaceProducer } from '@/lib/structured-agent-surface-production' export { buildFolderWorkspaceLinkedStartupPlan, @@ -205,13 +211,23 @@ export async function submitFolderWorkspaceCreate({ } : undefined onOpenChange(false) + let structuredProducer: WorkspaceSurfaceProducer | null = null try { + const workspaceKey = folderWorkspaceKey(workspace.id) + structuredProducer = structuredLaunch + ? registerWorkspaceSurfaceProducer({ + workspaceKey, + executionHostId: getExecutionHostIdForWorktree(useAppStore.getState(), workspaceKey) + }) + : null let activation = activateAndRevealFolderWorkspace(workspace.id, { agent: quickAgent, ...(!structuredLaunch && startup ? { startup } : {}), - ...(structuredLaunch ? { providesInitialSurface: true } : {}), runtimeEnvironmentId }) + if (activation === false) { + structuredProducer?.failed('The workspace is no longer available.') + } let structuredLaunchAccepted = structuredLaunch const settlement = plan?.route === 'structured-native-chat' @@ -244,6 +260,9 @@ export async function submitFolderWorkspaceCreate({ { worktreeId: folderWorkspaceKey(workspace.id) } ) : null + if (structuredProducer) { + settleStructuredAgentSurfaceProducer(structuredProducer, workspaceKey, settlement) + } if (settlement) { // Why: the workspace exists either way. Unknown keeps reporting false and failed true, as // the boolean did before the loop was shared; the launch layer owns the failure toast. @@ -288,6 +307,7 @@ export async function submitFolderWorkspaceCreate({ }) } } catch (error) { + structuredProducer?.failed(error) // Why: creation already succeeded. Do not leave the completed create modal // open if the follow-up reveal/startup path hits a transient issue. console.error('Failed to activate folder workspace after create:', error) diff --git a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts index 3f844d6f09d..7464d7d62a8 100644 --- a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts +++ b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts @@ -7,21 +7,34 @@ import { prepareActiveWorktreeFocusAfterDelete } from './active-worktree-focus-a import { showDeleteWorktreeFailureToast } from './delete-worktree-failure-toast' import type { WorktreeDeleteWithToastOptions } from './worktree-delete-request' import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match' +import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' // A failed delete usually means unresolved changes, so land on the diff panel. function viewWorktreeDiff( worktreeId: string, executionHostId: WorktreeRemovalTarget['executionHostId'] ): void { - // The Source Control panel is the requested surface — don't re-seed a shell in a - // workspace the user is trying to delete. - activateAndRevealWorktree(worktreeId, { - providesInitialSurface: true, - ...(executionHostId ? { executionHostId } : {}) - }) const state = useAppStore.getState() - state.setRightSidebarTab('source-control') - state.setRightSidebarOpen(true) + const producer = registerWorkspaceSurfaceProducer({ + workspaceKey: worktreeId, + executionHostId: executionHostId ?? getExecutionHostIdForWorktree(state, worktreeId) + }) + try { + const activation = activateAndRevealWorktree( + worktreeId, + executionHostId ? { executionHostId } : {} + ) + if (activation === false) { + producer.failed('The workspace is no longer available.') + return + } + state.setRightSidebarTab('source-control') + state.setRightSidebarOpen(true) + producer.materialized({ kind: 'workspace-content', id: 'source-control' }) + } catch (error) { + producer.failed(error) + } } export function runWorktreeDeleteWithToast( diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts index 9ea97f47307..7b9d3f7b5a0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts @@ -33,6 +33,10 @@ const store = { agentStatusByPaneKey: {} as Record, tabsByWorktree: {} as Record, getKnownWorktreeById: vi.fn(), + reconcileWorktreeTabModel: vi.fn(() => ({ + renderableTabCount: 1, + activeRenderableTabId: 'agent-session:session-1' + })), createWorktree: mockCreateWorktree } @@ -183,8 +187,7 @@ describe('forkAgentSessionFromPane', () => { await vi.waitFor(() => expect(mockActivateAndRevealWorktree).toHaveBeenCalled()) expect(mockActivateAndRevealWorktree).toHaveBeenCalledWith('wt-fork', { - sidebarRevealBehavior: 'auto', - providesInitialSurface: true + sidebarRevealBehavior: 'auto' }) expect(mockToast.success).not.toHaveBeenCalled() settle({ kind: 'structured', sessionId: 'session-1' }) @@ -274,8 +277,7 @@ describe('forkAgentSessionFromPane', () => { expect(mockToast.success).not.toHaveBeenCalled() expect(mockWriteClipboardText).toHaveBeenCalledTimes(copiesContext ? 1 : 0) expect(mockActivateAndRevealWorktree).toHaveBeenCalledWith('wt-fork', { - sidebarRevealBehavior: 'auto', - providesInitialSurface: true + sidebarRevealBehavior: 'auto' }) } ) diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts index fc22eae6417..d451c792cf6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts @@ -16,6 +16,9 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { TuiAgent } from '../../../../shared/tui-agent' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { translate } from '@/i18n/i18n' +import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { settleStructuredAgentSurfaceProducer } from '@/lib/structured-agent-surface-production' type ForkAgentSessionFromPaneArgs = { pane: ManagedPane @@ -239,29 +242,56 @@ export async function startAgentSessionFork(fork: PreparedAgentSessionFork): Pro worktreePath: created.worktree.path, projectRuntime: sourceProjectRuntime }) - const result = launchAgentInNewTab({ - agent: fork.agent, - worktreeId: forkWorktreeId, - prompt: fork.prompt, - promptDelivery: 'draft', - launchSource: 'terminal_context_menu', - ...(launchPlatform ? { launchPlatform } : {}) + const producer = registerWorkspaceSurfaceProducer({ + workspaceKey: forkWorktreeId, + executionHostId: getExecutionHostIdForWorktree(useAppStore.getState(), forkWorktreeId) }) + let result: ReturnType + try { + result = launchAgentInNewTab({ + agent: fork.agent, + worktreeId: forkWorktreeId, + prompt: fork.prompt, + promptDelivery: 'draft', + launchSource: 'terminal_context_menu', + ...(launchPlatform ? { launchPlatform } : {}) + }) + } catch (error) { + producer.failed(error) + throw error + } if (!result?.structuredSettlement) { - activateAndRevealWorktree(forkWorktreeId, { sidebarRevealBehavior: 'auto' }) + try { + activateAndRevealWorktree(forkWorktreeId, { sidebarRevealBehavior: 'auto' }) + } catch (error) { + producer.failed(error) + throw error + } if (!result) { + producer.failed('The agent launch did not start.') return copyAgentSessionForkContext(fork) } + if (result.tabId) { + producer.materialized({ kind: 'tab', id: result.tabId }) + } else { + producer.failed('The agent launch did not publish a surface.') + } notifyForkOpened() return true } // Why: the fresh worktree has no tabs yet; without the opt-out activation seeds a shell beside // the structured tab that is still on its way. - activateAndRevealWorktree(forkWorktreeId, { - sidebarRevealBehavior: 'auto', - providesInitialSurface: true - }) - const settlement = await result.structuredSettlement + let settlement: Awaited + try { + activateAndRevealWorktree(forkWorktreeId, { + sidebarRevealBehavior: 'auto' + }) + settlement = await result.structuredSettlement + } catch (error) { + producer.failed(error) + throw error + } + settleStructuredAgentSurfaceProducer(producer, forkWorktreeId, settlement) // Why: a refusal whose terminal fallback opened nothing is the structured twin of a null launch. if (settlement.kind === 'refused-then-legacy' && settlement.primaryTabId === null) { return copyAgentSessionForkContext(fork) diff --git a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts index 346424c960a..60d775979d2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts @@ -1,4 +1,3 @@ -import { absolutePathToFileUri } from '@/components/editor/markdown-internal-links' import { getWorkspaceFilePreviewPlan, openFileInBrowserTab } from '@/lib/file-preview' import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open' import { detectLanguage } from '@/lib/language-detect' @@ -19,6 +18,12 @@ import { toSshExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + openTerminalHtmlFileInBrowser, + settleTerminalFileProducedTab +} from './terminal-file-open-surface-production' type TerminalFileOpenDeps = { worktreeId: string @@ -32,19 +37,6 @@ export function isHtmlFilePath(filePath: string): boolean { return /\.html?$/i.test(filePath) } -function openHtmlFileInBrowser(filePath: string, worktreeId: string): void { - const store = useAppStore.getState() - if (worktreeId) { - // Why: following an HTML file link changes which worktree is foregrounded, - // so it must record a history visit before opening the browser tab — but the - // browser tab is the surface, so an emptied workspace must not gain a shell. - activateAndRevealWorktree(worktreeId, { providesInitialSurface: true }) - } - const fileUrl = absolutePathToFileUri(filePath) - const title = filePath.split(/[/\\]/).pop() ?? filePath - store.createBrowserTab(worktreeId, fileUrl, { title, activate: true }) -} - export function getTerminalFileContext( worktreeId: string, worktreePath: string, @@ -201,15 +193,27 @@ export function openDetectedFilePath( // and remain the fallback if Shift+Cmd/Ctrl cannot launch the OS default. if (isHtmlFilePath(mappedFilePath)) { if (shouldOpenTerminalFileWithSystemDefault(fileContext, mappedFilePath)) { - openHtmlFileInBrowser(mappedFilePath, worktreeId) + openTerminalHtmlFileInBrowser(mappedFilePath, worktreeId) return } // Why: the same gesture renders remote HTML too, through the doc preview; only an // unsupported plan (e.g. a paired doc outside the worktree) falls back to source. const plan = getWorkspaceFilePreviewPlan(useAppStore.getState(), worktreeId, mappedFilePath) if (plan.status === 'doc-preview') { - activateAndRevealWorktree(worktreeId, { providesInitialSurface: true }) - openFileInBrowserTab({ filePath: mappedFilePath, worktreeId }) + const producer = registerWorkspaceSurfaceProducer({ + workspaceKey: worktreeId, + executionHostId: getExecutionHostIdForWorktree(useAppStore.getState(), worktreeId) + }) + try { + if (activateAndRevealWorktree(worktreeId) === false) { + producer.failed('The workspace is no longer available.') + return + } + openFileInBrowserTab({ filePath: mappedFilePath, worktreeId }) + settleTerminalFileProducedTab(producer, worktreeId) + } catch (error) { + producer.failed(error) + } return } } @@ -246,32 +250,65 @@ export function openDetectedFilePath( if (targetWorktreeId) { // Why: the route may name a folder-workspace key, and the same worktree id can exist // on several hosts — dispatch by workspace shape and keep the resolved host. - activateAndRevealWorkspace(targetWorktreeId, { - providesInitialSurface: true, - ...(targetExecutionHostId ? { executionHostId: targetExecutionHostId } : {}) + const producer = registerWorkspaceSurfaceProducer({ + workspaceKey: targetWorktreeId, + executionHostId: + targetExecutionHostId ?? getExecutionHostIdForWorktree(store, targetWorktreeId) }) + try { + const activation = activateAndRevealWorkspace( + targetWorktreeId, + targetExecutionHostId ? { executionHostId: targetExecutionHostId } : {} + ) + if (activation === false) { + producer.failed('The workspace is no longer available.') + return + } + const language = detectLanguage(mappedFilePath) + const surfaceId = store.openFile( + { + filePath: mappedFilePath, + relativePath, + worktreeId: targetWorktreeId || '', + language, + mode: 'edit', + runtimeEnvironmentId, + // Why: absolute SSH paths outside the worktree otherwise look identical + // to client-local external files when the editor reloads or restores. + ...(relativePath === filePath && + !fileContext.settings?.activeRuntimeEnvironmentId?.trim() && + fileContext.connectionId + ? { externalSshTargetId: fileContext.connectionId } + : {}) + }, + { forceContentReload: true } + ) + settleTerminalFileProducedTab(producer, targetWorktreeId, surfaceId) + } catch (error) { + producer.failed(error) + return + } + } else { + const language = detectLanguage(mappedFilePath) + store.openFile( + { + filePath: mappedFilePath, + relativePath, + worktreeId: '', + language, + mode: 'edit', + runtimeEnvironmentId, + ...(relativePath === filePath && + !fileContext.settings?.activeRuntimeEnvironmentId?.trim() && + fileContext.connectionId + ? { externalSshTargetId: fileContext.connectionId } + : {}) + }, + { forceContentReload: true } + ) } const language = detectLanguage(mappedFilePath) - store.openFile( - { - filePath: mappedFilePath, - relativePath, - worktreeId: targetWorktreeId || '', - language, - mode: 'edit', - runtimeEnvironmentId, - // Why: absolute SSH paths outside the worktree otherwise look identical - // to client-local external files when the editor reloads or restores. - ...(relativePath === filePath && - !fileContext.settings?.activeRuntimeEnvironmentId?.trim() && - fileContext.connectionId - ? { externalSshTargetId: fileContext.connectionId } - : {}) - }, - { forceContentReload: true } - ) - if (line !== null) { const openedStore = useAppStore.getState() // Why: scope the reveal to the opened editor tab id so owner-qualified tabs diff --git a/src/renderer/src/components/terminal-pane/terminal-file-open-surface-production.ts b/src/renderer/src/components/terminal-pane/terminal-file-open-surface-production.ts new file mode 100644 index 00000000000..251b1c05778 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-file-open-surface-production.ts @@ -0,0 +1,57 @@ +import { absolutePathToFileUri } from '@/components/editor/markdown-internal-links' +import { useAppStore } from '@/store' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { + registerWorkspaceSurfaceProducer, + type WorkspaceSurfaceProducer +} from '@/lib/workspace-surface-production' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' + +export function settleTerminalFileProducedTab( + producer: WorkspaceSurfaceProducer, + worktreeId: string, + expectedSurfaceId?: string +): void { + try { + const state = useAppStore.getState() + const reconciliation = state.reconcileWorktreeTabModel(worktreeId) + const surfaceId = expectedSurfaceId ?? reconciliation.activeRenderableTabId + const published = surfaceId + ? (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).some( + (tab) => tab.id === surfaceId + ) + : false + if (surfaceId && published) { + producer.materialized({ kind: 'tab', id: surfaceId }) + return + } + producer.failed('The requested tab did not become available.') + } catch (error) { + // Why: recovery bookkeeping must not interrupt an editor/browser open that already succeeded. + producer.failed(error) + } +} + +export function openTerminalHtmlFileInBrowser(filePath: string, worktreeId: string): void { + const store = useAppStore.getState() + const fileUrl = absolutePathToFileUri(filePath) + const title = filePath.split(/[/\\]/).pop() ?? filePath + if (!worktreeId) { + store.createBrowserTab(worktreeId, fileUrl, { title, activate: true }) + return + } + const producer = registerWorkspaceSurfaceProducer({ + workspaceKey: worktreeId, + executionHostId: getExecutionHostIdForWorktree(store, worktreeId) + }) + try { + if (activateAndRevealWorktree(worktreeId) === false) { + producer.failed('The workspace is no longer available.') + return + } + const tab = store.createBrowserTab(worktreeId, fileUrl, { title, activate: true }) + settleTerminalFileProducedTab(producer, worktreeId, tab.id) + } catch (error) { + producer.failed(error) + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts index 61fc1e2c1ba..760f2ad475f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts @@ -65,11 +65,7 @@ describe('handleOscLink', () => { expect.objectContaining({ title: 'report.html', activate: true }) ) expect(openFilePathMock).not.toHaveBeenCalled() - // Why: the browser tab is the surface — activation must not re-seed a shell into a - // workspace whose last terminal the user closed. - expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { - providesInitialSurface: true - }) + expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1') }) it('also opens local .htm paths in Orca browser tabs with the platform modifier', async () => { @@ -108,10 +104,7 @@ describe('handleOscLink', () => { matchLength: 0 }) expect(openFilePathMock).not.toHaveBeenCalled() - // Why: the editor file is the surface — the cross-worktree jump must not add a shell. - expect(activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', { - providesInitialSurface: true - }) + expect(activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', {}) }) it('opens a sibling folder-workspace path under its owning host and workspace', async () => { @@ -131,7 +124,6 @@ describe('handleOscLink', () => { await flushDoubleRaf() expect(activateAndRevealWorkspace).toHaveBeenCalledWith('folder:notes', { - providesInitialSurface: true, executionHostId: 'local' }) expect(openFileMock).toHaveBeenCalledWith( diff --git a/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts index 67562aec052..8b10fbd0162 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts @@ -252,11 +252,7 @@ describe('handleOscLink', () => { filePath: '/home/me/repo/report.html', worktreeId: 'wt-1' }) - // Why: the preview tab is the surface — activation must not re-seed a shell into a - // workspace whose last terminal the user closed. - expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { - providesInitialSurface: true - }) + expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1') }) it('falls back to the source editor when the preview plan is unsupported', async () => { diff --git a/src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx b/src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx index efcc2fb8d44..21dce0bfa2f 100644 --- a/src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx +++ b/src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx @@ -65,8 +65,6 @@ function renderWatcherEffects(overrides: Partial = {}): Promi activityTerminalPortals: [], anyMountedWorktreeHasLayout: false, backgroundMountRevision: 0, - createTab: vi.fn(), - reconcileWorktreeTabModel: vi.fn(), pairedRuntimeParkingEnvironmentIds: new Set(), terminalSshParkingEnabled: true, terminalProviderSnapshotCapabilityRevision: 0, diff --git a/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx b/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx index 7a76434ce2f..7896c3ca03a 100644 --- a/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx +++ b/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx @@ -5,22 +5,30 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { useTerminalWatcherEffects } from '../use-terminal-watcher-effects' import type { TerminalColdActivationController } from '../terminal-cold-activation' -const mocks = vi.hoisted(() => ({ - gate: vi.fn(), - launchStatus: vi.fn((_worktreeId: string, _provider: string): string => 'idle'), - createTab: vi.fn() -})) +const mocks = vi.hoisted(() => { + let uuid = 0 + return { + recover: vi.fn(), + nextUuid: () => `startup-recovery-${++uuid}`, + resetUuid: () => { + uuid = 0 + } + } +}) + vi.mock('@/store', () => ({ useAppStore: Object.assign(() => 'none', { - getState: () => ({ activeWorktreeId: 'wt-1' }) + getState: () => ({ activeWorktreeId: 'folder:workspace-1' }) }) })) -vi.mock('@/lib/worktree-agent-activation-gate', () => ({ - gateWorktreeAgentActivation: mocks.gate +vi.mock('@/lib/worktree-activation-recovery', () => ({ + recoverWorkspaceActivation: mocks.recover })) -vi.mock('@/lib/structured-agent-session-launch', () => ({ - getStructuredAgentLaunchStatus: mocks.launchStatus +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getExecutionHostIdForWorktree: () => 'local', + getRuntimeEnvironmentIdForWorktree: () => null })) +vi.mock('@/lib/browser-uuid', () => ({ createBrowserUuid: mocks.nextUuid })) vi.mock('@/lib/resume-sleeping-agent-session', () => ({ resumeSleepingAgentSessionsForWorktree: vi.fn() })) @@ -34,50 +42,77 @@ vi.mock('../terminal-pane/terminal-parked-tab-watchers', () => ({ disposeAllParkedTerminalWatchers: vi.fn() })) -;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true +Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { + configurable: true, + value: true, + writable: true +}) let root: Root | undefined + afterEach(async () => { await act(async () => root?.unmount()) - vi.clearAllMocks() + root = undefined + mocks.recover.mockReset() + mocks.resetUuid() }) -function Watcher(): null { - useTerminalWatcherEffects({ - activeWorktreeId: 'wt-1', +function Watcher({ activeWorktreeId }: { activeWorktreeId: string }): null { + const controller = { + activeWorktreeId, workspaceSessionReady: true, terminalStartupRestorationReady: true, workspaceSurfaceIds: [], - tabsByWorktree: {}, - createTab: mocks.createTab, - reconcileWorktreeTabModel: () => ({ renderableTabCount: 0 }) - } as unknown as TerminalColdActivationController) + tabsByWorktree: {} + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This hook test exercises only the startup fields; unused controller dependencies stay inert. + useTerminalWatcherEffects(controller as unknown as TerminalColdActivationController) return null } -describe('passive terminal seeding during native chat creation', () => { - it.each([ - ['claude', 'pending', 0], - ['codex', 'pending', 0], - ['claude', 'unknown', 0], - ['codex', 'unknown', 0], - ['claude', 'idle', 1] - ] as const)('handles %s launch status %s', async (agent, status, expectedTabs) => { - let finishGate!: (outcome: 'empty') => void - mocks.gate.mockReturnValue( +describe('passive activation recovery', () => { + it('observes the folder-key general-setter path during startup', async () => { + mocks.recover.mockResolvedValue({ kind: 'intentional-empty' }) + root = createRoot(document.createElement('div')) + + await act(async () => root?.render()) + + expect(mocks.recover).toHaveBeenCalledWith( + { + workspaceKey: 'folder:workspace-1', + executionHostId: 'local', + runtimeEnvironmentId: null, + attemptId: 'startup-recovery-1' + }, + { mode: 'startup', signal: expect.any(AbortSignal) } + ) + }) + + it('does not turn later state-only selections into new recovery launches', async () => { + mocks.recover.mockResolvedValue({ kind: 'materialized' }) + root = createRoot(document.createElement('div')) + await act(async () => root?.render()) + await act(async () => undefined) + + await act(async () => root?.render()) + + expect(mocks.recover).toHaveBeenCalledOnce() + }) + + it('aborts an unsettled request without consuming the next startup assessment', async () => { + let settleSecond!: () => void + mocks.recover.mockReturnValueOnce(new Promise(() => undefined)).mockReturnValueOnce( new Promise((resolve) => { - finishGate = resolve + settleSecond = () => resolve({ kind: 'intentional-empty' }) }) ) - mocks.launchStatus.mockReturnValue('idle') root = createRoot(document.createElement('div')) - await act(async () => root?.render()) + await act(async () => root?.render()) + const firstSignal = mocks.recover.mock.calls[0]?.[1]?.signal - // A create starts after the inventory probe but before its empty result returns. - mocks.launchStatus.mockImplementation((_worktreeId, provider) => - provider === agent ? status : 'idle' - ) - await act(async () => finishGate('empty')) + await act(async () => root?.render()) - expect(mocks.createTab).toHaveBeenCalledTimes(expectedTabs) + expect(firstSignal?.aborted).toBe(true) + expect(mocks.recover).toHaveBeenCalledTimes(2) + await act(async () => settleSecond()) }) }) diff --git a/src/renderer/src/components/terminal/initial-terminal-wiring.test.ts b/src/renderer/src/components/terminal/initial-terminal-wiring.test.ts index ed56289625e..bb4fdcc7210 100644 --- a/src/renderer/src/components/terminal/initial-terminal-wiring.test.ts +++ b/src/renderer/src/components/terminal/initial-terminal-wiring.test.ts @@ -2,39 +2,32 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -// Why: the watcher owner is the passive half of the closed-last-terminal contract — it must keep -// honouring the tombstone while explicit activation re-seeds. Assert the wiring as source text; -// mounting the full terminal surface costs far more than it returns, and the e2e that covers it -// only runs when an e2e spec changes. +// Why: mounting the full terminal surface costs far more than the startup wiring assertion returns. const TERMINAL_PATH = 'src/renderer/src/components/use-terminal-watcher-effects.ts' function readSource(relativePath: string): string { return readFileSync(join(process.cwd(), relativePath), 'utf8') } -describe('Terminal auto-create wiring', () => { +describe('Terminal startup recovery wiring', () => { const source = readSource(TERMINAL_PATH) - it('derives the tombstone from the active worktree row', () => { - expect(source).toContain('Object.hasOwn(tabsByWorktree, activeWorktreeId)') + it('routes startup materialization through the recovery boundary', () => { + expect(source.split('recoverWorkspaceActivation(').length - 1).toBe(1) + expect(source).toContain("{ mode: 'startup', signal: abort.signal }") }) - it('passes that derivation into shouldAutoCreateInitialTerminal', () => { - // Why: the regression #14590 fixed is re-introduced by dropping this second argument, and a - // literal here would pin nothing — it has to be the identifier the effect actually derives. - // Exactly one call site: a second (flagless) call could otherwise hide behind this one. - expect( - source.split('shouldAutoCreateInitialTerminal(').length - 1, - 'expected exactly one shouldAutoCreateInitialTerminal call in the watcher owner' - ).toBe(1) + it('captures target, host, runtime, and attempt identity', () => { + expect(source).toContain('workspaceKey: activeWorktreeId') + expect(source).toContain('getExecutionHostIdForWorktree(state, activeWorktreeId)') + expect(source).toContain('getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId)') + expect(source).toContain('attemptId: createBrowserUuid()') + }) + + it('keeps recovery retryable until the asynchronous assessment settles', () => { expect(source).toContain( - 'shouldAutoCreateInitialTerminal(renderableTabCount, activeWorktreeHasTerminalState)' + 'if (!abort.signal.aborted) {\n startupRecoverySettledRef.current = true' ) - }) - - it('keeps that derivation in the effect dependencies', () => { - // Why: without the dep the effect never re-runs when the row appears or disappears. - // Anchored to the neighbouring dep so a stray trailing comma elsewhere can't satisfy it. - expect(source).toContain('activeWorktreeId,\n activeWorktreeHasTerminalState,') + expect(source).toContain('return () => {\n abort.abort()') }) }) diff --git a/src/renderer/src/components/use-terminal-watcher-effects.ts b/src/renderer/src/components/use-terminal-watcher-effects.ts index 3fae0571342..c7b9868272e 100644 --- a/src/renderer/src/components/use-terminal-watcher-effects.ts +++ b/src/renderer/src/components/use-terminal-watcher-effects.ts @@ -1,6 +1,5 @@ import { useEffect, useMemo, useRef } from 'react' import { findActivityTerminalPortal } from './activity/activity-terminal-portal' -import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal' import { canWatcherCoverParkedTerminalTab, disposeAllParkedTerminalWatchers, @@ -10,12 +9,15 @@ import { type ParkedTerminalTabWatcherSyncEntry } from './terminal-pane/terminal-parked-tab-watchers' import { useAppStore } from '@/store' -import { gateWorktreeAgentActivation } from '@/lib/worktree-agent-activation-gate' import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session' import { createWorkspaceTerminalHostAuthoritySelector } from '@/lib/workspace-terminal-host-authority' -import { getStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch' -import { AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS } from '../../../shared/agent-session-provider-handle' import type { TerminalColdActivationController } from './terminal-cold-activation' +import { recoverWorkspaceActivation } from '@/lib/worktree-activation-recovery' +import { + getExecutionHostIdForWorktree, + getRuntimeEnvironmentIdForWorktree +} from '@/lib/worktree-runtime-owner' +import { createBrowserUuid } from '@/lib/browser-uuid' // Why shared: surfaces without watchable live tabs need no per-pass allocation. const NO_PARKED_TAB_IDS: ReadonlySet = new Set() @@ -30,7 +32,6 @@ type TerminalWatcherController = Pick< | 'activityTerminalPortals' | 'anyMountedWorktreeHasLayout' | 'backgroundMountRevision' - | 'createTab' | 'effectiveParkedTerminalWorktreeIds' | 'evictionExemptTerminalTabIds' | 'getEffectiveLayoutForWorktree' @@ -40,7 +41,6 @@ type TerminalWatcherController = Pick< | 'mountedWorktreeIdsRef' | 'pairedRuntimeParkingEnvironmentIds' | 'pendingStartupByTabId' - | 'reconcileWorktreeTabModel' | 'renderedActiveWorktreeId' | 'tabsByWorktree' | 'terminalParkingEnabled' @@ -62,7 +62,6 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController) activityTerminalPortals, anyMountedWorktreeHasLayout, backgroundMountRevision, - createTab, effectiveParkedTerminalWorktreeIds, evictionExemptTerminalTabIds, getEffectiveLayoutForWorktree, @@ -72,7 +71,6 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController) mountedWorktreeIdsRef, pairedRuntimeParkingEnvironmentIds, pendingStartupByTabId, - reconcileWorktreeTabModel, renderedActiveWorktreeId, tabsByWorktree, terminalParkingEnabled, @@ -83,6 +81,7 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController) workspaceSessionReady, workspaceSurfaceIds } = controller + const startupRecoverySettledRef = useRef(false) useEffect(() => { pruneParkedTerminalWatchers(terminalWatcherLiveWorkspaceIds(workspaceSurfaceIds)) @@ -183,12 +182,6 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController) ]) useEffect(() => () => disposeAllParkedTerminalWatchers(), []) - const startupActivationGateWorktreeIdsRef = useRef(new Set()) - // Why (main): a missing row means never initialized, an explicit empty row means the user - // closed the last terminal — so the gate must not re-seed one in the second case. - const activeWorktreeHasTerminalState = activeWorktreeId - ? Object.hasOwn(tabsByWorktree, activeWorktreeId) - : false // Why a store subscription rather than a read inside the effects: the verdict flips to `none` the // moment the execution host answers, and that transition is what re-runs the passes below. // Why the retained selector: resolution walks the owner catalogs, so recomputing it on every store @@ -198,53 +191,43 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController) [activeWorktreeId] ) const activeWorktreeHostAuthority = useAppStore(hostAuthoritySelector) + const activeWorkspaceExecutionHostId = useAppStore( + (state) => state.activeWorkspaceExecutionHostId + ) useEffect(() => { - if (!workspaceSessionReady || !terminalStartupRestorationReady || !activeWorktreeId) { + if (!workspaceSessionReady || !terminalStartupRestorationReady) { + startupRecoverySettledRef.current = false return } - // Why: the execution host owns terminal creation, and a host that has not answered is not a host - // with no terminals — seeding into that gap duplicates its tabs on every launch (STA-4658). - if (activeWorktreeHostAuthority !== 'none') { + if (!activeWorktreeId || startupRecoverySettledRef.current) { return } - if (startupActivationGateWorktreeIdsRef.current.has(activeWorktreeId)) { - return - } - startupActivationGateWorktreeIdsRef.current.add(activeWorktreeId) - let cancelled = false - void gateWorktreeAgentActivation(activeWorktreeId).then((outcome) => { - if ( - cancelled || - outcome !== 'empty' || - useAppStore.getState().activeWorktreeId !== activeWorktreeId - ) { - return - } - // A pending or unanswered chat create owns the surface even before its tab is published. - if ( - AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS.some( - (agent) => getStructuredAgentLaunchStatus(activeWorktreeId, agent) !== 'idle' - ) - ) { - return - } - // Why: the activation gate reconciles durable/live agent state first; only an actually empty, never-visited workspace receives a default shell. - const { renderableTabCount } = reconcileWorktreeTabModel(activeWorktreeId) - if (shouldAutoCreateInitialTerminal(renderableTabCount, activeWorktreeHasTerminalState)) { - // Why: tag this never-visited-worktree tab so its PTY spawn doesn't count as activity and reshuffle the sidebar (explicit New Tab still bumps). - createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true }) - } - }) + const state = useAppStore.getState() + const abort = new AbortController() + void recoverWorkspaceActivation( + { + workspaceKey: activeWorktreeId, + executionHostId: getExecutionHostIdForWorktree(state, activeWorktreeId), + runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId), + attemptId: createBrowserUuid() + }, + { mode: 'startup', signal: abort.signal } + ).then( + () => { + if (!abort.signal.aborted) { + startupRecoverySettledRef.current = true + } + }, + () => undefined + ) return () => { - cancelled = true + abort.abort() } }, [ - activeWorktreeId, - activeWorktreeHasTerminalState, + activeWorkspaceExecutionHostId, activeWorktreeHostAuthority, - createTab, - reconcileWorktreeTabModel, + activeWorktreeId, terminalStartupRestorationReady, workspaceSessionReady ]) diff --git a/src/renderer/src/hooks/composer-state/full-creation-execution.test.ts b/src/renderer/src/hooks/composer-state/full-creation-execution.test.ts index 1195a5fa82c..c4a9bdacc40 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-execution.test.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-execution.test.ts @@ -7,6 +7,64 @@ import { type FullCreationExecutionInput } from './full-creation-execution' import type { PreparedFullSubmit } from './composer-submit-model' +import type { CreateWorktreeResult } from '../../../../shared/worktree/create-types' +import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' +import type * as FullCreationStructuredLaunchModule from './full-creation-structured-launch' + +const mocks = vi.hoisted(() => ({ + activateAndRevealWorktree: vi.fn(), + planAgentSessionLaunch: vi.fn(), + queueStandaloneSetupTab: vi.fn(), + registerWorkspaceSurfaceProducer: vi.fn(), + settleFullCreationStructuredLaunch: vi.fn(), + store: { + settings: {}, + reconcileWorktreeTabModel: vi.fn() + }, + surfaceProducer: { + attempt: { + id: 'full-creation-structured-attempt', + workspaceKey: 'repo-1::/repo/worktree', + executionHostId: 'local', + result: Promise.resolve({ kind: 'failed' as const, reason: 'setup queue failed' }) + }, + materialized: vi.fn(), + declined: vi.fn(), + failed: vi.fn(), + unverifiable: vi.fn() + } +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mocks.store + } +})) + +vi.mock('@/lib/agent-session-launch-plan', () => ({ + planAgentSessionLaunch: mocks.planAgentSessionLaunch +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: mocks.activateAndRevealWorktree +})) + +vi.mock('./full-creation-structured-launch', async (importOriginal) => ({ + ...(await importOriginal()), + settleFullCreationStructuredLaunch: mocks.settleFullCreationStructuredLaunch +})) + +vi.mock('@/lib/workspace-surface-production', () => ({ + registerWorkspaceSurfaceProducer: mocks.registerWorkspaceSurfaceProducer +})) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getExecutionHostIdForWorktree: () => 'local' +})) + +vi.mock('@/lib/worktree-setup-issue-command-queue', () => ({ + queueStandaloneSetupTab: mocks.queueStandaloneSetupTab +})) function deferred() { let resolve!: (value: T) => void @@ -102,4 +160,124 @@ describe('useFullCreationExecution cancellation', () => { expect(createWorktree).not.toHaveBeenCalled() }) + + it('settles structured surface ownership when post-registration setup throws', async () => { + const prepared = { + submitLinkedWorkItem: null, + submitLinkedIssueNumber: null, + submitLinkedPR: null, + submitTitleName: null, + nameIsAutoManaged: false, + smartGitHubCreateNames: { + workspaceName: 'workspace', + displayName: undefined + }, + workspaceName: 'workspace', + nameWasGenerated: false, + submitBaseBranch: 'main', + submitCompareBaseRef: undefined, + submitPushTarget: undefined, + submitBranchNameOverride: undefined, + submitLinkedWorkItemProvider: null, + submitStartupPrompt: '', + submitShouldRunIssueAutomation: false, + effectiveSetupDecision: 'skip', + issueCommandTrustDecision: 'skip', + confirmedIssueCommandTemplate: '', + linkedLinearIssue: undefined, + linkedLinearIssueWorkspaceId: undefined, + linkedLinearIssueOrganizationUrlKey: undefined, + effectiveBranchNameOverride: undefined, + createDisplayName: undefined, + pendingFirstAgentMessageRename: false, + startupPlan: null, + shouldSeedInitialAgentStatus: false, + composerTelemetry: { + agent_kind: 'claude-code', + launch_source: 'new_workspace_composer', + request_kind: 'new' + }, + backendStartup: undefined + } satisfies PreparedFullSubmit + const created = { + worktree: { + id: 'repo-1::/repo/worktree', + repoId: 'repo-1', + displayName: 'workspace', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + path: '/repo/worktree', + head: 'abc123', + branch: 'feature', + isBare: false, + isMainWorktree: false + }, + setup: { + runnerScriptPath: '/repo/.git/orca/setup-runner.sh', + envVars: {} + } + } satisfies CreateWorktreeResult + const structuredPlan = { + route: 'structured-native-chat', + agent: 'claude', + prompt: '', + launch: vi.fn() + } satisfies AgentSessionLaunchPlan + const createWorktree = vi + .fn() + .mockResolvedValue(created) + const state = { + applyWorktreeMeta: vi + .fn() + .mockResolvedValue(), + clearNewWorkspaceDraft: vi.fn(), + createWorktree, + effectivePresetId: null, + isSubmissionCancelled: () => false, + linkedGitLabIssue: null, + linkedGitLabMR: null, + normalizedSparseDirectories: [], + note: '', + onCreated: vi.fn>(), + parentWorktreeId: null, + persistDraft: false, + persistSetupAgentStartupPolicy: vi.fn(async () => true), + prepareFullSubmit: vi + .fn() + .mockResolvedValue(prepared), + resolvedInitialWorkspaceStatus: undefined, + selectedRepoExecutionHostId: 'local', + selectedRepoIsGit: true, + setSidebarOpen: vi.fn(), + sparseEnabled: false, + taskSourceContext: null, + telemetrySource: undefined, + tuiAgent: 'claude' + } satisfies FullCreationExecutionInput + mocks.planAgentSessionLaunch.mockReturnValueOnce(structuredPlan) + mocks.registerWorkspaceSurfaceProducer.mockReturnValueOnce(mocks.surfaceProducer) + mocks.queueStandaloneSetupTab.mockImplementationOnce(() => { + throw new Error('setup queue failed') + }) + const hook = renderHook(() => useFullCreationExecution(state)) + + await expect( + hook.result.current.executeFullCreation({ kind: 'none' }, 'repo-1') + ).rejects.toThrow('setup queue failed') + + expect(mocks.registerWorkspaceSurfaceProducer).toHaveBeenCalledWith({ + workspaceKey: 'repo-1::/repo/worktree', + executionHostId: 'local' + }) + expect(mocks.surfaceProducer.failed).toHaveBeenCalledWith(expect.any(Error)) + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(mocks.settleFullCreationStructuredLaunch).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/hooks/composer-state/full-creation-execution.ts b/src/renderer/src/hooks/composer-state/full-creation-execution.ts index 1ad5a92abab..66c1e28b64a 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-execution.ts @@ -38,7 +38,11 @@ import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/na import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus' import { useAppStore } from '@/store' import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan' -import { settleFullCreationStructuredLaunch } from './full-creation-structured-launch' +import { + beginFullCreationSurfaceProduction, + settleFullCreationStructuredLaunch, + settleFullCreationSurfaceProduction +} from './full-creation-structured-launch' import { finalizeFullCreation } from './full-creation-finalization' import { buildFullCreationIssueCommand } from './full-creation-issue-command' import { buildFullCreationStartup } from './full-creation-startup' @@ -217,25 +221,43 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) { telemetry: composerTelemetry }) - const initialActivation = activateAndRevealWorktree(worktree.id, { - sidebarRevealBehavior: 'auto', - agent: tuiAgent, - setup: result.setup, - defaultTabs: result.defaultTabs, - issueCommand, - ...(backendSpawnedStartup ? { backendStartupTerminalSpawned: true } : {}), - ...(!structuredLaunch && startup ? { startup } : {}), - ...(structuredLaunch ? { providesInitialSurface: true } : {}) - }) + const { producer: structuredProducer, setupRunsWithoutPrimary } = + beginFullCreationSurfaceProduction({ + structuredLaunch, + worktreeId: worktree.id, + setup: result.setup, + issueCommand, + defaultTabs: result.defaultTabs + }) + let initialActivation: ReturnType + let settlement: Awaited> + try { + initialActivation = activateAndRevealWorktree(worktree.id, { + sidebarRevealBehavior: 'auto', + agent: tuiAgent, + setup: setupRunsWithoutPrimary ? undefined : result.setup, + defaultTabs: result.defaultTabs, + issueCommand, + ...(backendSpawnedStartup ? { backendStartupTerminalSpawned: true } : {}), + ...(!structuredLaunch && startup ? { startup } : {}) + }) + if (initialActivation === false) { + structuredProducer?.failed('The workspace is no longer available.') + } - const settlement = await settleFullCreationStructuredLaunch({ - plan: launchPlan, - agent: tuiAgent, - worktreeId: worktree.id, - startup, - pendingFirstAgentMessageRename, - applyWorktreeMeta - }) + settlement = await settleFullCreationStructuredLaunch({ + plan: launchPlan, + agent: tuiAgent, + worktreeId: worktree.id, + startup, + pendingFirstAgentMessageRename, + applyWorktreeMeta + }) + settleFullCreationSurfaceProduction(structuredProducer, worktree.id, settlement) + } catch (error) { + structuredProducer?.failed(error) + throw error + } // Why: both leave the workspace revealed and the composer text intact; the launch layer has // already toasted a failure, and an unknown outcome reconciles on the next click. diff --git a/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts index 4293e97505a..a9fa21eaade 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts @@ -4,6 +4,45 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation' import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' import { activateStructuredAgentSessionById } from '@/lib/structured-agent-session-tab-activation' import type { TuiAgent } from '../../../../shared/tui-agent' +import { useAppStore } from '@/store' +import { + registerWorkspaceSurfaceProducer, + type WorkspaceSurfaceProducer +} from '@/lib/workspace-surface-production' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { queueStandaloneSetupTab } from '@/lib/worktree-setup-issue-command-queue' +import { settleStructuredAgentSurfaceProducer } from '@/lib/structured-agent-surface-production' + +type StandaloneSetupArgs = Parameters[0] + +export function beginFullCreationSurfaceProduction( + args: Omit & { structuredLaunch: boolean } +): { producer: WorkspaceSurfaceProducer | null; setupRunsWithoutPrimary: boolean } { + const store = useAppStore.getState() + const producer = args.structuredLaunch + ? registerWorkspaceSurfaceProducer({ + workspaceKey: args.worktreeId, + executionHostId: getExecutionHostIdForWorktree(store, args.worktreeId) + }) + : null + try { + const setupRunsWithoutPrimary = producer !== null && queueStandaloneSetupTab({ ...args, store }) + return { producer, setupRunsWithoutPrimary } + } catch (error) { + producer?.failed(error) + throw error + } +} + +export function settleFullCreationSurfaceProduction( + producer: WorkspaceSurfaceProducer | null, + worktreeId: string, + settlement: StructuredAgentLaunchSettlement | null +): void { + if (producer) { + settleStructuredAgentSurfaceProducer(producer, worktreeId, settlement) + } +} /** Full-create dialog: the structured launch plus what this flow did before structured chat * existed. Returns null when the plan's route is not structured. */ diff --git a/src/renderer/src/lib/direct-work-item-surface-production.test.ts b/src/renderer/src/lib/direct-work-item-surface-production.test.ts new file mode 100644 index 00000000000..c17e8d1605a --- /dev/null +++ b/src/renderer/src/lib/direct-work-item-surface-production.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { WorkspaceSurfaceProducer } from './workspace-surface-production' + +const mocks = vi.hoisted(() => ({ + register: vi.fn(), + queueSetup: vi.fn(() => true) +})) + +vi.mock('./workspace-surface-production', () => ({ + registerWorkspaceSurfaceProducer: mocks.register +})) +vi.mock('./worktree-runtime-owner', () => ({ + getExecutionHostIdForWorktree: () => 'local' +})) +vi.mock('./worktree-setup-issue-command-queue', () => ({ + queueStandaloneSetupTab: mocks.queueSetup +})) + +import { + beginDirectWorkItemSurfaceProduction, + settleDirectWorkItemSurfaceProduction +} from './direct-work-item-surface-production' + +const producer: WorkspaceSurfaceProducer = { + attempt: { + id: 'attempt-1', + workspaceKey: 'worktree-1', + executionHostId: 'local', + result: Promise.resolve({ kind: 'failed', reason: 'not used' }) + }, + materialized: vi.fn(), + declined: vi.fn(), + failed: vi.fn(), + unverifiable: vi.fn() +} + +describe('direct work item surface production', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.register.mockReturnValue(producer) + }) + + it('registers concrete ownership before queueing setup-only content', () => { + const result = beginDirectWorkItemSurfaceProduction({ + store: useAppStore.getState(), + structuredLaunch: true, + worktreeId: 'worktree-1', + setup: { runnerScriptPath: '/tmp/setup.sh', envVars: {} }, + issueCommand: undefined, + defaultTabs: undefined + }) + + expect(mocks.register).toHaveBeenCalledWith({ + workspaceKey: 'worktree-1', + executionHostId: 'local' + }) + expect(result).toEqual({ producer, setupRunsWithoutPrimary: true }) + }) + + it('does not mint ownership from an unstructured selection', () => { + const result = beginDirectWorkItemSurfaceProduction({ + store: useAppStore.getState(), + structuredLaunch: false, + worktreeId: 'worktree-1', + setup: undefined, + issueCommand: undefined, + defaultTabs: undefined + }) + + expect(mocks.register).not.toHaveBeenCalled() + expect(mocks.queueSetup).not.toHaveBeenCalled() + expect(result).toEqual({ producer: null, setupRunsWithoutPrimary: false }) + }) + + it('settles a structured launch with its exact session tab identity', () => { + settleDirectWorkItemSurfaceProduction(producer, { + completed: true, + structuredLaunch: true, + visibilityUnknown: false, + failed: false, + primaryTabId: null, + structuredSessionId: 'session-12' + }) + + expect(producer.materialized).toHaveBeenCalledWith({ + kind: 'tab', + id: 'agent-session:session-12' + }) + }) + + it('retains producer ownership when the host result is unknown', () => { + settleDirectWorkItemSurfaceProduction(producer, { + completed: false, + structuredLaunch: true, + visibilityUnknown: true, + failed: false, + primaryTabId: null + }) + + expect(producer.unverifiable).toHaveBeenCalledWith( + 'The execution host may have accepted the agent launch.' + ) + }) +}) diff --git a/src/renderer/src/lib/direct-work-item-surface-production.ts b/src/renderer/src/lib/direct-work-item-surface-production.ts new file mode 100644 index 00000000000..9de816f337a --- /dev/null +++ b/src/renderer/src/lib/direct-work-item-surface-production.ts @@ -0,0 +1,62 @@ +import type { AppState } from '@/store' +import type { settleDirectWorkItemStructuredLaunch } from './launch-work-item-direct-agent-routing' +import { getExecutionHostIdForWorktree } from './worktree-runtime-owner' +import { + registerWorkspaceSurfaceProducer, + type WorkspaceSurfaceProducer +} from './workspace-surface-production' +import { queueStandaloneSetupTab } from './worktree-setup-issue-command-queue' + +type StandaloneSetupArgs = Parameters[0] +type StructuredSettlement = Awaited> + +export function beginDirectWorkItemSurfaceProduction( + args: Omit & { + store: AppState + structuredLaunch: boolean + } +): { producer: WorkspaceSurfaceProducer | null; setupRunsWithoutPrimary: boolean } { + const producer = args.structuredLaunch + ? registerWorkspaceSurfaceProducer({ + workspaceKey: args.worktreeId, + executionHostId: getExecutionHostIdForWorktree(args.store, args.worktreeId) + }) + : null + try { + const setupRunsWithoutPrimary = + producer !== null && + queueStandaloneSetupTab({ + store: args.store, + worktreeId: args.worktreeId, + setup: args.setup, + issueCommand: args.issueCommand, + defaultTabs: args.defaultTabs, + ...(args.opts ? { opts: args.opts } : {}) + }) + return { producer, setupRunsWithoutPrimary } + } catch (error) { + producer?.failed(error) + throw error + } +} + +export function settleDirectWorkItemSurfaceProduction( + producer: WorkspaceSurfaceProducer, + result: StructuredSettlement +): void { + if (result.visibilityUnknown) { + producer.unverifiable('The execution host may have accepted the agent launch.') + } else if (result.failed) { + producer.failed('The agent launch did not publish a surface.') + } else if (result.structuredSessionId) { + producer.materialized({ kind: 'tab', id: `agent-session:${result.structuredSessionId}` }) + } else if (result.primaryTabId) { + producer.materialized({ kind: 'tab', id: result.primaryTabId }) + } else if (result.completed) { + producer.unverifiable( + 'The agent launch succeeded, but its exact surface identity is unavailable.' + ) + } else { + producer.failed('The agent launch fallback did not publish a surface.') + } +} diff --git a/src/renderer/src/lib/fix-checks-agent-launch.test.ts b/src/renderer/src/lib/fix-checks-agent-launch.test.ts index 785244bbddf..bb3fdcd340c 100644 --- a/src/renderer/src/lib/fix-checks-agent-launch.test.ts +++ b/src/renderer/src/lib/fix-checks-agent-launch.test.ts @@ -156,7 +156,7 @@ describe('startFixChecksAgent', () => { mocks.resolveSourceControlLaunchPlatform.mockReturnValue('darwin') }) - it('activates the attached workspace as a surface-providing caller', async () => { + it('activates the attached workspace before its registered launch', async () => { const { startFixChecksAgent } = await import('./fix-checks-agent-launch') await expect( @@ -168,11 +168,7 @@ describe('startFixChecksAgent', () => { }) ).resolves.toBe(true) - // Why: launchAgentInNewTab creates the surface; without the opt-out, activation would - // also re-seed a shell in a closed-last-terminal workspace. - expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { - providesInitialSurface: true - }) + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1') }) it('fails without launching when the requested worktree is missing', async () => { diff --git a/src/renderer/src/lib/fix-checks-agent-launch.ts b/src/renderer/src/lib/fix-checks-agent-launch.ts index 1a052fc2782..241b785a177 100644 --- a/src/renderer/src/lib/fix-checks-agent-launch.ts +++ b/src/renderer/src/lib/fix-checks-agent-launch.ts @@ -25,6 +25,9 @@ import type { TuiAgent } from '../../../shared/tui-agent' import type { WorkspaceSource as WorkspaceCreateTelemetrySource } from '../../../shared/workspace-source' import type { LaunchSource } from '../../../shared/telemetry-events' import { translate } from '@/i18n/i18n' +import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { settleStructuredAgentSurfaceProducer } from '@/lib/structured-agent-surface-production' type StartFixChecksAgentArgs = { repoId: string @@ -186,39 +189,58 @@ export async function startFixChecksAgent(args: StartFixChecksAgentArgs): Promis toast.error(agentArgsPlan.error) return false } - // launchAgentInNewTab below creates the surface; seeding here would add a stray shell. - if (!activateAndRevealWorktree(targetWorktreeId, { providesInitialSurface: true })) { - toast.error( - translate( - 'auto.lib.fix.checks.agent.launch.03c1d61f83', - 'Unable to open the workspace attached to these checks.' - ) - ) - return false - } - const result = launchAgentInNewTab({ - agent, - worktreeId: targetWorktreeId, - groupId: args.groupId ?? targetWorktreeId, - prompt: commandInput, - agentArgs: recipe.agentArgs, - promptDelivery: 'submit-after-ready', - launchPlatform, - launchSource: args.launchSource + const producer = registerWorkspaceSurfaceProducer({ + workspaceKey: targetWorktreeId, + executionHostId: getExecutionHostIdForWorktree(store, targetWorktreeId) }) - if (!result) { - toast.error( - translate( - 'auto.lib.fix.checks.agent.launch.fb6c294e85', - 'Could not build the agent launch command.' + try { + if (activateAndRevealWorktree(targetWorktreeId) === false) { + producer.failed('The workspace is no longer available.') + toast.error( + translate( + 'auto.lib.fix.checks.agent.launch.03c1d61f83', + 'Unable to open the workspace attached to these checks.' + ) ) - ) - return false + return false + } + const result = launchAgentInNewTab({ + agent, + worktreeId: targetWorktreeId, + groupId: args.groupId ?? targetWorktreeId, + prompt: commandInput, + agentArgs: recipe.agentArgs, + promptDelivery: 'submit-after-ready', + launchPlatform, + launchSource: args.launchSource + }) + if (!result) { + producer.failed('Could not build the agent launch command.') + toast.error( + translate( + 'auto.lib.fix.checks.agent.launch.fb6c294e85', + 'Could not build the agent launch command.' + ) + ) + return false + } + if (result.tabId) { + producer.materialized({ kind: 'tab', id: result.tabId }) + focusTerminalTabSurface(result.tabId) + } else if (result.structuredSettlement) { + void result.structuredSettlement.then( + (settlement) => + settleStructuredAgentSurfaceProducer(producer, targetWorktreeId, settlement), + (error: unknown) => producer.failed(error) + ) + } else { + producer.failed('The agent launch did not publish a surface.') + } + return true + } catch (error) { + producer.failed(error) + throw error } - if (result.tabId) { - focusTerminalTabSurface(result.tabId) - } - return true } if (!args.item || !args.openModalFallback) { diff --git a/src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts b/src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts index 18c7ed5f88e..399709cd6d9 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts @@ -59,7 +59,8 @@ describe('settleDirectWorkItemStructuredLaunch', () => { structuredLaunch: true, visibilityUnknown: false, failed: false, - primaryTabId: null + primaryTabId: null, + structuredSessionId: 'draft-session' }) expect(mocks.settleStructuredAgentLaunch).toHaveBeenCalledWith( 'worktree-1', @@ -112,26 +113,41 @@ describe('settleDirectWorkItemStructuredLaunch', () => { expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() }) - it.each([ - ['failed', { kind: 'failed', error: new Error('x') }], - ['cancelled', { kind: 'cancelled', sessionId: 'session-1' }] - ])( - 'drops the pre-launch tab on a %s settlement so nothing is pasted into it', - async (_kind, settlement) => { - mocks.settleStructuredAgentLaunch.mockResolvedValue(settlement) + it('drops the pre-launch tab on a failed settlement so nothing is pasted into it', async () => { + mocks.settleStructuredAgentLaunch.mockResolvedValue({ + kind: 'failed', + error: new Error('x') + }) - await expect( - settleDirectWorkItemStructuredLaunch({ ...baseArgs, primaryTabId: 'setup-shell-tab' }) - ).resolves.toEqual({ - completed: false, - structuredLaunch: true, - visibilityUnknown: false, - failed: true, - primaryTabId: null - }) - expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() - } - ) + await expect( + settleDirectWorkItemStructuredLaunch({ ...baseArgs, primaryTabId: 'setup-shell-tab' }) + ).resolves.toEqual({ + completed: false, + structuredLaunch: true, + visibilityUnknown: false, + failed: true, + primaryTabId: null + }) + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + }) + + it('retains producer ownership as unknown when a structured settlement is cancelled', async () => { + mocks.settleStructuredAgentLaunch.mockResolvedValue({ + kind: 'cancelled', + sessionId: 'session-1' + }) + + await expect( + settleDirectWorkItemStructuredLaunch({ ...baseArgs, primaryTabId: 'setup-shell-tab' }) + ).resolves.toEqual({ + completed: false, + structuredLaunch: true, + visibilityUnknown: true, + failed: false, + primaryTabId: null + }) + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + }) it('skips the loop when the route is not structured', async () => { await expect( diff --git a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts index 2189eed7236..dd309514e39 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts @@ -117,6 +117,7 @@ export async function settleDirectWorkItemStructuredLaunch(args: { /** The structured launch ended without a surface; there is nothing for the legacy path to finish. */ failed: boolean primaryTabId: string | null + structuredSessionId?: string }> { const { plan } = args const notLaunched = (structuredLaunch: boolean) => ({ @@ -176,7 +177,8 @@ export async function settleDirectWorkItemStructuredLaunch(args: { structuredLaunch: true, visibilityUnknown: false, failed: false, - primaryTabId: args.primaryTabId + primaryTabId: args.primaryTabId, + structuredSessionId: settlement.sessionId } case 'refused-then-legacy': return { @@ -195,8 +197,9 @@ export async function settleDirectWorkItemStructuredLaunch(args: { primaryTabId: args.primaryTabId } case 'failed': - case 'cancelled': // Why: the launch layer already toasted the failure. return withoutAgentSurface + case 'cancelled': + return { ...withoutAgentSurface, visibilityUnknown: true, failed: false } } } diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 8e7734908ea..8141c8fe79a 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -40,6 +40,11 @@ import { planAgentSessionLaunch, type AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' +import type { WorkspaceSurfaceProducer } from '@/lib/workspace-surface-production' +import { + beginDirectWorkItemSurfaceProduction, + settleDirectWorkItemSurfaceProduction +} from './direct-work-item-surface-production' /** * "Use" flow: create the workspace, activate it, launch the default agent, @@ -167,6 +172,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom let plan: AgentSessionLaunchPlan | null = null const draftContent = await getDirectWorkItemDraftContent(item, repoConnectionId) let startupPlanFailed = false + let structuredProducer: WorkspaceSurfaceProducer | null = null try { const result = await store.createWorktree( repoId, @@ -229,12 +235,21 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom startupPlanFailed = launchPreparation.startupPlanFailed plan = launchPreparation.plan + const surfaceProduction = beginDirectWorkItemSurfaceProduction({ + store: latestStore, + structuredLaunch: launchPreparation.structuredLaunch, + worktreeId, + setup: result.setup, + issueCommand: undefined, + defaultTabs: result.defaultTabs + }) + structuredProducer = surfaceProduction.producer const activation = activateAndRevealWorktree(worktreeId, { sidebarRevealBehavior: 'auto', - setup: result.setup, + setup: surfaceProduction.setupRunsWithoutPrimary ? undefined : result.setup, defaultTabs: result.defaultTabs, ...(launchPreparation.structuredLaunch - ? { providesInitialSurface: true } + ? {} : buildDirectWorkItemStartupOpts( effectiveAgent, startupPlan, @@ -242,7 +257,8 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom promptDelivery === 'draft' ? draftContent : undefined )) }) - if (!activation) { + if (activation === false) { + structuredProducer?.failed('The workspace is no longer available.') // Worktree vanished between create and activate — extremely unlikely but // worth handling explicitly rather than silently dropping the draft. toast.error(workspaceActivationErrorMessage()) @@ -250,6 +266,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom } primaryTabId = activation.primaryTabId } catch (error) { + structuredProducer?.failed(error) const message = error instanceof Error ? error.message : 'Failed to create workspace.' toast.error(message) return false @@ -257,15 +274,25 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom store.setSidebarOpen(true) - const structuredResult = await settleDirectWorkItemStructuredLaunch({ - plan, - worktreeId, - workspacePath: worktreePath, - connectionId: repoConnectionId, - primaryTabId, - startupPlan, - launchSource - }) + let structuredResult: Awaited> + try { + structuredResult = await settleDirectWorkItemStructuredLaunch({ + plan, + worktreeId, + workspacePath: worktreePath, + connectionId: repoConnectionId, + primaryTabId, + startupPlan, + launchSource + }) + } catch (error) { + structuredProducer?.failed(error) + toast.error(error instanceof Error ? error.message : 'The agent launch failed.') + return false + } + if (structuredProducer) { + settleDirectWorkItemSurfaceProduction(structuredProducer, structuredResult) + } if (structuredResult.visibilityUnknown || structuredResult.failed) { // Why: callers hang irreversible follow-up work off a `true` here, so a structured launch that // opened no surface must not report the workspace as started. diff --git a/src/renderer/src/lib/onboarding-folder-agent-launch.ts b/src/renderer/src/lib/onboarding-folder-agent-launch.ts index 26d90f5147c..0a53f1eb2d8 100644 --- a/src/renderer/src/lib/onboarding-folder-agent-launch.ts +++ b/src/renderer/src/lib/onboarding-folder-agent-launch.ts @@ -11,6 +11,10 @@ import { type OnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup' import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production' +import { useAppStore } from '@/store' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { settleStructuredAgentSurfaceProducer } from '@/lib/structured-agent-surface-production' export type OnboardingFolderAgentLaunch = { agent: TuiAgent | null @@ -59,30 +63,47 @@ export async function revealOnboardingFolderWithAgentLaunch(args: { executionHostId: ExecutionHostId | undefined launch: OnboardingFolderAgentLaunch }): Promise { - const reveal = ( - startup: OnboardingFolderAgentStartup | undefined, - providesInitialSurface = false - ) => + const reveal = (startup: OnboardingFolderAgentStartup | undefined) => activateAndRevealWorktree(args.worktreeId, { sidebarRevealBehavior: 'auto', ...(args.executionHostId ? { executionHostId: args.executionHostId } : {}), - ...(startup ? { startup } : {}), - ...(providesInitialSurface ? { providesInitialSurface: true } : {}) + ...(startup ? { startup } : {}) }) const { plan } = args.launch const structured = plan?.route === 'structured-native-chat' - reveal(args.launch.startup, structured) - if (!structured) { - return + const producer = structured + ? registerWorkspaceSurfaceProducer({ + workspaceKey: args.worktreeId, + executionHostId: + args.executionHostId ?? + getExecutionHostIdForWorktree(useAppStore.getState(), args.worktreeId) + }) + : null + try { + const initialActivation = reveal(args.launch.startup) + if (initialActivation === false) { + producer?.failed('The workspace is no longer available.') + } + if (!structured) { + return + } + const settlement = await plan.launch( + { + legacyFallback: async () => { + const activation = reveal(args.launch.fallbackStartup) + return { + activation, + primaryTabId: activation === false ? null : activation.primaryTabId + } + } + }, + { worktreeId: args.worktreeId } + ) + if (producer) { + settleStructuredAgentSurfaceProducer(producer, args.worktreeId, settlement) + } + } catch (error) { + producer?.failed(error) + throw error } - // Why: the outcome is not consumed; the workspace is already revealed and the launch layer toasts. - await plan.launch( - { - legacyFallback: async () => { - const activation = reveal(args.launch.fallbackStartup) - return { activation, primaryTabId: activation === false ? null : activation.primaryTabId } - } - }, - { worktreeId: args.worktreeId } - ) } diff --git a/src/renderer/src/lib/structured-agent-surface-production.test.ts b/src/renderer/src/lib/structured-agent-surface-production.test.ts new file mode 100644 index 00000000000..07d6a931a5a --- /dev/null +++ b/src/renderer/src/lib/structured-agent-surface-production.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { settleStructuredAgentSurfaceProducer } from './structured-agent-surface-production' +import { + consumeWorkspaceSurfaceProducerAttempt, + readWorkspaceSurfaceProducerEntries, + registerWorkspaceSurfaceProducer, + resetWorkspaceSurfaceProducersForTests +} from './workspace-surface-production' + +const WORKSPACE_KEY = 'worktree-1' + +function producer(attemptId: string) { + return registerWorkspaceSurfaceProducer({ + workspaceKey: WORKSPACE_KEY, + executionHostId: 'local', + attemptId + }) +} + +beforeEach(() => { + resetWorkspaceSurfaceProducersForTests() +}) + +describe('structured agent surface production', () => { + it.each([ + ['visibility-unknown', { kind: 'visibility-unknown', sessionId: 'session-1' }], + ['cancelled', { kind: 'cancelled', sessionId: 'session-1' }] + ] as const)('retains %s execution ownership as unverifiable', (_label, settlement) => { + const attempt = producer(`attempt-${_label}`) + + settleStructuredAgentSurfaceProducer(attempt, WORKSPACE_KEY, settlement) + consumeWorkspaceSurfaceProducerAttempt(attempt.attempt.id) + + expect(readWorkspaceSurfaceProducerEntries(attempt.attempt)).toMatchObject([ + { result: { kind: 'unverifiable' } } + ]) + }) + + it('carries a definitive structured launch failure to recovery', () => { + const attempt = producer('failed-attempt') + + settleStructuredAgentSurfaceProducer(attempt, WORKSPACE_KEY, { + kind: 'failed', + error: new Error('provider unavailable') + }) + + expect(readWorkspaceSurfaceProducerEntries(attempt.attempt)).toMatchObject([ + { result: { kind: 'failed', reason: 'provider unavailable' } } + ]) + }) + + it('carries the exact structured tab identity to inventory reconciliation', () => { + const attempt = producer('published-attempt') + + settleStructuredAgentSurfaceProducer(attempt, WORKSPACE_KEY, { + kind: 'structured', + sessionId: 'session-1' + }) + + expect(readWorkspaceSurfaceProducerEntries(attempt.attempt)).toMatchObject([ + { + result: { + kind: 'materialized', + surface: { kind: 'tab', id: 'agent-session:session-1' } + } + } + ]) + }) +}) diff --git a/src/renderer/src/lib/structured-agent-surface-production.ts b/src/renderer/src/lib/structured-agent-surface-production.ts new file mode 100644 index 00000000000..fbb2c719630 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-surface-production.ts @@ -0,0 +1,44 @@ +import type { StructuredAgentLaunchSettlement } from './structured-agent-launch-settlement' +import type { WorkspaceSurfaceProducer } from './workspace-surface-production' + +export function settleStructuredAgentSurfaceProducer( + producer: WorkspaceSurfaceProducer, + workspaceKey: string, + settlement: StructuredAgentLaunchSettlement | null +): void { + if (producer.attempt.workspaceKey !== workspaceKey) { + producer.failed('The agent launch settled for a different workspace.') + return + } + if (!settlement) { + producer.failed('The agent launch did not start.') + return + } + if (settlement.kind === 'visibility-unknown') { + producer.unverifiable('The execution host may have accepted the agent launch.') + return + } + const fallbackTabId = + settlement.kind === 'refused-then-legacy' + ? settlement.primaryTabId + : settlement.kind === 'cancelled' + ? settlement.fallback?.primaryTabId + : null + if (fallbackTabId) { + producer.materialized({ kind: 'tab', id: fallbackTabId }) + return + } + if (settlement.kind === 'failed') { + producer.failed(settlement.error) + return + } + if (settlement.kind === 'cancelled') { + producer.unverifiable('The cancelled agent launch could not be reconciled with its host.') + return + } + if (settlement.kind === 'refused-then-legacy') { + producer.failed('The agent launch did not publish a surface.') + return + } + producer.materialized({ kind: 'tab', id: `agent-session:${settlement.sessionId}` }) +} diff --git a/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts b/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts index bb3cf9f26a8..c6a856c458a 100644 --- a/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts +++ b/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts @@ -21,6 +21,8 @@ import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mod import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { getConnectionId } from '@/lib/connection-context' import { toast } from 'sonner' +import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production' +import { toRuntimeExecutionHostId } from '../../../shared/execution-host' export function ensureWebRuntimeWorktreeTerminalAfterWake( worktreeId: string, @@ -78,6 +80,14 @@ export function ensureWebRuntimeWorktreeTerminalAfterWake( if (!beginWebRuntimeWakeTerminalRespawn(worktreeId)) { return } + const producer = registerWorkspaceSurfaceProducer({ + workspaceKey: worktreeId, + executionHostId: toRuntimeExecutionHostId(runtimeEnvironmentId) + }) + state.reconcileWorktreeTabModel(worktreeId) + const existingSurfaceIds = new Set( + (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).map((tab) => tab.id) + ) const startup = opts?.startup const viewModeProps = launchAgent @@ -90,35 +100,52 @@ export function ensureWebRuntimeWorktreeTerminalAfterWake( }) : {} // Why: sleep keeps tab rows but terminal.stop clears host PTYs, while a failed create receipt leaves a selected agent with no host surface. - void createWebRuntimeSessionTerminal({ - worktreeId, - environmentId: runtimeEnvironmentId, - ...viewModeProps, - ...(startup - ? { - command: startup.command, - ...(startup.env ? { env: startup.env } : {}), - ...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}), - ...(startup.launchToken ? { launchToken: startup.launchToken } : {}), - ...(launchAgent ? { launchAgent, preparedAgentCommand: true } : {}), - ...(startup.startupCommandDelivery - ? { startupCommandDelivery: startup.startupCommandDelivery } - : {}) - } - : launchAgent - ? { agent: launchAgent } - : {}), - activate: opts?.activate !== false, - selectWorktree: false - }) + void Promise.resolve() + .then(() => + createWebRuntimeSessionTerminal({ + worktreeId, + environmentId: runtimeEnvironmentId, + ...viewModeProps, + ...(startup + ? { + command: startup.command, + ...(startup.env ? { env: startup.env } : {}), + ...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}), + ...(startup.launchToken ? { launchToken: startup.launchToken } : {}), + ...(launchAgent ? { launchAgent, preparedAgentCommand: true } : {}), + ...(startup.startupCommandDelivery + ? { startupCommandDelivery: startup.startupCommandDelivery } + : {}) + } + : launchAgent + ? { agent: launchAgent } + : {}), + activate: opts?.activate !== false, + selectWorktree: false + }) + ) .then((outcome) => { if (outcome.status === 'failed') { + producer.failed(outcome.message) toast.error(outcome.message, { id: `web-runtime-worktree-terminal:${runtimeEnvironmentId}:${worktreeId}` }) + return + } + const settledState = useAppStore.getState() + settledState.reconcileWorktreeTabModel(worktreeId) + const surfaceId = (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => !existingSurfaceIds.has(tab.id) + )?.id + if (surfaceId) { + producer.materialized({ kind: 'tab', id: surfaceId }) + } else { + producer.unverifiable('The execution host created a terminal that is not visible yet.') } }) + .catch(producer.failed) .finally(() => { endWebRuntimeWakeTerminalRespawn(worktreeId) }) + .catch(producer.failed) } diff --git a/src/renderer/src/lib/workspace-activation-recovery-coordinator.ts b/src/renderer/src/lib/workspace-activation-recovery-coordinator.ts new file mode 100644 index 00000000000..cc817b2f4c6 --- /dev/null +++ b/src/renderer/src/lib/workspace-activation-recovery-coordinator.ts @@ -0,0 +1,212 @@ +import { useAppStore } from '@/store' +import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal' +import { clearWorkspaceActivationRecoveryPresentation } from './workspace-activation-recovery-presentation' +import { readWorkspaceSurfaceProducerEntries } from './workspace-surface-production' +import { + resolveWorkspaceExecutionEvidence, + type WorkspaceExecutionEvidence +} from './workspace-execution-evidence' +import type { + WorkspaceActivationContext, + WorkspaceActivationIdentity, + WorkspaceActivationRecoveryResult +} from './worktree-activation-recovery' +import { + canInspectAgentActivationInventory, + captureActivationRecoverySelectionRevision, + hasLiveActivationTerminalTombstone, + installActivationRecoverySelectionTracker, + isActivationRecoveryFresh, + markLatestActivationRecoveryAttempt, + readActivationRenderableInventory, + readActivationRenderableSurface, + WORKSPACE_ACTIVATION_RECOVERY_DEADLINE_MS, + WORKSPACE_ACTIVATION_RECOVERY_PROGRESS_DELAY_MS +} from './workspace-activation-recovery-state' +import { + activationRecoveryFailedResult, + activationRecoveryMaterializedResult, + assessActivationProducerAttempts, + clearActivationRecoveryPresentation, + publishActivationRecovery, + waitForActivationProducerAttempts +} from './workspace-activation-recovery-settlement' +import { + runActivationRecoveryGate, + waitForActivationProducedSurface +} from './workspace-activation-recovery-gate' + +let seedingTargetKey: string | null = null + +function seedPrivateRecoverySurface( + identity: WorkspaceActivationIdentity, + context: WorkspaceActivationContext, + capturedSelectionRevision: number +): WorkspaceActivationRecoveryResult { + const key = `${identity.executionHostId}|${identity.workspaceKey}` + if ( + !isActivationRecoveryFresh(identity, capturedSelectionRevision, context) || + seedingTargetKey === key + ) { + return { kind: 'stale' } + } + seedingTargetKey = key + try { + const { renderableTabCount, surface } = readActivationRenderableInventory(identity) + // Why: reconciliation can synchronously notify subscribers and start a newer activation. + if (!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)) { + return { kind: 'stale' } + } + if (surface) { + return activationRecoveryMaterializedResult(identity, surface) + } + if (renderableTabCount > 0) { + publishActivationRecovery( + identity, + context, + 'unexpected', + 'Workspace content exists, but no renderable surface could be selected.' + ) + return activationRecoveryFailedResult(identity, 'unexpected') + } + if (context.mode === 'startup' && hasLiveActivationTerminalTombstone(identity.workspaceKey)) { + clearActivationRecoveryPresentation(identity) + return { kind: 'intentional-empty' } + } + const pendingProducer = readWorkspaceSurfaceProducerEntries(identity).find( + (entry) => entry.result === null || entry.result?.kind === 'unverifiable' + ) + if (pendingProducer) { + publishActivationRecovery( + identity, + context, + 'unverifiable', + 'A surface producer still owns this workspace.' + ) + return { + kind: 'deferred', + reason: 'A surface producer still owns this workspace.', + ownerAttemptId: pendingProducer.attempt.id + } + } + const evidence: WorkspaceExecutionEvidence = resolveWorkspaceExecutionEvidence( + useAppStore.getState(), + identity.workspaceKey, + identity.executionHostId + ) + if (evidence !== 'exited') { + const detail = + evidence === 'live' + ? 'The execution host owns this workspace surface. Wait for it to publish or reconnect.' + : 'Orca cannot verify the execution host. Reconnect before retrying recovery.' + publishActivationRecovery(identity, context, 'unverifiable', detail) + return { kind: 'deferred', reason: detail, ownerAttemptId: null } + } + if (!shouldAutoCreateInitialTerminal(renderableTabCount, false)) { + publishActivationRecovery( + identity, + context, + 'unexpected', + 'Workspace recovery could not select a surface.' + ) + return activationRecoveryFailedResult(identity, 'unexpected') + } + const tab = useAppStore.getState().createTab(identity.workspaceKey, undefined, undefined, { + pendingActivationSpawn: true + }) + clearActivationRecoveryPresentation(identity) + return { kind: 'materialized', surface: { id: tab.id, type: 'terminal' } } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + publishActivationRecovery(identity, context, 'unexpected', detail) + return activationRecoveryFailedResult(identity, 'unexpected') + } finally { + seedingTargetKey = null + } +} + +export async function recoverWorkspaceActivationOwned( + identity: WorkspaceActivationIdentity, + context: WorkspaceActivationContext +): Promise { + installActivationRecoverySelectionTracker() + markLatestActivationRecoveryAttempt(identity) + clearWorkspaceActivationRecoveryPresentation({ + workspaceKey: identity.workspaceKey, + executionHostId: identity.executionHostId + }) + const capturedSelectionRevision = captureActivationRecoverySelectionRevision() + const deadlineAt = Date.now() + WORKSPACE_ACTIVATION_RECOVERY_DEADLINE_MS + const progressTimer = setTimeout(() => { + try { + if ( + isActivationRecoveryFresh(identity, capturedSelectionRevision, context) && + !readActivationRenderableSurface(identity) + ) { + publishActivationRecovery(identity, context, 'recovering') + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + publishActivationRecovery(identity, context, 'unexpected', detail) + } + }, WORKSPACE_ACTIVATION_RECOVERY_PROGRESS_DELAY_MS) + try { + if (!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)) { + return { kind: 'stale' } + } + const producerAssessment = assessActivationProducerAttempts(identity, context) + const producerResult = + producerAssessment.kind === 'wait' + ? await waitForActivationProducerAttempts(identity, context, deadlineAt) + : producerAssessment.kind === 'complete' + ? producerAssessment.result + : null + if (producerResult) { + return producerResult + } + if (!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)) { + return { kind: 'stale' } + } + const state = useAppStore.getState() + const shouldGate = + Object.values(state.sleepingAgentSessionsByPaneKey).some( + (record) => record.worktreeId === identity.workspaceKey + ) || canInspectAgentActivationInventory() + if (shouldGate) { + const gateResult = await runActivationRecoveryGate(identity, context, deadlineAt) + if (gateResult !== 'empty' && gateResult !== 'produced') { + return gateResult + } + if (!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)) { + return { kind: 'stale' } + } + const gateSurface = readActivationRenderableSurface(identity) + if (gateSurface) { + return activationRecoveryMaterializedResult(identity, gateSurface) + } + if (gateResult === 'produced') { + return waitForActivationProducedSurface(identity, context, deadlineAt) + } + } + const seedResult = seedPrivateRecoverySurface(identity, context, capturedSelectionRevision) + if ( + seedResult.kind !== 'stale' || + !isActivationRecoveryFresh(identity, capturedSelectionRevision, context) + ) { + return seedResult + } + // Why: a reentrant subscriber can start the newer attempt while the superseded attempt still + // owns the synchronous seed guard; retry once after that critical section unwinds. + await Promise.resolve() + return seedPrivateRecoverySurface(identity, context, capturedSelectionRevision) + } catch (error) { + if (!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)) { + return { kind: 'stale' } + } + const detail = error instanceof Error ? error.message : String(error) + publishActivationRecovery(identity, context, 'unexpected', detail) + return activationRecoveryFailedResult(identity, 'unexpected') + } finally { + clearTimeout(progressTimer) + } +} diff --git a/src/renderer/src/lib/workspace-activation-recovery-gate.ts b/src/renderer/src/lib/workspace-activation-recovery-gate.ts new file mode 100644 index 00000000000..7e9df033c73 --- /dev/null +++ b/src/renderer/src/lib/workspace-activation-recovery-gate.ts @@ -0,0 +1,100 @@ +import { gateWorktreeAgentActivation } from './worktree-agent-activation-gate' +import type { + WorkspaceActivationContext, + WorkspaceActivationIdentity, + WorkspaceActivationRecoveryResult +} from './worktree-activation-recovery' +import { + activationRecoveryRouteKey, + readActivationRecoveryGateRoute, + readActivationRenderableSurface, + recordActivationRecoveryGateRoute, + waitForActivationRecoveryChange, + waitForActivationRecoveryPromise +} from './workspace-activation-recovery-state' +import { + activationRecoveryFailedResult, + activationRecoveryMaterializedResult, + publishActivationRecovery +} from './workspace-activation-recovery-settlement' + +export async function runActivationRecoveryGate( + identity: WorkspaceActivationIdentity, + context: WorkspaceActivationContext, + deadlineAt: number +): Promise { + const gate = gateWorktreeAgentActivation(identity.workspaceKey) + const gateRoute = readActivationRecoveryGateRoute(gate) + if (gateRoute && gateRoute !== activationRecoveryRouteKey(identity)) { + publishActivationRecovery( + identity, + context, + 'unverifiable', + 'Another execution host owns the in-progress recovery assessment. Retry after it settles.' + ) + return { + kind: 'deferred', + reason: 'A recovery gate from another execution host is still in progress.', + ownerAttemptId: null + } + } + recordActivationRecoveryGateRoute(gate, activationRecoveryRouteKey(identity)) + const outcome = await waitForActivationRecoveryPromise(gate, deadlineAt, context.signal) + if (outcome === 'cancelled') { + return { kind: 'stale' } + } + if (outcome === 'timeout') { + publishActivationRecovery( + identity, + context, + 'unexpected', + 'Workspace inventory did not finish before the recovery deadline.' + ) + return activationRecoveryFailedResult(identity, 'unexpected') + } + if (outcome.kind === 'rejected') { + const detail = outcome.error instanceof Error ? outcome.error.message : String(outcome.error) + publishActivationRecovery(identity, context, 'unexpected', detail) + return activationRecoveryFailedResult(identity, 'unexpected') + } + if (outcome.value === 'blocked') { + publishActivationRecovery( + identity, + context, + 'blocked', + 'Orca deliberately paused recovery because the execution host did not provide complete ownership evidence.' + ) + return activationRecoveryFailedResult(identity, 'blocked') + } + return outcome.value === 'empty' ? 'empty' : 'produced' +} + +export async function waitForActivationProducedSurface( + identity: WorkspaceActivationIdentity, + context: WorkspaceActivationContext, + deadlineAt: number +): Promise { + while (true) { + const surface = readActivationRenderableSurface(identity) + if (surface) { + return activationRecoveryMaterializedResult(identity, surface) + } + const wait = await waitForActivationRecoveryChange(deadlineAt, context.signal) + if (wait === 'cancelled') { + return { kind: 'stale' } + } + if (wait === 'timeout') { + publishActivationRecovery( + identity, + context, + 'unverifiable', + 'The execution host reported work, but no renderable surface became visible.' + ) + return { + kind: 'deferred', + reason: 'Host work did not publish a renderable surface before the recovery deadline.', + ownerAttemptId: null + } + } + } +} diff --git a/src/renderer/src/lib/workspace-activation-recovery-presentation.ts b/src/renderer/src/lib/workspace-activation-recovery-presentation.ts new file mode 100644 index 00000000000..2b42aac5f4d --- /dev/null +++ b/src/renderer/src/lib/workspace-activation-recovery-presentation.ts @@ -0,0 +1,64 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' + +export type WorkspaceActivationRecoveryPresentation = { + workspaceKey: string + executionHostId: ExecutionHostId + attemptId: string + kind: 'recovering' | 'blocked' | 'unexpected' | 'unverifiable' | 'producer-failed' + detail?: string + retry: () => void +} + +const presentationsByTarget = new Map() +const listeners = new Set<() => void>() + +function targetKey(workspaceKey: string, executionHostId: ExecutionHostId): string { + return `${executionHostId}|${workspaceKey}` +} + +function notifyListeners(): void { + for (const listener of listeners) { + listener() + } +} + +export function publishWorkspaceActivationRecoveryPresentation( + presentation: WorkspaceActivationRecoveryPresentation +): void { + presentationsByTarget.set( + targetKey(presentation.workspaceKey, presentation.executionHostId), + presentation + ) + notifyListeners() +} + +export function clearWorkspaceActivationRecoveryPresentation(args: { + workspaceKey: string + executionHostId: ExecutionHostId + attemptId?: string +}): void { + const key = targetKey(args.workspaceKey, args.executionHostId) + const current = presentationsByTarget.get(key) + if (!current || (args.attemptId && current.attemptId !== args.attemptId)) { + return + } + presentationsByTarget.delete(key) + notifyListeners() +} + +export function readWorkspaceActivationRecoveryPresentation( + workspaceKey: string, + executionHostId: ExecutionHostId +): WorkspaceActivationRecoveryPresentation | null { + return presentationsByTarget.get(targetKey(workspaceKey, executionHostId)) ?? null +} + +export function subscribeWorkspaceActivationRecoveryPresentation(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function resetWorkspaceActivationRecoveryPresentationsForTests(): void { + presentationsByTarget.clear() + notifyListeners() +} diff --git a/src/renderer/src/lib/workspace-activation-recovery-settlement.ts b/src/renderer/src/lib/workspace-activation-recovery-settlement.ts new file mode 100644 index 00000000000..f4bf094baf1 --- /dev/null +++ b/src/renderer/src/lib/workspace-activation-recovery-settlement.ts @@ -0,0 +1,236 @@ +import type { WorkspaceVisibleTabType } from '../../../shared/tab-types' +import { createBrowserUuid } from './browser-uuid' +import { + consumeWorkspaceSurfaceProducerAttempt, + readWorkspaceSurfaceProducerEntries +} from './workspace-surface-production' +import { + clearWorkspaceActivationRecoveryPresentation, + publishWorkspaceActivationRecoveryPresentation, + type WorkspaceActivationRecoveryPresentation +} from './workspace-activation-recovery-presentation' +import { + isActivationRecoveryCurrent, + readActivationRenderableSurface, + readActivationRenderableSurfaceById, + readActivationRenderableSurfaceIds, + readStructuredActivationProducerStatus, + waitForActivationRecoveryChange +} from './workspace-activation-recovery-state' +import { + recoverWorkspaceActivation, + type WorkspaceActivationContext, + type WorkspaceActivationIdentity, + type WorkspaceActivationRecoveryResult +} from './worktree-activation-recovery' + +const failureSurfaceIdsByProducerAttempt = new Map>() + +function retry(identity: WorkspaceActivationIdentity, context: WorkspaceActivationContext): void { + void recoverWorkspaceActivation( + { ...identity, attemptId: createBrowserUuid() }, + { mode: context.mode } + ) +} + +export function publishActivationRecovery( + identity: WorkspaceActivationIdentity, + context: WorkspaceActivationContext, + kind: WorkspaceActivationRecoveryPresentation['kind'], + detail?: string +): void { + if (!isActivationRecoveryCurrent(identity, context)) { + return + } + publishWorkspaceActivationRecoveryPresentation({ + workspaceKey: identity.workspaceKey, + executionHostId: identity.executionHostId, + attemptId: identity.attemptId, + kind, + ...(detail ? { detail } : {}), + retry: () => retry(identity, context) + }) +} + +export function clearActivationRecoveryPresentation(identity: WorkspaceActivationIdentity): void { + clearWorkspaceActivationRecoveryPresentation({ + workspaceKey: identity.workspaceKey, + executionHostId: identity.executionHostId, + attemptId: identity.attemptId + }) +} + +export function activationRecoveryMaterializedResult( + identity: WorkspaceActivationIdentity, + surface: { id: string; type: WorkspaceVisibleTabType } +): WorkspaceActivationRecoveryResult { + for (const entry of readWorkspaceSurfaceProducerEntries(identity)) { + if ( + entry.result?.kind === 'materialized' && + entry.result.surface.kind === 'tab' && + entry.result.surface.id === surface.id + ) { + consumeWorkspaceSurfaceProducerAttempt(entry.attempt.id) + } + } + clearActivationRecoveryPresentation(identity) + return { kind: 'materialized', surface } +} + +export function activationRecoveryFailedResult( + identity: WorkspaceActivationIdentity, + reason: 'blocked' | 'unexpected' | 'producer-failed' +): WorkspaceActivationRecoveryResult { + return { kind: 'failed', reason, diagnosticId: identity.attemptId } +} + +export type ProducerAssessment = + | { kind: 'complete'; result: WorkspaceActivationRecoveryResult } + | { kind: 'idle' } + | { kind: 'wait' } + +function materializedAfterProducerFailure( + identity: WorkspaceActivationIdentity, + producerAttemptId: string +): WorkspaceActivationRecoveryResult | null { + const currentSurfaceIds = readActivationRenderableSurfaceIds(identity) + const failureSurfaceIds = failureSurfaceIdsByProducerAttempt.get(producerAttemptId) + const laterSurfaceId = failureSurfaceIds + ? [...currentSurfaceIds].find((surfaceId) => !failureSurfaceIds.has(surfaceId)) + : undefined + if (laterSurfaceId) { + const laterSurface = readActivationRenderableSurfaceById(identity, laterSurfaceId) + if (laterSurface) { + failureSurfaceIdsByProducerAttempt.delete(producerAttemptId) + consumeWorkspaceSurfaceProducerAttempt(producerAttemptId) + return activationRecoveryMaterializedResult(identity, laterSurface) + } + } + if (!failureSurfaceIds) { + failureSurfaceIdsByProducerAttempt.set(producerAttemptId, currentSurfaceIds) + } + return null +} + +export function assessActivationProducerAttempts( + identity: WorkspaceActivationIdentity, + context: WorkspaceActivationContext +): ProducerAssessment { + const entries = readWorkspaceSurfaceProducerEntries(identity) + const failed = entries.find((entry) => entry.result?.kind === 'failed') + if (failed?.result?.kind === 'failed') { + const laterSurface = materializedAfterProducerFailure(identity, failed.attempt.id) + if (laterSurface) { + return { kind: 'complete', result: laterSurface } + } + publishActivationRecovery(identity, context, 'producer-failed', failed.result.reason) + return { + kind: 'complete', + result: activationRecoveryFailedResult(identity, 'producer-failed') + } + } + const unverifiable = entries.find((entry) => entry.result?.kind === 'unverifiable') + if (unverifiable?.result?.kind === 'unverifiable') { + publishActivationRecovery(identity, context, 'unverifiable', unverifiable.result.reason) + return { + kind: 'complete', + result: { + kind: 'deferred', + reason: unverifiable.result.reason, + ownerAttemptId: unverifiable.attempt.id + } + } + } + const structuredStatus = readStructuredActivationProducerStatus(identity.workspaceKey) + if (structuredStatus === 'unknown') { + publishActivationRecovery( + identity, + context, + 'unverifiable', + 'The execution host may have accepted the surface request, but its result cannot be verified.' + ) + return { + kind: 'complete', + result: { + kind: 'deferred', + reason: 'Structured surface ownership is unverifiable.', + ownerAttemptId: entries.find((entry) => entry.result === null)?.attempt.id ?? null + } + } + } + const declined = entries.find((entry) => entry.result?.kind === 'declined') + if (declined?.result?.kind === 'declined') { + const laterSurface = materializedAfterProducerFailure(identity, declined.attempt.id) + if (laterSurface) { + return { kind: 'complete', result: laterSurface } + } + publishActivationRecovery(identity, context, 'producer-failed', declined.result.reason) + return { + kind: 'complete', + result: activationRecoveryFailedResult(identity, 'producer-failed') + } + } + const pending = entries.find((entry) => entry.result === null) + if (pending || structuredStatus === 'pending') { + return { kind: 'wait' } + } + const materialized = entries.find((entry) => entry.result?.kind === 'materialized') + if (materialized?.result?.kind === 'materialized') { + const { surface } = materialized.result + if (surface.kind === 'workspace-content') { + clearActivationRecoveryPresentation(identity) + consumeWorkspaceSurfaceProducerAttempt(materialized.attempt.id) + return { + kind: 'complete', + result: { kind: 'materialized', surface: { id: surface.id, type: 'workspace-content' } } + } + } + const publishedSurface = readActivationRenderableSurfaceById(identity, surface.id) + return publishedSurface + ? { + kind: 'complete', + result: activationRecoveryMaterializedResult(identity, publishedSurface) + } + : { kind: 'wait' } + } + const surface = readActivationRenderableSurface(identity) + return surface + ? { kind: 'complete', result: activationRecoveryMaterializedResult(identity, surface) } + : { kind: 'idle' } +} + +export async function waitForActivationProducerAttempts( + identity: WorkspaceActivationIdentity, + context: WorkspaceActivationContext, + deadlineAt: number +): Promise { + while (true) { + const assessment = assessActivationProducerAttempts(identity, context) + if (assessment.kind === 'complete') { + return assessment.result + } + if (assessment.kind === 'idle') { + return null + } + const entries = readWorkspaceSurfaceProducerEntries(identity) + const pending = entries.find((entry) => entry.result === null) + const materialized = entries.find((entry) => entry.result?.kind === 'materialized') + const wait = await waitForActivationRecoveryChange(deadlineAt, context.signal) + if (wait === 'cancelled') { + return { kind: 'stale' } + } + if (wait === 'timeout') { + publishActivationRecovery( + identity, + context, + 'unverifiable', + 'The requested surface has not become visible. Its producer still owns the attempt.' + ) + return { + kind: 'deferred', + reason: 'Surface publication did not settle before the recovery deadline.', + ownerAttemptId: pending?.attempt.id ?? materialized?.attempt.id ?? null + } + } + } +} diff --git a/src/renderer/src/lib/workspace-activation-recovery-state.ts b/src/renderer/src/lib/workspace-activation-recovery-state.ts new file mode 100644 index 00000000000..e1b0f1d947c --- /dev/null +++ b/src/renderer/src/lib/workspace-activation-recovery-state.ts @@ -0,0 +1,275 @@ +import { useAppStore } from '@/store' +import { toVisibleTabType, type WorkspaceVisibleTabType } from '../../../shared/tab-types' +import { subscribeWorkspaceSurfaceProducers } from './workspace-surface-production' +import { + getStructuredAgentLaunchStatus, + subscribeStructuredAgentLaunchStatus +} from './structured-agent-session-launch' +import { AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS } from '../../../shared/agent-session-provider-handle' +import { + getExecutionHostIdForWorktree, + getRuntimeEnvironmentIdForWorktree +} from './worktree-runtime-owner' +import type { + WorkspaceActivationContext, + WorkspaceActivationIdentity +} from './worktree-activation-recovery' + +export type RecoveryWaitResult = 'changed' | 'cancelled' | 'timeout' + +export const WORKSPACE_ACTIVATION_RECOVERY_DEADLINE_MS = 30_000 +export const WORKSPACE_ACTIVATION_RECOVERY_PROGRESS_DELAY_MS = 200 + +const latestAttemptIdByTarget = new Map() +const gateIdentityByPromise = new WeakMap, string>() +let selectionRevision = 0 +let previousSelectionKey: string | null = null +let disposeSelectionTracker: (() => void) | null = null + +export function activationRecoveryTargetKey(identity: WorkspaceActivationIdentity): string { + return `${identity.executionHostId}|${identity.workspaceKey}` +} + +export function activationRecoveryRouteKey(identity: WorkspaceActivationIdentity): string { + return `${identity.executionHostId}|${identity.runtimeEnvironmentId ?? ''}` +} + +export function markLatestActivationRecoveryAttempt(identity: WorkspaceActivationIdentity): void { + latestAttemptIdByTarget.set(activationRecoveryTargetKey(identity), identity.attemptId) +} + +export function readLatestActivationRecoveryAttempt( + identity: WorkspaceActivationIdentity +): string | undefined { + return latestAttemptIdByTarget.get(activationRecoveryTargetKey(identity)) +} + +export function readActivationRecoveryGateRoute(gate: Promise): string | undefined { + return gateIdentityByPromise.get(gate) +} + +export function recordActivationRecoveryGateRoute(gate: Promise, route: string): void { + gateIdentityByPromise.set(gate, route) +} + +function currentSelectionKey(): string { + const state = useAppStore.getState() + const workspaceKey = state.activeWorktreeId + if (!workspaceKey) { + return 'none' + } + return `${getExecutionHostIdForWorktree(state, workspaceKey)}|${getRuntimeEnvironmentIdForWorktree(state, workspaceKey) ?? ''}|${workspaceKey}` +} + +export function installActivationRecoverySelectionTracker(): void { + if (disposeSelectionTracker) { + return + } + previousSelectionKey = currentSelectionKey() + disposeSelectionTracker = useAppStore.subscribe(() => { + const next = currentSelectionKey() + if (next !== previousSelectionKey) { + previousSelectionKey = next + selectionRevision += 1 + } + }) +} + +if (import.meta.hot) { + import.meta.hot.dispose(() => { + disposeSelectionTracker?.() + disposeSelectionTracker = null + }) +} + +export function captureActivationRecoverySelectionRevision(): number { + return selectionRevision +} + +export function isActivationRecoveryCurrent( + identity: WorkspaceActivationIdentity, + context: WorkspaceActivationContext +): boolean { + if ( + context.signal?.aborted || + readLatestActivationRecoveryAttempt(identity) !== identity.attemptId + ) { + return false + } + const state = useAppStore.getState() + return ( + state.activeWorktreeId === identity.workspaceKey && + getExecutionHostIdForWorktree(state, identity.workspaceKey) === identity.executionHostId && + getRuntimeEnvironmentIdForWorktree(state, identity.workspaceKey) === + identity.runtimeEnvironmentId + ) +} + +export function isActivationRecoveryFresh( + identity: WorkspaceActivationIdentity, + capturedSelectionRevision: number, + context: WorkspaceActivationContext +): boolean { + return ( + selectionRevision === capturedSelectionRevision && + isActivationRecoveryCurrent(identity, context) + ) +} + +export function readActivationRenderableSurface( + identity: WorkspaceActivationIdentity +): { id: string; type: WorkspaceVisibleTabType } | null { + const state = useAppStore.getState() + const reconciliation = state.reconcileWorktreeTabModel(identity.workspaceKey) + if (reconciliation.renderableTabCount === 0 || !reconciliation.activeRenderableTabId) { + return null + } + const tab = (useAppStore.getState().unifiedTabsByWorktree[identity.workspaceKey] ?? []).find( + (candidate) => candidate.id === reconciliation.activeRenderableTabId + ) + return tab ? { id: tab.id, type: toVisibleTabType(tab.contentType) } : null +} + +export function readActivationRenderableSurfaceById( + identity: WorkspaceActivationIdentity, + surfaceId: string +): { id: string; type: WorkspaceVisibleTabType } | null { + const state = useAppStore.getState() + state.reconcileWorktreeTabModel(identity.workspaceKey) + const tab = (useAppStore.getState().unifiedTabsByWorktree[identity.workspaceKey] ?? []).find( + (candidate) => candidate.id === surfaceId + ) + return tab ? { id: tab.id, type: toVisibleTabType(tab.contentType) } : null +} + +export function readActivationRenderableSurfaceIds( + identity: WorkspaceActivationIdentity +): ReadonlySet { + const state = useAppStore.getState() + state.reconcileWorktreeTabModel(identity.workspaceKey) + return new Set( + (useAppStore.getState().unifiedTabsByWorktree[identity.workspaceKey] ?? []).map((tab) => tab.id) + ) +} + +export function readActivationRenderableInventory(identity: WorkspaceActivationIdentity): { + renderableTabCount: number + surface: { id: string; type: WorkspaceVisibleTabType } | null +} { + const state = useAppStore.getState() + const reconciliation = state.reconcileWorktreeTabModel(identity.workspaceKey) + const tab = reconciliation.activeRenderableTabId + ? (useAppStore.getState().unifiedTabsByWorktree[identity.workspaceKey] ?? []).find( + (candidate) => candidate.id === reconciliation.activeRenderableTabId + ) + : null + return { + renderableTabCount: reconciliation.renderableTabCount, + surface: tab ? { id: tab.id, type: toVisibleTabType(tab.contentType) } : null + } +} + +export function hasLiveActivationTerminalTombstone(workspaceKey: string): boolean { + return Object.hasOwn(useAppStore.getState().tabsByWorktree, workspaceKey) +} + +export function readStructuredActivationProducerStatus( + workspaceKey: string +): 'idle' | 'pending' | 'unknown' { + let status: 'idle' | 'pending' | 'unknown' = 'idle' + for (const agent of AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS) { + const candidate = getStructuredAgentLaunchStatus(workspaceKey, agent) + if (candidate === 'unknown') { + return 'unknown' + } + if (candidate === 'pending') { + status = 'pending' + } + } + return status +} + +export function waitForActivationRecoveryChange( + deadlineAt: number, + signal: AbortSignal | undefined +): Promise { + const remaining = Math.max(0, deadlineAt - Date.now()) + if (remaining === 0) { + return Promise.resolve('timeout') + } + return new Promise((resolve) => { + let settled = false + const finish = (result: RecoveryWaitResult): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + unsubscribeStore() + unsubscribeProducers() + unsubscribeStructured() + signal?.removeEventListener('abort', onAbort) + resolve(result) + } + const onAbort = (): void => finish('cancelled') + const timeout = setTimeout(() => finish('timeout'), remaining) + const unsubscribeStore = useAppStore.subscribe(() => finish('changed')) + const unsubscribeProducers = subscribeWorkspaceSurfaceProducers(() => finish('changed')) + const unsubscribeStructured = subscribeStructuredAgentLaunchStatus(() => finish('changed')) + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + finish('cancelled') + } + }) +} + +export function waitForActivationRecoveryPromise( + promise: Promise, + deadlineAt: number, + signal: AbortSignal | undefined +): Promise< + | { kind: 'settled'; value: T } + | { kind: 'rejected'; error: unknown } + | Exclude +> { + const remaining = Math.max(0, deadlineAt - Date.now()) + if (remaining === 0) { + return Promise.resolve('timeout') + } + return new Promise((resolve) => { + let settled = false + const finish = ( + result: + | { kind: 'settled'; value: T } + | { kind: 'rejected'; error: unknown } + | Exclude + ): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + signal?.removeEventListener('abort', onAbort) + resolve(result) + } + const onAbort = (): void => finish('cancelled') + const timeout = setTimeout(() => finish('timeout'), remaining) + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + finish('cancelled') + return + } + void promise.then( + (value) => finish({ kind: 'settled', value }), + (error: unknown) => finish({ kind: 'rejected', error }) + ) + }) +} + +export function canInspectAgentActivationInventory(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.api?.runtime?.call === 'function' && + typeof window.api?.pty?.listSessions === 'function' + ) +} diff --git a/src/renderer/src/lib/workspace-execution-evidence.ts b/src/renderer/src/lib/workspace-execution-evidence.ts new file mode 100644 index 00000000000..9c15aa35a94 --- /dev/null +++ b/src/renderer/src/lib/workspace-execution-evidence.ts @@ -0,0 +1,31 @@ +import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { + resolveWorkspaceTerminalHostAuthority, + type WorkspaceTerminalHostAuthorityState +} from './workspace-terminal-host-authority' + +export type WorkspaceExecutionEvidence = 'live' | 'unverifiable' | 'exited' + +export function resolveWorkspaceExecutionEvidence( + state: WorkspaceTerminalHostAuthorityState, + workspaceKey: string, + executionHostId: ExecutionHostId +): WorkspaceExecutionEvidence { + const authority = resolveWorkspaceTerminalHostAuthority(state, workspaceKey) + if (authority !== 'none') { + return authority + } + const host = parseExecutionHostId(executionHostId) + if (!host || host.kind === 'local') { + return 'exited' + } + if (host.kind === 'runtime' || parseWorkspaceKey(workspaceKey)?.type === 'folder') { + return 'unverifiable' + } + const syncStatus = state.remoteWorkspaceSyncStatusByTargetId?.[host.targetId] + return state.remoteWorkspaceHydratedTargetIds?.has(host.targetId) && + syncStatus?.phase === 'synced' + ? 'exited' + : 'unverifiable' +} diff --git a/src/renderer/src/lib/workspace-port-actions.ts b/src/renderer/src/lib/workspace-port-actions.ts index cf745d3e25f..aa9e617d70b 100644 --- a/src/renderer/src/lib/workspace-port-actions.ts +++ b/src/renderer/src/lib/workspace-port-actions.ts @@ -1,24 +1,19 @@ -import { activateAndRevealWorktree } from '@/lib/worktree-activation' import type { useAppStore } from '@/store' +import { activateAndRevealWorktree } from './worktree-activation' import { - assertRuntimeEnvironmentCapability, callRuntimeRpc, RuntimeRpcCallError, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' -import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import type { WorkspacePort, WorkspacePortKillResult, WorkspacePortScanResult } from '../../../shared/workspace-ports' -import type { LocalhostWorktreeLabelRoute } from '../../../shared/localhost-worktree-labels' import { runWorkspacePortScanForTarget } from './workspace-port-scan-client' -import { browserUrlForPort } from './workspace-port-urls' -import { BROWSER_SCREENCAST_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' -import { RUNTIME_BROWSER_UNAVAILABLE_MESSAGE } from './client-creation-action-policy' export { addressForPort } from './workspace-port-urls' +export { openWorkspacePortInBrowser } from './workspace-port-browser-open' const WORKSPACE_PORT_STOP_SETTLE_MS = 500 const WORKSPACE_PORT_TARGET_UNAVAILABLE_REASON = @@ -33,10 +28,6 @@ export function canStopWorkspacePort( return port.kind === 'workspace' && Boolean(port.pid) && port.processName !== 'Electron' } -type BrowserTabCreator = ReturnType['createBrowserTab'] -type RemoteBrowserPageHandleSetter = ReturnType< - typeof useAppStore.getState ->['setRemoteBrowserPageHandle'] type WorkspacePortScanRefreshingSetter = ReturnType< typeof useAppStore.getState >['setWorkspacePortScanRefreshing'] @@ -99,84 +90,6 @@ export function goToWorkspacePortOwner(port: WorkspacePort): boolean { return Boolean(worktreeId && activateAndRevealWorktree(worktreeId)) } -export async function openWorkspacePortInBrowser(args: { - port: WorkspacePort - activeWorktreeId?: string | null - runtimeTarget: RuntimeClientTarget | null - createBrowserTab: BrowserTabCreator - setRemoteBrowserPageHandle: RemoteBrowserPageHandleSetter - openInOrcaBrowser?: boolean - localhostLabelRoute?: LocalhostWorktreeLabelRoute | null -}): Promise<{ ok: true } | { ok: false; reason: string }> { - if (!args.runtimeTarget) { - return { ok: false, reason: WORKSPACE_PORT_TARGET_UNAVAILABLE_REASON } - } - const rawUrl = browserUrlForPort(args.port) - let url = rawUrl - if (args.runtimeTarget.kind === 'local' && args.localhostLabelRoute) { - try { - url = (await window.api.localhostWorktreeLabels.register(args.localhostLabelRoute)).url - } catch { - url = rawUrl - } - } - if (args.openInOrcaBrowser === false && args.runtimeTarget.kind === 'local') { - try { - await window.api.shell.openUrl(url) - return { ok: true } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return { ok: false, reason: message || 'Failed to open system browser.' } - } - } - - const worktreeId = - args.port.kind === 'workspace' ? args.port.owner.worktreeId : args.activeWorktreeId - if (!worktreeId) { - return { ok: false, reason: 'No workspace selected for the browser.' } - } - // Why: the browser tab opened below is this jump's surface; seeding a shell would add a - // PTY the user never asked for in a workspace whose last terminal they closed. - activateAndRevealWorktree(worktreeId, { providesInitialSurface: true }) - if (args.runtimeTarget.kind === 'environment') { - try { - await assertRuntimeEnvironmentCapability( - args.runtimeTarget.environmentId, - BROWSER_SCREENCAST_RUNTIME_CAPABILITY, - RUNTIME_BROWSER_UNAVAILABLE_MESSAGE - ) - const remotePage = await callRuntimeRpc<{ browserPageId: string }>( - args.runtimeTarget, - 'browser.tabCreate', - { worktree: toRuntimeWorktreeSelector(worktreeId), url }, - { timeoutMs: 30_000 } - ) - const tab = args.createBrowserTab(worktreeId, url, { - activate: true, - browserRuntimeEnvironmentId: args.runtimeTarget.environmentId - }) - if (!tab.activePageId) { - return { ok: false, reason: 'Failed to create a browser page.' } - } - args.setRemoteBrowserPageHandle(tab.activePageId, { - environmentId: args.runtimeTarget.environmentId, - remotePageId: remotePage.browserPageId - }) - return { ok: true } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return { ok: false, reason: message || 'Failed to open remote browser.' } - } - } - try { - args.createBrowserTab(worktreeId, url, { activate: true }) - return { ok: true } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return { ok: false, reason: message || 'Failed to open browser.' } - } -} - /** * Stores one host's scan and republishes the aggregate the status bar reads. * Why: a single-host publish used to overwrite that aggregate, so every other diff --git a/src/renderer/src/lib/workspace-port-browser-open.ts b/src/renderer/src/lib/workspace-port-browser-open.ts new file mode 100644 index 00000000000..55709706219 --- /dev/null +++ b/src/renderer/src/lib/workspace-port-browser-open.ts @@ -0,0 +1,121 @@ +import { activateAndRevealWorktree } from './worktree-activation' +import { useAppStore } from '@/store' +import { + assertRuntimeEnvironmentCapability, + callRuntimeRpc, + type RuntimeClientTarget +} from '@/runtime/runtime-rpc-client' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import type { WorkspacePort } from '../../../shared/workspace-ports' +import type { LocalhostWorktreeLabelRoute } from '../../../shared/localhost-worktree-labels' +import { browserUrlForPort } from './workspace-port-urls' +import { BROWSER_SCREENCAST_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { RUNTIME_BROWSER_UNAVAILABLE_MESSAGE } from './client-creation-action-policy' +import { registerWorkspaceSurfaceProducer } from './workspace-surface-production' +import { getExecutionHostIdForWorktree } from './worktree-runtime-owner' +import { toRuntimeExecutionHostId } from '../../../shared/execution-host' + +type BrowserTabCreator = ReturnType['createBrowserTab'] +type RemoteBrowserPageHandleSetter = ReturnType< + typeof useAppStore.getState +>['setRemoteBrowserPageHandle'] + +const WORKSPACE_PORT_TARGET_UNAVAILABLE_REASON = + 'Workspace ports are unavailable for this execution host.' + +export async function openWorkspacePortInBrowser(args: { + port: WorkspacePort + activeWorktreeId?: string | null + runtimeTarget: RuntimeClientTarget | null + createBrowserTab: BrowserTabCreator + setRemoteBrowserPageHandle: RemoteBrowserPageHandleSetter + openInOrcaBrowser?: boolean + localhostLabelRoute?: LocalhostWorktreeLabelRoute | null +}): Promise<{ ok: true } | { ok: false; reason: string }> { + if (!args.runtimeTarget) { + return { ok: false, reason: WORKSPACE_PORT_TARGET_UNAVAILABLE_REASON } + } + const rawUrl = browserUrlForPort(args.port) + let url = rawUrl + if (args.runtimeTarget.kind === 'local' && args.localhostLabelRoute) { + try { + url = (await window.api.localhostWorktreeLabels.register(args.localhostLabelRoute)).url + } catch { + url = rawUrl + } + } + if (args.openInOrcaBrowser === false && args.runtimeTarget.kind === 'local') { + try { + await window.api.shell.openUrl(url) + return { ok: true } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { ok: false, reason: message || 'Failed to open system browser.' } + } + } + + const worktreeId = + args.port.kind === 'workspace' ? args.port.owner.worktreeId : args.activeWorktreeId + if (!worktreeId) { + return { ok: false, reason: 'No workspace selected for the browser.' } + } + const state = useAppStore.getState() + const executionHostId = + args.runtimeTarget.kind === 'environment' + ? toRuntimeExecutionHostId(args.runtimeTarget.environmentId) + : getExecutionHostIdForWorktree(state, worktreeId) + const producer = registerWorkspaceSurfaceProducer({ workspaceKey: worktreeId, executionHostId }) + try { + const activation = activateAndRevealWorktree(worktreeId, { executionHostId }) + if (activation === false) { + producer.failed('The workspace is no longer available.') + return { ok: false, reason: 'The workspace is no longer available.' } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + producer.failed(message || 'Failed to activate the workspace.') + return { ok: false, reason: message || 'Failed to activate the workspace.' } + } + if (args.runtimeTarget.kind === 'environment') { + try { + await assertRuntimeEnvironmentCapability( + args.runtimeTarget.environmentId, + BROWSER_SCREENCAST_RUNTIME_CAPABILITY, + RUNTIME_BROWSER_UNAVAILABLE_MESSAGE + ) + const remotePage = await callRuntimeRpc<{ browserPageId: string }>( + args.runtimeTarget, + 'browser.tabCreate', + { worktree: toRuntimeWorktreeSelector(worktreeId), url }, + { timeoutMs: 30_000 } + ) + const tab = args.createBrowserTab(worktreeId, url, { + activate: true, + browserRuntimeEnvironmentId: args.runtimeTarget.environmentId + }) + if (!tab.activePageId) { + producer.failed('Failed to create a browser page.') + return { ok: false, reason: 'Failed to create a browser page.' } + } + args.setRemoteBrowserPageHandle(tab.activePageId, { + environmentId: args.runtimeTarget.environmentId, + remotePageId: remotePage.browserPageId + }) + producer.materialized({ kind: 'tab', id: tab.id }) + return { ok: true } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + producer.failed(message || 'Failed to open remote browser.') + return { ok: false, reason: message || 'Failed to open remote browser.' } + } + } + try { + const tab = args.createBrowserTab(worktreeId, url, { activate: true }) + producer.materialized({ kind: 'tab', id: tab.id }) + return { ok: true } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + producer.failed(message || 'Failed to open browser.') + return { ok: false, reason: message || 'Failed to open browser.' } + } +} diff --git a/src/renderer/src/lib/workspace-surface-production.ts b/src/renderer/src/lib/workspace-surface-production.ts new file mode 100644 index 00000000000..ea329317039 --- /dev/null +++ b/src/renderer/src/lib/workspace-surface-production.ts @@ -0,0 +1,122 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' +import { createBrowserUuid } from './browser-uuid' + +export type WorkspaceSurfaceIdentity = + | { kind: 'tab'; id: string } + | { kind: 'workspace-content'; id: string } + +export type WorkspaceSurfaceProductionResult = + | { kind: 'materialized'; surface: WorkspaceSurfaceIdentity } + | { kind: 'declined'; reason: string } + | { kind: 'failed'; reason: string } + | { kind: 'unverifiable'; reason: string } + +export type WorkspaceSurfaceProducerAttempt = { + id: string + workspaceKey: string + executionHostId: ExecutionHostId + result: Promise +} + +export type WorkspaceSurfaceProducer = { + attempt: WorkspaceSurfaceProducerAttempt + materialized: (surface: WorkspaceSurfaceIdentity) => void + declined: (reason: unknown) => void + failed: (reason: unknown) => void + unverifiable: (reason: unknown) => void +} + +export type WorkspaceSurfaceProducerEntry = { + attempt: WorkspaceSurfaceProducerAttempt + result: WorkspaceSurfaceProductionResult | null + settle: (result: WorkspaceSurfaceProductionResult) => void +} + +const entriesByAttemptId = new Map() +const listeners = new Set<() => void>() + +function notifyListeners(): void { + for (const listener of listeners) { + listener() + } +} + +function reasonText(reason: unknown): string { + if (reason instanceof Error) { + return reason.message + } + const text = String(reason) + return text === '[object Object]' ? 'The surface producer did not provide a reason.' : text +} + +export function registerWorkspaceSurfaceProducer(args: { + workspaceKey: string + executionHostId: ExecutionHostId + attemptId?: string +}): WorkspaceSurfaceProducer { + const id = args.attemptId?.trim() || createBrowserUuid() + if (entriesByAttemptId.has(id)) { + throw new Error(`A workspace surface producer already owns attempt ${id}.`) + } + let settlePromise: (result: WorkspaceSurfaceProductionResult) => void = () => undefined + const result = new Promise((resolve) => { + settlePromise = resolve + }) + const attempt: WorkspaceSurfaceProducerAttempt = { + id, + workspaceKey: args.workspaceKey, + executionHostId: args.executionHostId, + result + } + const entry: WorkspaceSurfaceProducerEntry = { + attempt, + result: null, + settle: (settlement) => { + if (entry.result) { + return + } + entry.result = settlement + settlePromise(settlement) + notifyListeners() + } + } + entriesByAttemptId.set(id, entry) + notifyListeners() + return { + attempt, + materialized: (surface) => entry.settle({ kind: 'materialized', surface }), + declined: (reason) => entry.settle({ kind: 'declined', reason: reasonText(reason) }), + failed: (reason) => entry.settle({ kind: 'failed', reason: reasonText(reason) }), + unverifiable: (reason) => entry.settle({ kind: 'unverifiable', reason: reasonText(reason) }) + } +} + +export function readWorkspaceSurfaceProducerEntries(args: { + workspaceKey: string + executionHostId: ExecutionHostId +}): readonly Readonly[] { + return [...entriesByAttemptId.values()].filter( + (entry) => + entry.attempt.workspaceKey === args.workspaceKey && + entry.attempt.executionHostId === args.executionHostId + ) +} + +export function consumeWorkspaceSurfaceProducerAttempt(attemptId: string): void { + const entry = entriesByAttemptId.get(attemptId) + if (!entry || entry.result?.kind === 'unverifiable' || entry.result === null) { + return + } + entriesByAttemptId.delete(attemptId) + notifyListeners() +} + +export function subscribeWorkspaceSurfaceProducers(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function resetWorkspaceSurfaceProducersForTests(): void { + entriesByAttemptId.clear() + notifyListeners() +} diff --git a/src/renderer/src/lib/workspace-terminal-host-authority.test.ts b/src/renderer/src/lib/workspace-terminal-host-authority.test.ts index c7c5bc60f7e..fdce0431d25 100644 --- a/src/renderer/src/lib/workspace-terminal-host-authority.test.ts +++ b/src/renderer/src/lib/workspace-terminal-host-authority.test.ts @@ -98,26 +98,22 @@ describe('workspace terminal seeding authority', () => { // The host answers and holds nothing here. That is positive evidence, so the workspace seeds. store.getState().markRemoteWorkspaceHydrated(TARGET_ID) + store.getState().setRemoteWorkspaceSyncStatus(TARGET_ID, { phase: 'synced', direction: 'pull' }) expect(resolveWorkspaceTerminalHostAuthority(store.getState(), SSH_WORKTREE_ID)).toBe('none') expect(ensureWorktreeHasInitialTerminal(store.getState(), SSH_WORKTREE_ID)).toBeTruthy() expect(terminalTabCount(store, SSH_WORKTREE_ID)).toBe(1) }) it.each(['offline', 'error'] as const)( - 'falls back to none when a sync terminates in %s without ever hydrating', + 'does not let the legacy %s-to-none floor authorize a writer', (phase) => { - // The regression this guards: remoteWorkspaceHydratedTargetIds is add-only in practice - // (clearRemoteWorkspaceHydrated has no production caller), so without a floor one failed sync - // leaves every git worktree on this target terminal-less and its sleeping agents unresumable - // for the rest of the app session — strictly worse than the pre-gate behaviour, and escapable - // only by creating a tab by hand. const store = createTestStore() seedDirectSsh(store) store.getState().setRemoteWorkspaceSyncStatus(TARGET_ID, { phase, direction: 'pull' }) expect(resolveWorkspaceTerminalHostAuthority(store.getState(), SSH_WORKTREE_ID)).toBe('none') - expect(ensureWorktreeHasInitialTerminal(store.getState(), SSH_WORKTREE_ID)).toBeTruthy() - expect(terminalTabCount(store, SSH_WORKTREE_ID)).toBe(1) + expect(ensureWorktreeHasInitialTerminal(store.getState(), SSH_WORKTREE_ID)).toBeNull() + expect(terminalTabCount(store, SSH_WORKTREE_ID)).toBe(0) } ) diff --git a/src/renderer/src/lib/workspace-terminal-host-authority.ts b/src/renderer/src/lib/workspace-terminal-host-authority.ts index ad535e94563..ed8077e4bfb 100644 --- a/src/renderer/src/lib/workspace-terminal-host-authority.ts +++ b/src/renderer/src/lib/workspace-terminal-host-authority.ts @@ -45,14 +45,9 @@ export type WorkspaceTerminalHostAuthorityState = WorktreeRuntimeOwnerState & { * lift it. Four paths reach here: local-hydration timeout, a null `remoteWorkspace.get`, a falsy * apply token, and never connecting at all. * - * Known gap: this floor was reasoned about when hydration was add-only, so "un-hydrated" implied - * "the host never answered". A snapshot whose rows could not be placed now revokes hydration - * (remote-workspace-snapshot-apply.ts), so a target that later lands on `offline`/`error` reaches - * this floor having *demonstrably* answered with tabs. Seeding is then authorised over live host - * terminals. That is not a regression — before the revocation existed the same target was marked - * hydrated and `synced`, which reached `none` sooner — but the floor should learn to tell a - * revoked target from one that never answered. Tracked for the SSH-v3 consolidation, where a - * single authoritative liveness source replaces this pair. */ + * This legacy ownership floor is not execution evidence. Writer authorization goes through + * workspace-execution-evidence, which keeps offline/error and stale hydration `unverifiable`. + * Tracked for SSH-v3 consolidation, where one authoritative liveness source replaces this pair. */ const TERMINATED_WITHOUT_ANSWER_PHASES = new Set(['offline', 'error']) function resolveDirectSshAuthority( @@ -67,10 +62,8 @@ function resolveDirectSshAuthority( return phase === 'conflict' ? 'unverifiable' : 'none' } if (phase !== undefined && TERMINATED_WITHOUT_ANSWER_PHASES.has(phase)) { - // The bounded floor. Without it a single failed sync leaves every git worktree on this target - // terminal-less and its sleeping agents unresumable for the rest of the app session — strictly - // worse than the pre-gate behaviour, and only escapable by creating a tab by hand. Declining to - // seed is meant to be a wait, not a permanent refusal. + // Legacy readers retain the bounded floor; recovery and seeding separately require current + // `exited` evidence, so this value cannot authorize a writer while the host is unreachable. return 'none' } // Not connected, still pulling, or not yet attempted — "we could not ask", never "nothing there". diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index dd6eaa2ac31..f8a885fb1ed 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -217,7 +217,8 @@ describe('activateAndRevealWorktree', () => { }) it('forwards an explicit sidebar reveal behavior', () => { - const worktree = makeWorktree() + // A distinct identity prevents this synchronous contract from joining another test's gate. + const worktree = { ...makeWorktree(), id: 'wt-sidebar-reveal' } const revealWorktreeInSidebar = vi.fn() useAppStore.setState({ diff --git a/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts b/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts index 4d2b43e26bd..73aa2674fcb 100644 --- a/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts +++ b/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts @@ -1,304 +1,145 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' -import { - activateAndRevealFolderWorkspace, - activateAndRevealWorkspace, - activateAndRevealWorktree -} from './worktree-activation' +import { activateAndRevealFolderWorkspace, activateAndRevealWorktree } from './worktree-activation' import * as activationGate from './worktree-agent-activation-gate' -import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding' import { folderWorkspaceKey } from '../../../shared/workspace-scope' import { toSshExecutionHostId } from '../../../shared/execution-host' import { makeCreatedAgentWorktree as makeWorktree, seedEmptyActivatableWorktree } from '@/lib/worktree-activation-created-agent-test-state' -import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate' +import { + registerWorkspaceSurfaceProducer, + resetWorkspaceSurfaceProducersForTests +} from './workspace-surface-production' +import { resetWorkspaceActivationRecoveryPresentationsForTests } from './workspace-activation-recovery-presentation' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' const initialAppStoreState = useAppStore.getState() afterEach(() => { vi.unstubAllGlobals() vi.restoreAllMocks() + resetWorkspaceSurfaceProducersForTests() + resetWorkspaceActivationRecoveryPresentationsForTests() useAppStore.setState(initialAppStoreState, true) }) -/** The state a workspace lands in once its last terminal is closed: the row survives as an - * explicit empty list rather than disappearing. */ function seedClosedLastTerminal(worktreeId: string): void { useAppStore.setState({ tabsByWorktree: { [worktreeId]: [] } }) - const { renderableTabCount } = useAppStore.getState().reconcileWorktreeTabModel(worktreeId) - expect(renderableTabCount).toBe(0) + expect(useAppStore.getState().reconcileWorktreeTabModel(worktreeId).renderableTabCount).toBe(0) } -describe('activating a workspace whose last terminal was closed', () => { +function sleepingRecord(worktreeId: string): SleepingAgentSessionRecord { + return { + paneKey: 'pane-1', + worktreeId, + agent: 'codex', + providerSession: { key: 'session_id', id: 'session-1' }, + prompt: '', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } +} + +describe('selection-free empty-workspace recovery', () => { it.each([ - ['Blank Terminal', null, 1], - ['an agent', 'codex' as const, 0] - ])('seeds a default shell for %s selection only', (_label, agent, expectedTabCount) => { + ['Blank Terminal', null], + ['an agent selection without a producer', 'codex' as const] + ])('seeds one plain shell for %s when inventory APIs are unavailable', async (_label, agent) => { const worktree = makeWorktree() seedEmptyActivatableWorktree(worktree) + seedClosedLastTerminal(worktree.id) const result = activateAndRevealWorktree(worktree.id, { agent, notifyHostRuntime: false }) - expect(result).not.toBe(false) - expect(result === false ? null : result.primaryTabId === null).toBe(expectedTabCount === 0) - expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(expectedTabCount) + expect(result).toEqual({ primaryTabId: expect.any(String) }) + expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1) + expect(result === false ? null : result.primaryTabId).toBe( + useAppStore.getState().tabsByWorktree[worktree.id]?.[0]?.id + ) + expect(useAppStore.getState().tabsByWorktree[worktree.id]?.[0]?.launchAgent).toBeUndefined() }) - it.each([true, false])( - 'forwards providesInitialSurface=%s through the async activation gate', - async (providesInitialSurface) => { + it.each([null, 'codex' as const])( + 're-seeds after a gate reports empty regardless of picker selection (%s)', + async (agent) => { const worktree = makeWorktree() seedEmptyActivatableWorktree(worktree) seedClosedLastTerminal(worktree.id) useAppStore.setState({ sleepingAgentSessionsByPaneKey: { - 'pane-1': { worktreeId: worktree.id } - } as never + 'pane-1': sleepingRecord(worktree.id) + } }) - const gate = vi.spyOn(activationGate, 'gateWorktreeAgentActivation') - gate.mockResolvedValue('empty') + vi.spyOn(activationGate, 'gateWorktreeAgentActivation').mockResolvedValue('empty') - activateAndRevealWorktree(worktree.id, { - providesInitialSurface, - notifyHostRuntime: false - }) - await gate.mock.results[0]?.value + activateAndRevealWorktree(worktree.id, { agent, notifyHostRuntime: false }) - expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength( - providesInitialSurface ? 0 : 1 + await vi.waitFor(() => + expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1) ) } ) - // An empty gate invalidates agent-based suppression; only an explicit caller surface survives. - it.each([ - ['an agent selection', { agent: 'codex' as const }, 1], - [ - 'an agent selection whose caller promised its own surface', - { agent: 'codex' as const, providesInitialSurface: true }, - 0 - ] - ])('re-seeds a gate-reported empty workspace for %s', async (_label, selection, expectedTabs) => { + it('waits for a concrete surface producer instead of a promise boolean', async () => { const worktree = makeWorktree() seedEmptyActivatableWorktree(worktree) - seedClosedLastTerminal(worktree.id) - useAppStore.setState({ - sleepingAgentSessionsByPaneKey: { - 'pane-1': { worktreeId: worktree.id } - } as never + const producer = registerWorkspaceSurfaceProducer({ + workspaceKey: worktree.id, + executionHostId: 'local' }) - const gate = vi.spyOn(activationGate, 'gateWorktreeAgentActivation') - gate.mockResolvedValue('empty') - - const result = activateAndRevealWorktree(worktree.id, { - ...selection, - notifyHostRuntime: false - }) - await gate.mock.results[0]?.value - - // The gate owns seeding for this activation, so the synchronous call never returns a tab. - expect(result === false ? null : result.primaryTabId).toBeNull() - expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(expectedTabs) - }) - - it('lets the latest caller surface supersede an older callback on the shared gate', async () => { - const worktree = makeWorktree() - seedEmptyActivatableWorktree(worktree) - seedClosedLastTerminal(worktree.id) - useAppStore.setState({ - sleepingAgentSessionsByPaneKey: { - 'pane-1': { worktreeId: worktree.id } - } as never - }) - let resolveGate!: (outcome: activationGate.WorktreeAgentActivationOutcome) => void - const pendingGate = new Promise((resolve) => { - resolveGate = resolve - }) - vi.spyOn(activationGate, 'gateWorktreeAgentActivation').mockReturnValue(pendingGate) activateAndRevealWorktree(worktree.id, { agent: 'codex', notifyHostRuntime: false }) - activateAndRevealWorktree(worktree.id, { - providesInitialSurface: true, - notifyHostRuntime: false - }) - resolveGate('empty') - await pendingGate + await Promise.resolve() - expect(useAppStore.getState().tabsByWorktree[worktree.id]).toEqual([]) + expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0) + producer.materialized({ kind: 'workspace-content', id: 'requested-content' }) + await producer.attempt.result + expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0) }) - it('does not double-seed when another recovery creates a tab before the gate settles', async () => { + it('does not double-seed when content appears before the gate settles', async () => { const worktree = makeWorktree() seedEmptyActivatableWorktree(worktree) seedClosedLastTerminal(worktree.id) useAppStore.setState({ - sleepingAgentSessionsByPaneKey: { - 'pane-1': { worktreeId: worktree.id } - } as never + sleepingAgentSessionsByPaneKey: { 'pane-1': sleepingRecord(worktree.id) } }) let resolveGate!: (outcome: activationGate.WorktreeAgentActivationOutcome) => void - const pendingGate = new Promise((resolve) => { - resolveGate = resolve - }) - vi.spyOn(activationGate, 'gateWorktreeAgentActivation').mockReturnValue(pendingGate) - - activateAndRevealWorktree(worktree.id, { agent: 'codex', notifyHostRuntime: false }) - const recoveredTabId = ensureWorktreeHasInitialTerminal( - useAppStore.getState(), - worktree.id, - undefined, - undefined, - undefined, - undefined, - { reseedEmptiedWorkspace: true } - ) - resolveGate('empty') - await pendingGate - - expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1) - expect(useAppStore.getState().tabsByWorktree[worktree.id]?.[0]?.id).toBe(recoveredTabId) - }) - - it('re-seeds a terminal when the workspace is opened from elsewhere', () => { - const worktree = makeWorktree() - seedEmptyActivatableWorktree(worktree) - seedClosedLastTerminal(worktree.id) - - const result = activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false }) - - expect(result).not.toBe(false) - expect(result === false ? null : result.primaryTabId).toBeTruthy() - expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1) - }) - - // Why: hydration restores an emptied workspace as active, so the user is already looking at the - // blank pane when they click its row. Suppressing the re-seed there strands them on the bug. - it('re-seeds when the restored active workspace is reopened on the same host', () => { - const worktree = makeWorktree() - seedEmptyActivatableWorktree(worktree) - seedClosedLastTerminal(worktree.id) - useAppStore.setState({ - activeWorktreeId: worktree.id, - activeWorkspaceExecutionHostId: 'local', - activeView: 'terminal' - }) - - activateAndRevealWorktree(worktree.id, { - executionHostId: 'local', - notifyHostRuntime: false - }) - - expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1) - }) - - // Why: entry points disagree about whether to pass a host for the same local workspace — the - // sidebar derives 'local', the Cmd+J palette passes nothing. Re-seeding no longer reads the - // host at all; this guards against reintroducing a host-sensitive carve-out. - it('re-seeds identically whether or not the caller passes an execution host', () => { - for (const opts of [{}, { executionHostId: 'local' as const }]) { - const worktree = makeWorktree() - seedEmptyActivatableWorktree(worktree) - seedClosedLastTerminal(worktree.id) - useAppStore.setState({ - activeWorktreeId: worktree.id, - activeWorkspaceExecutionHostId: 'local', - activeView: 'terminal' + vi.spyOn(activationGate, 'gateWorktreeAgentActivation').mockReturnValue( + new Promise((resolve) => { + resolveGate = resolve }) - - activateAndRevealWorktree(worktree.id, { ...opts, notifyHostRuntime: false }) - - expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1) - } - }) - - // Why: terminal file links and check annotations activate only to route history before they - // open an editor tab. Seeding there hands the user a shell they never asked for and erases the - // tombstone permanently. See terminal-file-open-routing.ts and check-annotation-open.ts. - it('leaves the row empty when the caller opens its own surface', () => { - const worktree = makeWorktree() - seedEmptyActivatableWorktree(worktree) - seedClosedLastTerminal(worktree.id) - - const result = activateAndRevealWorktree(worktree.id, { - providesInitialSurface: true, - notifyHostRuntime: false - }) - - expect(result).not.toBe(false) - expect(result === false ? null : result.primaryTabId).toBeNull() - expect(useAppStore.getState().tabsByWorktree[worktree.id]).toEqual([]) - }) - - it('leaves the row empty for startup hydration, which never opts into re-seeding', () => { - const worktree = makeWorktree() - seedEmptyActivatableWorktree(worktree) - seedClosedLastTerminal(worktree.id) - - const tabId = ensureWorktreeHasInitialTerminal(useAppStore.getState(), worktree.id) - - expect(tabId).toBeNull() - expect(useAppStore.getState().tabsByWorktree[worktree.id]).toEqual([]) - }) - - // Why: this passes ahead of the tombstone check — a renderable browser tab short-circuits - // `shouldAutoCreateInitialTerminal` — so it guards `renderableTabCount`, not the re-seed flag. - it('does not add a terminal to a workspace that still renders a browser tab', () => { - const worktree = makeWorktree() - seedEmptyActivatableWorktree(worktree) - seedClosedLastTerminal(worktree.id) - useAppStore.setState({ - browserTabsByWorktree: { - [worktree.id]: [ - { - id: 'browser-1', - worktreeId: worktree.id, - url: 'https://example.com', - title: 'example', - sortOrder: 0, - createdAt: 1 - } - ] - }, - unifiedTabsByWorktree: { - [worktree.id]: [ - { - id: 'browser-1', - entityId: 'browser-1', - groupId: 'group-1', - worktreeId: worktree.id, - contentType: 'browser', - label: 'example', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - groupsByWorktree: { - [worktree.id]: [ - { - id: 'group-1', - worktreeId: worktree.id, - activeTabId: 'browser-1', - tabOrder: ['browser-1'] - } - ] - }, - activeGroupIdByWorktree: { [worktree.id]: 'group-1' } - } as unknown as Partial>) - expect( - useAppStore.getState().reconcileWorktreeTabModel(worktree.id).renderableTabCount - ).toBeGreaterThan(0) + ) activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false }) + useAppStore.getState().createTab(worktree.id) + resolveGate('empty') + + await vi.waitFor(() => + expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1) + ) + }) + + it('keeps a surviving browser as the chosen surface', async () => { + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + seedClosedLastTerminal(worktree.id) + useAppStore.getState().createBrowserTab(worktree.id, 'https://example.com', { activate: true }) + + activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false }) + await Promise.resolve() expect(useAppStore.getState().tabsByWorktree[worktree.id]).toEqual([]) + expect( + useAppStore.getState().reconcileWorktreeTabModel(worktree.id).activeRenderableTabId + ).toBeTruthy() }) }) @@ -306,203 +147,67 @@ const FOLDER_ID = 'folder-1' const FOLDER_KEY = folderWorkspaceKey(FOLDER_ID) const SSH_HOST_ID = toSshExecutionHostId('conn-1') -/** One folder id resolves to a different `FolderWorkspace` per host while both share a single - * `tabsByWorktree[FOLDER_KEY]` row. */ -function seedEmptiedFolderWorkspaceOnTwoHosts(): void { - const base = { - id: FOLDER_ID, - projectGroupId: 'group-1', - name: 'notes', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0 - } +function seedEmptyFolderWorkspace(executionHostId: 'local' | `ssh:${string}`): void { useAppStore.setState({ folderWorkspaces: [ - { ...base, folderPath: '/local/notes', executionHostId: 'local' }, - // Why: an SSH host, not a runtime one — runtime-owned workspaces return early from - // `ensureWorktreeHasInitialTerminal` because the host owns terminal creation. - { ...base, folderPath: '/remote/notes', executionHostId: SSH_HOST_ID, connectionId: 'conn-1' } + { + id: FOLDER_ID, + projectGroupId: 'group-1', + name: 'notes', + folderPath: executionHostId === 'local' ? '/local/notes' : '/remote/notes', + executionHostId, + ...(executionHostId === 'local' ? {} : { connectionId: 'conn-1' }), + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } ], activeView: 'terminal', tabsByWorktree: { [FOLDER_KEY]: [] }, unifiedTabsByWorktree: {}, groupsByWorktree: {}, - getFreshFolderWorkspacePathStatus: () => ({ exists: true }), + getFreshFolderWorkspacePathStatus: () => ({ exists: true, path: '/local/notes' }), markWorktreeVisited: vi.fn(), recordWorktreeVisit: vi.fn(), revealWorktreeInSidebar: vi.fn() - } as unknown as Partial>) - expect(useAppStore.getState().reconcileWorktreeTabModel(FOLDER_KEY).renderableTabCount).toBe(0) + }) } -describe('activating a folder workspace whose last terminal was closed', () => { - it.each([ - ['Blank Terminal', null, 1], - ['an agent', 'codex' as const, 0] - ])('seeds a default shell for %s selection only', (_label, agent, expectedTabCount) => { - seedEmptiedFolderWorkspaceOnTwoHosts() +describe('folder activation recovery', () => { + it('recovers the folder-specific activation path without consulting agent selection', async () => { + seedEmptyFolderWorkspace('local') - const result = activateAndRevealFolderWorkspace(FOLDER_ID, { - agent, + activateAndRevealFolderWorkspace(FOLDER_ID, { + agent: 'codex', executionHostId: 'local' }) - expect(result).not.toBe(false) - expect(useAppStore.getState().activeWorktreeId).toBe(FOLDER_KEY) - expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength(expectedTabCount) + await vi.waitFor(() => + expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength(1) + ) }) - it.each([true, false])( - 'forwards providesInitialSurface=%s through the async activation gate', - async (providesInitialSurface) => { - seedEmptiedFolderWorkspaceOnTwoHosts() - useAppStore.setState({ - sleepingAgentSessionsByPaneKey: { - 'pane-1': { worktreeId: FOLDER_KEY } - } as never - }) - const gate = vi.spyOn(activationGate, 'gateWorktreeAgentActivation') - gate.mockResolvedValue('empty') + it('keeps the direct general setter state-only', async () => { + seedEmptyFolderWorkspace('local') - activateAndRevealFolderWorkspace(FOLDER_ID, { - executionHostId: 'local', - providesInitialSurface - }) - await gate.mock.results[0]?.value + useAppStore.getState().setActiveWorktree(FOLDER_KEY, 'local') + await Promise.resolve() - expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength( - providesInitialSurface ? 0 : 1 - ) - } - ) - - it.each(['local', SSH_HOST_ID] as const)( - 'opens a notification on %s without revealing the folder', - (executionHostId) => { - seedEmptiedFolderWorkspaceOnTwoHosts() - useAppStore.setState({ sidebarBody: 'agents' }) - - const result = activateAndRevealWorkspace(FOLDER_KEY, { - executionHostId, - revealInSidebar: false, - clearSidebarFilters: false - }) - - expect(result).not.toBe(false) - expect(useAppStore.getState().activeWorktreeId).toBe(FOLDER_KEY) - expect(useAppStore.getState().sidebarBody).toBe('agents') - expect(useAppStore.getState().revealWorktreeInSidebar).not.toHaveBeenCalled() - } - ) - - // This must use the folder entry point; setActiveWorktree(folderWorkspaceKey(...)) takes the git path. - it.each([ - ['a local agent selection', { agent: 'codex' as const }, 'local' as const, 1], - ['an SSH agent selection', { agent: 'codex' as const }, SSH_HOST_ID, 1], - [ - 'an agent selection whose caller promised its own surface', - { agent: 'codex' as const, providesInitialSurface: true }, - 'local' as const, - 0 - ] - ])( - 're-seeds a gate-reported empty workspace for %s', - async (_label, selection, executionHostId, expectedTabs) => { - seedEmptiedFolderWorkspaceOnTwoHosts() - useAppStore.setState({ - sleepingAgentSessionsByPaneKey: { - 'pane-1': { worktreeId: FOLDER_KEY } - } as never - }) - const gate = vi.spyOn(activationGate, 'gateWorktreeAgentActivation') - gate.mockResolvedValue('empty') - - activateAndRevealFolderWorkspace(FOLDER_ID, { ...selection, executionHostId }) - await gate.mock.results[0]?.value - - expect(useAppStore.getState().activeWorktreeId).toBe(FOLDER_KEY) - expect(useAppStore.getState().activeWorkspaceExecutionHostId).toBe(executionHostId) - expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY] ?? []).toHaveLength(expectedTabs) - } - ) - - it('re-seeds a terminal when the workspace is opened', () => { - seedEmptiedFolderWorkspaceOnTwoHosts() - - const result = activateAndRevealFolderWorkspace(FOLDER_ID, { executionHostId: 'local' }) - - expect(result).not.toBe(false) - expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength(1) - }) - - it('re-seeds when the restored active folder workspace is reopened on the same host', () => { - seedEmptiedFolderWorkspaceOnTwoHosts() - useAppStore.setState({ - activeWorktreeId: FOLDER_KEY, - activeWorkspaceExecutionHostId: 'local' - }) - - activateAndRevealFolderWorkspace(FOLDER_ID, { executionHostId: 'local' }) - - expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength(1) - }) - - // Why: the opt-out must mean the same thing on both workspace shapes, or routing a - // file link through a folder workspace would silently regress to seeding a shell. - it('leaves the row empty when the caller opens its own surface', async () => { - seedEmptiedFolderWorkspaceOnTwoHosts() - useAppStore.setState({ - workspaceSessionReady: true, - terminalStartupRestorationReady: true - }) - vi.stubGlobal('window', { - api: { - runtime: { - // A `session.tabs.list` answer must name the scope it listed, or the gate refuses it and - // blocks — which would leave the row empty for the wrong reason. - call: vi.fn(async () => ({ - ok: true, - result: { - worktree: FOLDER_KEY, - publicationEpoch: 'epoch-1', - snapshotVersion: 1, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [] - } - })) - }, - pty: { listSessions: vi.fn(async () => []) } - } - }) - - const result = activateAndRevealFolderWorkspace(FOLDER_ID, { - executionHostId: 'local', - providesInitialSurface: true - }) - await waitForWorktreeAgentActivationGateForTests(FOLDER_KEY) - - expect(result).not.toBe(false) - expect(result === false ? null : result.primaryTabId).toBeNull() expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toEqual([]) }) - it('re-seeds the shared row when opening the same folder id on a different host', () => { - seedEmptiedFolderWorkspaceOnTwoHosts() - useAppStore.setState({ - activeWorktreeId: FOLDER_KEY, - activeWorkspaceExecutionHostId: 'local' - }) + it('does not start a writer for an unverifiable SSH folder', async () => { + seedEmptyFolderWorkspace(SSH_HOST_ID) - const result = activateAndRevealFolderWorkspace(FOLDER_ID, { executionHostId: SSH_HOST_ID }) + activateAndRevealFolderWorkspace(FOLDER_ID, { executionHostId: SSH_HOST_ID }) + await Promise.resolve() - expect(result).not.toBe(false) - expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength(1) - expect(useAppStore.getState().activeWorkspaceExecutionHostId).toBe(SSH_HOST_ID) + expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toEqual([]) }) }) diff --git a/src/renderer/src/lib/worktree-activation-empty-remote.test.ts b/src/renderer/src/lib/worktree-activation-empty-remote.test.ts index c16a8d6a448..d3e3300d87c 100644 --- a/src/renderer/src/lib/worktree-activation-empty-remote.test.ts +++ b/src/renderer/src/lib/worktree-activation-empty-remote.test.ts @@ -7,6 +7,11 @@ import { resetWebSessionTabsSnapshotFreshnessForTests } from '@/runtime/web-sess import { useAppStore } from '@/store' import { ensureWebRuntimeWorktreeTerminalAfterWake } from './web-runtime-worktree-terminal-after-wake' import { toast } from 'sonner' +import { + readWorkspaceSurfaceProducerEntries, + resetWorkspaceSurfaceProducersForTests +} from './workspace-surface-production' +import { toRuntimeExecutionHostId } from '../../../shared/execution-host' vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) @@ -21,6 +26,7 @@ afterEach(() => { vi.unstubAllGlobals() resetWebSessionTabsSnapshotFreshnessForTests() resetWebRuntimeWakeTerminalRespawnForTests() + resetWorkspaceSurfaceProducersForTests() useAppStore.setState(initialAppStoreState, true) }) @@ -120,6 +126,14 @@ describe('empty remote worktree activation', () => { }) ) expect(toast.error).not.toHaveBeenCalled() + await vi.waitFor(() => + expect( + readWorkspaceSurfaceProducerEntries({ + workspaceKey: worktree.id, + executionHostId: toRuntimeExecutionHostId('web-runtime-1') + }) + ).toMatchObject([{ result: { kind: 'unverifiable' } }]) + ) }) it('surfaces a failed host terminal request without retrying ambiguously', async () => { @@ -169,5 +183,11 @@ describe('empty remote worktree activation', () => { }) ) expect(callRuntimeEnvironment).toHaveBeenCalledTimes(1) + expect( + readWorkspaceSurfaceProducerEntries({ + workspaceKey: worktree.id, + executionHostId: toRuntimeExecutionHostId('web-runtime-1') + }) + ).toMatchObject([{ result: { kind: 'failed', reason: 'Host refused the terminal' } }]) }) }) diff --git a/src/renderer/src/lib/worktree-activation-gated-empty-reseed.ts b/src/renderer/src/lib/worktree-activation-gated-empty-reseed.ts deleted file mode 100644 index bb597d10e6e..00000000000 --- a/src/renderer/src/lib/worktree-activation-gated-empty-reseed.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { ExecutionHostId } from '../../../shared/execution-host' -import { - gateWorktreeAgentActivation, - type WorktreeAgentActivationOutcome -} from './worktree-agent-activation-gate' -import { reseedGatedEmptyWorkspace } from './worktree-initial-terminal-seeding' - -type GatedEmptyWorkspaceReseedIntent = { - callerProvidesSurface: boolean - executionHostId?: ExecutionHostId -} - -const latestReseedIntentByGate = new WeakMap< - Promise, - GatedEmptyWorkspaceReseedIntent ->() - -export function gateAndReseedEmptyWorkspace( - workspaceKey: string, - callerProvidesSurface: boolean, - executionHostId?: ExecutionHostId -): void { - const gate = gateWorktreeAgentActivation(workspaceKey) - const intent: GatedEmptyWorkspaceReseedIntent = { - callerProvidesSurface, - ...(executionHostId ? { executionHostId } : {}) - } - latestReseedIntentByGate.set(gate, intent) - void gate.then((outcome) => { - if (latestReseedIntentByGate.get(gate) !== intent) { - return - } - latestReseedIntentByGate.delete(gate) - if (outcome === 'empty') { - reseedGatedEmptyWorkspace(workspaceKey, intent.callerProvidesSurface, intent.executionHostId) - } - }) -} diff --git a/src/renderer/src/lib/worktree-activation-recovery-boundary-census.test.ts b/src/renderer/src/lib/worktree-activation-recovery-boundary-census.test.ts new file mode 100644 index 00000000000..892c5e46d79 --- /dev/null +++ b/src/renderer/src/lib/worktree-activation-recovery-boundary-census.test.ts @@ -0,0 +1,120 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' + +const rendererRoot = join(process.cwd(), 'src/renderer/src') + +function productionSourceFiles(directory = rendererRoot): string[] { + return readdirSync(directory).flatMap((entry) => { + const path = join(directory, entry) + if (statSync(path).isDirectory()) { + return productionSourceFiles(path) + } + return /\.(?:ts|tsx)$/.test(path) && !/\.(?:test|spec)\.(?:ts|tsx)$/.test(path) ? [path] : [] + }) +} + +function callerCounts(identifier: string): Record { + const counts: Record = {} + const call = new RegExp(`\\b${identifier}\\s*\\(`, 'g') + for (const path of productionSourceFiles()) { + const count = [...readFileSync(path, 'utf8').matchAll(call)].length + if (count > 0) { + counts[relative(process.cwd(), path)] = count + } + } + return counts +} + +const stateOnlySetActiveWorktreeCallers = { + 'src/renderer/src/components/settings/McpConfigSection.tsx': 2, + 'src/renderer/src/components/sidebar/hovered-workspace-delete.ts': 1, + 'src/renderer/src/components/sidebar/sleep-worktree-flow.ts': 2, + 'src/renderer/src/components/sidebar/use-worktree-card-workspace-actions.ts': 1, + 'src/renderer/src/components/sidebar/worktree-context-menu-delete-intent.ts': 1, + 'src/renderer/src/components/tab-group/workspace-tab-close-commands.ts': 1, + 'src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx': 1, + 'src/renderer/src/components/terminal-pane/terminal-handle-links.ts': 1, + 'src/renderer/src/components/terminal/terminal-tab-actions.ts': 1, + 'src/renderer/src/components/use-terminal-editor-close-queue.ts': 1, + 'src/renderer/src/hooks/automation-dispatch-handler.ts': 1, + 'src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts': 2, + 'src/renderer/src/hooks/ipc-events/terminal-command-state.ts': 1, + 'src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts': 2, + 'src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts': 1, + 'src/renderer/src/lib/file-preview.ts': 2, + 'src/renderer/src/lib/http-link-routing.ts': 1, + 'src/renderer/src/runtime/web-runtime-session-workspace-selection.ts': 3, + 'src/renderer/src/store/slices/editor/actions/restored-editor-owner.ts': 1 +} + +function selectionCouplingViolations(source: string): string[] { + const violations: string[] = [] + if (/providesInitialSurface|callerProvidesSurface|callerWillProvideSurface/.test(source)) { + violations.push('legacy surface promise') + } + if (/recoverWorkspaceActivation\s*\(\s*\{[\s\S]{0,160}\.\.\.(?!identity\b)/.test(source)) { + violations.push('options spread into recovery') + } + if ( + /(?:agent|picker|selection)[^\n]{0,120}(?:recoverWorkspaceActivation|registerWorkspaceSurfaceProducer)/i.test( + source + ) + ) { + violations.push('selection controls recovery ownership') + } + return violations +} + +describe('activation recovery architecture census', () => { + it('classifies every general setter caller and keeps only the public activation path recovery-triggering', () => { + expect(callerCounts('setActiveWorktree')).toEqual({ + ...stateOnlySetActiveWorktreeCallers, + 'src/renderer/src/lib/worktree-activation.ts': 1 + }) + }) + + it('keeps the folder setter distinct and owned only by the public activation service', () => { + expect(callerCounts('setActiveFolderWorkspace')).toEqual({ + 'src/renderer/src/lib/worktree-activation.ts': 1 + }) + }) + + it('keeps the boundary API to four exports with no creation or general-seeder dependency', () => { + const source = readFileSync(join(rendererRoot, 'lib/worktree-activation-recovery.ts'), 'utf8') + const exports = [ + ...source.matchAll(/^export (?:async )?(?:type |function )([A-Za-z0-9_]+)/gm) + ].map((match) => match[1]) + expect(exports).toEqual([ + 'WorkspaceActivationIdentity', + 'WorkspaceActivationContext', + 'WorkspaceActivationRecoveryResult', + 'recoverWorkspaceActivation' + ]) + expect(source).not.toContain('worktree-creation') + expect(source).not.toContain('ensureWorktreeHasInitialTerminal') + expect(selectionCouplingViolations(source)).toEqual([]) + }) + + it('detects aliases, option spreading, picker conditionals, and picker-minted fake claims', () => { + const fixtures = [ + `const callerWillProvideSurface = options.agent != null\nrecoverWorkspaceActivation(identity, { mode: 'explicit', callerWillProvideSurface })`, + `recoverWorkspaceActivation({ ...options }, context)`, + `if (selection.agent) recoverWorkspaceActivation(identity, context)`, + `if (picker.agent) registerWorkspaceSurfaceProducer(identity)` + ] + for (const fixture of fixtures) { + expect(selectionCouplingViolations(fixture)).not.toEqual([]) + } + }) + + it('routes all production recovery requests through the owner, watcher, or create failure adapter', () => { + expect(callerCounts('recoverWorkspaceActivation')).toEqual({ + 'src/renderer/src/components/use-terminal-watcher-effects.ts': 1, + 'src/renderer/src/lib/workspace-activation-recovery-settlement.ts': 1, + 'src/renderer/src/lib/worktree-activation-recovery-routing.ts': 1, + 'src/renderer/src/lib/worktree-activation-recovery.ts': 1, + 'src/renderer/src/lib/worktree-creation-flow-execute.ts': 1 + }) + }) +}) diff --git a/src/renderer/src/lib/worktree-activation-recovery-routing.ts b/src/renderer/src/lib/worktree-activation-recovery-routing.ts new file mode 100644 index 00000000000..5f4c3174907 --- /dev/null +++ b/src/renderer/src/lib/worktree-activation-recovery-routing.ts @@ -0,0 +1,125 @@ +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { useAppStore } from '@/store' +import { createBrowserUuid } from './browser-uuid' +import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding' +import type { WorktreeStartupPayload } from './worktree-startup-payload' +import type { WorktreeActivationOptions } from './worktree-activation-surface-selection' +import { + recoverWorkspaceActivation, + type WorkspaceActivationIdentity +} from './worktree-activation-recovery' +import { + consumeWorkspaceSurfaceProducerAttempt, + readWorkspaceSurfaceProducerEntries, + type WorkspaceSurfaceProducer +} from './workspace-surface-production' +import { + getExecutionHostIdForWorktree, + getRuntimeEnvironmentIdForWorktree +} from './worktree-runtime-owner' + +export function ensureFolderWorkspaceInitialTerminal( + folderWorkspace: FolderWorkspace, + startup?: WorktreeStartupPayload +): string | null { + return ensureWorktreeHasInitialTerminal( + useAppStore.getState(), + folderWorkspaceKey(folderWorkspace.id), + startup, + undefined, + undefined, + undefined, + { reseedEmptiedWorkspace: true } + ) +} + +export function hasWorkspaceActivationWork(options?: WorktreeActivationOptions): boolean { + return Boolean( + options?.startup || options?.setup || options?.defaultTabs || options?.issueCommand + ) +} + +export function createWorkspaceActivationIdentity( + workspaceKey: string, + route?: { executionHostId?: ExecutionHostId; runtimeEnvironmentId?: string | null } +): WorkspaceActivationIdentity { + const state = useAppStore.getState() + return { + workspaceKey, + executionHostId: route?.executionHostId ?? getExecutionHostIdForWorktree(state, workspaceKey), + runtimeEnvironmentId: + route && 'runtimeEnvironmentId' in route + ? (route.runtimeEnvironmentId ?? null) + : getRuntimeEnvironmentIdForWorktree(state, workspaceKey), + attemptId: createBrowserUuid() + } +} + +export function settleActivationSeedProducer( + producer: WorkspaceSurfaceProducer, + workspaceKey: string, + primaryTabId: string | null, + existingSurfaceIds: ReadonlySet +): void { + const state = useAppStore.getState() + state.reconcileWorktreeTabModel(workspaceKey) + const surfaceId = + primaryTabId ?? + (useAppStore.getState().unifiedTabsByWorktree[workspaceKey] ?? []).find( + (tab) => !existingSurfaceIds.has(tab.id) + )?.id ?? + null + if (surfaceId) { + producer.materialized({ kind: 'tab', id: surfaceId }) + return + } + producer.declined('The initial surface producer did not publish a renderable surface.') +} + +export function captureActivationRenderableSurfaceIds(workspaceKey: string): ReadonlySet { + const state = useAppStore.getState() + state.reconcileWorktreeTabModel(workspaceKey) + return new Set( + (useAppStore.getState().unifiedTabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) + ) +} + +export function recoverActivatedWorkspace(identity: WorkspaceActivationIdentity): string | null { + const existingTabIds = new Set( + (useAppStore.getState().tabsByWorktree[identity.workspaceKey] ?? []).map((tab) => tab.id) + ) + void recoverWorkspaceActivation(identity, { mode: 'explicit' }) + return ( + useAppStore + .getState() + .tabsByWorktree[identity.workspaceKey]?.find((tab) => !existingTabIds.has(tab.id))?.id ?? null + ) +} + +export function finalizeActivatedWorkspaceSurface( + identity: WorkspaceActivationIdentity, + primaryTabId: string | null, + initialCwd?: string +): string | null { + const settledPrimaryTabId = primaryTabId ?? recoverActivatedWorkspace(identity) + if (settledPrimaryTabId && initialCwd) { + useAppStore.getState().queueTabInitialCwd(settledPrimaryTabId, initialCwd) + } + return settledPrimaryTabId +} + +export function hasOutstandingActivationSurfaceProducer( + identity: WorkspaceActivationIdentity +): boolean { + return readWorkspaceSurfaceProducerEntries(identity).some( + (entry) => + entry.result?.kind !== 'materialized' || entry.result.surface.kind === 'workspace-content' + ) +} + +export function consumeTransferredActivationProducer(producer: WorkspaceSurfaceProducer): void { + producer.declined('Surface production transferred to the paired execution host.') + consumeWorkspaceSurfaceProducerAttempt(producer.attempt.id) +} diff --git a/src/renderer/src/lib/worktree-activation-recovery.test.ts b/src/renderer/src/lib/worktree-activation-recovery.test.ts new file mode 100644 index 00000000000..2f19ad40069 --- /dev/null +++ b/src/renderer/src/lib/worktree-activation-recovery.test.ts @@ -0,0 +1,635 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toSshExecutionHostId } from '../../../shared/execution-host' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { + recoverWorkspaceActivation, + type WorkspaceActivationIdentity +} from './worktree-activation-recovery' +import { + readWorkspaceActivationRecoveryPresentation, + resetWorkspaceActivationRecoveryPresentationsForTests +} from './workspace-activation-recovery-presentation' +import { + readWorkspaceSurfaceProducerEntries, + registerWorkspaceSurfaceProducer, + resetWorkspaceSurfaceProducersForTests +} from './workspace-surface-production' + +type FakeUnifiedTab = { + id: string + contentType: + | 'terminal' + | 'editor' + | 'diff' + | 'conflict-review' + | 'check-details' + | 'agent-session' + | 'browser' + | 'simulator' +} + +const mocks = vi.hoisted(() => { + const storeListeners = new Set<() => void>() + const structuredListeners = new Set<() => void>() + let tabSequence = 0 + let uuidSequence = 0 + let reconcileHook: (() => void) | null = null + let currentState: { + activeWorktreeId: string + activeWorkspaceExecutionHostId: string + executionHostId: string + runtimeEnvironmentId: string | null + sleepingAgentSessionsByPaneKey: Record + tabsByWorktree: Record + unifiedTabsByWorktree: Record + remoteWorkspaceHydratedTargetIds: Set + remoteWorkspaceSyncStatusByTargetId: Record + reconcileWorktreeTabModel: (workspaceKey: string) => { + renderableTabCount: number + activeRenderableTabId: string | null + } + createTab: ReturnType + } + const reconcileWorktreeTabModel = (workspaceKey: string) => { + const hook = reconcileHook + reconcileHook = null + hook?.() + const tabs = currentState.unifiedTabsByWorktree[workspaceKey] ?? [] + return { + renderableTabCount: tabs.length, + activeRenderableTabId: tabs[0]?.id ?? null + } + } + const createTab = vi.fn((workspaceKey: string) => { + const id = `recovery-tab-${++tabSequence}` + const tab = { id, contentType: 'terminal' as const } + currentState.tabsByWorktree[workspaceKey] = [tab] + currentState.unifiedTabsByWorktree[workspaceKey] = [tab] + for (const listener of storeListeners) { + listener() + } + return tab + }) + const freshState = () => ({ + activeWorktreeId: 'worktree-1', + activeWorkspaceExecutionHostId: 'local', + executionHostId: 'local', + runtimeEnvironmentId: null, + sleepingAgentSessionsByPaneKey: {}, + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + remoteWorkspaceHydratedTargetIds: new Set(), + remoteWorkspaceSyncStatusByTargetId: {}, + reconcileWorktreeTabModel, + createTab + }) + currentState = freshState() + return { + state: () => currentState, + reset: () => { + tabSequence = 0 + uuidSequence = 0 + reconcileHook = null + createTab.mockReset() + createTab.mockImplementation((workspaceKey: string) => { + const id = `recovery-tab-${++tabSequence}` + const tab = { id, contentType: 'terminal' as const } + currentState.tabsByWorktree[workspaceKey] = [tab] + currentState.unifiedTabsByWorktree[workspaceKey] = [tab] + for (const listener of storeListeners) { + listener() + } + return tab + }) + currentState = freshState() + }, + notifyStore: () => { + for (const listener of storeListeners) { + listener() + } + }, + runOnNextReconcile: (hook: () => void) => { + reconcileHook = hook + }, + subscribeStore: (listener: () => void) => { + storeListeners.add(listener) + return () => storeListeners.delete(listener) + }, + gate: vi.fn(), + authority: vi.fn(() => 'none'), + structuredStatus: vi.fn(() => 'idle'), + subscribeStructured: (listener: () => void) => { + structuredListeners.add(listener) + return () => structuredListeners.delete(listener) + }, + nextUuid: () => `recovery-attempt-${++uuidSequence}` + } +}) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: mocks.state, + subscribe: mocks.subscribeStore + } +})) +vi.mock('@/components/terminal/initial-terminal', () => ({ + shouldAutoCreateInitialTerminal: (count: number) => count === 0 +})) +vi.mock('./worktree-agent-activation-gate', () => ({ + gateWorktreeAgentActivation: mocks.gate +})) +vi.mock('./workspace-terminal-host-authority', () => ({ + resolveWorkspaceTerminalHostAuthority: mocks.authority +})) +vi.mock('./worktree-runtime-owner', () => ({ + getExecutionHostIdForWorktree: () => mocks.state().executionHostId, + getRuntimeEnvironmentIdForWorktree: () => mocks.state().runtimeEnvironmentId +})) +vi.mock('./structured-agent-session-launch', () => ({ + getStructuredAgentLaunchStatus: mocks.structuredStatus, + subscribeStructuredAgentLaunchStatus: mocks.subscribeStructured +})) +vi.mock('./browser-uuid', () => ({ createBrowserUuid: mocks.nextUuid })) + +const WORKSPACE_KEY = 'worktree-1' + +function identity( + attemptId: string, + overrides: Partial = {} +): WorkspaceActivationIdentity { + return { + workspaceKey: WORKSPACE_KEY, + executionHostId: 'local', + runtimeEnvironmentId: null, + attemptId, + ...overrides + } +} + +function forceGate(): void { + mocks.state().sleepingAgentSessionsByPaneKey = { + pane: { worktreeId: mocks.state().activeWorktreeId } + } +} + +function showSurface(contentType: FakeUnifiedTab['contentType'], id = 'surface-1'): void { + mocks.state().unifiedTabsByWorktree[mocks.state().activeWorktreeId] = [{ id, contentType }] + mocks.notifyStore() +} + +beforeEach(() => { + mocks.reset() + mocks.gate.mockReset() + mocks.gate.mockResolvedValue('empty') + mocks.authority.mockReset() + mocks.authority.mockReturnValue('none') + mocks.structuredStatus.mockReset() + mocks.structuredStatus.mockReturnValue('idle') + resetWorkspaceSurfaceProducersForTests() + resetWorkspaceActivationRecoveryPresentationsForTests() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('activation recovery failures', () => { + it('publishes a blocked error and starts no writer', async () => { + forceGate() + mocks.gate.mockResolvedValue('blocked') + + const result = await recoverWorkspaceActivation(identity('blocked-attempt'), { + mode: 'explicit' + }) + + expect(result).toMatchObject({ kind: 'failed', reason: 'blocked' }) + expect(mocks.state().createTab).not.toHaveBeenCalled() + expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')?.kind).toBe( + 'blocked' + ) + }) + + it.each(['resume', 'adoption'])( + 'contains an escaped %s rejection as an unexpected error', + async () => { + forceGate() + mocks.gate.mockRejectedValue(new Error('inventory mutation failed')) + + const result = await recoverWorkspaceActivation(identity('rejected-attempt'), { + mode: 'explicit' + }) + + expect(result).toMatchObject({ kind: 'failed', reason: 'unexpected' }) + expect(mocks.state().createTab).not.toHaveBeenCalled() + expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')).toMatchObject({ + kind: 'unexpected', + detail: 'inventory mutation failed' + }) + } + ) + + it('turns a private seeder throw into an actionable error', async () => { + mocks.state().createTab.mockImplementationOnce(() => { + throw new Error('tab commit failed') + }) + + const result = await recoverWorkspaceActivation(identity('seeder-attempt'), { + mode: 'explicit' + }) + + expect(result).toMatchObject({ kind: 'failed', reason: 'unexpected' }) + expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')).toMatchObject({ + kind: 'unexpected', + detail: 'tab commit failed' + }) + }) + + it('keeps a failed concrete producer visible without substituting a shell', async () => { + const producer = registerWorkspaceSurfaceProducer({ + workspaceKey: WORKSPACE_KEY, + executionHostId: 'local', + attemptId: 'producer-failure' + }) + producer.failed('agent executable was not found') + + const result = await recoverWorkspaceActivation(identity('failed-producer-attempt'), { + mode: 'explicit' + }) + + expect(result).toMatchObject({ kind: 'failed', reason: 'producer-failed' }) + expect(mocks.state().createTab).not.toHaveBeenCalled() + expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')).toMatchObject({ + kind: 'producer-failed', + detail: 'agent executable was not found' + }) + }) + + it('does not let a surviving unrelated surface hide a producer failure', async () => { + showSurface('terminal', 'setup-tab') + const producer = registerWorkspaceSurfaceProducer({ + workspaceKey: WORKSPACE_KEY, + executionHostId: 'local', + attemptId: 'failed-agent-producer' + }) + producer.failed('The requested agent did not start.') + + const result = await recoverWorkspaceActivation(identity('failed-agent-attempt'), { + mode: 'explicit' + }) + + expect(result).toMatchObject({ kind: 'failed', reason: 'producer-failed' }) + expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')).toMatchObject({ + kind: 'producer-failed', + detail: 'The requested agent did not start.' + }) + expect(mocks.state().createTab).not.toHaveBeenCalled() + }) + + it('clears a producer failure when Retry observes the requested surface', async () => { + const producer = registerWorkspaceSurfaceProducer({ + workspaceKey: WORKSPACE_KEY, + executionHostId: 'local', + attemptId: 'retry-producer' + }) + producer.failed('agent executable was not found') + await recoverWorkspaceActivation(identity('retry-failure'), { mode: 'explicit' }) + const failure = readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local') + expect(failure?.kind).toBe('producer-failed') + + showSurface('agent-session', 'published-after-retry') + failure?.retry() + + await vi.waitFor(() => + expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')).toBeNull() + ) + expect(mocks.state().createTab).not.toHaveBeenCalled() + }) + + it('retains unverifiable producer ownership after cancellation', async () => { + const producer = registerWorkspaceSurfaceProducer({ + workspaceKey: WORKSPACE_KEY, + executionHostId: 'local', + attemptId: 'unknown-producer' + }) + producer.unverifiable('dispatch acceptance is unknown') + + const result = await recoverWorkspaceActivation(identity('unknown-attempt'), { + mode: 'explicit' + }) + + expect(result).toMatchObject({ + kind: 'deferred', + ownerAttemptId: 'unknown-producer' + }) + expect(readWorkspaceSurfaceProducerEntries(identity('unused'))).toHaveLength(1) + expect(mocks.state().createTab).not.toHaveBeenCalled() + }) + + it('presents a producer refusal reason without substituting a shell', async () => { + const producer = registerWorkspaceSurfaceProducer({ + workspaceKey: WORKSPACE_KEY, + executionHostId: 'local', + attemptId: 'declined-producer' + }) + producer.declined('Browser publication was refused by the host.') + + await expect( + recoverWorkspaceActivation(identity('declined-attempt'), { mode: 'explicit' }) + ).resolves.toMatchObject({ kind: 'failed', reason: 'producer-failed' }) + expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')).toMatchObject({ + kind: 'producer-failed', + detail: 'Browser publication was refused by the host.' + }) + expect(mocks.state().createTab).not.toHaveBeenCalled() + + readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')?.retry() + await vi.waitFor(() => + expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')?.kind).toBe( + 'producer-failed' + ) + ) + expect(mocks.state().createTab).not.toHaveBeenCalled() + }) + + it('bounds an inventory assessment and publishes an actionable timeout', async () => { + vi.useFakeTimers() + forceGate() + mocks.gate.mockReturnValue(new Promise(() => undefined)) + + const recovery = recoverWorkspaceActivation(identity('deadline-attempt'), { + mode: 'explicit' + }) + await vi.advanceTimersByTimeAsync(30_000) + + await expect(recovery).resolves.toMatchObject({ kind: 'failed', reason: 'unexpected' }) + expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')?.kind).toBe( + 'unexpected' + ) + expect(mocks.state().createTab).not.toHaveBeenCalled() + }) + + it.each(['adopted', 'structured', 'resumed'] as const)( + 'requires real publication after a %s gate outcome', + async (outcome) => { + vi.useFakeTimers() + forceGate() + mocks.gate.mockResolvedValue(outcome) + + const recovery = recoverWorkspaceActivation(identity(`gate-${outcome}`), { + mode: 'explicit' + }) + await vi.advanceTimersByTimeAsync(30_000) + + await expect(recovery).resolves.toMatchObject({ kind: 'deferred' }) + expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')?.kind).toBe( + 'unverifiable' + ) + expect(mocks.state().createTab).not.toHaveBeenCalled() + } + ) +}) + +describe('activation recovery settlement', () => { + it('uses the live startup tombstone at final settlement', async () => { + forceGate() + let settleGate!: (outcome: string) => void + mocks.gate.mockReturnValue( + new Promise((resolve) => { + settleGate = resolve + }) + ) + const recovery = recoverWorkspaceActivation(identity('startup-tombstone'), { + mode: 'startup' + }) + await vi.waitFor(() => expect(mocks.gate).toHaveBeenCalled()) + mocks.state().tabsByWorktree[WORKSPACE_KEY] = [] + mocks.notifyStore() + settleGate('empty') + + await expect(recovery).resolves.toEqual({ kind: 'intentional-empty' }) + expect(mocks.state().createTab).not.toHaveBeenCalled() + }) + + it('lets an explicit reopen override a live tombstone', async () => { + mocks.state().tabsByWorktree[WORKSPACE_KEY] = [] + + await expect( + recoverWorkspaceActivation(identity('explicit-tombstone'), { mode: 'explicit' }) + ).resolves.toMatchObject({ kind: 'materialized', surface: { type: 'terminal' } }) + expect(mocks.state().createTab).toHaveBeenCalledOnce() + }) + + it.each([ + ['terminal', 'terminal'], + ['diff', 'editor'], + ['agent-session', 'agent-session'], + ['browser', 'browser'], + ['simulator', 'simulator'] + ] as const)( + 'accepts a surviving %s surface without a fallback', + async (contentType, visibleType) => { + showSurface(contentType) + + const result = await recoverWorkspaceActivation(identity(`surface-${contentType}`), { + mode: 'explicit' + }) + + expect(result).toMatchObject({ + kind: 'materialized', + surface: { id: 'surface-1', type: visibleType } + }) + expect(mocks.state().createTab).not.toHaveBeenCalled() + } + ) + + it('cancels one startup request without consuming a later assessment', async () => { + forceGate() + mocks.gate.mockReturnValueOnce(new Promise(() => undefined)).mockResolvedValueOnce('empty') + const abort = new AbortController() + const first = recoverWorkspaceActivation(identity('cancelled-startup'), { + mode: 'startup', + signal: abort.signal + }) + await vi.waitFor(() => expect(mocks.gate).toHaveBeenCalledOnce()) + abort.abort() + await expect(first).resolves.toEqual({ kind: 'stale' }) + + await expect( + recoverWorkspaceActivation(identity('replacement-startup'), { mode: 'startup' }) + ).resolves.toMatchObject({ kind: 'materialized' }) + expect(mocks.state().createTab).toHaveBeenCalledOnce() + }) + + it('invalidates an old request across an away-and-back selection cycle', async () => { + forceGate() + let settleGate!: (outcome: string) => void + const sharedGate = new Promise((resolve) => { + settleGate = resolve + }) + mocks.gate.mockReturnValue(sharedGate) + const first = recoverWorkspaceActivation(identity('before-away-and-back'), { + mode: 'explicit' + }) + await vi.waitFor(() => expect(mocks.gate).toHaveBeenCalledOnce()) + + mocks.state().activeWorktreeId = 'worktree-2' + mocks.notifyStore() + mocks.state().activeWorktreeId = WORKSPACE_KEY + mocks.notifyStore() + settleGate('empty') + + await expect(first).resolves.toEqual({ kind: 'stale' }) + expect(mocks.state().createTab).not.toHaveBeenCalled() + + await expect( + recoverWorkspaceActivation(identity('after-away-and-back'), { mode: 'explicit' }) + ).resolves.toMatchObject({ kind: 'materialized' }) + expect(mocks.state().createTab).toHaveBeenCalledOnce() + }) + + it('rejects a joined gate whose host tuple conflicts', async () => { + forceGate() + let settleGate!: (outcome: string) => void + const sharedGate = new Promise((resolve) => { + settleGate = resolve + }) + mocks.gate.mockReturnValue(sharedGate) + const first = recoverWorkspaceActivation(identity('host-a'), { mode: 'explicit' }) + await vi.waitFor(() => expect(mocks.gate).toHaveBeenCalledOnce()) + + const sshHost = toSshExecutionHostId('box') + mocks.state().executionHostId = sshHost + mocks.state().activeWorkspaceExecutionHostId = sshHost + mocks.notifyStore() + const second = recoverWorkspaceActivation(identity('host-b', { executionHostId: sshHost }), { + mode: 'explicit' + }) + + await expect(second).resolves.toMatchObject({ + kind: 'deferred', + reason: expect.stringContaining('another execution host') + }) + expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, sshHost)?.kind).toBe( + 'unverifiable' + ) + expect(mocks.state().createTab).not.toHaveBeenCalled() + settleGate('empty') + await expect(first).resolves.toEqual({ kind: 'stale' }) + }) + + it.each([ + ['worktree over SSH', WORKSPACE_KEY], + ['folder over SSH', folderWorkspaceKey('folder-1')] + ])('does not start a writer for unverifiable %s', async (_label, workspaceKey) => { + const sshHost = toSshExecutionHostId('box') + mocks.state().activeWorktreeId = workspaceKey + mocks.state().executionHostId = sshHost + mocks.state().activeWorkspaceExecutionHostId = sshHost + mocks.notifyStore() + + const result = await recoverWorkspaceActivation( + identity(`ssh-${workspaceKey}`, { workspaceKey, executionHostId: sshHost }), + { mode: 'explicit' } + ) + + expect(result).toMatchObject({ kind: 'deferred' }) + expect(readWorkspaceActivationRecoveryPresentation(workspaceKey, sshHost)?.kind).toBe( + 'unverifiable' + ) + expect(mocks.state().createTab).not.toHaveBeenCalled() + }) + + it('does not treat a previously hydrated but offline SSH host as exited', async () => { + const sshHost = toSshExecutionHostId('box') + mocks.state().executionHostId = sshHost + mocks.state().activeWorkspaceExecutionHostId = sshHost + mocks.state().remoteWorkspaceHydratedTargetIds.add('box') + mocks.state().remoteWorkspaceSyncStatusByTargetId.box = { phase: 'offline' } + mocks.notifyStore() + + await expect( + recoverWorkspaceActivation(identity('ssh-offline', { executionHostId: sshHost }), { + mode: 'explicit' + }) + ).resolves.toMatchObject({ kind: 'deferred' }) + expect(mocks.state().createTab).not.toHaveBeenCalled() + }) + + it('allows SSH recovery only after a current synced inventory proves emptiness', async () => { + const sshHost = toSshExecutionHostId('box') + mocks.state().executionHostId = sshHost + mocks.state().activeWorkspaceExecutionHostId = sshHost + mocks.state().remoteWorkspaceHydratedTargetIds.add('box') + mocks.state().remoteWorkspaceSyncStatusByTargetId.box = { phase: 'synced' } + mocks.notifyStore() + + await expect( + recoverWorkspaceActivation(identity('ssh-synced', { executionHostId: sshHost }), { + mode: 'explicit' + }) + ).resolves.toMatchObject({ kind: 'materialized', surface: { type: 'terminal' } }) + expect(mocks.state().createTab).toHaveBeenCalledOnce() + }) + + it('keeps a producer-owned tab pending until real publication reaches inventory', async () => { + vi.useFakeTimers() + showSurface('terminal', 'setup-tab') + const producer = registerWorkspaceSurfaceProducer({ + workspaceKey: WORKSPACE_KEY, + executionHostId: 'local', + attemptId: 'publication-owner' + }) + producer.materialized({ kind: 'tab', id: 'not-published' }) + const recovery = recoverWorkspaceActivation(identity('publication-attempt'), { + mode: 'explicit' + }) + await vi.advanceTimersByTimeAsync(30_000) + + await expect(recovery).resolves.toMatchObject({ + kind: 'deferred', + ownerAttemptId: 'publication-owner' + }) + expect(mocks.state().createTab).not.toHaveBeenCalled() + }) + + it('settles a producer only from its exact published surface identity', async () => { + showSurface('terminal', 'setup-tab') + const producer = registerWorkspaceSurfaceProducer({ + workspaceKey: WORKSPACE_KEY, + executionHostId: 'local', + attemptId: 'exact-publication-owner' + }) + producer.materialized({ kind: 'tab', id: 'agent-session:session-1' }) + const recovery = recoverWorkspaceActivation(identity('exact-publication-attempt'), { + mode: 'explicit' + }) + await Promise.resolve() + + mocks.state().unifiedTabsByWorktree[WORKSPACE_KEY] = [ + { id: 'setup-tab', contentType: 'terminal' }, + { id: 'agent-session:session-1', contentType: 'agent-session' } + ] + mocks.notifyStore() + + await expect(recovery).resolves.toEqual({ + kind: 'materialized', + surface: { id: 'agent-session:session-1', type: 'agent-session' } + }) + expect(mocks.state().createTab).not.toHaveBeenCalled() + }) + + it('lets a reentrant newer activation own the final seed critical section', async () => { + let newerRecovery: Promise | null = null + mocks.runOnNextReconcile(() => { + newerRecovery = recoverWorkspaceActivation(identity('reentrant-newer'), { + mode: 'explicit' + }) + }) + + await expect( + recoverWorkspaceActivation(identity('reentrant-older'), { mode: 'explicit' }) + ).resolves.toMatchObject({ kind: 'materialized' }) + await expect(newerRecovery).resolves.toMatchObject({ kind: 'materialized' }) + expect(mocks.state().createTab).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/lib/worktree-activation-recovery.ts b/src/renderer/src/lib/worktree-activation-recovery.ts new file mode 100644 index 00000000000..d88c7ec69a2 --- /dev/null +++ b/src/renderer/src/lib/worktree-activation-recovery.ts @@ -0,0 +1,55 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { WorkspaceVisibleTabType } from '../../../shared/tab-types' +import { recoverWorkspaceActivationOwned } from './workspace-activation-recovery-coordinator' +import { + markLatestActivationRecoveryAttempt, + readLatestActivationRecoveryAttempt +} from './workspace-activation-recovery-state' +import { + activationRecoveryFailedResult, + publishActivationRecovery +} from './workspace-activation-recovery-settlement' + +export type WorkspaceActivationIdentity = { + workspaceKey: string + executionHostId: ExecutionHostId + runtimeEnvironmentId: string | null + attemptId: string +} + +export type WorkspaceActivationContext = { + mode: 'explicit' | 'startup' + signal?: AbortSignal +} + +export type WorkspaceActivationRecoveryResult = + | { + kind: 'materialized' + surface: { id: string; type: WorkspaceVisibleTabType | 'workspace-content' } + } + | { kind: 'intentional-empty' } + | { kind: 'deferred'; reason: string; ownerAttemptId: string | null } + | { kind: 'failed'; reason: 'blocked' | 'unexpected' | 'producer-failed'; diagnosticId: string } + | { kind: 'stale' } + +export async function recoverWorkspaceActivation( + identity: WorkspaceActivationIdentity, + context: WorkspaceActivationContext +): Promise { + try { + return await recoverWorkspaceActivationOwned(identity, context) + } catch (error) { + const currentAttemptId = readLatestActivationRecoveryAttempt(identity) + if (currentAttemptId && currentAttemptId !== identity.attemptId) { + return { kind: 'stale' } + } + markLatestActivationRecoveryAttempt(identity) + const detail = error instanceof Error ? error.message : String(error) + try { + publishActivationRecovery(identity, context, 'unexpected', detail) + } catch (presentationError) { + console.error('workspace activation recovery presentation failed', presentationError) + } + return activationRecoveryFailedResult(identity, 'unexpected') + } +} diff --git a/src/renderer/src/lib/worktree-activation-reveal.test.ts b/src/renderer/src/lib/worktree-activation-reveal.test.ts index 585c86d8a30..906576cd53a 100644 --- a/src/renderer/src/lib/worktree-activation-reveal.test.ts +++ b/src/renderer/src/lib/worktree-activation-reveal.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { activateAndRevealWorktree } from './worktree-activation' import { registerWorktreeActivationReset } from './worktree-activation-test-harness' import { useAppStore } from '@/store' +import { makeTab, makeWorktree, TEST_REPO } from '@/store/slices/store-test-helpers' registerWorktreeActivationReset() @@ -19,47 +20,45 @@ describe('activateAndRevealWorktree', () => { it('queues a one-shot initial cwd for the primary activation-created tab', () => { const queueTabInitialCwd = vi.fn() const revealWorktreeInSidebar = vi.fn() + const worktree = makeWorktree({ + id: 'wt-1', + repoId: 'repo-1', + path: '/repo', + displayName: 'main', + branch: 'main', + isMainWorktree: true + }) useAppStore.setState({ activeRepoId: null, activeWorktreeId: null, activeView: 'settings', filterRepoIds: [], isNavigatingHistory: false, - repos: [{ id: 'repo-1', connectionId: null }], + repos: [{ ...TEST_REPO, id: 'repo-1', connectionId: null }], worktreesByRepo: { - 'repo-1': [ - { - id: 'wt-1', - repoId: 'repo-1', - path: '/repo', - displayName: 'main', - branch: 'main', - head: 'abc', - isBare: false, - isMainWorktree: true - } - ] + 'repo-1': [worktree] }, - getKnownWorktreeById: (worktreeId: string) => - worktreeId === 'wt-1' - ? ({ - id: 'wt-1', - repoId: 'repo-1', - path: '/repo', - displayName: 'main', - branch: 'main', - head: 'abc', - isBare: false, - isMainWorktree: true - } as never) - : null, + getKnownWorktreeById: (worktreeId: string) => (worktreeId === 'wt-1' ? worktree : undefined), setActiveRepo: vi.fn(), setActiveView: vi.fn(), - setActiveWorktree: vi.fn(), + setActiveWorktree: vi.fn((worktreeId: string | null) => { + useAppStore.setState({ activeWorktreeId: worktreeId }) + return true + }), markWorktreeVisited: vi.fn(), recordWorktreeVisit: vi.fn(), - reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 0 })), - createTab: vi.fn(() => ({ id: 'tab-1' })), + reconcileWorktreeTabModel: vi.fn(() => ({ + renderableTabCount: 0, + activeRenderableTabId: null + })), + createTab: vi.fn(() => { + const tab = makeTab({ id: 'tab-1', worktreeId: 'wt-1' }) + const current = useAppStore.getState() + useAppStore.setState({ + tabsByWorktree: { ...current.tabsByWorktree, 'wt-1': [tab] } + }) + return tab + }), setActiveTab: vi.fn(), setTabCustomTitle: vi.fn(), setTabColor: vi.fn(), @@ -69,17 +68,14 @@ describe('activateAndRevealWorktree', () => { queueTabSetupSplit: vi.fn(), queueTabIssueCommandSplit: vi.fn(), revealWorktreeInSidebar - } as never) + }) const result = activateAndRevealWorktree('wt-1', { - initialCwd: '/repo/packages/web', - executionHostId: 'ssh:box' + initialCwd: '/repo/packages/web' }) expect(result).toEqual({ primaryTabId: 'tab-1' }) expect(queueTabInitialCwd).toHaveBeenCalledWith('tab-1', '/repo/packages/web') - expect(revealWorktreeInSidebar).toHaveBeenCalledWith('wt-1', { - executionHostId: 'ssh:box' - }) + expect(revealWorktreeInSidebar).toHaveBeenCalledWith('wt-1') }) }) diff --git a/src/renderer/src/lib/worktree-activation-sidebar-filters.ts b/src/renderer/src/lib/worktree-activation-sidebar-filters.ts new file mode 100644 index 00000000000..830b94281f6 --- /dev/null +++ b/src/renderer/src/lib/worktree-activation-sidebar-filters.ts @@ -0,0 +1,41 @@ +import type { AppState } from '@/store' +import type { Worktree } from '../../../shared/worktree/types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui' +import { isDetachedHeadWorkspace } from '@/components/sidebar/visible-worktrees' + +export function clearWorktreeActivationSidebarFilters(state: AppState, worktree: Worktree): void { + if (state.filterRepoIds.length > 0 && !state.filterRepoIds.includes(worktree.repoId)) { + state.setFilterRepoIds([]) + } + if ( + state.hideAutomationGeneratedWorkspaces && + worktree.automationProvenance?.kind === 'created-by-automation' + ) { + state.setHideAutomationGeneratedWorkspaces(false) + } + if (state.hideCliCreatedWorkspaces && worktree.cliProvenance?.kind === 'created-by-cli') { + state.setHideCliCreatedWorkspaces(false) + } + if (state.hideDetachedHeadWorkspaces && isDetachedHeadWorkspace(worktree)) { + state.setHideDetachedHeadWorkspaces(false) + } +} + +export function revealActivatedWorktree( + state: AppState, + worktreeId: string, + options: { + behavior?: PendingSidebarWorktreeReveal['behavior'] + executionHostId?: ExecutionHostId + } +): void { + if (options.behavior || options.executionHostId) { + state.revealWorktreeInSidebar(worktreeId, { + ...(options.behavior ? { behavior: options.behavior } : {}), + ...(options.executionHostId ? { executionHostId: options.executionHostId } : {}) + }) + } else { + state.revealWorktreeInSidebar(worktreeId) + } +} diff --git a/src/renderer/src/lib/worktree-activation-store-contract.ts b/src/renderer/src/lib/worktree-activation-store-contract.ts index 63ce180ffed..bcfa915dca9 100644 --- a/src/renderer/src/lib/worktree-activation-store-contract.ts +++ b/src/renderer/src/lib/worktree-activation-store-contract.ts @@ -68,11 +68,7 @@ export type InitialTerminalOptions = { /** Why: an explicit empty terminal row is a "user closed the last tab" tombstone. Startup * hydration honours it through Terminal.tsx's passive auto-create (which never calls this * function), but opening the workspace on purpose (sidebar, palette, automation "Resume - * workspace", wake) has to hand back a usable surface. Activation sets this unless the - * caller says it provides its own surface; background worktree creation leaves it unset. */ + * workspace", wake) has to hand back a usable surface. Explicit activation sets this; + * background worktree creation leaves it unset. */ reseedEmptiedWorkspace?: boolean - /** Set by callers that open their own primary surface (a structured native chat session). - * Setup/issue work still runs, but work that needs no host terminal must not seed a shell - * beside the chat the caller is about to create. */ - callerProvidesSurface?: boolean } diff --git a/src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts b/src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts index 33be3888a24..62ac39b95fc 100644 --- a/src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts +++ b/src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import { activateAndRevealWorktree } from './worktree-activation' import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding' +import { queueStandaloneSetupTab } from './worktree-setup-issue-command-queue' import { makeCreatedAgentWorktree as makeWorktree, seedEmptyActivatableWorktree @@ -26,25 +27,22 @@ const setup = { envVars: { ORCA_WORKTREE_PATH: '/tmp/worktrees/wt-1' } } -// Why: a native-chat create used to land the user on a bare "Terminal 1" beside the chat, -// because the returned setup script counted as work needing a shell to attach to. -describe('seeding beside a caller-provided chat surface', () => { +describe('terminal seeding for explicit setup work', () => { it('runs a new-tab setup script without seeding a shell', () => { let createdIndex = 0 const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) const store = createMockStore({ createTab }) - const primaryTabId = ensureWorktreeHasInitialTerminal( + const queued = queueStandaloneSetupTab({ store, - 'wt-1', - undefined, + worktreeId: 'wt-1', setup, - undefined, - undefined, - { callerProvidesSurface: true } - ) + issueCommand: undefined, + defaultTabs: undefined, + opts: { activateCreatedTabs: false } + }) - expect(primaryTabId).toBeNull() + expect(queued).toBe(true) expect(createTab).toHaveBeenCalledTimes(1) expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Setup', { recordInteraction: false @@ -61,9 +59,7 @@ describe('seeding beside a caller-provided chat surface', () => { const store = createMockStore({ createTab }) setSetupScriptLaunchMode('split-vertical') - ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, setup, undefined, undefined, { - callerProvidesSurface: true - }) + ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, setup) expect(createTab).toHaveBeenCalledTimes(1) expect(store.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', expect.anything()) @@ -80,8 +76,7 @@ describe('seeding beside a caller-provided chat surface', () => { undefined, undefined, { command: 'orca issue run' }, - undefined, - { callerProvidesSurface: true } + undefined ) expect(createTab).toHaveBeenCalledTimes(1) @@ -104,18 +99,17 @@ describe('seeding beside a caller-provided chat surface', () => { }) }) - it('activation forwards providesInitialSurface so setup alone adds one tab', () => { + it('activation creates a primary shell plus a new-tab setup surface', () => { const worktree = makeWorktree() seedEmptyActivatableWorktree(worktree) const result = activateAndRevealWorktree(worktree.id, { - providesInitialSurface: true, notifyHostRuntime: false, setup }) expect(result).not.toBe(false) - expect(result === false ? 'unused' : result.primaryTabId).toBeNull() - expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1) + expect(result === false ? null : result.primaryTabId).toBeTruthy() + expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(2) }) }) diff --git a/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts b/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts deleted file mode 100644 index 4e7e02336ca..00000000000 --- a/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { readdirSync, readFileSync } from 'node:fs' -import { join, relative, sep } from 'node:path' -import { describe, expect, it } from 'vitest' - -// Why: `providesInitialSurface: true` is invisible to behavior tests — every opted-out caller -// still works with the flag deleted, it just re-seeds a shell the user never asked for in a -// closed-last-terminal workspace. Three review rounds each found a missed caller, so this is -// the census: activation callers that open their own surface (editor, browser, diff, agent tab) -// must appear here, and adding or removing an opt-out anywhere must update this list. -const SURFACE_PROVIDING_CALLERS = [ - 'src/renderer/src/components/editor/check-annotation-open.ts', - 'src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx', - 'src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts', - 'src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts', - 'src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts', - 'src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts', - 'src/renderer/src/hooks/composer-state/full-creation-execution.ts', - 'src/renderer/src/lib/fix-checks-agent-launch.ts', - 'src/renderer/src/lib/launch-work-item-direct.ts', - 'src/renderer/src/lib/worktree-creation-structured-session.ts', - 'src/renderer/src/lib/workspace-port-actions.ts', - 'src/renderer/src/lib/onboarding-folder-agent-launch.ts' -] - -// The activation seam itself: declares the option and forwards it into the tombstone gate. -const SEAM_FILES = [ - 'src/renderer/src/lib/worktree-activation-surface-selection.ts', - 'src/renderer/src/lib/worktree-activation.ts' -] - -function listSourceFiles(dir: string): string[] { - return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { - const fullPath = join(dir, entry.name) - if (entry.isDirectory()) { - return listSourceFiles(fullPath) - } - if (!/\.(ts|tsx)$/.test(entry.name) || /\.test\.(ts|tsx)$/.test(entry.name)) { - return [] - } - return [fullPath] - }) -} - -// Why: bound to an actual activation call so a comment or dead code containing the flag -// text cannot satisfy the census, and a variable-valued flag cannot hide in it. Comments -// are stripped first — commenting the flag out in place must fail this test. -const ACTIVATION_CALL_WITH_OPT_OUT = - /activateAndReveal(?:Worktree|FolderWorkspace|Workspace)\([\s\S]*?providesInitialSurface: true/ - -function stripComments(source: string): string { - return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1') -} - -describe('providesInitialSurface caller wiring', () => { - it.each(SURFACE_PROVIDING_CALLERS)('%s opts out of tombstone re-seeding', (relativePath) => { - const source = stripComments(readFileSync(join(process.cwd(), relativePath), 'utf8')) - expect(source).toMatch(ACTIVATION_CALL_WITH_OPT_OUT) - }) - - it('the census matches every mention under src/', () => { - const root = join(process.cwd(), 'src') - const mentions = listSourceFiles(root) - .filter((filePath) => readFileSync(filePath, 'utf8').includes('providesInitialSurface')) - .map((filePath) => relative(process.cwd(), filePath).split(sep).join('/')) - .sort() - expect(mentions).toEqual([...SURFACE_PROVIDING_CALLERS, ...SEAM_FILES].sort()) - }) -}) diff --git a/src/renderer/src/lib/worktree-activation-surface-selection.ts b/src/renderer/src/lib/worktree-activation-surface-selection.ts index 4385994ff94..caa8dedd42a 100644 --- a/src/renderer/src/lib/worktree-activation-surface-selection.ts +++ b/src/renderer/src/lib/worktree-activation-surface-selection.ts @@ -11,8 +11,6 @@ import type { IssueCommandLaunch } from '@/lib/worktree-setup-issue-command-queu export type WorktreeActivationSurfaceSelection = { /** The create picker's selection; null means Blank Terminal. */ agent?: TuiAgent | null - /** A navigation caller is about to open its own editor, diff, or other non-terminal surface. */ - providesInitialSurface?: boolean } export type WorktreeActivationOptions = WorktreeActivationSurfaceSelection & { @@ -31,10 +29,3 @@ export type WorktreeActivationOptions = WorktreeActivationSurfaceSelection & { /** Keep sidebar filters intact when navigating to a hidden target. */ clearSidebarFilters?: boolean } - -/** Create-time only: an agent selection suppresses the shell its own surface will replace. */ -export function activationProvidesInitialSurface( - selection?: WorktreeActivationSurfaceSelection -): boolean { - return selection?.providesInitialSurface === true || selection?.agent != null -} diff --git a/src/renderer/src/lib/worktree-activation-web-runtime.test.ts b/src/renderer/src/lib/worktree-activation-web-runtime.test.ts index 2daee618eba..0c06b4434a7 100644 --- a/src/renderer/src/lib/worktree-activation-web-runtime.test.ts +++ b/src/renderer/src/lib/worktree-activation-web-runtime.test.ts @@ -15,12 +15,16 @@ import { resetWebRuntimeWakeTerminalRespawnForTests, shouldSkipWebRuntimeWakeTerminalRespawn } from '@/runtime/web-runtime-wake-terminal-respawn' +import { resetWebSessionTabsSnapshotFreshnessForTests } from '@/runtime/web-session-tabs-sync' +import { resetWorkspaceSurfaceProducersForTests } from './workspace-surface-production' registerWorktreeActivationReset() afterEach(() => { vi.unstubAllGlobals() resetWebRuntimeWakeTerminalRespawnForTests() + resetWebSessionTabsSnapshotFreshnessForTests() + resetWorkspaceSurfaceProducersForTests() }) describe('activateAndRevealWorktree', () => { diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index bf4bbce1123..aa4963c900f 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -1,4 +1,3 @@ -import type { FolderWorkspace } from '../../../shared/folder-workspace-types' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui' @@ -7,9 +6,7 @@ import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session' import { registerWorktreeActivation } from '@/lib/worktree-activation-nav-registration' -import { workspaceHasSleepingAgentSessions } from '@/lib/worktree-agent-activation-gate' import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session' -import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { folderWorkspaceKey, parseWorkspaceKey } from '../../../shared/workspace-scope' import { @@ -18,19 +15,33 @@ import { getFolderWorkspacePathStatusTitle } from './folder-workspace-path-status' import { toast } from 'sonner' -import { isDetachedHeadWorkspace } from '@/components/sidebar/visible-worktrees' import type { ExecutionHostId } from '../../../shared/execution-host' import { findFolderWorkspaceOwner } from './folder-workspace-runtime-owner' import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding' import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake' import { applyWorktreeNavViewEntry } from '@/lib/worktree-nav-view-history-replay' -import { - activationProvidesInitialSurface, - type WorktreeActivationOptions, - type WorktreeActivationSurfaceSelection +import type { + WorktreeActivationOptions, + WorktreeActivationSurfaceSelection } from './worktree-activation-surface-selection' -import { gateAndReseedEmptyWorkspace } from './worktree-activation-gated-empty-reseed' +import { registerWorkspaceSurfaceProducer } from './workspace-surface-production' +import { resolveWorkspaceExecutionEvidence } from './workspace-execution-evidence' +import { + captureActivationRenderableSurfaceIds, + consumeTransferredActivationProducer, + createWorkspaceActivationIdentity, + ensureFolderWorkspaceInitialTerminal, + finalizeActivatedWorkspaceSurface, + hasOutstandingActivationSurfaceProducer, + hasWorkspaceActivationWork, + recoverActivatedWorkspace, + settleActivationSeedProducer +} from './worktree-activation-recovery-routing' +import { + clearWorktreeActivationSidebarFilters, + revealActivatedWorktree +} from './worktree-activation-sidebar-filters' /** * Shared activation sequence used by the worktree palette and add-repo/worktree dialogs. @@ -43,36 +54,6 @@ export type ActivateAndRevealResult = { primaryTabId: string | null } -function ensureFolderWorkspaceInitialTerminal( - folderWorkspace: FolderWorkspace, - startup?: WorktreeStartupPayload, - providesInitialSurface?: boolean -): string | null { - if (providesInitialSurface === true && startup === undefined) { - return null - } - const state = useAppStore.getState() - const workspaceKey = folderWorkspaceKey(folderWorkspace.id) - const primaryTabId = ensureWorktreeHasInitialTerminal( - state, - workspaceKey, - startup, - undefined, - undefined, - undefined, - { reseedEmptiedWorkspace: providesInitialSurface !== true } - ) - return primaryTabId -} - -function canInspectAgentActivationInventory(): boolean { - return ( - typeof window !== 'undefined' && - typeof window.api?.runtime?.call === 'function' && - typeof window.api?.pty?.listSessions === 'function' - ) -} - export function activateAndRevealFolderWorkspace( folderWorkspaceId: string, opts?: WorktreeActivationSurfaceSelection & { @@ -126,33 +107,37 @@ export function activateAndRevealFolderWorkspace( state.setActiveFolderWorkspace(folderWorkspaceId, opts?.executionHostId) const workspaceKey = folderWorkspaceKey(folderWorkspaceId) - const providesInitialSurface = activationProvidesInitialSurface(opts) state.markWorktreeVisited(workspaceKey) if (!state.isNavigatingHistory) { state.recordWorktreeVisit(workspaceKey) } - // Why: same ordering as the worktree path — gate first, then resume only when not deferring. - const shouldGateAgentActivation = - !opts?.startup && - (workspaceHasSleepingAgentSessions(state, workspaceKey) || - (canInspectAgentActivationInventory() && - shouldAutoCreateInitialTerminal( - state.reconcileWorktreeTabModel(workspaceKey).renderableTabCount - ))) - if (!shouldGateAgentActivation) { - resumeSleepingAgentSessionsForWorktree(workspaceKey) + const identity = createWorkspaceActivationIdentity(workspaceKey, { + ...(opts?.executionHostId ? { executionHostId: opts.executionHostId } : {}), + runtimeEnvironmentId + }) + const executionEvidence = resolveWorkspaceExecutionEvidence( + useAppStore.getState(), + workspaceKey, + identity.executionHostId + ) + const delegatesToRuntime = + executionEvidence === 'live' && isWebRuntimeSessionActive(runtimeEnvironmentId) + let primaryTabId: string | null = null + if (opts?.startup && !delegatesToRuntime) { + const producer = registerWorkspaceSurfaceProducer(identity) + if (executionEvidence !== 'exited') { + producer.unverifiable('Orca cannot verify the execution host for this requested surface.') + } else { + try { + const existingSurfaceIds = captureActivationRenderableSurfaceIds(workspaceKey) + resumeSleepingAgentSessionsForWorktree(workspaceKey) + primaryTabId = ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts.startup) + settleActivationSeedProducer(producer, workspaceKey, primaryTabId, existingSurfaceIds) + } catch (error) { + producer.failed(error) + } + } } - if (shouldGateAgentActivation) { - gateAndReseedEmptyWorkspace( - workspaceKey, - opts?.providesInitialSurface === true, - opts?.executionHostId - ) - } - const primaryTabId = shouldGateAgentActivation - ? null - : ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts?.startup, providesInitialSurface) - if (opts?.revealInSidebar !== false) { state.revealWorktreeInSidebar( workspaceKey, @@ -160,15 +145,14 @@ export function activateAndRevealFolderWorkspace( ) } - if (opts?.providesInitialSurface !== true) { + if (!hasOutstandingActivationSurfaceProducer(identity)) { ensureWebRuntimeWorktreeTerminalAfterWake(workspaceKey, { runtimeEnvironmentId, startup: opts?.startup, agent: opts?.agent }) } - - return { primaryTabId } + return { primaryTabId: primaryTabId ?? recoverActivatedWorkspace(identity) } } export function activateAndRevealWorktree( @@ -180,10 +164,7 @@ export function activateAndRevealWorktree( if (!wt) { return false } - const hasActivationWork = Boolean( - opts?.startup || opts?.setup || opts?.defaultTabs || opts?.issueCommand - ) - const providesInitialSurface = activationProvidesInitialSurface(opts) + const hasActivationWork = hasWorkspaceActivationWork(opts) // Why: a plain reselect should still reveal the sidebar row but must not restamp focus recency or wake persistence. const isPlainAlreadyActiveTerminal = !hasActivationWork && @@ -224,37 +205,28 @@ export function activateAndRevealWorktree( state.recordWorktreeVisit(worktreeId) } - // Why: the gate is decided BEFORE resuming. A sleeping session must defer seeding until startup - // restoration is ready (STA-1111) — resuming first would leave nothing to gate on. Structured - // agent inventory hydrates asynchronously too, so an empty tab model can otherwise authorize a - // fallback terminal beside a chat that is about to appear. - const shouldGateAgentActivation = - !hasActivationWork && - (workspaceHasSleepingAgentSessions(postActivationState, worktreeId) || - (canInspectAgentActivationInventory() && - shouldAutoCreateInitialTerminal( - postActivationState.reconcileWorktreeTabModel(worktreeId).renderableTabCount - ))) - if (!shouldGateAgentActivation) { + // Concrete launch work keeps its existing synchronous producer; empty activation is assessed by + // the recovery owner, which reconciles sleeping, structured, and live host inventory first. + const identity = createWorkspaceActivationIdentity( + worktreeId, + opts?.executionHostId ? { executionHostId: opts.executionHostId } : undefined + ) + const executionEvidence = resolveWorkspaceExecutionEvidence( + postActivationState, + worktreeId, + identity.executionHostId + ) + const delegatesToRuntime = + executionEvidence === 'live' && isWebRuntimeSessionActive(ownerRuntimeEnvironmentId) + let primaryTabId: string | null = null + if (hasActivationWork) { // Why: sleeping destroys the local PTY but preserves the provider session id, so waking should // restore those CLI sessions. Ordering is load-bearing: resuming synchronously creates the // session's tab first, so the seeding below doesn't add a bare shell next to it. - resumeSleepingAgentSessionsForWorktree(worktreeId) - } - if (shouldGateAgentActivation) { - gateAndReseedEmptyWorkspace( - worktreeId, - opts?.providesInitialSurface === true, - opts?.executionHostId - ) - } - - // 4. Ensure a focusable surface exists for externally-created worktrees - const primaryTabId = shouldGateAgentActivation - ? null - : providesInitialSurface && !hasActivationWork - ? null - : ensureWorktreeHasInitialTerminal( + const producer = registerWorkspaceSurfaceProducer(identity) + if (delegatesToRuntime) { + try { + ensureWorktreeHasInitialTerminal( useAppStore.getState(), worktreeId, opts?.startup, @@ -264,57 +236,88 @@ export function activateAndRevealWorktree( { ...(opts?.backendStartupTerminalSpawned ? { backendStartupTerminalSpawned: true } : {}), ...(opts?.createNewTerminalForStartup ? { createNewTerminalForStartup: true } : {}), - ...(providesInitialSurface ? { callerProvidesSurface: true } : {}), - reseedEmptiedWorkspace: !providesInitialSurface + reseedEmptiedWorkspace: true } ) - if (primaryTabId && opts?.initialCwd) { - useAppStore.getState().queueTabInitialCwd(primaryTabId, opts.initialCwd) + consumeTransferredActivationProducer(producer) + } catch (error) { + producer.failed(error) + } + } else if (opts?.backendStartupTerminalSpawned) { + try { + primaryTabId = ensureWorktreeHasInitialTerminal( + useAppStore.getState(), + worktreeId, + opts.startup, + opts.setup, + opts.issueCommand, + opts.defaultTabs, + { + backendStartupTerminalSpawned: true, + ...(opts.createNewTerminalForStartup ? { createNewTerminalForStartup: true } : {}), + reseedEmptiedWorkspace: true + } + ) + if (primaryTabId) { + producer.materialized({ kind: 'tab', id: primaryTabId }) + } else { + producer.unverifiable( + 'The execution host accepted the startup, but its surface is not visible yet.' + ) + } + } catch (error) { + producer.failed(error) + } + } else if (executionEvidence !== 'exited') { + producer.unverifiable('Orca cannot verify the execution host for this requested surface.') + } else { + try { + const existingSurfaceIds = captureActivationRenderableSurfaceIds(worktreeId) + resumeSleepingAgentSessionsForWorktree(worktreeId) + primaryTabId = ensureWorktreeHasInitialTerminal( + useAppStore.getState(), + worktreeId, + opts?.startup, + opts?.setup, + opts?.issueCommand, + opts?.defaultTabs, + { + ...(opts?.createNewTerminalForStartup ? { createNewTerminalForStartup: true } : {}), + reseedEmptiedWorkspace: true + } + ) + settleActivationSeedProducer(producer, worktreeId, primaryTabId, existingSurfaceIds) + } catch (error) { + producer.failed(error) + } + } } - // 5. Clear sidebar filters hiding the target — reveal needs the card rendered, else it silently no-ops. if (opts?.clearSidebarFilters !== false) { - if (state.filterRepoIds.length > 0 && !state.filterRepoIds.includes(wt.repoId)) { - state.setFilterRepoIds([]) - } - if ( - state.hideAutomationGeneratedWorkspaces && - wt.automationProvenance?.kind === 'created-by-automation' - ) { - state.setHideAutomationGeneratedWorkspaces(false) - } - if (state.hideCliCreatedWorkspaces && wt.cliProvenance?.kind === 'created-by-cli') { - state.setHideCliCreatedWorkspaces(false) - } - if (state.hideDetachedHeadWorkspaces && isDetachedHeadWorkspace(wt)) { - state.setHideDetachedHeadWorkspaces(false) - } + clearWorktreeActivationSidebarFilters(state, wt) } // 6. Reveal in sidebar if (opts?.revealInSidebar !== false) { - if (opts?.sidebarRevealBehavior || opts?.executionHostId) { - state.revealWorktreeInSidebar(worktreeId, { - ...(opts.sidebarRevealBehavior ? { behavior: opts.sidebarRevealBehavior } : {}), - ...(opts.executionHostId ? { executionHostId: opts.executionHostId } : {}) - }) - } else { - state.revealWorktreeInSidebar(worktreeId) - } + revealActivatedWorktree(state, worktreeId, { + ...(opts?.sidebarRevealBehavior ? { behavior: opts.sidebarRevealBehavior } : {}), + ...(opts?.executionHostId ? { executionHostId: opts.executionHostId } : {}) + }) } if ( opts?.notifyHostRuntime !== false && !opts?.backendStartupTerminalSpawned && - opts?.providesInitialSurface !== true + !hasOutstandingActivationSurfaceProducer(identity) ) { ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId, { startup: opts?.startup, agent: opts?.agent }) } - - return { primaryTabId } + return { + primaryTabId: finalizeActivatedWorkspaceSurface(identity, primaryTabId, opts?.initialCwd) + } } /** diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts index f80c1e50337..d25e1f48baa 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts @@ -590,9 +590,7 @@ describe('worktree agent activation gate', () => { }) }) - // Failing closed must not also fail silent: an unreadable census leaves the workspace with - // no surface, so the gate has to hand the caller its seed instead of claiming 'adopted'. - it('declines to mint but still asks for a seed when the host cannot answer', async () => { + it('blocks when the host cannot safely identify the live PTY surface', async () => { const livePtyId = `${WORKTREE_ID}@@live-agent` const { deps, createTab } = testDeps({ sessions: [listed(livePtyId)], @@ -600,12 +598,12 @@ describe('worktree agent activation gate', () => { resumeCount: 0 }) - await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('empty') + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('blocked') expect(createTab).not.toHaveBeenCalled() }) - it('declines to mint but still asks for a seed when two host surfaces claim one live PTY', async () => { + it('blocks when two host surfaces claim one live PTY', async () => { const livePtyId = `${WORKTREE_ID}@@live-agent` const { deps, createTab } = testDeps({ sessions: [listed(livePtyId)], @@ -613,7 +611,7 @@ describe('worktree agent activation gate', () => { resumeCount: 0 }) - await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('empty') + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('blocked') expect(createTab).not.toHaveBeenCalled() }) @@ -630,8 +628,7 @@ describe('worktree agent activation gate', () => { }) seedExistingSurface(deps.getState(), { tabId: 'tab-live', leafId: LIVE_LEAF_ID }) - // The seam re-checks its own guard, so an existing tab is not re-seeded by 'empty'. - await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('empty') + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('blocked') expect(createTab).not.toHaveBeenCalled() expect(warn).toHaveBeenCalledWith( diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.ts b/src/renderer/src/lib/worktree-agent-activation-gate.ts index 6ae016512ec..1fd6fa752ae 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.ts @@ -218,9 +218,8 @@ export async function runWorktreeAgentActivationGate( } let liveSurfaceAdopted = false if (liveWorkspaceSessions.length > 0) { - // Why: an unreadable census adopts nothing and mints nothing, so reporting 'adopted' - // would suppress the caller's seed and leave the workspace with no surface at all — - // fail-closed must still leave the user a usable pane (STA-5701). + // Why: an unreadable census adopts nothing and mints nothing, so reporting 'adopted' would + // falsely settle the request; recovery presents the blocked result without starting a writer. const adoption = await adoptLiveWorkspacePtySurfaces( deps.getState, worktreeId, @@ -234,6 +233,9 @@ export async function runWorktreeAgentActivationGate( worktreeId, declinedPtyIds: adoption.declinedPtyIds }) + if (!liveSurfaceAdopted) { + return 'blocked' + } } if (liveSurfaceAdopted && !workspaceHasSleepingAgentSessions(deps.getState(), worktreeId)) { return 'adopted' diff --git a/src/renderer/src/lib/worktree-agent-activation-seam.test.ts b/src/renderer/src/lib/worktree-agent-activation-seam.test.ts index 60644670b53..6a3205b04b6 100644 --- a/src/renderer/src/lib/worktree-agent-activation-seam.test.ts +++ b/src/renderer/src/lib/worktree-agent-activation-seam.test.ts @@ -9,6 +9,14 @@ import type { import { activateAndRevealWorktree } from './worktree-activation' import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate' import { makeCreatedAgentWorktree as makeWorktree } from './worktree-activation-created-agent-test-state' +import { + registerWorkspaceSurfaceProducer, + resetWorkspaceSurfaceProducersForTests +} from './workspace-surface-production' +import { + readWorkspaceActivationRecoveryPresentation, + resetWorkspaceActivationRecoveryPresentationsForTests +} from './workspace-activation-recovery-presentation' const initialState = useAppStore.getState() @@ -159,6 +167,8 @@ function stubInventory(args?: { afterEach(() => { vi.unstubAllGlobals() + resetWorkspaceSurfaceProducersForTests() + resetWorkspaceActivationRecoveryPresentationsForTests() useAppStore.setState(initialState, true) }) @@ -213,8 +223,9 @@ describe('worktree agent activation seam', () => { stubInventory({ livePtyId }) expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null }) - await waitForWorktreeAgentActivationGateForTests(worktree.id) - + await vi.waitFor(() => + expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(1) + ) const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? [] expect(tabs).toHaveLength(1) expect(tabs[0]?.ptyId).toBe(livePtyId) @@ -226,8 +237,9 @@ describe('worktree agent activation seam', () => { stubInventory() expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null }) - await waitForWorktreeAgentActivationGateForTests(worktree.id) - + await vi.waitFor(() => + expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(1) + ) const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? [] expect(tabs).toHaveLength(1) expect(tabs[0]?.ptyId).toBeNull() @@ -243,42 +255,66 @@ describe('worktree agent activation seam', () => { stubInventory() expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null }) - await waitForWorktreeAgentActivationGateForTests(worktree.id) - + await vi.waitFor(() => + expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(1) + ) const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? [] expect(tabs).toHaveLength(1) // A fresh shell, never a second surface forked onto the live agent's PTY. expect(tabs[0]?.ptyId).toBeNull() }) - it('does not race an explicitly promised surface with a fallback terminal', async () => { + it('does not race a registered surface producer with a fallback terminal', async () => { const worktree = makeWorktree() useAppStore.setState(baseState()) stubInventory() + const producer = registerWorkspaceSurfaceProducer({ + workspaceKey: worktree.id, + executionHostId: 'local' + }) - expect(activateAndRevealWorktree(worktree.id, { providesInitialSurface: true })).toEqual({ + expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null }) - await waitForWorktreeAgentActivationGateForTests(worktree.id) + await Promise.resolve() expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0) + producer.materialized({ kind: 'workspace-content', id: 'requested-content' }) + await producer.attempt.result + }) + + it('does not treat an unrelated surviving browser as completion of a requested launch', async () => { + const worktree = makeWorktree() + useAppStore.setState(baseState()) + useAppStore.getState().createBrowserTab(worktree.id, 'https://example.com', { activate: true }) + + expect( + activateAndRevealWorktree(worktree.id, { + startup: { command: 'codex', launchAgent: 'codex' } + }) + ).toEqual({ primaryTabId: null }) + + await vi.waitFor(() => + expect(readWorkspaceActivationRecoveryPresentation(worktree.id, 'local')?.kind).toBe( + 'producer-failed' + ) + ) + expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0) }) - // A paired-runtime owner is always omitted from its own scoped census, and an SSH relay that - // never answered omits everything. Declining to mint is right; leaving the workspace with no - // surface at all is not — the user asked for a pane and must get one. - it('still seeds a usable pane when the census cannot prove who owns a live PTY', async () => { + it('shows a blocked recovery without seeding when a live PTY cannot be safely surfaced', async () => { const worktree = makeWorktree() const livePtyId = `${worktree.id}@@live-codex` useAppStore.setState(baseState()) stubInventory({ livePtyId, unverifiableCensus: true }) expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null }) - await waitForWorktreeAgentActivationGateForTests(worktree.id) - - const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? [] - expect(tabs).toHaveLength(1) - expect(tabs[0]?.ptyId).toBeNull() + await vi.waitFor(() => + expect(readWorkspaceActivationRecoveryPresentation(worktree.id, 'local')?.kind).toBe( + 'blocked' + ) + ) + expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0) }) it('does not spawn before a structured chat tab hydrates', async () => { @@ -287,7 +323,7 @@ describe('worktree agent activation seam', () => { const { runtimeCall, listSessions } = stubInventory({ structured: true }) expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null }) - await waitForWorktreeAgentActivationGateForTests(worktree.id) + await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalled()) expect(useAppStore.getState().unifiedTabsByWorktree[worktree.id] ?? []).toHaveLength(0) expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0) @@ -302,9 +338,7 @@ describe('worktree agent activation seam', () => { }) }) - // A peer owns its own PTYs, so this client can never scope an inventory at it. Scoping must not - // turn that into a refusal: 'blocked' would also skip the sleeping-agent resume below. - it('still reaches a verdict for a paired-runtime-owned workspace', async () => { + it('keeps a paired-runtime launch producer authoritative when host creation fails', async () => { const worktree = makeWorktree() useAppStore.setState({ ...baseState(), @@ -313,14 +347,16 @@ describe('worktree agent activation seam', () => { const { listSessions } = stubInventory() expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null }) - await expect(waitForWorktreeAgentActivationGateForTests(worktree.id)).resolves.toBe('empty') - expect(listSessions).toHaveBeenCalledExactlyOnceWith() + await vi.waitFor(() => + expect(readWorkspaceActivationRecoveryPresentation(worktree.id, 'runtime:env-1')?.kind).toBe( + 'producer-failed' + ) + ) + expect(listSessions).not.toHaveBeenCalled() + expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0) }) - // Loss of contact with the relay is not evidence about the host, and once the sync has stopped - // without an answer the bounded floor in workspace-terminal-host-authority.ts hands seeding back - // to this client — a detached provider must not turn that into a permanently empty workspace. - it('still seeds a pane when the selected SSH relay is detached', async () => { + it('shows reconnect recovery without starting a requested writer when SSH is detached', async () => { const worktree = makeWorktree() useAppStore.setState({ ...baseState(), @@ -335,12 +371,17 @@ describe('worktree agent activation seam', () => { return [] }) - expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null }) - await expect(waitForWorktreeAgentActivationGateForTests(worktree.id)).resolves.toBe('empty') - expect(listSessions.mock.calls).toEqual([[{ connectionId: 'box' }], []]) + expect( + activateAndRevealWorktree(worktree.id, { + startup: { command: 'codex', launchAgent: 'codex' } + }) + ).toEqual({ primaryTabId: null }) await vi.waitFor(() => - expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(1) + expect(readWorkspaceActivationRecoveryPresentation(worktree.id, 'ssh:box')?.kind).toBe( + 'unverifiable' + ) ) - expect(useAppStore.getState().tabsByWorktree[worktree.id]?.[0]?.ptyId).toBeNull() + expect(listSessions).not.toHaveBeenCalled() + expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0) }) }) diff --git a/src/renderer/src/lib/worktree-creation-flow-execute.ts b/src/renderer/src/lib/worktree-creation-flow-execute.ts index a270a7a417b..b05fed7d959 100644 --- a/src/renderer/src/lib/worktree-creation-flow-execute.ts +++ b/src/renderer/src/lib/worktree-creation-flow-execute.ts @@ -26,6 +26,13 @@ import { import { completeWorktreeCreation } from '@/lib/worktree-creation-completion' import { markStructuredWorktreeLaunchUnconfirmed } from '@/lib/worktree-creation-structured-recovery' import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake' +import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production' +import { recoverWorkspaceActivation } from '@/lib/worktree-activation-recovery' +import { + getExecutionHostIdForWorktree, + getRuntimeEnvironmentIdForWorktree +} from '@/lib/worktree-runtime-owner' +import { queueStandaloneSetupTab } from '@/lib/worktree-setup-issue-command-queue' // Why: activePendingCreationId can outlive the terminal route when the user // switches app views; only the terminal route renders the creation panel. @@ -199,39 +206,17 @@ export async function executeWorktreeCreation( // Startup terminal ids and stamped agent tabs are the only safe primary // ids when activation returned no result. primaryTabId = verifiedLaunchTabId - } else if (existingTabs.length === 0) { - try { - primaryTabId = ensureWorktreeHasInitialTerminal( - useAppStore.getState(), - worktree.id, - startupOpt, - result.setup, - preparedRequest.issueCommand, - result.defaultTabs, - // Activation failed before providing its promised surface, so recovery must seed one. - backendSpawned ? { backendStartupTerminalSpawned: true } : undefined - ) - } catch (recoveryError) { - console.error( - 'worktree create: activation recovery seeding failed', - worktree.id, - recoveryError - ) - } - } - if (!backendSpawned) { - try { - ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id, { - startup: startupOpt, - agent: preparedRequest.agent - }) - } catch (recoveryError) { - console.error( - 'worktree create: activation recovery after-wake seeding failed', - worktree.id, - recoveryError - ) + } else { + const recoveryState = useAppStore.getState() + const identity = { + workspaceKey: worktree.id, + executionHostId: getExecutionHostIdForWorktree(recoveryState, worktree.id), + runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(recoveryState, worktree.id), + attemptId: createBrowserUuid() } + const producer = registerWorkspaceSurfaceProducer(identity) + producer.failed(error) + void recoverWorkspaceActivation(identity, { mode: 'explicit' }) } } } else { @@ -241,19 +226,31 @@ export async function executeWorktreeCreation( ) if (preparedRequest.agent === null || hasExplicitTerminalWork) { try { - primaryTabId = ensureWorktreeHasInitialTerminal( - useAppStore.getState(), - worktree.id, - startupOpt, - result.setup, - preparedRequest.issueCommand, - result.defaultTabs, - { - activateCreatedTabs: false, - ...(preparedRequest.agent !== null ? { callerProvidesSurface: true } : {}), - ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) - } - ) + const seedingState = useAppStore.getState() + const setupRunsWithoutPrimary = + structuredLaunch && + queueStandaloneSetupTab({ + store: seedingState, + worktreeId: worktree.id, + setup: result.setup, + issueCommand: preparedRequest.issueCommand, + defaultTabs: result.defaultTabs, + opts: { activateCreatedTabs: false } + }) + if (!setupRunsWithoutPrimary) { + primaryTabId = ensureWorktreeHasInitialTerminal( + seedingState, + worktree.id, + startupOpt, + result.setup, + preparedRequest.issueCommand, + result.defaultTabs, + { + activateCreatedTabs: false, + ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) + } + ) + } } catch (error) { console.error('worktree create: initial terminal seeding failed', worktree.id, error) } diff --git a/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts b/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts index 158a744413e..36d974fe8d0 100644 --- a/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts @@ -4,6 +4,7 @@ import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' import { shouldShowWorktreeCreationSurface } from '@/lib/worktree-creation-surface' +import type { WorkspaceSurfaceProducer } from '@/lib/workspace-surface-production' // Guards executeWorktreeCreation's post-create tail: callers fire and forget, // so a throw after createWorktree succeeds must be contained per-step and the @@ -56,6 +57,19 @@ const store = { unifiedTabsByWorktree: {} } +const surfaceProducer: WorkspaceSurfaceProducer = { + attempt: { + id: 'create-recovery-producer', + workspaceKey: 'wt-1', + executionHostId: 'local', + result: Promise.resolve({ kind: 'failed', reason: 'activation exploded' }) + }, + materialized: vi.fn(), + declined: vi.fn(), + failed: vi.fn(), + unverifiable: vi.fn() +} + vi.mock('@/store', () => ({ useAppStore: { getState: () => store @@ -107,6 +121,25 @@ vi.mock('@/lib/worktree-creation-structured-recovery', () => ({ retryStructuredWorktreeLaunch: vi.fn() })) +vi.mock('@/lib/workspace-surface-production', () => ({ + registerWorkspaceSurfaceProducer: vi.fn(() => surfaceProducer) +})) + +vi.mock('@/lib/worktree-activation-recovery', () => ({ + recoverWorkspaceActivation: vi.fn(async () => ({ + kind: 'failed', + reason: 'producer-failed', + diagnosticId: 'create-recovery-attempt' + })) +})) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getExecutionHostIdForWorktree: () => 'local', + getRuntimeEnvironmentIdForWorktree: () => null +})) + +vi.mock('@/lib/browser-uuid', () => ({ createBrowserUuid: () => 'create-recovery-attempt' })) + import { toast } from 'sonner' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding' @@ -115,6 +148,8 @@ import { ensureAgentStartupInTerminal } from '@/lib/new-workspace' import { prepareRequestForCreate } from '@/lib/ephemeral-vm-worktree-creation' import { executeWorktreeCreation } from './worktree-creation-flow-execute' import { runBackgroundWorktreeCreation } from './worktree-creation-flow' +import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production' +import { recoverWorkspaceActivation } from '@/lib/worktree-activation-recovery' function makeRequest(overrides: Partial = {}): WorktreeCreationRequest { return { @@ -172,13 +207,18 @@ beforeEach(() => { store.createWorktree.mockResolvedValue({ worktree: { id: 'wt-1', repoId: 'repo-1' } }) + vi.mocked(registerWorkspaceSurfaceProducer).mockReturnValue(surfaceProducer) + vi.mocked(recoverWorkspaceActivation).mockResolvedValue({ + kind: 'failed', + reason: 'producer-failed', + diagnosticId: 'create-recovery-attempt' + }) }) describe('a throw after createWorktree succeeds no longer strands the creation surface', () => { - it('activating branch: a planless agent throw recovers a terminal and completes', async () => { + it('activating branch: a planless agent throw reports recovery failure without a shell', async () => { const request = makeRequest({ agent: 'claude' }) seedPendingCreation(request) - vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('recovered-tab') vi.mocked(activateAndRevealWorktree).mockImplementation(() => { throw new Error('activation exploded') }) @@ -190,14 +230,23 @@ describe('a throw after createWorktree succeeds no longer strands the creation s 'wt-1', expect.any(Error) ) - expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith( - store, - 'wt-1', - undefined, - undefined, - undefined, - undefined, - undefined + expect(Object.hasOwn(store.tabsByWorktree, 'wt-1')).toBe(false) + expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() + expect(registerWorkspaceSurfaceProducer).toHaveBeenCalledWith({ + workspaceKey: 'wt-1', + executionHostId: 'local', + runtimeEnvironmentId: null, + attemptId: 'create-recovery-attempt' + }) + expect(surfaceProducer.failed).toHaveBeenCalledWith(expect.any(Error)) + expect(recoverWorkspaceActivation).toHaveBeenCalledWith( + { + workspaceKey: 'wt-1', + executionHostId: 'local', + runtimeEnvironmentId: null, + attemptId: 'create-recovery-attempt' + }, + { mode: 'explicit' } ) // Contained: completion still tears the surface down. expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { diff --git a/src/renderer/src/lib/worktree-creation-structured-session.test.ts b/src/renderer/src/lib/worktree-creation-structured-session.test.ts index f072c17d417..6d26bbb93fb 100644 --- a/src/renderer/src/lib/worktree-creation-structured-session.test.ts +++ b/src/renderer/src/lib/worktree-creation-structured-session.test.ts @@ -1,32 +1,61 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ - state: { - pendingWorktreeCreations: { 'creation-1': {} } as Record - }, - listener: null as ((state: { pendingWorktreeCreations: Record }) => void) | null, - unsubscribe: vi.fn(), - startStructuredAgentLaunch: vi.fn(), - cancelStructuredAgentLaunch: vi.fn(), - closeStructuredAgentSession: vi.fn(), - callRuntimeRpc: vi.fn(), - activateStructuredAgentSessionById: vi.fn(), - activateAndRevealWorktree: vi.fn(), - ensureWorktreeHasInitialTerminal: vi.fn(), - ensureWebRuntimeWorktreeTerminalAfterWake: vi.fn(), - preflightAgentTrust: vi.fn(), - updateWorktreeMeta: vi.fn() -})) +type MockState = { + pendingWorktreeCreations: Record + reconcileWorktreeTabModel: () => { + renderableTabCount: number + activeRenderableTabId: string + } + allWorktrees?: () => { id: string; path: string }[] + repos?: { id: string; connectionId: string }[] + updateWorktreeMeta?: ReturnType +} + +const mocks = vi.hoisted(() => { + let listener: ((state: MockState) => void) | null = null + const reconcileWorktreeTabModel = vi.fn(() => ({ + renderableTabCount: 1, + activeRenderableTabId: 'agent-session:session-1' + })) + let state: MockState = { + pendingWorktreeCreations: { 'creation-1': {} }, + reconcileWorktreeTabModel + } + return { + get state(): MockState { + return state + }, + set state(value: MockState) { + state = value + }, + get listener(): ((state: MockState) => void) | null { + return listener + }, + set listener(value: ((state: MockState) => void) | null) { + listener = value + }, + unsubscribe: vi.fn(), + startStructuredAgentLaunch: vi.fn(), + cancelStructuredAgentLaunch: vi.fn(), + closeStructuredAgentSession: vi.fn(), + callRuntimeRpc: vi.fn(), + activateStructuredAgentSessionById: vi.fn(), + activateAndRevealWorktree: vi.fn(), + ensureWorktreeHasInitialTerminal: vi.fn(), + ensureWebRuntimeWorktreeTerminalAfterWake: vi.fn(), + preflightAgentTrust: vi.fn(), + updateWorktreeMeta: vi.fn(), + reconcileWorktreeTabModel + } +}) vi.mock('@/store', () => ({ useAppStore: Object.assign(vi.fn(), { getState: () => mocks.state, - subscribe: vi.fn( - (listener: (state: { pendingWorktreeCreations: Record }) => void) => { - mocks.listener = listener - return mocks.unsubscribe - } - ) + subscribe: vi.fn((listener: (state: MockState) => void) => { + mocks.listener = listener + return mocks.unsubscribe + }) }) })) @@ -112,14 +141,18 @@ function storeWithWorktree() { pendingWorktreeCreations: { 'creation-1': {} }, allWorktrees: () => [{ id: 'worktree-1', path: '/tmp/worktree-1' }], repos: [{ id: 'repo-1', connectionId: 'ssh-1' }], - updateWorktreeMeta: mocks.updateWorktreeMeta - } as unknown as typeof mocks.state + updateWorktreeMeta: mocks.updateWorktreeMeta, + reconcileWorktreeTabModel: mocks.reconcileWorktreeTabModel + } } describe('launchStructuredWorktreeSession', () => { beforeEach(() => { vi.clearAllMocks() - mocks.state = { pendingWorktreeCreations: { 'creation-1': {} } } + mocks.state = { + pendingWorktreeCreations: { 'creation-1': {} }, + reconcileWorktreeTabModel: mocks.reconcileWorktreeTabModel + } mocks.listener = null mocks.closeStructuredAgentSession.mockResolvedValue('closed') mocks.callRuntimeRpc.mockResolvedValue(undefined) @@ -232,7 +265,10 @@ describe('launchStructuredWorktreeSession', () => { }) it('returns cancelled without starting a launch when the creation is already gone', async () => { - mocks.state = { pendingWorktreeCreations: {} } + mocks.state = { + pendingWorktreeCreations: {}, + reconcileWorktreeTabModel: mocks.reconcileWorktreeTabModel + } await expect( launchStructuredWorktreeSession({ @@ -273,7 +309,10 @@ describe('launchStructuredWorktreeSession', () => { await Promise.resolve() expect(mocks.cancelStructuredAgentLaunch).not.toHaveBeenCalled() - mocks.state = { pendingWorktreeCreations: {} } + mocks.state = { + pendingWorktreeCreations: {}, + reconcileWorktreeTabModel: mocks.reconcileWorktreeTabModel + } mocks.listener?.(mocks.state) mocks.listener?.(mocks.state) expect(mocks.cancelStructuredAgentLaunch).toHaveBeenCalledExactlyOnceWith( @@ -398,7 +437,10 @@ describe('launchStructuredWorktreeSession', () => { // Why: the module trusts its callers for the route, so the agent check is the last local // eligibility gate. Without it a dismissed creation reports itself cancelled for an agent that // was never going to open a session here. - mocks.state = { pendingWorktreeCreations: {} } + mocks.state = { + pendingWorktreeCreations: {}, + reconcileWorktreeTabModel: mocks.reconcileWorktreeTabModel + } await expect( launchStructuredWorktreeSession({ @@ -542,7 +584,10 @@ describe('launchStructuredWorktreeSession', () => { primaryTabId: null }) - mocks.state = { pendingWorktreeCreations: {} } + mocks.state = { + pendingWorktreeCreations: {}, + reconcileWorktreeTabModel: mocks.reconcileWorktreeTabModel + } mocks.listener?.(mocks.state) resolveLaunch({ sessionId: 'session-1', fence: 1 }) @@ -638,9 +683,7 @@ describe('launchStructuredWorktreeSession', () => { primaryTabId: null }) - expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('worktree-1', { - providesInitialSurface: true - }) + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('worktree-1') expect(mocks.activateAndRevealWorktree.mock.invocationCallOrder[0]).toBeLessThan( mocks.activateStructuredAgentSessionById.mock.invocationCallOrder[0] ) diff --git a/src/renderer/src/lib/worktree-creation-structured-session.ts b/src/renderer/src/lib/worktree-creation-structured-session.ts index df86f56efed..dc32770b0a3 100644 --- a/src/renderer/src/lib/worktree-creation-structured-session.ts +++ b/src/renderer/src/lib/worktree-creation-structured-session.ts @@ -13,6 +13,9 @@ import { closeStructuredAgentSession } from '@/runtime/structured-agent-session- import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake' +import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { settleStructuredAgentSurfaceProducer } from '@/lib/structured-agent-surface-production' export type WorktreeCreationStructuredSessionResult = { accepted: boolean @@ -138,6 +141,10 @@ export async function launchStructuredWorktreeSession( ...(args.request.promptDelivery ? { promptDelivery: args.request.promptDelivery } : {}) }) }) + const producer = registerWorkspaceSurfaceProducer({ + workspaceKey: args.worktreeId, + executionHostId: getExecutionHostIdForWorktree(useAppStore.getState(), args.worktreeId) + }) const abandoned = new AbortController() const unsubscribe = useAppStore.subscribe((state) => { if (!state.pendingWorktreeCreations[args.creationId]) { @@ -158,10 +165,8 @@ export async function launchStructuredWorktreeSession( return } // Why: chat selection requires its workspace to be active. - if (!activation) { - activation = activateAndRevealWorktree(args.worktreeId, { - providesInitialSurface: true - }) + if (activation === false) { + activation = activateAndRevealWorktree(args.worktreeId) primaryTabId = activation === false ? null : activation.primaryTabId } activateStructuredAgentSessionById({ worktreeId: args.worktreeId, sessionId }) @@ -169,16 +174,28 @@ export async function launchStructuredWorktreeSession( }, { worktreeId: args.worktreeId } ) - } catch { + } catch (error) { // Why: nothing awaits this creation's caller, so an escaped throw would strand the panel // mid-create. Report it the way a failed launch already does; the launch layer toasts it. + producer.failed(error) + if (args.shouldActivateOnCompletion && activation === false) { + activation = activateAndRevealWorktree(args.worktreeId) + } return { ...settled, activation, primaryTabId } } finally { unsubscribe() } if (!settlement) { + producer.failed('The agent launch did not start.') + if (args.shouldActivateOnCompletion && activation === false) { + activation = activateAndRevealWorktree(args.worktreeId) + } return { ...settled, activation, primaryTabId } } + if (settlement.kind === 'failed' && args.shouldActivateOnCompletion && activation === false) { + activation = activateAndRevealWorktree(args.worktreeId) + } + settleStructuredAgentSurfaceProducer(producer, args.worktreeId, settlement) switch (settlement.kind) { case 'cancelled': { // Why: a refusal means no session exists on the host, so there is nothing to retire. diff --git a/src/renderer/src/lib/worktree-creation-structured-unknown-outcome.test.ts b/src/renderer/src/lib/worktree-creation-structured-unknown-outcome.test.ts index 46e132132f1..a6ddf890cc6 100644 --- a/src/renderer/src/lib/worktree-creation-structured-unknown-outcome.test.ts +++ b/src/renderer/src/lib/worktree-creation-structured-unknown-outcome.test.ts @@ -94,6 +94,7 @@ vi.mock('sonner', () => ({ })) vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, translate: (_key: string, fallback: string) => fallback })) diff --git a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts index f9c2844d472..2eb75d2e58f 100644 --- a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts +++ b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts @@ -2,7 +2,6 @@ import type { WorktreeDefaultTabsLaunch, WorktreeSetupLaunch } from '../../../shared/worktree/launch-types' -import type { ExecutionHostId } from '../../../shared/execution-host' import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal' import { createSequencedSetupAgentCommands } from '../../../shared/setup-agent-sequencing' import { getSetupRunnerCommandPlatformForPath } from '../../../shared/setup-runner-command' @@ -28,6 +27,8 @@ import { type IssueCommandLaunch } from '@/lib/worktree-setup-issue-command-queue' import { applyDefaultTerminalTabs } from '@/lib/worktree-default-terminal-tabs' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { resolveWorkspaceExecutionEvidence } from '@/lib/workspace-execution-evidence' function getSetupRunnerCommandPlatformForLaunch(setup: WorktreeSetupLaunch): 'windows' | 'posix' { return getSetupRunnerCommandPlatformForPath( @@ -36,33 +37,6 @@ function getSetupRunnerCommandPlatformForLaunch(setup: WorktreeSetupLaunch): 'wi ) } -/** Re-seed after an empty gate unless its activation owns the surface or no longer owns the host. */ -export function reseedGatedEmptyWorkspace( - workspaceKey: string, - callerProvidesSurface: boolean, - executionHostId?: ExecutionHostId -): void { - const state = useAppStore.getState() - if ( - callerProvidesSurface === true || - state.activeWorktreeId !== workspaceKey || - (executionHostId !== undefined && state.activeWorkspaceExecutionHostId !== executionHostId) - ) { - return - } - ensureWorktreeHasInitialTerminal( - state, - workspaceKey, - undefined, - undefined, - undefined, - undefined, - { - reseedEmptiedWorkspace: true - } - ) -} - export function ensureWorktreeHasInitialTerminal( store: WorktreeActivationStore, worktreeId: string, @@ -99,6 +73,11 @@ export function ensureWorktreeHasInitialTerminal( const backendStartupTerminalSpawned = opts?.backendStartupTerminalSpawned === true const hostAuthority = resolveWorkspaceTerminalHostAuthority(ownerState, worktreeId) + const executionEvidence = resolveWorkspaceExecutionEvidence( + ownerState, + worktreeId, + getExecutionHostIdForWorktree(ownerState, worktreeId) + ) // Why: explicit spawn evidence survives the new-worktree ownership race; a host that owns terminal creation provides the same authority for later activations. if (backendStartupTerminalSpawned || hostAuthority === 'live') { const existingTerminalTabId = store.tabsByWorktree[worktreeId]?.[0]?.id @@ -137,32 +116,6 @@ export function ensureWorktreeHasInitialTerminal( } const hasExplicitLaunchWork = Boolean(sequencedStartup || setup || issueCommand) - // Why: a caller opening its own primary surface (a structured native chat) asked for that surface - // alone. Setup launched in its own tab needs no shell to attach to, so seeding one leaves a stray - // "Terminal 1" beside the chat. Splits and issue automation still need a pane to split from. - const setupNeedsHostTerminal = - setup !== undefined && - (useAppStore.getState().settings?.setupScriptLaunchMode ?? 'new-tab') !== 'new-tab' - if ( - opts?.callerProvidesSurface === true && - renderableTabCount === 0 && - !sequencedStartup && - !issueCommand && - !setupNeedsHostTerminal && - !defaultTabs?.tabs.length && - opts?.createNewTerminalForStartup !== true - ) { - queueSetupAndIssueCommands( - store, - worktreeId, - null, - setup, - undefined, - wrappedSetupCommandStr, - opts - ) - return null - } // Why: only startup hydration honours the closed-last-tab tombstone. Every explicit // activation (sidebar, palette, automation resume, wake) re-seeds a surface instead, // because closing the last terminal normally deactivates the workspace too @@ -175,15 +128,18 @@ export function ensureWorktreeHasInitialTerminal( // deactivation hooks for pane moves and retirement, where re-seeding is the wanted outcome. const shouldHonourClosedTerminalTombstone = Object.hasOwn(store.tabsByWorktree, worktreeId) && opts?.reseedEmptiedWorkspace !== true - // Why: an execution host that has not answered is not a host with no terminals; seeding into that - // gap is what adds a tab per launch (STA-4658). Explicit launch work below is a request to create - // a terminal now, so it stays ungated. + // Why: an execution host that has not answered is not a host with no terminals; neither automatic + // recovery nor explicit launch work can turn that uncertainty into permission for another writer. const shouldAutoCreate = hostAuthority === 'none' && + executionEvidence === 'exited' && shouldAutoCreateInitialTerminal(renderableTabCount, shouldHonourClosedTerminalTombstone) - const shouldCreateForExplicitWork = renderableTabCount === 0 && hasExplicitLaunchWork + const shouldCreateForExplicitWork = + executionEvidence === 'exited' && renderableTabCount === 0 && hasExplicitLaunchWork const shouldCreateNewStartupTerminal = - opts?.createNewTerminalForStartup === true && sequencedStartup !== undefined + executionEvidence === 'exited' && + opts?.createNewTerminalForStartup === true && + sequencedStartup !== undefined if (!shouldAutoCreate && !shouldCreateForExplicitWork && !shouldCreateNewStartupTerminal) { const existingTerminalTabId = store.tabsByWorktree[worktreeId]?.[0]?.id if (existingTerminalTabId && (setup || issueCommand)) { diff --git a/src/renderer/src/lib/worktree-setup-issue-command-queue.ts b/src/renderer/src/lib/worktree-setup-issue-command-queue.ts index 3a66305fb67..de2e4aeb57d 100644 --- a/src/renderer/src/lib/worktree-setup-issue-command-queue.ts +++ b/src/renderer/src/lib/worktree-setup-issue-command-queue.ts @@ -1,4 +1,7 @@ -import type { WorktreeSetupLaunch } from '../../../shared/worktree/launch-types' +import type { + WorktreeDefaultTabsLaunch, + WorktreeSetupLaunch +} from '../../../shared/worktree/launch-types' import { buildSetupRunnerCommand } from './setup-runner' import { useAppStore } from '@/store' import type { @@ -11,6 +14,34 @@ export type IssueCommandLaunch = | WorktreeSetupLaunch | { command: string; env?: Record } +export function queueStandaloneSetupTab(args: { + store: WorktreeActivationStore + worktreeId: string + setup: WorktreeSetupLaunch | undefined + issueCommand: IssueCommandLaunch | undefined + defaultTabs: WorktreeDefaultTabsLaunch | undefined + opts?: InitialTerminalOptions +}): boolean { + if ( + !args.setup || + args.issueCommand || + args.defaultTabs?.tabs.length || + (useAppStore.getState().settings?.setupScriptLaunchMode ?? 'new-tab') !== 'new-tab' + ) { + return false + } + queueSetupAndIssueCommands( + args.store, + args.worktreeId, + null, + args.setup, + undefined, + undefined, + args.opts + ) + return true +} + export function queueSetupAndIssueCommands( store: WorktreeActivationStore, worktreeId: string,