Files
oh-my-pi/packages/coding-agent/src/cli/gc-cli.ts
T
2026-08-01 17:17:46 -04:00

1567 lines
50 KiB
TypeScript

import { Database } from "bun:sqlite";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { gunzipSync, gzipSync } from "node:zlib";
import { withStatsSyncLock } from "@oh-my-pi/omp-stats/aggregator";
import {
getAgentDir,
getBlobsDir,
getHistoryDbPath,
getModelDbPath,
getSessionsDir,
getStatsDbPath,
readLines,
} from "@oh-my-pi/pi-utils";
import { Settings } from "../config/settings";
import { getDefault } from "../config/settings-schema";
import { BLOB_HASH_RE } from "../session/blob-store";
import { listSessionsReadOnly, type SessionInfo, type SessionStatus } from "../session/session-listing";
import { FileSessionStorage } from "../session/session-storage";
const BLOB_FILE_RE = /^([a-f0-9]{64})(?:\.[A-Za-z0-9][A-Za-z0-9._-]{0,31})?$/;
const BLOB_REF_RE = /\bblob:sha256:([a-f0-9]{64})\b/gi;
const JSONL_GLOB = new Bun.Glob("**/*.jsonl");
const JSONL_GZ_GLOB = new Bun.Glob("**/*.jsonl.gz");
const JSONL_BACKUP_GLOB = new Bun.Glob("**/*.jsonl.*.bak");
const ACTIVE_STATUSES: ReadonlySet<SessionStatus> = new Set(["pending", "interrupted", "unknown"]);
const DAY_MS = 86_400_000;
const GC_WRITE_GRACE_MS = 5 * 60_000;
const SESSION_SUFFIX = ".jsonl";
const COMPRESSED_SESSION_SUFFIX = ".jsonl.gz";
const GC_LOCK_BREAKER_SUFFIX = ".break";
export interface GcCommandFlags {
apply?: boolean;
json?: boolean;
agentDir?: string;
blobs?: boolean;
archive?: boolean;
wal?: boolean;
coldArchiveAfterDays?: number;
retainNewestGlobal?: number;
retainNewestPerCwd?: number;
}
export interface GcCommandArgs {
flags: GcCommandFlags;
}
export interface BlobGcResult {
referenced: number;
candidates: number;
wouldDelete: number;
deleted: number;
bytes: number;
errors: string[];
}
export interface ArchiveGcResult {
scanned: number;
skippedActive: number;
keptNewestGlobal: number;
keptNewestPerCwd: number;
wouldArchive: number;
archived: number;
historyRowsDeleted: number;
statsRowsDeleted: number;
ftsRebuilt: boolean;
errors: string[];
}
export interface WalCheckpointResult {
dbPath: string;
walBytes: number;
wouldCheckpoint: boolean;
checkpointed: boolean;
busy: number;
log: number;
checkpointedFrames: number;
}
export interface WalGcResult {
databases: WalCheckpointResult[];
walBytes: number;
wouldCheckpoint: boolean;
checkpointed: boolean;
}
export interface GcResult {
agentDir: string;
apply: boolean;
blobs?: BlobGcResult;
archive?: ArchiveGcResult;
wal?: WalGcResult;
lockPath: string;
}
interface BlobCandidate {
hash: string;
paths: string[];
bytes: number;
mtimeMs: number;
}
interface ArchiveCandidate {
session: SessionInfo;
relativePath: string;
destinationPath: string;
}
interface ResolvedGcOptions {
apply: boolean;
json: boolean;
agentDir: string;
runBlobs: boolean;
runArchive: boolean;
runWal: boolean;
coldArchiveAfterDays: number;
retainNewestGlobal: number;
retainNewestPerCwd: number;
}
interface SqliteRunResult {
changes?: number | bigint;
}
interface WalCheckpointRow {
busy?: number | bigint | null;
log?: number | bigint | null;
checkpointed?: number | bigint | null;
}
interface GcLockSnapshot {
dev: number;
ino: number;
size: number;
mtimeMs: number;
ctimeMs: number;
text: string;
}
function normalizeNumberSetting(value: unknown, defaultValue: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) return defaultValue;
return Math.max(0, Math.floor(value));
}
function numberSetting(value: number | undefined, fallback: unknown, defaultValue: number): number {
if (value !== undefined && Number.isFinite(value)) return Math.max(0, Math.floor(value));
return normalizeNumberSetting(fallback, defaultValue);
}
async function resolveOptions(flags: GcCommandFlags): Promise<ResolvedGcOptions> {
const agentDir = path.resolve(flags.agentDir ?? getAgentDir());
const selected = flags.blobs === true || flags.archive === true || flags.wal === true;
const settings =
flags.apply === true ? await Settings.loadIsolated({ agentDir }) : await Settings.loadReadOnly({ agentDir });
const getBoolean = (pathKey: "gc.blobs" | "gc.archive" | "gc.wal") => settings.get(pathKey);
const getNumber = (pathKey: "gc.coldArchiveAfterDays" | "gc.retainNewestGlobal" | "gc.retainNewestPerCwd") =>
settings.get(pathKey);
return {
apply: flags.apply === true,
json: flags.json === true,
agentDir,
runBlobs: selected ? flags.blobs === true : getBoolean("gc.blobs"),
runArchive: selected ? flags.archive === true : getBoolean("gc.archive"),
runWal: selected ? flags.wal === true : getBoolean("gc.wal"),
coldArchiveAfterDays: numberSetting(
flags.coldArchiveAfterDays,
getNumber("gc.coldArchiveAfterDays"),
getDefault("gc.coldArchiveAfterDays"),
),
retainNewestGlobal: numberSetting(
flags.retainNewestGlobal,
getNumber("gc.retainNewestGlobal"),
getDefault("gc.retainNewestGlobal"),
),
retainNewestPerCwd: numberSetting(
flags.retainNewestPerCwd,
getNumber("gc.retainNewestPerCwd"),
getDefault("gc.retainNewestPerCwd"),
),
};
}
export function collectGcErrors(result: GcResult): string[] {
return [
...(result.blobs?.errors ?? []).map(error => `blobs: ${error}`),
...(result.archive?.errors ?? []).map(error => `archive: ${error}`),
];
}
function getArchivedSessionsDir(agentDir: string): string {
return path.join(path.dirname(getSessionsDir(agentDir)), "archive", "sessions");
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function codeOf(error: unknown): string | undefined {
return typeof error === "object" && error !== null && "code" in error
? String((error as { code?: unknown }).code)
: undefined;
}
async function pathExists(target: string): Promise<boolean> {
try {
await fs.stat(target);
return true;
} catch (error) {
if (codeOf(error) === "ENOENT") return false;
throw error;
}
}
async function statIfPresent(target: string) {
try {
return await fs.stat(target);
} catch (error) {
if (codeOf(error) === "ENOENT") return null;
throw error;
}
}
async function readTextIfPresent(file: string): Promise<string> {
try {
if (file.endsWith(COMPRESSED_SESSION_SUFFIX)) {
return new TextDecoder().decode(gunzipSync(await Bun.file(file).bytes()));
}
return await Bun.file(file).text();
} catch (error) {
if (codeOf(error) === "ENOENT") return "";
throw error;
}
}
async function collectJsonlFiles(root: string): Promise<string[]> {
try {
const files = await Array.fromAsync(JSONL_GLOB.scan(root), name => path.join(root, name));
files.sort();
return files;
} catch (error) {
if (codeOf(error) === "ENOENT") return [];
throw error;
}
}
async function collectCompressedJsonlFiles(root: string): Promise<string[]> {
try {
const files = await Array.fromAsync(JSONL_GZ_GLOB.scan(root), name => path.join(root, name));
files.sort();
return files;
} catch (error) {
if (codeOf(error) === "ENOENT") return [];
throw error;
}
}
async function collectBackupJsonlFiles(root: string): Promise<string[]> {
try {
const files = await Array.fromAsync(JSONL_BACKUP_GLOB.scan(root), name => path.join(root, name));
files.sort();
return files;
} catch (error) {
if (codeOf(error) === "ENOENT") return [];
throw error;
}
}
async function collectReferencedBlobHashes(sessionRoots: string[]): Promise<Set<string>> {
const hashes = new Set<string>();
for (const root of sessionRoots) {
const files = [
...(await collectJsonlFiles(root)),
...(await collectCompressedJsonlFiles(root)),
...(await collectBackupJsonlFiles(root)),
];
for (const file of files) {
const text = await readTextIfPresent(file);
for (const match of text.matchAll(BLOB_REF_RE)) {
const hash = match[1]?.toLowerCase();
if (hash && BLOB_HASH_RE.test(hash)) hashes.add(hash);
}
}
}
return hashes;
}
async function collectBlobCandidates(blobDir: string): Promise<BlobCandidate[]> {
let entries: string[];
try {
entries = await fs.readdir(blobDir);
} catch (error) {
if (codeOf(error) === "ENOENT") return [];
throw error;
}
const byHash = new Map<string, BlobCandidate>();
for (const entry of entries) {
const match = entry.match(BLOB_FILE_RE);
const hash = match?.[1];
if (!hash) continue;
const file = path.join(blobDir, entry);
const stat = await statIfPresent(file);
if (!stat) continue;
if (!stat.isFile()) continue;
const candidate = byHash.get(hash) ?? { hash, paths: [], bytes: 0, mtimeMs: stat.mtimeMs };
candidate.paths.push(file);
candidate.bytes += stat.size;
candidate.mtimeMs = Math.max(candidate.mtimeMs, stat.mtimeMs);
byHash.set(hash, candidate);
}
return [...byHash.values()].sort((a, b) => a.hash.localeCompare(b.hash));
}
async function runBlobGc(options: ResolvedGcOptions, archiveSessionsRoot: string): Promise<BlobGcResult> {
const blobDir = getBlobsDir(options.agentDir);
const sessionsRoot = getSessionsDir(options.agentDir);
const referenced = await collectReferencedBlobHashes([sessionsRoot, archiveSessionsRoot]);
const candidates = await collectBlobCandidates(blobDir);
const result: BlobGcResult = {
referenced: referenced.size,
candidates: candidates.length,
wouldDelete: 0,
deleted: 0,
bytes: 0,
errors: [],
};
const deleteBeforeMs = Date.now() - GC_WRITE_GRACE_MS;
for (const candidate of candidates) {
if (referenced.has(candidate.hash)) continue;
if (candidate.mtimeMs > deleteBeforeMs) continue;
result.wouldDelete += candidate.paths.length;
result.bytes += candidate.bytes;
if (!options.apply) continue;
for (const file of candidate.paths) {
try {
await fs.unlink(file);
result.deleted += 1;
} catch (error) {
if (codeOf(error) === "ENOENT") continue;
result.errors.push(`${file}: ${errorMessage(error)}`);
}
}
}
return result;
}
async function listActiveSessions(sessionsRoot: string): Promise<SessionInfo[]> {
let entries: Array<{ name: string; isDirectory(): boolean }>;
try {
entries = await fs.readdir(sessionsRoot, { withFileTypes: true });
} catch (error) {
if (codeOf(error) === "ENOENT") return [];
throw error;
}
const storage = new FileSessionStorage();
const sessions: SessionInfo[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
sessions.push(...(await listSessionsReadOnly(path.join(sessionsRoot, entry.name), storage)));
}
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
return sessions;
}
async function listNestedSessionsReadOnly(artifactsRoot: string): Promise<SessionInfo[]> {
const files = await collectJsonlFiles(artifactsRoot);
const dirs = [...new Set(files.map(file => path.dirname(file)))].sort();
const storage = new FileSessionStorage();
const sessions: SessionInfo[] = [];
for (const dir of dirs) sessions.push(...(await listSessionsReadOnly(dir, storage)));
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
return sessions;
}
async function hasLiveNestedSessions(session: SessionInfo, archiveBeforeMs: number): Promise<boolean> {
for (const nested of await listNestedSessionsReadOnly(sessionArtifactsPath(session.path))) {
if (nested.status && ACTIVE_STATUSES.has(nested.status)) return true;
if (nested.modified.getTime() > archiveBeforeMs) return true;
}
return false;
}
function archiveDestination(
archiveRoot: string,
sessionsRoot: string,
session: SessionInfo,
): Omit<ArchiveCandidate, "session"> | null {
const sessionPath = session.path;
const relativePath = path.relative(sessionsRoot, sessionPath);
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) return null;
if (!relativePath.endsWith(SESSION_SUFFIX)) return null;
return {
relativePath,
destinationPath: path.join(archiveRoot, `${relativePath}.gz`),
};
}
function sessionCwdKey(sessionsRoot: string, session: SessionInfo): string {
const relativePath = path.relative(sessionsRoot, session.path);
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) return session.cwd || ".";
const dirname = path.dirname(relativePath);
return dirname === "." ? session.cwd || "." : dirname;
}
async function movePath(source: string, destination: string): Promise<void> {
await fs.mkdir(path.dirname(destination), { recursive: true });
try {
await fs.rename(source, destination);
return;
} catch (error) {
if (codeOf(error) !== "EXDEV") throw error;
}
const stat = await fs.stat(source);
if (stat.isDirectory()) {
await fs.cp(source, destination, { recursive: true });
await fs.rm(source, { recursive: true, force: true });
return;
}
await fs.copyFile(source, destination);
await fs.unlink(source);
}
function sessionArtifactsPath(sessionPath: string): string {
if (sessionPath.endsWith(COMPRESSED_SESSION_SUFFIX)) {
return sessionPath.slice(0, -COMPRESSED_SESSION_SUFFIX.length);
}
return sessionPath.slice(0, -SESSION_SUFFIX.length);
}
interface SessionLineageHeader {
id: string;
parentSession?: string;
previousSessionFiles: string[];
}
function sessionLineageHeaderFromText(text: string): SessionLineageHeader | undefined {
let sawTitleSlot = false;
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
try {
const record = JSON.parse(line) as {
type?: unknown;
id?: unknown;
parentSession?: unknown;
previousSessionFiles?: unknown;
};
if (!sawTitleSlot && record.type === "title") {
sawTitleSlot = true;
continue;
}
if (record.type !== "session" || typeof record.id !== "string" || record.id.length === 0) return undefined;
return {
id: record.id,
parentSession: typeof record.parentSession === "string" ? record.parentSession : undefined,
previousSessionFiles: Array.isArray(record.previousSessionFiles)
? record.previousSessionFiles.filter(
(previousSessionFile): previousSessionFile is string =>
typeof previousSessionFile === "string" && previousSessionFile.length > 0,
)
: [],
};
} catch {
return undefined;
}
}
return undefined;
}
async function readSessionLineageHeader(file: string): Promise<SessionLineageHeader | undefined> {
const decoder = new TextDecoder();
const lines: string[] = [];
for await (const line of readLines(Bun.file(file).stream())) {
const decoded = decoder.decode(line).trim();
if (!decoded) continue;
lines.push(decoded);
const header = sessionLineageHeaderFromText(lines.join("\n"));
if (header || lines.length >= 2) return header;
}
return undefined;
}
async function gzipSessionFile(source: string, destination: string): Promise<void> {
await fs.mkdir(path.dirname(destination), { recursive: true });
const tempPath = `${destination}.${process.pid}.${Date.now()}.tmp`;
let renamed = false;
try {
const compressed = gzipSync(await Bun.file(source).bytes(), { level: 9 });
await Bun.write(tempPath, compressed);
await fs.rename(tempPath, destination);
renamed = true;
await fs.unlink(source);
} catch (error) {
await fs.rm(tempPath, { force: true });
if (renamed) await fs.rm(destination, { force: true });
throw error;
}
}
async function restoreGzipSessionFile(source: string, destination: string): Promise<void> {
await fs.mkdir(path.dirname(destination), { recursive: true });
const decompressed = gunzipSync(await Bun.file(source).bytes());
await Bun.write(destination, decompressed);
await fs.unlink(source);
}
async function moveSessionWithArtifacts(candidate: ArchiveCandidate): Promise<void> {
const sourceSession = candidate.session.path;
const destSession = candidate.destinationPath;
const legacyDestSession = destSession.endsWith(".gz") ? destSession.slice(0, -".gz".length) : `${destSession}.gz`;
const sourceArtifacts = sessionArtifactsPath(sourceSession);
const destArtifacts = sessionArtifactsPath(destSession);
if (await pathExists(destSession)) throw new Error(`archive destination exists: ${destSession}`);
if (await pathExists(legacyDestSession)) throw new Error(`archive destination exists: ${legacyDestSession}`);
if ((await pathExists(sourceArtifacts)) && (await pathExists(destArtifacts))) {
throw new Error(`archive artifacts destination exists: ${destArtifacts}`);
}
const moved: Array<{ source: string; destination: string; compressed?: boolean }> = [];
try {
await gzipSessionFile(sourceSession, destSession);
moved.push({ source: sourceSession, destination: destSession, compressed: true });
if (await pathExists(sourceArtifacts)) {
await movePath(sourceArtifacts, destArtifacts);
moved.push({ source: sourceArtifacts, destination: destArtifacts });
}
} catch (error) {
for (const move of moved.reverse()) {
try {
if (move.compressed) {
await restoreGzipSessionFile(move.destination, move.source);
} else {
await movePath(move.destination, move.source);
}
} catch {
// Preserve the original failure; rollback failure is reported by the next scan.
}
}
throw error;
}
}
function sqliteNumber(value: number | bigint | null | undefined): number {
if (typeof value === "bigint") return Number(value);
if (typeof value === "number") return value;
return 0;
}
function tableExists(db: Database, table: string): boolean {
const row = db
.prepare("SELECT 1 AS present FROM sqlite_master WHERE type IN ('table','view') AND name = ?")
.get(table) as { present?: number } | null;
return row?.present === 1;
}
function historyHasSessionId(db: Database): boolean {
const rows = db.prepare("PRAGMA table_info(history)").all() as Array<{ name?: string | null }>;
return rows.some(row => row.name === "session_id");
}
function deleteHistoryRowsForSessions(dbPath: string, sessionIds: string[]): { deleted: number; ftsRebuilt: boolean } {
if (sessionIds.length === 0) return { deleted: 0, ftsRebuilt: false };
const db = new Database(dbPath);
try {
db.run("PRAGMA busy_timeout = 5000");
if (!tableExists(db, "history")) return { deleted: 0, ftsRebuilt: false };
if (!historyHasSessionId(db)) return { deleted: 0, ftsRebuilt: false };
const hasFts = tableExists(db, "history_fts");
const deleteStmt = db.prepare("DELETE FROM history WHERE session_id = ?");
let deleted = 0;
const tx = db.transaction((ids: string[]) => {
for (const id of ids) {
const result = deleteStmt.run(id) as SqliteRunResult;
deleted += sqliteNumber(result.changes);
}
if (deleted > 0 && hasFts) db.run("INSERT INTO history_fts(history_fts) VALUES('rebuild')");
});
tx(sessionIds);
return { deleted, ftsRebuilt: deleted > 0 && hasFts };
} finally {
db.close();
}
}
async function collectArchivedSessionIds(archiveRoot: string): Promise<string[]> {
const ids = new Set<string>();
for (const file of await collectCompressedJsonlFiles(archiveRoot)) {
const id = sessionLineageHeaderFromText(await readTextIfPresent(file))?.id;
if (id) ids.add(id);
}
return [...ids].sort();
}
async function cleanupHistoryRowsForArchivedSessions(
options: ResolvedGcOptions,
archiveRoot: string,
archivedSessionIds: string[],
result: ArchiveGcResult,
): Promise<void> {
const dbPath = getHistoryDbPath(options.agentDir);
if (!(await pathExists(dbPath))) return;
const cleanupIds = new Set(archivedSessionIds);
try {
for (const id of await collectArchivedSessionIds(archiveRoot)) cleanupIds.add(id);
} catch (error) {
result.errors.push(`history cleanup scan: ${errorMessage(error)}`);
}
try {
const cleanup = deleteHistoryRowsForSessions(dbPath, [...cleanupIds]);
result.historyRowsDeleted = cleanup.deleted;
result.ftsRebuilt = cleanup.ftsRebuilt;
} catch (error) {
result.errors.push(`history cleanup: ${errorMessage(error)}`);
}
}
const STATS_SESSION_TABLES = ["messages", "user_messages", "tool_calls", "file_offsets"] as const;
const STATS_ENTRY_TABLES = ["messages", "user_messages", "tool_calls"] as const;
type StatsEntryTable = (typeof STATS_ENTRY_TABLES)[number];
const STATS_IDENTITY_COLUMNS: Record<StatsEntryTable, readonly string[]> = {
messages: ["entry_id", "timestamp"],
user_messages: ["entry_id", "timestamp"],
tool_calls: ["entry_id", "timestamp", "tool_call_id"],
};
interface StatsSession {
path: string;
id: string;
parentSession?: string;
historicalPaths: string[];
identities: Record<StatsEntryTable, StatsEntryIdentity[]>;
}
interface StatsLineageNode extends StatsSession {
statsPaths: Set<string>;
identityKeys: Set<string>;
}
interface StatsEntryIdentity {
entryId: string;
timestamp: number;
toolCallId: string;
}
interface StatsTransferTarget {
path: string;
identities: Record<StatsEntryTable, StatsEntryIdentity[]>;
}
interface StatsCleanupPlan {
sessionPaths: string[];
retainedSessions: StatsLineageNode[];
transfers: StatsTransferTarget[];
archivedIdentityKeys: Set<string>;
preserveAll: boolean;
}
interface StatsCleanupContext {
plans: StatsCleanupPlan[];
incompleteRetainedSessions: StatsLineageNode[];
}
function createStatsIdentities(): Record<StatsEntryTable, StatsEntryIdentity[]> {
return {
messages: [],
user_messages: [],
tool_calls: [],
};
}
function statsIdentityKey(table: StatsEntryTable, identity: StatsEntryIdentity): string {
return `${table}\0${identity.entryId}\0${identity.timestamp}\0${identity.toolCallId}`;
}
function statsIdentityKeys(identities: Record<StatsEntryTable, StatsEntryIdentity[]>): Set<string> {
const keys = new Set<string>();
for (const table of STATS_ENTRY_TABLES) {
for (const identity of identities[table]) keys.add(statsIdentityKey(table, identity));
}
return keys;
}
function tableHasColumn(db: Database, table: string, column: string): boolean {
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name?: string | null }>;
return rows.some(row => row.name === column);
}
function collectStoredStatsSessionPaths(db: Database): string[] {
const sessionPaths = new Set<string>();
for (const table of STATS_SESSION_TABLES) {
if (!tableExists(db, table) || !tableHasColumn(db, table, "session_file")) continue;
const rows = db.prepare(`SELECT DISTINCT session_file FROM ${table}`).all() as Array<{
session_file?: string | null;
}>;
for (const row of rows) {
if (typeof row.session_file === "string") sessionPaths.add(row.session_file);
}
}
return [...sessionPaths];
}
/**
* `/move` preserves the session filename and artifacts-directory basename.
* Recover the top-level path represented by any main or nested stats row so
* one archived logical session can reconcile every historical location.
*/
function logicalSessionRootForStatsPath(statsPath: string, sessionPath: string): string | undefined {
const sessionFilename = path.basename(sessionPath);
if (path.basename(statsPath) === sessionFilename) return statsPath;
const artifactsDirname = sessionFilename.slice(0, -SESSION_SUFFIX.length);
let directory = path.dirname(path.resolve(statsPath));
while (true) {
if (path.basename(directory) === artifactsDirname) return `${directory}${SESSION_SUFFIX}`;
const parent = path.dirname(directory);
if (parent === directory) return undefined;
directory = parent;
}
}
function sessionPathEncodesId(sessionPath: string, sessionId: string): boolean {
const stem = path.basename(sessionPath, SESSION_SUFFIX);
return stem === sessionId || stem.endsWith(`_${sessionId}`);
}
function managedHistoricalSessionPaths(
header: SessionLineageHeader,
currentSessionPath: string,
sessionsRoot: string,
): string[] {
const root = path.resolve(sessionsRoot);
const filename = path.basename(currentSessionPath);
const paths = new Set<string>();
for (const previousSessionFile of header.previousSessionFiles) {
if (!path.isAbsolute(previousSessionFile) || path.basename(previousSessionFile) !== filename) continue;
const resolved = path.resolve(previousSessionFile);
const relative = path.relative(root, resolved);
if (
!relative ||
relative === ".." ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative) ||
!resolved.endsWith(SESSION_SUFFIX)
) {
continue;
}
paths.add(resolved);
}
return [...paths];
}
function resolveLogicalSessionMatch(
statsPath: string,
nodes: StatsLineageNode[],
): { node: StatsLineageNode; logicalRoot: string } | undefined {
const matches: Array<{ node: StatsLineageNode; logicalRoot: string }> = [];
for (const node of nodes) {
const logicalRoot = logicalSessionRootForStatsPath(statsPath, node.path);
if (logicalRoot) matches.push({ node, logicalRoot });
}
const exact = matches.filter(match => path.resolve(match.logicalRoot) === path.resolve(match.node.path));
if (exact.length === 1) return exact[0];
if (exact.length > 1) return undefined;
const known = matches.filter(match =>
[...match.node.statsPaths].some(statsPath => path.resolve(statsPath) === path.resolve(match.logicalRoot)),
);
if (known.length === 1) return known[0];
if (known.length > 1) return undefined;
const idMatches = matches.filter(match => sessionPathEncodesId(match.logicalRoot, match.node.id));
return idMatches.length === 1 ? idMatches[0] : undefined;
}
/**
* Resolve retained peers through `parentSession`, which is historically either
* a session id or an absolute path. Ambiguous aliases are deliberately left
* unresolved so cleanup fails safe instead of transferring across sessions.
*/
function buildStatsCleanupPlans(
archivedSessions: StatsSession[],
retainedSessions: StatsSession[],
dbPath: string,
): StatsCleanupContext {
const archivedNodes: StatsLineageNode[] = archivedSessions.map(session => ({
...session,
statsPaths: new Set([session.path, ...session.historicalPaths]),
identityKeys: statsIdentityKeys(session.identities),
}));
const retainedNodes: StatsLineageNode[] = retainedSessions.map(session => ({
...session,
statsPaths: new Set([session.path, ...session.historicalPaths]),
identityKeys: statsIdentityKeys(session.identities),
}));
const nodes = [...archivedNodes, ...retainedNodes];
const db = new Database(dbPath);
try {
db.run("PRAGMA busy_timeout = 5000");
for (const storedPath of collectStoredStatsSessionPaths(db)) {
const match = resolveLogicalSessionMatch(storedPath, nodes);
if (match) match.node.statsPaths.add(match.logicalRoot);
}
} finally {
db.close();
}
for (const child of nodes) {
if (
!child.parentSession ||
(!path.isAbsolute(child.parentSession) && !child.parentSession.endsWith(SESSION_SUFFIX))
) {
continue;
}
const match = resolveLogicalSessionMatch(child.parentSession, nodes);
if (match) match.node.statsPaths.add(match.logicalRoot);
}
const archivedPathClaimCounts = new Map<string, number>();
for (const archived of archivedNodes) {
for (const statsPath of archived.statsPaths) {
const key = path.resolve(statsPath);
archivedPathClaimCounts.set(key, (archivedPathClaimCounts.get(key) ?? 0) + 1);
}
}
const ambiguousArchivedPathKeys = new Set(
[...archivedPathClaimCounts].filter(([, count]) => count > 1).map(([key]) => key),
);
const aliases = new Map<string, StatsLineageNode | null>();
const identityKeysByNode = new Map<StatsLineageNode, Set<string>>();
for (const node of nodes) {
const keys = new Set([
`id:${node.id}`,
...[...node.statsPaths].map(statsPath => `path:${path.resolve(statsPath)}`),
]);
identityKeysByNode.set(node, keys);
for (const key of keys) {
if (!aliases.has(key)) {
aliases.set(key, node);
} else if (aliases.get(key) !== node) {
aliases.set(key, null);
}
}
}
const incompleteLineage = new Set<StatsLineageNode>();
const lineageKeysByNode = new Map<StatsLineageNode, Set<string>>();
for (const node of nodes) {
const lineageKeys = new Set(identityKeysByNode.get(node));
let current: StatsLineageNode | null = node;
const seen = new Set<StatsLineageNode>();
while (current?.parentSession) {
if (seen.has(current)) {
incompleteLineage.add(node);
break;
}
seen.add(current);
const parentReference = current.parentSession;
const parentKey =
path.isAbsolute(parentReference) || parentReference.endsWith(SESSION_SUFFIX)
? `path:${path.resolve(parentReference)}`
: `id:${parentReference}`;
lineageKeys.add(parentKey);
const parent = aliases.get(parentKey);
if (!parent) {
incompleteLineage.add(node);
break;
}
for (const key of identityKeysByNode.get(parent) ?? []) lineageKeys.add(key);
current = parent;
}
lineageKeysByNode.set(node, lineageKeys);
}
const retainedPathKeys = new Set(
retainedNodes.flatMap(retained => [...retained.statsPaths].map(statsPath => path.resolve(statsPath))),
);
const plans = archivedNodes.map(archived => {
const archivedLineage = lineageKeysByNode.get(archived) ?? new Set<string>();
return {
sessionPaths: [...archived.statsPaths].filter(statsPath => {
const key = path.resolve(statsPath);
return !retainedPathKeys.has(key) && !ambiguousArchivedPathKeys.has(key);
}),
retainedSessions: retainedNodes.filter(retained => {
if (incompleteLineage.has(retained)) return false;
const retainedLineage = lineageKeysByNode.get(retained);
return retainedLineage ? [...retainedLineage].some(key => archivedLineage.has(key)) : false;
}),
transfers: [],
archivedIdentityKeys: archived.identityKeys,
preserveAll: false,
};
});
return {
plans,
incompleteRetainedSessions: retainedNodes.filter(retained => incompleteLineage.has(retained)),
};
}
function addSessionStatsIdentity(line: string, identities: Record<StatsEntryTable, StatsEntryIdentity[]>): void {
if (line.length === 0) return;
try {
const record: unknown = JSON.parse(line);
if (
!record ||
typeof record !== "object" ||
!("type" in record) ||
record.type !== "message" ||
!("id" in record) ||
typeof record.id !== "string" ||
record.id.length === 0 ||
!("message" in record) ||
!record.message ||
typeof record.message !== "object"
) {
return;
}
const message = record.message;
if (!("role" in message)) return;
const parsedEntryTimestamp =
"timestamp" in record && typeof record.timestamp === "string" ? Date.parse(record.timestamp) : Number.NaN;
if (message.role === "user") {
identities.user_messages.push({
entryId: record.id,
timestamp: Number.isFinite(parsedEntryTimestamp) ? parsedEntryTimestamp : 0,
toolCallId: "",
});
return;
}
if (message.role !== "assistant") return;
const timestamp =
"timestamp" in message && typeof message.timestamp === "number" && Number.isFinite(message.timestamp)
? message.timestamp
: Number.isFinite(parsedEntryTimestamp)
? parsedEntryTimestamp
: 0;
identities.messages.push({ entryId: record.id, timestamp, toolCallId: "" });
if (!("content" in message) || !Array.isArray(message.content)) return;
for (const block of message.content) {
if (
block &&
typeof block === "object" &&
"type" in block &&
block.type === "toolCall" &&
"id" in block &&
typeof block.id === "string"
) {
identities.tool_calls.push({ entryId: record.id, timestamp, toolCallId: block.id });
}
}
} catch {
// Stats parsing is also lenient: a malformed line cannot own a retained row.
}
}
function collectSessionStatsIdentitiesFromText(text: string): Record<StatsEntryTable, StatsEntryIdentity[]> {
const identities = createStatsIdentities();
for (const line of text.split(/\r?\n/)) addSessionStatsIdentity(line, identities);
return identities;
}
async function collectSessionStatsIdentities(
sessionPath: string,
): Promise<Record<StatsEntryTable, StatsEntryIdentity[]>> {
const identities = createStatsIdentities();
const decoder = new TextDecoder();
for await (const line of readLines(Bun.file(sessionPath).stream())) {
addSessionStatsIdentity(decoder.decode(line), identities);
}
return identities;
}
async function populateStatsTransferTargets(context: StatsCleanupContext): Promise<void> {
const identitiesBySession = new Map<string, Promise<Record<StatsEntryTable, StatsEntryIdentity[]>>>();
const identitiesFor = (session: StatsLineageNode): Promise<Record<StatsEntryTable, StatsEntryIdentity[]>> => {
let identities = identitiesBySession.get(session.path);
if (!identities) {
identities = collectSessionStatsIdentities(session.path);
identitiesBySession.set(session.path, identities);
}
return identities;
};
const incompleteIdentityKeys = new Set<string>();
let hasUnidentifiableIncompleteSession = false;
for (const retained of context.incompleteRetainedSessions) {
const keys = statsIdentityKeys(await identitiesFor(retained));
if (keys.size === 0) hasUnidentifiableIncompleteSession = true;
for (const key of keys) incompleteIdentityKeys.add(key);
}
for (const plan of context.plans) {
if (context.incompleteRetainedSessions.length > 0) {
plan.preserveAll =
hasUnidentifiableIncompleteSession ||
plan.archivedIdentityKeys.size === 0 ||
[...plan.archivedIdentityKeys].some(key => incompleteIdentityKeys.has(key));
}
for (const retained of plan.retainedSessions) {
plan.transfers.push({ path: retained.path, identities: await identitiesFor(retained) });
}
}
}
/**
* Rekey copied entry rows to the newest retained lineage peer before pruning.
* The retained file's own offset is never rewritten: it already describes that
* file's byte position, while every archived historical offset is deleted.
*/
function reconcileStatsRowsForSessions(dbPath: string, plans: StatsCleanupPlan[]): number {
if (plans.length === 0) return 0;
const db = new Database(dbPath);
try {
db.run("PRAGMA busy_timeout = 5000");
const sessionTables = STATS_SESSION_TABLES.filter(
table => tableExists(db, table) && tableHasColumn(db, table, "session_file"),
);
const entryTables = STATS_ENTRY_TABLES.filter(
table =>
sessionTables.includes(table) &&
STATS_IDENTITY_COLUMNS[table].every(column => tableHasColumn(db, table, column)),
);
db.run(`
CREATE TEMP TABLE gc_retained_entries (
table_name TEXT NOT NULL,
entry_id TEXT NOT NULL,
timestamp INTEGER NOT NULL,
tool_call_id TEXT NOT NULL,
target_session_file TEXT NOT NULL,
PRIMARY KEY (table_name, entry_id, timestamp, tool_call_id)
)
`);
const clearRetainedEntries = db.prepare("DELETE FROM gc_retained_entries");
const insertRetainedEntry = db.prepare(`
INSERT OR IGNORE INTO gc_retained_entries (
table_name, entry_id, timestamp, tool_call_id, target_session_file
) VALUES (?, ?, ?, ?, ?)
`);
const transferStatements = entryTables.map(table => {
const toolCallMatch =
table === "tool_calls" ? `retained.tool_call_id = ${table}.tool_call_id` : "retained.tool_call_id = ''";
const identityMatch = `
retained.table_name = '${table}'
AND retained.entry_id = ${table}.entry_id
AND retained.timestamp = ${table}.timestamp
AND ${toolCallMatch}
`;
return db.prepare(`
UPDATE OR IGNORE ${table}
SET session_file = (
SELECT retained.target_session_file
FROM gc_retained_entries AS retained
WHERE ${identityMatch}
)
WHERE session_file = ?
AND EXISTS (
SELECT 1
FROM gc_retained_entries AS retained
WHERE ${identityMatch}
)
`);
});
const deletionStatements = sessionTables.map(table => ({
table,
statement: db.prepare(`DELETE FROM ${table} WHERE session_file = ? OR instr(session_file, ?) = 1`),
}));
let deleted = 0;
const tx = db.transaction((cleanupPlans: StatsCleanupPlan[]) => {
for (const plan of cleanupPlans) {
if (plan.preserveAll) continue;
clearRetainedEntries.run();
for (const target of plan.transfers) {
for (const table of entryTables) {
for (const identity of target.identities[table]) {
insertRetainedEntry.run(
table,
identity.entryId,
identity.timestamp,
identity.toolCallId,
target.path,
);
}
}
}
for (const sessionPath of new Set(plan.sessionPaths)) {
for (const statement of transferStatements) statement.run(sessionPath);
}
for (const sessionPath of new Set(plan.sessionPaths)) {
const nestedPrefix = `${sessionArtifactsPath(sessionPath)}${path.sep}`;
for (const { table, statement } of deletionStatements) {
const requiresTransfer =
plan.retainedSessions.length > 0 &&
table !== "file_offsets" &&
!entryTables.some(entryTable => entryTable === table);
if (requiresTransfer) continue;
const result = statement.run(sessionPath, nestedPrefix) as SqliteRunResult;
deleted += sqliteNumber(result.changes);
}
}
}
});
tx(plans);
return deleted;
} finally {
db.close();
}
}
async function collectArchivedStatsSessions(
archiveRoot: string,
sessionsRoot: string,
onError: (file: string, error: unknown) => void,
): Promise<StatsSession[]> {
const sessions: StatsSession[] = [];
for (const file of await collectCompressedJsonlFiles(archiveRoot)) {
const relative = path.relative(archiveRoot, file);
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) continue;
const sourcePath = path.join(sessionsRoot, relative.slice(0, -".gz".length));
try {
const text = await readTextIfPresent(file);
const header = sessionLineageHeaderFromText(text);
if (!header) throw new Error("archive is missing a valid session header");
sessions.push({
path: sourcePath,
id: header.id,
parentSession: header.parentSession,
historicalPaths: managedHistoricalSessionPaths(header, sourcePath, sessionsRoot),
identities: collectSessionStatsIdentitiesFromText(text),
});
} catch (error) {
onError(file, error);
}
}
return sessions;
}
async function cleanupStatsRowsForArchivedSessions(
options: ResolvedGcOptions,
archiveRoot: string,
newlyArchivedSessions: SessionInfo[],
result: ArchiveGcResult,
): Promise<void> {
const dbPath =
path.resolve(options.agentDir) === path.resolve(getAgentDir())
? getStatsDbPath()
: path.join(options.agentDir, "stats.db");
if (!(await pathExists(dbPath))) return;
const sessionsRoot = getSessionsDir(options.agentDir);
const archivedByPath = new Map<string, StatsSession>();
for (const session of newlyArchivedSessions) {
archivedByPath.set(path.resolve(session.path), {
path: session.path,
id: session.id,
parentSession: session.parentSessionPath,
historicalPaths: [],
identities: createStatsIdentities(),
});
}
let retainedSessions: SessionInfo[];
try {
for (const session of await collectArchivedStatsSessions(archiveRoot, sessionsRoot, (file, error) => {
result.errors.push(`stats cleanup scan ${file}: ${errorMessage(error)}`);
})) {
archivedByPath.set(path.resolve(session.path), session);
}
} catch (error) {
result.errors.push(`stats cleanup scan: ${errorMessage(error)}`);
}
try {
retainedSessions = await listActiveSessions(sessionsRoot);
} catch (error) {
result.errors.push(`stats cleanup scan: ${errorMessage(error)}`);
return;
}
try {
await withStatsSyncLock(dbPath, async () => {
const retainedStatsSessions = await Promise.all(
retainedSessions.map(async session => {
const header = await readSessionLineageHeader(session.path);
if (!header || header.id !== session.id) {
throw new Error(`session header changed during stats cleanup: ${session.path}`);
}
return {
path: session.path,
id: session.id,
parentSession: header.parentSession,
historicalPaths: managedHistoricalSessionPaths(header, session.path, sessionsRoot),
identities: createStatsIdentities(),
};
}),
);
const context = buildStatsCleanupPlans([...archivedByPath.values()], retainedStatsSessions, dbPath);
await populateStatsTransferTargets(context);
result.statsRowsDeleted = reconcileStatsRowsForSessions(dbPath, context.plans);
});
} catch (error) {
result.errors.push(`stats cleanup: ${errorMessage(error)}`);
}
}
async function runArchiveGc(options: ResolvedGcOptions, archiveRoot: string): Promise<ArchiveGcResult> {
const sessionsRoot = getSessionsDir(options.agentDir);
const sessions = await listActiveSessions(sessionsRoot);
const cutoffMs = Date.now() - options.coldArchiveAfterDays * DAY_MS;
const result: ArchiveGcResult = {
scanned: sessions.length,
skippedActive: 0,
keptNewestGlobal: 0,
keptNewestPerCwd: 0,
wouldArchive: 0,
archived: 0,
historyRowsDeleted: 0,
statsRowsDeleted: 0,
ftsRebuilt: false,
errors: [],
};
const candidates: ArchiveCandidate[] = [];
let inactiveSeen = 0;
const inactiveSeenByCwd = new Map<string, number>();
const archiveBeforeMs = Date.now() - GC_WRITE_GRACE_MS;
for (const session of sessions) {
if (session.status && ACTIVE_STATUSES.has(session.status)) {
result.skippedActive += 1;
continue;
}
if (session.modified.getTime() > archiveBeforeMs) {
result.skippedActive += 1;
continue;
}
if (await hasLiveNestedSessions(session, archiveBeforeMs)) {
result.skippedActive += 1;
continue;
}
const cwdKey = sessionCwdKey(sessionsRoot, session);
const cwdSeen = inactiveSeenByCwd.get(cwdKey) ?? 0;
const keepGlobal = inactiveSeen < options.retainNewestGlobal;
const keepPerCwd = cwdSeen < options.retainNewestPerCwd;
inactiveSeen += 1;
inactiveSeenByCwd.set(cwdKey, cwdSeen + 1);
if (keepGlobal) {
result.keptNewestGlobal += 1;
continue;
}
if (keepPerCwd) {
result.keptNewestPerCwd += 1;
continue;
}
if (options.coldArchiveAfterDays > 0 && session.modified.getTime() > cutoffMs) continue;
const destination = archiveDestination(archiveRoot, sessionsRoot, session);
if (!destination) continue;
candidates.push({ ...destination, session });
}
result.wouldArchive = candidates.length;
if (!options.apply) return result;
const archivedSessionIds: string[] = [];
const archivedSessions: SessionInfo[] = [];
for (const candidate of candidates) {
try {
await moveSessionWithArtifacts(candidate);
result.archived += 1;
archivedSessionIds.push(candidate.session.id);
archivedSessions.push(candidate.session);
} catch (error) {
result.errors.push(`${candidate.session.path}: ${errorMessage(error)}`);
}
}
await cleanupHistoryRowsForArchivedSessions(options, archiveRoot, archivedSessionIds, result);
await cleanupStatsRowsForArchivedSessions(options, archiveRoot, archivedSessions, result);
return result;
}
async function checkpointWal(dbPath: string, apply: boolean): Promise<WalCheckpointResult> {
const walPath = `${dbPath}-wal`;
let walBytes = 0;
try {
walBytes = (await fs.stat(walPath)).size;
} catch (error) {
if (codeOf(error) !== "ENOENT") throw error;
}
const result: WalCheckpointResult = {
dbPath,
walBytes,
wouldCheckpoint: walBytes > 0,
checkpointed: false,
busy: 0,
log: 0,
checkpointedFrames: 0,
};
if (!apply || !(await pathExists(dbPath))) return result;
const db = new Database(dbPath);
let checkpointAttempted = false;
try {
db.run("PRAGMA busy_timeout = 5000");
const row = db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get() as WalCheckpointRow | null;
checkpointAttempted = true;
result.busy = sqliteNumber(row?.busy);
result.log = sqliteNumber(row?.log);
result.checkpointedFrames = sqliteNumber(row?.checkpointed);
} finally {
db.close();
}
try {
result.walBytes = (await fs.stat(walPath)).size;
} catch (error) {
if (codeOf(error) !== "ENOENT") throw error;
result.walBytes = 0;
}
if (checkpointAttempted && (result.busy > 0 || result.walBytes > 0)) {
throw new Error(`WAL checkpoint failed for ${dbPath}: busy=${result.busy}, walBytes=${result.walBytes}`);
}
result.checkpointed = checkpointAttempted;
return result;
}
async function runWalGc(options: ResolvedGcOptions): Promise<WalGcResult> {
const databases = await Promise.all(
[getHistoryDbPath(options.agentDir), getModelDbPath(options.agentDir)].map(dbPath =>
checkpointWal(dbPath, options.apply),
),
);
return {
databases,
walBytes: databases.reduce((total, db) => total + db.walBytes, 0),
wouldCheckpoint: databases.some(db => db.wouldCheckpoint),
checkpointed: databases.some(db => db.checkpointed),
};
}
function gcLockPid(lockText: string): number | undefined {
const pid = Number.parseInt(lockText.split(/\r?\n/, 1)[0] ?? "", 10);
return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
}
function processExists(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
const code = codeOf(error);
if (code === "ESRCH" || code === "EINVAL") return false;
return true;
}
}
function gcLockStatSnapshot(stat: {
dev: number;
ino: number;
size: number;
mtimeMs: number;
ctimeMs: number;
}): Omit<GcLockSnapshot, "text"> {
return {
dev: stat.dev,
ino: stat.ino,
size: stat.size,
mtimeMs: stat.mtimeMs,
ctimeMs: stat.ctimeMs,
};
}
function sameGcLockStat(left: Omit<GcLockSnapshot, "text">, right: Omit<GcLockSnapshot, "text">): boolean {
return (
left.dev === right.dev &&
left.ino === right.ino &&
left.size === right.size &&
left.mtimeMs === right.mtimeMs &&
left.ctimeMs === right.ctimeMs
);
}
async function readGcLockSnapshot(lockPath: string): Promise<GcLockSnapshot | null> {
const stat = await statIfPresent(lockPath);
if (!stat) return null;
let lockText = "";
try {
lockText = await Bun.file(lockPath).text();
} catch (error) {
if (codeOf(error) === "ENOENT") return null;
throw error;
}
const afterStat = await statIfPresent(lockPath);
if (!afterStat) return null;
const before = gcLockStatSnapshot(stat);
const after = gcLockStatSnapshot(afterStat);
if (!sameGcLockStat(before, after)) return null;
return { ...after, text: lockText };
}
async function gcLockSnapshotStillCurrent(lockPath: string, snapshot: GcLockSnapshot): Promise<boolean> {
const stat = await statIfPresent(lockPath);
return stat ? sameGcLockStat(snapshot, gcLockStatSnapshot(stat)) : false;
}
function shouldBreakGcLock(snapshot: GcLockSnapshot): boolean {
const pid = gcLockPid(snapshot.text);
if (pid) return !processExists(pid);
const createdAtMs = Date.parse(snapshot.text.split(/\r?\n/, 2)[1] ?? "");
const ageFromMs = Number.isFinite(createdAtMs) ? createdAtMs : snapshot.mtimeMs;
return Date.now() - ageFromMs > GC_WRITE_GRACE_MS;
}
async function removeStaleGcLock(lockPath: string): Promise<boolean> {
const snapshot = await readGcLockSnapshot(lockPath);
if (!snapshot) return false;
if (!shouldBreakGcLock(snapshot)) return false;
if (!(await gcLockSnapshotStillCurrent(lockPath, snapshot))) return false;
try {
await fs.unlink(lockPath);
return true;
} catch (error) {
if (codeOf(error) === "ENOENT") return false;
throw error;
}
}
async function openNewGcLock(lockPath: string): Promise<fs.FileHandle | null> {
try {
return await fs.open(lockPath, "wx");
} catch (error) {
if (codeOf(error) === "EEXIST") return null;
throw error;
}
}
async function releaseGcLockFile(lockPath: string, handle: fs.FileHandle): Promise<void> {
try {
await handle.close();
} catch {
// Best effort: stale sidecar locks are recoverable by PID/timestamp.
}
try {
await fs.unlink(lockPath);
} catch (error) {
if (codeOf(error) === "ENOENT") return;
}
}
async function openGcBreakerLock(lockPath: string): Promise<{ path: string; handle: fs.FileHandle }> {
const breakerPath = `${lockPath}${GC_LOCK_BREAKER_SUFFIX}`;
for (let attempt = 0; attempt < 2; attempt += 1) {
const handle = await openNewGcLock(breakerPath);
if (handle) {
try {
await handle.writeFile(`${process.pid}\n${new Date().toISOString()}\n`);
return { path: breakerPath, handle };
} catch (error) {
await releaseGcLockFile(breakerPath, handle);
throw error;
}
}
if (!(await removeStaleGcLock(breakerPath))) throw new Error(`GC already running: ${lockPath}`);
}
throw new Error(`GC already running: ${lockPath}`);
}
async function openGcLock(lockPath: string): Promise<fs.FileHandle> {
const direct = await openNewGcLock(lockPath);
if (direct) return direct;
const breaker = await openGcBreakerLock(lockPath);
try {
const raced = await openNewGcLock(lockPath);
if (raced) return raced;
if (!(await removeStaleGcLock(lockPath))) throw new Error(`GC already running: ${lockPath}`);
const takeover = await openNewGcLock(lockPath);
if (takeover) return takeover;
throw new Error(`GC already running: ${lockPath}`);
} finally {
await releaseGcLockFile(breaker.path, breaker.handle);
}
}
async function withGcLock<T>(agentDir: string, fn: (lockPath: string) => Promise<T>): Promise<T> {
const lockPath = path.join(agentDir, "gc.lock");
await fs.mkdir(agentDir, { recursive: true });
const handle = await openGcLock(lockPath);
let result: T | undefined;
let runError: unknown;
try {
await handle.writeFile(`${process.pid}\n${new Date().toISOString()}\n`);
result = await fn(lockPath);
} catch (error) {
runError = error;
}
let closeError: unknown;
try {
await handle.close();
} catch (error) {
closeError = error;
}
let unlinkError: unknown;
try {
await fs.unlink(lockPath);
} catch (error) {
if (codeOf(error) !== "ENOENT") unlinkError = error;
}
if (runError) throw runError;
if (closeError) throw closeError;
if (unlinkError) throw unlinkError;
return result as T;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GiB`;
}
function renderText(result: GcResult): string {
const lines = [`GC ${result.apply ? "applied" : "dry-run"} (${result.agentDir})`];
if (result.blobs) {
lines.push(
`blobs: ${result.blobs.deleted}/${result.blobs.wouldDelete} files, ${formatBytes(result.blobs.bytes)}, ${result.blobs.referenced} refs`,
);
if (result.blobs.errors.length > 0) lines.push(`blob errors: ${result.blobs.errors.length}`);
}
if (result.archive) {
lines.push(
`sessions: ${result.archive.archived}/${result.archive.wouldArchive} archived, ${result.archive.historyRowsDeleted} history rows and ${result.archive.statsRowsDeleted} stats rows removed`,
);
if (result.archive.skippedActive > 0) lines.push(`sessions skipped active: ${result.archive.skippedActive}`);
if (result.archive.errors.length > 0) lines.push(`session errors: ${result.archive.errors.length}`);
}
if (result.wal) {
const state = result.wal.checkpointed ? "checkpointed" : "checkpoint dry-run";
lines.push(`wal: ${state}, ${formatBytes(result.wal.walBytes)} across ${result.wal.databases.length} dbs`);
}
return `${lines.join("\n")}\n`;
}
export async function runGcCommand(args: GcCommandArgs): Promise<GcResult> {
const options = await resolveOptions(args.flags);
const archiveRoot = getArchivedSessionsDir(options.agentDir);
const result = await withGcLock(options.agentDir, async lockPath => {
const next: GcResult = { agentDir: options.agentDir, apply: options.apply, lockPath };
if (options.runBlobs) next.blobs = await runBlobGc(options, archiveRoot);
if (options.runArchive) next.archive = await runArchiveGc(options, archiveRoot);
if (options.runWal) next.wal = await runWalGc(options);
return next;
});
const output = options.json ? `${JSON.stringify(result, null, 2)}\n` : renderText(result);
process.stdout.write(output);
return result;
}