fix(skills): fail closed during SSH owner hydration

This commit is contained in:
Brennan Benson
2026-08-05 18:50:13 -07:00
parent 5d7733ebe7
commit 851ea077d5
5 changed files with 172 additions and 13 deletions
@@ -19,6 +19,7 @@ export type NativeChatFolderSshRouteResolution =
environmentId: string | null
}
| { kind: 'ambiguous' }
| { kind: 'non-ssh'; ownerHostId: ExecutionHostId | null }
| { kind: 'missing'; ownerHostId?: ExecutionHostId | null }
type FolderSshRoute = Extract<NativeChatFolderSshRouteResolution, { kind: 'resolved' }>
@@ -29,6 +30,7 @@ export function resolveNativeChatFolderSshRoute(
): NativeChatFolderSshRouteResolution {
const routes = new Map<string, FolderSshRoute>()
const nonSshOwnerHostIds: (ExecutionHostId | null)[] = []
const unresolvedOwnerHostIds: (ExecutionHostId | null)[] = []
const activeHost =
state.activeWorktreeId === folderWorkspaceKey(folderWorkspaceId)
? parseExecutionHostId(state.activeWorkspaceExecutionHostId)
@@ -45,24 +47,22 @@ export function resolveNativeChatFolderSshRoute(
const groupHost = parseExecutionHostId(group.executionHostId)
return !workspaceHost || !groupHost || workspaceHost.id === groupHost.id
})
const owners = groups.length > 0 ? groups : [null]
for (const group of owners) {
for (const group of groups.length > 0 ? groups : [null]) {
const route = routeForFolderOwners(workspace, group)
if (route.kind === 'ambiguous') {
return route
}
if (route.kind === 'resolved') {
routes.set(JSON.stringify([route.hostId, route.environmentId]), route)
} else if (!group) {
unresolvedOwnerHostIds.push(route.ownerHostId ?? null)
} else if (route.kind === 'non-ssh') {
nonSshOwnerHostIds.push(route.ownerHostId)
} else {
nonSshOwnerHostIds.push(route.ownerHostId ?? null)
unresolvedOwnerHostIds.push(route.ownerHostId ?? null)
}
}
}
if (routes.size === 0) {
return activeHost?.kind === 'ssh' && nonSshOwnerHostIds.length > 0
? { kind: 'ambiguous' }
: { kind: 'missing' }
}
const candidates = [...routes.values()].filter((route) => {
if (!activeHost) {
return true
@@ -73,10 +73,37 @@ export function resolveNativeChatFolderSshRoute(
? activeHost.environmentId === route.environmentId
: false
})
const hasRelevantNonSshOwner = nonSshOwnerHostIds.some(
(ownerHostId) => !activeHost || !ownerHostId || ownerHostId === activeHost.id
const ownerMatchesActiveHost = (ownerHostId: ExecutionHostId | null): boolean =>
!activeHost || !ownerHostId || ownerHostId === activeHost.id
const relevantNonSshOwnerHostIds = nonSshOwnerHostIds.filter(ownerMatchesActiveHost)
const relevantUnresolvedOwnerHostIds = unresolvedOwnerHostIds.filter(ownerMatchesActiveHost)
const evidenceCount = routes.size + nonSshOwnerHostIds.length + unresolvedOwnerHostIds.length
if (
activeHost &&
candidates.length === 0 &&
relevantNonSshOwnerHostIds.length === 0 &&
relevantUnresolvedOwnerHostIds.length === 0 &&
evidenceCount > 0
) {
return { kind: 'ambiguous' }
}
if (candidates.length > 0) {
return candidates.length === 1 &&
relevantNonSshOwnerHostIds.length === 0 &&
relevantUnresolvedOwnerHostIds.length === 0
? candidates[0]
: { kind: 'ambiguous' }
}
if (relevantUnresolvedOwnerHostIds.length > 0) {
return { kind: 'missing' }
}
const nonSshHostIds = new Set(
relevantNonSshOwnerHostIds.map((ownerHostId) => ownerHostId ?? 'unresolved')
)
return candidates.length === 1 && !hasRelevantNonSshOwner ? candidates[0] : { kind: 'ambiguous' }
if (nonSshHostIds.size === 1) {
return { kind: 'non-ssh', ownerHostId: relevantNonSshOwnerHostIds[0] ?? null }
}
return nonSshHostIds.size > 1 ? { kind: 'ambiguous' } : { kind: 'missing' }
}
function routeForFolderOwners(
@@ -120,7 +147,7 @@ function routeForFolderOwners(
hostId = logicalHost.id
}
if (!hostId) {
return { kind: 'missing', ownerHostId: logicalHost?.id ?? null }
return { kind: 'non-ssh', ownerHostId: logicalHost?.id ?? null }
}
const host = parseExecutionHostId(hostId)
if (host?.kind !== 'ssh') {
@@ -114,6 +114,13 @@ export function resolveNativeChatSkillDiscoveryContext(
if (folderSshRoute.kind === 'ambiguous') {
return null
}
if (
workspaceScope?.type === 'folder' &&
parsedHost?.kind === 'runtime' &&
folderSshRoute.kind === 'missing'
) {
return null
}
const sshHost =
folderSshRoute.kind === 'resolved'
? parseExecutionHostId(folderSshRoute.hostId)
@@ -14,7 +14,10 @@ import { resolveExactWorktreeRoute } from '@/lib/worktree-owner-route'
type WorktreeSshTransportState = Pick<
AppState,
'detectedWorktreesByRepo' | 'repos' | 'worktreesByRepo'
| 'detectedWorktreesByRepo'
| 'repos'
| 'restoredRuntimeHostIdByWorkspaceSessionKey'
| 'worktreesByRepo'
>
export type NativeChatWorktreeSshTransportResolution =
@@ -77,6 +80,16 @@ export function resolveNativeChatWorktreeSshTransport(
environmentIds.add(executionHost?.kind === 'runtime' ? executionHost.environmentId : null)
}
}
const restoredHostId =
state.restoredRuntimeHostIdByWorkspaceSessionKey[rawWorktreeId] ??
state.restoredRuntimeHostIdByWorkspaceSessionKey[worktreeId]
if (restoredHostId) {
const restoredHost = parseExecutionHostId(restoredHostId)
if (restoredHost?.kind !== 'runtime') {
return { kind: 'ambiguous' }
}
environmentIds.add(restoredHost.environmentId)
}
if (environmentIds.size > 1) {
return { kind: 'ambiguous' }
}
@@ -282,6 +282,25 @@ describe('useNativeChatSkills', () => {
)
})
it('issues no RPC while a restored runtime folder catalog is missing', async () => {
const worktreeId = 'folder:folder-1'
mocks.state = {
...stateForHost('runtime:hub-a'),
activeWorktreeId: worktreeId,
folderWorkspaces: [],
projectGroups: [],
restoredRuntimeHostIdByWorkspaceSessionKey: { [worktreeId]: 'runtime:hub-a' },
tabsByWorktree: {
[worktreeId]: [{ id: 'tab-1', startupCwd: '/possibly-remote/folder' }]
},
worktreesByRepo: {}
}
render(<Probe enabled />)
await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('idle'))
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
})
it('issues no RPC for an ambiguous direct and paired SSH pane', async () => {
mocks.state = {
...stateForHost('ssh:shared-target'),
@@ -389,6 +389,27 @@ describe('resolveNativeChatSkillDiscoveryContext for SSH panes', () => {
expect(context.paneTarget).toEqual({ worktreeId, terminalTabId: 'tab-1' })
})
it('uses a restored paired runtime owner before the worktree catalog hydrates', () => {
const worktreeId = 'repo-1::/remote/repo'
const context = resolveNativeChatSkillDiscoveryContext(
selectedSshInputs({
activeWorkspaceExecutionHostId: 'ssh:private-target',
activeWorktreeId: worktreeId,
repos: [],
restoredRuntimeHostIdByWorkspaceSessionKey: { [worktreeId]: 'runtime:hub-a' },
sshConnectionStates: new Map([
['private-target', connectionState({ targetId: 'private-target' })]
]),
tabsByWorktree: { [worktreeId]: [{ id: 'tab-1' }] },
worktreesByRepo: {}
}),
'tab-1'
)
expect(context?.executionHostKind).toBe('ssh')
expect(context?.runtimeTarget).toEqual({ kind: 'environment', environmentId: 'hub-a' })
})
it('uses the active SSH host while duplicate catalogs are still hydrating', () => {
const worktreeId = 'repo-1::/remote/repo'
const context = resolveNativeChatSkillDiscoveryContext(
@@ -670,6 +691,78 @@ describe('resolveNativeChatSkillDiscoveryContext for SSH panes', () => {
expect(context?.runtimeTarget).toEqual({ kind: 'environment', environmentId: 'hub-a' })
})
it('fails closed while a restored runtime folder catalog is missing', () => {
const worktreeId = 'folder:folder-1'
const context = resolveNativeChatSkillDiscoveryContext(
selectedSshInputs({
activeWorkspaceExecutionHostId: 'runtime:hub-a',
activeWorktreeId: worktreeId,
folderWorkspaces: [],
projectGroups: [],
repos: [],
restoredRuntimeHostIdByWorkspaceSessionKey: { [worktreeId]: 'runtime:hub-a' },
tabsByWorktree: {
[worktreeId]: [{ id: 'tab-1', startupCwd: '/possibly-remote/folder' }]
},
worktreesByRepo: {}
}),
'tab-1'
)
expect(context).toBeNull()
})
it('fails closed before a runtime folder project group hydrates', () => {
const worktreeId = 'folder:folder-1'
const context = resolveNativeChatSkillDiscoveryContext(
selectedSshInputs({
activeWorkspaceExecutionHostId: 'runtime:hub-a',
activeWorktreeId: worktreeId,
folderWorkspaces: [
{
id: 'folder-1',
projectGroupId: 'group-1',
folderPath: '/possibly-remote/folder',
executionHostId: 'runtime:hub-a'
}
],
projectGroups: [],
repos: [],
tabsByWorktree: { [worktreeId]: [{ id: 'tab-1' }] },
worktreesByRepo: {}
}),
'tab-1'
)
expect(context).toBeNull()
})
it('keeps a fully hydrated runtime-local folder on normal discovery', () => {
const worktreeId = 'folder:folder-1'
const context = resolveNativeChatSkillDiscoveryContext(
selectedSshInputs({
activeWorkspaceExecutionHostId: 'runtime:hub-a',
activeWorktreeId: worktreeId,
folderWorkspaces: [
{
id: 'folder-1',
projectGroupId: 'group-1',
folderPath: '/runtime/folder',
executionHostId: 'runtime:hub-a'
}
],
projectGroups: [{ id: 'group-1', executionHostId: 'runtime:hub-a' }],
repos: [],
tabsByWorktree: { [worktreeId]: [{ id: 'tab-1' }] },
worktreesByRepo: {}
}),
'tab-1'
)
expect(context?.executionHostKind).toBe('runtime')
expect(context?.runtimeTarget).toEqual({ kind: 'environment', environmentId: 'hub-a' })
})
it('keeps the same remote path on two SSH hosts cache-isolated', () => {
const onTargetTwo = resolveNativeChatSkillDiscoveryContext(
sshInputs({