Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ import {
scheduleDebugStoragePrune,
setDebugRetentionJournal,
setLiveRecordingDirsProvider,
setGhostLogOwnersProvider,
ghostLogOwnersFrom,
} from '@main/storage/debugRetention.js'
import { cleanupClaudeImageCacheDir } from '@main/storage/claudeImageCache.js'
import {
Expand Down Expand Up @@ -1389,6 +1391,11 @@ async function startApp(): Promise<void> {
// renderer, which requires a window.
const workspaceFileStore = await WorkspaceFileStore.open()
shutdownWorkspaceStore = workspaceFileStore
// Ghost logs stay protected from retention only while their session exists
// somewhere: in any window's committed workspace, or running in the
// manager (a brand-new pane can run before its first save). Before this
// line runs, retention treats every ghost log as protected (#732).
setGhostLogOwnersProvider(() => ghostLogOwnersFrom(workspaceFileStore, manager?.list() ?? []))
assertStartupOpen()
// Conversation ledger (docs/decomposition/conversations.md, Stage 3): a
// projection of every window's sessions keyed by native id, so the picker
Expand Down
83 changes: 83 additions & 0 deletions src/main/storage/debugRetention.ghostOwners.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, afterEach, describe, expect, it } from 'vitest'
import { vi } from 'vitest'

// The production path of #732's fix (#1223 review A): provider -> policy ->
// prune, over real files. Every other test calls runPrunePasses or
// ghostLogOwnersFrom directly, so dropping the wiring (or making a throwing
// provider read as "no owners") left them all green. Only the state paths are
// redirected, into a temp dir.
const root = mkdtempSync(join(tmpdir(), 'ac-ghost-owners-'))
const ghostDir = join(root, 'ghost-logs')

vi.mock('@main/storage/paths.js', () => ({
AUTOSAVE_DEBUG_BUNDLE_DIR: join(root, 'bundles-autosave'),
DEBUG_BUNDLE_DIR: join(root, 'bundles'),
FEED_DEBUG_DIR: join(root, 'feed-debug'),
HEAP_SNAPSHOT_DIR: join(root, 'heap'),
INCIDENT_RUNS_DIR: join(root, 'incidents'),
MANUAL_DEBUG_BUNDLE_DIR: join(root, 'bundles-manual'),
PERFORMANCE_RUNS_DIR: join(root, 'performance'),
PROXY_EVENTS_DIR: join(root, 'proxy'),
SESSION_RECORDING_DIR: join(root, 'recordings'),
STATE_DIR: root,
}))
vi.mock('@main/ghostJournal.js', () => ({ ghostLogDir: () => ghostDir }))
vi.mock('@main/storage/debugBundleLog.js', () => ({
DEBUG_BUNDLE_LOG_FILE: join(root, 'debug-bundles.jsonl'),
isAutosaveDebugBundleReason: () => false,
}))

const { pruneDebugStorage, setGhostLogOwnersProvider } = await import('./debugRetention.js')

const DAY = 24 * 60 * 60 * 1000

function ghostLog(sessionId: string, ageMs: number): string {
mkdirSync(ghostDir, { recursive: true })
const path = join(ghostDir, `${sessionId}.ghost.jsonl`)
writeFileSync(path, '{"uuid":"g1"}\n')
const when = (Date.now() - ageMs) / 1000
utimesSync(path, when, when)
return path
}

afterEach(() => {
setGhostLogOwnersProvider(null)
rmSync(ghostDir, { recursive: true, force: true })
})
afterAll(() => rmSync(root, { recursive: true, force: true }))

describe('ghost-log owners through the real prune entry point (#732, #1223)', () => {
it('deletes an old orphan and keeps an old owned log', async () => {
const orphan = ghostLog('gone-1', 3 * DAY)
const owned = ghostLog('live-1', 3 * DAY)
setGhostLogOwnersProvider(() => new Set(['live-1']))

await pruneDebugStorage('test')

expect(existsSync(orphan)).toBe(false)
expect(existsSync(owned)).toBe(true)
})

it('deletes nothing when the owners are unknown', async () => {
const orphan = ghostLog('gone-1', 3 * DAY)
setGhostLogOwnersProvider(() => null)

await pruneDebugStorage('test')

expect(existsSync(orphan)).toBe(true)
})

it('deletes nothing when the provider throws', async () => {
// A store or manager touched during teardown can throw. That must read
// as unknown, never as "no owners".
const orphan = ghostLog('gone-1', 3 * DAY)
setGhostLogOwnersProvider(() => { throw new Error('store disposed') })

await pruneDebugStorage('test')

expect(existsSync(orphan)).toBe(true)
})
})
77 changes: 76 additions & 1 deletion src/main/storage/debugRetention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { join } from 'node:path'

import { afterEach, beforeEach, describe, expect, it } from 'vitest'

import { collectSessionRecordingDirs, runPrunePasses } from './debugRetention.js'
import { collectSessionRecordingDirs, ghostLogOwnersFrom, runPrunePasses } from './debugRetention.js'
import type {
DebugStorageArtifact,
DebugStorageBucket,
Expand Down Expand Up @@ -152,6 +152,81 @@ describe('runPrunePasses', () => {
expect(result).toEqual({ removed: 1, bytesFreed: 10, remainingBytes: 40 })
})

describe('ghost logs are recovery state only while their session exists (#732)', () => {
// Measured on the owner's machine (2026-09-25): 1,952 ghost logs, 2.1 GB,
// and 1,935 of them (2,128 MB) belonged to sessions no longer in any
// window's workspace. Every one was protected from every pass, and
// startup compaction rewrites each file, so none ever aged either.
const log = (sessionId: string, bytes: number, ageMs: number) =>
artifact(`/state/ghost-logs/${sessionId}.ghost.jsonl`, 'ghost-logs', bytes, ageMs)

it('trims orphaned logs over the cap, oldest first, and never a live session\'s', async () => {
const oldOrphan = log('gone-1', 100, 5 * HOUR)
const newerOrphan = log('gone-2', 100, 3 * HOUR)
const owned = log('live-1', 100, 6 * HOUR)
const { calls, remove } = recordingRemover()

await runPrunePasses(
[owned, newerOrphan, oldOrphan],
policy({ caps: capsOf(1_000_000, { 'ghost-logs': 150 }), ghostLogOwners: new Set(['live-1']) }),
remove,
)

// 300 > 150: the two orphans go, oldest first. The live session's log is
// the OLDEST file and still stays, because its session still exists.
expect(calls).toEqual(['/state/ghost-logs/gone-1.ghost.jsonl', '/state/ghost-logs/gone-2.ghost.jsonl'])
})

it('lets an orphaned log age out, but not a live session\'s', async () => {
const orphan = log('gone-1', 10, 72 * HOUR)
const owned = log('live-1', 10, 72 * HOUR)
const { calls, remove } = recordingRemover()

await runPrunePasses([orphan, owned], policy({ ghostLogOwners: new Set(['live-1']) }), remove)

expect(calls).toEqual(['/state/ghost-logs/gone-1.ghost.jsonl'])
})

it('protects every log when the workspace could not be read', async () => {
// A read-only store loaded an EMPTY file on purpose (unreadable,
// corrupt or newer workspace.json). Its empty session set is not
// evidence that the logs are orphans; the real file may own them all.
const readOnlyStore = { sessionOwnershipKnown: () => false, sessionIds: () => new Set<string>() }
const owners = ghostLogOwnersFrom(readOnlyStore, [])
expect(owners).toBeNull()

const old = log('owned-by-unreadable-file', 100, 72 * HOUR)
const { calls, remove } = recordingRemover()
await runPrunePasses(
[old],
policy({ caps: capsOf(1_000_000, { 'ghost-logs': 10 }), ghostLogOwners: owners }),
remove,
)
expect(calls).toEqual([])
})

it('uses a healthy store\'s sessions plus running ones', () => {
const store = { sessionOwnershipKnown: () => true, sessionIds: () => new Set(['saved']) }
expect(ghostLogOwnersFrom(store, ['running'])).toEqual(new Set(['saved', 'running']))
})

it('keeps a just-written orphan inside the active grace', async () => {
// The same active grace every bucket gets: nothing written in the last
// ten minutes is pruned. (Undo Close respawns under a fresh id and does
// not read the old log; this is the general rule, not an undo contract.)
const fresh = log('gone-1', 100, 60_000)
const { calls, remove } = recordingRemover()

await runPrunePasses(
[fresh],
policy({ caps: capsOf(1_000_000, { 'ghost-logs': 10 }), ghostLogOwners: new Set() }),
remove,
)

expect(calls).toEqual([])
})
})

it('cap pass trims the oldest inactive, unprotected artifacts of an over-cap bucket only', async () => {
const oldest = artifact('oldest', 'proxy', 60, 5 * HOUR)
const older = artifact('older', 'proxy', 60, 3 * HOUR)
Expand Down
79 changes: 68 additions & 11 deletions src/main/storage/debugRetention.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { mkdir, readFile, readdir, rm, stat, statfs } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { basename, dirname, join, resolve } from 'node:path'

import {
AUTOSAVE_DEBUG_BUNDLE_DIR,
Expand Down Expand Up @@ -54,6 +54,11 @@ export type DebugStoragePrunePolicy = {
activeGraceMs: number
budgetBytes: number
caps: Record<DebugStorageBucket, number>
/** Session ids whose ghost log is still recovery state (#732): every
* session in any window's workspace, plus every running one. Absent or
* null means "not known yet" (the first prune runs before the workspace
* store opens), and then every ghost log stays protected, as before. */
ghostLogOwners?: ReadonlySet<string> | null
}

export type DebugStoragePrunePassesResult = {
Expand Down Expand Up @@ -124,6 +129,45 @@ export function setLiveRecordingDirsProvider(fn: (() => Set<string>) | null): vo
liveRecordingDirsProvider = fn
}

// Provider for the sessions whose ghost logs are still recovery state (#732).
// Same singleton shape as the recording provider above, for the same reason.
// Read ONCE per prune (into the policy), not per artifact: the workspace
// store derives it from the whole file, and a prune can see ~2,000 logs.
let ghostLogOwnersProvider: (() => ReadonlySet<string> | null) | null = null

export function setGhostLogOwnersProvider(fn: (() => ReadonlySet<string> | null) | null): void {
ghostLogOwnersProvider = fn
}

/**
* Who owns ghost logs right now, or null when that cannot be known.
*
* WHY "unknown" unless the store read a complete workspace.json (#1223
* reviews): the owner set is the only thing standing between a ghost log and
* deletion, so an EMPTY set must mean "no sessions", never "could not tell".
* The store reports unknown for a read-only file (unreadable, corrupt, newer)
* and also for a missing, empty or partially decoded one: a user who moves
* workspace.json aside and relaunches would otherwise lose every ghost log of
* the workspace they put back. Unknown means protect all.
*/
export function ghostLogOwnersFrom(
store: { sessionOwnershipKnown(): boolean; sessionIds(): ReadonlySet<string> },
runningSessionIds: readonly string[],
): ReadonlySet<string> | null {
if (!store.sessionOwnershipKnown()) return null
return new Set([...store.sessionIds(), ...runningSessionIds])
}

/** The owner set for one prune, or null when it cannot be known. A provider
* that throws must not unprotect anything, so failure reads as "unknown". */
function readGhostLogOwners(): ReadonlySet<string> | null {
try {
return ghostLogOwnersProvider ? ghostLogOwnersProvider() : null
} catch {
return null
}
}

function envNumber(name: string, fallback: number): number {
const raw = process.env[name]?.trim()
if (!raw) return fallback
Expand Down Expand Up @@ -254,6 +298,7 @@ export async function pruneDebugStorage(reason: string): Promise<DebugStoragePru
activeGraceMs: ACTIVE_GRACE_MS,
budgetBytes: budget,
caps: bucketCaps(budget),
ghostLogOwners: readGhostLogOwners(),
},
async artifact => (await removeArtifact(artifact)) > 0,
)
Expand Down Expand Up @@ -310,12 +355,15 @@ export async function runPrunePasses(
}

for (const artifact of artifacts) {
// Ghost logs are recovery state. Age alone is not enough evidence that a
// log is disposable: a user can resume an older crashed session and still
// need those provisional rows during bootstrap. Keep ghost cleanup on the
// budget/cap passes below, where pressure is explicit and active recent
// files still get the activeGraceMs guard.
if (isProtectedFromDebugPrune(artifact)) continue
// A ghost log is kept while its session exists. The designed use is the
// crash-resume bootstrap (docs/design/ghost-system.md, "Crash + restart"):
// a restored pane rebuilding its provisional rows under its persisted id,
// however old the file. NOTE: that reader is not wired today. Only a fresh
// spawn reads a log, under an id minted a moment earlier (#1223 review A;
// #1225). Keeping owned logs costs little and is right the day it is. Once
// the session is gone from every workspace and not running, nothing can
// use the log, so it is ordinary debug output (#732).
if (isProtectedFromDebugPrune(artifact, policy.ghostLogOwners)) continue
if (artifact.mtimeMs >= cutoff) continue
await drop(artifact)
}
Expand All @@ -334,7 +382,7 @@ export async function runPrunePasses(
// silently breaking the "keep the 50 newest runs regardless" guarantee
// that collectIncidentRunDirs() establishes. (Manual debug bundles get
// the same protection via the `continue` at the top of this loop.)
if (isProtectedFromDebugPrune(artifact)) continue
if (isProtectedFromDebugPrune(artifact, policy.ghostLogOwners)) continue
if (artifact.mtimeMs > activeCutoff) continue
bucketBytes -= await drop(artifact)
}
Expand All @@ -343,7 +391,7 @@ export async function runPrunePasses(
let totalBytes = sumBytes([...live])
for (const artifact of [...live].sort((a, b) => a.mtimeMs - b.mtimeMs)) {
if (totalBytes <= policy.budgetBytes) break
if (isProtectedFromDebugPrune(artifact)) continue
if (isProtectedFromDebugPrune(artifact, policy.ghostLogOwners)) continue
if (artifact.mtimeMs > activeCutoff) continue
totalBytes -= await drop(artifact)
}
Expand Down Expand Up @@ -553,7 +601,7 @@ export function legacyDebugBundleBucketForPath(
: 'debug-bundles-legacy'
}

function isProtectedFromDebugPrune(artifact: Artifact): boolean {
function isProtectedFromDebugPrune(artifact: Artifact, ghostLogOwners?: ReadonlySet<string> | null): boolean {
// A live session recording is protected regardless of its folder mtime — see
// setLiveRecordingDirsProvider for WHY mtime is the wrong liveness oracle
// here (an idle-but-open recording ages past ACTIVE_GRACE_MS while still being
Expand All @@ -563,8 +611,17 @@ function isProtectedFromDebugPrune(artifact: Artifact): boolean {
if (artifact.bucket === 'session-recordings' && liveRecordingDirsProvider) {
if (liveRecordingDirsProvider().has(resolve(artifact.path))) return true
}
// WHY ghost logs are protected per SESSION, not as a bucket (#732): the
// whole bucket used to be protected from every pass, so neither the TTL nor
// the per-bucket cap above could ever act on it. Measured 2026-09-25: 1,952 logs, 2.1 GB, of which
// 1,935 (2,128 MB) belonged to sessions no longer in any workspace. A log
// is kept while its session exists; with no owner set (not known yet) all
// stay protected.
if (artifact.bucket === 'ghost-logs') {
if (!ghostLogOwners) return true
return ghostLogOwners.has(basename(artifact.path).replace(/\.ghost\.jsonl$/u, ''))
}
return artifact.protected === true ||
artifact.bucket === 'ghost-logs' ||
artifact.bucket === 'debug-bundles-manual'
}

Expand Down
Loading
Loading