feat(native-chat): chat scratch folders expire after 30 days unused (#28394)

* fix(files): never write through a dangling symlink

A path that doesn't exist yet was authorized by walking up to the nearest
existing folder. A dangling symlink fails realpath with ENOENT, so the walk
stepped over it and approved the link's own path; the write then followed
the link and created its target outside the allowed folders. The walk now
continues from the link's target, and returns that real destination.

* refactor(native-chat): rename the chat visuals folder to the chat scratch folder

The per-chat folder agents write into is the chat's scratch space, not only
a visuals folder. Its root becomes <state>/chat-scratch/<hash> (the hash is
unchanged) and the agent variable becomes ORCA_CHAT_SCRATCH_DIR. The old
ORCA_CHAT_VISUALS_DIR is never set and is still stripped from what every
agent child inherits, Pi included. The visuals skill names the new variable
and says the folder is temporary.

* test(native-chat): keep the delivery test to the rename

The launch parity snapshot already pins that every agent drops the
pre-rename variable.

* feat(native-chat): chat scratch folders expire after 30 days unused

A chat's scratch folder was removed only when its record was gone or its
local workspace was proven deleted, so the folder of a long-idle chat
lived forever.

The sweep now expires a chat's folder once its newest journal row and
the newest entry anywhere in the folder (complete, no-follow scan) are
older than 30 days, unless the host holds a running agent, a delivery or
an open dispatch for the chat. The final recheck and a rename to
<hash>.expired run in the chat's session lane and then a per-folder lock
that Orca scratch saves (desktop IPC and host RPC) also take; the delete
runs outside both. A save that settles after the age scan marks that
expiry stale. Leftover .expired folders and the pre-rename
native-chat-visuals root are removed.

The workspace-removed rule and its catalog verdict are deleted.

* fix(files): refuse any dangling symlink in the missing-path walk

Following a dangling link by hand resolved relative targets against the
lexical parent, not the link's real folder: through a folder link the walk
could loop forever or authorize the wrong file. Refuse instead: realpath
can't say where a dangling link leads, but a write would follow it.

* test(files): drop the loop case that duplicated the dangling-link refusal

* refactor(native-chat): trim the scratch expiry and test the real live-work veto

- Remove the other-profile catalog options (running profile id, never-written
  profiles) that only the deleted workspace verdict used; history cleanup's
  reader is unchanged.
- The sweep keeps its original remove-and-warn block; an unused chat's
  folder is parked by one function. Drop the unused `now` option and the
  candidate identity check.
- The live-agent case runs against the host's real runWhileExecutionIdle.

* style(native-chat): keep the session host within its line limit after the main sync

* fix(native-chat): every scratch save and every overlapping sweep share the folder lock

Binary (base64) runtime saves now take the scratch folder lock like text saves, and each folder
keeps a set of expiry candidates so one sweep finishing no longer hides a save from another.
This commit is contained in:
Brennan Benson
2026-10-14 18:22:39 -07:00
committed by GitHub
parent 095399aa4d
commit e2fa611916
17 changed files with 364 additions and 658 deletions
-1
View File
@@ -4456,7 +4456,6 @@
"src/main/runtime/mobile-subscribe-idle-pane-hydrate.test.ts": 5902,
"src/main/runtime/mobile-subscribe-integration.test.ts": 22684,
"src/main/runtime/multi-client-navigation-isolation.integration.test.ts": 5282,
"src/main/runtime/native-chat-visuals-workspace-verdict.test.ts": 326,
"src/main/runtime/omp-readiness-transcripts.test.ts": 7797,
"src/main/runtime/opencode-agent-row-enter-transcript.test.ts": 562,
"src/main/runtime/opencode-agent-row-structure-transcript.test.ts": 347,
@@ -152,6 +152,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts',
'src/main/native-chat/native-chat-visuals-sweep.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts',
@@ -14,6 +14,8 @@ import { registerFilesystemMutationHandlers } from '../filesystem-mutations'
import { registerAgentSessionAttachmentUploadHandlers } from '../agent-session-attachment-upload-ipc'
import type { FilesystemHandlerContext } from './filesystem-handler-context'
import { assertLocalWriteTargetIsRegularFile } from './local-regular-file-read'
import { runNativeChatScratchSave } from '../../native-chat/native-chat-scratch-folder-lock'
import { getProfileUserDataPath } from '../../orca-profiles/profile-storage-paths'
export function registerFilesystemWriteHandlers(context: FilesystemHandlerContext): void {
const { store } = context
@@ -50,7 +52,9 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex
}
}
await assertLocalWriteTargetIsRegularFile(filePath)
await writeFile(filePath, args.content, 'utf-8')
await runNativeChatScratchSave(getProfileUserDataPath(), filePath, () =>
writeFile(filePath, args.content, 'utf-8')
)
}
)
@@ -21,12 +21,23 @@ const SELECT_ROWS_AFTER = `SELECT seq, ts, row_json FROM journal_rows
WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC`
const SELECT_ROWS_AFTER_LIMITED = `${SELECT_ROWS_AFTER} LIMIT ?`
const DELETE_EPOCH = 'DELETE FROM journal_rows WHERE session_id = ? AND epoch = ?'
const SELECT_LAST_ACTIVITY = 'SELECT MAX(ts) AS ts FROM journal_rows WHERE session_id = ?'
export function readJournalSessionEpoch(db: Database.Database, sessionId: string): string | null {
const epoch = db.prepare(SELECT_EPOCH).get(sessionId)?.epoch
return typeof epoch === 'string' ? epoch : null
}
/** When the chat's newest row was saved, across every epoch; null for a chat with no rows. One
* aggregate over the chat's key range, so no row body reaches this process. */
export function readJournalSessionLastActivityAt(
db: Database.Database,
sessionId: string
): number | null {
const ts = db.prepare(SELECT_LAST_ACTIVITY).get(sessionId)?.ts
return typeof ts === 'number' ? ts : null
}
/** Points the chat at `epoch`. Only an epoch change writes this row. */
export function publishJournalSessionEpoch(
db: Database.Database,
@@ -77,6 +77,13 @@ export function createStructuredAgentSessionConversationLifetime(host: {
return closed
}
const hasOpenDispatch = (sessionId: string): boolean => {
const record = deps().store.getRecord(sessionId)
return record !== null && deps().hasOpenDispatch?.(record) === true
}
const providerHoldsDispatch = (sessionId: string): boolean =>
deps().adapter.holdsDispatch?.(sessionId) === true
const idleSweep = new StructuredAgentSessionIdleSweep({
sessions,
serialize,
@@ -84,11 +91,8 @@ export function createStructuredAgentSessionConversationLifetime(host: {
isDisposed: () => disposed,
deliveryActive: host.deliveryActive,
childWork: host.readChildWork,
hasOpenDispatch: (sessionId) => {
const record = deps().store.getRecord(sessionId)
return record !== null && deps().hasOpenDispatch?.(record) === true
},
providerHoldsDispatch: (sessionId) => deps().adapter.holdsDispatch?.(sessionId) === true,
hasOpenDispatch,
providerHoldsDispatch,
// The host puts an idle agent to rest: a turn it cuts short is news, not the user's Stop.
stopAgent: (sessionId) => stopAgent(sessionId, { cause: 'evict', resting: true }),
// A host stop: the child's end rejects what it was handed and writes the one error row, and
@@ -130,6 +134,18 @@ export function createStructuredAgentSessionConversationLifetime(host: {
})
)
},
/** Runs `task` in the chat's lane, so no start interleaves with it, unless quit has begun or
* the host holds a child, a delivery or a dispatch for the chat; null then. */
runWhileExecutionIdle: <T>(sessionId: string, task: () => Promise<T>): Promise<T | null> =>
serialize(sessionId, async () =>
disposed ||
sessions.get(sessionId)?.child ||
host.deliveryActive(sessionId) ||
providerHoldsDispatch(sessionId) ||
hasOpenDispatch(sessionId)
? null
: await task()
),
/** Quit has begun: nothing opens a conversation or sweeps one after this. */
dispose: (): void => {
disposed = true
@@ -177,8 +177,7 @@ export class StructuredAgentSessionHost {
private now = (): number => this.deps.now?.() ?? Date.now()
hasSession = (sessionId: string): boolean => this.sessions.has(sessionId)
hasLoadedProviders = (): boolean =>
[...this.sessions.values()].some(({ child }) => child !== null)
hasLoadedProviders = () => [...this.sessions.values()].some(({ child }) => child !== null)
sessionAgent = (sessionId: string) => this.deps.store.getRecord(sessionId)?.provider ?? null
handleAdapterEvent = (event: Parameters<StructuredAgentSessionEventRecovery['handle']>[0]) =>
@@ -232,6 +231,8 @@ export class StructuredAgentSessionHost {
showSessionTabs = this.tabs.showSessionTabs
setSessionTabVisibility = this.tabs.setSessionTabVisibility
notifySessionTabHidden = this.tabs.notifySessionTabHidden
runWhileExecutionIdle = <T>(sessionId: string, task: () => Promise<T>): Promise<T | null> =>
this.lifetime.runWhileExecutionIdle(sessionId, task)
/** This runtime holds a chat record, readable or not. */
holdsSessions = (): boolean => this.deps.store.holdsRecords()
onSessionsHeld = (listener: () => void): (() => void) => this.deps.store.onFirstRecord(listener)
@@ -0,0 +1,72 @@
// Orders an Orca save into a chat's scratch folder against the sweep expiring that folder. Expiry
// renames the folder aside under this lock, so a save either lands first (its candidate mark keeps
// the folder) or runs after and fails on the missing folder, leaving the draft unsaved. A save
// never takes the session lane, so it never waits for an agent start.
import { relative } from 'node:path'
import { runKeyedSerializedOperation } from '../cli/keyed-promise-queue'
import {
isNativeChatScratchFolderName,
nativeChatScratchRootFor
} from './native-chat-scratch-folder'
type ExpiryCandidate = { current: boolean }
const folderQueues = new Map<string, Promise<void>>()
// The age scan runs outside the lock, so a save that settles after it starts makes it stale.
// A set per folder: each runtime install runs its own sweep, so two can scan one folder at once.
const expiryCandidates = new Map<string, Set<ExpiryCandidate>>()
/** The scratch folder `filePath` lies inside, or null for any other path. */
function scratchFolderNameOf(stateDirectory: string, filePath: string): string | null {
const segments = relative(nativeChatScratchRootFor(stateDirectory), filePath).split(/[\\/]/)
return segments.length > 1 && isNativeChatScratchFolderName(segments[0]) ? segments[0] : null
}
/** Runs an Orca save; one into a chat scratch folder holds that folder's lock. */
export function runNativeChatScratchSave<T>(
stateDirectory: string,
filePath: string,
save: () => Promise<T>
): Promise<T> {
const folderName = scratchFolderNameOf(stateDirectory, filePath)
if (!folderName) {
return save()
}
return runKeyedSerializedOperation(folderQueues, folderName, async () => {
try {
return await save()
} finally {
for (const candidate of expiryCandidates.get(folderName) ?? []) {
candidate.current = false
}
}
})
}
export function runUnderNativeChatScratchFolderLock<T>(
folderName: string,
task: () => Promise<T>
): Promise<T> {
return runKeyedSerializedOperation(folderQueues, folderName, task)
}
/** Registered before the sweep's age scan; the scan stays usable only while `isCurrent`. */
export function trackNativeChatScratchExpiryCandidate(folderName: string): {
isCurrent: () => boolean
dispose: () => void
} {
const candidate: ExpiryCandidate = { current: true }
const candidates = expiryCandidates.get(folderName) ?? new Set<ExpiryCandidate>()
candidates.add(candidate)
expiryCandidates.set(folderName, candidates)
return {
isCurrent: () => candidate.current,
dispose: () => {
candidates.delete(candidate)
if (candidates.size === 0) {
expiryCandidates.delete(folderName)
}
}
}
}
@@ -5,6 +5,7 @@ import { createHash } from 'node:crypto'
import { join } from 'node:path'
const NATIVE_CHAT_SCRATCH_DIR_NAME = 'chat-scratch'
const FOLDER_NAME = /^[0-9a-f]{32}$/
/** Where every chat's scratch folder on this host lives. */
export function nativeChatScratchRootFor(stateDirectory: string): string {
@@ -16,6 +17,11 @@ export function nativeChatScratchFolderName(sessionId: string): string {
return createHash('sha256').update(sessionId, 'utf8').digest('hex').slice(0, 32)
}
/** A name this host minted for a chat's folder; nothing else under the root is a chat's. */
export function isNativeChatScratchFolderName(name: string): boolean {
return FOLDER_NAME.test(name)
}
/**
* `<stateDirectory>/chat-scratch/<sha256(session id), first 32 hex>`. Keyed by the session id
* alone, the chat record's primary key, so the folder needs no workspace lookup to find or remove;
@@ -1,16 +1,35 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
symlinkSync,
utimesSync,
writeFileSync
} from 'node:fs'
import { writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { basename, join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { runKeyedSerializedOperation } from '../cli/keyed-promise-queue'
import type { StructuredAgentSessionLogger } from './agent-session-wire/structured-agent-session-logger'
import { nativeChatScratchFolderFor, nativeChatScratchRootFor } from './native-chat-scratch-folder'
import {
runNativeChatScratchSave,
trackNativeChatScratchExpiryCandidate
} from './native-chat-scratch-folder-lock'
import {
attach,
hostTestState
} from './agent-session-wire/structured-agent-session-host-test-harness'
import { HOST_TEST_SESSION } from './agent-session-wire/structured-agent-session-host-test-data'
import {
scheduleNativeChatVisualsSweep,
sweepNativeChatVisualsFolders,
type NativeChatVisualsSweepDeps,
type NativeChatVisualsWorkspaceVerdict
type NativeChatVisualsSweepDeps
} from './native-chat-visuals-sweep'
const scratch: string[] = []
@@ -34,25 +53,36 @@ function visualsFor(state: string, sessionId: string): string {
return folder
}
const location = (workspaceId: string): AgentSessionExecutionLocation => ({
executionHostId: LOCAL_EXECUTION_HOST_ID,
wslDistro: null,
workspaceId,
workspaceKind: 'git-worktree'
})
const DAY_MS = 24 * 60 * 60_000
/** Sets the mtime of `path` and everything under it to `daysAgo`, children before parents. */
function age(path: string, daysAgo = 40): void {
if (statSync(path).isDirectory()) {
for (const name of readdirSync(path)) {
age(join(path, name), daysAgo)
}
}
const at = new Date(Date.now() - daysAgo * DAY_MS)
utimesSync(path, at, at)
}
function deps(
state: string,
held: readonly string[] | null,
verdicts: Record<string, NativeChatVisualsWorkspaceVerdict> = {}
facts: { journal?: Record<string, number> } = {}
): NativeChatVisualsSweepDeps & {
logger: { warn: ReturnType<typeof vi.fn<StructuredAgentSessionLogger['warn']>> }
lanes: Map<string, Promise<void>>
} {
const lanes = new Map<string, Promise<void>>()
return {
stateDirectory: state,
listHeldSessionIds: () => held,
locationOf: (sessionId) => (held?.includes(sessionId) ? location(`ws-${sessionId}`) : null),
workspaceVerdicts: () => async (where) => verdicts[where.workspaceId] ?? 'present',
lastJournalActivityAt: (sessionId) => facts.journal?.[sessionId] ?? null,
// Stands in for the host's lane: one task at a time per chat.
runWhileExecutionIdle: <T>(sessionId: string, task: () => Promise<T>) =>
runKeyedSerializedOperation<T | null>(lanes, sessionId, task),
lanes,
logger: { warn: vi.fn<StructuredAgentSessionLogger['warn']>(), error: vi.fn() }
}
}
@@ -72,32 +102,86 @@ describe('the visuals folder sweep', () => {
expect(existsSync(orphan)).toBe(false)
})
it('removes a chat folder only when its local workspace is proven removed', async () => {
it('expires a chat unused for 30 days and keeps one with recent use', async () => {
const state = stateDirectory()
const removed = visualsFor(state, 'chat-a')
const unverifiable = visualsFor(state, 'chat-b')
const present = visualsFor(state, 'chat-c')
await sweepNativeChatVisualsFolders(
deps(state, ['chat-a', 'chat-b', 'chat-c'], {
'ws-chat-a': 'removed',
'ws-chat-b': 'unverifiable'
const unused = visualsFor(state, 'chat-unused')
const talkedTo = visualsFor(state, 'chat-journal')
const edited = visualsFor(state, 'chat-nested')
mkdirSync(join(edited, 'drafts'))
writeFileSync(join(edited, 'drafts', 'v2.html'), '<p>v2</p>')
const legacy = join(state, 'native-chat-visuals', 'a'.repeat(32))
mkdirSync(legacy, { recursive: true })
age(nativeChatScratchRootFor(state))
const fresh = new Date()
utimesSync(join(edited, 'drafts', 'v2.html'), fresh, fresh)
const result = await sweepNativeChatVisualsFolders(
deps(state, ['chat-unused', 'chat-journal', 'chat-nested'], {
journal: { 'chat-unused': Date.now() - 31 * DAY_MS, 'chat-journal': Date.now() - DAY_MS }
})
)
expect(existsSync(removed)).toBe(false)
expect(existsSync(unverifiable)).toBe(true)
expect(existsSync(present)).toBe(true)
expect(result).toEqual({ removed: 1, failed: 0 })
expect(existsSync(unused)).toBe(false)
expect(existsSync(`${unused}.expired`)).toBe(false)
expect(existsSync(talkedTo)).toBe(true)
expect(existsSync(edited)).toBe(true)
expect(existsSync(join(state, 'native-chat-visuals'))).toBe(false)
})
it('keeps a held chat whose workspace verdict throws', async () => {
it('keeps a chat past 30 days while the host runs its agent', async () => {
await attach()
const state = stateDirectory()
const folder = visualsFor(state, HOST_TEST_SESSION)
age(folder)
const result = await sweepNativeChatVisualsFolders({
...deps(state, [HOST_TEST_SESSION]),
runWhileExecutionIdle: hostTestState().host.runWhileExecutionIdle
})
expect(result).toEqual({ removed: 0, failed: 0 })
expect(existsSync(folder)).toBe(true)
})
it('gives a start queued behind expiry a fresh folder', async () => {
const state = stateDirectory()
const folder = visualsFor(state, 'chat-a')
await sweepNativeChatVisualsFolders({
...deps(state, ['chat-a']),
workspaceVerdicts: () => async () => {
throw new Error('catalog unavailable')
age(folder)
const sweep = deps(state, ['chat-a'])
const result = await sweepNativeChatVisualsFolders({
...sweep,
runWhileExecutionIdle: (sessionId, task) => {
const expiry = sweep.runWhileExecutionIdle(sessionId, task)
// The start's own lane task, as the host's agent start runs it.
void runKeyedSerializedOperation(sweep.lanes, sessionId, async () => {
mkdirSync(folder, { recursive: true })
writeFileSync(join(folder, 'new.html'), '<p>new</p>')
})
return expiry
}
})
expect(existsSync(folder)).toBe(true)
expect(result).toEqual({ removed: 1, failed: 0 })
expect(readdirSync(folder)).toEqual(['new.html'])
expect(existsSync(`${folder}.expired`)).toBe(false)
})
it('keeps a folder saved into after its age scan', async () => {
const state = stateDirectory()
const folder = visualsFor(state, 'chat-a')
age(folder)
const sweep = deps(state, ['chat-a'])
await sweepNativeChatVisualsFolders({
...sweep,
runWhileExecutionIdle: async (sessionId, task) => {
// An overlapping sweep of the same folder ends first; this sweep's candidate must survive.
trackNativeChatScratchExpiryCandidate(basename(folder)).dispose()
// Settles between the scan and the locked recheck, which does not scan again.
await runNativeChatScratchSave(state, join(folder, 'chart.html'), () =>
writeFile(join(folder, 'chart.html'), '<p>saved</p>')
)
return sweep.runWhileExecutionIdle(sessionId, task)
}
})
expect(readFileSync(join(folder, 'chart.html'), 'utf8')).toBe('<p>saved</p>')
})
it('removes nothing when the held chats cannot be read', async () => {
@@ -177,13 +261,4 @@ describe('the visuals folder sweep', () => {
await sweepNativeChatVisualsFolders(deps(state, []))
expect(existsSync(join(elsewhere, 'a'.repeat(32)))).toBe(true)
})
it('takes one verdict snapshot per run', async () => {
const state = stateDirectory()
visualsFor(state, 'chat-a')
visualsFor(state, 'chat-b')
const workspaceVerdicts = vi.fn(() => async () => 'present' as const)
await sweepNativeChatVisualsFolders({ ...deps(state, ['chat-a', 'chat-b']), workspaceVerdicts })
expect(workspaceVerdicts).toHaveBeenCalledOnce()
})
})
+103 -42
View File
@@ -1,40 +1,49 @@
// How a chat's scratch folder dies. The host never deletes a chat record, so no deletion event
// exists to hang cleanup on; instead each folder's life is derived from the records every run:
// exists to hang cleanup on; instead each folder's life is re-derived every run:
// 1. a folder no chat record (readable or not) maps to is removed, so any future record
// deletion cleans up for free;
// 2. a folder whose chat ran in a local workspace this host can prove was removed is removed.
// Anything unproven is kept. A failure is logged and never blocks anything else.
// 2. a folder whose chat went unused for the retention window is removed: its newest journal row
// and everything in the folder are older than that, and the host holds no execution for it.
// Rule 2 decides again under the chat's lane and then its folder lock, so a start or a save that
// lands after the first look keeps the folder. A failure is logged and never blocks anything else.
import type { Dirent } from 'node:fs'
import { lstat, readdir, rm } from 'node:fs/promises'
import { lstat, readdir, rename, rm } from 'node:fs/promises'
import { join } from 'node:path'
import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record'
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
import { yieldToEventLoop } from '../../shared/event-loop-yield'
import type { StructuredAgentSessionLogger } from './agent-session-wire/structured-agent-session-logger'
import { nativeChatScratchFolderName, nativeChatScratchRootFor } from './native-chat-scratch-folder'
import {
isNativeChatScratchFolderName,
nativeChatScratchFolderName,
nativeChatScratchRootFor
} from './native-chat-scratch-folder'
import {
runUnderNativeChatScratchFolderLock,
trackNativeChatScratchExpiryCandidate
} from './native-chat-scratch-folder-lock'
/** `removed` only on positive proof from the host that owns the workspace. */
export type NativeChatVisualsWorkspaceVerdict = 'present' | 'removed' | 'unverifiable'
/** How long a chat's scratch files outlive its last use. */
export const NATIVE_CHAT_SCRATCH_RETENTION_MS = 30 * 24 * 60 * 60_000
export type NativeChatVisualsSweepDeps = {
stateDirectory: string
/** Every chat this host holds a row for, readable or not; null when that can't be trusted. */
listHeldSessionIds: () => readonly string[] | null
locationOf: (sessionId: string) => AgentSessionExecutionLocation | null
/** Called at most once per run, so one run judges every chat against one catalog snapshot.
* Absent: only rule 1 runs. */
workspaceVerdicts?: () => (
location: AgentSessionExecutionLocation
) => Promise<NativeChatVisualsWorkspaceVerdict>
/** When the chat's newest journal row was saved; null when it has none. */
lastJournalActivityAt: (sessionId: string) => number | null
/** Runs `task` in the chat's lane unless the host holds execution for it; null when it does. */
runWhileExecutionIdle: <T>(sessionId: string, task: () => Promise<T>) => Promise<T | null>
logger: StructuredAgentSessionLogger
remove?: (path: string) => Promise<void>
}
export type NativeChatVisualsSweepResult = { removed: number; failed: number }
// Only names this host minted: anything else in the folder is never touched.
const FOLDER_NAME = /^[0-9a-f]{32}$/
// Renamed under the locks and deleted after them; a cut-short delete leaves it for a later run.
const EXPIRED_SUFFIX = '.expired'
// The pre-rename root: native chat had no real users, and an update restarts every old child.
const LEGACY_ROOT_NAME = 'native-chat-visuals'
// Bounds one run's disk work; whatever is left goes in a later run.
const MAX_REMOVALS_PER_RUN = 200
const YIELD_EVERY = 32
@@ -60,6 +69,13 @@ export async function sweepNativeChatVisualsFolders(
): Promise<NativeChatVisualsSweepResult> {
const result = { removed: 0, failed: 0 }
const root = nativeChatScratchRootFor(deps.stateDirectory)
await rm(join(deps.stateDirectory, LEGACY_ROOT_NAME), { recursive: true, force: true }).catch(
(error: unknown) =>
deps.logger.warn('the pre-rename chat visuals root could not be removed', {
scope: 'nativeChatVisuals.sweep',
error
})
)
// Listed before the records are read: a folder is created only after its chat's record, so
// every folder seen here already has its record in the snapshot below.
const entries = await listFolders(root)
@@ -71,9 +87,7 @@ export async function sweepNativeChatVisualsFolders(
held.map((sessionId) => [nativeChatScratchFolderName(sessionId), sessionId])
)
const remove = deps.remove ?? ((path: string) => rm(path, { recursive: true, force: true }))
let verdict: ReturnType<NonNullable<NativeChatVisualsSweepDeps['workspaceVerdicts']>> | undefined
const verdictFor = (location: AgentSessionExecutionLocation) =>
(verdict ??= deps.workspaceVerdicts?.())?.(location)
const cutoff = Date.now() - NATIVE_CHAT_SCRATCH_RETENTION_MS
let visited = 0
for (const entry of entries) {
if (isStopped() || result.removed + result.failed >= MAX_REMOVALS_PER_RUN) {
@@ -82,18 +96,22 @@ export async function sweepNativeChatVisualsFolders(
if (++visited % YIELD_EVERY === 0) {
await yieldToEventLoop()
}
const expired = entry.name.endsWith(EXPIRED_SUFFIX)
const folderName = expired ? entry.name.slice(0, -EXPIRED_SUFFIX.length) : entry.name
// A Dirent describes the entry itself, so a symlink is never followed or removed here.
if (!entry.isDirectory() || !FOLDER_NAME.test(entry.name)) {
continue
}
const sessionId = sessionByFolder.get(entry.name)
const reason =
sessionId === undefined ? 'no-record' : await removedWorkspace(deps, sessionId, verdictFor)
if (!reason) {
if (!entry.isDirectory() || !isNativeChatScratchFolderName(folderName)) {
continue
}
const sessionId = expired ? undefined : sessionByFolder.get(entry.name)
const reason = expired ? 'expired' : sessionId === undefined ? 'no-record' : 'unused'
try {
await remove(join(root, entry.name))
if (
sessionId !== undefined &&
!(await parkUnusedFolder(deps, root, folderName, sessionId, cutoff))
) {
continue
}
await remove(join(root, sessionId === undefined ? entry.name : folderName + EXPIRED_SUFFIX))
result.removed += 1
} catch (error) {
result.failed += 1
@@ -108,26 +126,69 @@ export async function sweepNativeChatVisualsFolders(
return result
}
async function removedWorkspace(
deps: Pick<NativeChatVisualsSweepDeps, 'locationOf'>,
/** Renames a chat's folder to `<name>.expired` under the chat's lane and then its folder lock;
* false when the chat was used since `cutoff`, holds live work, or a save landed after the scan. */
async function parkUnusedFolder(
deps: NativeChatVisualsSweepDeps,
root: string,
name: string,
sessionId: string,
verdictFor: (
location: AgentSessionExecutionLocation
) => Promise<NativeChatVisualsWorkspaceVerdict> | undefined
): Promise<'workspace-removed' | null> {
const location = deps.locationOf(sessionId)
if (!location) {
return null
}
cutoff: number
): Promise<boolean> {
const folder = join(root, name)
const usedSince = (): boolean => (deps.lastJournalActivityAt(sessionId) ?? 0) >= cutoff
// Before the scan, so a save already running when it starts still marks it stale.
const candidate = trackNativeChatScratchExpiryCandidate(name)
try {
return (await verdictFor(location)) === 'removed' ? 'workspace-removed' : null
} catch {
return null
if (usedSince() || (await changedSince(folder, cutoff))) {
return false
}
const parked = await deps.runWhileExecutionIdle(sessionId, () =>
runUnderNativeChatScratchFolderLock(name, async () => {
if (!candidate.isCurrent() || usedSince()) {
return false
}
await rename(folder, folder + EXPIRED_SUFFIX)
return true
})
)
return parked === true
} catch (error) {
if (isDefinitiveAbsence(error)) {
return false
}
throw error
} finally {
candidate.dispose()
}
}
// Why minutes, not at boot: the first sweep must not compete with session restore and the
// workspace catalog loading; after that, a few runs a day is enough for files nothing reads.
/** Whether the folder or anything under it changed at or after `cutoff`. Every entry is read with
* lstat, so a link is judged as itself and never followed; the scan stops at the first change. */
async function changedSince(folder: string, cutoff: number): Promise<boolean> {
const pending = [folder]
let visited = 0
for (let dir = pending.pop(); dir !== undefined; dir = pending.pop()) {
if ((await lstat(dir)).mtimeMs >= cutoff) {
return true
}
for (const entry of await readdir(dir, { withFileTypes: true })) {
if (++visited % YIELD_EVERY === 0) {
await yieldToEventLoop()
}
const path = join(dir, entry.name)
if (entry.isDirectory()) {
pending.push(path)
} else if ((await lstat(path)).mtimeMs >= cutoff) {
return true
}
}
}
return false
}
// Why minutes, not at boot: the first sweep must not compete with session restore; after that, a
// few runs a day is enough for files nothing reads.
const FIRST_SWEEP_DELAY_MS = 2 * 60_000
const SWEEP_INTERVAL_MS = 6 * 60 * 60_000
@@ -287,29 +287,4 @@ describe('readOtherProfileWorkspaceCatalog', () => {
])
expect(readOtherProfileWorkspaceCatalog(root).unreadableProfiles).toBe(1)
})
it('skips the running profile even when the index already names another as active', () => {
const root = userDataWithProfiles('next', [
{ id: 'running', state: { repos: [{ id: 'repo-running' }] } },
{ id: 'next', state: { repos: [{ id: 'repo-next' }] } }
])
const catalog = readOtherProfileWorkspaceCatalog(root, { runningProfileId: 'running' })
expect(catalog.repoIds).toEqual(new Set(['repo-next']))
})
it('counts a never-written profile as empty only when asked', () => {
const root = userDataWithProfiles('active', [{ id: 'active', state: {} }, { id: 'fresh' }])
expect(readOtherProfileWorkspaceCatalog(root).unreadableProfiles).toBe(1)
const lenient = readOtherProfileWorkspaceCatalog(root, { neverWrittenIsEmpty: true })
expect(lenient.unreadableProfiles).toBe(0)
expect(lenient.repoIds.size).toBe(0)
})
it('never counts a profile with only backups of its data file as empty', () => {
const root = userDataWithProfiles('active', [{ id: 'active', state: {} }, { id: 'lost' }])
writeFileSync(join(root, 'profiles', 'lost', 'orca-data.json.bak.0'), '{}')
expect(
readOtherProfileWorkspaceCatalog(root, { neverWrittenIsEmpty: true }).unreadableProfiles
).toBe(1)
})
})
@@ -1,5 +1,4 @@
import { lstatSync, readFileSync } from 'node:fs'
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
import { folderWorkspaceKey } from '../../shared/workspace-scope'
import {
getOrcaProfileDataFile,
@@ -9,10 +8,6 @@ import {
import { getOrcaProfileIndexPath, readProfileIndex } from './profile-index-store'
import { readProfileStateDomains } from '../persistence/profile-state/profile-state-domain-reader'
import { assertNoRetainedProfileStateExports } from '../persistence/profile-state/profile-state-recovery-required'
import {
PROFILE_STATE_LEGACY_BACKUP_COUNT,
profileStateLegacyBackupPath
} from '../persistence/profile-state/legacy-json/profile-state-legacy-backup-path'
/**
* Worktree ids owned by Orca profiles OTHER than the running one.
@@ -45,20 +40,10 @@ type ProfileWorkspaceIds = {
repoIds: Set<string>
}
export type OtherProfileCatalogOptions = {
/** The profile this process runs, whose catalog the caller reads live. Defaults to the index's
* active profile, which a profile switch rewrites before the running one exits. */
runningProfileId?: string
/** A profile with no state database, sidecar or data file at all has never been written and
* holds nothing; by default it counts as unreadable like a missing file anywhere else. */
neverWrittenIsEmpty?: boolean
}
/** The workspaces and projects every profile other than the running one holds. Any profile whose
* state can't be read is counted, never skipped silently: its ids are unknown. */
export function readOtherProfileWorkspaceCatalog(
userDataPath = getProfileUserDataPath(),
options: OtherProfileCatalogOptions = {}
userDataPath = getProfileUserDataPath()
): ProfileWorkspaceIds & { unreadableProfiles: number } {
const ids = new Set<string>()
const repoIds = new Set<string>()
@@ -67,12 +52,11 @@ export function readOtherProfileWorkspaceCatalog(
return { ids, repoIds, unreadableProfiles: 0 }
}
let unreadableProfiles = 0
const running = options.runningProfileId ?? index.activeProfileId
for (const profile of index.profiles) {
if (profile.id === running) {
if (profile.id === index.activeProfileId) {
continue
}
const collected = readProfileWorktreeIds(profile.id, userDataPath, options)
const collected = readProfileWorktreeIds(profile.id, userDataPath)
if (!collected) {
unreadableProfiles += 1
continue
@@ -85,8 +69,7 @@ export function readOtherProfileWorkspaceCatalog(
function readProfileWorktreeIds(
profileId: string,
userDataPath: string,
options: OtherProfileCatalogOptions = {}
userDataPath: string
): ProfileWorkspaceIds | null {
const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath)
// A present database is authoritative. In particular, do not fall back to a
@@ -105,31 +88,9 @@ function readProfileWorktreeIds(
} catch {
return null
}
if (options.neverWrittenIsEmpty && neverWritten(dataFile)) {
return { ids: new Set(), repoIds: new Set() }
}
return readProfileWorktreeIdsFromJson(dataFile)
}
/** No data file and none of Orca's own backups of it: a lost primary file is not a fresh profile. */
function neverWritten(dataFile: string): boolean {
for (let index = 0; index < PROFILE_STATE_LEGACY_BACKUP_COUNT; index += 1) {
if (!definitivelyAbsent(profileStateLegacyBackupPath(dataFile, index))) {
return false
}
}
return definitivelyAbsent(dataFile)
}
function definitivelyAbsent(path: string): boolean {
try {
lstatSync(path)
return false
} catch (error) {
return isDefinitiveAbsence(error)
}
}
function profileStateDatabasePresence(path: string): 'absent' | 'present' | 'unreadable' {
let mainDatabasePresent = false
try {
+14 -6
View File
@@ -6,6 +6,8 @@ import {
resolveFileExplorerPaths
} from './file-target'
import type { RuntimeFileCommandHost } from './file-command-host'
import { runNativeChatScratchSave } from '../../native-chat/native-chat-scratch-folder-lock'
import { getProfileUserDataPath } from '../../orca-profiles/profile-storage-paths'
import { resolveRuntimeAuthorizedPath } from './chat-scratch-file-root'
import { constants, copyFile, mkdir, rm } from 'node:fs/promises'
import { dirname } from 'node:path'
@@ -33,7 +35,9 @@ export class RuntimeFileMutations {
expectedSshTargetId,
expectedSshConnectionGeneration
)
await requireRuntimeFileProvider(target, this.host).writeFile(target.path, content)
await runNativeChatScratchSave(getProfileUserDataPath(), target.path, () =>
requireRuntimeFileProvider(target, this.host).writeFile(target.path, content)
)
return { ok: true }
}
@@ -52,7 +56,9 @@ export class RuntimeFileMutations {
expectedSshTargetId,
expectedSshConnectionGeneration
)
await requireRuntimeFileProvider(target, this.host).writeFileBase64(target.path, contentBase64)
await runNativeChatScratchSave(getProfileUserDataPath(), target.path, () =>
requireRuntimeFileProvider(target, this.host).writeFileBase64(target.path, contentBase64)
)
return { ok: true }
}
@@ -72,10 +78,12 @@ export class RuntimeFileMutations {
expectedSshTargetId,
expectedSshConnectionGeneration
)
await requireRuntimeFileProvider(target, this.host).writeFileBase64Chunk(
target.path,
contentBase64,
append
await runNativeChatScratchSave(getProfileUserDataPath(), target.path, () =>
requireRuntimeFileProvider(target, this.host).writeFileBase64Chunk(
target.path,
contentBase64,
append
)
)
return { ok: true }
}
@@ -1,271 +0,0 @@
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import {
createNativeChatVisualsWorkspaceVerdicts,
readNativeChatVisualsWorkspaceCatalogs,
type NativeChatVisualsFilesystem,
type NativeChatVisualsWorkspaceCatalogs
} from './native-chat-visuals-workspace-verdict'
const REPO_PATH = '/work/repo-1'
const WORKTREE_PATH = '/work/repo-1-feature'
const WORKTREE_ID = `repo-1::${WORKTREE_PATH}`
function catalogs(
options: {
repos?: string[]
meta?: string[]
folders?: string[]
others?: Partial<NativeChatVisualsWorkspaceCatalogs['others']>
} = {}
): NativeChatVisualsWorkspaceCatalogs {
return {
active: {
getRepo: (id: string) =>
(options.repos ?? ['repo-1']).includes(id) ? { path: REPO_PATH } : undefined,
getAllWorktreeMeta: () => Object.fromEntries((options.meta ?? []).map((id) => [id, {}])),
getFolderWorkspaces: () => (options.folders ?? []).map((id) => ({ id }))
},
others: { ids: new Set(), repoIds: new Set(), unreadableProfiles: 0, ...options.others }
}
}
const at = (
workspaceId: string,
overrides: Partial<AgentSessionExecutionLocation> = {}
): AgentSessionExecutionLocation => ({
executionHostId: LOCAL_EXECUTION_HOST_ID,
wslDistro: null,
workspaceId,
workspaceKind: 'git-worktree',
...overrides
})
type Presence = 'present' | 'absent' | 'unknown'
function verdictWith(
known: NativeChatVisualsWorkspaceCatalogs | null,
paths: Record<string, Presence> = {},
gitRecords: boolean | null = false
) {
const fs: NativeChatVisualsFilesystem = {
presence: vi.fn(async (path: string): Promise<Presence> => paths[path] ?? 'present'),
gitRecordsWorktree: vi.fn(async () => gitRecords)
}
return { verdict: createNativeChatVisualsWorkspaceVerdicts(() => known, fs)(), fs }
}
describe('whether a chat workspace is provably removed', () => {
it('is removed when its project was removed from Orca in every profile', async () => {
const { verdict, fs } = verdictWith(catalogs({ repos: [] }))
await expect(verdict(at(WORKTREE_ID))).resolves.toBe('removed')
expect(fs.presence).not.toHaveBeenCalled()
})
it("keeps a chat whose project or folder only another profile holds (chats are shared, catalogs aren't)", async () => {
const { verdict } = verdictWith(
catalogs({
repos: [],
others: { repoIds: new Set(['repo-1']), ids: new Set(['folder:f-2']) }
})
)
await expect(verdict(at(WORKTREE_ID))).resolves.toBe('unverifiable')
await expect(verdict(at('folder:f-2', { workspaceKind: 'folder' }))).resolves.toBe('present')
})
it('decides nothing while any profile cannot be read', async () => {
const { verdict } = verdictWith(catalogs({ repos: [], others: { unreadableProfiles: 1 } }))
await expect(verdict(at(WORKTREE_ID))).resolves.toBe('unverifiable')
await expect(verdict(at('folder:gone', { workspaceKind: 'folder' }))).resolves.toBe(
'unverifiable'
)
})
it('is present while any profile still tracks the worktree', async () => {
for (const known of [
catalogs({ meta: [WORKTREE_ID] }),
catalogs({ others: { ids: new Set([WORKTREE_ID]) } })
]) {
const { verdict, fs } = verdictWith(known, { [WORKTREE_PATH]: 'absent' })
await expect(verdict(at(WORKTREE_ID))).resolves.toBe('present')
expect(fs.presence).not.toHaveBeenCalled()
}
})
it('is removed when the worktree folder is gone and git no longer records it', async () => {
const { verdict, fs } = verdictWith(catalogs(), { [WORKTREE_PATH]: 'absent' }, false)
await expect(verdict(at(WORKTREE_ID))).resolves.toBe('removed')
expect(fs.gitRecordsWorktree).toHaveBeenCalledWith(REPO_PATH, WORKTREE_PATH)
})
it('keeps a worktree on an unmounted drive: git still records it, or the project is unreachable', async () => {
await expect(
verdictWith(catalogs(), { [WORKTREE_PATH]: 'absent' }, true).verdict(at(WORKTREE_ID))
).resolves.toBe('unverifiable')
await expect(
verdictWith(catalogs(), { [WORKTREE_PATH]: 'absent' }, null).verdict(at(WORKTREE_ID))
).resolves.toBe('unverifiable')
await expect(
verdictWith(catalogs(), { [WORKTREE_PATH]: 'absent', [REPO_PATH]: 'absent' }).verdict(
at(WORKTREE_ID)
)
).resolves.toBe('unverifiable')
})
it("never removes the project's own checkout while the project is known", async () => {
const { verdict } = verdictWith(catalogs(), { [REPO_PATH]: 'absent' })
await expect(verdict(at(`repo-1::${REPO_PATH}`))).resolves.toBe('unverifiable')
})
it('keeps a worktree whose folder is there or unreadable', async () => {
await expect(verdictWith(catalogs()).verdict(at(WORKTREE_ID))).resolves.toBe('present')
await expect(
verdictWith(catalogs(), { [WORKTREE_PATH]: 'unknown' }).verdict(at(WORKTREE_ID))
).resolves.toBe('unverifiable')
})
it('reads a folder workspace from every catalog', async () => {
const folder = (id: string) => at(`folder:${id}`, { workspaceKind: 'folder' })
const { verdict } = verdictWith(catalogs({ folders: ['f-1'] }))
await expect(verdict(folder('f-1'))).resolves.toBe('present')
await expect(verdict(folder('f-2'))).resolves.toBe('removed')
})
it('never decides for another host, a WSL distro, the floating workspace, or without catalogs', async () => {
const { verdict, fs } = verdictWith(catalogs({ repos: [] }))
await expect(verdict(at(WORKTREE_ID, { executionHostId: 'ssh:box' }))).resolves.toBe(
'unverifiable'
)
await expect(verdict(at(WORKTREE_ID, { wslDistro: 'Ubuntu' }))).resolves.toBe('unverifiable')
await expect(
verdict(at(FLOATING_TERMINAL_WORKTREE_ID, { workspaceKind: 'folder' }))
).resolves.toBe('unverifiable')
await expect(verdictWith(null).verdict(at(WORKTREE_ID))).resolves.toBe('unverifiable')
expect(fs.presence).not.toHaveBeenCalled()
})
it('reads the catalogs once per run, however many chats it judges', async () => {
const read = vi.fn(() => catalogs({ repos: [] }))
const verdict = createNativeChatVisualsWorkspaceVerdicts(read)()
await verdict(at(WORKTREE_ID))
await verdict(at('repo-1::/work/other'))
expect(read).toHaveBeenCalledOnce()
})
})
describe("git's own record of a linked worktree", () => {
const scratch: string[] = []
afterEach(() => {
for (const dir of scratch.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
function repoWithWorktreeRecord(recordedWorktree: string | null): string {
const repo = realpathSync(mkdtempSync(join(tmpdir(), 'orca-visuals-git-')))
scratch.push(repo)
mkdirSync(join(repo, '.git', 'worktrees', 'feature'), { recursive: true })
if (recordedWorktree) {
writeFileSync(
join(repo, '.git', 'worktrees', 'feature', 'gitdir'),
`${recordedWorktree}/.git\n`
)
}
return repo
}
it('removes only a worktree git no longer records', async () => {
const gone = join(tmpdir(), 'orca-visuals-missing-worktree-a')
const verdicts = (repo: string) =>
createNativeChatVisualsWorkspaceVerdicts(() => ({
...catalogs(),
active: {
...catalogs().active,
getRepo: () => ({ path: repo })
}
}))()
const recorded = repoWithWorktreeRecord(gone)
await expect(verdicts(recorded)(at(`repo-1::${gone}`))).resolves.toBe('unverifiable')
const pruned = repoWithWorktreeRecord(null)
await expect(verdicts(pruned)(at(`repo-1::${gone}`))).resolves.toBe('removed')
})
it('reads a record git wrote relative to its own folder', async () => {
const repo = repoWithWorktreeRecord(null)
const worktree = join(repo, '..', 'orca-visuals-relative-worktree-b')
writeFileSync(
join(repo, '.git', 'worktrees', 'feature', 'gitdir'),
'../../../../orca-visuals-relative-worktree-b/.git\n'
)
const verdict = createNativeChatVisualsWorkspaceVerdicts(() => ({
...catalogs(),
active: { ...catalogs().active, getRepo: () => ({ path: repo }) }
}))()
await expect(verdict(at(`repo-1::${worktree}`))).resolves.toBe('unverifiable')
})
it('resolves a relative record against the real path when the project was added through a link', async () => {
const real = realpathSync(mkdtempSync(join(tmpdir(), 'orca-visuals-real-')))
scratch.push(real)
const nested = join(real, 'a', 'b', 'c')
mkdirSync(join(nested, 'repo', '.git', 'worktrees', 'wt'), { recursive: true })
const linkedParent = join(real, 'deep')
symlinkSync(nested, linkedParent)
const worktree = join(real, 'wts', 'wt')
// What git writes: relative from the record folder's real path.
writeFileSync(
join(nested, 'repo', '.git', 'worktrees', 'wt', 'gitdir'),
'../../../../../../../wts/wt/.git\n'
)
const verdict = createNativeChatVisualsWorkspaceVerdicts(() => ({
...catalogs(),
active: { ...catalogs().active, getRepo: () => ({ path: join(linkedParent, 'repo') }) }
}))()
await expect(verdict(at(`repo-1::${worktree}`))).resolves.toBe('unverifiable')
})
})
describe('reading the catalogs for a sweep run', () => {
it('skips the running profile named by its own storage folder', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-visuals-profiles-'))
try {
mkdirSync(join(root, 'profiles', 'running'), { recursive: true })
mkdirSync(join(root, 'profiles', 'next'), { recursive: true })
const profile = (id: string) => ({
id,
name: id,
kind: 'local',
createdAt: 0,
updatedAt: 0,
lastOpenedAt: 0,
avatar: { kind: 'initials', initials: id.slice(0, 2), color: 'neutral' }
})
// A switch already named the next profile active; this process still runs the old one.
writeFileSync(
join(root, 'orca-profile-index.json'),
JSON.stringify({ activeProfileId: 'next', profiles: [profile('running'), profile('next')] })
)
for (const id of ['running', 'next']) {
writeFileSync(
join(root, 'profiles', id, 'orca-data.json'),
JSON.stringify({ repos: [{ id: `repo-${id}` }] })
)
}
const read = readNativeChatVisualsWorkspaceCatalogs(
{
...catalogs().active,
getProfileStorageDirectory: () => join(root, 'profiles', 'running')
},
root
)
expect([...read.others.repoIds]).toEqual(['repo-next'])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
@@ -1,204 +0,0 @@
// Whether the local workspace a chat ran in is provably gone, for the scratch folder sweep.
// Chat records and visuals live at the user-data root, shared by every Orca profile, while each
// profile keeps its own workspace catalog: so only absence from EVERY profile's catalog counts. A
// worktree still in a known project is gone only when git itself no longer records it. Never runs
// git or lists a repo's worktrees: that walk can touch protected folders.
import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises'
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { isFloatingWorkspaceId } from '../../shared/floating-workspace-worktree'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
import { folderWorkspaceKey, parseWorkspaceKey } from '../../shared/workspace-scope'
import type { Store } from '../persistence'
import { readOtherProfileWorkspaceCatalog } from '../orca-profiles/other-profile-workspace-catalog'
import {
getOrcaProfilesDirectory,
getProfileUserDataPath
} from '../orca-profiles/profile-storage-paths'
import type { NativeChatVisualsWorkspaceVerdict } from '../native-chat/native-chat-visuals-sweep'
/** This profile's live catalog and every other profile's persisted one. The running profile is
* named by its own storage folder, not the profile index, which a switch rewrites first. */
export function readNativeChatVisualsWorkspaceCatalogs(
store: NativeChatVisualsWorkspaceCatalogs['active'] & Pick<Store, 'getProfileStorageDirectory'>,
userDataPath = getProfileUserDataPath()
): NativeChatVisualsWorkspaceCatalogs {
const storage = store.getProfileStorageDirectory()
const runningProfileId = samePath(dirname(storage), getOrcaProfilesDirectory(userDataPath))
? basename(storage)
: undefined
return {
active: store,
others: readOtherProfileWorkspaceCatalog(userDataPath, {
...(runningProfileId ? { runningProfileId } : {}),
// Chat records are never deleted, so this rule is what cleans up; a profile created but never
// opened must not switch it off.
neverWrittenIsEmpty: true
})
}
}
export type NativeChatVisualsWorkspaceCatalogs = {
/** The running profile's live catalog: the Store, narrowed to what a verdict reads. */
active: {
getRepo: (id: string) => { path: string } | undefined
getAllWorktreeMeta: () => Readonly<Record<string, unknown>>
getFolderWorkspaces: () => readonly { id: string }[]
}
/** Every other profile's worktree ids, folder workspace keys and project ids. */
others: { ids: ReadonlySet<string>; repoIds: ReadonlySet<string>; unreadableProfiles: number }
}
export type NativeChatVisualsFilesystem = {
/** `present`, `absent` (definitively), or `unknown` for any other answer. */
presence: (path: string) => Promise<'present' | 'absent' | 'unknown'>
/** Whether git in the repo at `repoPath` records a linked worktree at `worktreePath`; null when
* its records can't be read. */
gitRecordsWorktree: (repoPath: string, worktreePath: string) => Promise<boolean | null>
}
async function presence(path: string): Promise<'present' | 'absent' | 'unknown'> {
try {
await stat(path)
return 'present'
} catch (error) {
return isDefinitiveAbsence(error) ? 'absent' : 'unknown'
}
}
const samePath = (left: string, right: string): boolean =>
normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right)
async function gitRecordsWorktree(repoPath: string, worktreePath: string): Promise<boolean | null> {
const gitDir = join(repoPath, '.git')
try {
// A `.git` file (the repo is itself a linked worktree or a submodule) is not read here.
if (!(await lstat(gitDir)).isDirectory()) {
return null
}
let names: string[]
try {
names = await readdir(join(gitDir, 'worktrees'))
} catch (error) {
if (isDefinitiveAbsence(error)) {
return false
}
throw error
}
for (const name of names) {
let recorded: string
try {
recorded = await readFile(join(gitDir, 'worktrees', name, 'gitdir'), 'utf8')
} catch (error) {
if (isDefinitiveAbsence(error)) {
continue
}
throw error
}
// The record names the worktree's own `.git` file; git 2.48+ may write it relative to the
// record folder's real path, which differs from the stored one when that runs through a link.
const gitFile = recorded.trim()
const absolute = isAbsolute(gitFile)
? gitFile
: resolve(await realpath(join(gitDir, 'worktrees', name)), gitFile)
if (samePath(dirname(absolute), worktreePath)) {
return true
}
}
return false
} catch {
return null
}
}
const NODE_FILESYSTEM: NativeChatVisualsFilesystem = { presence, gitRecordsWorktree }
async function worktreeVerdict(
catalogs: NativeChatVisualsWorkspaceCatalogs,
workspaceId: string,
fs: NativeChatVisualsFilesystem
): Promise<NativeChatVisualsWorkspaceVerdict> {
const worktree = splitWorktreeIdForFilesystem(workspaceId)
if (!worktree?.repoId || !worktree.worktreePath) {
return 'unverifiable'
}
const repo = catalogs.active.getRepo(worktree.repoId)
// The project was removed from Orca in every profile.
if (!repo && !catalogs.others.repoIds.has(worktree.repoId)) {
return 'removed'
}
if (
Object.hasOwn(catalogs.active.getAllWorktreeMeta(), workspaceId) ||
catalogs.others.ids.has(workspaceId)
) {
return 'present'
}
// The project's own checkout, or one only another profile can say anything about.
if (!repo || samePath(repo.path, worktree.worktreePath)) {
return 'unverifiable'
}
const own = await fs.presence(worktree.worktreePath)
if (own !== 'absent') {
return own === 'present' ? 'present' : 'unverifiable'
}
// A worktree on an unmounted drive is still recorded by git, so absence alone proves nothing.
if ((await fs.presence(repo.path)) !== 'present') {
return 'unverifiable'
}
return (await fs.gitRecordsWorktree(repo.path, worktree.worktreePath)) === false
? 'removed'
: 'unverifiable'
}
/**
* A verdict function over one snapshot of every profile's catalog; call once per sweep run.
* Null catalogs, or any profile whose state can't be read, decide nothing.
*/
export function createNativeChatVisualsWorkspaceVerdicts(
readCatalogs: () => NativeChatVisualsWorkspaceCatalogs | null,
fs: NativeChatVisualsFilesystem = NODE_FILESYSTEM
): () => (location: AgentSessionExecutionLocation) => Promise<NativeChatVisualsWorkspaceVerdict> {
return () => {
let snapshot: NativeChatVisualsWorkspaceCatalogs | null | undefined
const catalogs = (): NativeChatVisualsWorkspaceCatalogs | null => {
if (snapshot === undefined) {
try {
snapshot = readCatalogs()
} catch {
snapshot = null
}
}
return snapshot && snapshot.others.unreadableProfiles === 0 ? snapshot : null
}
return async (location) => {
// Another host or a WSL distro owns its own answer; loss of contact is never removal.
if (location.executionHostId !== LOCAL_EXECUTION_HOST_ID || location.wslDistro !== null) {
return 'unverifiable'
}
// The floating workspace names a setting, not a place the user removes.
if (isFloatingWorkspaceId(location.workspaceId)) {
return 'unverifiable'
}
const known = catalogs()
if (!known) {
return 'unverifiable'
}
if (location.workspaceKind !== 'folder') {
return worktreeVerdict(known, location.workspaceId, fs)
}
if (parseWorkspaceKey(location.workspaceId)?.type !== 'folder') {
return 'unverifiable'
}
const listed =
known.others.ids.has(location.workspaceId) ||
known.active
.getFolderWorkspaces()
.some((workspace) => folderWorkspaceKey(workspace.id) === location.workspaceId)
return listed ? 'present' : 'removed'
}
}
}
@@ -56,10 +56,6 @@ import {
withoutHiddenHosts
} from './runtime-visible-catalog'
import { claudeCliFlagSupport } from '../claude/claude-cli-flag-support'
import {
createNativeChatVisualsWorkspaceVerdicts,
readNativeChatVisualsWorkspaceCatalogs
} from './native-chat-visuals-workspace-verdict'
export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVisibleReadProbe {
async getWorktreePs(
@@ -241,11 +237,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis
// Wired only here, so a test runtime never runs a real `claude --version`.
claudeCliFlags: claudeCliFlagSupport,
nativeChatVisuals: {
isEnabled: () => this.requireStore().getSettings().nativeChatInlineVisuals !== false,
// Chats and their visuals are shared by every profile; each profile keeps its own catalog.
workspaceVerdicts: createNativeChatVisualsWorkspaceVerdicts(() =>
this.store ? readNativeChatVisualsWorkspaceCatalogs(this.store) : null
)
isEnabled: () => this.requireStore().getSettings().nativeChatInlineVisuals !== false
},
resolveShellEnvironmentPolicy: () =>
nativeChatShellEnvironmentPolicy(this.requireStore().getSettings()),
@@ -67,10 +67,8 @@ import {
type RuntimeAgentAccountHomeResolver
} from './structured-agent-model-catalog-wiring'
import type { ClaudeCliFlagSupport } from '../claude/claude-cli-flag-support'
import {
scheduleNativeChatVisualsSweep,
type NativeChatVisualsSweepDeps
} from '../native-chat/native-chat-visuals-sweep'
import { scheduleNativeChatVisualsSweep } from '../native-chat/native-chat-visuals-sweep'
import { readJournalSessionLastActivityAt } from '../native-chat/agent-session-journal/journal-row-table'
/** Whether this profile holds a structured chat: a record or tab in the journal database. */
export function hasPersistedStructuredAgentSessionStore(
@@ -104,11 +102,11 @@ export type StructuredAgentSessionRuntimeDeps = {
/** Which version-gated flags a Claude CLI takes; absent never passes one. */
claudeCliFlags?: ClaudeCliFlagSupport
/** Gives each chat a scratch folder and the visuals skill that teaches it, and sweeps folders
* whose chat is gone. Wired by the real hosts only, so a test runtime never loads the bundled skill. */
* whose chat is gone or long unused. Wired by the real hosts only, so a test runtime never loads
* the bundled skill. */
nativeChatVisuals?: {
/** Read this host's preference when a chat starts a provider process. */
isEnabled: () => boolean
workspaceVerdicts: NonNullable<NativeChatVisualsSweepDeps['workspaceVerdicts']>
}
/** Provider transports are overridden only to drive the runtime against scripted children. */
openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection']
@@ -368,8 +366,9 @@ async function installOnJournal(
? scheduleNativeChatVisualsSweep({
stateDirectory: deps.stateDirectory,
listHeldSessionIds: () => (store.readOnly ? null : store.listHeldSessionIds()),
locationOf: (sessionId) => store.getRecord(sessionId)?.location ?? null,
workspaceVerdicts: deps.nativeChatVisuals.workspaceVerdicts,
lastJournalActivityAt: (sessionId) =>
readJournalSessionLastActivityAt(journalDatabase.db, sessionId),
runWhileExecutionIdle: host.runWhileExecutionIdle,
logger: deps.logger
})
: undefined