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:
parent
cb6069ceea
commit
b075a95b06
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -11318,8 +11318,7 @@
|
|||
"delete": "削除",
|
||||
"deleteReasonNonLocalHost": "このデバイス上のセッションのみ削除できます。",
|
||||
"deleteReasonSyntheticPath": "このセッションは Orca では削除できません。",
|
||||
"deleteReasonUnsupportedAgent": "{{value0}} のセッションは Orca では削除できません。",
|
||||
"deleteReasonSessionLive": "このセッションはまだ実行中です — 削除する前に完了を待ってください。"
|
||||
"deleteReasonUnsupportedAgent": "{{value0}} のセッションは Orca では削除できません。"
|
||||
},
|
||||
"AiVaultSessionDeleteDialog": {
|
||||
"title": "このセッションを削除しますか?",
|
||||
|
|
|
|||
|
|
@ -11318,8 +11318,7 @@
|
|||
"delete": "삭제",
|
||||
"deleteReasonNonLocalHost": "이 기기에 있는 세션만 삭제할 수 있습니다.",
|
||||
"deleteReasonSyntheticPath": "이 세션은 Orca에서 삭제할 수 없습니다.",
|
||||
"deleteReasonUnsupportedAgent": "{{value0}} 세션은 Orca에서 삭제할 수 없습니다.",
|
||||
"deleteReasonSessionLive": "이 세션은 아직 실행 중입니다 — 삭제하려면 완료될 때까지 기다리세요."
|
||||
"deleteReasonUnsupportedAgent": "{{value0}} 세션은 Orca에서 삭제할 수 없습니다."
|
||||
},
|
||||
"AiVaultSessionDeleteDialog": {
|
||||
"title": "이 세션을 삭제할까요?",
|
||||
|
|
|
|||
|
|
@ -11338,8 +11338,7 @@
|
|||
"delete": "删除",
|
||||
"deleteReasonNonLocalHost": "只能删除此设备上的会话。",
|
||||
"deleteReasonSyntheticPath": "此会话无法在 Orca 中删除。",
|
||||
"deleteReasonUnsupportedAgent": "{{value0}} 会话无法在 Orca 中删除。",
|
||||
"deleteReasonSessionLive": "此会话仍在运行 — 请等待其完成后再删除。"
|
||||
"deleteReasonUnsupportedAgent": "{{value0}} 会话无法在 Orca 中删除。"
|
||||
},
|
||||
"AiVaultSessionDeleteDialog": {
|
||||
"title": "要删除此会话吗?",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue