Skip to content

Commit af5d77d

Browse files
committed
fix(agents): persist subagent registry in sqlite
1 parent aa0d6e1 commit af5d77d

3 files changed

Lines changed: 457 additions & 5 deletions

File tree

src/agents/subagent-registry-state.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,26 @@
1-
import { loadSubagentRegistryFromDisk, saveSubagentRegistryToDisk } from "./subagent-registry.store.js";
1+
import {
2+
loadSubagentRegistryFromSqlite,
3+
saveSubagentRegistryToSqlite,
4+
} from "./subagent-registry.store.sqlite.js";
25
import type { SubagentRunRecord } from "./subagent-registry.types.js";
36

47
export function persistSubagentRunsToDisk(runs: Map<string, SubagentRunRecord>) {
58
try {
6-
saveSubagentRegistryToDisk(runs);
9+
saveSubagentRegistryToSqlite(runs);
710
} catch {
811
// ignore persistence failures
912
}
1013
}
1114

1215
export function persistSubagentRunsToDiskOrThrow(runs: Map<string, SubagentRunRecord>) {
13-
saveSubagentRegistryToDisk(runs);
16+
saveSubagentRegistryToSqlite(runs);
1417
}
1518

1619
export function restoreSubagentRunsFromDisk(params: {
1720
runs: Map<string, SubagentRunRecord>;
1821
mergeOnly?: boolean;
1922
}) {
20-
const restored = loadSubagentRegistryFromDisk();
23+
const restored = loadSubagentRegistryFromSqlite();
2124
if (restored.size === 0) {
2225
return 0;
2326
}
@@ -45,7 +48,7 @@ export function getSubagentRunsSnapshotForRead(
4548
if (shouldReadDisk) {
4649
try {
4750
// Persisted state lets other worker processes observe active runs.
48-
for (const [runId, entry] of loadSubagentRegistryFromDisk({ clone: false }).entries()) {
51+
for (const [runId, entry] of loadSubagentRegistryFromSqlite().entries()) {
4952
merged.set(runId, entry);
5053
}
5154
} catch {
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+
import {
6+
closeOpenClawStateDatabaseForTest,
7+
openOpenClawStateDatabase,
8+
} from "../state/openclaw-state-db.js";
9+
import { captureEnv } from "../test-utils/env.js";
10+
import {
11+
loadSubagentRegistryFromSqlite,
12+
saveSubagentRegistryToSqlite,
13+
} from "./subagent-registry.store.sqlite.js";
14+
import type { SubagentRunRecord } from "./subagent-registry.types.js";
15+
16+
function createRun(overrides: Partial<SubagentRunRecord> = {}): SubagentRunRecord {
17+
return {
18+
runId: "run-one",
19+
childSessionKey: "agent:main:subagent:one",
20+
requesterSessionKey: "agent:main:main",
21+
requesterDisplayKey: "main",
22+
task: "check sqlite persistence",
23+
cleanup: "keep",
24+
createdAt: 100,
25+
startedAt: 110,
26+
endedAt: 250,
27+
outcome: { status: "ok", startedAt: 110, endedAt: 250, elapsedMs: 140 },
28+
expectsCompletionMessage: true,
29+
completion: {
30+
required: true,
31+
resultText: "done",
32+
capturedAt: 260,
33+
},
34+
delivery: {
35+
status: "pending",
36+
createdAt: 270,
37+
lastAttemptAt: 280,
38+
attemptCount: 2,
39+
lastError: "retry later",
40+
payload: {
41+
requesterSessionKey: "agent:main:main",
42+
requesterDisplayKey: "main",
43+
childSessionKey: "agent:main:subagent:one",
44+
childRunId: "run-one",
45+
task: "check sqlite persistence",
46+
startedAt: 110,
47+
endedAt: 250,
48+
outcome: { status: "ok" },
49+
expectsCompletionMessage: true,
50+
},
51+
},
52+
...overrides,
53+
};
54+
}
55+
56+
describe("subagent registry sqlite store", () => {
57+
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
58+
let tempStateDir: string | null = null;
59+
60+
beforeEach(async () => {
61+
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-sqlite-"));
62+
process.env.OPENCLAW_STATE_DIR = tempStateDir;
63+
});
64+
65+
afterEach(async () => {
66+
closeOpenClawStateDatabaseForTest();
67+
if (tempStateDir) {
68+
await fs.rm(tempStateDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
69+
tempStateDir = null;
70+
}
71+
envSnapshot.restore();
72+
});
73+
74+
it("persists subagent runs in the shared sqlite state database", async () => {
75+
const run = createRun();
76+
77+
saveSubagentRegistryToSqlite(new Map([[run.runId, run]]));
78+
79+
const restored = loadSubagentRegistryFromSqlite();
80+
expect(restored.get(run.runId)).toMatchObject({
81+
runId: run.runId,
82+
childSessionKey: run.childSessionKey,
83+
requesterSessionKey: run.requesterSessionKey,
84+
task: run.task,
85+
endedAt: run.endedAt,
86+
outcome: run.outcome,
87+
completion: run.completion,
88+
delivery: run.delivery,
89+
});
90+
expect(await fs.stat(path.join(tempStateDir!, "state", "openclaw.sqlite"))).toBeTruthy();
91+
await expect(fs.stat(path.join(tempStateDir!, "subagents", "runs.json"))).rejects.toThrow();
92+
});
93+
94+
it("uses save calls as whole-registry snapshots", () => {
95+
const first = createRun({ runId: "run-one", childSessionKey: "agent:main:subagent:one" });
96+
const second = createRun({ runId: "run-two", childSessionKey: "agent:main:subagent:two" });
97+
98+
saveSubagentRegistryToSqlite(
99+
new Map([
100+
[first.runId, first],
101+
[second.runId, second],
102+
]),
103+
);
104+
saveSubagentRegistryToSqlite(new Map([[second.runId, second]]));
105+
106+
expect([...loadSubagentRegistryFromSqlite().keys()]).toEqual(["run-two"]);
107+
});
108+
109+
it("imports the legacy json registry when sqlite has no runs", async () => {
110+
const legacyRun = createRun({
111+
runId: "legacy-run",
112+
childSessionKey: "agent:main:subagent:legacy",
113+
task: "import legacy registry",
114+
});
115+
const registryPath = path.join(tempStateDir!, "subagents", "runs.json");
116+
await fs.mkdir(path.dirname(registryPath), { recursive: true });
117+
await fs.writeFile(
118+
registryPath,
119+
`${JSON.stringify({ version: 2, runs: { [legacyRun.runId]: legacyRun } })}\n`,
120+
"utf8",
121+
);
122+
123+
const imported = loadSubagentRegistryFromSqlite();
124+
125+
expect(imported.get(legacyRun.runId)?.task).toBe("import legacy registry");
126+
await expect(fs.stat(registryPath)).rejects.toThrow();
127+
expect(loadSubagentRegistryFromSqlite().get(legacyRun.runId)?.task).toBe(
128+
"import legacy registry",
129+
);
130+
expect(
131+
openOpenClawStateDatabase().db.prepare("SELECT COUNT(*) AS count FROM subagent_runs").get(),
132+
).toEqual({ count: 1 });
133+
});
134+
});

0 commit comments

Comments
 (0)