pi-harness-runtime 1.1.29 → 1.1.31

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.
Files changed (102) hide show
  1. package/harness/langchain/agents.ts +198 -0
  2. package/harness/langchain/daemon.test.ts +353 -0
  3. package/harness/langchain/daemon.ts +880 -0
  4. package/harness/langchain/graph.ts +284 -0
  5. package/harness/langchain/models.ts +95 -0
  6. package/harness/langchain/run.ts +205 -0
  7. package/package.json +1 -1
  8. package/packages/a2a-adapter/package.json +1 -1
  9. package/packages/architecture-generator/package.json +1 -1
  10. package/packages/auth/package.json +1 -1
  11. package/packages/autonomous-refactor/package.json +1 -1
  12. package/packages/autonomous-runtime/package.json +1 -1
  13. package/packages/cache-strategy/package.json +1 -1
  14. package/packages/capability-registry/package.json +1 -1
  15. package/packages/checkpoint/package.json +1 -1
  16. package/packages/cli-plugin-sdk/package.json +1 -1
  17. package/packages/clipboard/package.json +1 -1
  18. package/packages/clipboard-plugin/package.json +1 -1
  19. package/packages/code-generation/package.json +1 -1
  20. package/packages/code-review/package.json +1 -1
  21. package/packages/codex-adapter/package.json +1 -1
  22. package/packages/config-capture/package.json +1 -1
  23. package/packages/context-compiler/package.json +1 -1
  24. package/packages/context-discovery/package.json +1 -1
  25. package/packages/context-manager/package.json +1 -1
  26. package/packages/cookie-sanitizer/package.json +1 -1
  27. package/packages/cost-optimizer/package.json +1 -1
  28. package/packages/dependency-analyzer/package.json +1 -1
  29. package/packages/django-plugin/package.json +1 -1
  30. package/packages/doc-generator/package.json +1 -1
  31. package/packages/evaluation-engine/package.json +1 -1
  32. package/packages/evaluation-runner/package.json +1 -1
  33. package/packages/event-bus/dist/src/types.d.ts +1 -1
  34. package/packages/event-bus/dist/src/types.d.ts.map +1 -1
  35. package/packages/event-bus/dist/src/types.js.map +1 -1
  36. package/packages/event-bus/package.json +1 -1
  37. package/packages/event-bus/src/types.ts +1 -0
  38. package/packages/event-store/package.json +1 -1
  39. package/packages/experience-replay/package.json +1 -1
  40. package/packages/feedback-collector/package.json +1 -1
  41. package/packages/file-copy-helper/package.json +1 -1
  42. package/packages/file-copy-helper/src/extension.debug.ts +53 -16
  43. package/packages/file-copy-helper/src/extension.ts +48 -16
  44. package/packages/framework-detector/package.json +1 -1
  45. package/packages/framework-plugin-sdk/package.json +1 -1
  46. package/packages/frappe-plugin/package.json +1 -1
  47. package/packages/generic-web-plugin/package.json +1 -1
  48. package/packages/health-monitor/package.json +1 -1
  49. package/packages/intent-analyzer/package.json +1 -1
  50. package/packages/knowledge-graph/package.json +1 -1
  51. package/packages/knowledge-retrieval/package.json +1 -1
  52. package/packages/laravel-plugin/package.json +1 -1
  53. package/packages/learning-engine/package.json +1 -1
  54. package/packages/mcp-adapter/package.json +1 -1
  55. package/packages/memory-engine/package.json +1 -1
  56. package/packages/milestone-manager/package.json +1 -1
  57. package/packages/model-registry/package.json +1 -1
  58. package/packages/nextjs-plugin/package.json +1 -1
  59. package/packages/notification/package.json +1 -1
  60. package/packages/observability/package.json +1 -1
  61. package/packages/okf-indexer/package.json +1 -1
  62. package/packages/performance-optimizer/package.json +1 -1
  63. package/packages/privilege-broker/package.json +1 -1
  64. package/packages/project-analyzer/package.json +1 -1
  65. package/packages/project-bootstrap/package.json +1 -1
  66. package/packages/projection-engine/package.json +1 -1
  67. package/packages/prompt-compiler/package.json +1 -1
  68. package/packages/prompt-versioning/package.json +1 -1
  69. package/packages/provider-adapter-sdk/package.json +1 -1
  70. package/packages/provider-router/package.json +1 -1
  71. package/packages/provider-selector/package.json +1 -1
  72. package/packages/providers/package.json +1 -1
  73. package/packages/quota-manager/package.json +1 -1
  74. package/packages/rate-limiter/package.json +1 -1
  75. package/packages/react-vite-plugin/package.json +1 -1
  76. package/packages/release-manager/package.json +1 -1
  77. package/packages/requirement-compiler/package.json +1 -1
  78. package/packages/runtime/package.json +1 -1
  79. package/packages/scheduler/package.json +1 -1
  80. package/packages/scheduler-adapter/package.json +1 -1
  81. package/packages/session/package.json +1 -1
  82. package/packages/session-api/package.json +1 -1
  83. package/packages/session-export/package.json +1 -1
  84. package/packages/shared-context/package.json +1 -1
  85. package/packages/skill-mcp-client/package.json +1 -1
  86. package/packages/skill-registry/package.json +1 -1
  87. package/packages/sprint-planner/package.json +1 -1
  88. package/packages/subscription-engine/package.json +1 -1
  89. package/packages/task-compiler/package.json +1 -1
  90. package/packages/tencentdb-memory/package.json +1 -1
  91. package/packages/tencentdb-sync/package.json +1 -1
  92. package/packages/test-data-generator/package.json +1 -1
  93. package/packages/test-generator/package.json +1 -1
  94. package/packages/todo-bd-sync/package.json +1 -1
  95. package/packages/token-estimation/package.json +1 -1
  96. package/packages/token-optimizer/package.json +1 -1
  97. package/packages/tui/package.json +1 -1
  98. package/packages/types/package.json +1 -1
  99. package/packages/workflow-events/package.json +1 -1
  100. package/packages/workspace-scanner/package.json +1 -1
  101. package/packages/worktree/package.json +1 -1
  102. package/packages/write-review/package.json +1 -1
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Multi-Agent definitions — GPT (planner/reviewer/supervisor) + MiniMax (coder).
3
+ *
4
+ * This is a TypeScript port of the "supervisor + agents-as-tools" pattern from
5
+ * Mohamad-Hachem/MultiAgent_Wedding_Planner_With_Langchain (agents.py):
6
+ *
7
+ * Python (agents.py) → TypeScript (this file)
8
+ * ------------------------------------------------------------------
9
+ * subagent1 = create_agent(...) → coderAgent = createAgent({ model: minimax ... })
10
+ * @tool delegate_to_subagent1 → delegateToCoder = tool(... { name: "delegate_to_coder" })
11
+ * main_agent(tools=[delegates]) → supervisor = createAgent({ tools: [delegateToCoder, ...] })
12
+ *
13
+ * Verdict values intentionally match the existing harness LoopVerdict type
14
+ * ("approved" | "changes_requested" | "blocked") so this module can feed the
15
+ * existing blackboard / HerdrEventBus infrastructure.
16
+ *
17
+ * Wiki: wiki/multi-agent-langchain.md
18
+ */
19
+
20
+ import { createAgent, tool } from "langchain";
21
+ import { z } from "zod";
22
+ import {
23
+ createCoderModel,
24
+ createPlannerModel,
25
+ createReviewerModel,
26
+ type ModelOptions,
27
+ } from "./models.js";
28
+
29
+ // ─── Structured review output ───────────────────────────────────────────────
30
+
31
+ export const ReviewVerdictSchema = z.object({
32
+ verdict: z
33
+ .enum(["approved", "changes_requested", "blocked"])
34
+ .describe("Overall review verdict"),
35
+ summary: z.string().describe("One-paragraph summary of the review"),
36
+ comments: z
37
+ .array(
38
+ z.object({
39
+ file: z.string().optional().describe("Affected file, if known"),
40
+ comment: z.string().describe("Actionable change request"),
41
+ severity: z.enum(["critical", "major", "minor"]).optional(),
42
+ }),
43
+ )
44
+ .describe("Comments to send back to the coder (empty when approved)"),
45
+ });
46
+
47
+ export type ReviewVerdict = z.infer<typeof ReviewVerdictSchema>;
48
+
49
+ // ─── Prompts ────────────────────────────────────────────────────────────────
50
+
51
+ const PLANNER_PROMPT = `You are the Planning Agent (GPT). Given a feature request, produce a
52
+ concise implementation plan: goals, non-goals, ordered steps, files to touch,
53
+ risks, and a definition-of-done the reviewer can check against.
54
+ Output markdown only.`;
55
+
56
+ const CODER_PROMPT = `You are the Coding Agent (MiniMax). You receive a plan (and possibly
57
+ review comments from a previous iteration) and produce complete, runnable code.
58
+ Always output fenced code blocks with file-path headers. Address every review
59
+ comment explicitly.`;
60
+
61
+ const REVIEWER_PROMPT = `You are the Code Review Agent (GPT). You review the coder's output
62
+ against the plan and the review checklist. Be strict but fair: request changes
63
+ only for real problems. Respond via the structured schema.`;
64
+
65
+ const SUPERVISOR_PROMPT = `You are the Supervisor (GPT). Coordinate the workflow:
66
+ 1. Call delegate_to_coder with the instruction (include review comments when fixing).
67
+ 2. Call delegate_to_reviewer with the plan + code to review.
68
+ 3. If the reviewer requests changes, call delegate_to_coder again with the comments.
69
+ 4. Repeat until approved or you judge further iterations unproductive (then stop and summarize).
70
+ Keep the loop tight: no more than 4 coder-review rounds unless the task clearly needs more.`;
71
+
72
+ // ─── Agents ─────────────────────────────────────────────────────────────────
73
+
74
+ export function createPlannerAgent(opts: ModelOptions = {}) {
75
+ return createAgent({
76
+ model: createPlannerModel(opts),
77
+ tools: [],
78
+ systemPrompt: PLANNER_PROMPT,
79
+ });
80
+ }
81
+
82
+ export function createCoderAgent(opts: ModelOptions = {}) {
83
+ return createAgent({
84
+ model: createCoderModel(opts),
85
+ tools: [], // roadmap: file read/write tools for real repo edits
86
+ systemPrompt: CODER_PROMPT,
87
+ });
88
+ }
89
+
90
+ export function createReviewerAgent(opts: ModelOptions = {}) {
91
+ return createAgent({
92
+ model: createReviewerModel(opts),
93
+ tools: [],
94
+ systemPrompt: REVIEWER_PROMPT,
95
+ responseFormat: ReviewVerdictSchema,
96
+ });
97
+ }
98
+
99
+ export type PlannerAgent = ReturnType<typeof createPlannerAgent>;
100
+ export type CoderAgent = ReturnType<typeof createCoderAgent>;
101
+ export type ReviewerAgent = ReturnType<typeof createReviewerAgent>;
102
+ export type SupervisorAgent = ReturnType<typeof createSupervisor>;
103
+
104
+ /** Extract the last assistant message text from an agent result. */
105
+ export function lastMessage(agentResult: {
106
+ messages: Array<{ content: unknown }>;
107
+ }): string {
108
+ const last = agentResult.messages[agentResult.messages.length - 1];
109
+ if (typeof last?.content === "string") return last.content;
110
+ if (Array.isArray(last?.content)) {
111
+ return last.content.map((c: { text?: string }) => c?.text ?? "").join("\n");
112
+ }
113
+ return "";
114
+ }
115
+
116
+ // ─── Delegation tools (agents-as-tools, port of agents.py) ──────────────────
117
+
118
+ export function buildDelegationTools(
119
+ opts: { coder?: CoderAgent; reviewer?: ReviewerAgent } = {},
120
+ ) {
121
+ const coder = opts.coder ?? createCoderAgent();
122
+ const reviewer = opts.reviewer ?? createReviewerAgent();
123
+
124
+ const delegateToCoder = tool(
125
+ async ({ instruction, reviewComments }) => {
126
+ const userMsg = reviewComments
127
+ ? `${instruction}\n\nPrevious review requested changes:\n${reviewComments}`
128
+ : instruction;
129
+ const result = await coder.invoke({
130
+ messages: [{ role: "user", content: userMsg }],
131
+ });
132
+ return lastMessage(result);
133
+ },
134
+ {
135
+ name: "delegate_to_coder",
136
+ description:
137
+ "Delegate a coding instruction to the MiniMax coder agent. " +
138
+ "Pass previous review comments when fixing issues.",
139
+ schema: z.object({
140
+ instruction: z.string().describe("What the coder should implement or fix"),
141
+ reviewComments: z
142
+ .string()
143
+ .optional()
144
+ .describe("Review comments from the previous iteration"),
145
+ }),
146
+ },
147
+ );
148
+
149
+ const delegateToReviewer = tool(
150
+ async ({ plan, code }) => {
151
+ const result = await reviewer.invoke({
152
+ messages: [
153
+ {
154
+ role: "user",
155
+ content: `## Plan\n${plan}\n\n## Code to review\n${code}`,
156
+ },
157
+ ],
158
+ });
159
+ const structured = (result as { structuredResponse?: ReviewVerdict })
160
+ .structuredResponse;
161
+ return structured
162
+ ? JSON.stringify(structured, null, 2)
163
+ : lastMessage(result);
164
+ },
165
+ {
166
+ name: "delegate_to_reviewer",
167
+ description:
168
+ "Delegate a code review to the GPT reviewer agent. Returns a JSON verdict: " +
169
+ '{verdict: "approved"|"changes_requested"|"blocked", summary, comments[]}.',
170
+ schema: z.object({
171
+ plan: z.string().describe("The implementation plan"),
172
+ code: z.string().describe("The code the coder produced"),
173
+ }),
174
+ },
175
+ );
176
+
177
+ return { delegateToCoder, delegateToReviewer };
178
+ }
179
+
180
+ // ─── Supervisor (dynamic routing variant) ───────────────────────────────────
181
+
182
+ export function createSupervisor(
183
+ opts: {
184
+ plannerModel?: ModelOptions;
185
+ coder?: CoderAgent;
186
+ reviewer?: ReviewerAgent;
187
+ } = {},
188
+ ) {
189
+ const { delegateToCoder, delegateToReviewer } = buildDelegationTools({
190
+ coder: opts.coder,
191
+ reviewer: opts.reviewer,
192
+ });
193
+ return createAgent({
194
+ model: createPlannerModel(opts.plannerModel ?? {}),
195
+ tools: [delegateToCoder, delegateToReviewer],
196
+ systemPrompt: SUPERVISOR_PROMPT,
197
+ });
198
+ }
@@ -0,0 +1,353 @@
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
+ });