fix activation surface recovery ownership

This commit is contained in:
Brennan Benson
2026-09-14 03:30:19 -07:00
parent 0c812843ef
commit c49e1f6af3
73 changed files with 4255 additions and 1385 deletions
@@ -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({
<div
// Why: already out of flow via the workbench container when hidden, so retention only
// has to drop `hidden` — it does not need to leave the flex column a second time.
className={`flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${
className={`relative flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${
renderedActiveWorktreeId
? ''
: retainBrowserGuestPaint
@@ -30,6 +31,9 @@ export function TerminalSurface({
<TerminalTitlebarTabs controller={controller} />
<TerminalSplitWorkspaceSurfaces controller={controller} />
<TerminalLegacyWorkspaceSurface controller={controller} />
{renderedActiveWorktreeId ? (
<WorkspaceActivationRecoverySurface worktreeId={renderedActiveWorktreeId} />
) : null}
<TerminalWorkspaceDialogs controller={controller} />
</div>
)
@@ -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<string, unknown[]> } = {
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(<WorkspaceActivationRecoverySurface worktreeId={WORKSPACE_KEY} />)
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(<WorkspaceActivationRecoverySurface worktreeId={WORKSPACE_KEY} />)
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(<WorkspaceActivationRecoverySurface worktreeId={WORKSPACE_KEY} />)
expect(screen.getByRole('heading', { name: title })).not.toBeNull()
})
})
@@ -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 (
<div
className="absolute inset-0 z-20 flex items-center justify-center bg-background"
data-workspace-activation-recovery="recovering"
role="status"
>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
{translate(
'auto.components.workspace.activation.recovery.progress',
'Checking workspace surfaces…'
)}
</div>
</div>
)
}
const copy = presentationCopy(presentation)
return (
<div
className="absolute inset-0 z-20 flex items-center justify-center bg-background p-6"
data-workspace-activation-recovery={presentation.kind}
role="alert"
>
<div className="flex w-full max-w-md flex-col items-center text-center">
<CircleAlert className="size-8 text-destructive" aria-hidden="true" />
<h2 className="mt-4 text-base font-medium text-foreground">{copy.title}</h2>
<p className="mt-2 text-sm text-muted-foreground">{copy.description}</p>
<Button type="button" className="mt-5 gap-2" onClick={presentation.retry}>
<RefreshCw className="size-4" aria-hidden="true" />
{translate('auto.components.workspace.activation.recovery.retry', 'Retry')}
</Button>
</div>
</div>
)
}
@@ -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'
})
})
})
@@ -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
}
}
@@ -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 }
@@ -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.
@@ -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',
@@ -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',
@@ -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)
})
@@ -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)
@@ -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(
@@ -33,6 +33,10 @@ const store = {
agentStatusByPaneKey: {} as Record<string, { agentType?: string }>,
tabsByWorktree: {} as Record<string, { id: string; launchAgent?: string | null }[]>,
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'
})
}
)
@@ -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<typeof launchAgentInNewTab>
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<typeof result.structuredSettlement>
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)
@@ -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
@@ -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)
}
}
@@ -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(
@@ -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 () => {
@@ -65,8 +65,6 @@ function renderWatcherEffects(overrides: Partial<WatcherController> = {}): Promi
activityTerminalPortals: [],
anyMountedWorktreeHasLayout: false,
backgroundMountRevision: 0,
createTab: vi.fn(),
reconcileWorktreeTabModel: vi.fn(),
pairedRuntimeParkingEnvironmentIds: new Set(),
terminalSshParkingEnabled: true,
terminalProviderSnapshotCapabilityRevision: 0,
@@ -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(<Watcher activeWorktreeId="folder:workspace-1" />))
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(<Watcher activeWorktreeId="worktree-1" />))
await act(async () => undefined)
await act(async () => root?.render(<Watcher activeWorktreeId="worktree-2" />))
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(<Watcher />))
await act(async () => root?.render(<Watcher activeWorktreeId="worktree-1" />))
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(<Watcher activeWorktreeId="folder:workspace-2" />))
expect(mocks.createTab).toHaveBeenCalledTimes(expectedTabs)
expect(firstSignal?.aborted).toBe(true)
expect(mocks.recover).toHaveBeenCalledTimes(2)
await act(async () => settleSecond())
})
})
@@ -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()')
})
})
@@ -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<string> = 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<string>())
// 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
])
@@ -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<typeof FullCreationStructuredLaunchModule>()),
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<T>() {
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<FullCreationExecutionInput['createWorktree']>()
.mockResolvedValue(created)
const state = {
applyWorktreeMeta: vi
.fn<FullCreationExecutionInput['applyWorktreeMeta']>()
.mockResolvedValue(),
clearNewWorkspaceDraft: vi.fn<FullCreationExecutionInput['clearNewWorkspaceDraft']>(),
createWorktree,
effectivePresetId: null,
isSubmissionCancelled: () => false,
linkedGitLabIssue: null,
linkedGitLabMR: null,
normalizedSparseDirectories: [],
note: '',
onCreated: vi.fn<NonNullable<FullCreationExecutionInput['onCreated']>>(),
parentWorktreeId: null,
persistDraft: false,
persistSetupAgentStartupPolicy: vi.fn(async () => true),
prepareFullSubmit: vi
.fn<FullCreationExecutionInput['prepareFullSubmit']>()
.mockResolvedValue(prepared),
resolvedInitialWorkspaceStatus: undefined,
selectedRepoExecutionHostId: 'local',
selectedRepoIsGit: true,
setSidebarOpen: vi.fn<FullCreationExecutionInput['setSidebarOpen']>(),
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()
})
})
@@ -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<typeof activateAndRevealWorktree>
let settlement: Awaited<ReturnType<typeof settleFullCreationStructuredLaunch>>
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.
@@ -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<typeof queueStandaloneSetupTab>[0]
export function beginFullCreationSurfaceProduction(
args: Omit<StandaloneSetupArgs, 'store'> & { 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. */
@@ -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.'
)
})
})
@@ -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<typeof queueStandaloneSetupTab>[0]
type StructuredSettlement = Awaited<ReturnType<typeof settleDirectWorkItemStructuredLaunch>>
export function beginDirectWorkItemSurfaceProduction(
args: Omit<StandaloneSetupArgs, 'store'> & {
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.')
}
}
@@ -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 () => {
+52 -30
View File
@@ -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) {
@@ -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(
@@ -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 }
}
}
+39 -12
View File
@@ -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<ReturnType<typeof settleDirectWorkItemStructuredLaunch>>
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.
@@ -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<void> {
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 }
)
}
@@ -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' }
}
}
])
})
})
@@ -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}` })
}
@@ -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)
}
@@ -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<WorkspaceActivationRecoveryResult> {
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)
}
}
@@ -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<WorkspaceActivationRecoveryResult | 'empty' | 'produced'> {
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<WorkspaceActivationRecoveryResult> {
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
}
}
}
}
@@ -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<string, WorkspaceActivationRecoveryPresentation>()
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()
}
@@ -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<string, ReadonlySet<string>>()
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<WorkspaceActivationRecoveryResult | null> {
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
}
}
}
}
@@ -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<string, string>()
const gateIdentityByPromise = new WeakMap<Promise<unknown>, 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<unknown>): string | undefined {
return gateIdentityByPromise.get(gate)
}
export function recordActivationRecoveryGateRoute(gate: Promise<unknown>, 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<string> {
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<RecoveryWaitResult> {
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<T>(
promise: Promise<T>,
deadlineAt: number,
signal: AbortSignal | undefined
): Promise<
| { kind: 'settled'; value: T }
| { kind: 'rejected'; error: unknown }
| Exclude<RecoveryWaitResult, 'changed'>
> {
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<RecoveryWaitResult, 'changed'>
): 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'
)
}
@@ -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'
}
+2 -89
View File
@@ -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<typeof useAppStore.getState>['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
@@ -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<typeof useAppStore.getState>['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.' }
}
}
@@ -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<WorkspaceSurfaceProductionResult>
}
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<string, WorkspaceSurfaceProducerEntry>()
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<WorkspaceSurfaceProductionResult>((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<WorkspaceSurfaceProducerEntry>[] {
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()
}
@@ -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)
}
)
@@ -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".
@@ -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({
@@ -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<activationGate.WorktreeAgentActivationOutcome>((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<activationGate.WorktreeAgentActivationOutcome>((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<ReturnType<typeof useAppStore.getState>>)
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<ReturnType<typeof useAppStore.getState>>)
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([])
})
})
@@ -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' } }])
})
})
@@ -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<WorktreeAgentActivationOutcome>,
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)
}
})
}
@@ -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<string, number> {
const counts: Record<string, number> = {}
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
})
})
})
@@ -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<string>
): 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<string> {
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)
}
@@ -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<string, { worktreeId: string }>
tabsByWorktree: Record<string, unknown[]>
unifiedTabsByWorktree: Record<string, FakeUnifiedTab[]>
remoteWorkspaceHydratedTargetIds: Set<string>
remoteWorkspaceSyncStatusByTargetId: Record<string, { phase: 'offline' | 'synced' }>
reconcileWorktreeTabModel: (workspaceKey: string) => {
renderableTabCount: number
activeRenderableTabId: string | null
}
createTab: ReturnType<typeof vi.fn>
}
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<string>(),
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> = {}
): 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<unknown> | 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()
})
})
@@ -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<WorkspaceActivationRecoveryResult> {
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')
}
}
@@ -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')
})
})
@@ -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)
}
}
@@ -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
}
@@ -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)
})
})
@@ -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())
})
})
@@ -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
}
@@ -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', () => {
+130 -127
View File
@@ -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)
}
}
/**
@@ -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(
@@ -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'
@@ -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)
})
})
@@ -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)
}
@@ -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> = {}): 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', {
@@ -1,32 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
state: {
pendingWorktreeCreations: { 'creation-1': {} } as Record<string, unknown>
},
listener: null as ((state: { pendingWorktreeCreations: Record<string, unknown> }) => 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<string, unknown>
reconcileWorktreeTabModel: () => {
renderableTabCount: number
activeRenderableTabId: string
}
allWorktrees?: () => { id: string; path: string }[]
repos?: { id: string; connectionId: string }[]
updateWorktreeMeta?: ReturnType<typeof vi.fn>
}
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<string, unknown> }) => 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]
)
@@ -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.
@@ -94,6 +94,7 @@ vi.mock('sonner', () => ({
}))
vi.mock('@/i18n/i18n', () => ({
i18n: { language: 'en' },
translate: (_key: string, fallback: string) => fallback
}))
@@ -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)) {
@@ -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<string, string> }
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,