@felixdotgo/aidlc-kit 0.1.1 → 0.3.0

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 (198) hide show
  1. package/DEVELOPMENT.md +14 -13
  2. package/README.md +37 -43
  3. package/dist/assets/.agents/aidlc/docker/Dockerfile.claude +7 -0
  4. package/dist/assets/.agents/aidlc/docker/Dockerfile.codex +9 -0
  5. package/dist/assets/.agents/aidlc/docker/Dockerfile.opencode +8 -0
  6. package/dist/assets/.agents/aidlc/orchestrator.md +2 -2
  7. package/dist/assets/.agents/aidlc/phase-clarify.md +1 -1
  8. package/dist/assets/.agents/aidlc/phase-plan.md +2 -2
  9. package/dist/assets/.agents/aidlc/schemas/config.schema.json +2 -1
  10. package/dist/assets/.agents/aidlc/schemas/state.schema.json +2 -0
  11. package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +94 -48
  12. package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +5 -4
  13. package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +261 -0
  14. package/dist/assets/.agents/aidlc/scripts/lib/loop-commit-message.mjs +45 -0
  15. package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +206 -0
  16. package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +100 -14
  17. package/dist/assets/.agents/aidlc/scripts/lib/loop-env.mjs +279 -0
  18. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +105 -32
  19. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +17 -1
  20. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +24 -7
  21. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +42 -16
  22. package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +20 -6
  23. package/dist/assets/.agents/aidlc/scripts/lib/loop-holder.mjs +376 -0
  24. package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +33 -7
  25. package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +67 -61
  26. package/dist/assets/.agents/aidlc/scripts/lib/loop-leases.mjs +268 -0
  27. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +116 -28
  28. package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +114 -12
  29. package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +117 -0
  30. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +16 -56
  31. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +149 -6
  32. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +9 -1
  33. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +44 -6
  34. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-image.mjs +64 -0
  35. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-log.mjs +158 -0
  36. package/dist/assets/.agents/aidlc/scripts/lib/repo-head.mjs +22 -0
  37. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +68 -2
  38. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime-migrate.mjs +75 -0
  39. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime.mjs +97 -0
  40. package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +168 -73
  41. package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +19 -7
  42. package/dist/assets/.agents/aidlc/scripts/loop.cmd +101 -0
  43. package/dist/assets/.agents/aidlc/scripts/loop.mjs +2 -1
  44. package/dist/assets/.agents/aidlc/scripts/loop.sh +71 -0
  45. package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +10 -4
  46. package/dist/assets/.agents/aidlc/scripts/state.mjs +16 -8
  47. package/dist/assets/.agents/aidlc/templates/design.md +9 -0
  48. package/dist/assets/.agents/aidlc/templates/intent.md +4 -0
  49. package/dist/assets/.agents/aidlc/templates/workplan.md +1 -1
  50. package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +1 -1
  51. package/dist/assets/ui/bulma.min.css +3 -0
  52. package/dist/assets/ui/changes.js +83 -0
  53. package/dist/assets/ui/common.js +664 -0
  54. package/dist/assets/ui/dashboard.css +636 -0
  55. package/dist/assets/ui/dashboard.js +77 -0
  56. package/dist/assets/ui/detail.js +159 -0
  57. package/dist/assets/ui/live-log.js +332 -0
  58. package/dist/services/aidlc-coordination/Dockerfile +11 -3
  59. package/dist/services/aidlc-coordination/compose.yaml +12 -0
  60. package/dist/services/aidlc-coordination/package-lock.json +3 -1
  61. package/dist/services/aidlc-coordination/package.json +9 -4
  62. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +68 -2
  63. package/dist/services/aidlc-coordination/src/repository.mjs +66 -0
  64. package/dist/services/aidlc-coordination/src/server.mjs +9 -3
  65. package/dist/services/aidlc-coordination/test/fleet.test.mjs +21 -0
  66. package/dist/services/aidlc-coordination/test/server-health-auth.test.mjs +67 -0
  67. package/dist/src/adapters.d.ts +0 -3
  68. package/dist/src/adapters.js +3 -5
  69. package/dist/src/cli/argv.d.ts +12 -0
  70. package/dist/src/cli/argv.js +68 -0
  71. package/dist/src/cli/commands.d.ts +8 -0
  72. package/dist/src/cli/commands.js +159 -0
  73. package/dist/src/cli/loop-launcher.d.ts +47 -0
  74. package/dist/src/cli/loop-launcher.js +322 -0
  75. package/dist/src/cli/options.d.ts +25 -0
  76. package/dist/src/cli/options.js +32 -0
  77. package/dist/src/cli/prompt.d.ts +9 -0
  78. package/dist/src/cli/prompt.js +66 -0
  79. package/dist/src/cli.js +28 -191
  80. package/dist/src/gate.js +3 -2
  81. package/dist/src/installer.d.ts +14 -7
  82. package/dist/src/installer.js +42 -143
  83. package/dist/src/legacy.js +1 -0
  84. package/dist/src/managed-content.d.ts +7 -0
  85. package/dist/src/managed-content.js +15 -4
  86. package/dist/src/managed-planning.d.ts +65 -0
  87. package/dist/src/managed-planning.js +219 -0
  88. package/dist/src/mcp-setup.js +3 -1
  89. package/dist/src/model/config-contracts.d.ts +65 -0
  90. package/dist/src/model/config-contracts.js +1 -0
  91. package/dist/src/model/install-contracts.d.ts +29 -0
  92. package/dist/src/model/install-contracts.js +1 -0
  93. package/dist/src/model/lifecycle-enums.d.ts +13 -0
  94. package/dist/src/model/lifecycle-enums.js +1 -0
  95. package/dist/src/model/loop.d.ts +20 -0
  96. package/dist/src/model/loop.js +1 -0
  97. package/dist/src/model/manifest.d.ts +16 -0
  98. package/dist/src/model/manifest.js +1 -0
  99. package/dist/src/model/records.d.ts +142 -0
  100. package/dist/src/model/records.js +1 -0
  101. package/dist/src/model.d.ts +11 -264
  102. package/dist/src/model.js +1 -1
  103. package/dist/src/profiles.d.ts +2 -0
  104. package/dist/src/profiles.js +53 -9
  105. package/dist/src/state/archive-record.d.ts +9 -0
  106. package/dist/src/state/archive-record.js +26 -0
  107. package/dist/src/state/commands.d.ts +18 -0
  108. package/dist/src/state/commands.js +27 -0
  109. package/dist/src/state/domain.d.ts +9 -0
  110. package/dist/src/state/domain.js +9 -0
  111. package/dist/src/state/guards.d.ts +7 -0
  112. package/dist/src/state/guards.js +17 -0
  113. package/dist/src/state/io.d.ts +10 -0
  114. package/dist/src/state/io.js +35 -0
  115. package/dist/src/state/items.d.ts +3 -4
  116. package/dist/src/state/items.js +58 -57
  117. package/dist/src/state/lessons.js +14 -7
  118. package/dist/src/state/lifecycle.js +2 -2
  119. package/dist/src/state/lock.js +7 -6
  120. package/dist/src/state/memory.js +24 -12
  121. package/dist/src/state/{archive.js → migrate.js} +7 -7
  122. package/dist/src/state/partition.d.ts +15 -0
  123. package/dist/src/state/partition.js +90 -0
  124. package/dist/src/state/paths.d.ts +11 -0
  125. package/dist/src/state/paths.js +12 -0
  126. package/dist/src/state/render.js +9 -6
  127. package/dist/src/state/store.js +9 -15
  128. package/dist/src/state/transitions.js +9 -7
  129. package/dist/src/state/validate.js +55 -7
  130. package/dist/src/state.d.ts +16 -11
  131. package/dist/src/state.js +16 -11
  132. package/dist/src/task-ui.d.ts +33 -0
  133. package/dist/src/task-ui.js +33 -0
  134. package/dist/src/ui/assets.d.ts +14 -0
  135. package/dist/src/ui/assets.js +45 -0
  136. package/dist/src/ui/changes.d.ts +83 -0
  137. package/dist/src/ui/changes.js +498 -0
  138. package/dist/src/ui/containers.d.ts +71 -0
  139. package/dist/src/ui/containers.js +214 -0
  140. package/dist/src/ui/daemon.d.ts +75 -0
  141. package/dist/src/ui/daemon.js +232 -0
  142. package/dist/src/ui/data.d.ts +42 -0
  143. package/dist/src/ui/data.js +89 -0
  144. package/dist/src/ui/docs.d.ts +13 -0
  145. package/dist/src/ui/docs.js +39 -0
  146. package/dist/src/ui/files.d.ts +3 -0
  147. package/dist/src/ui/files.js +20 -0
  148. package/dist/src/ui/http.d.ts +14 -0
  149. package/dist/src/ui/http.js +80 -0
  150. package/dist/src/ui/live.d.ts +5 -0
  151. package/dist/src/ui/live.js +43 -0
  152. package/dist/src/ui/markdown.d.ts +2 -0
  153. package/dist/src/ui/markdown.js +127 -0
  154. package/dist/src/ui/options.d.ts +48 -0
  155. package/dist/src/ui/options.js +81 -0
  156. package/dist/src/ui/security.d.ts +34 -0
  157. package/dist/src/ui/security.js +38 -0
  158. package/dist/src/ui/server.d.ts +39 -0
  159. package/dist/src/ui/server.js +347 -0
  160. package/dist/src/ui/sse.d.ts +14 -0
  161. package/dist/src/ui/sse.js +281 -0
  162. package/dist/src/ui/views/board.d.ts +7 -0
  163. package/dist/src/ui/views/board.js +131 -0
  164. package/dist/src/ui/views/changes.d.ts +19 -0
  165. package/dist/src/ui/views/changes.js +154 -0
  166. package/dist/src/ui/views/components.d.ts +56 -0
  167. package/dist/src/ui/views/components.js +113 -0
  168. package/dist/src/ui/views/docs.d.ts +15 -0
  169. package/dist/src/ui/views/docs.js +64 -0
  170. package/dist/src/ui/views/layout.d.ts +44 -0
  171. package/dist/src/ui/views/layout.js +233 -0
  172. package/dist/src/ui/views/task.d.ts +25 -0
  173. package/dist/src/ui/views/task.js +320 -0
  174. package/dist/src/upgrade-board.js +10 -10
  175. package/dist/src/upgrade.d.ts +0 -1
  176. package/dist/src/upgrade.js +24 -85
  177. package/dist/src/workflow.d.ts +4 -0
  178. package/dist/src/workflow.js +36 -4
  179. package/docs/README.md +41 -22
  180. package/docs/architecture.md +101 -37
  181. package/docs/command-reference.md +289 -52
  182. package/docs/configuration.md +70 -30
  183. package/docs/coordination-service.md +91 -30
  184. package/docs/design-decisions.md +87 -0
  185. package/docs/development.md +124 -49
  186. package/docs/loop-local-run.md +228 -0
  187. package/docs/loop-runbook.md +214 -0
  188. package/docs/operating-workflow.md +55 -42
  189. package/docs/testing-and-release.md +72 -49
  190. package/docs/troubleshooting.md +221 -0
  191. package/package.json +4 -1
  192. package/dist/src/ledger.d.ts +0 -22
  193. package/dist/src/ledger.js +0 -119
  194. package/dist/src/state/shared.d.ts +0 -29
  195. package/dist/src/state/shared.js +0 -71
  196. package/dist/src/tracker.d.ts +0 -36
  197. package/dist/src/tracker.js +0 -44
  198. /package/dist/src/state/{archive.d.ts → migrate.d.ts} +0 -0
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Loop authority adapters: one interface the shared loop daemon, intake, and
3
+ * dashboard actions use for both `local` and `fleet` modes.
4
+ *
5
+ * mode "local" | "fleet"
6
+ * listActionable() loop-owned tasks whose nextAction is run_phase (oldest first)
7
+ * runTask({ taskId, signal, cancelSignal, onEvent })
8
+ * one supervised run until a gate, block, pause, completion or stop
9
+ * intake({ requirement, taskId?, title? }) create a loop-owned task without running it
10
+ * resumeTask(taskId) re-activate a paused task
11
+ * listTasks() / getTask(id) / gate(id) read models for the dashboard (fleet)
12
+ * approve({ taskId, source, reviewerToken }) fleet reviewer approval of the current snapshot
13
+ * feedback({ taskId, reason, source }) fleet human feedback that keeps the task at its gate
14
+ * (local dashboards call `state.mjs task status` directly)
15
+ *
16
+ * The adapters never approve a gate on their own and never fall back from one
17
+ * authority to the other. Fleet credentials are scoped: the reviewer token is
18
+ * stripped from every lead/worker environment and is passed only to the single
19
+ * `remote-state.mjs gate approve` child of `approve`.
20
+ */
21
+ import { execFileSync } from "node:child_process";
22
+ import { existsSync, lstatSync, readdirSync, readFileSync, rmdirSync, unlinkSync } from "node:fs";
23
+ import { join, resolve } from "node:path";
24
+ import { requireLocalAuthority } from "./authority.mjs";
25
+ import { isLoopProvider } from "./loop-drivers.mjs";
26
+ import { deriveTaskIdentity, prepareLoopIntake } from "./loop-intake.mjs";
27
+ import { readLoopRecord, validateLimits } from "./loop-runtime.mjs";
28
+ import { createIsolatedLocalSupervisor } from "./loop-supervisor-run-local.mjs";
29
+ import { explainFleetConflict } from "./loop-holder.mjs";
30
+ import { loadState, loadTask, nextAction } from "./store.mjs";
31
+ import { resolveTaskRuntimeRead, taskRuntimeDir } from "./task-runtime.mjs";
32
+
33
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
34
+ const byUpdatedAt = (a, b) => String(a.updatedAt ?? "").localeCompare(String(b.updatedAt ?? "")) || a.id.localeCompare(b.id);
35
+ const tasksDir = (root) => join(root, ".agents", "data", "tasks");
36
+
37
+ /** The intake record written by `prepareLoopIntake`, or null. */
38
+ export function readIntakeRecord(root, taskId) {
39
+ if (!ID.test(taskId ?? "")) return null;
40
+ const path = resolveTaskRuntimeRead(root, taskId, "intake.json");
41
+ try { if (!lstatSync(path).isFile()) return null; return JSON.parse(readFileSync(path, "utf8")); }
42
+ catch (error) { if (error?.code === "ENOENT") return null; throw error; }
43
+ }
44
+
45
+ const stateError = (error) => {
46
+ const stderr = String(error?.stderr ?? "").trim();
47
+ try { const parsed = JSON.parse(stderr); if (parsed?.error?.message) return new Error(explainFleetConflict(parsed.error.message)); } catch { /* not JSON */ }
48
+ return stderr ? new Error(explainFleetConflict(stderr.split("\n").filter(Boolean).at(-1))) : error;
49
+ };
50
+
51
+ /** Pick a unique, case-insensitive task id for an intake that did not name one. */
52
+ const uniqueTaskId = (root, slug, takenIds) => {
53
+ const dirs = existsSync(tasksDir(root)) ? readdirSync(tasksDir(root)) : [];
54
+ const existing = new Set([...takenIds, ...dirs].map((id) => id.toLowerCase()));
55
+ const base = slug.slice(0, 120).replace(/-+$/, "") || "loop-task";
56
+ let taskId = slug;
57
+ for (let suffix = 2; existing.has(taskId.toLowerCase()); suffix += 1) taskId = `${base}-${suffix}`;
58
+ return taskId;
59
+ };
60
+
61
+ /** Run intake and roll back the intake files this call created when no task came into being. */
62
+ const guardedIntake = async (root, taskId, readTask, body) => {
63
+ const files = [resolveTaskRuntimeRead(root, taskId, "intake.json"), resolveTaskRuntimeRead(root, taskId, "requirement.md")];
64
+ const preexisting = files.map((path) => existsSync(path));
65
+ const runtimeExisted = existsSync(taskRuntimeDir(root, taskId));
66
+ try { return await body(); }
67
+ catch (error) {
68
+ if (!(await readTask(taskId))) {
69
+ files.forEach((path, index) => { if (!preexisting[index]) { try { unlinkSync(path); } catch (cleanupError) { if (cleanupError?.code !== "ENOENT") throw cleanupError; } } });
70
+ if (!runtimeExisted) { try { rmdirSync(taskRuntimeDir(root, taskId)); } catch { /* not empty or already gone */ } }
71
+ try { rmdirSync(join(tasksDir(root), taskId)); } catch { /* not empty or already gone */ }
72
+ }
73
+ throw error;
74
+ }
75
+ };
76
+
77
+ const validRequirement = (requirement) => {
78
+ if (typeof requirement !== "string" || !requirement.trim() || Buffer.byteLength(requirement) > 8192) throw new Error("Requirement must contain 1–8192 bytes");
79
+ };
80
+
81
+ /* ---------------------------------------------------------------- local */
82
+
83
+ /** A task is loop-owned locally when it has an intake record or a loop run record. */
84
+ export function isLocalLoopOwned(root, taskId) {
85
+ if (!ID.test(taskId ?? "")) return false;
86
+ const record = readIntakeRecord(root, taskId);
87
+ if (record) return record.authority !== "fleet";
88
+ return readLoopRecord(root, taskId) !== null;
89
+ }
90
+
91
+ export function createLocalAuthority({ root, agent, workers = {}, workerImages = {}, limits, pollMs, supervisor = createIsolatedLocalSupervisor } = {}) {
92
+ requireLocalAuthority();
93
+ const stateScript = join(root, ".agents/aidlc/scripts/state.mjs");
94
+ const owned = (id) => { try { return isLocalLoopOwned(root, id); } catch { return true; } };
95
+ return {
96
+ mode: "local",
97
+ isOwned: owned,
98
+ async listActionable() {
99
+ return Object.values(loadState(root).tasks).filter((task) => nextAction(task, root).classification === "run_phase" && owned(task.id)).sort(byUpdatedAt);
100
+ },
101
+ async runTask({ taskId, signal, cancelSignal, onEvent }) {
102
+ const run = supervisor({ root, taskId, agent, workers, workerImages, limits, pollMs, signal, cancelSignal, untilStop: true, onEvent });
103
+ return readLoopRecord(root, taskId) ? run.resume() : run.start();
104
+ },
105
+ /** Interactive work keeps priority: intake refuses while a non-loop task is actionable. */
106
+ async intake({ requirement, taskId: requested, title: requestedTitle }) {
107
+ validRequirement(requirement);
108
+ const identity = deriveTaskIdentity(requirement);
109
+ const state = loadState(root);
110
+ const taskId = requested || uniqueTaskId(root, identity.slug, [...Object.keys(state.tasks), ...Object.keys(state.archive ?? {})]);
111
+ if (!ID.test(taskId)) throw new Error("Valid task ID required");
112
+ const title = requestedTitle || identity.title;
113
+ const actionableIds = (current) => Object.values(current.tasks).filter((task) => task.id !== taskId && nextAction(task, root).classification === "run_phase").map((task) => task.id).sort();
114
+ const interactive = actionableIds(state).filter((id) => !owned(id));
115
+ if (interactive.length) throw new Error(`Task create is blocked while interactive work is actionable: ${interactive.join(", ")}. Finish or pause it before starting loop work`);
116
+ const readTask = async (id) => loadTask(root, id);
117
+ await guardedIntake(root, taskId, readTask, () => prepareLoopIntake({ root, taskId, requirement, title, authority: "local", readTask, createTask: async ({ taskId: id, title: taskTitle }) => {
118
+ const loopOwned = actionableIds(loadState(root)).filter((other) => owned(other));
119
+ try { execFileSync(process.execPath, [stateScript, "task", "create", id, "--title", taskTitle, ...(loopOwned.length ? ["--switch-from", loopOwned.join(",")] : []), "--root", root], { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" }); }
120
+ catch (error) { throw stateError(error); }
121
+ } }));
122
+ return { ok: true, taskId, title, authority: "local" };
123
+ },
124
+ async resumeTask(taskId) {
125
+ const task = loadTask(root, taskId);
126
+ if (!task) throw new Error(`Unknown task: ${taskId}`);
127
+ if (task.status !== "paused" || task.handoff) throw new Error(`Only a paused task without a handoff can be resumed (status: ${task.status})`);
128
+ try { return JSON.parse(execFileSync(process.execPath, [stateScript, "task", "status", taskId, "--status", "active", "--root", root], { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" })); }
129
+ catch (error) { throw stateError(error); }
130
+ }
131
+ };
132
+ }
133
+
134
+ /* ---------------------------------------------------------------- fleet */
135
+
136
+ const FLEET_REQUIRED = ["AIDLC_FLEET_URL", "AIDLC_FLEET_WORKSPACE", "AIDLC_FLEET_TOKEN", "AIDLC_FLEET_INTEGRATION_BRANCH"];
137
+ /** Credentials that must never reach a lead or worker process. */
138
+ const REVIEWER_SECRETS = ["AIDLC_FLEET_REVIEWER_TOKEN", "AIDLC_UI_REVIEWER_TOKEN_FILE"];
139
+ const LIMIT_ENV = { maxTurns: "AIDLC_FLEET_MAX_TURNS", turnTimeoutMs: "AIDLC_FLEET_TURN_TIMEOUT_MS", maxStallTurns: "AIDLC_FLEET_MAX_STALL_TURNS" };
140
+
141
+ /** Build the lead environment for fleet runs: validated service settings, the
142
+ * configured roster, `loop.limits`/`loop.pollMs` as defaults (explicit
143
+ * `AIDLC_FLEET_*` env wins), and no reviewer credentials. */
144
+ export function fleetLeadEnv({ root, agent, workers, limits, pollMs, env = process.env }) {
145
+ for (const name of FLEET_REQUIRED) if (typeof env[name] !== "string" || !env[name].trim()) throw new Error(`Fleet loop requires ${name} for the configured remote authority`);
146
+ if (env.AIDLC_FLEET_PROJECT_ROOT && resolve(env.AIDLC_FLEET_PROJECT_ROOT) !== root) throw new Error("AIDLC_FLEET_PROJECT_ROOT does not match the loop root; refusing to run");
147
+ const workerIds = Object.keys(workers).join(",");
148
+ if (env.AIDLC_FLEET_WORKER_IDS && env.AIDLC_FLEET_WORKER_IDS.split(",").map((value) => value.trim()).filter(Boolean).join(",") !== workerIds) throw new Error("AIDLC_FLEET_WORKER_IDS does not match the configured loop workers");
149
+ if (env.AIDLC_FLEET_WORKER_PROVIDERS) {
150
+ // Compare content, not the JSON string: key order and spacing do not matter.
151
+ let mapping; try { mapping = JSON.parse(env.AIDLC_FLEET_WORKER_PROVIDERS); } catch { mapping = null; }
152
+ const same = mapping && typeof mapping === "object" && !Array.isArray(mapping) && Object.keys(mapping).length === Object.keys(workers).length && Object.entries(workers).every(([id, provider]) => mapping[id] === provider);
153
+ if (!same) throw new Error("AIDLC_FLEET_WORKER_PROVIDERS does not match the configured loop workers");
154
+ }
155
+ const lead = { ...env, AIDLC_FLEET_PROJECT_ROOT: root, AIDLC_FLEET_WORKER_IDS: workerIds, AIDLC_FLEET_WORKER_PROVIDERS: JSON.stringify(workers), AIDLC_FLEET_AGENT: agent };
156
+ for (const name of REVIEWER_SECRETS) delete lead[name];
157
+ for (const [key, name] of Object.entries(LIMIT_ENV)) if (limits?.[key] !== undefined && lead[name] === undefined) lead[name] = String(limits[key]);
158
+ if (pollMs !== undefined && lead.AIDLC_FLEET_POLL_MS === undefined) lead.AIDLC_FLEET_POLL_MS = String(pollMs);
159
+ return lead;
160
+ }
161
+
162
+ export async function createFleetAuthority({ root, agent, workers = {}, limits, pollMs, env = process.env, callTool: callToolOverride, runCli } = {}) {
163
+ if (!isLoopProvider(agent)) throw new Error("Loop requires a configured lead and worker providers");
164
+ const leadEnv = fleetLeadEnv({ root, agent, workers, limits, pollMs, env });
165
+ const url = leadEnv.AIDLC_FLEET_URL, token = leadEnv.AIDLC_FLEET_TOKEN, workspace = leadEnv.AIDLC_FLEET_WORKSPACE;
166
+ const remoteState = join(root, ".agents/aidlc/scripts/remote-state.mjs");
167
+ if (!existsSync(remoteState)) throw new Error(`Remote lifecycle CLI is missing: ${remoteState}`);
168
+ const { callRemoteTool } = await import("./remote-state-client.mjs");
169
+ const { createFleetRemoteRepository } = await import("./loop-fleet-remote.mjs");
170
+ const callTool = callToolOverride ?? (({ url: serviceUrl, token: serviceToken, name, args }) => callRemoteTool({ url: serviceUrl, token: serviceToken }, name, args));
171
+ const repository = createFleetRemoteRepository({ callTool, url, token, workspace, root });
172
+ const fleetCli = runCli ?? (await import("./loop-fleet-cli.mjs")).runFleetCli;
173
+ const owned = (id) => readIntakeRecord(root, id)?.authority === "fleet";
174
+ /** Run one remote lifecycle command under a short lead claim (task create/status). */
175
+ const withLead = async (taskId, args) => {
176
+ const claim = await repository.claimLead({ taskId, leaseMs: 60000 });
177
+ try { return JSON.parse(execFileSync(process.execPath, [remoteState, ...args], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...leadEnv, AIDLC_FLEET_LEAD_EPOCH: String(claim.epoch), AIDLC_FLEET_LEAD_OWNER_TOKEN: repository.leadOwnerToken() } })); }
178
+ catch (error) { throw stateError(error); }
179
+ finally { await repository.releaseLead({ taskId, epoch: claim.epoch }); }
180
+ };
181
+ const readTask = async (id) => { try { return (await repository.readTask({ taskId: id }))?.task ?? null; } catch (error) { if (String(error?.message ?? error).includes("task not found")) return null; throw error; } };
182
+ return {
183
+ mode: "fleet",
184
+ repository,
185
+ isOwned: owned,
186
+ /** Fail closed before starting: the service must answer and the integration branch must exist on the remote. */
187
+ async preflight() {
188
+ await callTool({ url, token, name: "fleet_get", args: { workspace } });
189
+ const { readIntegrationCommit } = await import("../remote-state.mjs");
190
+ await readIntegrationCommit({ projectRoot: root, integrationBranch: leadEnv.AIDLC_FLEET_INTEGRATION_BRANCH, gitRemote: leadEnv.AIDLC_FLEET_GIT_REMOTE ?? "origin" });
191
+ },
192
+ async listActionable() {
193
+ return (await repository.listTasks()).filter((task) => task.status === "active" && !task.handoff && owned(task.id) && nextAction(task, root).classification === "run_phase").sort(byUpdatedAt);
194
+ },
195
+ /** Bounded supervised run: returns at a gate (`gate`/`waiting`) instead of waiting for the reviewer. */
196
+ async runTask({ taskId, signal, cancelSignal, onEvent }) {
197
+ const result = await fleetCli(["supervisor", taskId], { callTool, env: leadEnv, signal, cancelSignal, untilStop: true, onEvent });
198
+ // The fleet supervisor already emitted its `gate` event through onEvent.
199
+ return result && typeof result === "object" ? { ...result, gateEmitted: true } : result;
200
+ },
201
+ async intake({ requirement, taskId: requested, title: requestedTitle }) {
202
+ validRequirement(requirement);
203
+ const identity = deriveTaskIdentity(requirement);
204
+ const taskId = requested || uniqueTaskId(root, identity.slug, (await repository.listTasks()).map((task) => task.id));
205
+ if (!ID.test(taskId)) throw new Error("Valid task ID required");
206
+ const title = requestedTitle || identity.title;
207
+ await guardedIntake(root, taskId, readTask, () => prepareLoopIntake({ root, taskId, requirement, title, authority: "fleet", readTask, createTask: async ({ taskId: id, title: taskTitle }) => { await withLead(id, ["task", "create", id, "--title", taskTitle]); } }));
208
+ return { ok: true, taskId, title, authority: "fleet" };
209
+ },
210
+ async resumeTask(taskId) {
211
+ const task = await readTask(taskId);
212
+ if (!task) throw new Error(`Unknown task: ${taskId}`);
213
+ if (task.status !== "paused" || task.handoff) throw new Error(`Only a paused task without a handoff can be resumed (status: ${task.status})`);
214
+ return withLead(taskId, ["task", "status", taskId, "--status", "active"]);
215
+ },
216
+ async listTasks() { return (await repository.listTasks()).map((task) => ({ ...task, loopOwned: owned(task.id) })); },
217
+ async getTask(taskId) { const snapshot = await repository.readTask({ taskId }); return { task: snapshot?.task ?? null, nextAction: snapshot?.nextAction ?? null, revision: snapshot?.revision ?? null }; },
218
+ /** The current review snapshot for a waiting task (lead-readable inbox + checkpoint). */
219
+ async gate(taskId) {
220
+ const task = await readTask(taskId);
221
+ if (!task) throw new Error(`Unknown task: ${taskId}`);
222
+ const checkpoint = await repository.readGateCheckpoint({ taskId });
223
+ const inbox = await callTool({ url, token, name: "gate_inbox_get", args: { workspace } });
224
+ const snapshot = Array.isArray(inbox) ? inbox.find((entry) => entry.taskId === taskId && entry.gate === task.gate && (!checkpoint?.snapshotId || entry.id === checkpoint.snapshotId)) ?? null : null;
225
+ return { taskId, status: task.status, gate: task.gate, checkpoint, snapshot };
226
+ },
227
+ /** Reviewer approval of the current snapshot. The reviewer token reaches only this child process. */
228
+ async approve({ taskId, source, reviewerToken }) {
229
+ if (typeof reviewerToken !== "string" || reviewerToken.length < 16) throw new Error("Fleet gate approval requires the reviewer credential (AIDLC_FLEET_REVIEWER_TOKEN)");
230
+ if (typeof source !== "string" || !source.trim()) throw new Error("Approval source is required");
231
+ const { snapshot, status } = await this.gate(taskId);
232
+ if (status !== "blocked_on_user" || !snapshot) throw new Error("Task has no current gate snapshot to approve");
233
+ try { return JSON.parse(execFileSync(process.execPath, [remoteState, "gate", "approve", taskId, "--snapshot", snapshot.id, "--source", source], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...leadEnv, AIDLC_FLEET_REVIEWER_TOKEN: reviewerToken } })); }
234
+ catch (error) { throw stateError(error); }
235
+ },
236
+ /** Human feedback at a gate: records the `Cancelled gate wait` evidence and re-activates the task
237
+ * so the loop revises and re-presents the same gate. Runs as the lead; never needs the reviewer credential. */
238
+ async feedback({ taskId, reason, source }) {
239
+ if (typeof reason !== "string" || !reason.trim()) throw new Error("Feedback reason is required");
240
+ if (typeof source !== "string" || !source.trim()) throw new Error("Feedback source is required");
241
+ const task = await readTask(taskId);
242
+ if (!task) throw new Error(`Unknown task: ${taskId}`);
243
+ if (task.status !== "blocked_on_user" || task.gate === "none") throw new Error("Task is not waiting at a human gate");
244
+ return withLead(taskId, ["task", "status", taskId, "--status", "active", "--mode", "audited", "--reason", reason, "--source", source]);
245
+ }
246
+ };
247
+ }
248
+
249
+ /** Create the authority for the configured loop mode; never falls back to the other mode. */
250
+ export async function createLoopAuthority({ root, config, env = process.env, ...overrides }) {
251
+ const mode = config?.loop?.mode;
252
+ const agent = config?.loop?.agents?.lead;
253
+ const workers = config?.loop?.agents?.workers ?? {};
254
+ // Fail fast on a bad roster or `loop.limits` instead of failing every task run.
255
+ if (!isLoopProvider(agent) || !workers || typeof workers !== "object" || Array.isArray(workers) || !Object.keys(workers).length || Object.entries(workers).some(([id, provider]) => !ID.test(id) || !isLoopProvider(provider))) throw new Error("Loop requires a configured lead and worker providers");
256
+ if (config?.loop?.limits !== undefined) validateLimits(config.loop.limits);
257
+ const options = { root, agent, workers, workerImages: config?.loop?.workerImage ?? {}, limits: config?.loop?.limits, pollMs: config?.loop?.pollMs, ...overrides };
258
+ if (mode === "local") return createLocalAuthority(options);
259
+ if (mode === "fleet") return createFleetAuthority({ ...options, env });
260
+ throw new Error("Loop mode is off; configure an enabled loop mode first");
261
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Conventional Commit message extraction for worker Git commits.
3
+ *
4
+ * The worker may author the commit message by ending its final answer with a
5
+ * dedicated line (`AI-DLC-COMMIT: type(scope): subject`) next to the existing
6
+ * `AI-DLC-ITEM-COMPLETE` completion marker. The supervisor parses and validates
7
+ * that message; when it is absent or malformed it derives a deterministic,
8
+ * conventional fallback from the item id and owned paths instead of reusing the
9
+ * legacy `AI-DLC <task>/<item>` form. This module is dependency-free like the
10
+ * rest of the installed loop runtime.
11
+ */
12
+
13
+ const COMMIT_LINE = /^AI-DLC-COMMIT:\s*(.+?)\s*$/m;
14
+ const TYPE = "(?:build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)";
15
+ const SCOPE = "\\([^()\\r\\n]{1,64}\\)";
16
+ const MESSAGE = new RegExp(`^${TYPE}(${SCOPE})?!?: [^\\r\\n]{1,200}$`);
17
+ const CONTROL = /[\u0000-\u001f\u007f]/;
18
+
19
+ function fallbackScope(ownedPaths) {
20
+ if (!Array.isArray(ownedPaths) || !ownedPaths.length) return "";
21
+ const topLevels = ownedPaths.map((path) => String(path).split("/")[0]);
22
+ const unique = [...new Set(topLevels)];
23
+ // Use a scope only when every owned path sits under one real top-level
24
+ // directory; a bare file (no "/") or a split across directories is ambiguous.
25
+ if (unique.length !== 1 || !ownedPaths.every((path) => String(path).includes("/"))) return "";
26
+ return unique[0];
27
+ }
28
+
29
+ /**
30
+ * Return a Conventional Commits message for a worker commit. When the worker's
31
+ * final answer carries a valid `AI-DLC-COMMIT:` line that message is used
32
+ * verbatim; otherwise a fallback `chore(<scope>): <item-id>` is derived.
33
+ */
34
+ export function extractCommitMessage(resultText, { itemId, ownedPaths = [] } = {}) {
35
+ if (typeof resultText === "string") {
36
+ const match = resultText.match(COMMIT_LINE);
37
+ if (match) {
38
+ const message = match[1].trim();
39
+ if (MESSAGE.test(message) && !CONTROL.test(message)) return message;
40
+ }
41
+ }
42
+ const scope = fallbackScope(ownedPaths);
43
+ const subject = typeof itemId === "string" && itemId.trim() ? itemId.trim() : "update";
44
+ return scope ? `chore(${scope}): ${subject}` : `chore: ${subject}`;
45
+ }
@@ -0,0 +1,206 @@
1
+ import { lstatSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { setTimeout as sleep } from "node:timers/promises";
4
+ import { requireLocalAuthority } from "./authority.mjs";
5
+ import { isLoopProvider, LOOP_PROVIDER_LIST } from "./loop-drivers.mjs";
6
+ import { acquireHolder, describeHolder, LEASE_TTL_MS, readHolder } from "./loop-holder.mjs";
7
+ import { validateLimits, validateRoot, readLoopRecord } from "./loop-runtime.mjs";
8
+ import { createIsolatedLocalSupervisor } from "./loop-supervisor-run-local.mjs";
9
+ import { loadState, nextAction } from "./store.mjs";
10
+ import { resolveTaskRuntimeRead } from "./task-runtime.mjs";
11
+ import { migrateAllTaskRuntime } from "./task-runtime-migrate.mjs";
12
+
13
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
14
+
15
+ export const DAEMON_POLL_MIN = 1000;
16
+ export const DAEMON_POLL_MAX = 300000;
17
+ export const DEFAULT_DAEMON_POLL_MS = 5000;
18
+
19
+ export function validateDaemonPollMs(value) {
20
+ const n = Number(value);
21
+ if (!Number.isSafeInteger(n) || n < DAEMON_POLL_MIN || n > DAEMON_POLL_MAX) throw new Error(`daemon pollMs must be an integer from ${DAEMON_POLL_MIN} to ${DAEMON_POLL_MAX}`);
22
+ return n;
23
+ }
24
+
25
+ /** A task is loop-owned when it was created through loop intake (CLI or web
26
+ * dashboard: `.agents/data/tasks/<id>/runtime/intake.json`) or already has a loop
27
+ * run record. Tasks created by an interactive lead are not loop-owned, so the
28
+ * daemon never runs them concurrently with that lead. */
29
+ export function isLoopOwned(root, taskId) {
30
+ if (!ID.test(taskId ?? "")) return false;
31
+ try { if (lstatSync(resolveTaskRuntimeRead(root, taskId, "intake.json")).isFile()) return true; }
32
+ catch (error) { if (error?.code !== "ENOENT") throw error; }
33
+ return readLoopRecord(root, taskId) !== null;
34
+ }
35
+
36
+ /** Path of the singleton daemon lock (one local daemon per project root). */
37
+ export const daemonLockPath = (root) => join(root, ".agents", "data", "loop", "daemon.lock");
38
+
39
+ /** Read the daemon lease: `null` when absent, otherwise `{ pid, startedAt,
40
+ * alive, state, holder }` where `state` is live|stale|corrupt (see
41
+ * `loop-holder.mjs`). `pid`/`startedAt` stay top-level for the web UI. */
42
+ export function readDaemonLock(root) {
43
+ const entry = readHolder(daemonLockPath(root), { ttlMs: LEASE_TTL_MS });
44
+ if (entry.state === "absent") return null;
45
+ if (entry.state === "corrupt") return { pid: null, startedAt: null, alive: false, state: "corrupt", holder: null };
46
+ const { holder } = entry;
47
+ return { pid: holder.pid, startedAt: holder.startedAt ?? null, alive: entry.state === "live", state: entry.state, holder };
48
+ }
49
+
50
+ /** Acquire the singleton daemon lease (`loop/daemon.lock`, kind lease, scope
51
+ * daemon, 60s TTL renewed every 15s). A stale holder (expired, or its pid is
52
+ * gone on this host) or an old corrupt file is reclaimed under the holder
53
+ * guard; a live owner or a recent corrupt file fails closed with the holder
54
+ * details and the `leases` command. Returns a release function. */
55
+ export function acquireDaemonLock(root, { onWarning = () => {}, onLost } = {}) {
56
+ const path = daemonLockPath(root);
57
+ const handle = acquireHolder({
58
+ path, kind: "lease", scope: "daemon", purpose: "loop daemon (one per project root)", onWarning, onLost,
59
+ busyMessage: (entry) => `Loop daemon already running (pid ${entry.holder.pid}): ${describeHolder(entry)}`,
60
+ });
61
+ return () => handle.release();
62
+ }
63
+
64
+ /** Enumerate actionable, loop-owned tasks from canonical state in a
65
+ * deterministic order: every task whose derived nextAction classification is
66
+ * `run_phase` and that `isOwned` accepts, sorted by updatedAt then id so a task
67
+ * that just ran moves to the back of the queue. A loop record that cannot be
68
+ * read counts as owned so its error surfaces through the turn (and backoff)
69
+ * instead of being silently skipped. */
70
+ export function actionableTasks(state, root, { isOwned = (id) => isLoopOwned(root, id) } = {}) {
71
+ const owned = (id) => { try { return isOwned(id); } catch { return true; } };
72
+ return Object.values(state.tasks)
73
+ .filter((task) => nextAction(task, root).classification === "run_phase" && owned(task.id))
74
+ .sort((a, b) => String(a.updatedAt ?? "").localeCompare(String(b.updatedAt ?? "")) || a.id.localeCompare(b.id));
75
+ }
76
+
77
+ /** Move pre-runtime per-task files under `runtime/` once at daemon start; one
78
+ * diagnostic summarises the result, nothing when there was nothing to move. */
79
+ function migrateTaskRuntimeOnStart(root, onEvent) {
80
+ let result;
81
+ try { result = migrateAllTaskRuntime(root); }
82
+ catch (error) { onEvent({ type: "diagnostic", message: `Task runtime migration failed: ${error.message}` }); return; }
83
+ if (result.moved || result.skipped) onEvent({ type: "diagnostic", message: `Task runtime migration: moved ${result.moved} file(s) under .agents/data/tasks/<id>/runtime/, skipped ${result.skipped} (run loop-entry.mjs migrate-data --dry-run --json for details)` });
84
+ }
85
+
86
+ export const DAEMON_BACKOFF_MAX_MS = 600000;
87
+
88
+ /** Exponential retry delay for a task whose turn keeps throwing: the poll
89
+ * interval doubled per consecutive failure, capped at 10 minutes. */
90
+ export function backoffDelay(pollMs, failures) {
91
+ if (failures < 1) return 0;
92
+ return Math.min(pollMs * 2 ** Math.min(failures - 1, 30), DAEMON_BACKOFF_MAX_MS);
93
+ }
94
+
95
+ /** Run one supervised lead run for an existing task (no requirement intake):
96
+ * a single runLoop invocation that continues until the next gate, block,
97
+ * pause, completion, or stop — bounded by the per-run `maxTurns`. A task with a
98
+ * loop run record is resumed; a freshly intaken task has none and starts. */
99
+ async function runTaskTurn({ root, taskId, agent, workers, workerImages, limits, pollMs, signal, cancelSignal, onEvent }) {
100
+ const supervisor = createIsolatedLocalSupervisor({ root, taskId, agent, workers, workerImages, limits, pollMs, signal, cancelSignal, untilStop: true, onEvent });
101
+ const record = readLoopRecord(root, taskId);
102
+ return record ? supervisor.resume() : supervisor.start();
103
+ }
104
+
105
+ /**
106
+ * Local multi-task daemon: a foreground long-running loop (one per project,
107
+ * enforced by the singleton lock) that polls canonical state and, for every
108
+ * actionable loop-owned task, runs one supervised lead run via the isolated
109
+ * local supervisor. Tasks are served one at a time in updatedAt order, so a
110
+ * long run delays the others until it reaches its next stop. A task that
111
+ * reaches a gate, blocks, completes, or terminates stops being actionable and
112
+ * is skipped on the next poll; a task whose run throws is retried with
113
+ * exponential backoff. Gate approval stays human-only. `signal` stops after the
114
+ * current turn; `cancelSignal` also cancels the in-flight agent turn.
115
+ */
116
+ /**
117
+ * Shared loop daemon (local and fleet): the same singleton lock, round-robin,
118
+ * backoff, two-stage shutdown, and taskId-stamped events over a loop authority
119
+ * from `loop-authority.mjs` (`listActionable` + `runTask`). The authority alone
120
+ * decides which tasks are loop-owned and how one bounded run executes.
121
+ */
122
+ export async function runLoopDaemon({ root: rootInput, authority, pollMs = DEFAULT_DAEMON_POLL_MS, signal, cancelSignal, onEvent = () => {}, now = Date.now } = {}) {
123
+ const root = validateRoot(rootInput);
124
+ if (!authority || typeof authority.listActionable !== "function" || typeof authority.runTask !== "function") throw new Error("Loop daemon requires a loop authority");
125
+ const poll = validateDaemonPollMs(pollMs);
126
+ const lost = new AbortController();
127
+ const releaseLock = acquireDaemonLock(root, { onWarning: onEvent, onLost: (reason) => { onEvent({ type: "daemon_lease_lost", message: `Daemon lease lost; stopping after the current turn: ${reason}` }); lost.abort(); } });
128
+ try {
129
+ onEvent({ type: "daemon_start", authority: authority.mode ?? "unknown", pid: process.pid });
130
+ migrateTaskRuntimeOnStart(root, onEvent);
131
+ return await daemonLoop({ root, poll, signal: signal ? AbortSignal.any([signal, lost.signal]) : lost.signal, cancelSignal, onEvent, now, listActionable: () => authority.listActionable(), turn: ({ taskId, signal: runSignal, cancelSignal: runCancel, onEvent: runEvent }) => authority.runTask({ taskId, signal: runSignal, cancelSignal: runCancel, onEvent: runEvent }) });
132
+ } finally { releaseLock(); }
133
+ }
134
+
135
+ export async function runLocalDaemon({ root: rootInput, agent, workers = {}, workerImages = {}, limits, pollMs = DEFAULT_DAEMON_POLL_MS, signal, cancelSignal, onEvent = () => {}, now = Date.now } = {}) {
136
+ requireLocalAuthority();
137
+ const root = validateRoot(rootInput);
138
+ if (!isLoopProvider(agent)) throw new Error(`local lead agent must be ${LOOP_PROVIDER_LIST}`);
139
+ if (!workers || typeof workers !== "object" || Array.isArray(workers) || !Object.keys(workers).length) throw new Error("Loop requires a configured lead and worker providers");
140
+ for (const [id, provider] of Object.entries(workers)) if (!ID.test(id) || !isLoopProvider(provider)) throw new Error(`invalid local worker: ${id}`);
141
+ const poll = validateDaemonPollMs(pollMs);
142
+ // Fail fast on a bad config.loop.limits instead of failing every task turn.
143
+ if (limits !== undefined) validateLimits(limits);
144
+ const lost = new AbortController();
145
+ const releaseLock = acquireDaemonLock(root, { onWarning: onEvent, onLost: (reason) => { onEvent({ type: "daemon_lease_lost", message: `Daemon lease lost; stopping after the current turn: ${reason}` }); lost.abort(); } });
146
+ try {
147
+ migrateTaskRuntimeOnStart(root, onEvent);
148
+ return await daemonLoop({ root, agent, workers, workerImages, limits, poll, signal: signal ? AbortSignal.any([signal, lost.signal]) : lost.signal, cancelSignal, onEvent, now, turn: runTaskTurn });
149
+ } finally { releaseLock(); }
150
+ }
151
+
152
+ /** Run results that leave the task actionable without progress; retried with backoff like errors. */
153
+ const RETRY_STATUSES = new Set(["blocked", "reconcile"]);
154
+
155
+ /** Poll/run loop. Exported for tests (inject `turn`, `now`, and optionally `listActionable`). */
156
+ export async function daemonLoop({ root, agent, workers, workerImages, limits, poll, signal, cancelSignal, onEvent, now = Date.now, turn = runTaskTurn, isOwned, listActionable }) {
157
+ const backoff = new Map();
158
+ let lastPollKey = null; // null forces the next successful poll to be emitted
159
+ for (;;) {
160
+ if (signal?.aborted) return { status: "stopped" };
161
+ let actionable;
162
+ try { actionable = listActionable ? await listActionable() : actionableTasks(loadState(root), root, isOwned ? { isOwned } : {}); }
163
+ catch (error) {
164
+ lastPollKey = null;
165
+ // An unreachable authority (e.g. the fleet service) must not stop the daemon.
166
+ onEvent({ type: "daemon_poll_error", message: error instanceof Error ? error.message : String(error) });
167
+ try { await sleep(poll, undefined, { signal }); } catch (sleepError) { if (sleepError?.name === "AbortError") return { status: "stopped" }; throw sleepError; }
168
+ continue;
169
+ }
170
+ // Forget backoff for tasks that are no longer actionable (e.g. a human
171
+ // paused or approved them), so they start fresh when they come back.
172
+ for (const id of [...backoff.keys()]) if (!actionable.some((task) => task.id === id)) backoff.delete(id);
173
+ // Emit a poll only when it carries news: the first poll, the first poll
174
+ // after a poll error (that clears the dashboard's poll error), or a change
175
+ // in the actionable or backing-off sets (that clears stale turn errors).
176
+ const pollKey = JSON.stringify([actionable.map((task) => task.id).sort(), [...backoff.keys()].sort()]);
177
+ if (pollKey !== lastPollKey) {
178
+ onEvent({ type: "daemon_poll", actionable: actionable.map((task) => task.id), ...(backoff.size ? { backingOff: [...backoff.keys()] } : {}) });
179
+ lastPollKey = pollKey;
180
+ }
181
+ for (const task of actionable) {
182
+ if (signal?.aborted) return { status: "stopped" };
183
+ const delay = backoff.get(task.id);
184
+ if (delay && now() < delay.nextAt) continue;
185
+ // Stamp every event of this run with its task id so the UI can filter.
186
+ const taskEvent = (event) => onEvent(event && typeof event === "object" && !event.taskId ? { ...event, taskId: task.id } : event);
187
+ try {
188
+ const result = await turn({ root, taskId: task.id, agent, workers, workerImages, limits, pollMs: poll, signal, cancelSignal, onEvent: taskEvent });
189
+ if (result?.status === "gate" && !result.gateEmitted) onEvent({ type: "gate", taskId: task.id, gate: result.gate, gateView: result.gateView });
190
+ onEvent({ type: "daemon_turn", taskId: task.id, status: result?.status ?? "unknown", ...(result?.gate ? { gate: result.gate } : {}), ...(result?.reason ? { reason: result.reason } : {}) });
191
+ if (RETRY_STATUSES.has(result?.status)) {
192
+ const failures = (backoff.get(task.id)?.failures ?? 0) + 1;
193
+ backoff.set(task.id, { failures, nextAt: now() + backoffDelay(poll, failures) });
194
+ } else backoff.delete(task.id);
195
+ } catch (error) {
196
+ // A failing task must not take the daemon down; surface it, back off, move on.
197
+ const failures = (backoff.get(task.id)?.failures ?? 0) + 1;
198
+ const retryInMs = backoffDelay(poll, failures);
199
+ backoff.set(task.id, { failures, nextAt: now() + retryInMs });
200
+ onEvent({ type: "daemon_turn_error", taskId: task.id, message: error instanceof Error ? error.message : String(error), failures, retryInMs });
201
+ }
202
+ }
203
+ try { await sleep(poll, undefined, { signal }); }
204
+ catch (error) { if (error?.name === "AbortError") return { status: "stopped" }; throw error; }
205
+ }
206
+ }