refactor(workspaces): make gated reseed policy explicit

This commit is contained in:
Merge Sim
2026-09-11 19:10:27 -07:00
parent 10a21ab307
commit 5f01512ccf
4 changed files with 45 additions and 38 deletions
@@ -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()
@@ -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'
}
+5 -5
View File
@@ -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)
}
})
}
@@ -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