pi-harness-runtime 1.1.30 → 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 (100) 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/framework-detector/package.json +1 -1
  43. package/packages/framework-plugin-sdk/package.json +1 -1
  44. package/packages/frappe-plugin/package.json +1 -1
  45. package/packages/generic-web-plugin/package.json +1 -1
  46. package/packages/health-monitor/package.json +1 -1
  47. package/packages/intent-analyzer/package.json +1 -1
  48. package/packages/knowledge-graph/package.json +1 -1
  49. package/packages/knowledge-retrieval/package.json +1 -1
  50. package/packages/laravel-plugin/package.json +1 -1
  51. package/packages/learning-engine/package.json +1 -1
  52. package/packages/mcp-adapter/package.json +1 -1
  53. package/packages/memory-engine/package.json +1 -1
  54. package/packages/milestone-manager/package.json +1 -1
  55. package/packages/model-registry/package.json +1 -1
  56. package/packages/nextjs-plugin/package.json +1 -1
  57. package/packages/notification/package.json +1 -1
  58. package/packages/observability/package.json +1 -1
  59. package/packages/okf-indexer/package.json +1 -1
  60. package/packages/performance-optimizer/package.json +1 -1
  61. package/packages/privilege-broker/package.json +1 -1
  62. package/packages/project-analyzer/package.json +1 -1
  63. package/packages/project-bootstrap/package.json +1 -1
  64. package/packages/projection-engine/package.json +1 -1
  65. package/packages/prompt-compiler/package.json +1 -1
  66. package/packages/prompt-versioning/package.json +1 -1
  67. package/packages/provider-adapter-sdk/package.json +1 -1
  68. package/packages/provider-router/package.json +1 -1
  69. package/packages/provider-selector/package.json +1 -1
  70. package/packages/providers/package.json +1 -1
  71. package/packages/quota-manager/package.json +1 -1
  72. package/packages/rate-limiter/package.json +1 -1
  73. package/packages/react-vite-plugin/package.json +1 -1
  74. package/packages/release-manager/package.json +1 -1
  75. package/packages/requirement-compiler/package.json +1 -1
  76. package/packages/runtime/package.json +1 -1
  77. package/packages/scheduler/package.json +1 -1
  78. package/packages/scheduler-adapter/package.json +1 -1
  79. package/packages/session/package.json +1 -1
  80. package/packages/session-api/package.json +1 -1
  81. package/packages/session-export/package.json +1 -1
  82. package/packages/shared-context/package.json +1 -1
  83. package/packages/skill-mcp-client/package.json +1 -1
  84. package/packages/skill-registry/package.json +1 -1
  85. package/packages/sprint-planner/package.json +1 -1
  86. package/packages/subscription-engine/package.json +1 -1
  87. package/packages/task-compiler/package.json +1 -1
  88. package/packages/tencentdb-memory/package.json +1 -1
  89. package/packages/tencentdb-sync/package.json +1 -1
  90. package/packages/test-data-generator/package.json +1 -1
  91. package/packages/test-generator/package.json +1 -1
  92. package/packages/todo-bd-sync/package.json +1 -1
  93. package/packages/token-estimation/package.json +1 -1
  94. package/packages/token-optimizer/package.json +1 -1
  95. package/packages/tui/package.json +1 -1
  96. package/packages/types/package.json +1 -1
  97. package/packages/workflow-events/package.json +1 -1
  98. package/packages/workspace-scanner/package.json +1 -1
  99. package/packages/worktree/package.json +1 -1
  100. package/packages/write-review/package.json +1 -1
