|
7 | 7 | resolveTrajectoryPointerFilePath, |
8 | 8 | } from "../../trajectory/paths.js"; |
9 | 9 | import { formatSessionArchiveTimestamp } from "./artifacts.js"; |
10 | | -import { enforceSessionDiskBudget } from "./disk-budget.js"; |
| 10 | +import { enforceSessionDiskBudget, pruneUnreferencedSessionArtifacts } from "./disk-budget.js"; |
11 | 11 | import type { SessionEntry } from "./types.js"; |
12 | 12 |
|
13 | 13 | async function expectPathExists(targetPath: string): Promise<void> { |
@@ -102,6 +102,50 @@ describe("enforceSessionDiskBudget", () => { |
102 | 102 | }); |
103 | 103 | }); |
104 | 104 |
|
| 105 | + it("reclaims stale store temps under pressure but never a fresh in-flight one (#56827)", async () => { |
| 106 | + await withTempDir({ prefix: "openclaw-disk-budget-" }, async (dir) => { |
| 107 | + const storePath = path.join(dir, "sessions.json"); |
| 108 | + const sessionId = "keep"; |
| 109 | + const transcriptPath = path.join(dir, `${sessionId}.jsonl`); |
| 110 | + const staleTemp = path.join( |
| 111 | + dir, |
| 112 | + "sessions.json.111.0f9c1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b.tmp", |
| 113 | + ); |
| 114 | + const freshTemp = path.join( |
| 115 | + dir, |
| 116 | + "sessions.json.222.1a2b3c4d-5e6f-4a8b-9c0d-1e2f3a4b5c6d.tmp", |
| 117 | + ); |
| 118 | + const store: Record<string, SessionEntry> = { |
| 119 | + "agent:main:main": { sessionId, updatedAt: Date.now() }, |
| 120 | + }; |
| 121 | + await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8"); |
| 122 | + await fs.writeFile(transcriptPath, "k".repeat(80), "utf-8"); |
| 123 | + await fs.writeFile(staleTemp, "s".repeat(300), "utf-8"); |
| 124 | + await fs.writeFile(freshTemp, "f".repeat(300), "utf-8"); |
| 125 | + // Age the stale temp past the staleness window; the fresh one is in-flight. |
| 126 | + const old = new Date(Date.now() - 30 * 60 * 1000); |
| 127 | + await fs.utimes(staleTemp, old, old); |
| 128 | + |
| 129 | + const result = await enforceSessionDiskBudget({ |
| 130 | + store, |
| 131 | + storePath, |
| 132 | + maintenance: { |
| 133 | + maxDiskBytes: 750, |
| 134 | + highWaterBytes: 600, |
| 135 | + }, |
| 136 | + warnOnly: false, |
| 137 | + }); |
| 138 | + |
| 139 | + // Stale orphan reclaimed; fresh in-flight temp (a live atomic-write source) |
| 140 | + // and referenced transcript preserved even though still over the high-water mark. |
| 141 | + await expectPathMissing(staleTemp); |
| 142 | + await expectPathExists(freshTemp); |
| 143 | + await expectPathExists(transcriptPath); |
| 144 | + expectBudgetResult(result); |
| 145 | + expect(result.removedFiles).toBe(1); |
| 146 | + }); |
| 147 | + }); |
| 148 | + |
105 | 149 | it("preserves runtime-provided session keys when removing entries for disk budget", async () => { |
106 | 150 | await withTempDir({ prefix: "openclaw-disk-budget-" }, async (dir) => { |
107 | 151 | const storePath = path.join(dir, "sessions.json"); |
@@ -287,3 +331,41 @@ describe("enforceSessionDiskBudget", () => { |
287 | 331 | }); |
288 | 332 | }); |
289 | 333 | }); |
| 334 | + |
| 335 | +describe("pruneUnreferencedSessionArtifacts", () => { |
| 336 | + it("reclaims stale store temp sidecars but preserves in-flight ones (#56827)", async () => { |
| 337 | + await withTempDir({ prefix: "openclaw-prune-temp-" }, async (dir) => { |
| 338 | + const storePath = path.join(dir, "sessions.json"); |
| 339 | + const staleTemp = path.join( |
| 340 | + dir, |
| 341 | + "sessions.json.111.0f9c1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b.tmp", |
| 342 | + ); |
| 343 | + const freshTemp = path.join( |
| 344 | + dir, |
| 345 | + "sessions.json.222.1a2b3c4d-5e6f-4a8b-9c0d-1e2f3a4b5c6d.tmp", |
| 346 | + ); |
| 347 | + const store: Record<string, SessionEntry> = { |
| 348 | + "agent:main:main": { sessionId: "keep", updatedAt: Date.now() }, |
| 349 | + }; |
| 350 | + await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8"); |
| 351 | + await fs.writeFile(staleTemp, "s".repeat(64), "utf-8"); |
| 352 | + await fs.writeFile(freshTemp, "f".repeat(64), "utf-8"); |
| 353 | + // Age the stale temp well past the temp staleness window; keep the other in-flight. |
| 354 | + const old = new Date(Date.now() - 30 * 60 * 1000); |
| 355 | + await fs.utimes(staleTemp, old, old); |
| 356 | + |
| 357 | + const result = await pruneUnreferencedSessionArtifacts({ |
| 358 | + store, |
| 359 | + storePath, |
| 360 | + // 30d general cutoff: a stale temp must be reclaimed by its own short window, |
| 361 | + // not by the unreferenced-artifact age threshold. |
| 362 | + olderThanMs: 30 * 24 * 60 * 60 * 1000, |
| 363 | + }); |
| 364 | + |
| 365 | + await expectPathMissing(staleTemp); |
| 366 | + await expectPathExists(freshTemp); |
| 367 | + await expectPathExists(storePath); |
| 368 | + expect(result.removedFiles).toBeGreaterThanOrEqual(1); |
| 369 | + }); |
| 370 | + }); |
| 371 | +}); |
0 commit comments