@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
@@ -3,7 +3,7 @@
3
3
  * for multi-host AI-DLC execution.
4
4
  */
5
5
  import { createHash, randomUUID } from "node:crypto";
6
- import { readyTaskItems, formatGateView } from "./store.mjs";
6
+ import { readyTaskItems, formatGateView, pathsOverlap } from "./store.mjs";
7
7
  import { isLoopProvider, LOOP_PROVIDER_LIST } from "./loop-drivers.mjs";
8
8
 
9
9
  export const FLEET_LIMITS = Object.freeze({ maxTurns: 12, turnTimeoutMs: 600_000, maxStallTurns: 2, maxWorkers: 4, leaseMs: 300_000, heartbeatMs: 60_000 });
@@ -37,9 +37,15 @@ function readyItems(task) {
37
37
  const ready = new Set(readyTaskItems(task).ready);
38
38
  return items.filter((item) => item.status === "todo" && ready.has(item.id));
39
39
  }
40
- const overlaps = (left, right) => left === right || (left.endsWith("/") && right.startsWith(left)) || (right.endsWith("/") && left.startsWith(right));
41
- const pathsOverlap = (left = [], right = []) => left.some((a) => right.some((b) => overlaps(a, b)));
42
- const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
40
+ /** Sleep `ms`, resolving early (never rejecting) once any given signal aborts. */
41
+ const wait = (ms, ...signals) => new Promise((resolve) => {
42
+ const live = signals.filter(Boolean);
43
+ if (live.some((signal) => signal.aborted)) return resolve();
44
+ const done = () => { clearTimeout(timer); for (const signal of live) signal.removeEventListener("abort", done); resolve(); };
45
+ const timer = setTimeout(done, ms);
46
+ for (const signal of live) signal.addEventListener("abort", done, { once: true });
47
+ });
48
+ const anySignal = (...signals) => { const live = signals.filter(Boolean); return live.length === 1 ? live[0] : AbortSignal.any(live); };
43
49
 
44
50
  function key(...parts) { return digest(parts); }
45
51
 
