diff --git a/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts b/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts index 673f77d5ad6..3ad8efb2ad6 100644 --- a/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts +++ b/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' +import { getRemoteConnectionIdForWorktree } from '@/store/terminals/terminal-workspace-routing' import { activateAndRevealFolderWorkspace, activateAndRevealWorkspace, @@ -75,10 +76,7 @@ describe('activating a workspace whose last terminal was closed', () => { } ) - // Why: an agent selection suppresses the *pre-emptive* seed at create time, but the gate's - // `empty` outcome is its evidence that the agent surface never arrived (dead PTY, unreadable - // census, null startup plan). Suppressing the re-seed on that evidence leaves the workspace with - // no tabs and no recovery; only an explicit caller promise may. See #19940 and STA-5701. + // An empty gate invalidates agent-based suppression; only an explicit caller surface survives. it.each([ ['an agent selection', { agent: 'codex' as const }, 1], [ @@ -344,31 +342,45 @@ describe('activating a folder workspace whose last terminal was closed', () => { } ) - // Why: folder workspaces activate through their own entry point — setActiveWorktree(folderKey) - // never reaches it — so the split has to be pinned on this path too, not just the git one. + // This must use the folder entry point; setActiveWorktree(folderWorkspaceKey(...)) takes the git path. it.each([ - ['an agent selection', { agent: 'codex' as const }, 1], + ['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, expectedTabs) => { - seedEmptiedFolderWorkspaceOnTwoHosts() - useAppStore.setState({ - sleepingAgentSessionsByPaneKey: { - 'pane-1': { worktreeId: FOLDER_KEY } - } as never - }) - const gate = vi.spyOn(activationGate, 'gateWorktreeAgentActivation') - gate.mockResolvedValue('empty') + ])( + 're-seeds a gate-reported empty workspace for %s', + async (_label, selection, executionHostId, expectedTabs) => { + seedEmptiedFolderWorkspaceOnTwoHosts() + const folderWorkspaces = useAppStore + .getState() + .folderWorkspaces.filter((workspace) => workspace.executionHostId === executionHostId) + useAppStore.setState({ + folderWorkspaces, + sleepingAgentSessionsByPaneKey: { + 'pane-1': { worktreeId: FOLDER_KEY } + } as never + }) + const gate = vi.spyOn(activationGate, 'gateWorktreeAgentActivation') + gate.mockResolvedValue('empty') - activateAndRevealFolderWorkspace(FOLDER_ID, { ...selection, executionHostId: 'local' }) - await gate.mock.results[0]?.value + activateAndRevealFolderWorkspace(FOLDER_ID, { ...selection, executionHostId }) + await gate.mock.results[0]?.value - expect(useAppStore.getState().activeWorktreeId).toBe(FOLDER_KEY) - expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY] ?? []).toHaveLength(expectedTabs) - }) + expect(useAppStore.getState().activeWorktreeId).toBe(FOLDER_KEY) + expect(useAppStore.getState().activeWorkspaceExecutionHostId).toBe(executionHostId) + expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY] ?? []).toHaveLength(expectedTabs) + if (expectedTabs > 0) { + expect(getRemoteConnectionIdForWorktree(useAppStore.getState(), FOLDER_KEY)).toBe( + executionHostId === 'local' ? null : 'conn-1' + ) + } + } + ) it('re-seeds a terminal when the workspace is opened', () => { seedEmptiedFolderWorkspaceOnTwoHosts() diff --git a/src/renderer/src/lib/worktree-activation-surface-selection.ts b/src/renderer/src/lib/worktree-activation-surface-selection.ts index 6caad293ddf..55864ee3d6a 100644 --- a/src/renderer/src/lib/worktree-activation-surface-selection.ts +++ b/src/renderer/src/lib/worktree-activation-surface-selection.ts @@ -32,20 +32,17 @@ export type WorktreeActivationOptions = WorktreeActivationSurfaceSelection & { clearSidebarFilters?: boolean } -/** Create time: a shell seeded now would land beside the agent/chat surface this selection is - * about to open, so an agent selection counts as a promised surface (#19940). */ export function activationProvidesInitialSurface( selection?: WorktreeActivationSurfaceSelection ): boolean { return selection?.providesInitialSurface === true || selection?.agent != null } -/** Deliberately narrower than `activationProvidesInitialSurface`: the async gate's `empty` - * outcome is evidence the agent surface never materialised (dead PTY, unreadable census, null - * startup plan), so only an explicit caller promise may suppress the fail-closed re-seed — - * honouring the agent half strands the workspace with zero tabs and no recovery (STA-5701). */ -export function gatedEmptyOutcomeReseedSuppressed( +export type GatedEmptyWorkspaceReseedPolicy = 'reseed' | 'caller-provides-surface' + +/** An empty gate invalidates agent selection; only an explicit caller surface suppresses reseeding. */ +export function gatedEmptyWorkspaceReseedPolicy( selection?: WorktreeActivationSurfaceSelection -): boolean { - return selection?.providesInitialSurface === true +): GatedEmptyWorkspaceReseedPolicy { + return selection?.providesInitialSurface === true ? 'caller-provides-surface' : 'reseed' } diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 06d028579bf..a6ac72ad5a9 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -33,7 +33,7 @@ import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-wor import { applyWorktreeNavViewEntry } from '@/lib/worktree-nav-view-history-replay' import { activationProvidesInitialSurface, - gatedEmptyOutcomeReseedSuppressed, + gatedEmptyWorkspaceReseedPolicy, type WorktreeActivationOptions, type WorktreeActivationSurfaceSelection } from './worktree-activation-surface-selection' @@ -150,8 +150,8 @@ export function activateAndRevealFolderWorkspace( } if (shouldGateAgentActivation) { void gateWorktreeAgentActivation(workspaceKey).then((outcome) => { - if (outcome === 'empty') { - reseedGatedEmptyWorkspace(workspaceKey, gatedEmptyOutcomeReseedSuppressed(opts)) + if (outcome === 'empty' && gatedEmptyWorkspaceReseedPolicy(opts) === 'reseed') { + reseedGatedEmptyWorkspace(workspaceKey, false) } }) } @@ -249,8 +249,8 @@ export function activateAndRevealWorktree( } if (shouldGateAgentActivation) { void gateWorktreeAgentActivation(worktreeId).then((outcome) => { - if (outcome === 'empty') { - reseedGatedEmptyWorkspace(worktreeId, gatedEmptyOutcomeReseedSuppressed(opts)) + if (outcome === 'empty' && gatedEmptyWorkspaceReseedPolicy(opts) === 'reseed') { + reseedGatedEmptyWorkspace(worktreeId, false) } }) } diff --git a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts index ae88bab1546..e36a79cb364 100644 --- a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts +++ b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts @@ -36,9 +36,7 @@ function getSetupRunnerCommandPlatformForLaunch(setup: WorktreeSetupLaunch): 'wi } /** After the async activation gate reports an empty workspace: re-seed a shell unless the caller - * promised its own surface or the user has already moved on. - * Callers must derive the flag from `gatedEmptyOutcomeReseedSuppressed`, never from an agent - * selection — `empty` means that agent surface never arrived. */ + * promised its own surface or the user has already moved on. */ export function reseedGatedEmptyWorkspace( workspaceKey: string, callerProvidesSurface: boolean | undefined