From 02ab51a03aff37c8fa412c0045fd14d647cbdbac Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:39:14 -0700 Subject: [PATCH] Strip liveness gate from AI Vault session delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete now requires only path validation + user confirmation — no process roster, no liveness check, no quiescence, no ownership ledger. Co-authored-by: Orca --- .../session-delete-liveness.repro.test.ts | 179 ------------ src/main/ai-vault/session-delete.test.ts | 10 +- src/main/ai-vault/session-delete.ts | 31 +-- src/main/ai-vault/session-liveness.test.ts | 263 ------------------ src/main/ai-vault/session-liveness.ts | 187 ------------- src/main/ipc/ai-vault-delete.ts | 25 +- src/main/ipc/ai-vault.test.ts | 6 +- src/main/ipc/ai-vault.ts | 6 +- src/main/ipc/register-core-handlers.ts | 3 +- src/main/runtime/orca-runtime.ts | 75 +---- .../right-sidebar/AiVaultSessionRow.tsx | 2 +- .../ai-vault-session-deletability.test.ts | 23 -- .../ai-vault-session-deletability.ts | 18 +- src/shared/ai-vault-session-deletion.ts | 4 - 14 files changed, 18 insertions(+), 814 deletions(-) delete mode 100644 src/main/ai-vault/session-delete-liveness.repro.test.ts delete mode 100644 src/main/ai-vault/session-liveness.test.ts delete mode 100644 src/main/ai-vault/session-liveness.ts diff --git a/src/main/ai-vault/session-delete-liveness.repro.test.ts b/src/main/ai-vault/session-delete-liveness.repro.test.ts deleted file mode 100644 index 6e4928a84ea..00000000000 --- a/src/main/ai-vault/session-delete-liveness.repro.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { existsSync } from 'node:fs' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' -import type { AiVaultDeleteSessionResult } from '../../shared/ai-vault-session-deletion' -import type { ValidateAiVaultSessionDeleteTargetArgs } from './session-delete-target' - -const { trashItemMock } = vi.hoisted(() => ({ trashItemMock: vi.fn() })) - -vi.mock('electron', () => ({ shell: { trashItem: trashItemMock } })) -vi.mock('../wsl-unc-delete', () => ({ tryDeleteWslUncPath: vi.fn().mockResolvedValue(false) })) - -import { deleteAiVaultSessionFile } from './session-delete' -import { resolveAiVaultSessionLiveness } from './session-liveness' - -type AiVaultSessionLiveness = 'live' | 'not-live' | 'unknown' - -type DeleteWithLiveness = ( - args: ValidateAiVaultSessionDeleteTargetArgs & { sessionId: string }, - deps: { getSessionLiveness: () => Promise } -) => Promise - -const deleteWithLiveness = deleteAiVaultSessionFile as unknown as DeleteWithLiveness -const fixtureRoots: string[] = [] - -async function createTranscript(): Promise<{ filePath: string; root: string }> { - const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-live-delete-repro-')) - fixtureRoots.push(root) - const filePath = join(root, 'session-live.json') - await writeFile(filePath, '{"sessionId":"session-live"}\n') - return { filePath, root } -} - -async function attemptDelete( - getSessionLiveness: () => Promise -): Promise<{ filePath: string; result: AiVaultDeleteSessionResult }> { - const { filePath, root } = await createTranscript() - const result = await deleteWithLiveness( - { - agent: 'gemini', - sessionId: 'session-live', - filePath, - executionHostId: 'local', - rootOptions: { geminiSessionsDir: root } - }, - { getSessionLiveness } - ) - return { filePath, result } -} - -describe('live AI Vault session delete safety invariant', () => { - afterEach(async () => { - trashItemMock.mockReset() - await Promise.all( - fixtureRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })) - ) - }) - - it('survives when the session becomes live after confirmation but before main authorization', async () => { - trashItemMock.mockImplementation((path: string) => rm(path)) - const rendererPaneSnapshot = new Set() - expect(rendererPaneSnapshot.has('session-live')).toBe(false) - - let authoritativeLiveness: AiVaultSessionLiveness = 'not-live' - const confirmationOpened = true - authoritativeLiveness = 'live' - expect(confirmationOpened).toBe(true) - - const { filePath, result } = await attemptDelete(async () => authoritativeLiveness) - - expect.soft(result).toEqual({ outcome: 'rejected', agent: 'gemini', reason: 'session-live' }) - expect.soft(existsSync(filePath), 'live transcript must survive on disk').toBe(true) - }) - - it('survives when a paired owner is absent from the renderer pane snapshot', async () => { - trashItemMock.mockImplementation((path: string) => rm(path)) - const rendererPaneSnapshot = new Set() - const authoritativeOwner = { - runtimeId: 'host-runtime', - connectionId: 'paired-client', - generation: 'generation-1', - sessionId: 'session-live', - liveness: 'live' as const - } - expect(rendererPaneSnapshot.has(authoritativeOwner.sessionId)).toBe(false) - - const { filePath, result } = await attemptDelete(async () => authoritativeOwner.liveness) - - expect.soft(result).toEqual({ outcome: 'rejected', agent: 'gemini', reason: 'session-live' }) - expect.soft(existsSync(filePath), 'externally owned transcript must survive on disk').toBe(true) - }) - - it('survives an exact live local hook identity absent from managed PTY inventory', async () => { - trashItemMock.mockImplementation((path: string) => rm(path)) - const inspectForegroundProcess = vi.fn() - const externalLiveStatus: AgentStatusIpcPayload = { - paneKey: 'tab:external-live', - terminalHandle: 'term_external-live', - connectionId: null, - receivedAt: 1, - stateStartedAt: 1, - state: 'working', - prompt: 'test', - agentType: 'gemini', - providerSession: { key: 'session_id', id: 'session-live' } - } - - const { filePath, result } = await attemptDelete(() => - resolveAiVaultSessionLiveness( - { agent: 'gemini', sessionId: 'session-live' }, - { - listProcesses: async () => [], - getStatusSnapshot: () => [externalLiveStatus], - inspectForegroundProcess, - getStatusPtyId: () => 'external-live', - getAgentHint: () => null - } - ) - ) - - expect.soft(result).toEqual({ - outcome: 'rejected', - agent: 'gemini', - reason: 'session-liveness-unknown' - }) - expect.soft(existsSync(filePath), 'external live transcript must survive on disk').toBe(true) - expect(inspectForegroundProcess).not.toHaveBeenCalled() - }) - - it('survives a dismissed live row retained as liveness-only identity', async () => { - trashItemMock.mockImplementation((path: string) => rm(path)) - const retainedIdentity: AgentStatusIpcPayload = { - paneKey: 'tab:external-live', - connectionId: null, - receivedAt: 1, - stateStartedAt: 1, - state: 'working', - prompt: 'test', - agentType: 'gemini', - providerSession: { key: 'session_id', id: 'session-live' }, - providerSessionOnly: true - } - - const { filePath, result } = await attemptDelete(() => - resolveAiVaultSessionLiveness( - { agent: 'gemini', sessionId: 'session-live' }, - { - listProcesses: async () => [], - getStatusSnapshot: () => [retainedIdentity], - inspectForegroundProcess: vi.fn(), - getStatusPtyId: () => null, - getAgentHint: () => null - } - ) - ) - - expect.soft(result).toEqual({ - outcome: 'rejected', - agent: 'gemini', - reason: 'session-liveness-unknown' - }) - expect.soft(existsSync(filePath), 'dismissed live transcript must survive on disk').toBe(true) - }) - - it('fails closed when authoritative liveness is unavailable', async () => { - trashItemMock.mockImplementation((path: string) => rm(path)) - - const { filePath, result } = await attemptDelete(async () => 'unknown') - - expect(result).toEqual({ - outcome: 'rejected', - agent: 'gemini', - reason: 'session-liveness-unknown' - }) - expect(existsSync(filePath), 'unknown-liveness transcript must survive on disk').toBe(true) - }) -}) diff --git a/src/main/ai-vault/session-delete.test.ts b/src/main/ai-vault/session-delete.test.ts index 3c6f86f43a7..cacb0dbbda5 100644 --- a/src/main/ai-vault/session-delete.test.ts +++ b/src/main/ai-vault/session-delete.test.ts @@ -33,21 +33,13 @@ vi.mock('../wsl-unc-delete', () => ({ WslDeleteValidationError: WslDeleteValidationErrorMock })) -import { deleteAiVaultSessionFile as deleteAiVaultSessionFileWithLiveness } from './session-delete' +import { deleteAiVaultSessionFile } from './session-delete' const HOME = join('/tmp', 'orca-ai-vault-delete-exec-fixture-home') const GEMINI_ROOT = join(HOME, '.gemini', 'tmp') const CLAUDE_ROOT = join(HOME, '.claude', 'projects') const ROVO_ROOT = join(HOME, '.rovodev', 'sessions') -function deleteAiVaultSessionFile( - args: Parameters[0] -) { - return deleteAiVaultSessionFileWithLiveness(args, { - getSessionLiveness: async () => 'not-live' - }) -} - function enoent(): NodeJS.ErrnoException { const error = new Error('not found') as NodeJS.ErrnoException error.code = 'ENOENT' diff --git a/src/main/ai-vault/session-delete.ts b/src/main/ai-vault/session-delete.ts index fb6876904ae..4676749068b 100644 --- a/src/main/ai-vault/session-delete.ts +++ b/src/main/ai-vault/session-delete.ts @@ -2,10 +2,8 @@ import { lstat, realpath } from 'node:fs/promises' import { shell } from 'electron' import type { AiVaultDeleteSessionResult, - AiVaultSessionDeleteRemoval, - AiVaultSessionLiveness + AiVaultSessionDeleteRemoval } from '../../shared/ai-vault-session-deletion' -import type { AiVaultAgent } from '../../shared/ai-vault-types' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import { validateAiVaultSessionDeleteTarget, @@ -19,14 +17,7 @@ import { isENOENT } from '../ipc/filesystem-auth' // Never throws: IPC payloads are untyped at runtime, so a bad input and an fs // error both resolve to a discriminated result the handler can render. export async function deleteAiVaultSessionFile( - args: ValidateAiVaultSessionDeleteTargetArgs & { sessionId?: string }, - deps: { - getSessionLiveness: (target: { - agent: AiVaultAgent - sessionId: string | undefined - filePath: string - }) => Promise - } + args: ValidateAiVaultSessionDeleteTargetArgs & { sessionId?: string } ): Promise { const validation = validateAiVaultSessionDeleteTarget(args) if (!validation.allowed) { @@ -34,24 +25,6 @@ export async function deleteAiVaultSessionFile( } const { agent, removals } = validation - let liveness: AiVaultSessionLiveness = 'unknown' - try { - liveness = await deps.getSessionLiveness({ - agent, - sessionId: args.sessionId, - filePath: validation.resolvedPath - }) - } catch { - // Inspection failure is unknown, never permission to delete. - } - if (liveness !== 'not-live') { - return { - outcome: 'rejected', - agent, - reason: liveness === 'live' ? 'session-live' : 'session-liveness-unknown' - } - } - try { for (const removal of removals) { const rejection = await removeOne(removal) diff --git a/src/main/ai-vault/session-liveness.test.ts b/src/main/ai-vault/session-liveness.test.ts deleted file mode 100644 index c4eb2a70328..00000000000 --- a/src/main/ai-vault/session-liveness.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' -import type { PtyProcessInfo } from '../providers/pty-process-info' -import { - readAiVaultSessionIdentity, - resolveAiVaultSessionLiveness, - type AiVaultSessionLivenessDependencies -} from './session-liveness' - -function processInfo(id: string, terminalHandle = `term_${id}`): PtyProcessInfo { - return { id, terminalHandle, cwd: '/workspace', title: 'terminal' } -} - -function status(args: { - sessionId: string - ptyId: string - connectionId?: string | null -}): AgentStatusIpcPayload { - return { - paneKey: `tab:${args.ptyId}`, - terminalHandle: `term_${args.ptyId}`, - connectionId: args.connectionId ?? null, - receivedAt: 1, - stateStartedAt: 1, - state: 'working', - prompt: 'test', - agentType: 'gemini', - providerSession: { key: 'session_id', id: args.sessionId } - } -} - -function dependencies( - overrides: Partial = {} -): AiVaultSessionLivenessDependencies { - return { - listProcesses: async () => [], - getStatusSnapshot: () => [], - inspectForegroundProcess: async () => ({ available: true, process: 'zsh' }), - getStatusPtyId: (row) => row.terminalHandle?.slice('term_'.length) ?? null, - getAgentHint: () => null, - ...overrides - } -} - -describe('resolveAiVaultSessionLiveness', () => { - it('binds renderer identity to the session parsed from the validated transcript', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-delete-identity-')) - const filePath = join(root, 'session.json') - await writeFile( - filePath, - JSON.stringify({ - sessionId: 'authoritative-session', - startTime: '2026-08-07T00:00:00.000Z', - lastUpdated: '2026-08-07T00:01:00.000Z', - messages: [{ type: 'user', content: 'test' }] - }) - ) - try { - await expect( - readAiVaultSessionIdentity({ - agent: 'gemini', - sessionId: 'spoofed-session', - filePath - }) - ).resolves.toEqual({ outcome: 'unknown' }) - await expect( - readAiVaultSessionIdentity({ - agent: 'gemini', - sessionId: 'authoritative-session', - filePath - }) - ).resolves.toEqual({ outcome: 'found', sessionId: 'authoritative-session' }) - } finally { - await rm(root, { recursive: true, force: true }) - } - }) - - it('finds a paired-owned live session without consulting a renderer pane snapshot', async () => { - const deps = dependencies({ - listProcesses: async () => [processInfo('paired-pty')], - getStatusSnapshot: () => [status({ sessionId: 'session-live', ptyId: 'paired-pty' })], - inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) - }) - - await expect( - resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-live' }, deps) - ).resolves.toBe('live') - }) - - it('reads hook identity after a controlled process-inventory barrier', async () => { - let releaseInventory: (processes: PtyProcessInfo[]) => void = () => {} - const inventory = new Promise((resolve) => { - releaseInventory = resolve - }) - let statuses = [status({ sessionId: 'session-other', ptyId: 'paired-pty' })] - const deps = dependencies({ - listProcesses: () => inventory, - getStatusSnapshot: () => statuses, - inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) - }) - - const liveness = resolveAiVaultSessionLiveness( - { agent: 'gemini', sessionId: 'session-live' }, - deps - ) - statuses = [status({ sessionId: 'session-live', ptyId: 'paired-pty' })] - releaseInventory([processInfo('paired-pty')]) - - await expect(liveness).resolves.toBe('live') - }) - - it('reads hook identity after a controlled foreground-inspection barrier', async () => { - let markInspectionStarted: () => void = () => {} - const inspectionStarted = new Promise((resolve) => { - markInspectionStarted = resolve - }) - let releaseInspection: (inspection: { - available: boolean - process: string | null - }) => void = () => {} - const inspection = new Promise<{ available: boolean; process: string | null }>((resolve) => { - releaseInspection = resolve - }) - let statuses = [status({ sessionId: 'session-other', ptyId: 'managed-pty' })] - const deps = dependencies({ - listProcesses: async () => [processInfo('managed-pty')], - getStatusSnapshot: () => statuses, - inspectForegroundProcess: () => { - markInspectionStarted() - return inspection - } - }) - - const liveness = resolveAiVaultSessionLiveness( - { agent: 'gemini', sessionId: 'session-live' }, - deps - ) - await inspectionStarted - statuses = [status({ sessionId: 'session-live', ptyId: 'managed-pty' })] - releaseInspection({ available: true, process: 'gemini' }) - - await expect(liveness).resolves.toBe('live') - }) - - it('treats WSL hook ownership as local authority', async () => { - const deps = dependencies({ - listProcesses: async () => [processInfo('wsl-pty')], - getStatusSnapshot: () => [ - status({ sessionId: 'session-live', ptyId: 'wsl-pty', connectionId: 'wsl:Ubuntu' }) - ], - inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) - }) - - await expect( - resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-live' }, deps) - ).resolves.toBe('live') - }) - - it('does not use SSH identity as local liveness evidence', async () => { - const deps = dependencies({ - listProcesses: async () => [processInfo('local-pty')], - getStatusSnapshot: () => [ - status({ sessionId: 'session-live', ptyId: 'local-pty', connectionId: 'ssh:dev-box' }) - ], - inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) - }) - - await expect( - resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-live' }, deps) - ).resolves.toBe('unknown') - }) - - it('returns not-live when every local Gemini process owns another known session', async () => { - const deps = dependencies({ - listProcesses: async () => [processInfo('other-pty')], - getStatusSnapshot: () => [status({ sessionId: 'session-other', ptyId: 'other-pty' })], - inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) - }) - - await expect( - resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-target' }, deps) - ).resolves.toBe('not-live') - }) - - it.each([ - ['missing session identity', dependencies(), undefined], - [ - 'unavailable process inventory', - dependencies({ - listProcesses: async () => { - throw new Error('daemon offline') - } - }), - 'session-live' - ], - [ - 'unattributed live agent process', - dependencies({ - listProcesses: async () => [processInfo('unknown-pty')], - inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) - }), - 'session-live' - ] - ])('preserves unknown for %s', async (_name, deps, sessionId) => { - await expect(resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId }, deps)).resolves.toBe( - 'unknown' - ) - }) - - it('bounds foreground inspection concurrency', async () => { - let active = 0 - let maxActive = 0 - const inspectForegroundProcess = vi.fn(async () => { - active += 1 - maxActive = Math.max(maxActive, active) - await new Promise((resolve) => queueMicrotask(resolve)) - active -= 1 - return { available: true, process: 'zsh' } - }) - const deps = dependencies({ - listProcesses: async () => - Array.from({ length: 20 }, (_, index) => processInfo(`shell-${index}`)), - inspectForegroundProcess - }) - - await expect( - resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-target' }, deps) - ).resolves.toBe('not-live') - expect(inspectForegroundProcess).toHaveBeenCalledTimes(20) - expect(maxActive).toBeLessThanOrEqual(8) - }) - - it('fails closed before inspecting an oversized inventory', async () => { - const inspectForegroundProcess = vi.fn() - const deps = dependencies({ - listProcesses: async () => - Array.from({ length: 513 }, (_, index) => processInfo(`shell-${index}`)), - inspectForegroundProcess - }) - - await expect( - resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-target' }, deps) - ).resolves.toBe('unknown') - expect(inspectForegroundProcess).not.toHaveBeenCalled() - }) - - it('does not start foreground inspections after the shared deadline', async () => { - const inspectForegroundProcess = vi.fn() - const deps = dependencies({ - deadlineMs: 0, - listProcesses: async () => [processInfo('late-pty')], - inspectForegroundProcess - }) - - await expect( - resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-target' }, deps) - ).resolves.toBe('unknown') - expect(inspectForegroundProcess).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ai-vault/session-liveness.ts b/src/main/ai-vault/session-liveness.ts deleted file mode 100644 index 6f7170687ad..00000000000 --- a/src/main/ai-vault/session-liveness.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { isShellProcess } from '../../shared/agent-detection' -import { recognizeAgentProcess } from '../../shared/agent-process-recognition' -import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' -import type { AiVaultSessionLiveness } from '../../shared/ai-vault-session-deletion' -import type { AiVaultAgent } from '../../shared/ai-vault-types' -import type { PtyProcessInfo } from '../providers/pty-process-info' -import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' -import { lstat } from 'node:fs/promises' -import { parseAgentSessionFileCached } from './session-scanner-parse-cache' - -const MAX_LIVENESS_PROCESSES = 512 -const PROCESS_INSPECTION_CONCURRENCY = 8 - -type ProcessInspection = { - available: boolean - process: string | null -} - -type AgentProcessInspection = 'target-agent' | 'unknown' | 'other' - -export type AiVaultSessionLivenessDependencies = { - deadlineMs?: number - listProcesses: () => Promise - getStatusSnapshot: () => AgentStatusIpcPayload[] - inspectForegroundProcess: (ptyId: string) => Promise - getStatusPtyId: (status: AgentStatusIpcPayload) => string | null - getAgentHint: (process: PtyProcessInfo) => string | null -} - -export type AiVaultSessionIdentityRead = - | { outcome: 'found'; sessionId: string } - | { outcome: 'missing' } - | { outcome: 'unknown' } - -/** Binds the requested identity to the validated transcript on disk. */ -export async function readAiVaultSessionIdentity(target: { - agent: AiVaultAgent - sessionId: string | undefined - filePath: string -}): Promise { - try { - const fileStats = await lstat(target.filePath) - if (!fileStats.isFile()) { - return { outcome: 'unknown' } - } - const session = await parseAgentSessionFileCached( - { - agent: target.agent, - file: { - path: target.filePath, - mtimeMs: fileStats.mtimeMs, - modifiedAt: fileStats.mtime.toISOString(), - sizeBytes: fileStats.size - }, - codexHome: null - }, - process.platform - ) - return session?.sessionId && session.sessionId === target.sessionId - ? { outcome: 'found', sessionId: session.sessionId } - : { outcome: 'unknown' } - } catch (error) { - return (error as NodeJS.ErrnoException).code === 'ENOENT' - ? { outcome: 'missing' } - : { outcome: 'unknown' } - } -} - -function isLocalStatus(status: AgentStatusIpcPayload): boolean { - return status.connectionId === null || isWslHookRelayConnectionId(status.connectionId) -} - -function isValidSessionId(sessionId: string | undefined): sessionId is string { - return ( - typeof sessionId === 'string' && - sessionId.length > 0 && - sessionId.length <= 512 && - sessionId.trim() === sessionId - ) -} - -async function inspectInBatches( - values: readonly T[], - inspect: (value: T) => Promise, - deadlineMs?: number -): Promise { - const results: AgentProcessInspection[] = [] - let nextIndex = 0 - const worker = async (): Promise => { - while (nextIndex < values.length) { - const index = nextIndex - nextIndex += 1 - if (deadlineMs !== undefined && Date.now() >= deadlineMs) { - results[index] = 'unknown' - continue - } - results[index] = await inspect(values[index]!) - } - } - await Promise.all( - Array.from({ length: Math.min(PROCESS_INSPECTION_CONCURRENCY, values.length) }, worker) - ) - return results -} - -/** Resolves owning-runtime liveness without treating unavailable evidence as absence. */ -export async function resolveAiVaultSessionLiveness( - target: { agent: AiVaultAgent; sessionId: string | undefined }, - deps: AiVaultSessionLivenessDependencies -): Promise { - if (!isValidSessionId(target.sessionId)) { - return 'unknown' - } - - let processes: PtyProcessInfo[] - try { - processes = await deps.listProcesses() - } catch { - return 'unknown' - } - if (processes.length > MAX_LIVENESS_PROCESSES) { - return 'unknown' - } - - const processInspections = await inspectInBatches( - processes, - async (process) => { - let inspection: ProcessInspection - try { - inspection = await deps.inspectForegroundProcess(process.id) - } catch { - return 'unknown' - } - if (!inspection.available) { - return 'unknown' - } - - const recognizedAgent = recognizeAgentProcess(inspection.process)?.agent ?? null - const hintedAgent = deps.getAgentHint(process) - const ownsTargetAgent = - recognizedAgent === target.agent || - (recognizedAgent === null && - (inspection.process === null || !isShellProcess(inspection.process)) && - hintedAgent === target.agent) - if (!ownsTargetAgent) { - return 'other' - } - return 'target-agent' - }, - deps.deadlineMs - ) - - let localStatuses: AgentStatusIpcPayload[] - try { - localStatuses = deps.getStatusSnapshot().filter(isLocalStatus) - } catch { - return 'unknown' - } - const processIds = new Set(processes.map((process) => process.id)) - const hasUnmatchedTargetStatus = localStatuses.some( - (status) => - status.agentType === target.agent && - status.providerSession?.id === target.sessionId && - !processIds.has(deps.getStatusPtyId(status) ?? '') - ) - if (hasUnmatchedTargetStatus) { - return 'unknown' - } - - const results = processInspections.map((inspection, index) => { - if (inspection !== 'target-agent') { - return inspection - } - const identities = localStatuses.filter( - (status) => - status.agentType === target.agent && deps.getStatusPtyId(status) === processes[index]!.id - ) - if (identities.some((status) => status.providerSession?.id === target.sessionId)) { - return 'live' - } - return identities.some((status) => status.providerSession) ? 'other' : 'unknown' - }) - if (results.includes('live')) { - return 'live' - } - return results.includes('unknown') ? 'unknown' : 'not-live' -} diff --git a/src/main/ipc/ai-vault-delete.ts b/src/main/ipc/ai-vault-delete.ts index 3f3a1c8abd4..a221cc28ceb 100644 --- a/src/main/ipc/ai-vault-delete.ts +++ b/src/main/ipc/ai-vault-delete.ts @@ -8,19 +8,13 @@ import { invalidateSessionParseCacheEntry } from '../ai-vault/session-scanner-pa import type { AiVaultAgent } from '../../shared/ai-vault-types' import type { AiVaultDeleteSessionArgs, - AiVaultDeleteSessionResult, - AiVaultSessionLiveness + AiVaultDeleteSessionResult } from '../../shared/ai-vault-session-deletion' // Which cache backs the multi-host list is ai-vault.ts's concern, so its // invalidation is injected rather than reached into from here. type AiVaultDeleteDeps = { invalidateMultiHostListCache: () => void - getSessionLiveness: (target: { - agent: AiVaultAgent - sessionId: string | undefined - filePath: string - }) => Promise } // Binds the delete orchestration to the caller's cache-invalidation seam. @@ -40,16 +34,13 @@ export async function deleteAiVaultSession( // The validator tolerates a malformed agent/filePath but destructures `args`, // so an absent payload is defaulted here to keep the never-throws boundary. const wslHomeDirs = await getAiVaultWslHomeDirs() - const result = await deleteAiVaultSessionFile( - { - agent: args?.agent as AiVaultAgent, - sessionId: args?.sessionId, - filePath: args?.filePath ?? '', - executionHostId: args?.executionHostId, - wslHomeDirs - }, - deps - ) + const result = await deleteAiVaultSessionFile({ + agent: args?.agent as AiVaultAgent, + sessionId: args?.sessionId, + filePath: args?.filePath ?? '', + executionHostId: args?.executionHostId, + wslHomeDirs + }) if (result.outcome === 'deleted') { // Three caches could otherwise resurrect it: the desktop per-host leg diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index c696ea71dfd..4740bef70d7 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -722,8 +722,7 @@ describe('deleteAiVaultSession', () => { sessionId: args.sessionId, filePath: args.filePath, executionHostId: 'local' - }), - expect.objectContaining({ getSessionLiveness: expect.any(Function) }) + }) ) expect(mocks.invalidateAiVaultSessionListCache).toHaveBeenCalledTimes(1) expect(mocks.invalidateSessionParseCacheEntry).toHaveBeenCalledWith(args.filePath) @@ -772,8 +771,7 @@ describe('deleteAiVaultSession', () => { reason: 'invalid-path' }) expect(mocks.deleteAiVaultSessionFile).toHaveBeenCalledWith( - expect.objectContaining({ filePath: '' }), - expect.objectContaining({ getSessionLiveness: expect.any(Function) }) + expect.objectContaining({ filePath: '' }) ) expect(mocks.invalidateAiVaultSessionListCache).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 5701b97f230..a9d53fd59bd 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -62,7 +62,6 @@ type AiVaultHandlerOptions = AiVaultSessionSources & AiVaultResumeHandlerOptions & { getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[] scanRuntimeAiVaultSessions?: RuntimeAiVaultScanner - getSessionLiveness?: Parameters[1]['getSessionLiveness'] } let scanCoordinator = new AiVaultScanCoordinator() @@ -71,10 +70,7 @@ const listCancellations = createSenderScopedRequestCancellations() // Shared by the IPC registration and the test internals: a delete must drop // the multi-host leg cache, which this module owns the only caller of. const aiVaultDeleteDeps = { - invalidateMultiHostListCache: invalidateAiVaultHostLegCache, - getSessionLiveness: ( - target: Parameters>[0] - ) => handlerOptions.getSessionLiveness?.(target) ?? Promise.resolve('unknown' as const) + invalidateMultiHostListCache: invalidateAiVaultHostLegCache } async function listAiVaultSessions( diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 90f1a0e647d..ef903dcae05 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -223,8 +223,7 @@ export function registerCoreHandlers( scanRuntimeAiVaultSessions: async (environmentId, args, options) => scanRuntimeAiVaultSessions(app.getPath('userData'), environmentId, args, options), prepareRuntimeSessionResume: async (environmentId, args) => - prepareRuntimeAiVaultSessionResume(app.getPath('userData'), environmentId, args), - getSessionLiveness: (target) => runtime.getAiVaultSessionLiveness(target) + prepareRuntimeAiVaultSessionResume(app.getPath('userData'), environmentId, args) }) registerNativeChatHandlers() registerClipboardHandlers(store) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 2e8daee6bfb..c9117a06b53 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -485,12 +485,7 @@ import { configureAiVaultSessionSources, listAiVaultSessions } from '../ai-vault/cached-session-list' -import { - readAiVaultSessionIdentity, - resolveAiVaultSessionLiveness -} from '../ai-vault/session-liveness' -import type { AiVaultAgent, AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' -import type { AiVaultSessionLiveness } from '../../shared/ai-vault-session-deletion' +import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' import type { AiVaultPrepareSessionResumeArgs, AiVaultPrepareSessionResumeResult @@ -4957,74 +4952,6 @@ export class OrcaRuntimeService { return listAiVaultSessions(args) } - async getAiVaultSessionLiveness(target: { - agent: AiVaultAgent - sessionId: string | undefined - filePath: string - }): Promise { - const provider = this.getLocalProviderFn?.() - if (!provider || !this.getAgentProviderSessionSnapshotFn) { - return 'unknown' - } - const identity = await readAiVaultSessionIdentity(target) - if (identity.outcome !== 'found') { - return 'unknown' - } - const deadlineMs = Date.now() + 3_000 - return await resolveAiVaultSessionLiveness( - { - agent: target.agent, - sessionId: identity.sessionId - }, - { - deadlineMs, - listProcesses: async () => { - const result = await withTimeoutResult( - provider.listProcesses({ deadlineMs }), - Math.max(1, deadlineMs - Date.now()) - ) - if (!result.ok) { - throw new Error('agent_session_ownership_unknown') - } - return result.value - }, - getStatusSnapshot: this.getAgentProviderSessionSnapshotFn, - inspectForegroundProcess: async (ptyId) => { - const result = await withTimeoutResult( - provider.getForegroundProcess(ptyId), - Math.max(1, deadlineMs - Date.now()) - ) - return result.ok - ? { available: true, process: result.value } - : { available: false, process: null } - }, - getStatusPtyId: (status) => { - if (status.terminalHandle) { - const live = this.getLivePtyForHandle(status.terminalHandle) - if (live) { - return live.pty.ptyId - } - for (const [ptyId, handle] of this.handleByPtyId) { - if (handle === status.terminalHandle) { - return ptyId - } - } - } - return this.getPtyRecordForPaneKey(status.paneKey)?.ptyId ?? null - }, - getAgentHint: (process) => { - const pty = this.ptysById.get(process.id) - const runtimeHint = pty?.foregroundAgent ?? pty?.launchAgent - if (runtimeHint) { - return runtimeHint - } - const ownerAgents = new Set(process.agentSessionOwners?.map((owner) => owner.claim.agent)) - return ownerAgents.size === 1 ? ([...ownerAgents][0] ?? null) : null - } - } - ) - } - prepareAiVaultSessionResume( args: AiVaultPrepareSessionResumeArgs ): Promise { diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 9e2f1702f7c..e4af4f3e972 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -85,7 +85,7 @@ export function VaultSessionRow({ const detailsId = getSessionDetailsId(session.id) const latestTurn = latestSessionConversationTurn(session) // Computed once so the dropdown menu and the context menu never disagree. - const deleteBlockedReason = aiVaultSessionDeleteBlockedReason(session, liveState) + const deleteBlockedReason = aiVaultSessionDeleteBlockedReason(session) const requestDelete = (): void => onRequestDelete(session) const detailsTooltip = detailsExpanded ? translate('auto.components.right.sidebar.AiVaultSessionRow.hideDetails', 'Hide Details') diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts index 49e8d39e123..3e2a370e8f5 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts @@ -5,7 +5,6 @@ import { aiVaultSessionDeleteBlockedReason } from './ai-vault-session-deletabili // assertions pin the English copy as well as the gate order. const NON_LOCAL = 'Only sessions on this device can be deleted.' const SYNTHETIC = "This session can't be deleted from Orca." -const LIVE = 'This session is still running — wait for it to finish before deleting.' const localGeminiSession = { agent: 'gemini' as const, @@ -28,28 +27,6 @@ describe('aiVaultSessionDeleteBlockedReason', () => { ).toBeNull() }) - it('tells the user to wait while the agent is still running', () => { - for (const live of ['working', 'blocked', 'waiting'] as const) { - expect(aiVaultSessionDeleteBlockedReason(localGeminiSession, live)).toBe(LIVE) - } - }) - - it('offers Delete for a finished session (done) and one with no live state', () => { - expect(aiVaultSessionDeleteBlockedReason(localGeminiSession, 'done')).toBeNull() - expect(aiVaultSessionDeleteBlockedReason(localGeminiSession, null)).toBeNull() - }) - - it('keeps the permanent reason over "running" for an unsupported live session', () => { - // A live but unsupported agent stays "unsupported" — it would never become - // deletable, so "wait for it to finish" would mislead. - expect( - aiVaultSessionDeleteBlockedReason( - { agent: 'codex', executionHostId: 'local', filePath: '/home/user/.codex/x.jsonl' }, - 'working' - ) - ).toBe("Codex sessions can't be deleted from Orca.") - }) - it('blocks ssh- and runtime-hosted sessions regardless of agent', () => { for (const executionHostId of ['ssh:dev-box', 'runtime:gpu-box'] as const) { expect(aiVaultSessionDeleteBlockedReason({ ...localGeminiSession, executionHostId })).toBe( diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.ts index 033ebd87c81..87007c376af 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.ts @@ -1,6 +1,5 @@ import { isAiVaultDeletableAgent } from '../../../../shared/ai-vault-session-deletion' import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import type { AgentStatusState } from '../../../../shared/agent-status-types' import { translate } from '@/i18n/i18n' import { agentLabel } from './ai-vault-session-filters' import { @@ -8,11 +7,6 @@ import { isSyntheticAiVaultSessionPath } from './ai-vault-session-path-actions' -// Matches the active-dot rule in ai-vault-session-row-display. -function isSessionLive(liveState: AgentStatusState | null | undefined): boolean { - return liveState != null && liveState !== 'done' -} - /** * Why Delete is unavailable for this session, as the tooltip text to show — or * null when it is offered. Each message says which sessions are affected, never @@ -25,8 +19,7 @@ function isSessionLive(liveState: AgentStatusState | null | undefined): boolean * it does: both consult the same shared agent set and host/synthetic predicates. */ export function aiVaultSessionDeleteBlockedReason( - session: Pick, - liveState?: AgentStatusState | null + session: Pick ): string | null { if (!canUseLocalAiVaultSessionPathActions(session.executionHostId)) { return translate( @@ -47,14 +40,5 @@ export function aiVaultSessionDeleteBlockedReason( { value0: agentLabel(session.agent) } ) } - // Last, so an otherwise-deletable session reads as "wait for it to finish" - // rather than a permanent reason. Trashing a live transcript would drop the - // writes the agent is still appending. - if (isSessionLive(liveState)) { - return translate( - 'auto.components.right.sidebar.AiVaultSessionRow.deleteReasonSessionLive', - 'This session is still running — wait for it to finish before deleting.' - ) - } return null } diff --git a/src/shared/ai-vault-session-deletion.ts b/src/shared/ai-vault-session-deletion.ts index 7fdde68b0e4..deda545f26e 100644 --- a/src/shared/ai-vault-session-deletion.ts +++ b/src/shared/ai-vault-session-deletion.ts @@ -71,10 +71,6 @@ export type AiVaultSessionDeleteRejectionCode = // fs-side guard: lstat disagrees with the removal's declared kind (a symlink, // or a file where the plan expects a directory). | 'unexpected-target-kind' - | 'session-live' - | 'session-liveness-unknown' - -export type AiVaultSessionLiveness = 'live' | 'not-live' | 'unknown' // One path the executor removes. A `kind` mismatch on disk is a rejection, // never a coerced delete. `roots` are what the path's realpath must still