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:
Neil
2026-10-26 08:44:18 -07:00
committed by GitHub
parent d47ffbe46d
commit d7cf9d2d37
8 changed files with 299 additions and 23 deletions
@@ -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'
}