Skip to content

Commit 1c6911c

Browse files
committed
fix: ignore compaction checkpoints in session usage
1 parent 956cb1c commit 1c6911c

11 files changed

Lines changed: 346 additions & 15 deletions

src/config/sessions/artifacts.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { describe, expect, it } from "vitest";
22
import {
33
formatSessionArchiveTimestamp,
4+
isCompactionCheckpointTranscriptFileName,
45
isPrimarySessionTranscriptFileName,
56
isSessionArchiveArtifactName,
67
isUsageCountedSessionTranscriptFileName,
8+
parseCompactionCheckpointTranscriptFileName,
79
parseUsageCountedSessionIdFromFileName,
810
parseSessionArchiveTimestamp,
911
} from "./artifacts.js";
@@ -21,6 +23,11 @@ describe("session artifact helpers", () => {
2123
it("classifies primary transcript files", () => {
2224
expect(isPrimarySessionTranscriptFileName("abc.jsonl")).toBe(true);
2325
expect(isPrimarySessionTranscriptFileName("keep.deleted.keep.jsonl")).toBe(true);
26+
expect(
27+
isPrimarySessionTranscriptFileName(
28+
"abc.checkpoint.11111111-1111-4111-8111-111111111111.jsonl",
29+
),
30+
).toBe(false);
2431
expect(isPrimarySessionTranscriptFileName("abc.jsonl.deleted.2026-01-01T00-00-00.000Z")).toBe(
2532
false,
2633
);
@@ -38,6 +45,11 @@ describe("session artifact helpers", () => {
3845
expect(isUsageCountedSessionTranscriptFileName("abc.jsonl.bak.2026-01-01T00-00-00.000Z")).toBe(
3946
false,
4047
);
48+
expect(
49+
isUsageCountedSessionTranscriptFileName(
50+
"abc.checkpoint.11111111-1111-4111-8111-111111111111.jsonl",
51+
),
52+
).toBe(false);
4153
});
4254

4355
it("parses usage-counted session ids from file names", () => {
@@ -51,6 +63,28 @@ describe("session artifact helpers", () => {
5163
expect(parseUsageCountedSessionIdFromFileName("abc.jsonl.bak.2026-01-01T00-00-00.000Z")).toBe(
5264
null,
5365
);
66+
expect(
67+
parseUsageCountedSessionIdFromFileName(
68+
"abc.checkpoint.11111111-1111-4111-8111-111111111111.jsonl",
69+
),
70+
).toBeNull();
71+
});
72+
73+
it("parses exact compaction checkpoint transcript file names", () => {
74+
expect(
75+
parseCompactionCheckpointTranscriptFileName(
76+
"abc.checkpoint.11111111-1111-4111-8111-111111111111.jsonl",
77+
),
78+
).toEqual({
79+
sessionId: "abc",
80+
checkpointId: "11111111-1111-4111-8111-111111111111",
81+
});
82+
expect(isCompactionCheckpointTranscriptFileName("abc.checkpoint.not-a-uuid.jsonl")).toBe(false);
83+
expect(
84+
isCompactionCheckpointTranscriptFileName(
85+
"abc.checkpoint.11111111-1111-4111-8111-111111111111.jsonl.deleted.2026-01-01T00-00-00.000Z",
86+
),
87+
).toBe(false);
5488
});
5589

5690
it("formats and parses archive timestamps", () => {

src/config/sessions/artifacts.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export type SessionArchiveReason = "bak" | "reset" | "deleted";
22

33
const ARCHIVE_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(?:\.\d{3})?Z$/;
44
const LEGACY_STORE_BACKUP_RE = /^sessions\.json\.bak\.\d+$/;
5+
const COMPACTION_CHECKPOINT_TRANSCRIPT_RE =
6+
/^(.+)\.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;
57

68
function hasArchiveSuffix(fileName: string, reason: SessionArchiveReason): boolean {
79
const marker = `.${reason}.`;
@@ -24,13 +26,30 @@ export function isSessionArchiveArtifactName(fileName: string): boolean {
2426
);
2527
}
2628

29+
export function parseCompactionCheckpointTranscriptFileName(fileName: string): {
30+
sessionId: string;
31+
checkpointId: string;
32+
} | null {
33+
const match = COMPACTION_CHECKPOINT_TRANSCRIPT_RE.exec(fileName);
34+
const sessionId = match?.[1];
35+
const checkpointId = match?.[2];
36+
return sessionId && checkpointId ? { sessionId, checkpointId } : null;
37+
}
38+
39+
export function isCompactionCheckpointTranscriptFileName(fileName: string): boolean {
40+
return parseCompactionCheckpointTranscriptFileName(fileName) !== null;
41+
}
42+
2743
export function isPrimarySessionTranscriptFileName(fileName: string): boolean {
2844
if (fileName === "sessions.json") {
2945
return false;
3046
}
3147
if (!fileName.endsWith(".jsonl")) {
3248
return false;
3349
}
50+
if (isCompactionCheckpointTranscriptFileName(fileName)) {
51+
return false;
52+
}
3453
return !isSessionArchiveArtifactName(fileName);
3554
}
3655

src/config/sessions/disk-budget.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,65 @@ describe("enforceSessionDiskBudget", () => {
8181
);
8282
});
8383
});
84+
85+
it("removes unreferenced compaction checkpoint artifacts under pressure", async () => {
86+
await withTempDir({ prefix: "openclaw-disk-budget-" }, async (dir) => {
87+
const storePath = path.join(dir, "sessions.json");
88+
const sessionId = "keep";
89+
const transcriptPath = path.join(dir, `${sessionId}.jsonl`);
90+
const checkpointPath = path.join(
91+
dir,
92+
"keep.checkpoint.11111111-1111-4111-8111-111111111111.jsonl",
93+
);
94+
const referencedCheckpointPath = path.join(
95+
dir,
96+
"keep.checkpoint.22222222-2222-4222-8222-222222222222.jsonl",
97+
);
98+
const store: Record<string, SessionEntry> = {
99+
"agent:main:main": {
100+
sessionId,
101+
updatedAt: Date.now(),
102+
compactionCheckpoints: [
103+
{
104+
checkpointId: "referenced",
105+
sessionKey: "agent:main:main",
106+
sessionId,
107+
createdAt: Date.now(),
108+
reason: "manual",
109+
preCompaction: {
110+
sessionId,
111+
sessionFile: referencedCheckpointPath,
112+
leafId: "leaf",
113+
},
114+
postCompaction: { sessionId },
115+
},
116+
],
117+
},
118+
};
119+
await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8");
120+
await fs.writeFile(transcriptPath, "k".repeat(80), "utf-8");
121+
await fs.writeFile(checkpointPath, "c".repeat(5000), "utf-8");
122+
await fs.writeFile(referencedCheckpointPath, "r".repeat(260), "utf-8");
123+
124+
const result = await enforceSessionDiskBudget({
125+
store,
126+
storePath,
127+
maintenance: {
128+
maxDiskBytes: 4000,
129+
highWaterBytes: 3000,
130+
},
131+
warnOnly: false,
132+
});
133+
134+
await expect(fs.stat(transcriptPath)).resolves.toBeDefined();
135+
await expect(fs.stat(checkpointPath)).rejects.toThrow();
136+
await expect(fs.stat(referencedCheckpointPath)).resolves.toBeDefined();
137+
expect(result).toEqual(
138+
expect.objectContaining({
139+
removedFiles: 1,
140+
removedEntries: 0,
141+
}),
142+
);
143+
});
144+
});
84145
});

src/config/sessions/disk-budget.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import {
44
normalizeLowercaseStringOrEmpty,
55
normalizeOptionalLowercaseString,
66
} from "../../shared/string-coerce.js";
7-
import { isPrimarySessionTranscriptFileName, isSessionArchiveArtifactName } from "./artifacts.js";
7+
import {
8+
isCompactionCheckpointTranscriptFileName,
9+
isPrimarySessionTranscriptFileName,
10+
isSessionArchiveArtifactName,
11+
} from "./artifacts.js";
812
import { resolveSessionFilePath } from "./paths.js";
913
import type { SessionEntry } from "./types.js";
1014

@@ -120,6 +124,7 @@ function resolveReferencedSessionTranscriptPaths(params: {
120124
store: Record<string, SessionEntry>;
121125
}): Set<string> {
122126
const referenced = new Set<string>();
127+
const resolvedSessionsDir = canonicalizePathForComparison(params.sessionsDir);
123128
for (const entry of Object.values(params.store)) {
124129
const resolved = resolveSessionTranscriptPathForEntry({
125130
sessionsDir: params.sessionsDir,
@@ -128,6 +133,17 @@ function resolveReferencedSessionTranscriptPaths(params: {
128133
if (resolved) {
129134
referenced.add(canonicalizePathForComparison(resolved));
130135
}
136+
for (const checkpoint of entry.compactionCheckpoints ?? []) {
137+
const checkpointFile = checkpoint.preCompaction.sessionFile?.trim();
138+
if (!checkpointFile) {
139+
continue;
140+
}
141+
const resolvedCheckpointPath = canonicalizePathForComparison(checkpointFile);
142+
const relative = path.relative(resolvedSessionsDir, resolvedCheckpointPath);
143+
if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
144+
referenced.add(resolvedCheckpointPath);
145+
}
146+
}
131147
}
132148
return referenced;
133149
}
@@ -259,6 +275,8 @@ export async function enforceSessionDiskBudget(params: {
259275
.filter(
260276
(file) =>
261277
isSessionArchiveArtifactName(file.name) ||
278+
(isCompactionCheckpointTranscriptFileName(file.name) &&
279+
!referencedPaths.has(file.canonicalPath)) ||
262280
(isPrimarySessionTranscriptFileName(file.name) && !referencedPaths.has(file.canonicalPath)),
263281
)
264282
.toSorted((a, b) => a.mtimeMs - b.mtimeMs);

src/config/sessions/paths.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { expandHomePrefix, resolveRequiredHomeDir } from "../../infra/home-dir.j
55
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js";
66
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
77
import { resolveStateDir } from "../paths.js";
8+
import { isCompactionCheckpointTranscriptFileName } from "./artifacts.js";
89

910
function resolveAgentSessionsDir(
1011
agentId?: string,
@@ -62,7 +63,10 @@ export const SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
6263

6364
export function validateSessionId(sessionId: string): string {
6465
const trimmed = sessionId.trim();
65-
if (!SAFE_SESSION_ID_RE.test(trimmed)) {
66+
if (
67+
!SAFE_SESSION_ID_RE.test(trimmed) ||
68+
isCompactionCheckpointTranscriptFileName(`${trimmed}.jsonl`)
69+
) {
6670
throw new Error(`Invalid session ID: ${sessionId}`);
6771
}
6872
return trimmed;

src/config/sessions/sessions.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,13 @@ import { mergeSessionEntry, type SessionEntry } from "./types.js";
2121

2222
describe("session path safety", () => {
2323
it("rejects unsafe session IDs", () => {
24-
const unsafeSessionIds = ["../etc/passwd", "a/b", "a\\b", "/abs"];
24+
const unsafeSessionIds = [
25+
"../etc/passwd",
26+
"a/b",
27+
"a\\b",
28+
"/abs",
29+
"sess.checkpoint.11111111-1111-4111-8111-111111111111",
30+
];
2531
for (const sessionId of unsafeSessionIds) {
2632
expect(() => validateSessionId(sessionId), sessionId).toThrow(/Invalid session ID/);
2733
}

src/gateway/session-compaction-checkpoints.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import path from "node:path";
55
import type { AssistantMessage, UserMessage } from "@mariozechner/pi-ai";
66
import { SessionManager } from "@mariozechner/pi-coding-agent";
77
import { afterEach, describe, expect, test } from "vitest";
8+
import type { OpenClawConfig } from "../config/types.openclaw.js";
89
import {
910
captureCompactionCheckpointSnapshot,
1011
cleanupCompactionCheckpointSnapshot,
12+
persistSessionCompactionCheckpoint,
1113
} from "./session-compaction-checkpoints.js";
1214

1315
const tempDirs: string[] = [];
@@ -81,4 +83,83 @@ describe("session-compaction-checkpoints", () => {
8183
expect(fsSync.existsSync(snapshot!.sessionFile)).toBe(false);
8284
expect(fsSync.existsSync(sessionFile!)).toBe(true);
8385
});
86+
87+
test("persist trims old checkpoint metadata and removes trimmed snapshot files", async () => {
88+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-checkpoint-trim-"));
89+
tempDirs.push(dir);
90+
91+
const storePath = path.join(dir, "sessions.json");
92+
const sessionId = "sess";
93+
const sessionKey = "agent:main:main";
94+
const now = Date.now();
95+
const existingCheckpoints = Array.from({ length: 26 }, (_, index) => {
96+
const uuid = `${String(index + 1).padStart(8, "0")}-1111-4111-8111-111111111111`;
97+
const sessionFile = path.join(dir, `sess.checkpoint.${uuid}.jsonl`);
98+
fsSync.writeFileSync(sessionFile, `checkpoint ${index}`, "utf-8");
99+
return {
100+
checkpointId: `old-${index}`,
101+
sessionKey,
102+
sessionId,
103+
createdAt: now + index,
104+
reason: "manual" as const,
105+
preCompaction: {
106+
sessionId,
107+
sessionFile,
108+
leafId: `old-leaf-${index}`,
109+
},
110+
postCompaction: { sessionId },
111+
};
112+
});
113+
await fs.writeFile(
114+
storePath,
115+
JSON.stringify(
116+
{
117+
[sessionKey]: {
118+
sessionId,
119+
updatedAt: now,
120+
compactionCheckpoints: existingCheckpoints,
121+
},
122+
},
123+
null,
124+
2,
125+
),
126+
"utf-8",
127+
);
128+
129+
const currentSnapshotFile = path.join(
130+
dir,
131+
"sess.checkpoint.99999999-9999-4999-8999-999999999999.jsonl",
132+
);
133+
await fs.writeFile(currentSnapshotFile, "current", "utf-8");
134+
135+
const stored = await persistSessionCompactionCheckpoint({
136+
cfg: {
137+
session: { store: storePath },
138+
agents: { list: [{ id: "main", default: true }] },
139+
} as OpenClawConfig,
140+
sessionKey: "main",
141+
sessionId,
142+
reason: "manual",
143+
snapshot: {
144+
sessionId,
145+
sessionFile: currentSnapshotFile,
146+
leafId: "current-leaf",
147+
},
148+
createdAt: now + 100,
149+
});
150+
151+
expect(stored).not.toBeNull();
152+
expect(fsSync.existsSync(existingCheckpoints[0].preCompaction.sessionFile)).toBe(false);
153+
expect(fsSync.existsSync(existingCheckpoints[1].preCompaction.sessionFile)).toBe(false);
154+
expect(fsSync.existsSync(existingCheckpoints[2].preCompaction.sessionFile)).toBe(true);
155+
expect(fsSync.existsSync(currentSnapshotFile)).toBe(true);
156+
157+
const nextStore = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
158+
string,
159+
{ compactionCheckpoints?: unknown[] }
160+
>;
161+
expect(
162+
Object.values(nextStore).find((entry) => entry.compactionCheckpoints)?.compactionCheckpoints,
163+
).toHaveLength(25);
164+
});
84165
});

0 commit comments

Comments
 (0)