Strip liveness gate from AI Vault session delete (#13279)

* Strip liveness gate from AI Vault session delete

Delete now requires only path validation + user confirmation — no process
roster, no liveness check, no quiescence, no ownership ledger.

Co-authored-by: Orca <help@stably.ai>

* Remove obsolete AI Vault liveness delete reliability gate

Session delete no longer checks process liveness, so drop the
manifest entry that still referenced the deleted test files.

* minor fix

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing 2026-08-09 18:40:01 -07:00 committed by GitHub
parent cb6069ceea
commit b075a95b06
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 32 additions and 924 deletions

View File

@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-08-07",
"updatedAt": "2026-08-09",
"policy": {
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
"blockingPromotion": {
@ -10,103 +10,6 @@
}
},
"gates": [
{
"id": "ai-vault.live-session-delete-authority",
"title": "AI Vault deletion fails closed against owning-runtime session liveness",
"maturity": "experimental",
"protection": "partial",
"owner": "ai-vault-runtime",
"layer": "main-process-filesystem-mutation",
"surfaces": [
"AI Vault transcript deletion",
"local and WSL agent sessions",
"paired-client-owned host sessions"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "wsl", "ssh", "paired-runtime"],
"coveredPlatforms": ["macos", "linux", "windows"],
"coveredProviders": ["local", "wsl", "ssh", "paired-runtime"],
"coverageNotes": "Platform-independent contracts cover local and WSL authority, paired-owned and unmanaged live sessions absent from renderer or managed-PTY state, identity retention across UI dismissal, inventory and foreground-inspection ordering, SSH exclusion, unavailable and ambiguous inventory, bounded inspection concurrency, oversized inventories, returned rejection, and real temporary-file survival. Live headed/headless paired-runtime, physical Windows/WSL, and Docker SSH journeys remain uncollected.",
"motivatingLinks": ["https://github.com/stablyai/orca/releases/tag/v1.4.177-rc.0"],
"invariant": "Main may delete a local AI transcript only after the owning runtime freshly proves that no live local or WSL provider-session identity exists, including exact hook identities absent from managed PTY inventory; live and unknown liveness preserve the transcript regardless of renderer freshness.",
"oracle": "Open confirmation against a stale empty renderer snapshot, transition the authoritative session to live before main authorization and during a controlled foreground-inspection barrier, and separately model paired and unmanaged local owners absent from renderer or managed-PTY state. Dismiss a live row and require its identity-only authority to remain. Require session-live or session-liveness-unknown rejection and the real temporary transcript to survive. Make process inventory unavailable or ambiguous and require unknown with the file preserved; prove known other sessions permit deletion and inspection concurrency stays at or below eight.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ai-vault/session-delete-liveness.repro.test.ts src/main/ai-vault/session-liveness.test.ts src/main/agent-hooks/server-ai-vault-liveness.test.ts --reporter=dot"
],
"testFiles": [
"src/main/ai-vault/session-delete-liveness.repro.test.ts",
"src/main/ai-vault/session-liveness.test.ts",
"src/main/agent-hooks/server-ai-vault-liveness.test.ts"
],
"assertionRefs": [
{
"file": "src/main/ai-vault/session-delete-liveness.repro.test.ts",
"assertions": [
"confirmation TOCTOU returns session-live and preserves the real transcript",
"paired ownership missing from the renderer snapshot returns session-live and preserves the real transcript",
"exact live local hook identity missing from managed PTY inventory returns unknown and preserves the real transcript",
"dismissed live status retained as identity-only authority returns unknown and preserves the real transcript",
"unknown authoritative liveness fails closed and preserves the real transcript"
]
},
{
"file": "src/main/ai-vault/session-liveness.test.ts",
"assertions": [
"local, WSL, paired-runtime, and SSH host partitions resolve without local fallback",
"renderer session identity must match the identity parsed from the validated transcript",
"hook identity is captured after the controlled process-inventory barrier",
"hook identity is captured after the controlled foreground-inspection barrier",
"missing identity, unavailable inventory, unattributed ownership, and oversized inventory remain unknown",
"foreground inspection concurrency never exceeds eight and stops scheduling at the shared deadline"
]
},
{
"file": "src/main/agent-hooks/server-ai-vault-liveness.test.ts",
"assertions": [
"user dismissal retains provider identity without retaining a visible turn-status row",
"restart hydration preserves dismissed identity as unconfirmed liveness-only authority"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-07",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ai-vault/session-delete-liveness.repro.test.ts src/main/ai-vault/session-liveness.test.ts src/main/agent-hooks/server-ai-vault-liveness.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 1,
"summary": "Twenty deterministic deletion, identity-binding, inventory/inspection-ordering, dismissal/restart-retention, unmanaged-ownership, unknown-state, filesystem-survival, host-partition, deadline, and bounded-concurrency assertions passed."
}
],
"runtimeBudget": {
"p95Seconds": 2,
"scope": "focused main-process liveness and temporary-filesystem contracts"
},
"flakeHistory": {
"status": "unknown",
"evidence": "One local deterministic run is recorded; CI and soak history have not started."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The byte-identical two-scenario oracle failed on v1.4.177-rc.0 (9e948fbdf4) and latest main (7a867f12aa): both returned deleted and both real transcripts were removed. On PR commit 8e2fcfd681, an exact live local hook identity absent from empty managed-PTY inventory returned deleted and removed the real transcript. Independent review then proved a session-other to session-live transition during foreground inspection returned not-live, and live-row dismissal erased the only external identity. The corrected candidate passed all 20 assertions, including restart hydration of dismissed identity; temporarily disabling only the original liveness decision made all three original deletion-safety tests red again."
},
"performanceBudget": {
"required": true,
"evidence": "Deletion parses only the validated target through the incremental parse cache, performs one local-provider process listing, makes no SSH or paired-runtime request, caps inventory at 512 PTYs, bounds foreground inspections to eight concurrent calls under one shared three-second fail-closed deadline, and adds no polling or background work."
},
"promotionCriteria": [
"Collect CI soak history for the deterministic gate.",
"Collect headed and headless paired-runtime deletion-refusal evidence.",
"Collect physical Windows/WSL and Docker SSH host-partition evidence."
],
"knownGaps": [
"No live headed/headless paired-runtime journey was run.",
"No physical Windows/WSL or Docker SSH journey was run.",
"The authorization check cannot make an external provider process start atomic with filesystem trashing; it closes the reported renderer-confirmation window at the main mutation boundary."
],
"demotionRule": "Keep experimental or demote if live or unknown ownership can reach trashing, remote ownership falls back locally, process inspection becomes unbounded, or the deterministic filesystem oracle flakes without a product or harness defect."
},
{
"id": "editor.restored-sibling-owner-reparent",
"title": "Restored sibling tabs migrate filesystem authority before becoming editable",

View File

@ -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<AiVaultSessionLiveness> }
) => Promise<AiVaultDeleteSessionResult>
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<AiVaultSessionLiveness>
): 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<string>()
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<string>()
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)
})
})

