diff --git a/src/renderer/src/lib/workspace-terminal-host-authority.test.ts b/src/renderer/src/lib/workspace-terminal-host-authority.test.ts index c7c5bc60f7e..e4a23a9eef3 100644 --- a/src/renderer/src/lib/workspace-terminal-host-authority.test.ts +++ b/src/renderer/src/lib/workspace-terminal-host-authority.test.ts @@ -225,4 +225,134 @@ describe('workspace terminal seeding authority', () => { expect(ensureWorktreeHasInitialTerminal(store.getState(), PAIRED_WORKTREE_ID)).toBeNull() expect(terminalTabCount(store, PAIRED_WORKTREE_ID)).toBe(0) }) + + it('does not seed a runtime-unnameable workspace even for a gate-approved reseed', () => { + // Why: a runtime host that cannot be named also reports `unverifiable`, and the gate reports + // `empty` there (its scoped census is undefined, so it falls back to the local inventory). + // Seeding would plant a client terminal beside the host's own — the #15556 double-write. + const store = createTestStore() + store.setState({ + repos: [repo('repoPaired', '/srv/proj')], + worktreesByRepo: {}, + tabsByWorktree: { [PAIRED_WORKTREE_ID]: [] }, + detectedWorktreesByRepo: { + repoPaired: { + authoritative: true, + worktrees: [ + makeWorktree({ + id: PAIRED_WORKTREE_ID, + repoId: 'repoPaired', + path: '/srv/proj/paired', + hostId: `runtime:${ENVIRONMENT_ID}`, + runtimeOwnerEnvironmentId: ENVIRONMENT_ID + } as never) + ] + }, + repoPairedRival: { + authoritative: true, + worktrees: [ + makeWorktree({ + id: PAIRED_WORKTREE_ID, + repoId: 'repoPairedRival', + path: '/srv/proj/paired', + hostId: 'local' + } as never) + ] + } + } as never + }) + + expect(resolveWorkspaceTerminalHostAuthority(store.getState(), PAIRED_WORKTREE_ID)).toBe( + 'unverifiable' + ) + expect( + ensureWorktreeHasInitialTerminal( + store.getState(), + PAIRED_WORKTREE_ID, + undefined, + undefined, + undefined, + undefined, + { reseedEmptiedWorkspace: true, gateApprovedReseed: true } + ) + ).toBeNull() + expect(terminalTabCount(store, PAIRED_WORKTREE_ID)).toBe(0) + }) + + it('seeds an explicitly reopened empty SSH workspace once the gate approved the reseed', () => { + // Why: closing the last tab leaves an explicit empty row (tombstone). Direct activation must + // not seed while the host is unanswered (STA-4658), but a gate-approved reseed of an + // explicitly reopened direct-SSH workspace proceeds instead of stranding it with no surface + // (no-terminal-awaken). Runtime-unnameable hosts also report `unverifiable` but stay excluded + // (#15556, covered below). + const store = createTestStore() + seedDirectSsh(store) + store.setState({ tabsByWorktree: { [SSH_WORKTREE_ID]: [] } }) + + expect(resolveWorkspaceTerminalHostAuthority(store.getState(), SSH_WORKTREE_ID)).toBe( + 'unverifiable' + ) + expect( + ensureWorktreeHasInitialTerminal( + store.getState(), + SSH_WORKTREE_ID, + undefined, + undefined, + undefined, + undefined, + { + reseedEmptiedWorkspace: true + } + ) + ).toBeNull() + expect(terminalTabCount(store, SSH_WORKTREE_ID)).toBe(0) + + const tabId = ensureWorktreeHasInitialTerminal( + store.getState(), + SSH_WORKTREE_ID, + undefined, + undefined, + undefined, + undefined, + { reseedEmptiedWorkspace: true, gateApprovedReseed: true } + ) + expect(tabId).toBeTruthy() + expect(terminalTabCount(store, SSH_WORKTREE_ID)).toBe(1) + }) + + it('never seeds a live runtime workspace even when the gate approved the reseed', () => { + const store = createTestStore() + store.setState({ + repos: [repo('repoPaired', '/srv/proj')], + worktreesByRepo: { + repoPaired: [ + makeWorktree({ + id: PAIRED_WORKTREE_ID, + repoId: 'repoPaired', + path: '/srv/proj/paired', + hostId: `runtime:${ENVIRONMENT_ID}`, + runtimeOwnerEnvironmentId: ENVIRONMENT_ID + } as never) + ] + }, + tabsByWorktree: { [PAIRED_WORKTREE_ID]: [] } + }) + + expect(resolveWorkspaceTerminalHostAuthority(store.getState(), PAIRED_WORKTREE_ID)).toBe('live') + expect( + ensureWorktreeHasInitialTerminal( + store.getState(), + PAIRED_WORKTREE_ID, + undefined, + undefined, + undefined, + undefined, + { + reseedEmptiedWorkspace: true, + gateApprovedReseed: true + } + ) + ).toBeNull() + expect(terminalTabCount(store, PAIRED_WORKTREE_ID)).toBe(0) + }) }) diff --git a/src/renderer/src/lib/workspace-terminal-host-authority.ts b/src/renderer/src/lib/workspace-terminal-host-authority.ts index ad535e94563..76c91606a12 100644 --- a/src/renderer/src/lib/workspace-terminal-host-authority.ts +++ b/src/renderer/src/lib/workspace-terminal-host-authority.ts @@ -112,6 +112,24 @@ export function resolveWorkspaceTerminalHostAuthority( return 'none' } +/** + * True only for git worktrees whose execution host is direct-SSH (folder workspaces excluded). + * A runtime host that cannot be named also resolves `unverifiable`, but the host owns creation + * there — it must never be treated as the SSH-sync-in-flight case (#15556). + */ +export function isDirectSshWorkspace( + state: WorkspaceTerminalHostAuthorityState, + worktreeId: string | null | undefined +): boolean { + if (!worktreeId || worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + return false + } + return ( + parseExecutionHostId(getExecutionHostIdForWorktree(state, worktreeId))?.kind === 'ssh' && + parseWorkspaceKey(worktreeId)?.type !== 'folder' + ) +} + /** * Every slice the resolution above reads. Resolution walks the owner catalogs (and, for an active * `ssh:` selection, `worktreesByRepo` uncached via resolveSelectedHostRoute), so a Zustand selector diff --git a/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts b/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts index 4d2b43e26bd..0775b5ee6e8 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 @@ -6,6 +6,7 @@ import { activateAndRevealWorktree } from './worktree-activation' import * as activationGate from './worktree-agent-activation-gate' +import { resolveWorkspaceTerminalHostAuthority } from './workspace-terminal-host-authority' import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding' import { folderWorkspaceKey } from '../../../shared/workspace-scope' import { toSshExecutionHostId } from '../../../shared/execution-host' @@ -176,6 +177,106 @@ describe('activating a workspace whose last terminal was closed', () => { expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1) }) + // Why: the wiring this covers is `gateAndReseedEmptyWorkspace` passing the gate-approved flag + // for `empty` — driving `ensureWorktreeHasInitialTerminal` directly would stay green if that + // argument were dropped. + it('seeds an explicitly reopened empty SSH workspace through a mocked empty gate', async () => { + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + seedClosedLastTerminal(worktree.id) + useAppStore.setState({ + worktreesByRepo: { + // Why Object.assign and not a spread literal: the helper's row type carries no host + // field, and a spread literal with an extra prop trips consistent-type-assertions. + [worktree.repoId]: (useAppStore.getState().worktreesByRepo[worktree.repoId] ?? []).map( + (w) => (w.id === worktree.id ? Object.assign({}, w, { hostId: 'ssh:target-1' }) : w) + ) + }, + sleepingAgentSessionsByPaneKey: { + 'pane-1': { worktreeId: worktree.id } + } as never + }) + expect(resolveWorkspaceTerminalHostAuthority(useAppStore.getState(), worktree.id)).toBe( + 'unverifiable' + ) + const gate = vi.spyOn(activationGate, 'gateWorktreeAgentActivation') + gate.mockResolvedValue('empty') + + activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false }) + await gate.mock.results[0]?.value + + expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1) + }) + + // Why: `blocked-census-unavailable` means the PTY census itself could not answer — no + // structured surface is known, so with no sleeping sessions the local authority check still + // decides (`none` reseeds) instead of stranding an explicitly opened empty workspace + // (no-terminal-awaken). + it('re-seeds a local empty workspace with no sleeping sessions when the census is unavailable', async () => { + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + seedClosedLastTerminal(worktree.id) + // Why: without a runtime/pty inventory the gate never triggers in tests, so the sync path + // would seed before the mocked `blocked` outcome is even reached. Stub the inventory surface + // so this exercises the gate's blocked fallback, as in production. + vi.stubGlobal('window', { + api: { + runtime: { call: vi.fn() }, + pty: { listSessions: vi.fn() } + } + }) + const gate = vi.spyOn(activationGate, 'gateWorktreeAgentActivation') + gate.mockResolvedValue('blocked-census-unavailable') + + activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false }) + await gate.mock.results[0]?.value + + expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1) + }) + + // Why: plain `blocked` covers unrestored sessions and inconsistent structured ownership — + // cases where the renderer does not yet know what surfaces exist (e.g. an ownerless + // `agent-session` the runtime is about to project). Seeding there would plant the stray + // shell beside the chat that appears moments later. + it('leaves an empty workspace alone when the gate reports plain blocked', async () => { + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + seedClosedLastTerminal(worktree.id) + vi.stubGlobal('window', { + api: { + runtime: { call: vi.fn() }, + pty: { listSessions: vi.fn() } + } + }) + const gate = vi.spyOn(activationGate, 'gateWorktreeAgentActivation') + gate.mockResolvedValue('blocked') + + activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false }) + await gate.mock.results[0]?.value + + expect(useAppStore.getState().tabsByWorktree[worktree.id]).toEqual([]) + }) + + // Why: the census-unavailable fallback must still defer to known sleeping sessions — + // reseeding there would add the stray shell beside the session the gate was protecting. + it('leaves a sleeping workspace alone when the census is unavailable', async () => { + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + seedClosedLastTerminal(worktree.id) + useAppStore.setState({ + sleepingAgentSessionsByPaneKey: { + 'pane-1': { worktreeId: worktree.id } + } as never + }) + const gate = vi.spyOn(activationGate, 'gateWorktreeAgentActivation') + gate.mockResolvedValue('blocked-census-unavailable') + + activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false }) + await gate.mock.results[0]?.value + + expect(useAppStore.getState().tabsByWorktree[worktree.id]).toEqual([]) + }) + // 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', () => { diff --git a/src/renderer/src/lib/worktree-activation-gated-empty-reseed.ts b/src/renderer/src/lib/worktree-activation-gated-empty-reseed.ts index bb597d10e6e..8e54ac75e8e 100644 --- a/src/renderer/src/lib/worktree-activation-gated-empty-reseed.ts +++ b/src/renderer/src/lib/worktree-activation-gated-empty-reseed.ts @@ -1,6 +1,8 @@ import type { ExecutionHostId } from '../../../shared/execution-host' +import { useAppStore } from '@/store' import { gateWorktreeAgentActivation, + workspaceHasSleepingAgentSessions, type WorktreeAgentActivationOutcome } from './worktree-agent-activation-gate' import { reseedGatedEmptyWorkspace } from './worktree-initial-terminal-seeding' @@ -32,7 +34,26 @@ export function gateAndReseedEmptyWorkspace( } latestReseedIntentByGate.delete(gate) if (outcome === 'empty') { - reseedGatedEmptyWorkspace(workspaceKey, intent.callerProvidesSurface, intent.executionHostId) + reseedGatedEmptyWorkspace( + workspaceKey, + intent.callerProvidesSurface, + intent.executionHostId, + true + ) + } else if (outcome === 'blocked-census-unavailable') { + // Why only this blocked flavor: the PTY census itself could not answer, as distinct from + // the renderer not yet knowing what surfaces exist (unrestored sessions, structured reads + // that threw, inconsistent structured ownership). With no sleeping sessions known, the local + // authority check still decides: `none` reseeds instead of stranding an explicitly opened + // empty workspace, while `unverifiable`/`live` stay unseeded without the gate's positive + // empty verdict (STA-4658, #15556). + if (!workspaceHasSleepingAgentSessions(useAppStore.getState(), workspaceKey)) { + reseedGatedEmptyWorkspace( + workspaceKey, + intent.callerProvidesSurface, + intent.executionHostId + ) + } } }) } diff --git a/src/renderer/src/lib/worktree-activation-store-contract.ts b/src/renderer/src/lib/worktree-activation-store-contract.ts index 63ce180ffed..f8a45697794 100644 --- a/src/renderer/src/lib/worktree-activation-store-contract.ts +++ b/src/renderer/src/lib/worktree-activation-store-contract.ts @@ -75,4 +75,14 @@ export type InitialTerminalOptions = { * 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 + /** Set only by the async activation gate after it finished without producing a surface for an + * explicitly reopened empty workspace. Lets the reseed proceed for direct-SSH workspaces whose + * workspace-sync hydration still reports `unverifiable`. This is not proof the host holds no + * terminals — `empty` is the gate's "produced no surface, seed one" directive (it also arises + * from the detached-relay unscoped fallback and declined live-PT adoption) — only a bounded + * exception for the SSH-sync-in-flight case, where stranding with no surface is worse than one + * fresh shell. Never honored for `live` or runtime-unnameable hosts: the host owns creation + * there (#15556). The `empty` branch passes the flag for every host kind; the predicate + * honors it only for direct-SSH. */ + gateApprovedReseed?: boolean } diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts index f80c1e50337..35f025e2a3c 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts @@ -268,6 +268,85 @@ describe('worktree agent activation gate', () => { expect(resume).not.toHaveBeenCalled() }) + it('reports census-unavailable when the PTY census itself throws', async () => { + const { deps, createTab, resume } = testDeps({}) + deps.listSessions.mockRejectedValue(new Error('No PTY provider for connection: gone')) + + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe( + 'blocked-census-unavailable' + ) + + expect(createTab).not.toHaveBeenCalled() + expect(resume).not.toHaveBeenCalled() + }) + + it('keeps a native-only structured inventory structured without seeding', async () => { + // Why: native owners name the surface hydration will project, so the gate must resolve + // `structured` — never a seed — while still running the census so adoption can mint + // coexisting live shells. + const { deps, createTab, resume } = testDeps({}) + const hasStructuredSession = vi.fn(async () => ({ + snapshot: runtimeSnapshot('tab-1', LIVE_LEAF_ID, []), + ownerBySessionId: new Map([['live-session', { owner: 'native' as const }]]) + })) + + await expect( + runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, hasStructuredSession }) + ).resolves.toBe('structured') + + expect(deps.listSessions).toHaveBeenCalledOnce() + expect(createTab).not.toHaveBeenCalled() + expect(resume).not.toHaveBeenCalled() + }) + + it('stays blocked when the census rejects with native owners on record', async () => { + // Why: with structured owners on record the census is load-bearing validation — its + // failure must stay conservative instead of becoming the reseedable flavor. + const { deps, createTab, resume } = testDeps({}) + deps.listSessions.mockRejectedValue(new Error('gone')) + const hasStructuredSession = vi.fn(async () => ({ + snapshot: runtimeSnapshot('tab-1', LIVE_LEAF_ID, []), + ownerBySessionId: new Map([['live-session', { owner: 'native' as const }]]) + })) + + await expect( + runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, hasStructuredSession }) + ).resolves.toBe('blocked') + + expect(createTab).not.toHaveBeenCalled() + expect(resume).not.toHaveBeenCalled() + }) + + it('stays blocked when the census rejects with TUI owners on record', async () => { + // Why: TUI terminals need census validation to bind — a rejected census must not become the + // reseedable flavor, or a shell lands beside the chat once its PTY answers. + const { deps, createTab, resume } = testDeps({}) + deps.listSessions.mockRejectedValue(new Error('gone')) + const hasStructuredSession = vi.fn(async () => ({ + snapshot: runtimeSnapshot('surf-tab', LIVE_LEAF_ID, []), + ownerBySessionId: new Map([ + [ + 'live-session', + { + owner: 'tui' as const, + terminal: { + paneKey: `surf-tab:${LIVE_LEAF_ID}`, + ptyId: `${WORKTREE_ID}@@live-agent`, + tabId: 'surf-tab' + } + } + ] + ]) + })) + + await expect( + runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, hasStructuredSession }) + ).resolves.toBe('blocked') + + expect(createTab).not.toHaveBeenCalled() + expect(resume).not.toHaveBeenCalled() + }) + it('adopts a live daemon PTY before activation can resume another agent', async () => { const ptyId = `${WORKTREE_ID}@@live-pty` const { deps, createTab, resume } = testDeps({ sessions: [listed(ptyId)] }) diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.ts b/src/renderer/src/lib/worktree-agent-activation-gate.ts index 6ae016512ec..cfc5bc8775b 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.ts @@ -45,6 +45,12 @@ export type WorktreeAgentActivationOutcome = | 'resumed' | 'empty' | 'blocked' + // Why a second blocked flavor instead of a boolean: the reseed caller must tell "the census + // itself could not answer" apart from "the renderer does not yet know what surfaces exist" + // (unrestored sessions, structured reads that threw, inconsistent structured ownership). + // Only the former may reseed a local shell; the latter would plant the stray terminal beside a + // chat that is about to appear. + | 'blocked-census-unavailable' const inFlightByWorktreeId = new Map>() const WORKSPACE_SESSION_READY_TIMEOUT_MS = 30_000 @@ -191,8 +197,14 @@ export async function runWorktreeAgentActivationGate( try { sessions = await deps.listSessions() } catch { - // Inventory uncertainty cannot authorize a second writer. - return 'blocked' + // Why `blocked` when structured owners are on record: the census is load-bearing validation + // for them — TUI terminals need it to bind, and it still lets adoption mint coexisting live + // shells — so its failure stays conservative. Only a census with no structured context gets + // the reseedable flavor. + if (structuredInventory) { + return 'blocked' + } + return 'blocked-census-unavailable' } // Why either signal rather than a preference: a relay row's worktreeId can be seeded from the diff --git a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts index f9c2844d472..d03d07fc42c 100644 --- a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts +++ b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts @@ -9,7 +9,10 @@ import { getSetupRunnerCommandPlatformForPath } from '../../../shared/setup-runn import { agentKindToTuiAgent } from '../../../shared/agent-kind' import { useAppStore } from '@/store' import { queueHookCommandsForFirstWorktreeTab } from '@/lib/hook-command-delayed-delivery' -import { resolveWorkspaceTerminalHostAuthority } from '@/lib/workspace-terminal-host-authority' +import { + isDirectSshWorkspace, + resolveWorkspaceTerminalHostAuthority +} from '@/lib/workspace-terminal-host-authority' import { initialAgentTabViewModeProps } from './native-chat-initial-view-mode' import { getConnectionId } from '@/lib/connection-context' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' @@ -40,7 +43,8 @@ function getSetupRunnerCommandPlatformForLaunch(setup: WorktreeSetupLaunch): 'wi export function reseedGatedEmptyWorkspace( workspaceKey: string, callerProvidesSurface: boolean, - executionHostId?: ExecutionHostId + executionHostId?: ExecutionHostId, + gateApprovedReseed?: boolean ): void { const state = useAppStore.getState() if ( @@ -58,7 +62,8 @@ export function reseedGatedEmptyWorkspace( undefined, undefined, { - reseedEmptiedWorkspace: true + reseedEmptiedWorkspace: true, + ...(gateApprovedReseed === true ? { gateApprovedReseed: true } : {}) } ) } @@ -177,9 +182,17 @@ export function ensureWorktreeHasInitialTerminal( 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. + // a terminal now, so it stays ungated. A gate-approved reseed is the one exception, and only for + // direct-SSH workspaces: the gate finished without producing a surface, and stranding an + // explicitly reopened empty workspace is worse than one fresh shell there. A runtime host that + // cannot be named also reports `unverifiable`, but the host owns creation there, so it stays + // excluded (#15556). `live` stays excluded for the same reason. const shouldAutoCreate = - hostAuthority === 'none' && + (hostAuthority === 'none' || + (opts?.gateApprovedReseed === true && + opts?.reseedEmptiedWorkspace === true && + hostAuthority === 'unverifiable' && + isDirectSshWorkspace(ownerState, worktreeId))) && shouldAutoCreateInitialTerminal(renderableTabCount, shouldHonourClosedTerminalTombstone) const shouldCreateForExplicitWork = renderableTabCount === 0 && hasExplicitLaunchWork const shouldCreateNewStartupTerminal =