mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
fix: prune orphan session artifacts
This commit is contained in:
@@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI/sessions: prune old unreferenced transcript, compaction checkpoint, and trajectory artifacts during normal `sessions cleanup`, so gateway restart or crash orphans do not accumulate indefinitely outside `sessions.json`. Fixes #77608. Thanks @slideshow-dingo.
|
||||
- Video generation: wait up to 20 minutes for slow fal/MiniMax queue-backed jobs, stop forwarding unsupported Google Veo generated-audio options, and normalize MiniMax `720P` requests to its supported `768P` resolution with the usual override warning/details instead of failing fallback.
|
||||
- Update/restart: probe managed Gateway restarts with the service environment and add a Docker product lane that exercises candidate-owned `openclaw update --yes --json` restarts, so SecretRef-backed local gateway auth cannot regress behind mocked restart checks. Thanks @vincentkoc.
|
||||
- Webhooks/Gmail/Windows: resolve `gcloud`, `gog`, and `tailscale` PATH/PATHEXT shims before setup and watcher spawns, using the Windows-safe `.cmd` wrapper for long-lived `gog serve` processes. (#74881, fixes #54470) Thanks @Angfr95.
|
||||
|
||||
@@ -99,6 +99,7 @@ openclaw sessions cleanup --json
|
||||
`openclaw sessions cleanup` uses `session.maintenance` settings from config:
|
||||
|
||||
- Scope note: `openclaw sessions cleanup` maintains session stores, transcripts, and trajectory sidecars. It does not prune cron run logs (`cron/runs/<jobId>.jsonl`), which are managed by `cron.runLog.maxBytes` and `cron.runLog.keepLines` in [Cron configuration](/automation/cron-jobs#configuration) and explained in [Cron maintenance](/automation/cron-jobs#maintenance).
|
||||
- Cleanup also prunes unreferenced primary transcripts, compaction checkpoints, and trajectory sidecars older than `session.maintenance.pruneAfter`; files still referenced by `sessions.json` are preserved.
|
||||
|
||||
- `--dry-run`: preview how many entries would be pruned/capped without writing.
|
||||
- In text mode, dry-run prints a per-session action table (`Action`, `Key`, `Age`, `Model`, `Flags`) so you can see what would be kept vs removed.
|
||||
|
||||
@@ -85,7 +85,7 @@ Session persistence has automatic maintenance controls (`session.maintenance`) f
|
||||
- `maxDiskBytes`: optional sessions-directory budget
|
||||
- `highWaterBytes`: optional target after cleanup (default `80%` of `maxDiskBytes`)
|
||||
|
||||
Normal Gateway writes flow through a per-store session writer that serializes in-process mutations without taking a runtime file lock. Hot-path patch helpers borrow the validated mutable cache while they hold that writer slot, so large `sessions.json` files are not cloned or reread for every metadata update. Runtime code should prefer `updateSessionStore(...)` or `updateSessionStoreEntry(...)`; direct whole-store saves are compatibility and offline-maintenance tools. When a Gateway is reachable, non-dry-run `openclaw sessions cleanup` and `openclaw agents delete` delegate store mutations to the Gateway so cleanup joins the same writer queue; `--store <path>` is the explicit offline repair path for direct file maintenance. `maxEntries` cleanup is still batched for production-sized caps, so a store may briefly exceed the configured cap before the next high-water cleanup rewrites it back down. Session store reads do not prune or cap entries during Gateway startup; use writes or `openclaw sessions cleanup --enforce` for cleanup. `openclaw sessions cleanup --enforce` still applies the configured cap immediately.
|
||||
Normal Gateway writes flow through a per-store session writer that serializes in-process mutations without taking a runtime file lock. Hot-path patch helpers borrow the validated mutable cache while they hold that writer slot, so large `sessions.json` files are not cloned or reread for every metadata update. Runtime code should prefer `updateSessionStore(...)` or `updateSessionStoreEntry(...)`; direct whole-store saves are compatibility and offline-maintenance tools. When a Gateway is reachable, non-dry-run `openclaw sessions cleanup` and `openclaw agents delete` delegate store mutations to the Gateway so cleanup joins the same writer queue; `--store <path>` is the explicit offline repair path for direct file maintenance. `maxEntries` cleanup is still batched for production-sized caps, so a store may briefly exceed the configured cap before the next high-water cleanup rewrites it back down. Session store reads do not prune or cap entries during Gateway startup; use writes or `openclaw sessions cleanup --enforce` for cleanup. `openclaw sessions cleanup --enforce` still applies the configured cap immediately and prunes old unreferenced transcript, checkpoint, and trajectory artifacts even when no disk budget is configured.
|
||||
|
||||
Maintenance keeps durable external conversation pointers such as group sessions
|
||||
and thread-scoped chat sessions, but synthetic runtime entries for cron, hooks,
|
||||
|
||||
@@ -395,6 +395,12 @@ describe("sessionsCleanupCommand", () => {
|
||||
missing: 0,
|
||||
pruned: 1,
|
||||
capped: 0,
|
||||
unreferencedArtifacts: {
|
||||
scannedFiles: 5,
|
||||
removedFiles: 2,
|
||||
freedBytes: 128,
|
||||
olderThanMs: 604800000,
|
||||
},
|
||||
diskBudget: null,
|
||||
wouldMutate: true,
|
||||
},
|
||||
@@ -420,6 +426,7 @@ describe("sessionsCleanupCommand", () => {
|
||||
);
|
||||
|
||||
expect(logs.some((line) => line.includes("Planned session actions:"))).toBe(true);
|
||||
expect(logs.some((line) => line.includes("Would prune unreferenced artifacts: 2"))).toBe(true);
|
||||
expect(logs.some((line) => line.includes("Action") && line.includes("Key"))).toBe(true);
|
||||
expect(logs.some((line) => line.includes("fresh") && line.includes("keep"))).toBe(true);
|
||||
expect(logs.some((line) => line.includes("stale") && line.includes("prune-stale"))).toBe(true);
|
||||
|
||||
@@ -93,6 +93,11 @@ function renderStoreDryRunPlan(params: {
|
||||
params.runtime.log(`Would prune missing transcripts: ${params.summary.missing}`);
|
||||
params.runtime.log(`Would prune stale: ${params.summary.pruned}`);
|
||||
params.runtime.log(`Would cap overflow: ${params.summary.capped}`);
|
||||
if (params.summary.unreferencedArtifacts?.scannedFiles) {
|
||||
params.runtime.log(
|
||||
`Would prune unreferenced artifacts: ${params.summary.unreferencedArtifacts.removedFiles}`,
|
||||
);
|
||||
}
|
||||
if (params.summary.diskBudget) {
|
||||
params.runtime.log(
|
||||
`Would enforce disk budget: ${params.summary.diskBudget.totalBytesBefore} -> ${params.summary.diskBudget.totalBytesAfter} bytes (files ${params.summary.diskBudget.removedFiles}, entries ${params.summary.diskBudget.removedEntries})`,
|
||||
@@ -141,6 +146,11 @@ function renderAppliedSummaries(params: {
|
||||
}
|
||||
params.runtime.log(`Session store: ${summary.storePath}`);
|
||||
params.runtime.log(`Applied maintenance. Current entries: ${summary.appliedCount ?? 0}`);
|
||||
if (summary.unreferencedArtifacts?.removedFiles) {
|
||||
params.runtime.log(
|
||||
`Pruned unreferenced artifacts: ${summary.unreferencedArtifacts.removedFiles}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { resolveStoredSessionOwnerAgentId } from "../../gateway/session-store-key.js";
|
||||
import { getLogger } from "../../logging/logger.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import type { OpenClawConfig } from "../types.openclaw.js";
|
||||
import { enforceSessionDiskBudget } from "./disk-budget.js";
|
||||
import {
|
||||
enforceSessionDiskBudget,
|
||||
pruneUnreferencedSessionArtifacts,
|
||||
resolveSessionArtifactCanonicalPathsForEntry,
|
||||
type SessionUnreferencedArtifactSweepResult,
|
||||
} from "./disk-budget.js";
|
||||
import {
|
||||
resolveSessionFilePath,
|
||||
resolveSessionFilePathOptions,
|
||||
@@ -54,6 +60,7 @@ export type SessionCleanupSummary = {
|
||||
missing: number;
|
||||
pruned: number;
|
||||
capped: number;
|
||||
unreferencedArtifacts: SessionUnreferencedArtifactSweepResult;
|
||||
diskBudget: Awaited<ReturnType<typeof enforceSessionDiskBudget>>;
|
||||
wouldMutate: boolean;
|
||||
applied?: true;
|
||||
@@ -143,14 +150,35 @@ function pruneMissingTranscriptEntries(params: {
|
||||
return removed;
|
||||
}
|
||||
|
||||
function addEntryArtifactPathsToSet(params: {
|
||||
paths: Set<string>;
|
||||
store: Record<string, SessionEntry>;
|
||||
storePath: string;
|
||||
keys: ReadonlySet<string>;
|
||||
}): void {
|
||||
const sessionsDir = path.dirname(params.storePath);
|
||||
for (const key of params.keys) {
|
||||
const entry = params.store[key];
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
for (const artifactPath of resolveSessionArtifactCanonicalPathsForEntry({
|
||||
sessionsDir,
|
||||
entry,
|
||||
})) {
|
||||
params.paths.add(artifactPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function previewStoreCleanup(params: {
|
||||
target: SessionStoreTarget;
|
||||
maintenance: ResolvedSessionMaintenanceConfig;
|
||||
mode: ResolvedSessionMaintenanceConfig["mode"];
|
||||
dryRun: boolean;
|
||||
activeKey?: string;
|
||||
fixMissing?: boolean;
|
||||
}) {
|
||||
const maintenance = resolveMaintenanceConfig();
|
||||
const beforeStore = loadSessionStore(params.target.storePath, { skipCache: true });
|
||||
const previewStore = cloneSessionStoreRecord(beforeStore);
|
||||
const staleKeys = new Set<string>();
|
||||
@@ -166,26 +194,50 @@ async function previewStoreCleanup(params: {
|
||||
},
|
||||
})
|
||||
: 0;
|
||||
const pruned = pruneStaleEntries(previewStore, maintenance.pruneAfterMs, {
|
||||
const pruned = pruneStaleEntries(previewStore, params.maintenance.pruneAfterMs, {
|
||||
log: false,
|
||||
onPruned: ({ key }) => {
|
||||
staleKeys.add(key);
|
||||
},
|
||||
});
|
||||
const capped = capEntryCount(previewStore, maintenance.maxEntries, {
|
||||
const capped = capEntryCount(previewStore, params.maintenance.maxEntries, {
|
||||
log: false,
|
||||
onCapped: ({ key }) => {
|
||||
cappedKeys.add(key);
|
||||
},
|
||||
});
|
||||
const entryCleanupArtifactPaths = new Set<string>();
|
||||
addEntryArtifactPathsToSet({
|
||||
paths: entryCleanupArtifactPaths,
|
||||
store: beforeStore,
|
||||
storePath: params.target.storePath,
|
||||
keys: staleKeys,
|
||||
});
|
||||
addEntryArtifactPathsToSet({
|
||||
paths: entryCleanupArtifactPaths,
|
||||
store: beforeStore,
|
||||
storePath: params.target.storePath,
|
||||
keys: cappedKeys,
|
||||
});
|
||||
const beforeBudgetStore = cloneSessionStoreRecord(previewStore);
|
||||
const budgetRemovedFilePaths = new Set<string>();
|
||||
const diskBudget = await enforceSessionDiskBudget({
|
||||
store: previewStore,
|
||||
storePath: params.target.storePath,
|
||||
activeSessionKey: params.activeKey,
|
||||
maintenance,
|
||||
maintenance: params.maintenance,
|
||||
warnOnly: false,
|
||||
dryRun: true,
|
||||
onRemoveFile: (canonicalPath) => {
|
||||
budgetRemovedFilePaths.add(canonicalPath);
|
||||
},
|
||||
});
|
||||
const unreferencedArtifacts = await pruneUnreferencedSessionArtifacts({
|
||||
store: previewStore,
|
||||
storePath: params.target.storePath,
|
||||
olderThanMs: params.maintenance.pruneAfterMs,
|
||||
dryRun: true,
|
||||
excludeCanonicalPaths: new Set([...budgetRemovedFilePaths, ...entryCleanupArtifactPaths]),
|
||||
});
|
||||
const budgetEvictedKeys = new Set<string>();
|
||||
for (const key of Object.keys(beforeBudgetStore)) {
|
||||
@@ -199,6 +251,7 @@ async function previewStoreCleanup(params: {
|
||||
missing > 0 ||
|
||||
pruned > 0 ||
|
||||
capped > 0 ||
|
||||
unreferencedArtifacts.removedFiles > 0 ||
|
||||
(diskBudget?.removedEntries ?? 0) > 0 ||
|
||||
(diskBudget?.removedFiles ?? 0) > 0;
|
||||
|
||||
@@ -212,6 +265,7 @@ async function previewStoreCleanup(params: {
|
||||
missing,
|
||||
pruned,
|
||||
capped,
|
||||
unreferencedArtifacts,
|
||||
diskBudget,
|
||||
wouldMutate,
|
||||
};
|
||||
@@ -232,7 +286,8 @@ export async function runSessionsCleanup(params: {
|
||||
targets?: SessionStoreTarget[];
|
||||
}): Promise<SessionsCleanupRunResult> {
|
||||
const { cfg, opts } = params;
|
||||
const mode = opts.enforce ? "enforce" : resolveMaintenanceConfig().mode;
|
||||
const maintenance = resolveMaintenanceConfig();
|
||||
const mode = opts.enforce ? "enforce" : maintenance.mode;
|
||||
const targets =
|
||||
params.targets ??
|
||||
resolveSessionStoreTargets(cfg, {
|
||||
@@ -245,6 +300,7 @@ export async function runSessionsCleanup(params: {
|
||||
for (const target of targets) {
|
||||
const result = await previewStoreCleanup({
|
||||
target,
|
||||
maintenance,
|
||||
mode,
|
||||
dryRun: Boolean(opts.dryRun),
|
||||
activeKey: opts.activeKey,
|
||||
@@ -281,6 +337,20 @@ export async function runSessionsCleanup(params: {
|
||||
},
|
||||
);
|
||||
const afterStore = loadSessionStore(target.storePath, { skipCache: true });
|
||||
const unreferencedArtifacts =
|
||||
mode === "warn"
|
||||
? {
|
||||
scannedFiles: 0,
|
||||
removedFiles: 0,
|
||||
freedBytes: 0,
|
||||
olderThanMs: maintenance.pruneAfterMs,
|
||||
}
|
||||
: await pruneUnreferencedSessionArtifacts({
|
||||
store: afterStore,
|
||||
storePath: target.storePath,
|
||||
olderThanMs: maintenance.pruneAfterMs,
|
||||
dryRun: false,
|
||||
});
|
||||
const preview = previewResults.find(
|
||||
(result) => result.summary.storePath === target.storePath,
|
||||
);
|
||||
@@ -298,10 +368,14 @@ export async function runSessionsCleanup(params: {
|
||||
missing: 0,
|
||||
pruned: 0,
|
||||
capped: 0,
|
||||
unreferencedArtifacts,
|
||||
diskBudget: null,
|
||||
wouldMutate: false,
|
||||
}),
|
||||
dryRun: false,
|
||||
unreferencedArtifacts,
|
||||
wouldMutate:
|
||||
(preview?.summary.wouldMutate ?? false) || unreferencedArtifacts.removedFiles > 0,
|
||||
applied: true,
|
||||
appliedCount: Object.keys(afterStore).length,
|
||||
}
|
||||
@@ -315,11 +389,13 @@ export async function runSessionsCleanup(params: {
|
||||
missing: missingApplied,
|
||||
pruned: appliedReport.pruned,
|
||||
capped: appliedReport.capped,
|
||||
unreferencedArtifacts,
|
||||
diskBudget: appliedReport.diskBudget,
|
||||
wouldMutate:
|
||||
missingApplied > 0 ||
|
||||
appliedReport.pruned > 0 ||
|
||||
appliedReport.capped > 0 ||
|
||||
unreferencedArtifacts.removedFiles > 0 ||
|
||||
(appliedReport.diskBudget?.removedEntries ?? 0) > 0 ||
|
||||
(appliedReport.diskBudget?.removedFiles ?? 0) > 0,
|
||||
applied: true,
|
||||
|
||||
@@ -34,6 +34,13 @@ export type SessionDiskBudgetSweepResult = {
|
||||
overBudget: boolean;
|
||||
};
|
||||
|
||||
export type SessionUnreferencedArtifactSweepResult = {
|
||||
scannedFiles: number;
|
||||
removedFiles: number;
|
||||
freedBytes: number;
|
||||
olderThanMs: number;
|
||||
};
|
||||
|
||||
export type SessionDiskBudgetLogger = {
|
||||
warn: (message: string, context?: Record<string, unknown>) => void;
|
||||
info: (message: string, context?: Record<string, unknown>) => void;
|
||||
@@ -147,6 +154,13 @@ function resolveSessionArtifactPathsForEntry(params: {
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function resolveSessionArtifactCanonicalPathsForEntry(params: {
|
||||
sessionsDir: string;
|
||||
entry: SessionEntry;
|
||||
}): string[] {
|
||||
return resolveSessionArtifactPathsForEntry(params).map(canonicalizePathForComparison);
|
||||
}
|
||||
|
||||
function resolveReferencedSessionArtifactPaths(params: {
|
||||
sessionsDir: string;
|
||||
store: Record<string, SessionEntry>;
|
||||
@@ -154,11 +168,11 @@ function resolveReferencedSessionArtifactPaths(params: {
|
||||
const referenced = new Set<string>();
|
||||
const resolvedSessionsDir = canonicalizePathForComparison(params.sessionsDir);
|
||||
for (const entry of Object.values(params.store)) {
|
||||
for (const resolved of resolveSessionArtifactPathsForEntry({
|
||||
for (const resolved of resolveSessionArtifactCanonicalPathsForEntry({
|
||||
sessionsDir: params.sessionsDir,
|
||||
entry,
|
||||
})) {
|
||||
referenced.add(canonicalizePathForComparison(resolved));
|
||||
referenced.add(resolved);
|
||||
}
|
||||
for (const checkpoint of entry.compactionCheckpoints ?? []) {
|
||||
const checkpointFile = checkpoint.preCompaction.sessionFile?.trim();
|
||||
@@ -200,6 +214,30 @@ async function readSessionsDirFiles(sessionsDir: string): Promise<SessionsDirFil
|
||||
return files;
|
||||
}
|
||||
|
||||
function isUnreferencedSessionArtifactFile(
|
||||
file: Pick<SessionsDirFileStat, "canonicalPath" | "name">,
|
||||
referencedPaths: ReadonlySet<string>,
|
||||
): boolean {
|
||||
if (referencedPaths.has(file.canonicalPath)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
isCompactionCheckpointTranscriptFileName(file.name) ||
|
||||
isTrajectorySessionArtifactName(file.name) ||
|
||||
isPrimarySessionTranscriptFileName(file.name)
|
||||
);
|
||||
}
|
||||
|
||||
function isDiskBudgetRemovableSessionFile(
|
||||
file: Pick<SessionsDirFileStat, "canonicalPath" | "name">,
|
||||
referencedPaths: ReadonlySet<string>,
|
||||
): boolean {
|
||||
return (
|
||||
isSessionArchiveArtifactName(file.name) ||
|
||||
isUnreferencedSessionArtifactFile(file, referencedPaths)
|
||||
);
|
||||
}
|
||||
|
||||
async function removeFileIfExists(filePath: string): Promise<number> {
|
||||
const stat = await fs.promises.stat(filePath).catch(() => null);
|
||||
if (!stat?.isFile()) {
|
||||
@@ -215,6 +253,7 @@ async function removeFileForBudget(params: {
|
||||
dryRun: boolean;
|
||||
fileSizesByPath: Map<string, number>;
|
||||
simulatedRemovedPaths: Set<string>;
|
||||
onRemovedPath?: (canonicalPath: string) => void;
|
||||
}): Promise<number> {
|
||||
const resolvedPath = path.resolve(params.filePath);
|
||||
const canonicalPath = params.canonicalPath ?? canonicalizePathForComparison(resolvedPath);
|
||||
@@ -227,9 +266,66 @@ async function removeFileForBudget(params: {
|
||||
return 0;
|
||||
}
|
||||
params.simulatedRemovedPaths.add(canonicalPath);
|
||||
params.onRemovedPath?.(canonicalPath);
|
||||
return size;
|
||||
}
|
||||
return removeFileIfExists(resolvedPath);
|
||||
const size = await removeFileIfExists(resolvedPath);
|
||||
if (size > 0) {
|
||||
params.onRemovedPath?.(canonicalPath);
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
export async function pruneUnreferencedSessionArtifacts(params: {
|
||||
store: Record<string, SessionEntry>;
|
||||
storePath: string;
|
||||
olderThanMs: number;
|
||||
dryRun?: boolean;
|
||||
excludeCanonicalPaths?: ReadonlySet<string>;
|
||||
}): Promise<SessionUnreferencedArtifactSweepResult> {
|
||||
const olderThanMs =
|
||||
Number.isFinite(params.olderThanMs) && params.olderThanMs > 0 ? params.olderThanMs : 0;
|
||||
const sessionsDir = path.dirname(params.storePath);
|
||||
const files = await readSessionsDirFiles(sessionsDir);
|
||||
const fileSizesByPath = new Map(files.map((file) => [file.canonicalPath, file.size]));
|
||||
const simulatedRemovedPaths = new Set<string>();
|
||||
const referencedPaths = resolveReferencedSessionArtifactPaths({
|
||||
sessionsDir,
|
||||
store: params.store,
|
||||
});
|
||||
const cutoffMs = Date.now() - olderThanMs;
|
||||
const removableFiles = files
|
||||
.filter(
|
||||
(file) =>
|
||||
!params.excludeCanonicalPaths?.has(file.canonicalPath) &&
|
||||
file.mtimeMs <= cutoffMs &&
|
||||
isUnreferencedSessionArtifactFile(file, referencedPaths),
|
||||
)
|
||||
.toSorted((a, b) => a.mtimeMs - b.mtimeMs);
|
||||
|
||||
let removedFiles = 0;
|
||||
let freedBytes = 0;
|
||||
for (const file of removableFiles) {
|
||||
const deletedBytes = await removeFileForBudget({
|
||||
filePath: file.path,
|
||||
canonicalPath: file.canonicalPath,
|
||||
dryRun: params.dryRun === true,
|
||||
fileSizesByPath,
|
||||
simulatedRemovedPaths,
|
||||
});
|
||||
if (deletedBytes <= 0) {
|
||||
continue;
|
||||
}
|
||||
removedFiles += 1;
|
||||
freedBytes += deletedBytes;
|
||||
}
|
||||
|
||||
return {
|
||||
scannedFiles: files.length,
|
||||
removedFiles,
|
||||
freedBytes,
|
||||
olderThanMs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function enforceSessionDiskBudget(params: {
|
||||
@@ -240,6 +336,7 @@ export async function enforceSessionDiskBudget(params: {
|
||||
warnOnly: boolean;
|
||||
dryRun?: boolean;
|
||||
log?: SessionDiskBudgetLogger;
|
||||
onRemoveFile?: (canonicalPath: string) => void;
|
||||
}): Promise<SessionDiskBudgetSweepResult | null> {
|
||||
const maxBytes = params.maintenance.maxDiskBytes;
|
||||
const highWaterBytes = params.maintenance.highWaterBytes;
|
||||
@@ -299,14 +396,7 @@ export async function enforceSessionDiskBudget(params: {
|
||||
store: params.store,
|
||||
});
|
||||
const removableFileQueue = files
|
||||
.filter(
|
||||
(file) =>
|
||||
isSessionArchiveArtifactName(file.name) ||
|
||||
(isCompactionCheckpointTranscriptFileName(file.name) &&
|
||||
!referencedPaths.has(file.canonicalPath)) ||
|
||||
(isTrajectorySessionArtifactName(file.name) && !referencedPaths.has(file.canonicalPath)) ||
|
||||
(isPrimarySessionTranscriptFileName(file.name) && !referencedPaths.has(file.canonicalPath)),
|
||||
)
|
||||
.filter((file) => isDiskBudgetRemovableSessionFile(file, referencedPaths))
|
||||
.toSorted((a, b) => a.mtimeMs - b.mtimeMs);
|
||||
for (const file of removableFileQueue) {
|
||||
if (total <= highWaterBytes) {
|
||||
@@ -318,6 +408,7 @@ export async function enforceSessionDiskBudget(params: {
|
||||
dryRun,
|
||||
fileSizesByPath,
|
||||
simulatedRemovedPaths,
|
||||
onRemovedPath: params.onRemoveFile,
|
||||
});
|
||||
if (deletedBytes <= 0) {
|
||||
continue;
|
||||
@@ -379,6 +470,7 @@ export async function enforceSessionDiskBudget(params: {
|
||||
dryRun,
|
||||
fileSizesByPath,
|
||||
simulatedRemovedPaths,
|
||||
onRemovedPath: params.onRemoveFile,
|
||||
});
|
||||
if (deletedBytes <= 0) {
|
||||
continue;
|
||||
|
||||
@@ -16,6 +16,7 @@ vi.mock("../config.js", async () => ({
|
||||
}));
|
||||
|
||||
import { getRuntimeConfig } from "../config.js";
|
||||
import { runSessionsCleanup } from "./cleanup-service.js";
|
||||
import {
|
||||
clearSessionStoreCacheForTest,
|
||||
loadSessionStore,
|
||||
@@ -211,6 +212,190 @@ describe("Integration: saveSessionStore with pruning", () => {
|
||||
await expect(fs.stat(freshPointer)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("sessions cleanup prunes old unreferenced session artifacts without touching referenced files", async () => {
|
||||
applyEnforcedMaintenanceConfig(mockLoadConfig);
|
||||
|
||||
const now = Date.now();
|
||||
const oldDate = new Date(now - 10 * DAY_MS);
|
||||
const freshDate = new Date(now);
|
||||
const referencedCheckpointPath = path.join(
|
||||
testDir,
|
||||
"fresh-session.checkpoint.22222222-2222-4222-8222-222222222222.jsonl",
|
||||
);
|
||||
const store: Record<string, SessionEntry> = {
|
||||
fresh: {
|
||||
sessionId: "fresh-session",
|
||||
updatedAt: now,
|
||||
compactionCheckpoints: [
|
||||
{
|
||||
checkpointId: "referenced",
|
||||
sessionKey: "fresh",
|
||||
sessionId: "fresh-session",
|
||||
createdAt: now,
|
||||
reason: "manual",
|
||||
preCompaction: {
|
||||
sessionId: "fresh-session",
|
||||
sessionFile: referencedCheckpointPath,
|
||||
leafId: "leaf",
|
||||
},
|
||||
postCompaction: { sessionId: "fresh-session" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const referencedTranscript = path.join(testDir, "fresh-session.jsonl");
|
||||
const oldOrphanTranscript = path.join(testDir, "orphan-session.jsonl");
|
||||
const freshOrphanTranscript = path.join(testDir, "fresh-orphan.jsonl");
|
||||
const orphanRuntime = path.join(testDir, "orphan-session.trajectory.jsonl");
|
||||
const orphanPointer = path.join(testDir, "orphan-session.trajectory-path.json");
|
||||
const orphanCheckpoint = path.join(
|
||||
testDir,
|
||||
"orphan-session.checkpoint.11111111-1111-4111-8111-111111111111.jsonl",
|
||||
);
|
||||
await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8");
|
||||
await fs.writeFile(referencedTranscript, "referenced", "utf-8");
|
||||
await fs.writeFile(referencedCheckpointPath, "referenced checkpoint", "utf-8");
|
||||
await fs.writeFile(oldOrphanTranscript, "orphan transcript", "utf-8");
|
||||
await fs.writeFile(freshOrphanTranscript, "fresh orphan", "utf-8");
|
||||
await fs.writeFile(orphanRuntime, "orphan runtime", "utf-8");
|
||||
await fs.writeFile(orphanPointer, "orphan pointer", "utf-8");
|
||||
await fs.writeFile(orphanCheckpoint, "orphan checkpoint", "utf-8");
|
||||
for (const file of [
|
||||
referencedTranscript,
|
||||
referencedCheckpointPath,
|
||||
oldOrphanTranscript,
|
||||
orphanRuntime,
|
||||
orphanPointer,
|
||||
orphanCheckpoint,
|
||||
]) {
|
||||
await fs.utimes(file, oldDate, oldDate);
|
||||
}
|
||||
await fs.utimes(freshOrphanTranscript, freshDate, freshDate);
|
||||
|
||||
const dryRun = await runSessionsCleanup({
|
||||
cfg: {},
|
||||
opts: { store: storePath, dryRun: true, enforce: true },
|
||||
targets: [{ agentId: "main", storePath }],
|
||||
});
|
||||
expect(dryRun.previewResults[0]?.summary.unreferencedArtifacts).toEqual(
|
||||
expect.objectContaining({
|
||||
removedFiles: 4,
|
||||
}),
|
||||
);
|
||||
await expect(fs.stat(oldOrphanTranscript)).resolves.toBeDefined();
|
||||
await expect(fs.stat(orphanRuntime)).resolves.toBeDefined();
|
||||
await expect(fs.stat(orphanPointer)).resolves.toBeDefined();
|
||||
await expect(fs.stat(orphanCheckpoint)).resolves.toBeDefined();
|
||||
|
||||
const applied = await runSessionsCleanup({
|
||||
cfg: {},
|
||||
opts: { store: storePath, enforce: true },
|
||||
targets: [{ agentId: "main", storePath }],
|
||||
});
|
||||
|
||||
expect(applied.appliedSummaries[0]?.unreferencedArtifacts).toEqual(
|
||||
expect.objectContaining({
|
||||
removedFiles: 4,
|
||||
}),
|
||||
);
|
||||
await expect(fs.stat(oldOrphanTranscript)).rejects.toThrow();
|
||||
await expect(fs.stat(orphanRuntime)).rejects.toThrow();
|
||||
await expect(fs.stat(orphanPointer)).rejects.toThrow();
|
||||
await expect(fs.stat(orphanCheckpoint)).rejects.toThrow();
|
||||
await expect(fs.stat(referencedTranscript)).resolves.toBeDefined();
|
||||
await expect(fs.stat(referencedCheckpointPath)).resolves.toBeDefined();
|
||||
await expect(fs.stat(freshOrphanTranscript)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("sessions cleanup dry-run does not double-count artifacts already covered by disk budget", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
session: {
|
||||
maintenance: {
|
||||
mode: "enforce",
|
||||
pruneAfter: "7d",
|
||||
maxEntries: 500,
|
||||
maxDiskBytes: 1000,
|
||||
highWaterBytes: 900,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const store: Record<string, SessionEntry> = {
|
||||
fresh: { sessionId: "fresh-session", updatedAt: Date.now() },
|
||||
};
|
||||
const oldOrphanTranscript = path.join(testDir, "orphan-session.jsonl");
|
||||
await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8");
|
||||
await fs.writeFile(oldOrphanTranscript, "x".repeat(2000), "utf-8");
|
||||
const oldDate = new Date(Date.now() - 10 * DAY_MS);
|
||||
await fs.utimes(oldOrphanTranscript, oldDate, oldDate);
|
||||
|
||||
const dryRun = await runSessionsCleanup({
|
||||
cfg: {},
|
||||
opts: { store: storePath, dryRun: true, enforce: true },
|
||||
targets: [{ agentId: "main", storePath }],
|
||||
});
|
||||
|
||||
expect(dryRun.previewResults[0]?.summary.diskBudget).toEqual(
|
||||
expect.objectContaining({
|
||||
removedFiles: 1,
|
||||
}),
|
||||
);
|
||||
expect(dryRun.previewResults[0]?.summary.unreferencedArtifacts).toEqual(
|
||||
expect.objectContaining({
|
||||
removedFiles: 0,
|
||||
}),
|
||||
);
|
||||
await expect(fs.stat(oldOrphanTranscript)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("sessions cleanup dry-run excludes stale and capped entry transcripts from orphan counts", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
session: {
|
||||
maintenance: {
|
||||
mode: "enforce",
|
||||
pruneAfter: "7d",
|
||||
maxEntries: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
const store: Record<string, SessionEntry> = {
|
||||
stale: { sessionId: "stale-session", updatedAt: now - 30 * DAY_MS },
|
||||
capped: { sessionId: "capped-session", updatedAt: now - DAY_MS },
|
||||
fresh: { sessionId: "fresh-session", updatedAt: now },
|
||||
};
|
||||
const staleTranscript = path.join(testDir, "stale-session.jsonl");
|
||||
const cappedTranscript = path.join(testDir, "capped-session.jsonl");
|
||||
const freshTranscript = path.join(testDir, "fresh-session.jsonl");
|
||||
await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8");
|
||||
await fs.writeFile(staleTranscript, "stale", "utf-8");
|
||||
await fs.writeFile(cappedTranscript, "capped", "utf-8");
|
||||
await fs.writeFile(freshTranscript, "fresh", "utf-8");
|
||||
const oldDate = new Date(now - 10 * DAY_MS);
|
||||
await fs.utimes(staleTranscript, oldDate, oldDate);
|
||||
await fs.utimes(cappedTranscript, oldDate, oldDate);
|
||||
|
||||
const dryRun = await runSessionsCleanup({
|
||||
cfg: {},
|
||||
opts: { store: storePath, dryRun: true, enforce: true },
|
||||
targets: [{ agentId: "main", storePath }],
|
||||
});
|
||||
|
||||
expect(dryRun.previewResults[0]?.summary).toEqual(
|
||||
expect.objectContaining({
|
||||
pruned: 1,
|
||||
capped: 1,
|
||||
unreferencedArtifacts: expect.objectContaining({
|
||||
removedFiles: 0,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await expect(fs.stat(staleTranscript)).resolves.toBeDefined();
|
||||
await expect(fs.stat(cappedTranscript)).resolves.toBeDefined();
|
||||
await expect(fs.stat(freshTranscript)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("cleans up archived transcripts older than the prune window", async () => {
|
||||
applyEnforcedMaintenanceConfig(mockLoadConfig);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user