fix(sessions): disk budget counts unremovable *.migrated sidecars and evicts live sessions instead (#107238)

* fix(sessions): exclude migration archives from disk budget

Co-authored-by: SL4N <272086617+SL4N@users.noreply.github.com>

* refactor(sessions): keep disk budget scan bounded

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: SL4N <272086617+SL4N@users.noreply.github.com>
This commit is contained in:
SL4N
2026-07-14 17:19:08 +08:00
committed by GitHub
parent 31a845875d
commit 275cc7fd8f
4 changed files with 55 additions and 4 deletions
+9
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
formatSessionArchiveTimestamp,
isCompactionCheckpointTranscriptFileName,
isMigrationArchiveArtifactName,
isPrimarySessionTranscriptFileName,
isSessionArchiveArtifactName,
isSessionStoreTempArtifactName,
@@ -22,6 +23,14 @@ describe("session artifact helpers", () => {
expect(isSessionArchiveArtifactName("abc.jsonl")).toBe(false);
});
it("classifies migration archive file names", () => {
expect(isMigrationArchiveArtifactName("abc.jsonl.migrated")).toBe(true);
expect(isMigrationArchiveArtifactName("sessions.json.migrated.2")).toBe(true);
expect(isMigrationArchiveArtifactName("abc.jsonl.migrated.tmp")).toBe(false);
expect(isMigrationArchiveArtifactName("abc.migrated.jsonl")).toBe(false);
expect(isMigrationArchiveArtifactName("abc.jsonl.MIGRATED")).toBe(false);
});
it("classifies orphaned session store atomic-write temp files", () => {
const uuid = "0f9c1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b";
const store = "sessions.json";
+6
View File
@@ -9,6 +9,7 @@ export type SessionArchiveReason = "bak" | "reset" | "deleted";
const ARCHIVE_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(?:\.\d{3})?Z$/;
const LEGACY_STORE_BACKUP_RE = /^sessions\.json\.bak\.\d+$/;
const MIGRATION_ARCHIVE_RE = /\.migrated(?:\.\d+)?$/u;
const COMPACTION_CHECKPOINT_TRANSCRIPT_RE =
/^(.+)\.checkpoint\.([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.jsonl$/i;
@@ -37,6 +38,11 @@ export function isSessionArchiveArtifactName(fileName: string): boolean {
);
}
/** Returns true for migration rollback archives retained beside their legacy source. */
export function isMigrationArchiveArtifactName(fileName: string): boolean {
return MIGRATION_ARCHIVE_RE.test(fileName);
}
// Compiled-pattern cache keyed by store basename. A disk sweep calls the matcher
// once per file, so compiling the per-store pattern once (basenames are few — one
// per agent store) keeps the hot path allocation-free.
+37
View File
@@ -54,6 +54,43 @@ function refreshPathBeforeSecondStat(targetPath: string): ReturnType<typeof vi.s
}
describe("enforceSessionDiskBudget", () => {
it("excludes migration archives from the session disk budget (#106875)", async () => {
await withTempDir({ prefix: "openclaw-disk-budget-" }, async (dir) => {
const storePath = path.join(dir, "sessions.json");
const sessionKey = "agent:main:main";
const sessionId = "keep";
const transcriptPath = path.join(dir, `${sessionId}.jsonl`);
const migrationArchivePath = path.join(dir, "legacy.jsonl.migrated");
const numberedMigrationArchivePath = path.join(dir, "legacy.jsonl.migrated.2");
const store: Record<string, SessionEntry> = {
[sessionKey]: { sessionId, updatedAt: Date.now() },
};
await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8");
await fs.writeFile(transcriptPath, "t".repeat(64), "utf-8");
await fs.writeFile(migrationArchivePath, "m".repeat(400), "utf-8");
await fs.writeFile(numberedMigrationArchivePath, "n".repeat(400), "utf-8");
const result = await enforceSessionDiskBudget({
store,
storePath,
maintenance: {
maxDiskBytes: 300,
highWaterBytes: 200,
},
warnOnly: false,
});
expectBudgetResult(result);
expect(result.overBudget).toBe(false);
expect(result.removedEntries).toBe(0);
expect(result.removedFiles).toBe(0);
expect(store).toHaveProperty(sessionKey);
await expectPathExists(transcriptPath);
await expectPathExists(migrationArchivePath);
await expectPathExists(numberedMigrationArchivePath);
});
});
it("does not treat referenced transcripts with marker-like session IDs as archived artifacts", async () => {
await withTempDir({ prefix: "openclaw-disk-budget-" }, async (dir) => {
const storePath = path.join(dir, "sessions.json");
+3 -4
View File
@@ -12,6 +12,7 @@ import {
import { runTasksWithConcurrency } from "../../utils/run-with-concurrency.js";
import {
isCompactionCheckpointTranscriptFileName,
isMigrationArchiveArtifactName,
isPrimarySessionTranscriptFileName,
isSessionArchiveArtifactName,
isSessionStoreTempArtifactName,
@@ -223,11 +224,9 @@ async function readSessionsDirFiles(sessionsDir: string): Promise<SessionsDirFil
const dirEntries = await fs.promises
.readdir(sessionsDir, { withFileTypes: true })
.catch(() => []);
// Stat concurrently: the budget sweep stats every session file, and serial
// stats turn one sweep into per-file latency round trips on networked
// filesystems.
// Skip rollback archives before concurrent stats so retained bytes cannot evict live sessions.
const tasks = dirEntries
.filter((dirent) => dirent.isFile())
.filter((dirent) => dirent.isFile() && !isMigrationArchiveArtifactName(dirent.name))
.map((dirent) => async (): Promise<SessionsDirFileStat | null> => {
const filePath = path.join(sessionsDir, dirent.name);
const stat = await fs.promises.stat(filePath).catch(() => null);