Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions src/renderer/src/lib/workspace-terminal-host-authority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Comment thread
pullfrog[bot] marked this conversation as resolved.
Resolved
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)
})
})
18 changes: 18 additions & 0 deletions src/renderer/src/lib/workspace-terminal-host-authority.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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', () => {
Expand Down
23 changes: 22 additions & 1 deletion src/renderer/src/lib/worktree-activation-gated-empty-reseed.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)) {
Comment thread
pullfrog[bot] marked this conversation as resolved.
Resolved
reseedGatedEmptyWorkspace(
workspaceKey,
intent.callerProvidesSurface,
intent.executionHostId
)
}
}
})
}
10 changes: 10 additions & 0 deletions src/renderer/src/lib/worktree-activation-store-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading