@felixdotgo/aidlc-kit 0.1.0 → 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 (150) hide show
  1. package/DEVELOPMENT.md +14 -0
  2. package/README.md +14 -6
  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/schemas/manifest.schema.json +1 -1
  11. package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +5 -0
  12. package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +245 -0
  13. package/dist/assets/.agents/aidlc/scripts/lib/loop-commit-message.mjs +45 -0
  14. package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +232 -0
  15. package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +21 -4
  16. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +86 -28
  17. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +7 -0
  18. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +22 -6
  19. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +46 -15
  20. package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +22 -9
  21. package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +23 -5
  22. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +43 -15
  23. package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +82 -6
  24. package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +60 -0
  25. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +142 -5
  26. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +9 -1
  27. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +31 -5
  28. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-image.mjs +64 -0
  29. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-snapshot.mjs +3 -2
  30. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +59 -4
  31. package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +85 -73
  32. package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +19 -7
  33. package/dist/assets/.agents/aidlc/scripts/loop.mjs +2 -1
  34. package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +2 -0
  35. package/dist/assets/.agents/aidlc/scripts/state.mjs +4 -0
  36. package/dist/assets/.agents/aidlc/templates/design.md +9 -0
  37. package/dist/assets/.agents/aidlc/templates/intent.md +4 -0
  38. package/dist/assets/.agents/aidlc/templates/workplan.md +1 -1
  39. package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +1 -1
  40. package/dist/assets/ui/bulma.min.css +3 -0
  41. package/dist/assets/ui/dashboard.css +414 -0
  42. package/dist/services/aidlc-coordination/Dockerfile +11 -3
  43. package/dist/services/aidlc-coordination/compose.yaml +12 -0
  44. package/dist/services/aidlc-coordination/package-lock.json +3 -1
  45. package/dist/services/aidlc-coordination/package.json +9 -4
  46. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +59 -4
  47. package/dist/services/aidlc-coordination/src/repository.mjs +66 -0
  48. package/dist/services/aidlc-coordination/src/server.mjs +9 -3
  49. package/dist/services/aidlc-coordination/test/fleet.test.mjs +21 -0
  50. package/dist/services/aidlc-coordination/test/server-health-auth.test.mjs +67 -0
  51. package/dist/src/adapters.d.ts +0 -3
  52. package/dist/src/adapters.js +11 -6
  53. package/dist/src/cli/argv.d.ts +12 -0
  54. package/dist/src/cli/argv.js +62 -0
  55. package/dist/src/cli/commands.d.ts +8 -0
  56. package/dist/src/cli/commands.js +129 -0
  57. package/dist/src/cli/options.d.ts +25 -0
  58. package/dist/src/cli/options.js +32 -0
  59. package/dist/src/cli/prompt.d.ts +9 -0
  60. package/dist/src/cli/prompt.js +66 -0
  61. package/dist/src/cli.js +67 -217
  62. package/dist/src/context.js +2 -0
  63. package/dist/src/gate.d.ts +1 -0
  64. package/dist/src/gate.js +51 -37
  65. package/dist/src/installer.d.ts +19 -7
  66. package/dist/src/installer.js +111 -178
  67. package/dist/src/legacy.js +1 -0
  68. package/dist/src/managed-content.d.ts +7 -0
  69. package/dist/src/managed-content.js +15 -4
  70. package/dist/src/managed-planning.d.ts +65 -0
  71. package/dist/src/managed-planning.js +219 -0
  72. package/dist/src/mcp-setup.js +3 -1
  73. package/dist/src/model/config-contracts.d.ts +65 -0
  74. package/dist/src/model/config-contracts.js +1 -0
  75. package/dist/src/model/install-contracts.d.ts +29 -0
  76. package/dist/src/model/install-contracts.js +1 -0
  77. package/dist/src/model/lifecycle-enums.d.ts +13 -0
  78. package/dist/src/model/lifecycle-enums.js +1 -0
  79. package/dist/src/model/loop.d.ts +20 -0
  80. package/dist/src/model/loop.js +1 -0
  81. package/dist/src/model/manifest.d.ts +16 -0
  82. package/dist/src/model/manifest.js +1 -0
  83. package/dist/src/model/records.d.ts +138 -0
  84. package/dist/src/model/records.js +1 -0
  85. package/dist/src/model.d.ts +11 -264
  86. package/dist/src/model.js +1 -1
  87. package/dist/src/profiles.d.ts +8 -0
  88. package/dist/src/profiles.js +63 -8
  89. package/dist/src/project-migration.d.ts +1 -0
  90. package/dist/src/project-migration.js +7 -0
  91. package/dist/src/state/archive-record.d.ts +9 -0
  92. package/dist/src/state/archive-record.js +26 -0
  93. package/dist/src/state/commands.d.ts +18 -0
  94. package/dist/src/state/commands.js +27 -0
  95. package/dist/src/state/domain.d.ts +9 -0
  96. package/dist/src/state/domain.js +9 -0
  97. package/dist/src/state/guards.d.ts +7 -0
  98. package/dist/src/state/guards.js +17 -0
  99. package/dist/src/state/io.d.ts +10 -0
  100. package/dist/src/state/io.js +35 -0
  101. package/dist/src/state/items.d.ts +24 -0
  102. package/dist/src/state/items.js +147 -0
  103. package/dist/src/state/lessons.d.ts +7 -0
  104. package/dist/src/state/lessons.js +74 -0
  105. package/dist/src/state/lifecycle.d.ts +5 -0
  106. package/dist/src/state/lifecycle.js +78 -0
  107. package/dist/src/state/lock.d.ts +7 -0
  108. package/dist/src/state/lock.js +125 -0
  109. package/dist/src/state/memory.d.ts +8 -0
  110. package/dist/src/state/memory.js +100 -0
  111. package/dist/src/state/migrate.d.ts +17 -0
  112. package/dist/src/state/migrate.js +60 -0
  113. package/dist/src/state/partition.d.ts +15 -0
  114. package/dist/src/state/partition.js +90 -0
  115. package/dist/src/state/paths.d.ts +11 -0
  116. package/dist/src/state/paths.js +12 -0
  117. package/dist/src/state/render.d.ts +26 -0
  118. package/dist/src/state/render.js +32 -0
  119. package/dist/src/state/store.d.ts +4 -0
  120. package/dist/src/state/store.js +75 -0
  121. package/dist/src/state/transitions.d.ts +21 -0
  122. package/dist/src/state/transitions.js +95 -0
  123. package/dist/src/state/validate.d.ts +4 -0
  124. package/dist/src/state/validate.js +143 -0
  125. package/dist/src/state.d.ts +20 -117
  126. package/dist/src/state.js +20 -815
  127. package/dist/src/task-ui.d.ts +54 -0
  128. package/dist/src/task-ui.js +1570 -0
  129. package/dist/src/upgrade-board.d.ts +5 -0
  130. package/dist/src/upgrade-board.js +89 -0
  131. package/dist/src/upgrade.d.ts +3 -5
  132. package/dist/src/upgrade.js +174 -255
  133. package/dist/src/workflow.js +4 -4
  134. package/docs/README.md +6 -2
  135. package/docs/architecture.md +12 -8
  136. package/docs/command-reference.md +83 -19
  137. package/docs/configuration.md +30 -7
  138. package/docs/coordination-service.md +20 -5
  139. package/docs/design-decisions.md +83 -0
  140. package/docs/development.md +41 -2
  141. package/docs/loop-local-run.md +495 -0
  142. package/docs/loop-runbook.md +235 -0
  143. package/docs/operating-workflow.md +5 -2
  144. package/docs/testing-and-release.md +11 -7
  145. package/docs/troubleshooting.md +125 -0
  146. package/package.json +5 -2
  147. package/dist/src/ledger.d.ts +0 -22
  148. package/dist/src/ledger.js +0 -119
  149. package/dist/src/tracker.d.ts +0 -29
  150. package/dist/src/tracker.js +0 -41
@@ -1,5 +1,9 @@
1
+ /**
2
+ * Fleet coordination: job reconciliation, coordinator loop, and worker loop
3
+ * for multi-host AI-DLC execution.
4
+ */
1
5
  import { createHash, randomUUID } from "node:crypto";
2
- import { readyTaskItems, formatGateView } from "./store.mjs";
6
+ import { readyTaskItems, formatGateView, pathsOverlap } from "./store.mjs";
3
7
  import { isLoopProvider, LOOP_PROVIDER_LIST } from "./loop-drivers.mjs";
4
8
 
5
9
  export const FLEET_LIMITS = Object.freeze({ maxTurns: 12, turnTimeoutMs: 600_000, maxStallTurns: 2, maxWorkers: 4, leaseMs: 300_000, heartbeatMs: 60_000 });
@@ -33,9 +37,15 @@ function readyItems(task) {
33
37
  const ready = new Set(readyTaskItems(task).ready);
34
38
  return items.filter((item) => item.status === "todo" && ready.has(item.id));
35
39
  }
36
- const overlaps = (left, right) => left === right || (left.endsWith("/") && right.startsWith(left)) || (right.endsWith("/") && left.startsWith(right));
37
- const pathsOverlap = (left = [], right = []) => left.some((a) => right.some((b) => overlaps(a, b)));
38
- 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); };
39
49
 
40
50
  function key(...parts) { return digest(parts); }
41
51
 
@@ -109,13 +119,19 @@ async function withHeartbeat(renew, intervalMs, work) {
109
119
  /**
110
120
  * One fenced coordinator invocation. The repository is the remote authority;
111
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.
112
127
  */
113
- 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 = () => {} }) {
114
129
  assertId(taskId, "task ID"); assertId(ownerId, "owner ID");
115
130
  if (!Array.isArray(workerIds) || new Set(workerIds).size !== workerIds.length) throw new Error("workerIds must be distinct IDs");
116
131
  for (const workerId of workerIds) assertId(workerId, "worker ID");
117
132
  if (workerProviders !== undefined && (!workerProviders || typeof workerProviders !== "object" || Array.isArray(workerProviders) || Object.keys(workerProviders).length !== workerIds.length || workerIds.some((workerId) => !isLoopProvider(workerProviders[workerId])) || Object.keys(workerProviders).some((workerId) => !workerIds.includes(workerId)))) throw new Error(`workerProviders must map each configured worker ID to ${LOOP_PROVIDER_LIST}`);
118
133
  const limits = validateFleetLimits(inputLimits);
134
+ // --- Validate limits and normalize inputs ---------------------------------
119
135
  integer(pollMs, "pollMs", 300000);
120
136
  const pickupTimeoutMs = integer(inputPickupTimeout ?? limits.leaseMs, "pickupTimeoutMs", 3_600_000);
121
137
  const readTask = required(repository, "readTask");
@@ -134,15 +150,19 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
134
150
  const readySeen = new Map();
135
151
  try {
136
152
  return await withHeartbeat(() => renewLead({ taskId, ownerId, epoch, leaseMs: limits.leaseMs }), limits.heartbeatMs, async (leaseSignal) => {
137
- 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 });
138
157
  for (;;) {
139
- 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");
140
160
  if (Date.now() - startedAt >= limits.maxTurns * limits.turnTimeoutMs) return { status: "paused", taskId, reason: "coordinator wall-time limit reached" };
141
161
  snapshot = validateSnapshot(await readTask({ taskId }), taskId);
142
162
  const kind = actionKind(snapshot.nextAction);
143
163
  if (kind === "await_user") {
144
164
  const reconciled = await reconcileFleetJobs({ repository, taskId, leadEpoch: epoch, snapshot, handoff, allowCodeOnlyRetry, quiesceMs: limits.turnTimeoutMs + limits.heartbeatMs + 1000 });
145
- 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; }
146
166
  if (reconciled.pending.length) return { status: "reconcile", taskId, revision: snapshot.revision, pending: reconciled.pending };
147
167
  const gate = snapshot.nextAction.gate;
148
168
  const baseReview = await (gateView ?? ((value) => formatGateView(value.task, [], "markdown")))(snapshot);
@@ -158,7 +178,7 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
158
178
  const before = digest({ revision: snapshot.revision, action: snapshot.nextAction });
159
179
  if (kind === "dispatch" || (kind === "run_phase" && snapshot.task.phase === "build")) {
160
180
  const reconciled = await reconcileFleetJobs({ repository, taskId, leadEpoch: epoch, snapshot, handoff, allowCodeOnlyRetry, quiesceMs: limits.turnTimeoutMs + limits.heartbeatMs + 1000 });
161
- 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; }
162
182
  if (reconciled.pending.length) {
163
183
  const verified = reconciled.pending.filter((entry) => entry.artifact?.valid);
164
184
  if (verified.length && typeof handoff?.integrate === "function" && typeof completeItem === "function") {
@@ -207,7 +227,7 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
207
227
  }
208
228
  }
209
229
  onEvent({ type: "jobs_active", taskId, assignments: active.map((job) => job.assignmentId) });
210
- await wait(pollMs);
230
+ await wait(pollMs, signal, stopSignal);
211
231
  continue;
212
232
  }
213
233
  }
@@ -215,7 +235,11 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
215
235
  if (typeof packet !== "function" || typeof driver !== "function") throw new Error("Fleet lead requires packet and driver adapters");
216
236
  const prompt = await packet(snapshot, epoch);
217
237
  if (typeof prompt !== "string" || !prompt) throw new Error("Empty fleet lead packet");
218
- 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
+ }
219
243
  turns++;
220
244
  const after = validateSnapshot(await readTask({ taskId }), taskId);
221
245
  const nextSignal = digest({ revision: after.revision, action: after.nextAction });
@@ -229,11 +253,14 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
229
253
  }
230
254
  }
231
255
 
232
- /** A worker claims one attempt, persists its exact CLI session, and reports a Git artifact. */
233
- 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 = () => {} }) {
234
260
  assertId(workerId, "worker ID");
235
261
  if (workerAgent !== undefined && !isLoopProvider(workerAgent)) throw new Error(`workerAgent must be ${LOOP_PROVIDER_LIST}`);
236
262
  const limits = validateFleetLimits(inputLimits);
263
+ // --- Validate limits and bind adapter methods ------------------------------
237
264
  const claimJob = required(repository, "claimJob");
238
265
  const renewJob = required(repository, "renewJob");
239
266
  const checkpointJob = required(repository, "checkpointJob");
@@ -253,12 +280,14 @@ export async function runFleetWorker({ repository, workerId = randomUUID(), work
253
280
  let turns = 0, stalls = 0;
254
281
  const report = async (outcome) => reportJob({ ...identity, report: outcome, idempotencyKey: key("job-report", job.assignmentId, job.attempt, job.epoch, outcome.status, outcome.commitSha ?? "") });
255
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 }; };
256
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");
257
285
  const workspace = await handoff.prepare(job);
258
286
  if (!workspace?.root) throw new Error("Git handoff did not supply a worker root");
259
287
  let lastObservation = null;
260
288
  for (;;) {
261
289
  if (signal.aborted) throw new Error("Job lease renewal failed");
290
+ if (cancelSignal?.aborted) return cancelled();
262
291
  const snapshot = validateSnapshot(await readTask({ taskId: job.taskId }), job.taskId);
263
292
  const item = snapshot.task.tasks?.find((candidate) => candidate.id === job.itemId);
264
293
  if (!item || item.status === "done") {
@@ -276,11 +305,13 @@ export async function runFleetWorker({ repository, workerId = randomUUID(), work
276
305
  }
277
306
  const prompt = await packet({ job, snapshot, workspace, turns });
278
307
  if (typeof prompt !== "string" || !prompt) throw new Error("Empty worker packet");
279
- 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) => {
280
310
  if (typeof id !== "string" || !id) throw new Error("Invalid worker session ID");
281
311
  if (sessionId && sessionId !== id) throw new Error("Worker session changed during the same assignment");
282
312
  sessionId = id;
283
- }, onEvent });
313
+ }, onEvent }); }
314
+ catch (error) { if (!signal.aborted && cancelSignal?.aborted) return cancelled(); throw error; }
284
315
  if (result?.sessionId && sessionId && result.sessionId !== sessionId) throw new Error("Worker session changed during the same assignment");
285
316
  if (result?.sessionId && !sessionId) {
286
317
  sessionId = result.sessionId;
@@ -1,8 +1,13 @@
1
+ /**
2
+ * Git handoff helpers: create lead/worker/isolated worktrees, sync the
3
+ * integration branch, and produce scoped change reports.
4
+ */
1
5
  import { createHash, randomUUID } from "node:crypto";
2
6
  import { execFileSync } from "node:child_process";
3
7
  import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync } from "node:fs";
4
8
  import { join, resolve } from "node:path";
5
9
  import { isLoopProvider } from "./loop-drivers.mjs";
10
+ import { extractCommitMessage } from "./loop-commit-message.mjs";
6
11
  import { createWorkerSnapshot, inspectWorkerSnapshot } from "./loop-worker-snapshot.mjs";
7
12
 
8
13
  const SHA = /^[0-9a-f]{40,64}$/;
@@ -30,9 +35,9 @@ function validPath(value, label = "path") {
30
35
  }
31
36
  function scopes(input) {
32
37
  if (!Array.isArray(input) || input.length === 0) throw new Error("Assignment requires nonempty ownedPaths");
33
- return [...new Set(input.map((path) => validPath(path, "owned path")))].sort();
38
+ return [...new Set(input.map((path) => validPath(path, "owned path").replace(/\/+$/, "")))].filter(Boolean).sort();
34
39
  }
35
- function inScope(path, ownedPaths) { return ownedPaths.some((scope) => path === scope || (scope.endsWith("/") && path.startsWith(scope))); }
40
+ function inScope(path, ownedPaths) { return ownedPaths.some((scope) => path === scope || path.startsWith(`${scope}/`)); }
36
41
  function changedPaths(root, base, head) {
37
42
  const raw = execFileSync("git", ["diff", "--no-renames", "--name-only", "-z", base, head], { cwd: root, encoding: "buffer", timeout: 30_000, maxBuffer: 4 * 1024 * 1024 });
38
43
  return raw.toString("utf8").split("\0").filter(Boolean).map((path) => validPath(path, "changed path")).sort();
@@ -72,10 +77,16 @@ function fetchBranch(root, remote, branch) {
72
77
  return git(root, "rev-parse", remoteRef(remote, branch));
73
78
  }
74
79
  function ancestor(root, base, head) {
75
- try { git(root, "merge-base", "--is-ancestor", base, head); return true; } catch { return false; }
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 */
76
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/";
77
85
  function assertClean(root) {
78
- 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");
79
90
  }
80
91
  function normaliseReport(job) {
81
92
  const report = job.report ?? job;
@@ -87,6 +98,7 @@ function normaliseReport(job) {
87
98
 
88
99
  /** The coordinator calls this from its protected integration checkout. */
89
100
  export function createLeadGitHandoff({ root: inputRoot, integrationBranch, remote = "origin", agent = "codex" }) {
101
+ // --- Validate inputs and resolve the integration checkout ------------------
90
102
  const root = resolve(inputRoot);
91
103
  validBranch(integrationBranch); validId(remote, "remote");
92
104
  if (!isLoopProvider(agent)) throw new Error("Invalid worker agent");
@@ -209,7 +221,7 @@ export function createWorkerGitHandoff({ root: inputRoot, worktreesRoot: inputWo
209
221
  const done = typeof result?.resultText === "string" && result.resultText.split(/\r?\n/).some((line) => line.trim() === "AI-DLC-ITEM-COMPLETE");
210
222
  return { done, fingerprint: worktreeFingerprint(workspace.root, changed) };
211
223
  },
212
- async finish({ job, workspace }) {
224
+ async finish({ job, workspace, result }) {
213
225
  const branch = validBranch(job.branch);
214
226
  if (git(workspace.root, "symbolic-ref", "--short", "HEAD") !== branch) throw new Error("Worker switched away from assigned branch");
215
227
  const ownedPaths = scopes(job.ownedPaths);
@@ -217,7 +229,8 @@ export function createWorkerGitHandoff({ root: inputRoot, worktreesRoot: inputWo
217
229
  checkScope(dirty, ownedPaths);
218
230
  if (dirty.length) {
219
231
  git(workspace.root, "add", "-A", "--", ...dirty);
220
- 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);
221
234
  }
222
235
  const commitSha = git(workspace.root, "rev-parse", "HEAD");
223
236
  const base = validSha(job.baseCommit, "base commit");
@@ -254,7 +267,7 @@ export function createIsolatedWorkerGitHandoff(options) {
254
267
  const done = typeof result?.resultText === "string" && result.resultText.split(/\r?\n/).some((line) => line.trim() === "AI-DLC-ITEM-COMPLETE");
255
268
  return {done, fingerprint};
256
269
  },
257
- async finish({job, workspace}) {
270
+ async finish({job, workspace, result}) {
258
271
  const changed=inspectWorkerSnapshot(workspace.root, workspace.baseline, job.ownedPaths);
259
272
  if (!changed.length) throw new Error("Worker produced no changes");
260
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");
@@ -271,8 +284,8 @@ export function createIsolatedWorkerGitHandoff(options) {
271
284
  if (!existsSync(source)) { rmSync(target,{force:true}); continue; }
272
285
  copyFileSync(source,target); chmodSync(target,lstatSync(source).mode & 0o777);
273
286
  }
274
- const result=await host.finish({job,workspace:workspace.checkout});
275
- 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;
276
289
  },
277
290
  async discard({workspace}) { rmSync(workspace.root,{recursive:true,force:true}); return host.discard({workspace:workspace.checkout}); }
278
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");
@@ -37,28 +38,48 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
37
38
  if (!Number.isSafeInteger(maxTurns) || maxTurns < 1 || maxTurns > 1000 || !Number.isSafeInteger(maxStallTurns) || maxStallTurns < 1 || maxStallTurns > 20) throw new Error("Invalid worker turn or stall limit");
38
39
  const ids = new Set(), workers = new Set(), scopes = [];
39
40
  for (const job of jobs) {
41
+ if (Array.isArray(job.ownedPaths)) job.ownedPaths = [...new Set(job.ownedPaths.map((path) => (typeof path === "string" ? path.replace(/\/+$/, "") : path)).filter((path) => path))];
40
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.
41
45
  if (ids.has(job.itemId) || workers.has(job.workerId)) throw new Error("Items and workers must be distinct within a batch");
42
46
  ids.add(job.itemId); workers.add(job.workerId);
43
- 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); }
44
48
  }
45
- if (jobs.some((job) => job.agent === "opencode")) throw new Error(OPENCODE_CONTAINER_ERROR);
46
- 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
+ };
47
55
  const uid = process.getuid?.(), gid = process.getgid?.();
48
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
+ }
49
63
  for (const job of jobs) {
50
64
  const keys = docker.credentials[job.agent];
51
- 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 });
52
66
  }
53
67
  const dockerEnv = { PATH: process.env.PATH ?? "/usr/bin:/bin" };
54
68
  const dockerCheck = (...args) => execFileSync("docker", args, { env: dockerEnv, stdio: "ignore", timeout: 30000 });
55
- 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-"));
56
73
  try {
57
74
  dockerCheck("info");
58
- image=execFileSync("docker",["image","inspect","--format","{{.Id}}",docker.image],{env:dockerEnv,encoding:"utf8",stdio:["ignore","pipe","ignore"],timeout:30000}).trim();
59
- if (!/^sha256:[a-f0-9]{64}$/.test(image)) throw new Error("Docker worker image lacks immutable image ID");
60
- 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
+ }
61
81
  } catch { throw new Error("Docker engine, image, or provider CLI preflight failed"); }
82
+ finally { rmSync(preflightWorkspace, { recursive: true, force: true }); }
62
83
  clean(root);
63
84
  const base = git(root, "rev-parse", "HEAD").trim();
64
85
  const initialBranch = git(root, "symbolic-ref", "--short", "HEAD").trim();
@@ -85,6 +106,10 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
85
106
  const execute = async ({ job, assignment, workspace, snapshot, baseline, branch }) => {
86
107
  let renewalError;
87
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 });
88
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)));
89
114
  try {
90
115
  const home=mkdtempSync(join(tmpdir(), "aidlc-local-home-")); chmodSync(home,0o700);
@@ -94,10 +119,10 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
94
119
  if (renewalError || controller.signal.aborted) throw renewalError ?? new Error("Worker lease lost");
95
120
  const containerName = `aidlc-worker-${randomUUID()}`;
96
121
  const spawnProcess = (command, args, options) => {
97
- 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 });
98
123
  return spawn(invocation.command, invocation.args, { ...options, cwd: snapshot, env: invocation.env, shell: false });
99
124
  };
100
- 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); } }); }
101
126
  finally {
102
127
  try { dockerCheck("rm", "-f", containerName); }
103
128
  catch {
@@ -135,12 +160,15 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
135
160
  // Fold any commits made by the agent into one scoped assignment commit.
136
161
  git(workspace, "reset", "--soft", base);
137
162
  git(workspace, "add", "-A", "--", ...changed);
138
- 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
+ }
139
167
  const commit = git(workspace, "rev-parse", "HEAD").trim(), committed = commitPaths(workspace, base, commit);
140
168
  if (commit === base || !committed.length || !scoped(committed, job.ownedPaths)) throw new Error("Worker commit is outside assignment scope");
141
169
  await report(root, taskId, assignment.id, { commit, changedPaths: committed, checkpoint: `provider=${job.agent}` });
142
170
  return { assignmentId: assignment.id, itemId: job.itemId, workerId: job.workerId, agent: job.agent, baseCommit: base, branch, commit, changedPaths: committed, workspace };
143
- } finally { clearInterval(timer); }
171
+ } finally { clearInterval(timer); signal?.removeEventListener("abort", onCancel); }
144
172
  };
145
173
  const results = await Promise.allSettled(prepared.map(execute));
146
174
  const failure = results.find((result) => result.status === "rejected");
@@ -1,3 +1,7 @@
1
+ /**
2
+ * Local loop runtime: record management, checkpointing, turn orchestration,
3
+ * and pause/resume handling for a single lead.
4
+ */
1
5
  import { createHash, randomUUID } from "node:crypto";
2
6
  import { execFileSync } from "node:child_process";
3
7
  import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
@@ -6,7 +10,7 @@ import { acquireLoopLease, assertLoopPath, readLiveLease } from "./loop-lease.mj
6
10
  import { isLoopProvider, LOOP_PROVIDER_LIST } from "./loop-drivers.mjs";
7
11
 
8
12
  export const DEFAULT_LIMITS = Object.freeze({ maxTurns: 12, turnTimeoutMs: 600000, maxStallTurns: 2 });
9
- const states = new Set(["running", "gate", "paused", "blocked", "terminal", "complete"]);
13
+ const states = new Set(["running", "gate", "paused", "blocked", "terminal", "complete", "stopped"]);
10
14
  const hash = (value) => createHash("sha256").update(value).digest("hex");
11
15
  const iso = () => new Date().toISOString();
12
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; };
@@ -148,10 +152,33 @@ export const classifyAction = (action) => {
148
152
  };
149
153
 
150
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
+ }
151
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 } : {}) }; }
152
169
 
153
- 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);
154
180
  const root = validateRoot(rootInput);
181
+ // --- Validate mode and load/create the loop record -------------------------
155
182
  let record = readLoopRecord(root, taskId);
156
183
  if (mode === "run") {
157
184
  if (record) throw new Error(`Loop record already exists for ${taskId}; use resume`);
@@ -181,13 +208,22 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
181
208
  envelope(root, "state.mjs", ["task", "status", taskId, "--status", "active"]);
182
209
  record.pausedByLoop = false;
183
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;
184
215
  }
185
216
  const initial = fingerprint(root, current.task, current.action);
186
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 });
187
218
  record.lastFingerprint = initial.digest;
188
219
  record.checkpoint = checkpoint(current.task, current.action, initial.tree, record.iteration);
189
220
  record.status = "running"; record.stopReason = null; record.updatedAt = iso(); writeLoopRecord(root, record);
190
- for (;;) {
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;
226
+ for (;;) {
191
227
  const kind = classifyAction(current.action);
192
228
  if (kind !== "run_phase") {
193
229
  record.status = kind === "await_user" ? "gate" : kind;
@@ -196,39 +232,79 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
196
232
  const gateView = kind === "await_user" ? cli(root, "gate-view.mjs", [taskId]).trim() : undefined;
197
233
  return outputStop(record, current.action, gateView);
198
234
  }
199
- 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
+ }
200
256
  let prompt;
201
257
  try {
202
258
  const packet = JSON.parse(cli(root, "context.mjs", [taskId, "--phase", current.action.phase, ...(current.action.itemId ? ["--item", current.action.itemId] : []), "--format", "json"]));
203
259
  if (typeof packet?.content !== "string" || !packet.content.trim()) throw new Error("Context packet was empty or invalid");
204
260
  prompt = packet.content;
205
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");
206
264
  // No driver has started for this turn, so it is safe to resume after repairing context.
207
265
  // Do not persist arbitrary subprocess output or malformed context in the stop reason.
208
266
  return pause("context packet generation or validation failed; inspect context.mjs and resume after repair");
209
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
+ }
210
272
  const beforeApprovals = approvalCount(current.task);
273
+ onEvent({ type: "turn_start", taskId, iteration: record.iteration, phase: current.action.phase, agent: record.agent });
211
274
  let observedId = record.sessionId;
212
275
  try {
213
- 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); } } });
214
277
  if (!observedId && result?.sessionId) { observedId = result.sessionId; record.sessionId = observedId; record.updatedAt = iso(); writeLoopRecord(root, record); }
215
278
  if (!observedId) throw new Error("Agent turn ended without a resumable session id");
216
279
  } catch (error) {
217
280
  current = readCanonical(root, taskId);
218
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");
219
283
  return pause(`agent transport failure: ${error.message}`);
220
284
  }
285
+ onEvent({ type: "turn_end", taskId, iteration: record.iteration });
221
286
  const refreshed = readCanonical(root, taskId);
222
287
  if (approvalCount(refreshed.task) !== beforeApprovals) return pause("Approval evidence changed during an agent turn");
223
288
  const signal = fingerprint(root, refreshed.task, refreshed.action);
224
- record.iteration += 1;
289
+ record.iteration += 1; runTurns += 1;
225
290
  record.stallTurns = signal.digest === record.lastFingerprint ? record.stallTurns + 1 : 0;
226
291
  record.lastFingerprint = signal.digest;
227
292
  record.checkpoint = checkpoint(refreshed.task, refreshed.action, signal.tree, record.iteration);
228
293
  record.updatedAt = iso(); writeLoopRecord(root, record);
229
294
  current = refreshed;
295
+ if (stopping()) continue; // the loop top records `stopped` (or the reached stop) instead of a stall pause
230
296
  if (classifyAction(current.action) === "run_phase" && record.stallTurns >= limits.maxStallTurns) return pause(`${record.stallTurns} consecutive turns without observable progress`);
231
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
+ }
232
308
  function pause(reason) {
233
309
  const fresh = readCanonical(root, taskId);
234
310
  record.status = "paused"; record.stopReason = reason;