View File

@ -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<typeof deleteAiVaultSessionFileWithLiveness>[0]
) {
return deleteAiVaultSessionFileWithLiveness(args, {
getSessionLiveness: async () => 'not-live'
})
}
function enoent(): NodeJS.ErrnoException {
const error = new Error('not found') as NodeJS.ErrnoException
error.code = 'ENOENT'

View File

@ -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<AiVaultSessionLiveness>
}
args: ValidateAiVaultSessionDeleteTargetArgs & { sessionId?: string }
): Promise<AiVaultDeleteSessionResult> {
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)

View File

@ -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> = {}
): 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<PtyProcessInfo[]>((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<void>((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<void>((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()
})
})

View File

@ -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<PtyProcessInfo[]>
getStatusSnapshot: () => AgentStatusIpcPayload[]
inspectForegroundProcess: (ptyId: string) => Promise<ProcessInspection>
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<AiVaultSessionIdentityRead> {
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<T>(
values: readonly T[],
inspect: (value: T) => Promise<AgentProcessInspection>,
deadlineMs?: number
): Promise<AgentProcessInspection[]> {
const results: AgentProcessInspection[] = []
let nextIndex = 0
const worker = async (): Promise<void> => {
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<AiVaultSessionLiveness> {
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'
}

View File

@ -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<AiVaultSessionLiveness>
}
// 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

View File

@ -800,8 +800,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)
@ -850,8 +849,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()
})

View File

@ -71,7 +71,6 @@ type AiVaultHandlerOptions = AiVaultSessionSources &
getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[]
scanRuntimeAiVaultSessions?: RuntimeAiVaultScanner
resolveRuntimeAiVaultSessionTitles?: RuntimeAiVaultSessionTitleResolver
getSessionLiveness?: Parameters<typeof deleteAiVaultSession>[1]['getSessionLiveness']
}
let scanCoordinator = new AiVaultScanCoordinator()
@ -80,10 +79,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<NonNullable<AiVaultHandlerOptions['getSessionLiveness']>>[0]
) => handlerOptions.getSessionLiveness?.(target) ?? Promise.resolve('unknown' as const)
invalidateMultiHostListCache: invalidateAiVaultHostLegCache
}
const resolveAiVaultSessionTitles = (

View File

@ -227,8 +227,7 @@ export function registerCoreHandlers(
resolveRuntimeAiVaultSessionTitles: async (environmentId, args) =>
resolveRuntimeAiVaultSessionTitles(app.getPath('userData'), environmentId, args),
prepareRuntimeSessionResume: async (environmentId, args) =>
prepareRuntimeAiVaultSessionResume(app.getPath('userData'), environmentId, args),
getSessionLiveness: (target) => runtime.getAiVaultSessionLiveness(target)
prepareRuntimeAiVaultSessionResume(app.getPath('userData'), environmentId, args)
})
registerNativeChatHandlers()
registerClipboardHandlers(store)

