@felixdotgo/aidlc-kit 0.1.1 → 0.2.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 (134) hide show
  1. package/DEVELOPMENT.md +14 -0
  2. package/README.md +11 -13
  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 +1 -1
  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/scripts/lib/loop-authority.mjs +245 -0
  11. package/dist/assets/.agents/aidlc/scripts/lib/loop-commit-message.mjs +45 -0
  12. package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +232 -0
  13. package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +15 -3
  14. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +86 -28
  15. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +7 -0
  16. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +22 -6
  17. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +40 -15
  18. package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +14 -6
  19. package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +23 -5
  20. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +42 -15
  21. package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +75 -5
  22. package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +60 -0
  23. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +142 -5
  24. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +9 -1
  25. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +31 -5
  26. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-image.mjs +64 -0
  27. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +57 -2
  28. package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +85 -73
  29. package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +19 -7
  30. package/dist/assets/.agents/aidlc/scripts/loop.mjs +2 -1
  31. package/dist/assets/.agents/aidlc/templates/design.md +9 -0
  32. package/dist/assets/.agents/aidlc/templates/intent.md +4 -0
  33. package/dist/assets/.agents/aidlc/templates/workplan.md +1 -1
  34. package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +1 -1
  35. package/dist/assets/ui/bulma.min.css +3 -0
  36. package/dist/assets/ui/dashboard.css +414 -0
  37. package/dist/services/aidlc-coordination/Dockerfile +11 -3
  38. package/dist/services/aidlc-coordination/compose.yaml +12 -0
  39. package/dist/services/aidlc-coordination/package-lock.json +3 -1
  40. package/dist/services/aidlc-coordination/package.json +9 -4
  41. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +57 -2
  42. package/dist/services/aidlc-coordination/src/repository.mjs +66 -0
  43. package/dist/services/aidlc-coordination/src/server.mjs +9 -3
  44. package/dist/services/aidlc-coordination/test/fleet.test.mjs +21 -0
  45. package/dist/services/aidlc-coordination/test/server-health-auth.test.mjs +67 -0
  46. package/dist/src/adapters.d.ts +0 -3
  47. package/dist/src/adapters.js +3 -5
  48. package/dist/src/cli/argv.d.ts +12 -0
  49. package/dist/src/cli/argv.js +62 -0
  50. package/dist/src/cli/commands.d.ts +8 -0
  51. package/dist/src/cli/commands.js +129 -0
  52. package/dist/src/cli/options.d.ts +25 -0
  53. package/dist/src/cli/options.js +32 -0
  54. package/dist/src/cli/prompt.d.ts +9 -0
  55. package/dist/src/cli/prompt.js +66 -0
  56. package/dist/src/cli.js +25 -191
  57. package/dist/src/gate.js +3 -2
  58. package/dist/src/installer.d.ts +14 -7
  59. package/dist/src/installer.js +38 -141
  60. package/dist/src/legacy.js +1 -0
  61. package/dist/src/managed-content.d.ts +7 -0
  62. package/dist/src/managed-content.js +15 -4
  63. package/dist/src/managed-planning.d.ts +65 -0
  64. package/dist/src/managed-planning.js +219 -0
  65. package/dist/src/mcp-setup.js +3 -1
  66. package/dist/src/model/config-contracts.d.ts +65 -0
  67. package/dist/src/model/config-contracts.js +1 -0
  68. package/dist/src/model/install-contracts.d.ts +29 -0
  69. package/dist/src/model/install-contracts.js +1 -0
  70. package/dist/src/model/lifecycle-enums.d.ts +13 -0
  71. package/dist/src/model/lifecycle-enums.js +1 -0
  72. package/dist/src/model/loop.d.ts +20 -0
  73. package/dist/src/model/loop.js +1 -0
  74. package/dist/src/model/manifest.d.ts +16 -0
  75. package/dist/src/model/manifest.js +1 -0
  76. package/dist/src/model/records.d.ts +138 -0
  77. package/dist/src/model/records.js +1 -0
  78. package/dist/src/model.d.ts +11 -264
  79. package/dist/src/model.js +1 -1
  80. package/dist/src/profiles.d.ts +2 -0
  81. package/dist/src/profiles.js +53 -9
  82. package/dist/src/state/archive-record.d.ts +9 -0
  83. package/dist/src/state/archive-record.js +26 -0
  84. package/dist/src/state/commands.d.ts +18 -0
  85. package/dist/src/state/commands.js +27 -0
  86. package/dist/src/state/domain.d.ts +9 -0
  87. package/dist/src/state/domain.js +9 -0
  88. package/dist/src/state/guards.d.ts +7 -0
  89. package/dist/src/state/guards.js +17 -0
  90. package/dist/src/state/io.d.ts +10 -0
  91. package/dist/src/state/io.js +35 -0
  92. package/dist/src/state/items.d.ts +3 -4
  93. package/dist/src/state/items.js +58 -57
  94. package/dist/src/state/lessons.js +14 -7
  95. package/dist/src/state/lifecycle.js +2 -2
  96. package/dist/src/state/lock.js +7 -6
  97. package/dist/src/state/memory.js +24 -12
  98. package/dist/src/state/{archive.js → migrate.js} +7 -7
  99. package/dist/src/state/partition.d.ts +15 -0
  100. package/dist/src/state/partition.js +90 -0
  101. package/dist/src/state/paths.d.ts +11 -0
  102. package/dist/src/state/paths.js +12 -0
  103. package/dist/src/state/render.js +9 -6
  104. package/dist/src/state/store.js +9 -15
  105. package/dist/src/state/transitions.js +9 -7
  106. package/dist/src/state/validate.js +50 -7
  107. package/dist/src/state.d.ts +16 -11
  108. package/dist/src/state.js +16 -11
  109. package/dist/src/task-ui.d.ts +54 -0
  110. package/dist/src/task-ui.js +1570 -0
  111. package/dist/src/upgrade-board.js +10 -10
  112. package/dist/src/upgrade.d.ts +0 -1
  113. package/dist/src/upgrade.js +19 -83
  114. package/dist/src/workflow.js +3 -3
  115. package/docs/README.md +6 -2
  116. package/docs/architecture.md +9 -8
  117. package/docs/command-reference.md +81 -20
  118. package/docs/configuration.md +28 -8
  119. package/docs/coordination-service.md +18 -6
  120. package/docs/design-decisions.md +83 -0
  121. package/docs/development.md +27 -2
  122. package/docs/loop-local-run.md +495 -0
  123. package/docs/loop-runbook.md +235 -0
  124. package/docs/operating-workflow.md +2 -2
  125. package/docs/testing-and-release.md +5 -1
  126. package/docs/troubleshooting.md +125 -0
  127. package/package.json +4 -1
  128. package/dist/src/ledger.d.ts +0 -22
  129. package/dist/src/ledger.js +0 -119
  130. package/dist/src/state/shared.d.ts +0 -29
  131. package/dist/src/state/shared.js +0 -71
  132. package/dist/src/tracker.d.ts +0 -36
  133. package/dist/src/tracker.js +0 -44
  134. /package/dist/src/state/{archive.d.ts → migrate.d.ts} +0 -0