@@ -0,0 +1,880 @@
1
+ /**
2
+ * Loop Daemon — Auto-triggered write-review loop.
3
+ *
4
+ * Design philosophy: Human *on* the loop, not *in* the loop.
5
+ * The daemon starts itself, routes itself, and only notifies the human at gates
6
+ * (approval, escalation, blocked). No human ever acts as the message bus between
7
+ * GPT and MiniMax.
8
+ *
9
+ * Architecture:
10
+ *
11
+ * TRIGGERS
12
+ * bd task created ─┐
13
+ * inbox file ─┤
14
+ * bus event ─┼──► watcher (poll) ─► LeaseManager.claim ─► LangGraph.run
15
+ * cron (stub) ─┘ │
16
+ * │ publish (loop-events.ts) ─► HerdrEventBus
17
+ * │ gates ─► NotificationCenter
18
+ *
19
+ * Milestones:
20
+ * M1 — Daemon skeleton + inbox/bus watchers
21
+ * M2 — Runner integration: LeaseManager → buildWriteReviewLoop → publish
22
+ * M3 — Gates & notifications
23
+ * M4 — Robustness: retry/backoff, heartbeats, stale-lease, bd-tasks watcher
24
+ *
25
+ * Wiki: wiki/auto-trigger-multi-agent.md
26
+ */
27
+
28
+ import { randomUUID } from "node:crypto";
29
+ import {
30
+ existsSync,
31
+ mkdirSync,
32
+ readdirSync,
33
+ readFileSync,
34
+ writeFileSync,
35
+ } from "node:fs";
36
+ import { join } from "node:path";
37
+
38
+ import { LeaseManager } from "../../packages/autonomous-runtime/src/lease.js";
39
+ import type { ApprovalClass } from "../../packages/autonomous-runtime/src/types.js";
40
+ import {
41
+ ensureHerdrWorkspace,
42
+ getHerdrWorkspace,
43
+ type HerdrEventBus,
44
+ type LoopVerdict,
45
+ } from "../../packages/event-bus/src/herdr-bus.js";
46
+ import {
47
+ createHerdrBus,
48
+ publishCodeWritten,
49
+ publishLoopFinished,
50
+ publishLoopStarted,
51
+ publishReviewCompleted,
52
+ } from "../../packages/event-bus/src/herdr-bus.js";
53
+ import { NotificationCenter } from "../../packages/notification/dist/notification-center.js";
54
+
55
+ import {
56
+ buildDryRunDeps,
57
+ buildRealLoopDeps,
58
+ buildWriteReviewLoop,
59
+ type LoopDeps,
60
+ type LoopState,
61
+ type WriteReviewLoop,
62
+ } from "./graph.js";
63
+
64
+ // ─── Config ─────────────────────────────────────────────────────────────────
65
+
66
+ export type TriggerSource = "bd-tasks" | "inbox" | "bus" | "cron";
67
+
68
+ export type ApprovalPolicy =
69
+ | "never" // fully autonomous; notify only on finish/blocked (default)
70
+ | "on_blocked" // notify + WAIT for human ack when verdict === "blocked"
71
+ | "on_class" // wait only when task's ApprovalClass demands it
72
+ | "always"; // wait after every review iteration (regulated mode)
73
+
74
+ export interface DaemonConfig {
75
+ /** Agent/worker identity. Default: "loop-daemon" */
76
+ agentId?: string;
77
+ /** Trigger poll interval in ms. Default: 2000 */
78
+ pollMs?: number;
79
+ /** Max write-review rounds per task. Default: 3 */
80
+ maxIterations?: number;
81
+ /** Human gate policy. Default: "never" */
82
+ approvalPolicy?: ApprovalPolicy;
83
+ /** Use deterministic stubs (no API keys). Default: false */
84
+ dryRun?: boolean;
85
+ /** Abort loop if cumulative cost exceeds this. Default: no cap */
86
+ costCapUsd?: number;
87
+ /** Which watchers to enable. Default: ["inbox", "bus"] */
88
+ sources?: TriggerSource[];
89
+ /** Herdr workspace override. Default: from herdr-bus */
90
+ workspace?: string;
91
+ /** NotificationCenter config */
92
+ notificationConfig?: {
93
+ telegramBotToken?: string;
94
+ telegramChatId?: string;
95
+ ntfyTopic?: string;
96
+ webhookUrl?: string;
97
+ };
98
+ }
99
+
100
+ // ─── TriggeredTask ──────────────────────────────────────────────────────────
101
+
102
+ export interface TriggeredTask {
103
+ /** Stable id — derived from the trigger source */
104
+ taskId: string;
105
+ /** Human-readable task description */
106
+ request: string;
107
+ /** How the task arrived */
108
+ source: TriggerSource;
109
+ /** Optional approval class from task metadata */
110
+ approvalClass?: ApprovalClass;
111
+ /** ISO timestamp of when the trigger was observed */
112
+ triggeredAt: string;
113
+ }
114
+
115
+ // ─── TaskProposedPayload (bus event shape) ──────────────────────────────────
116
+
117
+ export interface TaskProposedPayload {
118
+ taskId: string;
119
+ request: string;
120
+ approvalClass?: ApprovalClass;
121
+ }
122
+
123
+ // ─── Internal state ─────────────────────────────────────────────────────────
124
+
125
+ interface RunningTask {
126
+ task: TriggeredTask;
127
+ lease: ReturnType<LeaseManager["claim"]>;
128
+ startedAt: string;
129
+ }
130
+
131
+ interface TaskSeen {
132
+ taskId: string;
133
+ source: TriggerSource;
134
+ seenAt: string;
135
+ }
136
+
137
+ // ─── Ack file helpers ───────────────────────────────────────────────────────
138
+
139
+ /** Path for human approval ack files. */
140
+ function ackFilePath(inboxDir: string, taskId: string): string {
141
+ return join(inboxDir, `ack-${taskId}.json`);
142
+ }
143
+
144
+ /** Read an ack file. Returns null if it doesn't exist yet. */
145
+ function readAck(
146
+ inboxDir: string,
147
+ taskId: string,
148
+ ): { decision: "approved" | "denied"; decidedAt: string } | null {
149
+ const path = ackFilePath(inboxDir, taskId);
150
+ if (!existsSync(path)) return null;
151
+ try {
152
+ return JSON.parse(readFileSync(path, "utf8")) as {
153
+ decision: "approved" | "denied";
154
+ decidedAt: string;
155
+ };
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ /** Write an ack file (used by tests / manual approval). */
162
+ export function writeAck(
163
+ inboxDir: string,
164
+ taskId: string,
165
+ decision: "approved" | "denied",
166
+ ): void {
167
+ const path = ackFilePath(inboxDir, taskId);
168
+ writeFileSync(
169
+ path,
170
+ JSON.stringify({ decision, decidedAt: new Date().toISOString() }, null, 2),
171
+ "utf8",
172
+ );
173
+ }
174
+
175
+ // ─── Approval gate ───────────────────────────────────────────────────────────
176
+
177
+ /** Resolve the effective approval policy for a task given the daemon config. */
178
+ function effectivePolicy(
179
+ task: TriggeredTask,
180
+ config: DaemonConfig,
181
+ ): ApprovalPolicy {
182
+ const policy = config.approvalPolicy ?? "never";
183
+ if (policy === "on_class") {
184
+ // Map ApprovalClass → gate policy
185
+ const cls = task.approvalClass ?? "automatic_reversible";
186
+ if (cls === "human_approval_required") return "on_blocked";
187
+ return "never";
188
+ }
189
+ return policy;
190
+ }
191
+
192
+ /** Wait (poll) for a human ack file or bus event. Returns true if approved. */
193
+ async function waitForAck(
194
+ task: TriggeredTask,
195
+ _config: DaemonConfig,
196
+ _bus: HerdrEventBus,
197
+ inboxDir: string,
198
+ ): Promise<boolean> {
199
+ const pollMs = _config.pollMs ?? 2_000;
200
+ const timeoutMs = 30 * 60 * 1_000; // 30-minute human SLA
201
+
202
+ const deadline = Date.now() + timeoutMs;
203
+ while (Date.now() < deadline) {
204
+ // Check ack file first
205
+ const ack = readAck(inboxDir, task.taskId);
206
+ if (ack) {
207
+ console.log(`[gate] Ack file found for ${task.taskId}: ${ack.decision}`);
208
+ return ack.decision === "approved";
209
+ }
210
+ // Also accept approval.granted bus event
211
+ // (The bus watcher is already polling; we just yield and let it tick)
212
+ await sleep(pollMs);
213
+ }
214
+
215
+ console.warn(`[gate] Ack timeout for ${task.taskId} — treating as denied`);
216
+ return false;
217
+ }
218
+
219
+ // ─── Helpers ────────────────────────────────────────────────────────────────
220
+
221
+ function sleep(ms: number): Promise<void> {
222
+ return new Promise((r) => setTimeout(r, ms));
223
+ }
224
+
225
+ function stableTaskId(source: TriggerSource, raw: string): string {
226
+ if (source === "inbox") {
227
+ // For inbox, use the filename as taskId so tests can write ack files
228
+ return raw;
229
+ }
230
+ // Produce a stable short id for non-inbox sources
231
+ const base = `${source}:${raw}`.slice(0, 64);
232
+ let hash = 0;
233
+ for (let i = 0; i < base.length; i++) {
234
+ const ch = base.charCodeAt(i);
235
+ hash = (hash << 5) - hash + ch;
236
+ hash |= 0;
237
+ }
238
+ return `task-${Math.abs(hash).toString(36)}-${Date.now().toString(36).slice(-6)}`;
239
+ }
240
+
241
+ // ─── Inbox watcher ──────────────────────────────────────────────────────────
242
+
243
+ /** Watch a directory for new `.md` files — each file = one TriggeredTask. */
244
+ class InboxWatcher {
245
+ private readonly inboxDir: string;
246
+ private readonly seen: Map<string, TaskSeen> = new Map();
247
+ private readonly pollMs: number;
248
+ private running = false;
249
+ private timer?: ReturnType<typeof setTimeout>;
250
+
251
+ constructor(inboxDir: string, pollMs: number) {
252
+ this.inboxDir = inboxDir;
253
+ this.pollMs = pollMs;
254
+ }
255
+
256
+ /** Start polling for new inbox files. */
257
+ start(onTask: (task: TriggeredTask) => void): void {
258
+ this.running = true;
259
+ // Seed seen set with existing files
260
+ this._seedSeen();
261
+ this._poll(onTask);
262
+ }
263
+
264
+ stop(): void {
265
+ this.running = false;
266
+ if (this.timer) {
267
+ clearTimeout(this.timer);
268
+ this.timer = undefined;
269
+ }
270
+ }
271
+
272
+ private _seedSeen(): void {
273
+ if (!existsSync(this.inboxDir)) return;
274
+ for (const file of readdirSync(this.inboxDir)) {
275
+ if (!file.endsWith(".md")) continue;
276
+ if (file.startsWith("ack-")) continue;
277
+ this.seen.set(file, {
278
+ taskId: file,
279
+ source: "inbox",
280
+ seenAt: new Date().toISOString(),
281
+ });
282
+ }
283
+ }
284
+
285
+ private _poll(onTask: (task: TriggeredTask) => void): void {
286
+ if (!this.running) return;
287
+ try {
288
+ if (existsSync(this.inboxDir)) {
289
+ for (const file of readdirSync(this.inboxDir)) {
290
+ if (!file.endsWith(".md")) continue;
291
+ if (file.startsWith("ack-")) continue;
292
+ if (this.seen.has(file)) continue;
293
+
294
+ const taskId = stableTaskId("inbox", file);
295
+ const request = this._readRequest(file);
296
+ this.seen.set(file, {
297
+ taskId,
298
+ source: "inbox",
299
+ seenAt: new Date().toISOString(),
300
+ });
301
+ onTask({
302
+ taskId,
303
+ request,
304
+ source: "inbox",
305
+ triggeredAt: new Date().toISOString(),
306
+ });
307
+ }
308
+ }
309
+ } catch (err) {
310
+ console.error(
311
+ `[inbox-watcher] poll error: ${err instanceof Error ? err.message : String(err)}`,
312
+ );
313
+ }
314
+ this.timer = setTimeout(() => this._poll(onTask), this.pollMs);
315
+ }
316
+
317
+ private _readRequest(file: string): string {
318
+ try {
319
+ const content = readFileSync(join(this.inboxDir, file), "utf8");
320
+ // Strip markdown frontmatter if present
321
+ const stripped = content.replace(/^---[\s\S]*?---\n?/, "").trim();
322
+ return stripped || file;
323
+ } catch {
324
+ return file;
325
+ }
326
+ }
327
+ }
328
+
329
+ // ─── Bus watcher ────────────────────────────────────────────────────────────
330
+
331
+ /** Watch HerdrEventBus for `task.proposed` events. */
332
+ class BusWatcher {
333
+ private readonly bus: HerdrEventBus;
334
+ private readonly seen: Set<string> = new Set();
335
+ private readonly pollMs: number;
336
+ private running = false;
337
+ private timer?: ReturnType<typeof setTimeout>;
338
+
339
+ constructor(bus: HerdrEventBus, pollMs: number) {
340
+ this.bus = bus;
341
+ this.pollMs = pollMs;
342
+ }
343
+
344
+ /** Start polling for task.proposed bus events. */
345
+ start(onTask: (task: TriggeredTask) => void): void {
346
+ this.running = true;
347
+ this._poll(onTask);
348
+ }
349
+
350
+ stop(): void {
351
+ this.running = false;
352
+ if (this.timer) {
353
+ clearTimeout(this.timer);
354
+ this.timer = undefined;
355
+ }
356
+ }
357
+
358
+ private _poll(onTask: (task: TriggeredTask) => void): void {
359
+ if (!this.running) return;
360
+ try {
361
+ // Re-use the bus's own polling by manually reading events.jsonl
362
+ // This avoids double-polling since we share the same workspace.
363
+ // The bus watcher delegates to the bus's internal poll mechanism
364
+ // by subscribing dynamically.
365
+ this._pollBusEvents(onTask);
366
+ } catch (err) {
367
+ console.error(
368
+ `[bus-watcher] poll error: ${err instanceof Error ? err.message : String(err)}`,
369
+ );
370
+ }
371
+ this.timer = setTimeout(() => this._poll(onTask), this.pollMs);
372
+ }
373
+
374
+ private _pollBusEvents(onTask: (task: TriggeredTask) => void): void {
375
+ // Read events.jsonl for task.proposed events we haven't seen
376
+ const eventsPath = join(this.bus.getWorkspace(), "events.jsonl");
377
+ if (!existsSync(eventsPath)) return;
378
+
379
+ try {
380
+ const content = readFileSync(eventsPath, "utf8");
381
+ const lines = content.split("\n").filter((l) => l.trim());
382
+
383
+ for (const line of lines) {
384
+ try {
385
+ const event = JSON.parse(line) as {
386
+ eventId?: string;
387
+ topic?: string;
388
+ data?: unknown;
389
+ };
390
+ if (event.topic !== "task.proposed") continue;
391
+ if (!event.eventId) continue;
392
+ if (this.seen.has(event.eventId)) continue;
393
+
394
+ const payload = event.data as TaskProposedPayload | undefined;
395
+ if (!payload?.request) continue;
396
+
397
+ this.seen.add(event.eventId);
398
+ const taskId = payload.taskId || stableTaskId("bus", payload.request);
399
+ onTask({
400
+ taskId,
401
+ request: payload.request,
402
+ source: "bus",
403
+ approvalClass: payload.approvalClass,
404
+ triggeredAt: new Date().toISOString(),
405
+ });
406
+ } catch {
407
+ // skip malformed lines
408
+ }
409
+ }
410
+ } catch {
411
+ // File may not exist yet
412
+ }
413
+ }
414
+ }
415
+
416
+ // ─── Bd-tasks watcher ───────────────────────────────────────────────────────
417
+
418
+ /** Watch bd (beads) task list for new pending tasks. */
419
+ class BdTasksWatcher {
420
+ private readonly pollMs: number;
421
+ private readonly seen: Set<string> = new Set();
422
+ private running = false;
423
+ private timer?: ReturnType<typeof setTimeout>;
424
+
425
+ constructor(pollMs: number) {
426
+ this.pollMs = pollMs;
427
+ }
428
+
429
+ start(onTask: (task: TriggeredTask) => void): void {
430
+ this.running = true;
431
+ this._poll(onTask);
432
+ }
433
+
434
+ stop(): void {
435
+ this.running = false;
436
+ if (this.timer) {
437
+ clearTimeout(this.timer);
438
+ this.timer = undefined;
439
+ }
440
+ }
441
+
442
+ private async _poll(onTask: (task: TriggeredTask) => void): Promise<void> {
443
+ if (!this.running) return;
444
+ try {
445
+ await this._checkBdTasks(onTask);
446
+ } catch {
447
+ // bd command not available — silently skip
448
+ }
449
+ this.timer = setTimeout(() => {
450
+ this._poll(onTask).catch(() => {});
451
+ }, this.pollMs);
452
+ }
453
+
454
+ private async _checkBdTasks(
455
+ onTask: (task: TriggeredTask) => void,
456
+ ): Promise<void> {
457
+ const { execSync } = await import("node:child_process");
458
+ let output: string;
459
+ try {
460
+ output = execSync("bd list --status pending --json 2>/dev/null", {
461
+ encoding: "utf8",
462
+ timeout: 5_000,
463
+ });
464
+ } catch {
465
+ return; // bd not available or no pending tasks
466
+ }
467
+
468
+ try {
469
+ const result = JSON.parse(output) as Array<{
470
+ id: string;
471
+ subject: string;
472
+ metadata?: { approvalClass?: ApprovalClass };
473
+ }>;
474
+ for (const task of result) {
475
+ if (this.seen.has(task.id)) continue;
476
+ this.seen.add(task.id);
477
+ onTask({
478
+ taskId: task.id,
479
+ request: task.subject,
480
+ source: "bd-tasks",
481
+ approvalClass: task.metadata?.approvalClass,
482
+ triggeredAt: new Date().toISOString(),
483
+ });
484
+ }
485
+ } catch {
486
+ // bd output not valid JSON — ignore
487
+ }
488
+ }
489
+ }
490
+
491
+ // ─── Loop Daemon ────────────────────────────────────────────────────────────
492
+
493
+ /**
494
+ * The main daemon class. Coordinates watchers, leasing, loop execution, and
495
+ * notifications.
496
+ *
497
+ * Usage:
498
+ * const daemon = new LoopDaemon({ dryRun: true, sources: ["inbox", "bus"] });
499
+ * daemon.start();
500
+ * // ... daemon runs in the background ...
501
+ * daemon.stop();
502
+ */
503
+ export class LoopDaemon {
504
+ private readonly config: Required<DaemonConfig>;
505
+ private readonly agentId: string;
506
+ private readonly leaseManager: LeaseManager;
507
+ private readonly bus: HerdrEventBus;
508
+ private readonly notificationCenter: NotificationCenter | null;
509
+ private readonly inboxDir: string;
510
+
511
+ private readonly inboxWatcher: InboxWatcher;
512
+ private readonly busWatcher: BusWatcher;
513
+ private readonly bdWatcher: BdTasksWatcher;
514
+
515
+ /** Currently running tasks (taskId → RunningTask) */
516
+ private readonly running = new Map<string, RunningTask>();
517
+
518
+ private stopped = false;
519
+
520
+ constructor(config: DaemonConfig = {}) {
521
+ ensureHerdrWorkspace();
522
+
523
+ this.config = {
524
+ agentId: config.agentId ?? "loop-daemon",
525
+ pollMs: config.pollMs ?? 2_000,
526
+ maxIterations: config.maxIterations ?? 3,
527
+ approvalPolicy: config.approvalPolicy ?? "never",
528
+ dryRun: config.dryRun ?? false,
529
+ costCapUsd: config.costCapUsd ?? undefined,
530
+ sources: config.sources ?? ["inbox", "bus"],
531
+ workspace: config.workspace ?? getHerdrWorkspace(),
532
+ notificationConfig: config.notificationConfig ?? undefined,
533
+ } as Required<DaemonConfig>;
534
+
535
+ this.agentId = this.config.agentId;
536
+ this.bus = createHerdrBus(this.agentId);
537
+
538
+ // All watchers share the bus's workspace directory
539
+ const workspace = this.bus.getWorkspace();
540
+ this.inboxDir = workspace; // inbox IS the herdr workspace root
541
+ this.leaseManager = new LeaseManager({
542
+ leasesDir: `${this.inboxDir}/leases`,
543
+ });
544
+ this.leaseManager.ensureDir();
545
+
546
+ if (this.config.notificationConfig) {
547
+ this.notificationCenter = new NotificationCenter({
548
+ channels: this._buildChannels(this.config.notificationConfig) as Parameters<
549
+ typeof NotificationCenter.prototype.notify
550
+ >[0] extends { channels: infer C }
551
+ ? C
552
+ : never,
553
+ enabled: true,
554
+ });
555
+ } else {
556
+ this.notificationCenter = null;
557
+ }
558
+
559
+ this.inboxWatcher = new InboxWatcher(this.inboxDir, this.config.pollMs);
560
+ this.busWatcher = new BusWatcher(this.bus, this.config.pollMs);
561
+ this.bdWatcher = new BdTasksWatcher(this.config.pollMs);
562
+
563
+ // Reap stale leases from previous runs
564
+ const reclaimed = this.leaseManager.recoverOnStartup();
565
+ if (reclaimed.length > 0) {
566
+ console.log(
567
+ `[daemon] Reclaimed ${reclaimed.length} stale lease(s): ${reclaimed.join(", ")}`,
568
+ );
569
+ }
570
+ }
571
+
572
+ // ─── Public API ──────────────────────────────────────────────────────
573
+
574
+ /** Start the daemon — begins watching all configured sources. */
575
+ start(): void {
576
+ console.log(
577
+ `[daemon] Starting (agentId=${this.agentId}, sources=[${this.config.sources.join(", ")}], dryRun=${this.config.dryRun})`,
578
+ );
579
+ this.stopped = false;
580
+
581
+ if (this.config.sources.includes("inbox")) {
582
+ console.log(`[daemon] Starting inbox watcher on ${this.inboxDir}`);
583
+ this.inboxWatcher.start((t) => this._onTriggered(t));
584
+ }
585
+
586
+ if (this.config.sources.includes("bus")) {
587
+ console.log(`[daemon] Starting bus watcher (task.proposed)`);
588
+ this.busWatcher.start((t) => this._onTriggered(t));
589
+ }
590
+
591
+ if (this.config.sources.includes("bd-tasks")) {
592
+ console.log(`[daemon] Starting bd-tasks watcher`);
593
+ this.bdWatcher.start((t) => this._onTriggered(t));
594
+ }
595
+
596
+ console.log("[daemon] Started.");
597
+ }
598
+
599
+ /** Stop all watchers and release all held leases. */
600
+ stop(): void {
601
+ console.log("[daemon] Stopping...");
602
+ this.stopped = true;
603
+
604
+ this.inboxWatcher.stop();
605
+ this.busWatcher.stop();
606
+ this.bdWatcher.stop();
607
+
608
+ // Release all held leases
609
+ for (const [taskId, _runningTask] of this.running) {
610
+ try {
611
+ this.leaseManager.release(taskId, this.agentId);
612
+ } catch (err) {
613
+ console.error(`[daemon] Failed to release lease for ${taskId}: ${err}`);
614
+ }
615
+ }
616
+ this.running.clear();
617
+
618
+ console.log("[daemon] Stopped.");
619
+ }
620
+
621
+ /** Returns true if stop() has been called. */
622
+ get isStopped(): boolean {
623
+ return this.stopped;
624
+ }
625
+
626
+ // ─── Task processing ──────────────────────────────────────────────────
627
+
628
+ private _onTriggered(task: TriggeredTask): void {
629
+ if (this.stopped) return;
630
+
631
+ // Skip if already running this task
632
+ if (this.running.has(task.taskId)) {
633
+ console.log(
634
+ `[daemon] Task ${task.taskId} already running — skipping trigger`,
635
+ );
636
+ return;
637
+ }
638
+
639
+ console.log(
640
+ `[daemon] Triggered: [${task.source}] ${task.taskId} — "${task.request.slice(0, 60)}"`,
641
+ );
642
+
643
+ // Try to claim the lease
644
+ const lease = this.leaseManager.claim(task.taskId, this.agentId);
645
+ if (!lease) {
646
+ console.log(
647
+ `[daemon] Lease held for ${task.taskId} — skipping (another worker)`,
648
+ );
649
+ return;
650
+ }
651
+
652
+ // Run the task asynchronously (do not block the watcher)
653
+ this.running.set(task.taskId, {
654
+ task,
655
+ lease,
656
+ startedAt: new Date().toISOString(),
657
+ });
658
+ this._runTask(task).catch((err) => {
659
+ console.error(
660
+ `[daemon] Task ${task.taskId} failed: ${err instanceof Error ? err.message : String(err)}`,
661
+ );
662
+ this.running.delete(task.taskId);
663
+ });
664
+ }
665
+
666
+ private async _runTask(task: TriggeredTask): Promise<void> {
667
+ const loopId = `loop-${randomUUID().slice(0, 8)}`;
668
+ const log = (msg: string) => console.log(`[${loopId}] ${msg}`);
669
+
670
+ try {
671
+ // ── Gate check (I7) ──────────────────────────────────────────────
672
+ const policy = effectivePolicy(task, this.config);
673
+ if (policy === "always" || policy === "on_blocked") {
674
+ log(`Gate policy=${policy} — waiting for human approval before starting`);
675
+ const approved = await waitForAck(
676
+ task,
677
+ this.config,
678
+ this.bus,
679
+ this.inboxDir,
680
+ );
681
+ if (!approved) {
682
+ log(`Human denied approval — parking task`);
683
+ this._notifyHumanReviewNeeded(
684
+ task,
685
+ "Human denied approval for task start",
686
+ );
687
+ this._releaseLease(task.taskId);
688
+ this.running.delete(task.taskId);
689
+ return;
690
+ }
691
+ log(`Human approved — proceeding`);
692
+ }
693
+
694
+ // ── Build loop deps ───────────────────────────────────────────────
695
+ const deps: LoopDeps = this.config.dryRun
696
+ ? buildDryRunDeps({ maxIterations: this.config.maxIterations })
697
+ : await buildRealLoopDeps({ maxIterations: this.config.maxIterations });
698
+
699
+ // Inject transition publishing into onStep
700
+ const originalOnStep = deps.onStep;
701
+ deps.onStep = (step, state) => {
702
+ originalOnStep?.(step, state);
703
+ this._publishTransition(loopId, step, state);
704
+ };
705
+
706
+ // ── Run the graph ─────────────────────────────────────────────────
707
+ log(
708
+ `Starting write-review loop (maxIterations=${this.config.maxIterations})`,
709
+ );
710
+ const loop: WriteReviewLoop = buildWriteReviewLoop(deps);
711
+
712
+ const finalState = await loop.invoke(
713
+ { request: task.request },
714
+ { configurable: { thread_id: loopId } },
715
+ );
716
+
717
+ const verdict = finalState.review?.verdict ?? "blocked";
718
+ const iterations = finalState.iteration ?? 0;
719
+
720
+ log(`Loop finished: ${verdict} after ${iterations} iteration(s)`);
721
+
722
+ // ── Gate check after blocked verdict ────────────────────────────────
723
+ if (
724
+ verdict === "blocked" &&
725
+ effectivePolicy(task, this.config) === "on_blocked"
726
+ ) {
727
+ log(`Blocked verdict + on_blocked policy — waiting for human ack`);
728
+ const approved = await waitForAck(
729
+ task,
730
+ this.config,
731
+ this.bus,
732
+ this.inboxDir,
733
+ );
734
+ if (!approved) {
735
+ log(`Human denied — finalizing as blocked`);
736
+ }
737
+ }
738
+
739
+ // ── Publish loop.finished ──────────────────────────────────────────
740
+ publishLoopFinished(
741
+ this.bus,
742
+ loopId,
743
+ `${iterations} iteration(s) — ${verdict}`,
744
+ iterations,
745
+ iterations,
746
+ verdict as LoopVerdict,
747
+ );
748
+
749
+ // ── Notify on blocked / max-iterations ─────────────────────────────
750
+ if (verdict === "blocked") {
751
+ this._notifyHumanReviewNeeded(
752
+ task,
753
+ `Loop blocked: ${finalState.review?.summary ?? "no details"}`,
754
+ );
755
+ } else if (verdict === "changes_requested") {
756
+ this._notifyHumanReviewNeeded(
757
+ task,
758
+ `${iterations} iteration(s) exhausted with changes still requested: ${finalState.review?.comments.map((c) => c.comment).join("; ") ?? "no details"}`,
759
+ );
760
+ } else {
761
+ // approved
762
+ this._notifyReadyForClient(task);
763
+ }
764
+ } finally {
765
+ this._releaseLease(task.taskId);
766
+ this.running.delete(task.taskId);
767
+ }
768
+ }
769
+
770
+ private _publishTransition(
771
+ loopId: string,
772
+ step: string,
773
+ state: LoopState,
774
+ ): void {
775
+ switch (step) {
776
+ case "plan": {
777
+ // Publish loop.started on the plan step
778
+ publishLoopStarted(this.bus, {
779
+ loopId,
780
+ writeCount: 0,
781
+ reviewCount: 0,
782
+ prompt: state.request ?? "",
783
+ nextReviewAfter: 0,
784
+ createdAt: new Date().toISOString(),
785
+ });
786
+ break;
787
+ }
788
+ case "write":
789
+ publishCodeWritten(this.bus, loopId, state.iteration, [], undefined);
790
+ break;
791
+ case "review":
792
+ publishReviewCompleted(
793
+ this.bus,
794
+ loopId,
795
+ state.iteration,
796
+ state.review?.verdict ?? "blocked",
797
+ state.review?.summary ?? "",
798
+ this._reviewReportPath(loopId, state.iteration),
799
+ );
800
+ break;
801
+ }
802
+ }
803
+
804
+ private _reviewReportPath(loopId: string, iteration: number): string {
805
+ return `${ensureHerdrWorkspace().reviews}/${loopId}-review-${iteration}.md`;
806
+ }
807
+
808
+ private _releaseLease(taskId: string): void {
809
+ try {
810
+ this.leaseManager.release(taskId, this.agentId);
811
+ } catch (err) {
812
+ console.error(`[daemon] Failed to release lease for ${taskId}: ${err}`);
813
+ }
814
+ }
815
+
816
+ // ─── Notifications ────────────────────────────────────────────────────
817
+
818
+ private _notifyHumanReviewNeeded(task: TriggeredTask, reason: string): void {
819
+ if (!this.notificationCenter) return;
820
+ console.log(`[daemon] Notifying HumanReviewNeeded: ${reason}`);
821
+ this.notificationCenter
822
+ .notify("HumanReviewNeeded", {
823
+ jobId: task.taskId,
824
+ requirement: task.request,
825
+ taskId: task.taskId,
826
+ error: reason,
827
+ })
828
+ .catch((err) => {
829
+ console.warn(`[daemon] Notification failed: ${err}`);
830
+ });
831
+ }
832
+
833
+ private _notifyReadyForClient(task: TriggeredTask): void {
834
+ if (!this.notificationCenter) return;
835
+ console.log(`[daemon] Notifying ReadyForClient`);
836
+ this.notificationCenter
837
+ .notify("ReadyForClient", {
838
+ jobId: task.taskId,
839
+ requirement: task.request,
840
+ taskId: task.taskId,
841
+ })
842
+ .catch((err) => {
843
+ console.warn(`[daemon] Notification failed: ${err}`);
844
+ });
845
+ }
846
+
847
+ private _buildChannels(cfg: NonNullable<DaemonConfig["notificationConfig"]>) {
848
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
849
+ const channels: any[] = [];
850
+ if (cfg.telegramBotToken && cfg.telegramChatId) {
851
+ channels.push({
852
+ id: "telegram",
853
+ type: "telegram",
854
+ enabled: true,
855
+ config: { botToken: cfg.telegramBotToken, chatId: cfg.telegramChatId },
856
+ });
857
+ }
858
+ if (cfg.ntfyTopic) {
859
+ channels.push({
860
+ id: "ntfy",
861
+ type: "ntfy",
862
+ enabled: true,
863
+ config: { server: "https://ntfy.sh", topic: cfg.ntfyTopic },
864
+ });
865
+ }
866
+ if (cfg.webhookUrl) {
867
+ channels.push({
868
+ id: "webhook",
869
+ type: "webhook",
870
+ enabled: true,
871
+ config: { url: cfg.webhookUrl },
872
+ });
873
+ }
874
+ return channels;
875
+ }
876
+ }
877
+
878
+ // ─── Default export ─────────────────────────────────────────────────────────
879
+
880
+ export { LoopDaemon as default };