@@ -113,8 +119,13 @@ async function withHeartbeat(renew, intervalMs, work) {
113
119
  /**
114
120
  * One fenced coordinator invocation. The repository is the remote authority;
115
121
  * no local lifecycle file or PID lease is consulted in fleet mode.
122
+ *
123
+ * `supervisorSignal` reports a lost supervisor lease (aborts the turn and
124
+ * fails). `stopSignal` requests a graceful stop at the next turn boundary;
125
+ * `cancelSignal` additionally aborts the in-flight lead turn. Both end as
126
+ * `{ status: "stopped" }` without changing canonical task state.
116
127
  */
117
- export async function runFleetCoordinator({ repository, taskId, ownerId = randomUUID(), workerIds = [], workerProviders, reservedJobs = [], allowCodeOnlyRetry = false, limits: inputLimits, pollMs = 5000, pickupTimeoutMs: inputPickupTimeout, packet, driver, gateView, handoff, completeItem, claimedLead, supervisorSignal, onEvent = () => {} }) {
128
+ export async function runFleetCoordinator({ repository, taskId, ownerId = randomUUID(), workerIds = [], workerProviders, reservedJobs = [], allowCodeOnlyRetry = false, limits: inputLimits, pollMs = 5000, pickupTimeoutMs: inputPickupTimeout, packet, driver, gateView, handoff, completeItem, claimedLead, supervisorSignal, stopSignal, cancelSignal, onEvent = () => {} }) {
118
129
  assertId(taskId, "task ID"); assertId(ownerId, "owner ID");
119
130
  if (!Array.isArray(workerIds) || new Set(workerIds).size !== workerIds.length) throw new Error("workerIds must be distinct IDs");
120
131
  for (const workerId of workerIds) assertId(workerId, "worker ID");
@@ -139,15 +150,19 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
139
150
  const readySeen = new Map();
140
151
  try {
141
152
  return await withHeartbeat(() => renewLead({ taskId, ownerId, epoch, leaseMs: limits.leaseMs }), limits.heartbeatMs, async (leaseSignal) => {
142
- const signal = supervisorSignal ? AbortSignal.any([leaseSignal, supervisorSignal]) : leaseSignal;
153
+ const leaseLost = anySignal(leaseSignal, supervisorSignal);
154
+ const signal = anySignal(leaseLost, cancelSignal);
155
+ const stopping = () => Boolean(stopSignal?.aborted || cancelSignal?.aborted);
156
+ const stopped = (reason) => ({ status: "stopped", taskId, reason, revision: snapshot.revision });
143
157
  for (;;) {
144
- if (signal.aborted) throw new Error("Lead lease renewal failed");
158
+ if (leaseLost.aborted) throw new Error("Lead lease renewal failed");
159
+ if (stopping()) return stopped(cancelSignal?.aborted ? "cancel requested" : "stop requested");
145
160
  if (Date.now() - startedAt >= limits.maxTurns * limits.turnTimeoutMs) return { status: "paused", taskId, reason: "coordinator wall-time limit reached" };
146
161
  snapshot = validateSnapshot(await readTask({ taskId }), taskId);
147
162
  const kind = actionKind(snapshot.nextAction);
148
163
  if (kind === "await_user") {
149
164
  const reconciled = await reconcileFleetJobs({ repository, taskId, leadEpoch: epoch, snapshot, handoff, allowCodeOnlyRetry, quiesceMs: limits.turnTimeoutMs + limits.heartbeatMs + 1000 });
150
- if (reconciled.pending.length && reconciled.pending.every((entry) => entry.waitUntil)) { await wait(pollMs); continue; }
165
+ if (reconciled.pending.length && reconciled.pending.every((entry) => entry.waitUntil)) { await wait(pollMs, signal, stopSignal); continue; }
151
166
  if (reconciled.pending.length) return { status: "reconcile", taskId, revision: snapshot.revision, pending: reconciled.pending };
152
167
  const gate = snapshot.nextAction.gate;
153
168
  const baseReview = await (gateView ?? ((value) => formatGateView(value.task, [], "markdown")))(snapshot);
@@ -163,7 +178,7 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
163
178
  const before = digest({ revision: snapshot.revision, action: snapshot.nextAction });
164
179
  if (kind === "dispatch" || (kind === "run_phase" && snapshot.task.phase === "build")) {
165
180
  const reconciled = await reconcileFleetJobs({ repository, taskId, leadEpoch: epoch, snapshot, handoff, allowCodeOnlyRetry, quiesceMs: limits.turnTimeoutMs + limits.heartbeatMs + 1000 });
166
- if (reconciled.pending.length && reconciled.pending.every((entry) => entry.waitUntil)) { await wait(pollMs); continue; }
181
+ if (reconciled.pending.length && reconciled.pending.every((entry) => entry.waitUntil)) { await wait(pollMs, signal, stopSignal); continue; }
167
182
  if (reconciled.pending.length) {
168
183
  const verified = reconciled.pending.filter((entry) => entry.artifact?.valid);
169
184
  if (verified.length && typeof handoff?.integrate === "function" && typeof completeItem === "function") {
@@ -212,7 +227,7 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
212
227
  }
213
228
  }
214
229
  onEvent({ type: "jobs_active", taskId, assignments: active.map((job) => job.assignmentId) });
215
- await wait(pollMs);
230
+ await wait(pollMs, signal, stopSignal);
216
231
  continue;
217
232
  }
218
233
  }
@@ -220,7 +235,11 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
220
235
  if (typeof packet !== "function" || typeof driver !== "function") throw new Error("Fleet lead requires packet and driver adapters");
221
236
  const prompt = await packet(snapshot, epoch);
222
237
  if (typeof prompt !== "string" || !prompt) throw new Error("Empty fleet lead packet");
223
- await driver({ taskId, snapshot, leadEpoch: epoch, prompt, timeoutMs: limits.turnTimeoutMs, signal });
238
+ try { await driver({ taskId, snapshot, leadEpoch: epoch, prompt, timeoutMs: limits.turnTimeoutMs, signal }); }
239
+ catch (error) {
240
+ if (!leaseLost.aborted && cancelSignal?.aborted) return stopped("cancelled the in-flight lead turn");
241
+ throw error;
242
+ }
224
243
  turns++;
225
244
  const after = validateSnapshot(await readTask({ taskId }), taskId);
226
245
  const nextSignal = digest({ revision: after.revision, action: after.nextAction });
@@ -234,8 +253,10 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
234
253
  }
235
254
  }
236
255
 
237
- /** A worker claims one attempt, persists its exact CLI session, and reports a Git artifact. */
238
- export async function runFleetWorker({ repository, workerId = randomUUID(), workerAgent, preflight, limits: inputLimits, driver, packet, handoff, onEvent = () => {} }) {
256
+ /** A worker claims one attempt, persists its exact CLI session, and reports a Git artifact.
257
+ * `cancelSignal` aborts the in-flight worker turn; the attempt is then abandoned
258
+ * (service state `uncertain`, recoverable by the lead) and the result is `stopped`. */
259
+ export async function runFleetWorker({ repository, workerId = randomUUID(), workerAgent, preflight, limits: inputLimits, driver, packet, handoff, cancelSignal, onEvent = () => {} }) {
239
260
  assertId(workerId, "worker ID");
240
261
  if (workerAgent !== undefined && !isLoopProvider(workerAgent)) throw new Error(`workerAgent must be ${LOOP_PROVIDER_LIST}`);
241
262
  const limits = validateFleetLimits(inputLimits);
@@ -259,12 +280,14 @@ export async function runFleetWorker({ repository, workerId = randomUUID(), work
259
280
  let turns = 0, stalls = 0;
260
281
  const report = async (outcome) => reportJob({ ...identity, report: outcome, idempotencyKey: key("job-report", job.assignmentId, job.attempt, job.epoch, outcome.status, outcome.commitSha ?? "") });
261
282
  return withHeartbeat(() => renewJob({ ...identity, leaseMs: limits.leaseMs }), limits.heartbeatMs, async (signal) => {
283
+ const cancelled = async () => { await report({ status: "uncertain", reason: "worker turn cancelled", sessionId }); return { status: "stopped", assignmentId: job.assignmentId }; };
262
284
  if (typeof driver !== "function" || typeof packet !== "function" || typeof handoff?.prepare !== "function" || typeof handoff?.observe !== "function" || typeof handoff?.finish !== "function") throw new Error("Worker requires driver, packet, and Git handoff adapters");
263
285
  const workspace = await handoff.prepare(job);
264
286
  if (!workspace?.root) throw new Error("Git handoff did not supply a worker root");
265
287
  let lastObservation = null;
266
288
  for (;;) {
267
289
  if (signal.aborted) throw new Error("Job lease renewal failed");
290
+ if (cancelSignal?.aborted) return cancelled();
268
291
  const snapshot = validateSnapshot(await readTask({ taskId: job.taskId }), job.taskId);
269
292
  const item = snapshot.task.tasks?.find((candidate) => candidate.id === job.itemId);
270
293
  if (!item || item.status === "done") {
@@ -282,11 +305,13 @@ export async function runFleetWorker({ repository, workerId = randomUUID(), work
282
305
  }
283
306
  const prompt = await packet({ job, snapshot, workspace, turns });
284
307
  if (typeof prompt !== "string" || !prompt) throw new Error("Empty worker packet");
285
- const result = await driver({ agent: job.agent, root: workspace.root, sessionId: sessionId ?? undefined, prompt: `${prompt}\n\nWorker scope: modify only assigned paths. Do not mutate AI-DLC lifecycle state or approve any gate. When the scoped implementation is ready for commit, end the final answer with AI-DLC-ITEM-COMPLETE on its own line.`, timeoutMs: limits.turnTimeoutMs, signal, onSessionId: async (id) => {
308
+ let result;
309
+ try { result = await driver({ agent: job.agent, root: workspace.root, sessionId: sessionId ?? undefined, prompt: `${prompt}\n\nWorker scope: modify only assigned paths. Do not mutate AI-DLC lifecycle state or approve any gate. When the scoped implementation is ready for commit, 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.`, timeoutMs: limits.turnTimeoutMs, signal: anySignal(signal, cancelSignal), onSessionId: async (id) => {
286
310
  if (typeof id !== "string" || !id) throw new Error("Invalid worker session ID");
287
311
  if (sessionId && sessionId !== id) throw new Error("Worker session changed during the same assignment");
288
312
  sessionId = id;
289
- }, onEvent });
313
+ }, onEvent }); }
314
+ catch (error) { if (!signal.aborted && cancelSignal?.aborted) return cancelled(); throw error; }
290
315
  if (result?.sessionId && sessionId && result.sessionId !== sessionId) throw new Error("Worker session changed during the same assignment");
291
316
  if (result?.sessionId && !sessionId) {
292
317
  sessionId = result.sessionId;
@@ -7,6 +7,7 @@ import { execFileSync } from "node:child_process";
7
7
  import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync } from "node:fs";
8
8
  import { join, resolve } from "node:path";
9
9
  import { isLoopProvider } from "./loop-drivers.mjs";
10
+ import { extractCommitMessage } from "./loop-commit-message.mjs";
10
11
  import { createWorkerSnapshot, inspectWorkerSnapshot } from "./loop-worker-snapshot.mjs";
11
12
 
12
13
  const SHA = /^[0-9a-f]{40,64}$/;
@@ -78,8 +79,14 @@ function fetchBranch(root, remote, branch) {
78
79
  function ancestor(root, base, head) {
79
80
  try { git(root, "merge-base", "--is-ancestor", base, head); return true; } catch { return false; } /* intentional: non-git/non-worktree is treated as not clean */
80
81
  }
82
+ // Host-local loop operational files (daemon lock/log, UI event sidecar) live in
83
+ // the lead checkout but are never part of a handoff or integration.
84
+ const LOOP_OPERATIONAL_PREFIX = ".agents/data/loop/";
81
85
  function assertClean(root) {
82
- if (git(root, "status", "--porcelain=v1", "--untracked-files=all")) throw new Error("Git worktree must be clean before handoff or integration");
86
+ // Not via git(): its trim() would eat the leading status column of the first row.
87
+ const status = execFileSync("git", ["status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"], { cwd: root, encoding: "utf8", timeout: 30_000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
88
+ const dirty = status.split("\0").filter(Boolean).map((row) => row.slice(3)).filter((path) => !path.startsWith(LOOP_OPERATIONAL_PREFIX));
89
+ if (dirty.length) throw new Error("Git worktree must be clean before handoff or integration");
83
90
  }
84
91
  function normaliseReport(job) {
85
92
  const report = job.report ?? job;
@@ -214,7 +221,7 @@ export function createWorkerGitHandoff({ root: inputRoot, worktreesRoot: inputWo
214
221
  const done = typeof result?.resultText === "string" && result.resultText.split(/\r?\n/).some((line) => line.trim() === "AI-DLC-ITEM-COMPLETE");
215
222
  return { done, fingerprint: worktreeFingerprint(workspace.root, changed) };
216
223
  },
217
- async finish({ job, workspace }) {
224
+ async finish({ job, workspace, result }) {
218
225
  const branch = validBranch(job.branch);
219
226
  if (git(workspace.root, "symbolic-ref", "--short", "HEAD") !== branch) throw new Error("Worker switched away from assigned branch");
220
227
  const ownedPaths = scopes(job.ownedPaths);
@@ -222,7 +229,8 @@ export function createWorkerGitHandoff({ root: inputRoot, worktreesRoot: inputWo
222
229
  checkScope(dirty, ownedPaths);
223
230
  if (dirty.length) {
224
231
  git(workspace.root, "add", "-A", "--", ...dirty);
225
- git(workspace.root, "-c", "user.name=AI-DLC worker", "-c", "user.email=aidlc-worker@localhost", "commit", "-m", `AI-DLC ${validId(job.taskId, "task ID")}/${validId(job.itemId, "item ID")} ${validId(job.assignmentId, "assignment ID")}`);
232
+ const message = extractCommitMessage(result?.resultText, { itemId: job.itemId, ownedPaths });
233
+ git(workspace.root, "-c", "user.name=AI-DLC worker", "-c", "user.email=aidlc-worker@localhost", "commit", "-m", message);
226
234
  }
227
235
  const commitSha = git(workspace.root, "rev-parse", "HEAD");
228
236
  const base = validSha(job.baseCommit, "base commit");
@@ -259,7 +267,7 @@ export function createIsolatedWorkerGitHandoff(options) {
259
267
  const done = typeof result?.resultText === "string" && result.resultText.split(/\r?\n/).some((line) => line.trim() === "AI-DLC-ITEM-COMPLETE");
260
268
  return {done, fingerprint};
261
269
  },
262
- async finish({job, workspace}) {
270
+ async finish({job, workspace, result}) {
263
271
  const changed=inspectWorkerSnapshot(workspace.root, workspace.baseline, job.ownedPaths);
264
272
  if (!changed.length) throw new Error("Worker produced no changes");
265
273
  if (worktreePaths(workspace.checkout.root).length || git(workspace.checkout.root,"rev-parse","HEAD") !== job.baseCommit) throw new Error("Host checkout changed during worker run");
@@ -276,8 +284,8 @@ export function createIsolatedWorkerGitHandoff(options) {
276
284
  if (!existsSync(source)) { rmSync(target,{force:true}); continue; }
277
285
  copyFileSync(source,target); chmodSync(target,lstatSync(source).mode & 0o777);
278
286
  }
279
- const result=await host.finish({job,workspace:workspace.checkout});
280
- rmSync(workspace.root,{recursive:true,force:true}); return result;
287
+ const report=await host.finish({job,workspace:workspace.checkout,result});
288
+ rmSync(workspace.root,{recursive:true,force:true}); return report;
281
289
  },
282
290
  async discard({workspace}) { rmSync(workspace.root,{recursive:true,force:true}); return host.discard({workspace:workspace.checkout}); }
283
291
  };
@@ -5,8 +5,26 @@ import { join, resolve } from "node:path";
5
5
  const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
6
6
  const digest = (value) => createHash("sha256").update(value).digest("hex");
7
7
 
8
+ const firstLine = (text) => {
9
+ if (typeof text !== "string") return "";
10
+ return text.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0) ?? "";
11
+ };
12
+
13
+ const slugify = (text) => {
14
+ const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+/, "").slice(0, 127).replace(/-+$/, "");
15
+ return slug || "loop-task";
16
+ };
17
+
18
+ /** Derive a memorable task id (kebab-case slug) and a descriptive title from the
19
+ * requirement's first non-empty line, replacing the opaque `loop-<uuid>` id and
20
+ * the unhelpful `Loop task <id>` title. */
21
+ export function deriveTaskIdentity(requirement) {
22
+ const line = firstLine(requirement);
23
+ return { slug: slugify(line), title: line ? line.slice(0, 256) : "Loop task" };
24
+ }
25
+
8
26
  /** Prepare a durable, idempotent intake without treating it as lifecycle state. */
9
- export async function prepareLoopIntake({ root, taskId, requirement, authority, createTask, readTask }) {
27
+ export async function prepareLoopIntake({ root, taskId, requirement, authority, createTask, readTask, title }) {
10
28
  if (!ID.test(taskId ?? "")) throw new Error("Invalid task ID");
11
29
  if (!["local", "fleet"].includes(authority)) throw new Error("Invalid loop authority");
12
30
  if (typeof requirement !== "string" || !requirement.trim() || Buffer.byteLength(requirement) > 8192) throw new Error("Requirement must contain 1–8192 bytes");
@@ -39,13 +57,13 @@ export async function prepareLoopIntake({ root, taskId, requirement, authority,
39
57
  const existing = lstatSync(requirementPath);
40
58
  if (!existing.isFile() || existing.isSymbolicLink() || digest(readFileSync(requirementPath, "utf8")) !== record.requirementHash) throw new Error("Existing requirement does not match intake");
41
59
  }
42
- const title = `Loop task ${taskId}`;
60
+ const titleText = typeof title === "string" && title.trim() ? title.trim().slice(0, 256) : deriveTaskIdentity(requirement).title;
43
61
  let task = await readTask(taskId);
44
62
  if (!task) {
45
- try { await createTask({ taskId, title }); }
46
- catch (error) { task = await readTask(taskId); if (!task || task.title !== title) throw error; }
63
+ try { await createTask({ taskId, title: titleText }); }
64
+ catch (error) { task = await readTask(taskId); if (!task || task.title !== titleText) throw error; }
47
65
  task ??= await readTask(taskId);
48
66
  }
49
- if (!task || task.id !== taskId || task.title !== title) throw new Error("Existing task conflicts with intake");
67
+ if (!task || task.id !== taskId || task.title !== titleText) throw new Error("Existing task conflicts with intake");
50
68
  return { taskId, authority, requirementPath };
51
69
  }
@@ -4,15 +4,16 @@ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
4
4
  import { createHash, randomUUID } from "node:crypto";
5
5
  import { tmpdir } from "node:os";
6
6
  import { createWorkerSnapshot, inspectWorkerSnapshot } from "./loop-worker-snapshot.mjs";
7
- import { OPENCODE_CONTAINER_ERROR, workerDockerInvocation } from "./loop-worker-docker.mjs";
7
+ import { workerDockerInvocation } from "./loop-worker-docker.mjs";
8
8
  import { isLoopProvider, runAgentTurn } from "./loop-drivers.mjs";
9
+ import { extractCommitMessage } from "./loop-commit-message.mjs";
10
+ import { pathOverlaps } from "./store.mjs";
9
11
  import { claimLocalAssignment, heartbeatLocalAssignment, reportLocalAssignment, releaseLocalAssignment } from "./assignment-store.mjs";
10
12
 
11
13
  const git = (root, ...args) => execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30000 });
12
14
  const lines = (value) => value.split("\0").filter(Boolean);
13
15
  const identifier = (value) => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value);
14
16
  const safePath = (path) => typeof path === "string" && path.length > 0 && !isAbsolute(path) && !path.includes("\\") && !path.split("/").some((part) => !part || part === "." || part === ".." || part === ".git" || part === ".agents") && !/[\x00-\x1f]/.test(path);
15
- const overlaps = (a, b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
16
17
  const scoped = (paths, scopes) => paths.every((path) => safePath(path) && scopes.some((scope) => path === scope || path.startsWith(`${scope}/`)));
17
18
  const statusPaths = (root) => {
18
19
  // --no-renames yields one entry per path, including both sides of a rename.
@@ -29,7 +30,7 @@ const clean = (root) => { if (statusPaths(root).some((path) => !path.startsWith(
29
30
  * state: the caller alone marks canonical items done after inspecting results.
30
31
  * A failed batch is deliberately left for reconciliation, not auto-retried.
31
32
  */
32
- export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, worktreesRoot: inputWorktreesRoot, docker, timeoutMs = 600000, leaseMs = 300000, maxTurns = 12, maxStallTurns = 2, claim = claimLocalAssignment, heartbeat = heartbeatLocalAssignment, report = reportLocalAssignment, release = releaseLocalAssignment, onEvent = () => {} }) {
33
+ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, worktreesRoot: inputWorktreesRoot, docker, timeoutMs = 600000, leaseMs = 300000, maxTurns = 12, maxStallTurns = 2, claim = claimLocalAssignment, heartbeat = heartbeatLocalAssignment, report = reportLocalAssignment, release = releaseLocalAssignment, onEvent = () => {}, signal } = {}) {
33
34
  const root = resolve(inputRoot), worktreesRoot = resolve(inputWorktreesRoot);
34
35
  if (!identifier(taskId) || !Array.isArray(jobs) || !jobs.length) throw new Error("Valid task and non-empty jobs are required");
35
36
  if (root === worktreesRoot || worktreesRoot.startsWith(`${root}${sep}`)) throw new Error("Worker worktree root must be outside the integration checkout");
@@ -39,27 +40,46 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
39
40
  for (const job of jobs) {
40
41
  if (Array.isArray(job.ownedPaths)) job.ownedPaths = [...new Set(job.ownedPaths.map((path) => (typeof path === "string" ? path.replace(/\/+$/, "") : path)).filter((path) => path))];
41
42
  if (!identifier(job.itemId) || !identifier(job.workerId) || !isLoopProvider(job.agent) || typeof job.prompt !== "string" || !job.prompt.trim() || !Array.isArray(job.ownedPaths) || !job.ownedPaths.length || job.ownedPaths.length > 64 || !job.ownedPaths.every(safePath)) throw new Error("Invalid local job");
43
+ // Last-resort guards: the dispatcher partitions batches (selectBuildBatch),
44
+ // so plan data should never reach these.
42
45
  if (ids.has(job.itemId) || workers.has(job.workerId)) throw new Error("Items and workers must be distinct within a batch");
43
46
  ids.add(job.itemId); workers.add(job.workerId);
44
- for (const path of job.ownedPaths) { if (scopes.some((prior) => overlaps(path, prior))) throw new Error(`Overlapping assignment scope: ${path}`); scopes.push(path); }
47
+ for (const path of job.ownedPaths) { if (scopes.some((prior) => pathOverlaps(path, prior))) throw new Error(`Overlapping assignment scope: ${path}`); scopes.push(path); }
45
48
  }
46
- if (jobs.some((job) => job.agent === "opencode")) throw new Error(OPENCODE_CONTAINER_ERROR);
47
- if (process.platform !== "linux" || !docker || typeof docker.image !== "string" || !docker.credentials || typeof docker.credentials !== "object") throw new Error("Linux Docker worker isolation and explicit image and provider credentials are required");
49
+ const hasImages = docker && typeof docker.images === "object" && !Array.isArray(docker.images);
50
+ if (process.platform !== "linux" || !docker || (typeof docker.image !== "string" && !hasImages) || !docker.credentials || typeof docker.credentials !== "object") throw new Error("Linux Docker worker isolation and explicit image and provider credentials are required");
51
+ const resolveImage = (agent) => {
52
+ const specific = hasImages && typeof docker.images[agent] === "string" && docker.images[agent] ? docker.images[agent] : undefined;
53
+ return specific ?? docker.image;
54
+ };
48
55
  const uid = process.getuid?.(), gid = process.getgid?.();
49
56
  if (!uid || !gid) throw new Error("Docker workers require a non-root host user");
57
+ const resolvedImages = {};
58
+ for (const agent of new Set(jobs.map((job) => job.agent))) {
59
+ const agentImage = resolveImage(agent);
60
+ if (typeof agentImage !== "string" || !agentImage) throw new Error(`Worker Docker image is required for provider ${agent}`);
61
+ resolvedImages[agent] = agentImage;
62
+ }
50
63
  for (const job of jobs) {
51
64
  const keys = docker.credentials[job.agent];
52
- workerDockerInvocation({ workspace: root, image: docker.image, command: job.agent, args: [], credentialEnv: keys, hostEnv: process.env, uid, gid });
65
+ workerDockerInvocation({ workspace: root, image: resolvedImages[job.agent], command: job.agent, args: [], credentialEnv: keys, hostEnv: process.env, uid, gid });
53
66
  }
54
67
  const dockerEnv = { PATH: process.env.PATH ?? "/usr/bin:/bin" };
55
68
  const dockerCheck = (...args) => execFileSync("docker", args, { env: dockerEnv, stdio: "ignore", timeout: 30000 });
56
- let image;
69
+ // Run each provider CLI through workerDockerInvocation (not a bare `docker run`)
70
+ // so OpenCode's HOME/XDG roots are redirected to a writable tmpfs. A raw
71
+ // invocation fails under `--read-only` with `EROFS: mkdir '/home/node/.local'`.
72
+ const preflightWorkspace = mkdtempSync(join(tmpdir(), "aidlc-worker-preflight-"));
57
73
  try {
58
74
  dockerCheck("info");
59
- image=execFileSync("docker",["image","inspect","--format","{{.Id}}",docker.image],{env:dockerEnv,encoding:"utf8",stdio:["ignore","pipe","ignore"],timeout:30000}).trim();
60
- if (!/^sha256:[a-f0-9]{64}$/.test(image)) throw new Error("Docker worker image lacks immutable image ID");
61
- for (const agent of new Set(jobs.map((job) => job.agent))) dockerCheck("run", "--rm", "--network", "none", "--read-only", "--user", `${uid}:${gid}`, "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--entrypoint", agent, image, "--version");
75
+ for (const [agent, agentImage] of Object.entries(resolvedImages)) {
76
+ const id=execFileSync("docker",["image","inspect","--format","{{.Id}}",agentImage],{env:dockerEnv,encoding:"utf8",stdio:["ignore","pipe","ignore"],timeout:30000}).trim();
77
+ if (!/^sha256:[a-f0-9]{64}$/.test(id)) throw new Error("Docker worker image lacks immutable image ID");
78
+ const invocation = workerDockerInvocation({ workspace: preflightWorkspace, image: agentImage, command: agent, args: ["--version"], credentialEnv: docker.credentials[agent], hostEnv: process.env, uid, gid });
79
+ execFileSync(invocation.command, invocation.args, { env: invocation.env, stdio: "ignore", timeout: 30000 });
80
+ }
62
81
  } catch { throw new Error("Docker engine, image, or provider CLI preflight failed"); }
82
+ finally { rmSync(preflightWorkspace, { recursive: true, force: true }); }
63
83
  clean(root);
64
84
  const base = git(root, "rev-parse", "HEAD").trim();
65
85
  const initialBranch = git(root, "symbolic-ref", "--short", "HEAD").trim();
@@ -86,6 +106,10 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
86
106
  const execute = async ({ job, assignment, workspace, snapshot, baseline, branch }) => {
87
107
  let renewalError;
88
108
  const controller = new AbortController();
109
+ // An external cancel (daemon shutdown stage 2) aborts every worker turn the
110
+ // same way a lost lease does; the batch then fails and is not integrated.
111
+ const onCancel = () => { renewalError ??= new Error("Worker batch cancelled by daemon shutdown"); controller.abort(); };
112
+ if (signal?.aborted) onCancel(); else signal?.addEventListener("abort", onCancel, { once: true });
89
113
  const timer = setInterval(() => { Promise.resolve().then(() => heartbeat(root, taskId, assignment.id, leaseMs)).catch((error) => { renewalError = error; controller.abort(); }); }, Math.max(500, Math.floor(leaseMs / 3)));
90
114
  try {
91
115
  const home=mkdtempSync(join(tmpdir(), "aidlc-local-home-")); chmodSync(home,0o700);
@@ -95,10 +119,10 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
95
119
  if (renewalError || controller.signal.aborted) throw renewalError ?? new Error("Worker lease lost");
96
120
  const containerName = `aidlc-worker-${randomUUID()}`;
97
121
  const spawnProcess = (command, args, options) => {
98
- const invocation = workerDockerInvocation({ workspace: snapshot, image, command, args, credentialEnv: docker.credentials[job.agent], hostEnv: process.env, uid, gid, containerName, sessionHome:home });
122
+ const invocation = workerDockerInvocation({ workspace: snapshot, image: resolvedImages[job.agent], command, args, credentialEnv: docker.credentials[job.agent], hostEnv: process.env, uid, gid, containerName, sessionHome:home });
99
123
  return spawn(invocation.command, invocation.args, { ...options, cwd: snapshot, env: invocation.env, shell: false });
100
124
  };
101
- try { result = await runAgentTurn({ agent: job.agent, root: "/workspace", sessionId, spawnProcess, prompt: `${job.prompt}\n\nOnly modify assigned paths: ${job.ownedPaths.join(", ")}. Do not modify AI-DLC lifecycle state or approve gates. End with AI-DLC-ITEM-COMPLETE on its own line.`, timeoutMs, signal: controller.signal, onSessionId: async (id) => { await heartbeat(root, taskId, assignment.id, leaseMs, id); } }); }
125
+ try { result = await runAgentTurn({ agent: job.agent, root: "/workspace", sessionId, spawnProcess, prompt: `${job.prompt}\n\nOnly modify assigned paths: ${job.ownedPaths.join(", ")}. Do not modify AI-DLC lifecycle state or approve gates. End 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.`, timeoutMs, signal: controller.signal, onSessionId: async (id) => { await heartbeat(root, taskId, assignment.id, leaseMs, id); } }); }
102
126
  finally {
103
127
  try { dockerCheck("rm", "-f", containerName); }
104
128
  catch {
@@ -136,12 +160,15 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
136
160
  // Fold any commits made by the agent into one scoped assignment commit.
137
161
  git(workspace, "reset", "--soft", base);
138
162
  git(workspace, "add", "-A", "--", ...changed);
139
- if (git(workspace, "diff", "--cached", "--name-only").trim()) git(workspace, "-c", "user.name=AI-DLC worker", "-c", "user.email=aidlc-worker@localhost", "commit", "-m", `AI-DLC ${taskId}/${job.itemId}`);
163
+ if (git(workspace, "diff", "--cached", "--name-only").trim()) {
164
+ const message = extractCommitMessage(result?.resultText, { itemId: job.itemId, ownedPaths: job.ownedPaths });
165
+ git(workspace, "-c", "user.name=AI-DLC worker", "-c", "user.email=aidlc-worker@localhost", "commit", "-m", message);
166
+ }
140
167
  const commit = git(workspace, "rev-parse", "HEAD").trim(), committed = commitPaths(workspace, base, commit);
141
168
  if (commit === base || !committed.length || !scoped(committed, job.ownedPaths)) throw new Error("Worker commit is outside assignment scope");
142
169
  await report(root, taskId, assignment.id, { commit, changedPaths: committed, checkpoint: `provider=${job.agent}` });
143
170
  return { assignmentId: assignment.id, itemId: job.itemId, workerId: job.workerId, agent: job.agent, baseCommit: base, branch, commit, changedPaths: committed, workspace };
144
- } finally { clearInterval(timer); }
171
+ } finally { clearInterval(timer); signal?.removeEventListener("abort", onCancel); }
145
172
  };
146
173
  const results = await Promise.allSettled(prepared.map(execute));
147
174
  const failure = results.find((result) => result.status === "rejected");
@@ -10,7 +10,7 @@ import { acquireLoopLease, assertLoopPath, readLiveLease } from "./loop-lease.mj
10
10
  import { isLoopProvider, LOOP_PROVIDER_LIST } from "./loop-drivers.mjs";
11
11
 
12
12
  export const DEFAULT_LIMITS = Object.freeze({ maxTurns: 12, turnTimeoutMs: 600000, maxStallTurns: 2 });
13
- const states = new Set(["running", "gate", "paused", "blocked", "terminal", "complete"]);
13
+ const states = new Set(["running", "gate", "paused", "blocked", "terminal", "complete", "stopped"]);
14
14
  const hash = (value) => createHash("sha256").update(value).digest("hex");
15
15
  const iso = () => new Date().toISOString();
16
16
  const bounded = (value, name, max) => { const n = Number(value); if (!Number.isSafeInteger(n) || n < 1 || n > max) throw new Error(`${name} must be an integer from 1 to ${max}`); return n; };
@@ -152,9 +152,31 @@ export const classifyAction = (action) => {
152
152
  };
153
153
 
154
154
  function approvalCount(task) { return task.evidence.filter((item) => item.kind === "approval" && item.result === "pass").length; }
155
+ function pendingFeedback(task) {
156
+ const evidence = Array.isArray(task?.evidence) ? task.evidence : [];
157
+ const pending = [];
158
+ for (let i = 0; i < evidence.length; i++) {
159
+ const entry = evidence[i];
160
+ if (entry?.kind !== "diagnostic" || typeof entry.detail !== "string") continue;
161
+ const match = entry.detail.match(/^Cancelled gate wait at (G0_confirm|G1_review|G2_codereview): /);
162
+ if (!match) continue;
163
+ const approvedAfter = evidence.slice(i + 1).some((later) => later?.kind === "approval" && later.result === "pass" && later.gate === match[1]);
164
+ if (!approvedAfter) pending.push(entry.detail);
165
+ }
166
+ return pending;
167
+ }
155
168
  function outputStop(record, action, gateView) { return { taskId: record.taskId, runId: record.runId, status: record.status, iteration: record.iteration, sessionId: record.sessionId, stopReason: record.stopReason, nextAction: action, ...(gateView ? { gateView } : {}) }; }
156
169
 
157
- export async function runLoop({ root: rootInput, taskId, agent, mode = "run", limits: limitInput, driver, onEvent = () => {}, preflight = false, precondition }) {
170
+ /**
171
+ * `signal` requests a graceful stop at the next turn boundary; `cancelSignal`
172
+ * additionally cancels the in-flight agent turn. Either way the run ends as
173
+ * `stopped` (the canonical task is NOT paused) and a later resume continues
174
+ * from fresh canonical nextAction. A failure observed while stopping is a
175
+ * shutdown side effect (a terminal Ctrl-C also reaches the agent child), not a
176
+ * transport failure, so it never pauses the task.
177
+ */
178
+ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", limits: limitInput, driver, dispatchBuild, onEvent = () => {}, preflight = false, precondition, signal: stopSignal, cancelSignal }) {
179
+ const stopping = () => Boolean(stopSignal?.aborted || cancelSignal?.aborted);
158
180
  const root = validateRoot(rootInput);
159
181
  // --- Validate mode and load/create the loop record -------------------------
160
182
  let record = readLoopRecord(root, taskId);
@@ -186,6 +208,10 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
186
208
  envelope(root, "state.mjs", ["task", "status", taskId, "--status", "active"]);
187
209
  record.pausedByLoop = false;
188
210
  current = readCanonical(root, taskId);
211
+ } else if (mode === "resume" && record.pausedByLoop && current.task.status === "active") {
212
+ // A human (CLI or dashboard) already re-activated the task: the loop no
213
+ // longer owns that pause, so a later human pause must not be auto-undone.
214
+ record.pausedByLoop = false;
189
215
  }
190
216
  const initial = fingerprint(root, current.task, current.action);
191
217
  if (mode === "resume" && record.lastFingerprint && record.lastFingerprint !== initial.digest) onEvent({ type: "context_changed", message: "Canonical task context changed since the last checkpoint; continuing from fresh nextAction", previousCheckpointAt: record.checkpoint?.capturedAt ?? null });
@@ -193,6 +219,10 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
193
219
  record.checkpoint = checkpoint(current.task, current.action, initial.tree, record.iteration);
194
220
  record.status = "running"; record.stopReason = null; record.updatedAt = iso(); writeLoopRecord(root, record);
195
221
  // --- Main turn loop --------------------------------------------------------
222
+ // maxTurns bounds one runLoop invocation (a supervised run), not the task's
223
+ // lifetime: record.iteration stays a cumulative observability counter, so a
224
+ // long multi-gate task is not permanently re-paused on every resume.
225
+ let runTurns = 0;
196
226
  for (;;) {
197
227
  const kind = classifyAction(current.action);
198
228
  if (kind !== "run_phase") {
@@ -202,39 +232,79 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
202
232
  const gateView = kind === "await_user" ? cli(root, "gate-view.mjs", [taskId]).trim() : undefined;
203
233
  return outputStop(record, current.action, gateView);
204
234
  }
205
- if (record.iteration >= limits.maxTurns) return pause("turn limit reached");
235
+ if (stopping()) return stop(cancelSignal?.aborted ? "daemon cancelled the run" : "daemon shutdown");
236
+ if (runTurns >= limits.maxTurns) return pause("turn limit reached");
237
+ if (dispatchBuild && current.task.phase === "build" && current.task.tasks.some((item) => item.status === "todo" || item.status === "in_progress")) {
238
+ try {
239
+ await dispatchBuild({ root, taskId, task: current.task, action: current.action, record });
240
+ } catch (error) {
241
+ if (stopping()) return stop("daemon shutdown interrupted build dispatch");
242
+ return pause(`build dispatch failure: ${error.message}`);
243
+ }
244
+ const refreshed = readCanonical(root, taskId);
245
+ const signal = fingerprint(root, refreshed.task, refreshed.action);
246
+ record.iteration += 1; runTurns += 1;
247
+ record.stallTurns = signal.digest === record.lastFingerprint ? record.stallTurns + 1 : 0;
248
+ record.lastFingerprint = signal.digest;
249
+ record.checkpoint = checkpoint(refreshed.task, refreshed.action, signal.tree, record.iteration);
250
+ record.updatedAt = iso(); writeLoopRecord(root, record);
251
+ current = refreshed;
252
+ if (stopping()) continue; // the loop top records `stopped` (or the reached stop) instead of a stall pause
253
+ if (classifyAction(current.action) === "run_phase" && record.stallTurns >= limits.maxStallTurns) return pause(`${record.stallTurns} consecutive turns without observable progress`);
254
+ continue;
255
+ }
206
256
  let prompt;
207
257
  try {
208
258
  const packet = JSON.parse(cli(root, "context.mjs", [taskId, "--phase", current.action.phase, ...(current.action.itemId ? ["--item", current.action.itemId] : []), "--format", "json"]));
209
259
  if (typeof packet?.content !== "string" || !packet.content.trim()) throw new Error("Context packet was empty or invalid");
210
260
  prompt = packet.content;
211
261
  } catch {
262
+ // A terminal Ctrl-C also kills the context.mjs child: that is a shutdown, not a broken packet.
263
+ if (stopping()) return stop("daemon shutdown interrupted context generation");
212
264
  // No driver has started for this turn, so it is safe to resume after repairing context.
213
265
  // Do not persist arbitrary subprocess output or malformed context in the stop reason.
214
266
  return pause("context packet generation or validation failed; inspect context.mjs and resume after repair");
215
267
  }
268
+ const feedback = pendingFeedback(current.task);
269
+ if (feedback.length) {
270
+ prompt = `Human feedback (revise the current review artifact accordingly, then re-present the gate):\n${feedback.map((text) => `- ${text}`).join("\n")}\n\n${prompt}`;
271
+ }
216
272
  const beforeApprovals = approvalCount(current.task);
273
+ onEvent({ type: "turn_start", taskId, iteration: record.iteration, phase: current.action.phase, agent: record.agent });
217
274
  let observedId = record.sessionId;
218
275
  try {
219
- const result = await driver({ agent: record.agent, root, sessionId: record.sessionId ?? undefined, prompt: `${prompt}\n\nLoop supervisor: complete the current authorized phase or item. Use canonical lifecycle commands for evidence and gates. Never approve a human gate. Stop at a gate and report progress.`, timeoutMs: limits.turnTimeoutMs, onSessionId: (id) => { if (typeof id !== "string" || !id) throw new Error("Invalid agent session id"); observedId = id; record.sessionId = id; record.updatedAt = iso(); writeLoopRecord(root, record); }, onEvent: (event) => { onEvent(event); if (event?.type === "lifecycle_command") { const observed = readCanonical(root, taskId); const signal = fingerprint(root, observed.task, observed.action); record.checkpoint = checkpoint(observed.task, observed.action, signal.tree, record.iteration); record.updatedAt = iso(); writeLoopRecord(root, record); } } });
276
+ const result = await driver({ agent: record.agent, root, sessionId: record.sessionId ?? undefined, prompt: `${prompt}\n\nLoop supervisor: complete the current authorized phase or item. Use canonical lifecycle commands for evidence and gates. Never approve a human gate. Stop at a gate and report progress.`, timeoutMs: limits.turnTimeoutMs, ...(cancelSignal ? { signal: cancelSignal } : {}), onSessionId: (id) => { if (typeof id !== "string" || !id) throw new Error("Invalid agent session id"); observedId = id; record.sessionId = id; record.updatedAt = iso(); writeLoopRecord(root, record); }, onEvent: (event) => { onEvent(event); if (event?.type === "lifecycle_command") { const observed = readCanonical(root, taskId); const signal = fingerprint(root, observed.task, observed.action); record.checkpoint = checkpoint(observed.task, observed.action, signal.tree, record.iteration); record.updatedAt = iso(); writeLoopRecord(root, record); } } });
220
277
  if (!observedId && result?.sessionId) { observedId = result.sessionId; record.sessionId = observedId; record.updatedAt = iso(); writeLoopRecord(root, record); }
221
278
  if (!observedId) throw new Error("Agent turn ended without a resumable session id");
222
279
  } catch (error) {
223
280
  current = readCanonical(root, taskId);
224
281
  if (classifyAction(current.action) === "await_user") continue;
282
+ if (stopping()) return stop(cancelSignal?.aborted ? "daemon cancelled the in-flight turn" : "daemon shutdown interrupted the turn");
225
283
  return pause(`agent transport failure: ${error.message}`);
226
284
  }
285
+ onEvent({ type: "turn_end", taskId, iteration: record.iteration });
227
286
  const refreshed = readCanonical(root, taskId);
228
287
  if (approvalCount(refreshed.task) !== beforeApprovals) return pause("Approval evidence changed during an agent turn");
229
288
  const signal = fingerprint(root, refreshed.task, refreshed.action);
230
- record.iteration += 1;
289
+ record.iteration += 1; runTurns += 1;
231
290
  record.stallTurns = signal.digest === record.lastFingerprint ? record.stallTurns + 1 : 0;
232
291
  record.lastFingerprint = signal.digest;
233
292
  record.checkpoint = checkpoint(refreshed.task, refreshed.action, signal.tree, record.iteration);
234
293
  record.updatedAt = iso(); writeLoopRecord(root, record);
235
294
  current = refreshed;
295
+ if (stopping()) continue; // the loop top records `stopped` (or the reached stop) instead of a stall pause
236
296
  if (classifyAction(current.action) === "run_phase" && record.stallTurns >= limits.maxStallTurns) return pause(`${record.stallTurns} consecutive turns without observable progress`);
237
297
  }
298
+ function stop(reason) {
299
+ const stopped = readCanonical(root, taskId);
300
+ const signal = fingerprint(root, stopped.task, stopped.action);
301
+ record.status = "stopped"; record.stopReason = reason;
302
+ record.lastFingerprint = signal.digest;
303
+ record.checkpoint = checkpoint(stopped.task, stopped.action, signal.tree, record.iteration, reason);
304
+ record.updatedAt = iso(); writeLoopRecord(root, record);
305
+ onEvent({ type: "loop_stopped", taskId, reason });
306
+ return outputStop(record, stopped.action);
307
+ }
238
308
  function pause(reason) {
239
309
  const fresh = readCanonical(root, taskId);
240
310
  record.status = "paused"; record.stopReason = reason;
@@ -0,0 +1,60 @@
1
+ import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+
4
+ /** The event-log sidecar rotates to `<path>.1` (one generation kept) once it
5
+ * reaches this size, so a 24/7 daemon cannot grow it without bound. */
6
+ export const EVENT_LOG_MAX_BYTES = 5 * 1024 * 1024;
7
+
8
+ let preparedDirectory;
9
+
10
+ /** Append one structured event to the optional UI event-log sidecar (JSONL).
11
+ * Each record gets an `at` timestamp; the directory is created on demand. */
12
+ function appendEventLog(event) {
13
+ const path = process.env.AIDLC_LOOP_EVENT_LOG;
14
+ if (!path) return;
15
+ try {
16
+ const directory = dirname(path);
17
+ if (preparedDirectory !== directory) { mkdirSync(directory, { recursive: true }); preparedDirectory = directory; }
18
+ try { if (statSync(path).size >= EVENT_LOG_MAX_BYTES) renameSync(path, `${path}.1`); }
19
+ catch (error) { if (error?.code !== "ENOENT") throw error; }
20
+ const record = event && typeof event === "object" && !Array.isArray(event) && !("at" in event) ? { at: new Date().toISOString(), ...event } : event;
21
+ appendFileSync(path, `${JSON.stringify(record)}\n`);
22
+ } catch { /* best-effort: never break the loop on a log write failure */ }
23
+ }
24
+
25
+ /** Render foreground loop progress to stderr without polluting stdout JSON. */
26
+ export function streamLog(event) {
27
+ appendEventLog(event);
28
+ if (!event || typeof event.type !== "string") return;
29
+ switch (event.type) {
30
+ case "turn_start":
31
+ process.stderr.write(`\n━━ turn ${Number(event.iteration ?? 0) + 1} · phase ${event.phase ?? "?"} · agent ${event.agent ?? "?"} ━━\n`);
32
+ break;
33
+ case "turn_end":
34
+ process.stderr.write(`━━ turn done ━━\n`);
35
+ break;
36
+ case "agent_text":
37
+ process.stderr.write(`${event.text ?? ""}\n`);
38
+ break;
39
+ case "agent_tool":
40
+ process.stderr.write(` › tool: ${event.tool ?? "?"}\n`);
41
+ break;
42
+ case "lifecycle_command":
43
+ process.stderr.write(` › command ${event.status ?? "?"} (exit ${event.exitCode ?? "?"})\n`);
44
+ break;
45
+ case "daemon_turn_error":
46
+ process.stderr.write(` ! ${event.taskId ?? "?"}: ${event.message ?? "error"} (retry in ${Math.round(Number(event.retryInMs ?? 0) / 1000)}s)\n`);
47
+ break;
48
+ case "loop_stopped":
49
+ process.stderr.write(` ■ ${event.taskId ?? "?"} stopped: ${event.reason ?? ""}\n`);
50
+ break;
51
+ case "gate":
52
+ process.stderr.write(`\n${event.gateView ?? `Gate ${event.gate ?? "?"} reached — approve from the trusted terminal.`}\n`);
53
+ break;
54
+ case "worker_image_build":
55
+ process.stderr.write(`\n▶ building worker image ${event.image ?? "?"} (provider ${event.provider ?? "?"})…\n`);
56
+ break;
57
+ default:
58
+ break;
59
+ }
60
+ }