@@ -0,0 +1,232 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { setTimeout as sleep } from "node:timers/promises";
5
+ import { requireLocalAuthority } from "./authority.mjs";
6
+ import { isLoopProvider, LOOP_PROVIDER_LIST } from "./loop-drivers.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
+
11
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
12
+
13
+ export const DAEMON_POLL_MIN = 1000;
14
+ export const DAEMON_POLL_MAX = 300000;
15
+ export const DEFAULT_DAEMON_POLL_MS = 5000;
16
+
17
+ export function validateDaemonPollMs(value) {
18
+ const n = Number(value);
19
+ 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}`);
20
+ return n;
21
+ }
22
+
23
+ /** A task is loop-owned when it was created through loop intake (CLI or web
24
+ * dashboard: `.agents/data/tasks/<id>/loop-intake.json`) or already has a loop
25
+ * run record. Tasks created by an interactive lead are not loop-owned, so the
26
+ * daemon never runs them concurrently with that lead. */
27
+ export function isLoopOwned(root, taskId) {
28
+ if (!ID.test(taskId ?? "")) return false;
29
+ try { if (lstatSync(join(root, ".agents", "data", "tasks", taskId, "loop-intake.json")).isFile()) return true; }
30
+ catch (error) { if (error?.code !== "ENOENT") throw error; }
31
+ return readLoopRecord(root, taskId) !== null;
32
+ }
33
+
34
+ /** Path of the singleton daemon lock (one local daemon per project root). */
35
+ export const daemonLockPath = (root) => join(root, ".agents", "data", "loop", "daemon.lock");
36
+
37
+ const pidAlive = (pid) => {
38
+ try { process.kill(pid, 0); return true; }
39
+ catch (error) { return error?.code !== "ESRCH"; }
40
+ };
41
+
42
+ const parseDaemonLock = (path) => {
43
+ let value;
44
+ try { value = JSON.parse(readFileSync(path, "utf8")); }
45
+ catch (error) { if (error?.code === "ENOENT") return null; return { corrupt: true }; }
46
+ if (!value || value.schemaVersion !== 1 || !Number.isSafeInteger(value.pid) || value.pid < 1 || typeof value.token !== "string" || !value.token) return { corrupt: true };
47
+ return value;
48
+ };
49
+
50
+ /** Read the daemon lock: `null` when absent, otherwise `{ pid, startedAt, alive }`. */
51
+ export function readDaemonLock(root) {
52
+ const lock = parseDaemonLock(daemonLockPath(root));
53
+ if (!lock) return null;
54
+ if (lock.corrupt) return { pid: null, startedAt: null, alive: false };
55
+ return { pid: lock.pid, startedAt: lock.startedAt ?? null, alive: pidAlive(lock.pid) };
56
+ }
57
+
58
+ /** Run `action` under a short-lived exclusive guard file so two starters
59
+ * cannot both reclaim the same stale lock (one would delete the other's fresh
60
+ * lock). A guard older than 10s is treated as abandoned. */
61
+ function withReclaimGuard(path, action) {
62
+ const guard = `${path}.guard`;
63
+ const deadline = Date.now() + 5000;
64
+ for (;;) {
65
+ try { writeFileSync(guard, String(process.pid), { flag: "wx", mode: 0o600 }); break; }
66
+ catch (error) {
67
+ if (error?.code !== "EEXIST") throw error;
68
+ try { if (Date.now() - lstatSync(guard).mtimeMs > 10000) { unlinkSync(guard); continue; } }
69
+ catch (statError) { if (statError?.code === "ENOENT") continue; throw statError; }
70
+ if (Date.now() >= deadline) throw new Error("Timed out waiting for the loop daemon lock guard");
71
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
72
+ }
73
+ }
74
+ try { return action(); }
75
+ finally { try { unlinkSync(guard); } catch (error) { if (error?.code !== "ENOENT") throw error; } }
76
+ }
77
+
78
+ /** Acquire the singleton daemon lock. A lock owned by a dead pid (or a corrupt
79
+ * lock) is reclaimed under a guard after re-checking it is still the same stale
80
+ * lock; a live owner fails closed. Liveness is a pid probe, like the loop and
81
+ * supervisor leases, so a recycled pid can keep a stale lock "alive" until the
82
+ * file is removed by hand. Returns a release function. */
83
+ export function acquireDaemonLock(root) {
84
+ const path = daemonLockPath(root);
85
+ mkdirSync(join(root, ".agents", "data", "loop"), { recursive: true });
86
+ const token = randomUUID();
87
+ for (let attempt = 0; attempt < 5; attempt += 1) {
88
+ try {
89
+ writeFileSync(path, `${JSON.stringify({ schemaVersion: 1, pid: process.pid, token, startedAt: new Date().toISOString() })}\n`, { flag: "wx", mode: 0o600 });
90
+ return () => {
91
+ const current = parseDaemonLock(path);
92
+ if (current && !current.corrupt && current.token === token) { try { unlinkSync(path); } catch (error) { if (error?.code !== "ENOENT") throw error; } }
93
+ };
94
+ } catch (error) {
95
+ if (error?.code !== "EEXIST") throw error;
96
+ let stat;
97
+ try { stat = lstatSync(path); } catch (statError) { if (statError?.code === "ENOENT") continue; throw statError; }
98
+ if (stat.isSymbolicLink()) throw new Error(`Daemon lock is a symlink: ${path}`);
99
+ const observed = parseDaemonLock(path);
100
+ if (!observed) continue;
101
+ if (!observed.corrupt && pidAlive(observed.pid)) throw new Error(`Loop daemon already running (pid ${observed.pid})`);
102
+ withReclaimGuard(path, () => {
103
+ const current = parseDaemonLock(path);
104
+ const same = current && (observed.corrupt ? current.corrupt : !current.corrupt && current.token === observed.token);
105
+ if (same) { try { unlinkSync(path); } catch (unlinkError) { if (unlinkError?.code !== "ENOENT") throw unlinkError; } }
106
+ });
107
+ }
108
+ }
109
+ throw new Error("Could not acquire the loop daemon lock");
110
+ }
111
+
112
+ /** Enumerate actionable, loop-owned tasks from canonical state in a
113
+ * deterministic order: every task whose derived nextAction classification is
114
+ * `run_phase` and that `isOwned` accepts, sorted by updatedAt then id so a task
115
+ * that just ran moves to the back of the queue. A loop record that cannot be
116
+ * read counts as owned so its error surfaces through the turn (and backoff)
117
+ * instead of being silently skipped. */
118
+ export function actionableTasks(state, root, { isOwned = (id) => isLoopOwned(root, id) } = {}) {
119
+ const owned = (id) => { try { return isOwned(id); } catch { return true; } };
120
+ return Object.values(state.tasks)
121
+ .filter((task) => nextAction(task, root).classification === "run_phase" && owned(task.id))
122
+ .sort((a, b) => String(a.updatedAt ?? "").localeCompare(String(b.updatedAt ?? "")) || a.id.localeCompare(b.id));
123
+ }
124
+
125
+ export const DAEMON_BACKOFF_MAX_MS = 600000;
126
+
127
+ /** Exponential retry delay for a task whose turn keeps throwing: the poll
128
+ * interval doubled per consecutive failure, capped at 10 minutes. */
129
+ export function backoffDelay(pollMs, failures) {
130
+ if (failures < 1) return 0;
131
+ return Math.min(pollMs * 2 ** Math.min(failures - 1, 30), DAEMON_BACKOFF_MAX_MS);
132
+ }
133
+
134
+ /** Run one supervised lead run for an existing task (no requirement intake):
135
+ * a single runLoop invocation that continues until the next gate, block,
136
+ * pause, completion, or stop — bounded by the per-run `maxTurns`. A task with a
137
+ * loop run record is resumed; a freshly intaken task has none and starts. */
138
+ async function runTaskTurn({ root, taskId, agent, workers, workerImages, limits, pollMs, signal, cancelSignal, onEvent }) {
139
+ const supervisor = createIsolatedLocalSupervisor({ root, taskId, agent, workers, workerImages, limits, pollMs, signal, cancelSignal, untilStop: true, onEvent });
140
+ const record = readLoopRecord(root, taskId);
141
+ return record ? supervisor.resume() : supervisor.start();
142
+ }
143
+
144
+ /**
145
+ * Local multi-task daemon: a foreground long-running loop (one per project,
146
+ * enforced by the singleton lock) that polls canonical state and, for every
147
+ * actionable loop-owned task, runs one supervised lead run via the isolated
148
+ * local supervisor. Tasks are served one at a time in updatedAt order, so a
149
+ * long run delays the others until it reaches its next stop. A task that
150
+ * reaches a gate, blocks, completes, or terminates stops being actionable and
151
+ * is skipped on the next poll; a task whose run throws is retried with
152
+ * exponential backoff. Gate approval stays human-only. `signal` stops after the
153
+ * current turn; `cancelSignal` also cancels the in-flight agent turn.
154
+ */
155
+ /**
156
+ * Shared loop daemon (local and fleet): the same singleton lock, round-robin,
157
+ * backoff, two-stage shutdown, and taskId-stamped events over a loop authority
158
+ * from `loop-authority.mjs` (`listActionable` + `runTask`). The authority alone
159
+ * decides which tasks are loop-owned and how one bounded run executes.
160
+ */
161
+ export async function runLoopDaemon({ root: rootInput, authority, pollMs = DEFAULT_DAEMON_POLL_MS, signal, cancelSignal, onEvent = () => {}, now = Date.now } = {}) {
162
+ const root = validateRoot(rootInput);
163
+ if (!authority || typeof authority.listActionable !== "function" || typeof authority.runTask !== "function") throw new Error("Loop daemon requires a loop authority");
164
+ const poll = validateDaemonPollMs(pollMs);
165
+ const releaseLock = acquireDaemonLock(root);
166
+ try {
167
+ onEvent({ type: "daemon_start", authority: authority.mode ?? "unknown", pid: process.pid });
168
+ return await daemonLoop({ root, poll, signal, cancelSignal, onEvent, now, listActionable: () => authority.listActionable(), turn: ({ taskId, signal: runSignal, cancelSignal: runCancel, onEvent: runEvent }) => authority.runTask({ taskId, signal: runSignal, cancelSignal: runCancel, onEvent: runEvent }) });
169
+ } finally { releaseLock(); }
170
+ }
171
+
172
+ export async function runLocalDaemon({ root: rootInput, agent, workers = {}, workerImages = {}, limits, pollMs = DEFAULT_DAEMON_POLL_MS, signal, cancelSignal, onEvent = () => {}, now = Date.now } = {}) {
173
+ requireLocalAuthority();
174
+ const root = validateRoot(rootInput);
175
+ if (!isLoopProvider(agent)) throw new Error(`local lead agent must be ${LOOP_PROVIDER_LIST}`);
176
+ if (!workers || typeof workers !== "object" || Array.isArray(workers) || !Object.keys(workers).length) throw new Error("Loop requires a configured lead and worker providers");
177
+ for (const [id, provider] of Object.entries(workers)) if (!ID.test(id) || !isLoopProvider(provider)) throw new Error(`invalid local worker: ${id}`);
178
+ const poll = validateDaemonPollMs(pollMs);
179
+ // Fail fast on a bad config.loop.limits instead of failing every task turn.
180
+ if (limits !== undefined) validateLimits(limits);
181
+ const releaseLock = acquireDaemonLock(root);
182
+ try {
183
+ return await daemonLoop({ root, agent, workers, workerImages, limits, poll, signal, cancelSignal, onEvent, now, turn: runTaskTurn });
184
+ } finally { releaseLock(); }
185
+ }
186
+
187
+ /** Run results that leave the task actionable without progress; retried with backoff like errors. */
188
+ const RETRY_STATUSES = new Set(["blocked", "reconcile"]);
189
+
190
+ /** Poll/run loop. Exported for tests (inject `turn`, `now`, and optionally `listActionable`). */
191
+ export async function daemonLoop({ root, agent, workers, workerImages, limits, poll, signal, cancelSignal, onEvent, now = Date.now, turn = runTaskTurn, isOwned, listActionable }) {
192
+ const backoff = new Map();
193
+ for (;;) {
194
+ if (signal?.aborted) return { status: "stopped" };
195
+ let actionable;
196
+ try { actionable = listActionable ? await listActionable() : actionableTasks(loadState(root), root, isOwned ? { isOwned } : {}); }
197
+ catch (error) {
198
+ // An unreachable authority (e.g. the fleet service) must not stop the daemon.
199
+ onEvent({ type: "daemon_poll_error", message: error instanceof Error ? error.message : String(error) });
200
+ try { await sleep(poll, undefined, { signal }); } catch (sleepError) { if (sleepError?.name === "AbortError") return { status: "stopped" }; throw sleepError; }
201
+ continue;
202
+ }
203
+ // Forget backoff for tasks that are no longer actionable (e.g. a human
204
+ // paused or approved them), so they start fresh when they come back.
205
+ for (const id of [...backoff.keys()]) if (!actionable.some((task) => task.id === id)) backoff.delete(id);
206
+ onEvent({ type: "daemon_poll", actionable: actionable.map((task) => task.id), ...(backoff.size ? { backingOff: [...backoff.keys()] } : {}) });
207
+ for (const task of actionable) {
208
+ if (signal?.aborted) return { status: "stopped" };
209
+ const delay = backoff.get(task.id);
210
+ if (delay && now() < delay.nextAt) continue;
211
+ // Stamp every event of this run with its task id so the UI can filter.
212
+ const taskEvent = (event) => onEvent(event && typeof event === "object" && !event.taskId ? { ...event, taskId: task.id } : event);
213
+ try {
214
+ const result = await turn({ root, taskId: task.id, agent, workers, workerImages, limits, pollMs: poll, signal, cancelSignal, onEvent: taskEvent });
215
+ if (result?.status === "gate" && !result.gateEmitted) onEvent({ type: "gate", taskId: task.id, gate: result.gate, gateView: result.gateView });
216
+ onEvent({ type: "daemon_turn", taskId: task.id, status: result?.status ?? "unknown", ...(result?.gate ? { gate: result.gate } : {}), ...(result?.reason ? { reason: result.reason } : {}) });
217
+ if (RETRY_STATUSES.has(result?.status)) {
218
+ const failures = (backoff.get(task.id)?.failures ?? 0) + 1;
219
+ backoff.set(task.id, { failures, nextAt: now() + backoffDelay(poll, failures) });
220
+ } else backoff.delete(task.id);
221
+ } catch (error) {
222
+ // A failing task must not take the daemon down; surface it, back off, move on.
223
+ const failures = (backoff.get(task.id)?.failures ?? 0) + 1;
224
+ const retryInMs = backoffDelay(poll, failures);
225
+ backoff.set(task.id, { failures, nextAt: now() + retryInMs });
226
+ onEvent({ type: "daemon_turn_error", taskId: task.id, message: error instanceof Error ? error.message : String(error), failures, retryInMs });
227
+ }
228
+ }
229
+ try { await sleep(poll, undefined, { signal }); }
230
+ catch (error) { if (error?.name === "AbortError") return { status: "stopped" }; throw error; }
231
+ }
232
+ }
@@ -9,6 +9,7 @@ import { delimiter, join } from "node:path";
9
9
  const MAX_LINE_BYTES = 1024 * 1024;
10
10
  const MAX_STDERR_BYTES = 4096;
11
11
  const MAX_RESULT_CHARS = 8192;
12
+ const MAX_STREAM_CHARS = 4096;
12
13
  const MAX_DIAGNOSTICS = 16;
13
14
 
14
15
  /** One runtime enumeration for every loop provider. Host gates import this
@@ -51,7 +52,7 @@ registerAgentAdapter("codex", {
51
52
  if (event.type === "turn.completed") context.complete();
52
53
  if (event.type === "turn.failed" || event.type === "error") throw new Error(`codex reported ${event.type}: ${diagnostic(JSON.stringify(event.error ?? event.message ?? "unknown"))}`);
53
54
  const item = record(event.item);
54
- if (event.type === "item.completed" && item?.type === "agent_message" && typeof item.text === "string") context.result(item.text);
55
+ if (event.type === "item.completed" && item?.type === "agent_message" && typeof item.text === "string") { context.result(item.text); await context.stream(item.text); }
55
56
  if (event.type === "item.completed" && item?.type === "command_execution") await context.commandEvent(item.exit_code, item.status);
56
57
  }
57
58
  });
@@ -88,8 +89,13 @@ registerAgentAdapter("opencode", {
88
89
  },
89
90
  parse: async (event, context) => {
90
91
  if (event.sessionID !== undefined) await context.captureId(event.sessionID);
92
+ if (event.type === "error") {
93
+ const info = record(event.error);
94
+ throw new Error(`opencode reported an error: ${diagnostic(info?.message ?? info?.type ?? JSON.stringify(event.error ?? "unknown"))}`);
95
+ }
91
96
  const part = record(event.part);
92
- if (event.type === "text" && typeof part?.text === "string") context.result(part.text);
97
+ if (event.type === "text" && typeof part?.text === "string") { context.result(part.text); await context.stream(part.text); }
98
+ if (event.type === "tool_use" && typeof part?.tool === "string") await context.tool(part.tool);
93
99
  if (event.type === "tool_use" && part?.tool === "shell") {
94
100
  const state = record(part.state);
95
101
  const metadata = record(record(state?.metadata)?.metadata);
@@ -168,11 +174,17 @@ export async function runAgentTurn({ agent, root, sessionId, prompt, timeoutMs,
168
174
  await emit(onEvent, { type: "lifecycle_command", exitCode: safeExitCode, status: safeStatus });
169
175
  if ((safeExitCode !== null && safeExitCode !== 0) || safeStatus === "failed") addDiagnostic(`commandFailed exitCode=${String(safeExitCode ?? "unknown")} status=${safeStatus}`);
170
176
  };
177
+ const stream = async (text) => {
178
+ if (typeof text === "string" && text.trim()) await emit(onEvent, { type: "agent_text", text: text.slice(0, MAX_STREAM_CHARS) });
179
+ };
180
+ const tool = async (name) => {
181
+ if (typeof name === "string" && name) await emit(onEvent, { type: "agent_tool", tool: name });
182
+ };
171
183
  const parseEvent = async (line) => {
172
184
  let event;
173
185
  try { event = JSON.parse(line); } catch { throw new Error(`${agent} emitted malformed JSONL`); }
174
186
  if (!record(event) || typeof event.type !== "string") throw new Error(`${agent} emitted malformed event`);
175
- await adapter.parse(event, { captureId, commandEvent, toolUses, complete: () => { completed = true; resultSeen = true; }, result: (value) => { resultText = value.slice(0, MAX_RESULT_CHARS); } });
187
+ await adapter.parse(event, { captureId, commandEvent, toolUses, stream, tool, complete: () => { completed = true; resultSeen = true; }, result: (value) => { resultText = value.slice(0, MAX_RESULT_CHARS); } });
176
188
  await emit(onEvent, { type: "agent_event", eventType: event.type });
177
189
  };
178
190
  const queueLine = (line) => {
@@ -1,4 +1,4 @@
1
- import { chmodSync, existsSync, mkdtempSync, rmSync } from "node:fs";
1
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { execFileSync, spawn } from "node:child_process";
4
4
  import { randomUUID } from "node:crypto";
@@ -8,11 +8,32 @@ import { runFleetCoordinator, runFleetWorker, validateFleetLimits } from "./loop
8
8
  import { runFleetSupervisor } from "./loop-fleet-supervisor.mjs";
9
9
  import { createFleetRemoteRepository } from "./loop-fleet-remote.mjs";
10
10
  import { createLeadGitHandoff, createIsolatedWorkerGitHandoff } from "./loop-git.mjs";
11
- import { workerDockerInvocation, OPENCODE_CONTAINER_ERROR } from "./loop-worker-docker.mjs";
11
+ import { workerDockerInvocation, workerCredentialKeys } from "./loop-worker-docker.mjs";
12
+ import { defaultWorkerImageTag, ensureWorkerImage, validWorkerImageTag } from "./loop-worker-image.mjs";
12
13
 
13
14
  const required = (env, name) => { const value = env[name]; if (typeof value !== "string" || !value.trim()) throw new Error(`${name} is required`); return value; };
14
- const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
15
+ /** Sleep `ms`, resolving early once any given signal aborts. */
16
+ const pause = (ms, ...signals) => new Promise((resolve) => {
17
+ const live = signals.filter(Boolean);
18
+ if (live.some((signal) => signal.aborted)) return resolve();
19
+ const done = () => { clearTimeout(timer); for (const signal of live) signal.removeEventListener("abort", done); resolve(); };
20
+ const timer = setTimeout(done, ms);
21
+ for (const signal of live) signal.addEventListener("abort", done, { once: true });
22
+ });
15
23
  const provider = (value, name) => { if (!isLoopProvider(value)) throw new Error(`${name} must be ${LOOP_PROVIDER_LIST}`); return value; };
24
+ const pendingFeedback = (task) => {
25
+ const evidence = Array.isArray(task?.evidence) ? task.evidence : [];
26
+ const pending = [];
27
+ for (let i = 0; i < evidence.length; i++) {
28
+ const entry = evidence[i];
29
+ if (entry?.kind !== "diagnostic" || typeof entry.detail !== "string") continue;
30
+ const match = entry.detail.match(/^Cancelled gate wait at (G0_confirm|G1_review|G2_codereview): /);
31
+ if (!match) continue;
32
+ const approvedAfter = evidence.slice(i + 1).some((later) => later?.kind === "approval" && later.result === "pass" && later.gate === match[1]);
33
+ if (!approvedAfter) pending.push(entry.detail);
34
+ }
35
+ return pending;
36
+ };
16
37
  function workerMapping(env, workerIds, defaultAgent) {
17
38
  if (!env.AIDLC_FLEET_WORKER_PROVIDERS) return Object.fromEntries(workerIds.map((id) => [id, defaultAgent]));
18
39
  let mapping;
@@ -20,9 +41,46 @@ function workerMapping(env, workerIds, defaultAgent) {
20
41
  if (!mapping || typeof mapping !== "object" || Array.isArray(mapping) || Object.keys(mapping).length !== workerIds.length || workerIds.some((id) => !Object.hasOwn(mapping, id) || !isLoopProvider(mapping[id])) || Object.keys(mapping).some((id) => !workerIds.includes(id))) throw new Error(`AIDLC_FLEET_WORKER_PROVIDERS must map every configured worker ID to ${LOOP_PROVIDER_LIST}`);
21
42
  return mapping;
22
43
  }
44
+ /** This worker's provider: AIDLC_FLEET_WORKER_AGENT, else its entry in
45
+ * AIDLC_FLEET_WORKER_PROVIDERS (keyed by worker ID), else AIDLC_FLEET_AGENT
46
+ * (the lead's provider — only a last resort, so a copied lead env cannot
47
+ * override this worker's own mapping), else undefined. */
48
+ function workerProvider(env, workerId) {
49
+ if (env.AIDLC_FLEET_WORKER_AGENT !== undefined) return provider(env.AIDLC_FLEET_WORKER_AGENT, "AIDLC_FLEET_WORKER_AGENT");
50
+ if (env.AIDLC_FLEET_WORKER_PROVIDERS) {
51
+ let mapping;
52
+ try { mapping = JSON.parse(env.AIDLC_FLEET_WORKER_PROVIDERS); } catch { throw new Error("AIDLC_FLEET_WORKER_PROVIDERS must be a JSON object"); }
53
+ if (!mapping || typeof mapping !== "object" || Array.isArray(mapping)) throw new Error("AIDLC_FLEET_WORKER_PROVIDERS must be a JSON object");
54
+ if (Object.hasOwn(mapping, workerId)) return provider(mapping[workerId], `AIDLC_FLEET_WORKER_PROVIDERS["${workerId}"]`);
55
+ }
56
+ return env.AIDLC_FLEET_AGENT === undefined ? undefined : provider(env.AIDLC_FLEET_AGENT, "AIDLC_FLEET_AGENT");
57
+ }
58
+
59
+ /** Worker image tag: explicit AIDLC_WORKER_DOCKER_IMAGE, else the project's
60
+ * `.agents/config.json` `loop.workerImage[provider]`, else the default tag
61
+ * (built from the shipped Dockerfile when missing). */
62
+ function workerImageTag({ env, root, agent, onEvent }) {
63
+ const requested = env.AIDLC_WORKER_DOCKER_IMAGE;
64
+ if (typeof requested === "string" && requested.trim()) return requested;
65
+ if (!agent) throw new Error("AIDLC_WORKER_DOCKER_IMAGE is required when the worker provider is unknown; set AIDLC_FLEET_WORKER_AGENT (or this worker's AIDLC_FLEET_WORKER_PROVIDERS entry) to use the default worker image, or build one with `aidlc-kit loop worker-image --provider <provider>` and set the env var");
66
+ const configPath = join(root, ".agents", "config.json");
67
+ let configured;
68
+ if (existsSync(configPath)) {
69
+ let config;
70
+ try { config = JSON.parse(readFileSync(configPath, "utf8")); } catch { throw new Error(`Invalid project config: ${configPath}`); }
71
+ configured = config?.loop?.workerImage?.[agent];
72
+ if (configured !== undefined && !validWorkerImageTag(configured)) throw new Error(`Invalid loop.workerImage.${agent} in ${configPath}`);
73
+ }
74
+ const image = configured ?? defaultWorkerImageTag(agent);
75
+ return ensureWorkerImage({ root, provider: agent, image, env, onEvent });
76
+ }
23
77
 
24
- /** Package-local entrypoint. The service wrapper supplies its MCP HTTP client. */
25
- export async function runFleetCli(argv, { callTool, env = process.env, out = console.log, signal, coordinator: coordinatorOverride } = {}) {
78
+ /** Package-local entrypoint. The service wrapper supplies its MCP HTTP client.
79
+ * `signal` stops after the current turn; `cancelSignal` also aborts the
80
+ * in-flight agent turn (result status `stopped`). `onEvent(event)` receives
81
+ * every emitted event object instead of `out(JSON.stringify(event))`.
82
+ * `untilStop` applies to role `supervisor` only (see runFleetSupervisor). */
83
+ export async function runFleetCli(argv, { callTool, env = process.env, out = console.log, onEvent, signal, cancelSignal, untilStop = false, coordinator: coordinatorOverride } = {}) {
26
84
  const [role, targetId, ...flags] = argv;
27
85
  if (!["coordinator", "feature", "worker", "supervisor"].includes(role) || ((role === "coordinator" || role === "feature" || role === "supervisor") && !targetId) || flags.some((arg) => arg !== "--once") || (role === "supervisor" && flags.length) || (role === "worker" && targetId && targetId !== "--once")) throw new Error("Usage: aidlc-fleet coordinator <task-id> [--once] | feature <feature-id> [--once] | supervisor <task-id> | worker [--once]");
28
86
  const once = flags.includes("--once") || (role === "worker" && targetId === "--once");
@@ -44,23 +102,21 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
44
102
  const pollMs = env.AIDLC_FLEET_POLL_MS ? Number(env.AIDLC_FLEET_POLL_MS) : 5000;
45
103
  if (!Number.isSafeInteger(pollMs) || pollMs < 1000 || pollMs > 300000) throw new Error("AIDLC_FLEET_POLL_MS must be 1000..300000");
46
104
  if (env.AIDLC_FLEET_CODE_ONLY_RETRY && !["0", "1"].includes(env.AIDLC_FLEET_CODE_ONLY_RETRY)) throw new Error("AIDLC_FLEET_CODE_ONLY_RETRY must be 0 or 1");
47
- const emit = (event) => out(JSON.stringify(event));
105
+ const emit = typeof onEvent === "function" ? (event) => onEvent(event) : (event) => out(JSON.stringify(event));
106
+ const stopRequested = () => Boolean(signal?.aborted || cancelSignal?.aborted);
48
107
  if (role === "worker") {
49
108
  const workerId = required(env, "AIDLC_FLEET_WORKER_ID");
50
- const workerAgentSetting = env.AIDLC_FLEET_WORKER_AGENT ?? env.AIDLC_FLEET_AGENT;
51
- const workerAgent = workerAgentSetting === undefined ? undefined : provider(workerAgentSetting, "AIDLC_FLEET_WORKER_AGENT");
52
- if (workerAgent === "opencode") throw new Error(OPENCODE_CONTAINER_ERROR);
53
- const requestedImage = required(env, "AIDLC_WORKER_DOCKER_IMAGE");
109
+ const workerAgent = workerProvider(env, workerId);
110
+ const requestedImage = workerImageTag({ env, root, agent: workerAgent, onEvent: emit });
54
111
  if (!/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,255}$/.test(requestedImage)) throw new Error("Invalid Docker worker image");
55
112
  const image = execFileSync("docker", ["image", "inspect", "--format", "{{.Id}}", requestedImage], {env:{PATH:env.PATH ?? "/usr/bin:/bin"},encoding:"utf8",timeout:30000,stdio:["ignore","pipe","ignore"]}).trim();
56
113
  if (!/^sha256:[a-f0-9]{64}$/.test(image)) throw new Error("Docker worker image lacks immutable image ID");
57
114
  const uid = process.getuid?.(), gid = process.getgid?.();
58
115
  if (!Number.isSafeInteger(uid) || uid < 1 || !Number.isSafeInteger(gid) || gid < 1) throw new Error("Docker worker requires a non-root host uid and gid");
59
116
  const preflight = async (agent) => {
60
- const credential = agent === "codex" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
61
117
  const empty = mkdtempSync(join(tmpdir(), "aidlc-worker-preflight-"));
62
118
  try {
63
- const invocation = workerDockerInvocation({workspace:empty,image,command:agent,args:["--version"],credentialEnv:[credential],hostEnv:env,uid,gid});
119
+ const invocation = workerDockerInvocation({workspace:empty,image,command:agent,args:["--version"],credentialEnv:workerCredentialKeys(agent, env),hostEnv:env,uid,gid});
64
120
  execFileSync("docker", ["info"], {env:{PATH:invocation.env.PATH},stdio:"ignore",timeout:30000});
65
121
  execFileSync("docker", ["image","inspect",image], {env:{PATH:invocation.env.PATH},stdio:"ignore",timeout:30000});
66
122
  const name = `aidlc-worker-preflight-${randomUUID()}`;
@@ -90,15 +146,14 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
90
146
  async discard(args) { try { return await baseHandoff.discard(args); } finally { cleanupSession(args.workspace); } }
91
147
  };
92
148
  const isolatedDriver = async (turn) => {
93
- if (turn.agent === "opencode") throw new Error(OPENCODE_CONTAINER_ERROR);
94
149
  const session=sessions.get(turn.root);
95
150
  if (!session || session.agent !== turn.agent || (turn.sessionId && turn.sessionId !== session.sessionId)) throw new Error("Docker worker session is not recoverable; reconcile this assignment");
96
- const credential = turn.agent === "codex" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
151
+ const credentialEnv = workerCredentialKeys(turn.agent, env);
97
152
  const name = `aidlc-worker-${randomUUID()}`;
98
153
  let started = false;
99
154
  try {
100
- const result = await runAgentTurn({...turn, root:"/workspace", spawnProcess: (command,args,options) => {
101
- const invocation=workerDockerInvocation({workspace:turn.root,image,command,args,credentialEnv:[credential],hostEnv:env,uid,gid,sessionHome:session.home});
155
+ const result = await runAgentTurn({...turn, root:"/workspace", env, spawnProcess: (command,args,options) => {
156
+ const invocation=workerDockerInvocation({workspace:turn.root,image,command,args,credentialEnv,hostEnv:env,uid,gid,sessionHome:session.home});
102
157
  const dockerArgs=invocation.args.filter((arg,index) => !(index===1 && arg==="--rm"));
103
158
  dockerArgs.splice(1,0,"--name",name);
104
159
  started = true;
@@ -114,20 +169,20 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
114
169
  }
115
170
  };
116
171
  for (;;) {
117
- if (signal?.aborted) return { status: "stopped" };
172
+ if (stopRequested()) return { status: "stopped" };
118
173
  let result;
119
- try { result = await runFleetWorker({ repository, workerId, workerAgent, preflight, limits, driver: isolatedDriver, handoff, packet: async ({ job, snapshot }) => [
174
+ try { result = await runFleetWorker({ repository, workerId, workerAgent, preflight, limits, driver: isolatedDriver, handoff, cancelSignal, packet: async ({ job, snapshot }) => [
120
175
  `AI-DLC worker assignment ${job.assignmentId}, attempt ${job.attempt}.`,
121
176
  `Task: ${snapshot.task.title} (${job.taskId}). Item: ${job.itemId}.`,
122
177
  `Only change these owned paths: ${job.ownedPaths.join(", ")}.`,
123
178
  `Base commit: ${job.baseCommit}. Assigned branch: ${job.branch}.`,
124
- "Complete the scoped item and report remaining risks. The supervisor handles the Git commit and push.",
179
+ "Complete the scoped item and report remaining risks. The supervisor handles the Git commit and push. End the final answer with a Conventional Commits message on its own line as AI-DLC-COMMIT: type(scope): subject (for example AI-DLC-COMMIT: docs(ui): add web UI improvement proposal), followed by AI-DLC-ITEM-COMPLETE on its own line.",
125
180
  ...(env.AIDLC_FLEET_CODE_ONLY_RETRY === "1" ? ["This assignment is declared code-only: perform no external side effects beyond the supervisor's Git handoff."] : [])
126
181
  ].join("\n"), onEvent: emit }); }
127
182
  finally { for (const [workspace, entry] of sessions) { rmSync(entry.home,{recursive:true,force:true}); sessions.delete(workspace); } }
128
183
  emit(result);
129
- if (once) return result;
130
- await pause(pollMs);
184
+ if (once || result.status === "stopped") return result;
185
+ await pause(pollMs, signal, cancelSignal);
131
186
  }
132
187
  }
133
188
  const workerIds = required(env, "AIDLC_FLEET_WORKER_IDS").split(",").map((value) => value.trim()).filter(Boolean);
@@ -142,19 +197,21 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
142
197
  const raw = execFileSync(process.execPath, [remoteState, "phase", "context", taskId], { cwd: root, env: fleetEnv, encoding: "utf8", timeout: 30000, maxBuffer: 2 * 1024 * 1024 });
143
198
  const packet = JSON.parse(raw);
144
199
  if (packet?.ok !== true || typeof packet.result?.content !== "string" || !packet.result.content) throw new Error("Remote phase context is empty or invalid");
145
- return `${packet.result.content}\n\nFleet supervisor: canonical nextAction is ${JSON.stringify(snapshot.nextAction)}. Use only remote-state.mjs for lifecycle commands. Never approve a gate.`;
200
+ const feedback = pendingFeedback(snapshot.task);
201
+ const feedbackBlock = feedback.length ? `Human feedback (revise the current review artifact accordingly, then re-present the gate):\n${feedback.map((text) => `- ${text}`).join("\n")}\n\n` : "";
202
+ return `${feedbackBlock}${packet.result.content}\n\nFleet supervisor: canonical nextAction is ${JSON.stringify(snapshot.nextAction)}. Use only remote-state.mjs for lifecycle commands. Never approve a gate.`;
146
203
  },
147
204
  driver: async ({ leadEpoch, prompt, timeoutMs, signal: turnSignal }) => {
148
205
  return runAgentTurn({ agent, root, prompt, timeoutMs, signal: turnSignal, env: { ...env, AIDLC_FLEET_LEAD_EPOCH: String(leadEpoch), AIDLC_FLEET_LEAD_OWNER_TOKEN: repository.leadOwnerToken() }, onEvent: emit });
149
206
  } });
150
- const runTask = async (taskId, reservedJobs = []) => runFleetCoordinator({ repository, taskId, ...leadTaskFor(taskId), reservedJobs, onEvent: emit });
207
+ const runTask = async (taskId, reservedJobs = []) => runFleetCoordinator({ repository, taskId, ...leadTaskFor(taskId), reservedJobs, stopSignal: signal, cancelSignal, onEvent: emit });
151
208
  if (role === "supervisor") {
152
- const result = await runFleetSupervisor({ repository, taskId: targetId, ...leadTaskFor(targetId), signal, onEvent: emit, ...(coordinatorOverride ? { coordinator: coordinatorOverride } : {}) });
209
+ const result = await runFleetSupervisor({ repository, taskId: targetId, ...leadTaskFor(targetId), signal, cancelSignal, untilStop, onEvent: emit, ...(coordinatorOverride ? { coordinator: coordinatorOverride } : {}) });
153
210
  emit(result);
154
211
  return result;
155
212
  }
156
213
  for (;;) {
157
- if (signal?.aborted) return { status: "stopped" };
214
+ if (stopRequested()) return { status: "stopped" };
158
215
  if (role === "feature") {
159
216
  const feature = await repository.getFeature({ featureId: targetId });
160
217
  if (!feature) throw new Error(`Unknown feature ${targetId}`);
@@ -167,17 +224,18 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
167
224
  const reservedJobs = (await repository.listAllJobs()).filter((job) => job.taskId !== member.taskId);
168
225
  const result = await runTask(member.taskId, reservedJobs);
169
226
  results.push(result); emit(result);
227
+ if (result.status === "stopped") return { status: "stopped", featureId: targetId, results };
170
228
  if (result.status === "gate") return { status: "gate", featureId: targetId, results, blockedTaskIds: readiness.items.filter((item) => !item.ready && item.blockers.length).map((item) => item.taskId) };
171
229
  }
172
230
  const stopped = results.some((entry) => ["blocked", "reconcile", "paused", "terminal"].includes(entry.status));
173
231
  const result = { status: !ready.length || stopped ? "blocked" : "active", featureId: targetId, results, blockedTaskIds: readiness.items.filter((member) => !member.ready && member.blockers.length).map((member) => member.taskId) };
174
232
  if (once || result.status === "blocked") return result;
175
- await pause(pollMs);
233
+ await pause(pollMs, signal, cancelSignal);
176
234
  continue;
177
235
  }
178
236
  const result = await runTask(targetId);
179
237
  emit(result);
180
- if (once || ["terminal", "complete", "blocked", "paused", "reconcile", "gate"].includes(result.status)) return result;
181
- await pause(pollMs);
238
+ if (once || ["terminal", "complete", "blocked", "paused", "reconcile", "gate", "stopped"].includes(result.status)) return result;
239
+ await pause(pollMs, signal, cancelSignal);
182
240
  }
183
241
  }
@@ -44,6 +44,13 @@ export function createFleetRemoteRepository({ callTool, url, token, workspace, r
44
44
  getFeature: async ({ featureId }) => (await fleet()).fleet.features[featureId] ?? null,
45
45
  featureReady: async ({ featureId }) => call("feature_ready", { featureId }),
46
46
  listAllJobs: async () => Object.values((await fleet()).fleet.jobs).map(normalizeJob),
47
+ /** Every canonical task in the workspace (unordered), for daemon task discovery. */
48
+ listTasks: async () => {
49
+ const value = await call("state_get", {});
50
+ const tasks = value?.state?.tasks;
51
+ if (!tasks || typeof tasks !== "object" || Array.isArray(tasks)) throw new Error("Invalid remote workspace state");
52
+ return Object.values(tasks);
53
+ },
47
54
  readTask: async ({ taskId }) => {
48
55
  const value = await call("task_get", { taskId, clientProjectRoot: root });
49
56
  return { task: value.task, nextAction: value.nextAction, revision: value.revision };
@@ -6,14 +6,24 @@ const taskDigest = (task) => createHash("sha256").update(JSON.stringify(task)).d
6
6
  const targets = { G0_confirm: "plan", G1_review: "build", G2_codereview: "wrap" };
7
7
 
8
8
  /** The service owns the checkpoint and atomically binds each run claim to this
9
- * supervisor. An unavailable or ambiguous checkpoint never authorizes work. */
10
- export async function runFleetSupervisor({ repository, taskId, limits: inputLimits, pollMs = 5000, signal, onEvent = () => {}, coordinator = runFleetCoordinator, ...options }) {
9
+ * supervisor. An unavailable or ambiguous checkpoint never authorizes work.
10
+ *
11
+ * `signal` stops at the next turn boundary; `cancelSignal` also aborts the
12
+ * in-flight lead turn. Both end as `{ status: "stopped" }`.
13
+ *
14
+ * `untilStop: true` (daemon mode) never polls a human gate: a newly published
15
+ * gate returns `{ status: "gate", taskId, gate, snapshotId, gateView }` and an
16
+ * already pending checkpoint returns `{ status: "waiting", taskId, gate,
17
+ * snapshotId }`. The supervisor lease is released either way; the service
18
+ * accepts the reviewer approval against the pending checkpoint, and a later
19
+ * supervisor resumes from the `approved` checkpoint. */
20
+ export async function runFleetSupervisor({ repository, taskId, limits: inputLimits, pollMs = 5000, signal, cancelSignal, untilStop = false, onEvent = () => {}, coordinator = runFleetCoordinator, ...options }) {
11
21
  const limits = validateFleetLimits(inputLimits);
12
22
  if (!repository || typeof taskId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(taskId) || !Number.isSafeInteger(pollMs) || pollMs < 1 || pollMs > 300000) throw new Error("Invalid fleet supervisor configuration");
13
23
  for (const name of ["claimSupervisor", "renewSupervisor", "releaseSupervisor", "readGateCheckpoint", "readTask", "claimLead"]) if (typeof repository[name] !== "function") throw new Error(`Fleet supervisor requires ${name}`);
14
24
  await repository.claimSupervisor({ taskId, leaseMs: limits.leaseMs, idempotencyKey: randomUUID() });
15
25
  const controller = new AbortController();
16
- const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
26
+ const combined = AbortSignal.any([signal, cancelSignal, controller.signal].filter(Boolean));
17
27
  let failure; let pending = Promise.resolve();
18
28
  const timer = setInterval(() => { pending = pending.then(() => repository.renewSupervisor({ taskId, leaseMs: limits.leaseMs })).catch((error) => { failure = error; controller.abort(error); }); }, limits.heartbeatMs);
19
29
  timer.unref?.();
@@ -28,12 +38,17 @@ export async function runFleetSupervisor({ repository, taskId, limits: inputLimi
28
38
  if (checkpoint?.status === "pending") {
29
39
  if (task.status !== "blocked_on_user" || task.gate !== checkpoint.gate || taskDigest(task) !== checkpoint.taskDigest || snapshot.nextAction?.classification !== "await_user") return { status: "blocked", taskId, reason: "Pending checkpoint does not match canonical gate" };
30
40
  await onEvent({ type: "waiting", taskId, gate: checkpoint.gate, snapshotId: checkpoint.snapshotId });
41
+ if (untilStop) return { status: "waiting", taskId, gate: checkpoint.gate, snapshotId: checkpoint.snapshotId };
31
42
  try { await sleep(pollMs, undefined, { signal: combined }); } catch (error) { if (error?.name !== "AbortError") throw error; }
32
43
  continue;
33
44
  }
34
45
  if (checkpoint?.status === "consumed") return { status: "blocked", taskId, reason: "Consumed checkpoint requires reconciliation before restart" };
35
- if (checkpoint && checkpoint.status !== "approved") return { status: "blocked", taskId, reason: "Approved checkpoint does not match canonical state" };
36
- if (checkpoint) {
46
+ if (checkpoint?.status === "rejected") {
47
+ if (task.status !== "active" || task.phase !== checkpoint.phase || task.gate !== checkpoint.gate || snapshot.nextAction?.classification !== "run_phase") return { status: "blocked", taskId, reason: "Rejected checkpoint does not match canonical state" };
48
+ } else if (checkpoint && checkpoint.status !== "approved") {
49
+ return { status: "blocked", taskId, reason: "Approved checkpoint does not match canonical state" };
50
+ }
51
+ if (checkpoint?.status === "approved") {
37
52
  // A task snapshot older than the atomic approval is a read race between
38
53
  // the two reads, not a mismatch: re-read the canonical task immediately.
39
54
  if (Number.isSafeInteger(checkpoint.approvalRevision) && checkpoint.approvalRevision > snapshot.revision) { await onEvent({ type: "resync", taskId, gate: checkpoint.gate }); continue; }
@@ -43,11 +58,12 @@ export async function runFleetSupervisor({ repository, taskId, limits: inputLimi
43
58
  await repository.renewSupervisor({ taskId, leaseMs: limits.leaseMs });
44
59
  if (combined.aborted) break;
45
60
  const claim = await repository.claimLead({ taskId, leaseMs: limits.leaseMs, expectedStateRevision: snapshot.revision, expectedTaskDigest: taskDigest(task), checkpoint, idempotencyKey: randomUUID() });
46
- const result = await coordinator({ ...options, repository, taskId, limits, claimedLead: claim, supervisorSignal: combined, onEvent });
61
+ const result = await coordinator({ ...options, repository, taskId, limits, claimedLead: claim, supervisorSignal: controller.signal, stopSignal: signal, cancelSignal, onEvent });
47
62
  if (failure) throw failure;
48
63
  if (result?.status !== "gate") return result;
49
64
  // gate_snapshot_put stores snapshot and checkpoint in one service transaction.
50
65
  await onEvent({ type: "gate", taskId, gate: result.gate, snapshotId: result.snapshotId, gateView: result.gateView });
66
+ if (untilStop) return { status: "gate", taskId, gate: result.gate, snapshotId: result.snapshotId, gateView: result.gateView };
51
67
  }
52
68
  if (failure) throw new Error(`Supervisor lease renewal failed: ${failure.message}`);
53
69
  return { status: "stopped", taskId };