Skip to content

Commit 7845182

Browse files
authored
clawdbot-d02.1.9.1.39: add task session registry maintenance seam (#93734)
1 parent aba6f7a commit 7845182

6 files changed

Lines changed: 262 additions & 93 deletions

File tree

scripts/check-session-accessor-boundary.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export const migratedSessionAccessorFiles = new Set([
6868
"src/commands/sessions.ts",
6969
"src/commands/status.agent-local.ts",
7070
"src/commands/status.summary.ts",
71+
"src/commands/tasks.ts",
7172
"src/config/sessions/combined-store-gateway.ts",
7273
"src/cron/isolated-agent/delivery-target.ts",
7374
"src/cron/service/timer.ts",
@@ -108,9 +109,10 @@ export const migratedSessionAccessorWriteFiles = new Set([
108109
"src/auto-reply/reply/session-reset-model.ts",
109110
"src/auto-reply/reply/session-updates.ts",
110111
"src/auto-reply/reply/session-usage.ts",
111-
"src/tui/embedded-backend.ts",
112+
"src/commands/tasks.ts",
112113
"src/config/sessions/cleanup-service.ts",
113114
"src/plugins/host-hook-cleanup.ts",
115+
"src/tui/embedded-backend.ts",
114116
]);
115117

116118
export const migratedTranscriptWriterFiles = new Set([
@@ -336,6 +338,7 @@ export async function main() {
336338
const writeSourceRoots = resolveSourceRoots(repoRoot, [
337339
"src/agents",
338340
"src/auto-reply",
341+
"src/commands",
339342
"src/config/sessions",
340343
"src/plugins",
341344
"src/tui",

src/commands/tasks.ts

Lines changed: 9 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,18 @@
11
// Human-facing background task commands.
22
// Handles task listing/show/cancel/notify/audit plus registry maintenance for tasks, flows, and sessions.
33

4-
import fs from "node:fs";
54
import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion";
65
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
76
import { isRich, theme } from "../../packages/terminal-core/src/theme.js";
87
import { formatCliCommand } from "../cli/command-format.js";
98
import { formatLookupMiss } from "../cli/error-format.js";
109
import { getRuntimeConfig } from "../config/config.js";
1110
import {
12-
loadSessionStore,
13-
pruneStaleEntries,
1411
resolveAllAgentSessionStoreTargetsSync,
15-
updateSessionStore,
16-
type SessionEntry,
12+
runSessionRegistryMaintenanceForStore,
1713
} from "../config/sessions.js";
1814
import { loadCronJobsStoreSync, resolveCronJobsStorePath } from "../cron/store.js";
1915
import type { RuntimeEnv } from "../runtime.js";
20-
import { parseAgentSessionKey } from "../sessions/session-key-utils.js";
2116
import { getTaskById, updateTaskNotifyPolicyById } from "../tasks/runtime-internal.js";
2217
import { cancelDetachedTaskRunById } from "../tasks/task-executor.js";
2318
import { listTaskFlowAuditFindings } from "../tasks/task-flow-registry.audit.js";
@@ -133,14 +128,6 @@ type SessionRegistryMaintenanceSummary = {
133128
stores: SessionRegistryMaintenanceStoreSummary[];
134129
};
135130

136-
function parseCronRunSessionJobId(sessionKey: string): string | undefined {
137-
const parsed = parseAgentSessionKey(sessionKey);
138-
if (!parsed) {
139-
return undefined;
140-
}
141-
return /^cron:([^:]+):run:[^:]+$/u.exec(parsed.rest)?.[1];
142-
}
143-
144131
function readRunningCronJobIds(): Set<string> {
145132
try {
146133
const cronStorePath = resolveCronJobsStorePath(getRuntimeConfig().cron?.store);
@@ -155,95 +142,26 @@ function readRunningCronJobIds(): Set<string> {
155142
}
156143
}
157144

158-
function buildSessionRegistryPreserveKeys(params: {
159-
store: Record<string, SessionEntry>;
160-
runningCronJobIds: ReadonlySet<string>;
161-
}): { preserveKeys: Set<string>; preservedRunning: number } {
162-
const preserveKeys = new Set<string>();
163-
let preservedRunning = 0;
164-
for (const key of Object.keys(params.store)) {
165-
const jobId = parseCronRunSessionJobId(key);
166-
if (!jobId) {
167-
// Non-cron session rows are outside this maintenance pass; preserve them.
168-
preserveKeys.add(key);
169-
continue;
170-
}
171-
if (params.runningCronJobIds.has(jobId)) {
172-
preserveKeys.add(key);
173-
preservedRunning += 1;
174-
}
175-
}
176-
return { preserveKeys, preservedRunning };
177-
}
178-
179145
async function runSessionRegistryMaintenance(params: {
180146
apply: boolean;
181147
}): Promise<SessionRegistryMaintenanceSummary> {
182148
const cfg = getRuntimeConfig();
183149
const runningCronJobIds = readRunningCronJobIds();
184150
const stores: SessionRegistryMaintenanceStoreSummary[] = [];
185151
for (const target of resolveAllAgentSessionStoreTargetsSync(cfg)) {
186-
if (!fs.existsSync(target.storePath)) {
187-
stores.push({
188-
agentId: target.agentId,
189-
storePath: target.storePath,
190-
beforeCount: 0,
191-
afterCount: 0,
192-
pruned: 0,
193-
preservedRunning: 0,
194-
});
195-
continue;
196-
}
197-
const beforeStore = loadSessionStore(target.storePath, { skipCache: true });
198-
const beforeCount = Object.keys(beforeStore).length;
199-
if (params.apply) {
200-
// Apply mode mutates each store atomically through updateSessionStore.
201-
const applied = await updateSessionStore(
202-
target.storePath,
203-
(store) => {
204-
const { preserveKeys, preservedRunning } = buildSessionRegistryPreserveKeys({
205-
store,
206-
runningCronJobIds,
207-
});
208-
const pruned = pruneStaleEntries(store, SESSION_REGISTRY_RETENTION_MS, {
209-
log: false,
210-
preserveKeys,
211-
});
212-
return {
213-
pruned,
214-
afterCount: Object.keys(store).length,
215-
preservedRunning,
216-
};
217-
},
218-
{ skipMaintenance: true },
219-
);
220-
stores.push({
221-
agentId: target.agentId,
222-
storePath: target.storePath,
223-
beforeCount,
224-
afterCount: applied.afterCount,
225-
pruned: applied.pruned,
226-
preservedRunning: applied.preservedRunning,
227-
});
228-
continue;
229-
}
230-
const previewStore = structuredClone(beforeStore);
231-
// Preview mode runs pruning against a clone so dry-run output cannot change stores.
232-
const { preserveKeys, preservedRunning } = buildSessionRegistryPreserveKeys({
233-
store: previewStore,
152+
const result = await runSessionRegistryMaintenanceForStore({
153+
apply: params.apply,
154+
retentionMs: SESSION_REGISTRY_RETENTION_MS,
234155
runningCronJobIds,
235-
});
236-
const pruned = pruneStaleEntries(previewStore, SESSION_REGISTRY_RETENTION_MS, {
237-
log: false,
238-
preserveKeys,
156+
storePath: target.storePath,
239157
});
240158
stores.push({
241159
agentId: target.agentId,
242160
storePath: target.storePath,
243-
beforeCount,
244-
afterCount: Object.keys(previewStore).length,
245-
pruned,
246-
preservedRunning,
161+
beforeCount: result.beforeCount,
162+
afterCount: result.afterCount,
163+
pruned: result.pruned,
164+
preservedRunning: result.preservedRunning,
247165
});
248166
}
249167
return {

src/config/sessions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export * from "./sessions/types.js";
2626
export * from "./sessions/transcript.js";
2727
export * from "./sessions/session-file.js";
2828
export * from "./sessions/session-file-rotation.js";
29+
export * from "./sessions/session-registry-maintenance.js";
2930
export * from "./sessions/delivery-info.js";
3031
export * from "./sessions/disk-budget.js";
3132
export * from "./sessions/targets.js";
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Session registry maintenance tests cover the task-owned cron-run pruning seam.
2+
import fs from "node:fs/promises";
3+
import path from "node:path";
4+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
5+
import { createFixtureSuite } from "../../test-utils/fixture-suite.js";
6+
import { runSessionRegistryMaintenanceForStore } from "./session-registry-maintenance.js";
7+
import { loadSessionStore, saveSessionStore } from "./store.js";
8+
import type { SessionEntry } from "./types.js";
9+
10+
const DAY_MS = 24 * 60 * 60 * 1000;
11+
const fixtureSuite = createFixtureSuite("openclaw-session-registry-maintenance-");
12+
13+
beforeAll(async () => {
14+
await fixtureSuite.setup();
15+
});
16+
17+
afterAll(async () => {
18+
await fixtureSuite.cleanup();
19+
});
20+
21+
function sessionEntry(sessionId: string, updatedAt: number): SessionEntry {
22+
return { sessionId, updatedAt };
23+
}
24+
25+
async function createStore(entries: Record<string, SessionEntry>): Promise<string> {
26+
const dir = await fixtureSuite.createCaseDir("store");
27+
const storePath = path.join(dir, "sessions.json");
28+
await fs.mkdir(dir, { recursive: true });
29+
await saveSessionStore(storePath, entries, { skipMaintenance: true });
30+
return storePath;
31+
}
32+
33+
describe("runSessionRegistryMaintenanceForStore", () => {
34+
it("summarizes a missing store without creating it", async () => {
35+
const dir = await fixtureSuite.createCaseDir("missing-store");
36+
const storePath = path.join(dir, "sessions.json");
37+
38+
const result = await runSessionRegistryMaintenanceForStore({
39+
apply: true,
40+
retentionMs: 7 * DAY_MS,
41+
runningCronJobIds: new Set(),
42+
storePath,
43+
});
44+
45+
expect(result).toEqual({
46+
beforeCount: 0,
47+
afterCount: 0,
48+
preservedRunning: 0,
49+
pruned: 0,
50+
});
51+
await expect(fs.stat(storePath)).rejects.toMatchObject({ code: "ENOENT" });
52+
});
53+
54+
it("previews stale cron-run pruning without mutating the store", async () => {
55+
const now = Date.now();
56+
const storePath = await createStore({
57+
"agent:main:cron:done-job:run:old-run": sessionEntry("old-run", now - 8 * DAY_MS),
58+
"agent:main:cron:done-job:run:recent-run": sessionEntry("recent-run", now),
59+
});
60+
61+
const result = await runSessionRegistryMaintenanceForStore({
62+
apply: false,
63+
retentionMs: 7 * DAY_MS,
64+
runningCronJobIds: new Set(),
65+
storePath,
66+
});
67+
68+
expect(result).toEqual({
69+
beforeCount: 2,
70+
afterCount: 1,
71+
preservedRunning: 0,
72+
pruned: 1,
73+
});
74+
expect(loadSessionStore(storePath, { skipCache: true })).toHaveProperty(
75+
"agent:main:cron:done-job:run:old-run",
76+
);
77+
});
78+
79+
it("applies one store-sized pruning transaction and preserves running cron rows", async () => {
80+
const now = Date.now();
81+
const storePath = await createStore({
82+
"agent:main:cron:done-job:run:old-run": sessionEntry("done-run", now - 8 * DAY_MS),
83+
"agent:main:cron:running-job:run:old-run": sessionEntry("running-run", now - 8 * DAY_MS),
84+
"agent:main:cron:done-job:run:recent-run": sessionEntry("recent-run", now),
85+
});
86+
87+
const result = await runSessionRegistryMaintenanceForStore({
88+
apply: true,
89+
retentionMs: 7 * DAY_MS,
90+
runningCronJobIds: new Set(["running-job"]),
91+
storePath,
92+
});
93+
94+
expect(result).toEqual({
95+
beforeCount: 3,
96+
afterCount: 2,
97+
preservedRunning: 1,
98+
pruned: 1,
99+
});
100+
const updated = loadSessionStore(storePath, { skipCache: true });
101+
expect(updated["agent:main:cron:done-job:run:old-run"]).toBeUndefined();
102+
expect(updated).toHaveProperty("agent:main:cron:running-job:run:old-run");
103+
expect(updated).toHaveProperty("agent:main:cron:done-job:run:recent-run");
104+
});
105+
106+
it("skips generic session maintenance while applying task registry pruning", async () => {
107+
const now = Date.now();
108+
const oldOrdinaryKey = "agent:main:subagent:old-worker";
109+
const storePath = await createStore({
110+
"agent:main:cron:done-job:run:old-run": sessionEntry("done-run", now - 8 * DAY_MS),
111+
[oldOrdinaryKey]: sessionEntry("old-worker", now - 40 * DAY_MS),
112+
});
113+
114+
const result = await runSessionRegistryMaintenanceForStore({
115+
apply: true,
116+
retentionMs: 7 * DAY_MS,
117+
runningCronJobIds: new Set(),
118+
storePath,
119+
});
120+
121+
expect(result.pruned).toBe(1);
122+
const updated = loadSessionStore(storePath, { skipCache: true });
123+
expect(updated["agent:main:cron:done-job:run:old-run"]).toBeUndefined();
124+
expect(updated).toHaveProperty(oldOrdinaryKey);
125+
});
126+
});

0 commit comments

Comments
 (0)