View File

@ -490,16 +490,11 @@ import {
listAiVaultSessions
} from '../ai-vault/cached-session-list'
import { resolveLocalAiVaultSessionTitles } from '../ai-vault/session-title-resolver'
import {
readAiVaultSessionIdentity,
resolveAiVaultSessionLiveness
} from '../ai-vault/session-liveness'
import type { AiVaultAgent, AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
import type {
AiVaultSessionTitleRequest,
AiVaultSessionTitlesResult
} from '../../shared/ai-vault-session-title'
import type { AiVaultSessionLiveness } from '../../shared/ai-vault-session-deletion'
import type {
AiVaultPrepareSessionResumeArgs,
AiVaultPrepareSessionResumeResult
@ -4990,74 +4985,6 @@ export class OrcaRuntimeService {
return resolveLocalAiVaultSessionTitles(requests, signal)
}
async getAiVaultSessionLiveness(target: {
agent: AiVaultAgent
sessionId: string | undefined
filePath: string
}): Promise<AiVaultSessionLiveness> {
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<AiVaultPrepareSessionResumeResult> {

View File

@ -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')

View File

@ -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(

View File

@ -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<AiVaultSession, 'agent' | 'executionHostId' | 'filePath'>,
liveState?: AgentStatusState | null
session: Pick<AiVaultSession, 'agent' | 'executionHostId' | 'filePath'>
): 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
}

View File

@ -11738,8 +11738,7 @@
"delete": "Delete",
"deleteReasonNonLocalHost": "Only sessions on this device can be deleted.",
"deleteReasonSyntheticPath": "This session can't be deleted from Orca.",
"deleteReasonUnsupportedAgent": "{{value0}} sessions can't be deleted from Orca.",
"deleteReasonSessionLive": "This session is still running — wait for it to finish before deleting."
"deleteReasonUnsupportedAgent": "{{value0}} sessions can't be deleted from Orca."
},
"AiVaultSessionDeleteDialog": {
"title": "Delete this session?",

View File

@ -11318,8 +11318,7 @@
"delete": "Eliminar",
"deleteReasonNonLocalHost": "Solo se pueden eliminar las sesiones de este dispositivo.",
"deleteReasonSyntheticPath": "Esta sesión no se puede eliminar desde Orca.",
"deleteReasonUnsupportedAgent": "Las sesiones de {{value0}} no se pueden eliminar desde Orca.",
"deleteReasonSessionLive": "Esta sesión aún se está ejecutando: espera a que termine antes de eliminarla."
"deleteReasonUnsupportedAgent": "Las sesiones de {{value0}} no se pueden eliminar desde Orca."
},
"AiVaultSessionDeleteDialog": {
"title": "¿Eliminar esta sesión?",

View File

@ -11318,8 +11318,7 @@
"delete": "削除",
"deleteReasonNonLocalHost": "このデバイス上のセッションのみ削除できます。",
"deleteReasonSyntheticPath": "このセッションは Orca では削除できません。",
"deleteReasonUnsupportedAgent": "{{value0}} のセッションは Orca では削除できません。",
"deleteReasonSessionLive": "このセッションはまだ実行中です — 削除する前に完了を待ってください。"
"deleteReasonUnsupportedAgent": "{{value0}} のセッションは Orca では削除できません。"
},
"AiVaultSessionDeleteDialog": {
"title": "このセッションを削除しますか?",

View File

@ -11318,8 +11318,7 @@
"delete": "삭제",
"deleteReasonNonLocalHost": "이 기기에 있는 세션만 삭제할 수 있습니다.",
"deleteReasonSyntheticPath": "이 세션은 Orca에서 삭제할 수 없습니다.",
"deleteReasonUnsupportedAgent": "{{value0}} 세션은 Orca에서 삭제할 수 없습니다.",
"deleteReasonSessionLive": "이 세션은 아직 실행 중입니다 — 삭제하려면 완료될 때까지 기다리세요."
"deleteReasonUnsupportedAgent": "{{value0}} 세션은 Orca에서 삭제할 수 없습니다."
},
"AiVaultSessionDeleteDialog": {
"title": "이 세션을 삭제할까요?",

View File

@ -11338,8 +11338,7 @@
"delete": "删除",
"deleteReasonNonLocalHost": "只能删除此设备上的会话。",
"deleteReasonSyntheticPath": "此会话无法在 Orca 中删除。",
"deleteReasonUnsupportedAgent": "{{value0}} 会话无法在 Orca 中删除。",
"deleteReasonSessionLive": "此会话仍在运行 — 请等待其完成后再删除。"
"deleteReasonUnsupportedAgent": "{{value0}} 会话无法在 Orca 中删除。"
},
"AiVaultSessionDeleteDialog": {
"title": "要删除此会话吗?",

View File

@ -4,7 +4,8 @@ import type { ExecutionHostId } from './execution-host'
// IPC payload for aiVault:deleteSession.
export type AiVaultDeleteSessionArgs = {
agent: AiVaultAgent
// Optional for mixed renderer/main versions; main rejects missing identity.
// Optional for mixed renderer/main versions. Main ignores this field on delete
// (path + host + agent validation only; no identity/liveness check).
sessionId?: string
filePath: string
// The session's host; only a local session may be deleted.
@ -71,10 +72,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

View File

@ -149,7 +149,12 @@ test.describe('Terminal macOS system key remap', () => {
expect(received).toEqual([Buffer.from(`${committed}\n`).toString('hex')])
completed = true
} finally {
await closeTerminalImePaneArena(arena, testInfo, `${arm.slug}-${layout.label}`, !completed)
await closeTerminalImePaneArena(
arena,
testInfo,
`${arm.slug}-${layout.label}`,
!completed
)
removeTerminalImeByteReader(reader)
}
})