mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(ai-vault): resume a server session whose workspace was deleted; show remote hit previews (#33965)
Since #27214 every SSH session is retagged runtime:<env>, so the #24236 check that drops a deleted cwd (and its prebuilt `cd`) before resuming only ever ran for the legacy ssh: spelling. Resume on a paired or SSH-managed server therefore ran `cd '<deleted folder>' &&` and failed. The check now covers runtime hosts too, probing the folder with the existing files.browseServerDir RPC; only a definite "missing" answer drops the cwd, so an older or unreachable host keeps today's behaviour. Search-hit cards carry no turns and remote hosts cannot reparse a first prompt, so a server session found through search showed "No first prompt" and "No conversation preview". Hits now borrow the preview of their scanned history row.
This commit is contained in:
@@ -35,6 +35,7 @@ import { AgentSessionContinuationDialog } from '@/components/agent-session-conti
|
||||
import { AiVaultScanIssueBanners } from './AiVaultScanIssueBanners'
|
||||
import { useAiVaultSessionDeleteAction } from './ai-vault-session-delete-action'
|
||||
import { useAiVaultPanelSearch } from './use-ai-vault-search'
|
||||
import { withAiVaultHistoryPreviews } from './ai-vault-search-session'
|
||||
import { aiVaultSearchScopeIdentity } from './ai-vault-search-scope-identity'
|
||||
import { AiVaultPanelSearch } from './AiVaultPanelSearch'
|
||||
import { useAiVaultPanelSessionResume } from './use-ai-vault-panel-session-resume'
|
||||
@@ -159,7 +160,11 @@ export default function AiVaultPanel({
|
||||
)
|
||||
const search = useAiVaultPanelSearch(query, agents, searchWithin, executionHostScope, searchSort)
|
||||
const { searching, searchHits } = search
|
||||
const sessions = searching ? search.sessions : history
|
||||
const searchSessions = useMemo(
|
||||
() => withAiVaultHistoryPreviews(search.sessions, history),
|
||||
[history, search.sessions]
|
||||
)
|
||||
const sessions = searching ? searchSessions : history
|
||||
const requestedScanKey = aiVaultLocalScanKey({
|
||||
scopePaths,
|
||||
includeAntigravityIdeSessions: true,
|
||||
|
||||
@@ -3,9 +3,41 @@ import { searchHit } from '../../../../shared/ai-vault-search-test-fixture'
|
||||
import {
|
||||
aiVaultSearchHitToSession,
|
||||
canResumeAiVaultSearchHit,
|
||||
hasAiVaultSearchHitPath
|
||||
hasAiVaultSearchHitPath,
|
||||
withAiVaultHistoryPreviews
|
||||
} from './ai-vault-search-session'
|
||||
|
||||
describe('withAiVaultHistoryPreviews', () => {
|
||||
it("shows a paired server hit's conversation preview from its scanned history row", () => {
|
||||
// A relay hit arrives without its transcript path, so its row id differs from the scanned row's.
|
||||
const hitSession = aiVaultSearchHitToSession(
|
||||
{ ...searchHit(), source: { presence: 'present' as const } },
|
||||
'runtime:env-1'
|
||||
)
|
||||
const turns = [
|
||||
{ role: 'user' as const, text: 'Fix the flaky test', timestamp: null },
|
||||
{ role: 'assistant' as const, text: 'Done', timestamp: null }
|
||||
]
|
||||
|
||||
const [hydrated] = withAiVaultHistoryPreviews(
|
||||
[hitSession],
|
||||
[
|
||||
{
|
||||
...hitSession,
|
||||
id: 'runtime:env-1:codex:host-session:/host/transcript.jsonl',
|
||||
filePath: '/host/transcript.jsonl',
|
||||
previewMessages: turns,
|
||||
firstUserPrompt: 'Fix the flaky test'
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
expect(hydrated.previewMessages).toEqual(turns)
|
||||
expect(hydrated.firstUserPrompt).toBe('Fix the flaky test')
|
||||
expect(hydrated.resumeCommand).toBe('host-resume-command')
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiVaultSearchHitToSession', () => {
|
||||
it('uses the selected execution host and only host-provided source fields', () => {
|
||||
const hit = { ...searchHit(), executionHostId: 'local' }
|
||||
|
||||
@@ -34,6 +34,40 @@ export function aiVaultSearchHitToSession(
|
||||
}
|
||||
}
|
||||
|
||||
/** Hits carry no turns and remote hosts cannot reparse a first prompt, so borrow the scanned row's preview. */
|
||||
export function withAiVaultHistoryPreviews(
|
||||
hitSessions: AiVaultSession[],
|
||||
history: readonly AiVaultSession[]
|
||||
): AiVaultSession[] {
|
||||
if (hitSessions.length === 0 || history.length === 0) {
|
||||
return hitSessions
|
||||
}
|
||||
// Relay hits are redacted of their transcript path, so join on host, agent and session id; skip ambiguous ones.
|
||||
const historyByIdentity = new Map<string, AiVaultSession | null>()
|
||||
for (const session of history) {
|
||||
const key = previewIdentity(session)
|
||||
historyByIdentity.set(key, historyByIdentity.has(key) ? null : session)
|
||||
}
|
||||
return hitSessions.map((hitSession) => {
|
||||
const row = historyByIdentity.get(previewIdentity(hitSession))
|
||||
return row && hitSession.previewMessages.length === 0
|
||||
? {
|
||||
...hitSession,
|
||||
previewMessages: row.previewMessages,
|
||||
...(row.previewMessagesTruncated ? { previewMessagesTruncated: true } : {}),
|
||||
...(row.lastUserPrompt !== undefined ? { lastUserPrompt: row.lastUserPrompt } : {}),
|
||||
...(row.firstUserPrompt !== undefined ? { firstUserPrompt: row.firstUserPrompt } : {})
|
||||
}
|
||||
: hitSession
|
||||
})
|
||||
}
|
||||
|
||||
function previewIdentity(
|
||||
session: Pick<AiVaultSession, 'executionHostId' | 'agent' | 'sessionId'>
|
||||
): string {
|
||||
return JSON.stringify([session.executionHostId, session.agent, session.sessionId])
|
||||
}
|
||||
|
||||
export function canResumeAiVaultSearchHit(hit: AiVaultSearchHit): boolean {
|
||||
return (
|
||||
hit.structuredSession !== undefined ||
|
||||
|
||||
@@ -35,7 +35,7 @@ vi.mock('@/lib/ai-vault-session-resume-preparation', () => ({
|
||||
prepareAiVaultSessionForFork: mocks.prepareAiVaultSessionForFork,
|
||||
prepareAiVaultSessionForResume: mocks.prepareAiVaultSessionForResume,
|
||||
prepareAiVaultSessionForTerminalResume: mocks.prepareAiVaultSessionForTerminalResume,
|
||||
dropDeletedSshResumeCwd: async (session: unknown) => session,
|
||||
dropDeletedRemoteResumeCwd: async (session: unknown) => session,
|
||||
aiVaultCliForkHostTooOldMessage: () =>
|
||||
'Update Orca on the host that runs this chat to resume it in a new CLI.'
|
||||
}))
|
||||
|
||||
@@ -19,7 +19,7 @@ import { launchAiVaultSessionAndReveal } from '@/lib/launch-ai-vault-session-and
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import {
|
||||
dropDeletedSshResumeCwd,
|
||||
dropDeletedRemoteResumeCwd,
|
||||
prepareAiVaultSessionForFork,
|
||||
prepareAiVaultSessionForResume,
|
||||
prepareAiVaultSessionForTerminalResume
|
||||
@@ -136,7 +136,7 @@ export function useAiVaultSessionLaunchActions({
|
||||
targetWorktreeId,
|
||||
(worktreeId) =>
|
||||
prepareAiVaultSessionForTerminalResume(session)
|
||||
.then(dropDeletedSshResumeCwd)
|
||||
.then(dropDeletedRemoteResumeCwd)
|
||||
.then((preparedSession) => buildResumeStartup(preparedSession, worktreeId)),
|
||||
(message) => describeAiVaultResumeFailure(new Error(message), session.agent)
|
||||
)
|
||||
@@ -155,7 +155,7 @@ export function useAiVaultSessionLaunchActions({
|
||||
commandOverride: agentCmdOverrides?.[session.agent]
|
||||
})
|
||||
const prepared = await prepareAiVaultSessionForFork(session)
|
||||
const target = await dropDeletedSshResumeCwd(prepared.session)
|
||||
const target = await dropDeletedRemoteResumeCwd(prepared.session)
|
||||
// A copy the chat's host already made is the terminal's own conversation: a plain resume.
|
||||
const startup = prepared.copied
|
||||
? buildAiVaultCopiedSessionStartupForWorktree(
|
||||
|
||||
@@ -5,12 +5,18 @@ import {
|
||||
buildAiVaultResumeCopyCommandForWorktree,
|
||||
buildAiVaultResumeStartupForWorktree
|
||||
} from './ai-vault-resume-command'
|
||||
import { dropDeletedSshResumeCwd } from './ai-vault-session-resume-preparation'
|
||||
import { dropDeletedRemoteResumeCwd } from './ai-vault-session-resume-preparation'
|
||||
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
|
||||
|
||||
vi.mock('@/lib/new-workspace', () => ({
|
||||
CLIENT_PLATFORM: 'win32'
|
||||
}))
|
||||
|
||||
const browseServerDir = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/runtime/runtime-server-directory-browser', () => ({
|
||||
browseRuntimeServerDirectory: browseServerDir
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
@@ -515,7 +521,7 @@ describe('ai vault resume command runtime', () => {
|
||||
const startup = buildAiVaultResumeStartupForWorktree({
|
||||
state: makeState({ worktreePath: '/home/alice/wt/main' }),
|
||||
worktreeId: 'repo-1::worktree-1',
|
||||
session: await dropDeletedSshResumeCwd(session)
|
||||
session: await dropDeletedRemoteResumeCwd(session)
|
||||
})
|
||||
|
||||
// A deleted SSH cwd blocks startup, and Codex retains that missing folder even when resumed at root (#17745).
|
||||
@@ -528,6 +534,43 @@ describe('ai vault resume command runtime', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('resumes a paired server session at the workspace root once the server reports its folder deleted', async () => {
|
||||
browseServerDir.mockImplementation(async (_environmentId: string, path: string) => {
|
||||
if (path === '~') {
|
||||
return { resolvedPath: '/home/alice', entries: [], pathFlavor: 'posix' }
|
||||
}
|
||||
throw new RuntimeRpcCallError({
|
||||
id: 'req',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'runtime_error',
|
||||
message: `ENOENT: no such file or directory, stat '${path}'`
|
||||
}
|
||||
})
|
||||
})
|
||||
const session = {
|
||||
agent: 'codex' as const,
|
||||
sessionId: 'session one',
|
||||
cwd: '/home/alice/wt/feat-x',
|
||||
codexHome: '/home/alice/.codex',
|
||||
executionHostId: 'runtime:env-1' as const,
|
||||
executionHostPlatform: 'linux' as const,
|
||||
resumeCommand:
|
||||
"cd '/home/alice/wt/feat-x' && CODEX_HOME='/home/alice/.codex' codex resume 'session one'"
|
||||
}
|
||||
|
||||
const startup = buildAiVaultResumeStartupForWorktree({
|
||||
state: makeState({ worktreePath: '/home/alice/wt/main' }),
|
||||
worktreeId: 'repo-1::worktree-1',
|
||||
session: await dropDeletedRemoteResumeCwd(session)
|
||||
})
|
||||
|
||||
expect(startup.command).not.toContain('feat-x')
|
||||
expect(startup.command).toContain("'-c' 'tui.resume_cwd=current' 'resume'")
|
||||
expect(startup.cwd).toBeUndefined()
|
||||
expect(browseServerDir).toHaveBeenCalledWith('env-1', '/home/alice/wt/feat-x')
|
||||
})
|
||||
|
||||
it('rebuilds remote real-home Codex commands without a stored home assignment', () => {
|
||||
const state = makeState({ worktreePath: '/home/alice/repo' })
|
||||
state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AiVaultSession } from '../../../shared/ai-vault-types'
|
||||
import {
|
||||
dropDeletedSshResumeCwd,
|
||||
dropDeletedRemoteResumeCwd,
|
||||
prepareAiVaultSessionForFork,
|
||||
prepareAiVaultSessionForResume,
|
||||
prepareAiVaultSessionForTerminalResume
|
||||
} from './ai-vault-session-resume-preparation'
|
||||
|
||||
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
|
||||
|
||||
const browseServerDir = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/runtime/runtime-server-directory-browser', () => ({
|
||||
browseRuntimeServerDirectory: browseServerDir
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
browseServerDir.mockReset()
|
||||
})
|
||||
|
||||
describe('prepareAiVaultSessionForResume', () => {
|
||||
@@ -179,14 +187,14 @@ describe('prepareAiVaultSessionForFork', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('dropDeletedSshResumeCwd', () => {
|
||||
describe('dropDeletedRemoteResumeCwd', () => {
|
||||
it('leaves a live SSH session alone, scanner command and all', async () => {
|
||||
const pathExists = vi.fn().mockResolvedValue(true)
|
||||
vi.stubGlobal('window', { api: { fs: { pathExists } } })
|
||||
const remote = session({ executionHostId: 'ssh:server-1', cwd: '/home/ada/wt/feat-x' })
|
||||
|
||||
// Preserve recorded cwd or ordinary remote resumes would start at the workspace root.
|
||||
await expect(dropDeletedSshResumeCwd(remote)).resolves.toBe(remote)
|
||||
await expect(dropDeletedRemoteResumeCwd(remote)).resolves.toBe(remote)
|
||||
expect(pathExists).toHaveBeenCalledWith({
|
||||
filePath: '/home/ada/wt/feat-x',
|
||||
connectionId: 'server-1'
|
||||
@@ -198,7 +206,7 @@ describe('dropDeletedSshResumeCwd', () => {
|
||||
vi.stubGlobal('window', { api: { fs: { pathExists } } })
|
||||
const local = session({ cwd: '/repo' })
|
||||
|
||||
await expect(dropDeletedSshResumeCwd(local)).resolves.toBe(local)
|
||||
await expect(dropDeletedRemoteResumeCwd(local)).resolves.toBe(local)
|
||||
expect(pathExists).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -208,20 +216,104 @@ describe('dropDeletedSshResumeCwd', () => {
|
||||
const remote = session({ executionHostId: 'ssh:server-1', cwd: '/home/ada/wt/feat-x' })
|
||||
|
||||
// Losing the host cannot prove its folder is gone (ssh-execution-boundary.md).
|
||||
await expect(dropDeletedSshResumeCwd(remote)).resolves.toBe(remote)
|
||||
await expect(dropDeletedRemoteResumeCwd(remote)).resolves.toBe(remote)
|
||||
expect(pathExists).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops a paired server session folder only when that server reports it missing', async () => {
|
||||
serveHostWithMissingFolders('posix')
|
||||
const gone = session({
|
||||
executionHostId: 'runtime:env-1',
|
||||
cwd: '/srv/wt/gone',
|
||||
resumeCommand: "cd '/srv/wt/gone' && codex resume session-1"
|
||||
})
|
||||
|
||||
const dropped = await dropDeletedRemoteResumeCwd(gone)
|
||||
|
||||
expect(dropped.cwd).toBeNull()
|
||||
expect(dropped.resumeCommand).toBeUndefined()
|
||||
expect(browseServerDir).toHaveBeenCalledWith('env-1', '/srv/wt/gone')
|
||||
})
|
||||
|
||||
it('keeps a paired server session folder that exists or cannot be checked', async () => {
|
||||
const remote = session({ executionHostId: 'runtime:env-1', cwd: '/srv/wt/live' })
|
||||
browseServerDir.mockResolvedValueOnce({ resolvedPath: '/srv/wt/live', entries: [] })
|
||||
await expect(dropDeletedRemoteResumeCwd(remote)).resolves.toBe(remote)
|
||||
|
||||
// A dropped connection proves nothing about the folder.
|
||||
browseServerDir.mockRejectedValueOnce(new Error('Runtime environment is unreachable'))
|
||||
await expect(dropDeletedRemoteResumeCwd(remote)).resolves.toBe(remote)
|
||||
|
||||
// A path that merely contains "not found" is not the host saying the folder is gone.
|
||||
browseServerDir.mockRejectedValueOnce(runtimeError('Workspace not found on this server'))
|
||||
await expect(dropDeletedRemoteResumeCwd(remote)).resolves.toBe(remote)
|
||||
})
|
||||
|
||||
it('keeps a folder the server browser would trim into a different path', async () => {
|
||||
serveHostWithMissingFolders('posix')
|
||||
const padded = session({ executionHostId: 'runtime:env-1', cwd: '/srv/repo ' })
|
||||
|
||||
await expect(dropDeletedRemoteResumeCwd(padded)).resolves.toBe(padded)
|
||||
expect(browseServerDir).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rebuilds a deleted-folder resume for a Windows server with its shell', async () => {
|
||||
serveHostWithMissingFolders('win32')
|
||||
const gone = session({
|
||||
executionHostId: 'runtime:windows-server',
|
||||
cwd: 'C:\\Users\\ada\\wt\\gone',
|
||||
resumeCommand: "cd 'C:\\Users\\ada\\wt\\gone' && codex resume session-1"
|
||||
})
|
||||
|
||||
const dropped = await dropDeletedRemoteResumeCwd(gone)
|
||||
|
||||
expect(dropped.cwd).toBeNull()
|
||||
expect(dropped.executionHostPlatform).toBe('win32')
|
||||
})
|
||||
|
||||
it("keeps a WSL session's Linux folder that its Windows host cannot stat", async () => {
|
||||
serveHostWithMissingFolders('win32')
|
||||
const wsl = session({
|
||||
executionHostId: 'runtime:windows-box',
|
||||
cwd: '/home/ada/wt/live',
|
||||
resumeCommand: "cd '/home/ada/wt/live' && codex resume session-1"
|
||||
})
|
||||
|
||||
await expect(dropDeletedRemoteResumeCwd(wsl)).resolves.toBe(wsl)
|
||||
})
|
||||
|
||||
it('keeps the recorded folder for an agent that can only resume there', async () => {
|
||||
const pathExists = vi.fn().mockResolvedValue(false)
|
||||
vi.stubGlobal('window', { api: { fs: { pathExists } } })
|
||||
const kimi = session({ agent: 'kimi', executionHostId: 'ssh:server-1', cwd: '/home/ada/wt/x' })
|
||||
|
||||
// Why: kimi-cli 1.52 silently opens a new empty session with the same id from any other folder.
|
||||
await expect(dropDeletedSshResumeCwd(kimi)).resolves.toBe(kimi)
|
||||
await expect(dropDeletedRemoteResumeCwd(kimi)).resolves.toBe(kimi)
|
||||
})
|
||||
})
|
||||
|
||||
/** A host whose home browses as `flavor`, and on which every other folder is missing. */
|
||||
function serveHostWithMissingFolders(flavor: 'posix' | 'win32'): void {
|
||||
browseServerDir.mockImplementation(async (_environmentId: string, path: string) => {
|
||||
if (path === '~') {
|
||||
return {
|
||||
resolvedPath: flavor === 'win32' ? 'C:\\Users\\ada' : '/home/ada',
|
||||
entries: [],
|
||||
pathFlavor: flavor
|
||||
}
|
||||
}
|
||||
throw runtimeError(`ENOENT: no such file or directory, stat '${path}'`)
|
||||
})
|
||||
}
|
||||
|
||||
function runtimeError(message: string): RuntimeRpcCallError {
|
||||
return new RuntimeRpcCallError({
|
||||
id: 'req',
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message }
|
||||
})
|
||||
}
|
||||
|
||||
function stubPreparation(prepareSessionResume: ReturnType<typeof vi.fn>): void {
|
||||
vi.stubGlobal('window', { api: { aiVault: { prepareSessionResume } } })
|
||||
}
|
||||
|
||||
@@ -11,10 +11,15 @@ import {
|
||||
isPerAccountManagedCodexHome
|
||||
} from '../../../shared/ai-vault-resume-preparation'
|
||||
import {
|
||||
getSshTargetIdForExecutionHost,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
parseExecutionHostId
|
||||
parseExecutionHostId,
|
||||
parseRoutableExecutionHostId
|
||||
} from '../../../shared/execution-host'
|
||||
import { browseRuntimeServerDirectory } from '@/runtime/runtime-server-directory-browser'
|
||||
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
|
||||
import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision'
|
||||
import type { FilesystemPathFlavor } from '../../../shared/filesystem-entry-types'
|
||||
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
|
||||
import { readIpcErrorMessage } from './ipc-error'
|
||||
import type { AiVaultResumeCommandSession } from './ai-vault-resume-command'
|
||||
import { agentForksThroughChat } from '../../../shared/agent-resume-argv'
|
||||
@@ -193,22 +198,87 @@ export function aiVaultSessionNeedsResumePreparation(
|
||||
// Kimi is folder-keyed: wrong cwd rejects resume or silently opens an empty same-ID session (1.52+).
|
||||
const RESUMES_ONLY_IN_RECORDED_CWD: ReadonlySet<AiVaultSession['agent']> = new Set(['kimi', 'muse'])
|
||||
|
||||
/** After the SSH host confirms missing cwd, drop it and its prebuilt cd command so resume starts at the workspace root (#17745). */
|
||||
export async function dropDeletedSshResumeCwd(
|
||||
/** After the session's remote host confirms missing cwd, drop it and its prebuilt cd command so resume starts at the workspace root (#17745, #26509). */
|
||||
export async function dropDeletedRemoteResumeCwd(
|
||||
session: AiVaultResumeCommandSession
|
||||
): Promise<AiVaultResumeCommandSession> {
|
||||
const connectionId = getSshTargetIdForExecutionHost(session.executionHostId)
|
||||
if (!connectionId || !session.cwd || RESUMES_ONLY_IN_RECORDED_CWD.has(session.agent)) {
|
||||
const host = parseRoutableExecutionHostId(session.executionHostId)
|
||||
const cwd = session.cwd
|
||||
if (
|
||||
(host?.kind !== 'ssh' && host?.kind !== 'runtime') ||
|
||||
!cwd ||
|
||||
RESUMES_ONLY_IN_RECORDED_CWD.has(session.agent)
|
||||
) {
|
||||
return session
|
||||
}
|
||||
let hostFlavor: FilesystemPathFlavor | null = null
|
||||
try {
|
||||
if (await window.api.fs.pathExists({ filePath: session.cwd, connectionId })) {
|
||||
return session
|
||||
if (host.kind === 'ssh') {
|
||||
if (await window.api.fs.pathExists({ filePath: cwd, connectionId: host.targetId })) {
|
||||
return session
|
||||
}
|
||||
} else {
|
||||
hostFlavor = await runtimeServerFolderMissingFlavor(host.environmentId, cwd)
|
||||
if (!hostFlavor) {
|
||||
return session
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Why: only a definite ENOENT proves the folder is gone; losing the host proves nothing.
|
||||
return session
|
||||
}
|
||||
const { resumeCommand: _scannerCommand, ...rest } = session
|
||||
return { ...rest, cwd: null }
|
||||
// The rebuilt command must quote for the host's shell; an unstamped row would otherwise build POSIX.
|
||||
return hostFlavor === 'win32' && !rest.executionHostPlatform
|
||||
? { ...rest, cwd: null, executionHostPlatform: 'win32' }
|
||||
: { ...rest, cwd: null }
|
||||
}
|
||||
|
||||
/** The host's path flavor when it proves `cwd` is gone, else null. files.stat needs the deleted workspace, so browse instead. */
|
||||
async function runtimeServerFolderMissingFlavor(
|
||||
environmentId: string,
|
||||
cwd: string
|
||||
): Promise<FilesystemPathFlavor | null> {
|
||||
// The browser trims, expands `~` and resolves relative paths, so it would probe another folder; keep those.
|
||||
if (cwd.trim() !== cwd || (!cwd.startsWith('/') && !isWindowsAbsolutePathLike(cwd))) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
await browseRuntimeServerDirectory(environmentId, cwd)
|
||||
return null
|
||||
} catch (error) {
|
||||
if (!isDefiniteMissingPathError(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
// A WSL session on a Windows host records a Linux cwd the host cannot stat; absence there proves nothing.
|
||||
const flavor = await runtimeServerPathFlavor(environmentId)
|
||||
return flavor === aiVaultCwdPathFlavor(cwd) ? flavor : null
|
||||
}
|
||||
|
||||
function isDefiniteMissingPathError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof RuntimeRpcCallError &&
|
||||
error.code === 'runtime_error' &&
|
||||
/^(?:ENOENT|ENOTDIR):/.test(error.message)
|
||||
)
|
||||
}
|
||||
|
||||
const runtimeServerPathFlavors = new Map<string, Promise<FilesystemPathFlavor>>()
|
||||
|
||||
function runtimeServerPathFlavor(environmentId: string): Promise<FilesystemPathFlavor> {
|
||||
// Keyed by pairing: a same-id re-pair may name a machine with another OS.
|
||||
const key = `${environmentId}:${getRuntimeEnvironmentRevision(environmentId) ?? ''}`
|
||||
let flavor = runtimeServerPathFlavors.get(key)
|
||||
if (!flavor) {
|
||||
flavor = browseRuntimeServerDirectory(environmentId, '~').then((listing) => listing.pathFlavor)
|
||||
// A failed probe must not pin its verdict; the next resume asks again.
|
||||
flavor.catch(() => runtimeServerPathFlavors.delete(key))
|
||||
runtimeServerPathFlavors.set(key, flavor)
|
||||
}
|
||||
return flavor
|
||||
}
|
||||
|
||||
function aiVaultCwdPathFlavor(cwd: string): FilesystemPathFlavor {
|
||||
return isWindowsAbsolutePathLike(cwd) ? 'win32' : 'posix'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user