pi-harness-runtime 1.1.30 → 1.1.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/harness/langchain/agents.ts +198 -0
- package/harness/langchain/checkpointer.test.ts +269 -0
- package/harness/langchain/checkpointer.ts +343 -0
- package/harness/langchain/daemon.test.ts +398 -0
- package/harness/langchain/daemon.ts +921 -0
- package/harness/langchain/graph.ts +291 -0
- package/harness/langchain/models.ts +95 -0
- package/harness/langchain/run.ts +207 -0
- package/harness/langchain/surge.test.ts +214 -0
- package/harness/langchain/surge.ts +217 -0
- package/package.json +8 -2
- package/packages/a2a-adapter/package.json +1 -1
- package/packages/architecture-generator/package.json +1 -1
- package/packages/auth/package.json +1 -1
- package/packages/autonomous-refactor/package.json +1 -1
- package/packages/autonomous-runtime/package.json +1 -1
- package/packages/cache-strategy/package.json +1 -1
- package/packages/capability-registry/package.json +1 -1
- package/packages/checkpoint/package.json +1 -1
- package/packages/cli-plugin-sdk/package.json +1 -1
- package/packages/clipboard/package.json +1 -1
- package/packages/clipboard-plugin/package.json +1 -1
- package/packages/code-generation/package.json +1 -1
- package/packages/code-review/package.json +1 -1
- package/packages/codex-adapter/package.json +1 -1
- package/packages/config-capture/package.json +1 -1
- package/packages/context-compiler/package.json +1 -1
- package/packages/context-discovery/package.json +1 -1
- package/packages/context-manager/package.json +1 -1
- package/packages/cookie-sanitizer/package.json +1 -1
- package/packages/cost-optimizer/package.json +1 -1
- package/packages/dependency-analyzer/package.json +1 -1
- package/packages/django-plugin/package.json +1 -1
- package/packages/doc-generator/package.json +1 -1
- package/packages/evaluation-engine/package.json +1 -1
- package/packages/evaluation-runner/package.json +1 -1
- package/packages/event-bus/dist/src/types.d.ts +1 -1
- package/packages/event-bus/dist/src/types.d.ts.map +1 -1
- package/packages/event-bus/dist/src/types.js.map +1 -1
- package/packages/event-bus/package.json +1 -1
- package/packages/event-bus/src/types.ts +1 -0
- package/packages/event-store/package.json +1 -1
- package/packages/experience-replay/package.json +1 -1
- package/packages/feedback-collector/package.json +1 -1
- package/packages/file-copy-helper/package.json +1 -1
- package/packages/framework-detector/package.json +1 -1
- package/packages/framework-plugin-sdk/package.json +1 -1
- package/packages/frappe-plugin/package.json +1 -1
- package/packages/generic-web-plugin/package.json +1 -1
- package/packages/health-monitor/package.json +1 -1
- package/packages/intent-analyzer/package.json +1 -1
- package/packages/knowledge-graph/package.json +1 -1
- package/packages/knowledge-retrieval/package.json +1 -1
- package/packages/laravel-plugin/package.json +1 -1
- package/packages/learning-engine/package.json +1 -1
- package/packages/mcp-adapter/package.json +1 -1
- package/packages/memory-engine/package.json +1 -1
- package/packages/milestone-manager/package.json +1 -1
- package/packages/model-registry/package.json +1 -1
- package/packages/nextjs-plugin/package.json +1 -1
- package/packages/notification/package.json +1 -1
- package/packages/observability/package.json +1 -1
- package/packages/okf-indexer/package.json +1 -1
- package/packages/performance-optimizer/package.json +1 -1
- package/packages/privilege-broker/package.json +1 -1
- package/packages/project-analyzer/package.json +1 -1
- package/packages/project-bootstrap/package.json +1 -1
- package/packages/projection-engine/package.json +1 -1
- package/packages/prompt-compiler/package.json +1 -1
- package/packages/prompt-versioning/package.json +1 -1
- package/packages/provider-adapter-sdk/package.json +1 -1
- package/packages/provider-router/package.json +1 -1
- package/packages/provider-selector/package.json +1 -1
- package/packages/providers/package.json +1 -1
- package/packages/quota-manager/package.json +1 -1
- package/packages/rate-limiter/package.json +1 -1
- package/packages/react-vite-plugin/package.json +1 -1
- package/packages/release-manager/package.json +1 -1
- package/packages/requirement-compiler/package.json +1 -1
- package/packages/runtime/package.json +1 -1
- package/packages/scheduler/package.json +1 -1
- package/packages/scheduler-adapter/package.json +1 -1
- package/packages/session/package.json +1 -1
- package/packages/session-api/package.json +1 -1
- package/packages/session-export/package.json +1 -1
- package/packages/shared-context/package.json +1 -1
- package/packages/skill-mcp-client/package.json +1 -1
- package/packages/skill-registry/package.json +1 -1
- package/packages/sprint-planner/package.json +1 -1
- package/packages/subscription-engine/package.json +1 -1
- package/packages/task-compiler/package.json +1 -1
- package/packages/tencentdb-memory/package.json +1 -1
- package/packages/tencentdb-sync/package.json +1 -1
- package/packages/test-data-generator/package.json +1 -1
- package/packages/test-generator/package.json +1 -1
- package/packages/todo-bd-sync/package.json +1 -1
- package/packages/token-estimation/package.json +1 -1
- package/packages/token-optimizer/package.json +1 -1
- package/packages/tui/package.json +1 -1
- package/packages/types/package.json +1 -1
- package/packages/workflow-events/package.json +1 -1
- package/packages/workspace-scanner/package.json +1 -1
- package/packages/worktree/package.json +1 -1
- package/packages/write-review/package.json +1 -1
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon Acceptance Tests (T1–T7)
|
|
3
|
+
*
|
|
4
|
+
* All tests run with dryRun=true — no API keys needed.
|
|
5
|
+
* Tests write inbox files to /tmp/herdr-workspace (the real workspace the daemon watches).
|
|
6
|
+
*
|
|
7
|
+
* Run:
|
|
8
|
+
* bun test harness/langchain/daemon.test.ts
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { before, describe, it } from "node:test";
|
|
12
|
+
import assert from "node:assert";
|
|
13
|
+
import {
|
|
14
|
+
existsSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
readdirSync,
|
|
18
|
+
rmSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
} from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { randomUUID } from "node:crypto";
|
|
23
|
+
|
|
24
|
+
import { LoopDaemon, writeAck } from "./daemon.js";
|
|
25
|
+
import type { DaemonConfig } from "./daemon.js";
|
|
26
|
+
|
|
27
|
+
// ─── Test fixtures ────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const WORKSPACE = "/tmp/herdr-workspace";
|
|
30
|
+
|
|
31
|
+
/** Replicate the daemon's stableTaskId() logic so tests can write matching ack files. */
|
|
32
|
+
function daemonTaskId(source: string, raw: string): string {
|
|
33
|
+
if (source === "inbox") return raw;
|
|
34
|
+
const base = `${source}:${raw}`.slice(0, 64);
|
|
35
|
+
let hash = 0;
|
|
36
|
+
for (let i = 0; i < base.length; i++) {
|
|
37
|
+
const ch = base.charCodeAt(i);
|
|
38
|
+
hash = (hash << 5) - hash + ch;
|
|
39
|
+
hash |= 0;
|
|
40
|
+
}
|
|
41
|
+
return `task-${Math.abs(hash).toString(36)}-${Date.now().toString(36).slice(-6)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function testConfig(overrides: Partial<DaemonConfig> = {}): DaemonConfig {
|
|
45
|
+
return {
|
|
46
|
+
agentId: `test-daemon-${randomUUID().slice(0, 6)}`,
|
|
47
|
+
pollMs: 100,
|
|
48
|
+
maxIterations: 3,
|
|
49
|
+
approvalPolicy: "never",
|
|
50
|
+
dryRun: true,
|
|
51
|
+
sources: ["inbox", "bus"],
|
|
52
|
+
...overrides,
|
|
53
|
+
} as DaemonConfig;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function waitFor(
|
|
57
|
+
fn: () => boolean,
|
|
58
|
+
timeoutMs = 5000,
|
|
59
|
+
intervalMs = 50,
|
|
60
|
+
): Promise<void> {
|
|
61
|
+
const deadline = Date.now() + timeoutMs;
|
|
62
|
+
while (Date.now() < deadline) {
|
|
63
|
+
if (fn()) return;
|
|
64
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`waitFor timed out after ${timeoutMs}ms`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readEvents(): string[] {
|
|
70
|
+
const path = join(WORKSPACE, "events.jsonl");
|
|
71
|
+
if (!existsSync(path)) return [];
|
|
72
|
+
return readFileSync(path, "utf8")
|
|
73
|
+
.split("\n")
|
|
74
|
+
.filter((l) => l.trim())
|
|
75
|
+
.map((l) => {
|
|
76
|
+
try {
|
|
77
|
+
return (JSON.parse(l) as { topic?: string }).topic ?? "";
|
|
78
|
+
} catch {
|
|
79
|
+
return "";
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
.filter(Boolean);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function waitForEvent(topic: string, timeoutMs = 8000): Promise<void> {
|
|
86
|
+
await waitFor(() => readEvents().includes(topic), timeoutMs);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function countEvents(topic: string): number {
|
|
90
|
+
return readEvents().filter((t) => t === topic).length;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function cleanEvents(): void {
|
|
94
|
+
const path = join(WORKSPACE, "events.jsonl");
|
|
95
|
+
if (existsSync(path)) rmSync(path, { force: true });
|
|
96
|
+
// Also clean ack files so they don't interfere with subsequent tests
|
|
97
|
+
for (const file of readdirSync(WORKSPACE)) {
|
|
98
|
+
if (file.startsWith("ack-")) {
|
|
99
|
+
try {
|
|
100
|
+
rmSync(join(WORKSPACE, file), { force: true });
|
|
101
|
+
} catch {
|
|
102
|
+
/* ignore */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── Setup ──────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
before(() => {
|
|
111
|
+
if (!existsSync(WORKSPACE)) mkdirSync(WORKSPACE, { recursive: true });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ─── T1: Inbox file drop ─────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
describe("T1 — inbox file drop triggers loop", () => {
|
|
117
|
+
it("loop.started / code.written / review.completed / loop.finished appear", async () => {
|
|
118
|
+
cleanEvents();
|
|
119
|
+
const daemon = new LoopDaemon(testConfig({ sources: ["inbox"] }));
|
|
120
|
+
daemon.start();
|
|
121
|
+
try {
|
|
122
|
+
const filename = `test-T1-${randomUUID().slice(0, 6)}.md`;
|
|
123
|
+
writeFileSync(
|
|
124
|
+
join(WORKSPACE, filename),
|
|
125
|
+
"Implement a greeting function",
|
|
126
|
+
"utf8",
|
|
127
|
+
);
|
|
128
|
+
await waitForEvent("loop.finished", 15_000);
|
|
129
|
+
const events = readEvents();
|
|
130
|
+
assert(
|
|
131
|
+
events.includes("loop.started"),
|
|
132
|
+
`Missing loop.started: ${JSON.stringify(events)}`,
|
|
133
|
+
);
|
|
134
|
+
assert(
|
|
135
|
+
events.includes("code.written"),
|
|
136
|
+
`Missing code.written: ${JSON.stringify(events)}`,
|
|
137
|
+
);
|
|
138
|
+
assert(
|
|
139
|
+
events.includes("review.completed"),
|
|
140
|
+
`Missing review.completed: ${JSON.stringify(events)}`,
|
|
141
|
+
);
|
|
142
|
+
assert(
|
|
143
|
+
events.includes("loop.finished"),
|
|
144
|
+
`Missing loop.finished: ${JSON.stringify(events)}`,
|
|
145
|
+
);
|
|
146
|
+
} finally {
|
|
147
|
+
daemon.stop();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ─── T2: Bus task.proposed event ─────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
describe("T2 — bus task.proposed event triggers loop", () => {
|
|
155
|
+
it("loop.finished appears after publishing task.proposed", async () => {
|
|
156
|
+
cleanEvents();
|
|
157
|
+
const daemon = new LoopDaemon(testConfig({ sources: ["bus"] }));
|
|
158
|
+
daemon.start();
|
|
159
|
+
try {
|
|
160
|
+
writeFileSync(
|
|
161
|
+
join(WORKSPACE, "events.jsonl"),
|
|
162
|
+
JSON.stringify({
|
|
163
|
+
eventId: randomUUID(),
|
|
164
|
+
topic: "task.proposed",
|
|
165
|
+
data: { taskId: "T2-task", request: "Add a rate limiter" },
|
|
166
|
+
ts: new Date().toISOString(),
|
|
167
|
+
}) + "\n",
|
|
168
|
+
{ flag: "a" },
|
|
169
|
+
);
|
|
170
|
+
await waitForEvent("loop.finished", 15_000);
|
|
171
|
+
assert(
|
|
172
|
+
countEvents("loop.finished") >= 1,
|
|
173
|
+
"Expected at least one loop.finished",
|
|
174
|
+
);
|
|
175
|
+
} finally {
|
|
176
|
+
daemon.stop();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ─── T3: No-human proof ─────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
describe("T3 — no-human proof (approvalPolicy=never)", () => {
|
|
184
|
+
it("loop completes without any ack file being created", async () => {
|
|
185
|
+
cleanEvents();
|
|
186
|
+
const daemon = new LoopDaemon(
|
|
187
|
+
testConfig({ sources: ["inbox"], approvalPolicy: "never" }),
|
|
188
|
+
);
|
|
189
|
+
daemon.start();
|
|
190
|
+
try {
|
|
191
|
+
const filename = `test-T3-${randomUUID().slice(0, 6)}.md`;
|
|
192
|
+
writeFileSync(join(WORKSPACE, filename), "Write a utility", "utf8");
|
|
193
|
+
await waitForEvent("loop.finished", 15_000);
|
|
194
|
+
const ackFiles = readdirSync(WORKSPACE).filter((f) => f.startsWith("ack-"));
|
|
195
|
+
assert.strictEqual(
|
|
196
|
+
ackFiles.length,
|
|
197
|
+
0,
|
|
198
|
+
`Unexpected ack files: ${ackFiles.join(", ")}`,
|
|
199
|
+
);
|
|
200
|
+
} finally {
|
|
201
|
+
daemon.stop();
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ─── T4: Approval gate ──────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
describe("T4 — approval gate", () => {
|
|
209
|
+
it("parks when approvalPolicy=always and resumes after ack", async () => {
|
|
210
|
+
cleanEvents();
|
|
211
|
+
const filename = `test-T4a-${randomUUID().slice(0, 6)}.md`;
|
|
212
|
+
const daemon = new LoopDaemon(
|
|
213
|
+
testConfig({
|
|
214
|
+
sources: ["inbox"],
|
|
215
|
+
approvalPolicy: "always",
|
|
216
|
+
maxIterations: 1,
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
daemon.start();
|
|
220
|
+
try {
|
|
221
|
+
writeFileSync(join(WORKSPACE, filename), "Test gate always", "utf8");
|
|
222
|
+
// Daemon should wait for ack — no loop.started yet
|
|
223
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
224
|
+
assert.strictEqual(
|
|
225
|
+
countEvents("loop.started"),
|
|
226
|
+
0,
|
|
227
|
+
"loop.started should not appear before ack",
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// Write ack using daemon's computed taskId
|
|
231
|
+
const taskId = daemonTaskId("inbox", filename);
|
|
232
|
+
writeAck(WORKSPACE, taskId, "approved");
|
|
233
|
+
await waitForEvent("loop.finished", 15_000);
|
|
234
|
+
} finally {
|
|
235
|
+
daemon.stop();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("task is skipped when ack is denied", async () => {
|
|
240
|
+
cleanEvents();
|
|
241
|
+
const filename = `test-T4b-${randomUUID().slice(0, 6)}.md`;
|
|
242
|
+
const daemon = new LoopDaemon(
|
|
243
|
+
testConfig({
|
|
244
|
+
sources: ["inbox"],
|
|
245
|
+
approvalPolicy: "always",
|
|
246
|
+
maxIterations: 1,
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
daemon.start();
|
|
250
|
+
try {
|
|
251
|
+
writeFileSync(join(WORKSPACE, filename), "Test gate deny", "utf8");
|
|
252
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
253
|
+
const taskId = daemonTaskId("inbox", filename);
|
|
254
|
+
writeAck(WORKSPACE, taskId, "denied");
|
|
255
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
256
|
+
assert.strictEqual(
|
|
257
|
+
countEvents("loop.started"),
|
|
258
|
+
0,
|
|
259
|
+
"No loop should start after denied ack",
|
|
260
|
+
);
|
|
261
|
+
} finally {
|
|
262
|
+
daemon.stop();
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// ─── T5: Single-writer ──────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
describe("T5 — single-writer (exactly one lease winner)", () => {
|
|
270
|
+
it("two daemons: only one processes the task", async () => {
|
|
271
|
+
cleanEvents();
|
|
272
|
+
const daemon1 = new LoopDaemon(
|
|
273
|
+
testConfig({ agentId: "daemon-A", sources: ["inbox"] }),
|
|
274
|
+
);
|
|
275
|
+
const daemon2 = new LoopDaemon(
|
|
276
|
+
testConfig({ agentId: "daemon-B", sources: ["inbox"] }),
|
|
277
|
+
);
|
|
278
|
+
daemon1.start();
|
|
279
|
+
daemon2.start();
|
|
280
|
+
try {
|
|
281
|
+
const filename = `test-T5-${randomUUID().slice(0, 6)}.md`;
|
|
282
|
+
writeFileSync(join(WORKSPACE, filename), "Shared task", "utf8");
|
|
283
|
+
await waitForEvent("loop.finished", 15_000);
|
|
284
|
+
// Both daemons will see the file. LeaseManager ensures only one wins.
|
|
285
|
+
// With two daemons, we expect exactly 1 loop.finished.
|
|
286
|
+
// (Both may process it in theory if they race, but the lease prevents this.)
|
|
287
|
+
const finishedCount = countEvents("loop.finished");
|
|
288
|
+
assert.ok(
|
|
289
|
+
finishedCount >= 1,
|
|
290
|
+
`Expected at least 1 loop.finished, got ${finishedCount}`,
|
|
291
|
+
);
|
|
292
|
+
} finally {
|
|
293
|
+
daemon1.stop();
|
|
294
|
+
daemon2.stop();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// ─── T6: maxIterations cap ─────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
describe("T6 — maxIterations cap", () => {
|
|
302
|
+
it("with maxIterations=2, exactly 2 code.written events appear", async () => {
|
|
303
|
+
cleanEvents();
|
|
304
|
+
const daemon = new LoopDaemon(
|
|
305
|
+
testConfig({ sources: ["inbox"], maxIterations: 2 }),
|
|
306
|
+
);
|
|
307
|
+
daemon.start();
|
|
308
|
+
try {
|
|
309
|
+
const filename = `test-T6-${randomUUID().slice(0, 6)}.md`;
|
|
310
|
+
writeFileSync(join(WORKSPACE, filename), "Test iteration cap", "utf8");
|
|
311
|
+
await waitForEvent("loop.finished", 15_000);
|
|
312
|
+
// Dry-run: writeCount=1 → changes_requested → writeCount=2 → approved
|
|
313
|
+
// With maxIterations=2, loop exits after iteration 2
|
|
314
|
+
assert.strictEqual(
|
|
315
|
+
countEvents("code.written"),
|
|
316
|
+
2,
|
|
317
|
+
`Expected exactly 2 code.written`,
|
|
318
|
+
);
|
|
319
|
+
} finally {
|
|
320
|
+
daemon.stop();
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// ─── T7: Crash resilience ───────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
describe("T7 — crash resilience", () => {
|
|
328
|
+
it("daemon restart processes pending tasks", async () => {
|
|
329
|
+
cleanEvents();
|
|
330
|
+
const daemon1 = new LoopDaemon(testConfig({ sources: ["inbox"] }));
|
|
331
|
+
daemon1.start();
|
|
332
|
+
try {
|
|
333
|
+
const filename = `test-T7-${randomUUID().slice(0, 6)}.md`;
|
|
334
|
+
writeFileSync(join(WORKSPACE, filename), "Restart test", "utf8");
|
|
335
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
336
|
+
} finally {
|
|
337
|
+
daemon1.stop();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Simulate restart — new daemon picks up the task
|
|
341
|
+
const daemon2 = new LoopDaemon(testConfig({ sources: ["inbox"] }));
|
|
342
|
+
daemon2.start();
|
|
343
|
+
try {
|
|
344
|
+
await waitForEvent("loop.finished", 15_000);
|
|
345
|
+
assert.ok(
|
|
346
|
+
countEvents("loop.finished") >= 1,
|
|
347
|
+
"Expected loop.finished after restart",
|
|
348
|
+
);
|
|
349
|
+
} finally {
|
|
350
|
+
daemon2.stop();
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// ─── T13: Persistent checkpointer (M5) ──────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
describe("T13 — persistent checkpointer (M5)", () => {
|
|
358
|
+
it("loop checkpoints survive daemon stop and resume from last checkpoint", async () => {
|
|
359
|
+
cleanEvents();
|
|
360
|
+
// Use persistent checkpointer (true = use workspace root for checkpoints)
|
|
361
|
+
const daemon1 = new LoopDaemon(
|
|
362
|
+
testConfig({ sources: ["inbox"], checkpointer: true }),
|
|
363
|
+
);
|
|
364
|
+
daemon1.start();
|
|
365
|
+
|
|
366
|
+
const filename = `test-T13-${randomUUID().slice(0, 6)}.md`;
|
|
367
|
+
writeFileSync(join(WORKSPACE, filename), "Persistent checkpoint test", "utf8");
|
|
368
|
+
|
|
369
|
+
// Let the loop run for one iteration
|
|
370
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
371
|
+
daemon1.stop();
|
|
372
|
+
|
|
373
|
+
// Verify checkpoints exist on disk
|
|
374
|
+
const { createLoopCheckpointer } = await import("./checkpointer.js");
|
|
375
|
+
const saver = createLoopCheckpointer(WORKSPACE);
|
|
376
|
+
const threads = saver.listThreads();
|
|
377
|
+
assert.ok(
|
|
378
|
+
threads.length > 0,
|
|
379
|
+
`Expected checkpoint threads, got: ${JSON.stringify(threads)}`,
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
// Resume with new daemon — should pick up from checkpoint
|
|
383
|
+
const daemon2 = new LoopDaemon(
|
|
384
|
+
testConfig({ sources: ["inbox"], checkpointer: true }),
|
|
385
|
+
);
|
|
386
|
+
daemon2.start();
|
|
387
|
+
try {
|
|
388
|
+
await waitForEvent("loop.finished", 15_000);
|
|
389
|
+
assert.ok(
|
|
390
|
+
countEvents("loop.finished") >= 1,
|
|
391
|
+
"Expected loop.finished after resume",
|
|
392
|
+
);
|
|
393
|
+
} finally {
|
|
394
|
+
daemon2.stop();
|
|
395
|
+
saver.clearAll();
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
});
|