@felixdotgo/aidlc-kit 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/DEVELOPMENT.md +14 -13
  2. package/README.md +37 -43
  3. package/dist/assets/.agents/aidlc/docker/Dockerfile.claude +7 -0
  4. package/dist/assets/.agents/aidlc/docker/Dockerfile.codex +9 -0
  5. package/dist/assets/.agents/aidlc/docker/Dockerfile.opencode +8 -0
  6. package/dist/assets/.agents/aidlc/orchestrator.md +2 -2
  7. package/dist/assets/.agents/aidlc/phase-clarify.md +1 -1
  8. package/dist/assets/.agents/aidlc/phase-plan.md +2 -2
  9. package/dist/assets/.agents/aidlc/schemas/config.schema.json +2 -1
  10. package/dist/assets/.agents/aidlc/schemas/state.schema.json +2 -0
  11. package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +94 -48
  12. package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +5 -4
  13. package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +261 -0
  14. package/dist/assets/.agents/aidlc/scripts/lib/loop-commit-message.mjs +45 -0
  15. package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +206 -0
  16. package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +100 -14
  17. package/dist/assets/.agents/aidlc/scripts/lib/loop-env.mjs +279 -0
  18. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +105 -32
  19. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +17 -1
  20. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +24 -7
  21. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +42 -16
  22. package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +20 -6
  23. package/dist/assets/.agents/aidlc/scripts/lib/loop-holder.mjs +376 -0
  24. package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +33 -7
  25. package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +67 -61
  26. package/dist/assets/.agents/aidlc/scripts/lib/loop-leases.mjs +268 -0
  27. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +116 -28
  28. package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +114 -12
  29. package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +117 -0
  30. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +16 -56
  31. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +149 -6
  32. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +9 -1
  33. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +44 -6
  34. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-image.mjs +64 -0
  35. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-log.mjs +158 -0
  36. package/dist/assets/.agents/aidlc/scripts/lib/repo-head.mjs +22 -0
  37. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +68 -2
  38. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime-migrate.mjs +75 -0
  39. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime.mjs +97 -0
  40. package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +168 -73
  41. package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +19 -7
  42. package/dist/assets/.agents/aidlc/scripts/loop.cmd +101 -0
  43. package/dist/assets/.agents/aidlc/scripts/loop.mjs +2 -1
  44. package/dist/assets/.agents/aidlc/scripts/loop.sh +71 -0
  45. package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +10 -4
  46. package/dist/assets/.agents/aidlc/scripts/state.mjs +16 -8
  47. package/dist/assets/.agents/aidlc/templates/design.md +9 -0
  48. package/dist/assets/.agents/aidlc/templates/intent.md +4 -0
  49. package/dist/assets/.agents/aidlc/templates/workplan.md +1 -1
  50. package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +1 -1
  51. package/dist/assets/ui/bulma.min.css +3 -0
  52. package/dist/assets/ui/changes.js +83 -0
  53. package/dist/assets/ui/common.js +664 -0
  54. package/dist/assets/ui/dashboard.css +636 -0
  55. package/dist/assets/ui/dashboard.js +77 -0
  56. package/dist/assets/ui/detail.js +159 -0
  57. package/dist/assets/ui/live-log.js +332 -0
  58. package/dist/services/aidlc-coordination/Dockerfile +11 -3
  59. package/dist/services/aidlc-coordination/compose.yaml +12 -0
  60. package/dist/services/aidlc-coordination/package-lock.json +3 -1
  61. package/dist/services/aidlc-coordination/package.json +9 -4
  62. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +68 -2
  63. package/dist/services/aidlc-coordination/src/repository.mjs +66 -0
  64. package/dist/services/aidlc-coordination/src/server.mjs +9 -3
  65. package/dist/services/aidlc-coordination/test/fleet.test.mjs +21 -0
  66. package/dist/services/aidlc-coordination/test/server-health-auth.test.mjs +67 -0
  67. package/dist/src/adapters.d.ts +0 -3
  68. package/dist/src/adapters.js +3 -5
  69. package/dist/src/cli/argv.d.ts +12 -0
  70. package/dist/src/cli/argv.js +68 -0
  71. package/dist/src/cli/commands.d.ts +8 -0
  72. package/dist/src/cli/commands.js +159 -0
  73. package/dist/src/cli/loop-launcher.d.ts +47 -0
  74. package/dist/src/cli/loop-launcher.js +322 -0
  75. package/dist/src/cli/options.d.ts +25 -0
  76. package/dist/src/cli/options.js +32 -0
  77. package/dist/src/cli/prompt.d.ts +9 -0
  78. package/dist/src/cli/prompt.js +66 -0
  79. package/dist/src/cli.js +28 -191
  80. package/dist/src/gate.js +3 -2
  81. package/dist/src/installer.d.ts +14 -7
  82. package/dist/src/installer.js +42 -143
  83. package/dist/src/legacy.js +1 -0
  84. package/dist/src/managed-content.d.ts +7 -0
  85. package/dist/src/managed-content.js +15 -4
  86. package/dist/src/managed-planning.d.ts +65 -0
  87. package/dist/src/managed-planning.js +219 -0
  88. package/dist/src/mcp-setup.js +3 -1
  89. package/dist/src/model/config-contracts.d.ts +65 -0
  90. package/dist/src/model/config-contracts.js +1 -0
  91. package/dist/src/model/install-contracts.d.ts +29 -0
  92. package/dist/src/model/install-contracts.js +1 -0
  93. package/dist/src/model/lifecycle-enums.d.ts +13 -0
  94. package/dist/src/model/lifecycle-enums.js +1 -0
  95. package/dist/src/model/loop.d.ts +20 -0
  96. package/dist/src/model/loop.js +1 -0
  97. package/dist/src/model/manifest.d.ts +16 -0
  98. package/dist/src/model/manifest.js +1 -0
  99. package/dist/src/model/records.d.ts +142 -0
  100. package/dist/src/model/records.js +1 -0
  101. package/dist/src/model.d.ts +11 -264
  102. package/dist/src/model.js +1 -1
  103. package/dist/src/profiles.d.ts +2 -0
  104. package/dist/src/profiles.js +53 -9
  105. package/dist/src/state/archive-record.d.ts +9 -0
  106. package/dist/src/state/archive-record.js +26 -0
  107. package/dist/src/state/commands.d.ts +18 -0
  108. package/dist/src/state/commands.js +27 -0
  109. package/dist/src/state/domain.d.ts +9 -0
  110. package/dist/src/state/domain.js +9 -0
  111. package/dist/src/state/guards.d.ts +7 -0
  112. package/dist/src/state/guards.js +17 -0
  113. package/dist/src/state/io.d.ts +10 -0
  114. package/dist/src/state/io.js +35 -0
  115. package/dist/src/state/items.d.ts +3 -4
  116. package/dist/src/state/items.js +58 -57
  117. package/dist/src/state/lessons.js +14 -7
  118. package/dist/src/state/lifecycle.js +2 -2
  119. package/dist/src/state/lock.js +7 -6
  120. package/dist/src/state/memory.js +24 -12
  121. package/dist/src/state/{archive.js → migrate.js} +7 -7
  122. package/dist/src/state/partition.d.ts +15 -0
  123. package/dist/src/state/partition.js +90 -0
  124. package/dist/src/state/paths.d.ts +11 -0
  125. package/dist/src/state/paths.js +12 -0
  126. package/dist/src/state/render.js +9 -6
  127. package/dist/src/state/store.js +9 -15
  128. package/dist/src/state/transitions.js +9 -7
  129. package/dist/src/state/validate.js +55 -7
  130. package/dist/src/state.d.ts +16 -11
  131. package/dist/src/state.js +16 -11
  132. package/dist/src/task-ui.d.ts +33 -0
  133. package/dist/src/task-ui.js +33 -0
  134. package/dist/src/ui/assets.d.ts +14 -0
  135. package/dist/src/ui/assets.js +45 -0
  136. package/dist/src/ui/changes.d.ts +83 -0
  137. package/dist/src/ui/changes.js +498 -0
  138. package/dist/src/ui/containers.d.ts +71 -0
  139. package/dist/src/ui/containers.js +214 -0
  140. package/dist/src/ui/daemon.d.ts +75 -0
  141. package/dist/src/ui/daemon.js +232 -0
  142. package/dist/src/ui/data.d.ts +42 -0
  143. package/dist/src/ui/data.js +89 -0
  144. package/dist/src/ui/docs.d.ts +13 -0
  145. package/dist/src/ui/docs.js +39 -0
  146. package/dist/src/ui/files.d.ts +3 -0
  147. package/dist/src/ui/files.js +20 -0
  148. package/dist/src/ui/http.d.ts +14 -0
  149. package/dist/src/ui/http.js +80 -0
  150. package/dist/src/ui/live.d.ts +5 -0
  151. package/dist/src/ui/live.js +43 -0
  152. package/dist/src/ui/markdown.d.ts +2 -0
  153. package/dist/src/ui/markdown.js +127 -0
  154. package/dist/src/ui/options.d.ts +48 -0
  155. package/dist/src/ui/options.js +81 -0
  156. package/dist/src/ui/security.d.ts +34 -0
  157. package/dist/src/ui/security.js +38 -0
  158. package/dist/src/ui/server.d.ts +39 -0
  159. package/dist/src/ui/server.js +347 -0
  160. package/dist/src/ui/sse.d.ts +14 -0
  161. package/dist/src/ui/sse.js +281 -0
  162. package/dist/src/ui/views/board.d.ts +7 -0
  163. package/dist/src/ui/views/board.js +131 -0
  164. package/dist/src/ui/views/changes.d.ts +19 -0
  165. package/dist/src/ui/views/changes.js +154 -0
  166. package/dist/src/ui/views/components.d.ts +56 -0
  167. package/dist/src/ui/views/components.js +113 -0
  168. package/dist/src/ui/views/docs.d.ts +15 -0
  169. package/dist/src/ui/views/docs.js +64 -0
  170. package/dist/src/ui/views/layout.d.ts +44 -0
  171. package/dist/src/ui/views/layout.js +233 -0
  172. package/dist/src/ui/views/task.d.ts +25 -0
  173. package/dist/src/ui/views/task.js +320 -0
  174. package/dist/src/upgrade-board.js +10 -10
  175. package/dist/src/upgrade.d.ts +0 -1
  176. package/dist/src/upgrade.js +24 -85
  177. package/dist/src/workflow.d.ts +4 -0
  178. package/dist/src/workflow.js +36 -4
  179. package/docs/README.md +41 -22
  180. package/docs/architecture.md +101 -37
  181. package/docs/command-reference.md +289 -52
  182. package/docs/configuration.md +70 -30
  183. package/docs/coordination-service.md +91 -30
  184. package/docs/design-decisions.md +87 -0
  185. package/docs/development.md +124 -49
  186. package/docs/loop-local-run.md +228 -0
  187. package/docs/loop-runbook.md +214 -0
  188. package/docs/operating-workflow.md +55 -42
  189. package/docs/testing-and-release.md +72 -49
  190. package/docs/troubleshooting.md +221 -0
  191. package/package.json +4 -1
  192. package/dist/src/ledger.d.ts +0 -22
  193. package/dist/src/ledger.js +0 -119
  194. package/dist/src/state/shared.d.ts +0 -29
  195. package/dist/src/state/shared.js +0 -71
  196. package/dist/src/tracker.d.ts +0 -36
  197. package/dist/src/tracker.js +0 -44
  198. /package/dist/src/state/{archive.d.ts → migrate.d.ts} +0 -0
@@ -1,4 +1,4 @@
1
- import { chmodSync, existsSync, mkdtempSync, rmSync } from "node:fs";
1
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { execFileSync, spawn } from "node:child_process";
4
4
  import { randomUUID } from "node:crypto";
@@ -8,11 +8,33 @@ import { runFleetCoordinator, runFleetWorker, validateFleetLimits } from "./loop
8
8
  import { runFleetSupervisor } from "./loop-fleet-supervisor.mjs";
9
9
  import { createFleetRemoteRepository } from "./loop-fleet-remote.mjs";
10
10
  import { createLeadGitHandoff, createIsolatedWorkerGitHandoff } from "./loop-git.mjs";
11
- import { workerDockerInvocation, OPENCODE_CONTAINER_ERROR } from "./loop-worker-docker.mjs";
11
+ import { workerDockerInvocation, workerCredentialKeys } from "./loop-worker-docker.mjs";
12
+ import { openWorkerLog } from "./loop-worker-log.mjs";
13
+ import { defaultWorkerImageTag, ensureWorkerImage, validWorkerImageTag } from "./loop-worker-image.mjs";
12
14
 
13
15
  const required = (env, name) => { const value = env[name]; if (typeof value !== "string" || !value.trim()) throw new Error(`${name} is required`); return value; };
14
- const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
16
+ /** Sleep `ms`, resolving early once any given signal aborts. */
17
+ const pause = (ms, ...signals) => new Promise((resolve) => {
18
+ const live = signals.filter(Boolean);
19
+ if (live.some((signal) => signal.aborted)) return resolve();
20
+ const done = () => { clearTimeout(timer); for (const signal of live) signal.removeEventListener("abort", done); resolve(); };
21
+ const timer = setTimeout(done, ms);
22
+ for (const signal of live) signal.addEventListener("abort", done, { once: true });
23
+ });
15
24
  const provider = (value, name) => { if (!isLoopProvider(value)) throw new Error(`${name} must be ${LOOP_PROVIDER_LIST}`); return value; };
25
+ const pendingFeedback = (task) => {
26
+ const evidence = Array.isArray(task?.evidence) ? task.evidence : [];
27
+ const pending = [];
28
+ for (let i = 0; i < evidence.length; i++) {
29
+ const entry = evidence[i];
30
+ if (entry?.kind !== "diagnostic" || typeof entry.detail !== "string") continue;
31
+ const match = entry.detail.match(/^Cancelled gate wait at (G0_confirm|G1_review|G2_codereview): /);
32
+ if (!match) continue;
33
+ const approvedAfter = evidence.slice(i + 1).some((later) => later?.kind === "approval" && later.result === "pass" && later.gate === match[1]);
34
+ if (!approvedAfter) pending.push(entry.detail);
35
+ }
36
+ return pending;
37
+ };
16
38
  function workerMapping(env, workerIds, defaultAgent) {
17
39
  if (!env.AIDLC_FLEET_WORKER_PROVIDERS) return Object.fromEntries(workerIds.map((id) => [id, defaultAgent]));
18
40
  let mapping;
@@ -20,9 +42,46 @@ function workerMapping(env, workerIds, defaultAgent) {
20
42
  if (!mapping || typeof mapping !== "object" || Array.isArray(mapping) || Object.keys(mapping).length !== workerIds.length || workerIds.some((id) => !Object.hasOwn(mapping, id) || !isLoopProvider(mapping[id])) || Object.keys(mapping).some((id) => !workerIds.includes(id))) throw new Error(`AIDLC_FLEET_WORKER_PROVIDERS must map every configured worker ID to ${LOOP_PROVIDER_LIST}`);
21
43
  return mapping;
22
44
  }
45
+ /** This worker's provider: AIDLC_FLEET_WORKER_AGENT, else its entry in
46
+ * AIDLC_FLEET_WORKER_PROVIDERS (keyed by worker ID), else AIDLC_FLEET_AGENT
47
+ * (the lead's provider — only a last resort, so a copied lead env cannot
48
+ * override this worker's own mapping), else undefined. */
49
+ function workerProvider(env, workerId) {
50
+ if (env.AIDLC_FLEET_WORKER_AGENT !== undefined) return provider(env.AIDLC_FLEET_WORKER_AGENT, "AIDLC_FLEET_WORKER_AGENT");
51
+ if (env.AIDLC_FLEET_WORKER_PROVIDERS) {
52
+ let mapping;
53
+ try { mapping = JSON.parse(env.AIDLC_FLEET_WORKER_PROVIDERS); } catch { throw new Error("AIDLC_FLEET_WORKER_PROVIDERS must be a JSON object"); }
54
+ if (!mapping || typeof mapping !== "object" || Array.isArray(mapping)) throw new Error("AIDLC_FLEET_WORKER_PROVIDERS must be a JSON object");
55
+ if (Object.hasOwn(mapping, workerId)) return provider(mapping[workerId], `AIDLC_FLEET_WORKER_PROVIDERS["${workerId}"]`);
56
+ }
57
+ return env.AIDLC_FLEET_AGENT === undefined ? undefined : provider(env.AIDLC_FLEET_AGENT, "AIDLC_FLEET_AGENT");
58
+ }
23
59
 
24
- /** Package-local entrypoint. The service wrapper supplies its MCP HTTP client. */
25
- export async function runFleetCli(argv, { callTool, env = process.env, out = console.log, signal, coordinator: coordinatorOverride } = {}) {
60
+ /** Worker image tag: explicit AIDLC_WORKER_DOCKER_IMAGE, else the project's
61
+ * `.agents/config.json` `loop.workerImage[provider]`, else the default tag
62
+ * (built from the shipped Dockerfile when missing). */
63
+ function workerImageTag({ env, root, agent, onEvent }) {
64
+ const requested = env.AIDLC_WORKER_DOCKER_IMAGE;
65
+ if (typeof requested === "string" && requested.trim()) return requested;
66
+ if (!agent) throw new Error("AIDLC_WORKER_DOCKER_IMAGE is required when the worker provider is unknown; set AIDLC_FLEET_WORKER_AGENT (or this worker's AIDLC_FLEET_WORKER_PROVIDERS entry) to use the default worker image, or build one with `aidlc-kit loop worker-image --provider <provider>` and set the env var");
67
+ const configPath = join(root, ".agents", "config.json");
68
+ let configured;
69
+ if (existsSync(configPath)) {
70
+ let config;
71
+ try { config = JSON.parse(readFileSync(configPath, "utf8")); } catch { throw new Error(`Invalid project config: ${configPath}`); }
72
+ configured = config?.loop?.workerImage?.[agent];
73
+ if (configured !== undefined && !validWorkerImageTag(configured)) throw new Error(`Invalid loop.workerImage.${agent} in ${configPath}`);
74
+ }
75
+ const image = configured ?? defaultWorkerImageTag(agent);
76
+ return ensureWorkerImage({ root, provider: agent, image, env, onEvent });
77
+ }
78
+
79
+ /** Package-local entrypoint. The service wrapper supplies its MCP HTTP client.
80
+ * `signal` stops after the current turn; `cancelSignal` also aborts the
81
+ * in-flight agent turn (result status `stopped`). `onEvent(event)` receives
82
+ * every emitted event object instead of `out(JSON.stringify(event))`.
83
+ * `untilStop` applies to role `supervisor` only (see runFleetSupervisor). */
84
+ export async function runFleetCli(argv, { callTool, env = process.env, out = console.log, onEvent, signal, cancelSignal, untilStop = false, coordinator: coordinatorOverride } = {}) {
26
85
  const [role, targetId, ...flags] = argv;
27
86
  if (!["coordinator", "feature", "worker", "supervisor"].includes(role) || ((role === "coordinator" || role === "feature" || role === "supervisor") && !targetId) || flags.some((arg) => arg !== "--once") || (role === "supervisor" && flags.length) || (role === "worker" && targetId && targetId !== "--once")) throw new Error("Usage: aidlc-fleet coordinator <task-id> [--once] | feature <feature-id> [--once] | supervisor <task-id> | worker [--once]");
28
87
  const once = flags.includes("--once") || (role === "worker" && targetId === "--once");
@@ -44,23 +103,21 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
44
103
  const pollMs = env.AIDLC_FLEET_POLL_MS ? Number(env.AIDLC_FLEET_POLL_MS) : 5000;
45
104
  if (!Number.isSafeInteger(pollMs) || pollMs < 1000 || pollMs > 300000) throw new Error("AIDLC_FLEET_POLL_MS must be 1000..300000");
46
105
  if (env.AIDLC_FLEET_CODE_ONLY_RETRY && !["0", "1"].includes(env.AIDLC_FLEET_CODE_ONLY_RETRY)) throw new Error("AIDLC_FLEET_CODE_ONLY_RETRY must be 0 or 1");
47
- const emit = (event) => out(JSON.stringify(event));
106
+ const emit = typeof onEvent === "function" ? (event) => onEvent(event) : (event) => out(JSON.stringify(event));
107
+ const stopRequested = () => Boolean(signal?.aborted || cancelSignal?.aborted);
48
108
  if (role === "worker") {
49
109
  const workerId = required(env, "AIDLC_FLEET_WORKER_ID");
50
- const workerAgentSetting = env.AIDLC_FLEET_WORKER_AGENT ?? env.AIDLC_FLEET_AGENT;
51
- const workerAgent = workerAgentSetting === undefined ? undefined : provider(workerAgentSetting, "AIDLC_FLEET_WORKER_AGENT");
52
- if (workerAgent === "opencode") throw new Error(OPENCODE_CONTAINER_ERROR);
53
- const requestedImage = required(env, "AIDLC_WORKER_DOCKER_IMAGE");
110
+ const workerAgent = workerProvider(env, workerId);
111
+ const requestedImage = workerImageTag({ env, root, agent: workerAgent, onEvent: emit });
54
112
  if (!/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,255}$/.test(requestedImage)) throw new Error("Invalid Docker worker image");
55
113
  const image = execFileSync("docker", ["image", "inspect", "--format", "{{.Id}}", requestedImage], {env:{PATH:env.PATH ?? "/usr/bin:/bin"},encoding:"utf8",timeout:30000,stdio:["ignore","pipe","ignore"]}).trim();
56
114
  if (!/^sha256:[a-f0-9]{64}$/.test(image)) throw new Error("Docker worker image lacks immutable image ID");
57
115
  const uid = process.getuid?.(), gid = process.getgid?.();
58
116
  if (!Number.isSafeInteger(uid) || uid < 1 || !Number.isSafeInteger(gid) || gid < 1) throw new Error("Docker worker requires a non-root host uid and gid");
59
117
  const preflight = async (agent) => {
60
- const credential = agent === "codex" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
61
118
  const empty = mkdtempSync(join(tmpdir(), "aidlc-worker-preflight-"));
62
119
  try {
63
- const invocation = workerDockerInvocation({workspace:empty,image,command:agent,args:["--version"],credentialEnv:[credential],hostEnv:env,uid,gid});
120
+ const invocation = workerDockerInvocation({workspace:empty,image,command:agent,args:["--version"],credentialEnv:workerCredentialKeys(agent, env),hostEnv:env,uid,gid});
64
121
  execFileSync("docker", ["info"], {env:{PATH:invocation.env.PATH},stdio:"ignore",timeout:30000});
65
122
  execFileSync("docker", ["image","inspect",image], {env:{PATH:invocation.env.PATH},stdio:"ignore",timeout:30000});
66
123
  const name = `aidlc-worker-preflight-${randomUUID()}`;
@@ -77,32 +134,45 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
77
134
  if (workerAgent) await preflight(workerAgent);
78
135
  const baseHandoff = createIsolatedWorkerGitHandoff({ root, worktreesRoot: resolve(required(env, "AIDLC_FLEET_WORKTREES_ROOT")), remote });
79
136
  const sessions = new Map();
80
- const cleanupSession = (workspace) => { const current=sessions.get(workspace.root); if(current) { rmSync(current.home,{recursive:true,force:true}); sessions.delete(workspace.root); } };
137
+ const dropSession = (key, current) => { current.log?.close(); rmSync(current.home,{recursive:true,force:true}); sessions.delete(key); };
138
+ const cleanupSession = (workspace) => { const current=sessions.get(workspace.root); if(current) dropSession(workspace.root, current); };
139
+ // Same container record and raw output tee as the local loop, written under
140
+ // this worker checkout's .agents/data/tasks/<task>/runtime/workers (the
141
+ // index entry carries this host's name). Best-effort: reported once, never fatal.
142
+ const openLog = (job) => {
143
+ const diagnostic = (error) => emit({ type: "diagnostic", taskId: job.taskId, itemId: job.itemId, message: `Worker log unavailable for assignment ${job.assignmentId}: ${error instanceof Error ? error.message : String(error)}` });
144
+ try { return openWorkerLog({ root, taskId: job.taskId, assignmentId: job.assignmentId, itemId: job.itemId, workerId, agent: job.agent, mode: "fleet", onError: diagnostic }); }
145
+ catch (error) { diagnostic(error); return null; }
146
+ };
81
147
  const handoff = {
82
148
  ...baseHandoff,
83
149
  async prepare(job) {
84
150
  const workspace=await baseHandoff.prepare(job);
85
151
  const home=mkdtempSync(join(tmpdir(),"aidlc-worker-home-")); chmodSync(home,0o700);
86
- sessions.set(workspace.root,{home,agent:job.agent,assignmentId:job.assignmentId,attempt:job.attempt,epoch:job.epoch,workerId,image,sessionId:null});
152
+ const labels = { "aidlc.task": job.taskId, "aidlc.item": job.itemId, "aidlc.worker": workerId, "aidlc.assignment": job.assignmentId };
153
+ sessions.set(workspace.root,{home,agent:job.agent,assignmentId:job.assignmentId,attempt:job.attempt,epoch:job.epoch,workerId,image,sessionId:null,labels,log:openLog(job),turns:0});
87
154
  return workspace;
88
155
  },
89
156
  async finish(args) { try { return await baseHandoff.finish(args); } finally { cleanupSession(args.workspace); } },
90
157
  async discard(args) { try { return await baseHandoff.discard(args); } finally { cleanupSession(args.workspace); } }
91
158
  };
92
159
  const isolatedDriver = async (turn) => {
93
- if (turn.agent === "opencode") throw new Error(OPENCODE_CONTAINER_ERROR);
94
160
  const session=sessions.get(turn.root);
95
161
  if (!session || session.agent !== turn.agent || (turn.sessionId && turn.sessionId !== session.sessionId)) throw new Error("Docker worker session is not recoverable; reconcile this assignment");
96
- const credential = turn.agent === "codex" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
162
+ const credentialEnv = workerCredentialKeys(turn.agent, env);
97
163
  const name = `aidlc-worker-${randomUUID()}`;
98
164
  let started = false;
165
+ session.turns += 1;
166
+ session.log?.turnStart(name, session.turns);
99
167
  try {
100
- const result = await runAgentTurn({...turn, root:"/workspace", spawnProcess: (command,args,options) => {
101
- const invocation=workerDockerInvocation({workspace:turn.root,image,command,args,credentialEnv:[credential],hostEnv:env,uid,gid,sessionHome:session.home});
168
+ const result = await runAgentTurn({...turn, root:"/workspace", env, spawnProcess: (command,args,options) => {
169
+ const invocation=workerDockerInvocation({workspace:turn.root,image,command,args,credentialEnv,hostEnv:env,uid,gid,sessionHome:session.home,labels:session.labels});
102
170
  const dockerArgs=invocation.args.filter((arg,index) => !(index===1 && arg==="--rm"));
103
171
  dockerArgs.splice(1,0,"--name",name);
104
172
  started = true;
105
- return spawn(invocation.command,dockerArgs,{...options,cwd:turn.root,env:invocation.env});
173
+ const child = spawn(invocation.command,dockerArgs,{...options,cwd:turn.root,env:invocation.env});
174
+ if (session.log) { session.log.tee(child); child.once("close", (code) => session.log.turnEnd(code)); }
175
+ return child;
106
176
  }});
107
177
  session.sessionId=result.sessionId;
108
178
  return result;
@@ -114,20 +184,20 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
114
184
  }
115
185
  };
116
186
  for (;;) {
117
- if (signal?.aborted) return { status: "stopped" };
187
+ if (stopRequested()) return { status: "stopped" };
118
188
  let result;
119
- try { result = await runFleetWorker({ repository, workerId, workerAgent, preflight, limits, driver: isolatedDriver, handoff, packet: async ({ job, snapshot }) => [
189
+ try { result = await runFleetWorker({ repository, workerId, workerAgent, preflight, limits, driver: isolatedDriver, handoff, cancelSignal, packet: async ({ job, snapshot }) => [
120
190
  `AI-DLC worker assignment ${job.assignmentId}, attempt ${job.attempt}.`,
121
191
  `Task: ${snapshot.task.title} (${job.taskId}). Item: ${job.itemId}.`,
122
192
  `Only change these owned paths: ${job.ownedPaths.join(", ")}.`,
123
193
  `Base commit: ${job.baseCommit}. Assigned branch: ${job.branch}.`,
124
- "Complete the scoped item and report remaining risks. The supervisor handles the Git commit and push.",
194
+ "Complete the scoped item and report remaining risks. The supervisor handles the Git commit and push. End the final answer with a Conventional Commits message on its own line as AI-DLC-COMMIT: type(scope): subject (for example AI-DLC-COMMIT: docs(ui): add web UI improvement proposal), followed by AI-DLC-ITEM-COMPLETE on its own line.",
125
195
  ...(env.AIDLC_FLEET_CODE_ONLY_RETRY === "1" ? ["This assignment is declared code-only: perform no external side effects beyond the supervisor's Git handoff."] : [])
126
196
  ].join("\n"), onEvent: emit }); }
127
- finally { for (const [workspace, entry] of sessions) { rmSync(entry.home,{recursive:true,force:true}); sessions.delete(workspace); } }
197
+ finally { for (const [workspace, entry] of sessions) dropSession(workspace, entry); }
128
198
  emit(result);
129
- if (once) return result;
130
- await pause(pollMs);
199
+ if (once || result.status === "stopped") return result;
200
+ await pause(pollMs, signal, cancelSignal);
131
201
  }
132
202
  }
133
203
  const workerIds = required(env, "AIDLC_FLEET_WORKER_IDS").split(",").map((value) => value.trim()).filter(Boolean);
@@ -142,19 +212,21 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
142
212
  const raw = execFileSync(process.execPath, [remoteState, "phase", "context", taskId], { cwd: root, env: fleetEnv, encoding: "utf8", timeout: 30000, maxBuffer: 2 * 1024 * 1024 });
143
213
  const packet = JSON.parse(raw);
144
214
  if (packet?.ok !== true || typeof packet.result?.content !== "string" || !packet.result.content) throw new Error("Remote phase context is empty or invalid");
145
- return `${packet.result.content}\n\nFleet supervisor: canonical nextAction is ${JSON.stringify(snapshot.nextAction)}. Use only remote-state.mjs for lifecycle commands. Never approve a gate.`;
215
+ const feedback = pendingFeedback(snapshot.task);
216
+ const feedbackBlock = feedback.length ? `Human feedback (revise the current review artifact accordingly, then re-present the gate):\n${feedback.map((text) => `- ${text}`).join("\n")}\n\n` : "";
217
+ return `${feedbackBlock}${packet.result.content}\n\nFleet supervisor: canonical nextAction is ${JSON.stringify(snapshot.nextAction)}. Use only remote-state.mjs for lifecycle commands. Never approve a gate.`;
146
218
  },
147
219
  driver: async ({ leadEpoch, prompt, timeoutMs, signal: turnSignal }) => {
148
220
  return runAgentTurn({ agent, root, prompt, timeoutMs, signal: turnSignal, env: { ...env, AIDLC_FLEET_LEAD_EPOCH: String(leadEpoch), AIDLC_FLEET_LEAD_OWNER_TOKEN: repository.leadOwnerToken() }, onEvent: emit });
149
221
  } });
150
- const runTask = async (taskId, reservedJobs = []) => runFleetCoordinator({ repository, taskId, ...leadTaskFor(taskId), reservedJobs, onEvent: emit });
222
+ const runTask = async (taskId, reservedJobs = []) => runFleetCoordinator({ repository, taskId, ...leadTaskFor(taskId), reservedJobs, stopSignal: signal, cancelSignal, onEvent: emit });
151
223
  if (role === "supervisor") {
152
- const result = await runFleetSupervisor({ repository, taskId: targetId, ...leadTaskFor(targetId), signal, onEvent: emit, ...(coordinatorOverride ? { coordinator: coordinatorOverride } : {}) });
224
+ const result = await runFleetSupervisor({ repository, taskId: targetId, ...leadTaskFor(targetId), signal, cancelSignal, untilStop, onEvent: emit, ...(coordinatorOverride ? { coordinator: coordinatorOverride } : {}) });
153
225
  emit(result);
154
226
  return result;
155
227
  }
156
228
  for (;;) {
157
- if (signal?.aborted) return { status: "stopped" };
229
+ if (stopRequested()) return { status: "stopped" };
158
230
  if (role === "feature") {
159
231
  const feature = await repository.getFeature({ featureId: targetId });
160
232
  if (!feature) throw new Error(`Unknown feature ${targetId}`);
@@ -167,17 +239,18 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
167
239
  const reservedJobs = (await repository.listAllJobs()).filter((job) => job.taskId !== member.taskId);
168
240
  const result = await runTask(member.taskId, reservedJobs);
169
241
  results.push(result); emit(result);
242
+ if (result.status === "stopped") return { status: "stopped", featureId: targetId, results };
170
243
  if (result.status === "gate") return { status: "gate", featureId: targetId, results, blockedTaskIds: readiness.items.filter((item) => !item.ready && item.blockers.length).map((item) => item.taskId) };
171
244
  }
172
245
  const stopped = results.some((entry) => ["blocked", "reconcile", "paused", "terminal"].includes(entry.status));
173
246
  const result = { status: !ready.length || stopped ? "blocked" : "active", featureId: targetId, results, blockedTaskIds: readiness.items.filter((member) => !member.ready && member.blockers.length).map((member) => member.taskId) };
174
247
  if (once || result.status === "blocked") return result;
175
- await pause(pollMs);
248
+ await pause(pollMs, signal, cancelSignal);
176
249
  continue;
177
250
  }
178
251
  const result = await runTask(targetId);
179
252
  emit(result);
180
- if (once || ["terminal", "complete", "blocked", "paused", "reconcile", "gate"].includes(result.status)) return result;
181
- await pause(pollMs);
253
+ if (once || ["terminal", "complete", "blocked", "paused", "reconcile", "gate", "stopped"].includes(result.status)) return result;
254
+ await pause(pollMs, signal, cancelSignal);
182
255
  }
183
256
  }
@@ -1,11 +1,20 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { explainFleetConflict } from "./loop-holder.mjs";
2
3
 
3
4
  const idempotency = () => randomUUID();
4
5
 
5
6
  /** Map the coordination service's versioned MCP tools to the headless runner. */
6
7
  export function createFleetRemoteRepository({ callTool, url, token, workspace, root, workerId }) {
7
8
  if (typeof callTool !== "function" || !url || !token || !workspace || !root) throw new Error("Fleet remote requires MCP callTool, URL, token, workspace, and project root");
8
- const call = (name, args) => callTool({ url, token, name, args: { workspace, ...args } });
9
+ // A FleetConflict keeps its code/current for retry logic; its message gains
10
+ // a lease/claim explanation and the inspect command (original kept in parentheses).
11
+ const call = async (name, args) => {
12
+ try { return await callTool({ url, token, name, args: { workspace, ...args } }); }
13
+ catch (error) {
14
+ if (error?.code === "FLEET_CONFLICT" && typeof error.message === "string") error.message = explainFleetConflict(error.message);
15
+ throw error;
16
+ }
17
+ };
9
18
  let ownerToken;
10
19
  let supervisorToken, supervisorEpoch, supervisorTask;
11
20
  const fleet = () => call("fleet_get", {});
@@ -44,6 +53,13 @@ export function createFleetRemoteRepository({ callTool, url, token, workspace, r
44
53
  getFeature: async ({ featureId }) => (await fleet()).fleet.features[featureId] ?? null,
45
54
  featureReady: async ({ featureId }) => call("feature_ready", { featureId }),
46
55
  listAllJobs: async () => Object.values((await fleet()).fleet.jobs).map(normalizeJob),
56
+ /** Every canonical task in the workspace (unordered), for daemon task discovery. */
57
+ listTasks: async () => {
58
+ const value = await call("state_get", {});
59
+ const tasks = value?.state?.tasks;
60
+ if (!tasks || typeof tasks !== "object" || Array.isArray(tasks)) throw new Error("Invalid remote workspace state");
61
+ return Object.values(tasks);
62
+ },
47
63
  readTask: async ({ taskId }) => {
48
64
  const value = await call("task_get", { taskId, clientProjectRoot: root });
49
65
  return { task: value.task, nextAction: value.nextAction, revision: value.revision };
@@ -1,19 +1,30 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { setTimeout as sleep } from "node:timers/promises";
3
+ import { explainFleetConflict } from "./loop-holder.mjs";
3
4
  import { runFleetCoordinator, validateFleetLimits } from "./loop-fleet.mjs";
4
5
 
5
6
  const taskDigest = (task) => createHash("sha256").update(JSON.stringify(task)).digest("hex");
6
7
  const targets = { G0_confirm: "plan", G1_review: "build", G2_codereview: "wrap" };
7
8
 
8
9
  /** The service owns the checkpoint and atomically binds each run claim to this
9
- * supervisor. An unavailable or ambiguous checkpoint never authorizes work. */
10
- export async function runFleetSupervisor({ repository, taskId, limits: inputLimits, pollMs = 5000, signal, onEvent = () => {}, coordinator = runFleetCoordinator, ...options }) {
10
+ * supervisor. An unavailable or ambiguous checkpoint never authorizes work.
11
+ *
12
+ * `signal` stops at the next turn boundary; `cancelSignal` also aborts the
13
+ * in-flight lead turn. Both end as `{ status: "stopped" }`.
14
+ *
15
+ * `untilStop: true` (daemon mode) never polls a human gate: a newly published
16
+ * gate returns `{ status: "gate", taskId, gate, snapshotId, gateView }` and an
17
+ * already pending checkpoint returns `{ status: "waiting", taskId, gate,
18
+ * snapshotId }`. The supervisor lease is released either way; the service
19
+ * accepts the reviewer approval against the pending checkpoint, and a later
20
+ * supervisor resumes from the `approved` checkpoint. */
21
+ export async function runFleetSupervisor({ repository, taskId, limits: inputLimits, pollMs = 5000, signal, cancelSignal, untilStop = false, onEvent = () => {}, coordinator = runFleetCoordinator, ...options }) {
11
22
  const limits = validateFleetLimits(inputLimits);
12
23
  if (!repository || typeof taskId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(taskId) || !Number.isSafeInteger(pollMs) || pollMs < 1 || pollMs > 300000) throw new Error("Invalid fleet supervisor configuration");
13
24
  for (const name of ["claimSupervisor", "renewSupervisor", "releaseSupervisor", "readGateCheckpoint", "readTask", "claimLead"]) if (typeof repository[name] !== "function") throw new Error(`Fleet supervisor requires ${name}`);
14
25
  await repository.claimSupervisor({ taskId, leaseMs: limits.leaseMs, idempotencyKey: randomUUID() });
15
26
  const controller = new AbortController();
16
- const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
27
+ const combined = AbortSignal.any([signal, cancelSignal, controller.signal].filter(Boolean));
17
28
  let failure; let pending = Promise.resolve();
18
29
  const timer = setInterval(() => { pending = pending.then(() => repository.renewSupervisor({ taskId, leaseMs: limits.leaseMs })).catch((error) => { failure = error; controller.abort(error); }); }, limits.heartbeatMs);
19
30
  timer.unref?.();
@@ -28,12 +39,17 @@ export async function runFleetSupervisor({ repository, taskId, limits: inputLimi
28
39
  if (checkpoint?.status === "pending") {
29
40
  if (task.status !== "blocked_on_user" || task.gate !== checkpoint.gate || taskDigest(task) !== checkpoint.taskDigest || snapshot.nextAction?.classification !== "await_user") return { status: "blocked", taskId, reason: "Pending checkpoint does not match canonical gate" };
30
41
  await onEvent({ type: "waiting", taskId, gate: checkpoint.gate, snapshotId: checkpoint.snapshotId });
42
+ if (untilStop) return { status: "waiting", taskId, gate: checkpoint.gate, snapshotId: checkpoint.snapshotId };
31
43
  try { await sleep(pollMs, undefined, { signal: combined }); } catch (error) { if (error?.name !== "AbortError") throw error; }
32
44
  continue;
33
45
  }
34
46
  if (checkpoint?.status === "consumed") return { status: "blocked", taskId, reason: "Consumed checkpoint requires reconciliation before restart" };
35
- if (checkpoint && checkpoint.status !== "approved") return { status: "blocked", taskId, reason: "Approved checkpoint does not match canonical state" };
36
- if (checkpoint) {
47
+ if (checkpoint?.status === "rejected") {
48
+ if (task.status !== "active" || task.phase !== checkpoint.phase || task.gate !== checkpoint.gate || snapshot.nextAction?.classification !== "run_phase") return { status: "blocked", taskId, reason: "Rejected checkpoint does not match canonical state" };
49
+ } else if (checkpoint && checkpoint.status !== "approved") {
50
+ return { status: "blocked", taskId, reason: "Approved checkpoint does not match canonical state" };
51
+ }
52
+ if (checkpoint?.status === "approved") {
37
53
  // A task snapshot older than the atomic approval is a read race between
38
54
  // the two reads, not a mismatch: re-read the canonical task immediately.
39
55
  if (Number.isSafeInteger(checkpoint.approvalRevision) && checkpoint.approvalRevision > snapshot.revision) { await onEvent({ type: "resync", taskId, gate: checkpoint.gate }); continue; }
@@ -43,13 +59,14 @@ export async function runFleetSupervisor({ repository, taskId, limits: inputLimi
43
59
  await repository.renewSupervisor({ taskId, leaseMs: limits.leaseMs });
44
60
  if (combined.aborted) break;
45
61
  const claim = await repository.claimLead({ taskId, leaseMs: limits.leaseMs, expectedStateRevision: snapshot.revision, expectedTaskDigest: taskDigest(task), checkpoint, idempotencyKey: randomUUID() });
46
- const result = await coordinator({ ...options, repository, taskId, limits, claimedLead: claim, supervisorSignal: combined, onEvent });
62
+ const result = await coordinator({ ...options, repository, taskId, limits, claimedLead: claim, supervisorSignal: controller.signal, stopSignal: signal, cancelSignal, onEvent });
47
63
  if (failure) throw failure;
48
64
  if (result?.status !== "gate") return result;
49
65
  // gate_snapshot_put stores snapshot and checkpoint in one service transaction.
50
66
  await onEvent({ type: "gate", taskId, gate: result.gate, snapshotId: result.snapshotId, gateView: result.gateView });
67
+ if (untilStop) return { status: "gate", taskId, gate: result.gate, snapshotId: result.snapshotId, gateView: result.gateView };
51
68
  }
52
- if (failure) throw new Error(`Supervisor lease renewal failed: ${failure.message}`);
69
+ if (failure) throw new Error(`Supervisor lease renewal failed: ${explainFleetConflict(failure.message)}`);
53
70
  return { status: "stopped", taskId };
54
71
  } finally {
55
72
  clearInterval(timer); await pending;
@@ -3,8 +3,9 @@
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
+ import { explainFleetConflict, LEASES_COMMAND } from "./loop-holder.mjs";
8
9
 
9
10
  export const FLEET_LIMITS = Object.freeze({ maxTurns: 12, turnTimeoutMs: 600_000, maxStallTurns: 2, maxWorkers: 4, leaseMs: 300_000, heartbeatMs: 60_000 });
10
11
  const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
@@ -37,9 +38,15 @@ function readyItems(task) {
37
38
  const ready = new Set(readyTaskItems(task).ready);
38
39
  return items.filter((item) => item.status === "todo" && ready.has(item.id));
39
40
  }
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));
41
+ /** Sleep `ms`, resolving early (never rejecting) once any given signal aborts. */
42
+ const wait = (ms, ...signals) => new Promise((resolve) => {
43
+ const live = signals.filter(Boolean);
44
+ if (live.some((signal) => signal.aborted)) return resolve();
45
+ const done = () => { clearTimeout(timer); for (const signal of live) signal.removeEventListener("abort", done); resolve(); };
46
+ const timer = setTimeout(done, ms);
47
+ for (const signal of live) signal.addEventListener("abort", done, { once: true });
48
+ });
49
+ const anySignal = (...signals) => { const live = signals.filter(Boolean); return live.length === 1 ? live[0] : AbortSignal.any(live); };
43
50
 
44
51
  function key(...parts) { return digest(parts); }
45
52
 
@@ -105,7 +112,7 @@ async function withHeartbeat(renew, intervalMs, work) {
105
112
  try {
106
113
  const result = await work(abort.signal);
107
114
  await pending;
108
- if (failure) throw new Error(`Fleet lease renewal failed: ${failure.message}`);
115
+ if (failure) throw new Error(`Fleet lease renewal failed: ${explainFleetConflict(failure.message)}`);
109
116
  return result;
110
117
  } finally { clearInterval(timer); await pending; }
111
118
  }
@@ -113,8 +120,13 @@ async function withHeartbeat(renew, intervalMs, work) {
113
120
  /**
114
121
  * One fenced coordinator invocation. The repository is the remote authority;
115
122
  * no local lifecycle file or PID lease is consulted in fleet mode.
123
+ *
124
+ * `supervisorSignal` reports a lost supervisor lease (aborts the turn and
125
+ * fails). `stopSignal` requests a graceful stop at the next turn boundary;
126
+ * `cancelSignal` additionally aborts the in-flight lead turn. Both end as
127
+ * `{ status: "stopped" }` without changing canonical task state.
116
128
  */
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 = () => {} }) {
129
+ 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
130
  assertId(taskId, "task ID"); assertId(ownerId, "owner ID");
119
131
  if (!Array.isArray(workerIds) || new Set(workerIds).size !== workerIds.length) throw new Error("workerIds must be distinct IDs");
120
132
  for (const workerId of workerIds) assertId(workerId, "worker ID");
@@ -139,15 +151,19 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
139
151
  const readySeen = new Map();
140
152
  try {
141
153
  return await withHeartbeat(() => renewLead({ taskId, ownerId, epoch, leaseMs: limits.leaseMs }), limits.heartbeatMs, async (leaseSignal) => {
142
- const signal = supervisorSignal ? AbortSignal.any([leaseSignal, supervisorSignal]) : leaseSignal;
154
+ const leaseLost = anySignal(leaseSignal, supervisorSignal);
155
+ const signal = anySignal(leaseLost, cancelSignal);
156
+ const stopping = () => Boolean(stopSignal?.aborted || cancelSignal?.aborted);
157
+ const stopped = (reason) => ({ status: "stopped", taskId, reason, revision: snapshot.revision });
143
158
  for (;;) {
144
- if (signal.aborted) throw new Error("Lead lease renewal failed");
159
+ if (leaseLost.aborted) throw new Error(`Lead lease renewal failed: the lead lease (run_claim, lead epoch ${epoch}) for ${taskId} could not be renewed or the supervisor lease was lost; another lead may have taken over or the coordination service is unreachable. Inspect: ${LEASES_COMMAND} ${taskId}`);
160
+ if (stopping()) return stopped(cancelSignal?.aborted ? "cancel requested" : "stop requested");
145
161
  if (Date.now() - startedAt >= limits.maxTurns * limits.turnTimeoutMs) return { status: "paused", taskId, reason: "coordinator wall-time limit reached" };
146
162
  snapshot = validateSnapshot(await readTask({ taskId }), taskId);
147
163
  const kind = actionKind(snapshot.nextAction);
148
164
  if (kind === "await_user") {
149
165
  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; }
166
+ if (reconciled.pending.length && reconciled.pending.every((entry) => entry.waitUntil)) { await wait(pollMs, signal, stopSignal); continue; }
151
167
  if (reconciled.pending.length) return { status: "reconcile", taskId, revision: snapshot.revision, pending: reconciled.pending };
152
168
  const gate = snapshot.nextAction.gate;
153
169
  const baseReview = await (gateView ?? ((value) => formatGateView(value.task, [], "markdown")))(snapshot);
@@ -163,7 +179,7 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
163
179
  const before = digest({ revision: snapshot.revision, action: snapshot.nextAction });
164
180
  if (kind === "dispatch" || (kind === "run_phase" && snapshot.task.phase === "build")) {
165
181
  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; }
182
+ if (reconciled.pending.length && reconciled.pending.every((entry) => entry.waitUntil)) { await wait(pollMs, signal, stopSignal); continue; }
167
183
  if (reconciled.pending.length) {
168
184
  const verified = reconciled.pending.filter((entry) => entry.artifact?.valid);
169
185
  if (verified.length && typeof handoff?.integrate === "function" && typeof completeItem === "function") {
@@ -212,7 +228,7 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
212
228
  }
213
229
  }
214
230
  onEvent({ type: "jobs_active", taskId, assignments: active.map((job) => job.assignmentId) });
215
- await wait(pollMs);
231
+ await wait(pollMs, signal, stopSignal);
216
232
  continue;
217
233
  }
218
234
  }
@@ -220,7 +236,11 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
220
236
  if (typeof packet !== "function" || typeof driver !== "function") throw new Error("Fleet lead requires packet and driver adapters");
221
237
  const prompt = await packet(snapshot, epoch);
222
238
  if (typeof prompt !== "string" || !prompt) throw new Error("Empty fleet lead packet");
223
- await driver({ taskId, snapshot, leadEpoch: epoch, prompt, timeoutMs: limits.turnTimeoutMs, signal });
239
+ try { await driver({ taskId, snapshot, leadEpoch: epoch, prompt, timeoutMs: limits.turnTimeoutMs, signal }); }
240
+ catch (error) {
241
+ if (!leaseLost.aborted && cancelSignal?.aborted) return stopped("cancelled the in-flight lead turn");
242
+ throw error;
243
+ }
224
244
  turns++;
225
245
  const after = validateSnapshot(await readTask({ taskId }), taskId);
226
246
  const nextSignal = digest({ revision: after.revision, action: after.nextAction });
@@ -234,8 +254,10 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
234
254
  }
235
255
  }
236
256
 
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 = () => {} }) {
257
+ /** A worker claims one attempt, persists its exact CLI session, and reports a Git artifact.
258
+ * `cancelSignal` aborts the in-flight worker turn; the attempt is then abandoned
259
+ * (service state `uncertain`, recoverable by the lead) and the result is `stopped`. */
260
+ export async function runFleetWorker({ repository, workerId = randomUUID(), workerAgent, preflight, limits: inputLimits, driver, packet, handoff, cancelSignal, onEvent = () => {} }) {
239
261
  assertId(workerId, "worker ID");
240
262
  if (workerAgent !== undefined && !isLoopProvider(workerAgent)) throw new Error(`workerAgent must be ${LOOP_PROVIDER_LIST}`);
241
263
  const limits = validateFleetLimits(inputLimits);
@@ -259,12 +281,14 @@ export async function runFleetWorker({ repository, workerId = randomUUID(), work
259
281
  let turns = 0, stalls = 0;
260
282
  const report = async (outcome) => reportJob({ ...identity, report: outcome, idempotencyKey: key("job-report", job.assignmentId, job.attempt, job.epoch, outcome.status, outcome.commitSha ?? "") });
261
283
  return withHeartbeat(() => renewJob({ ...identity, leaseMs: limits.leaseMs }), limits.heartbeatMs, async (signal) => {
284
+ const cancelled = async () => { await report({ status: "uncertain", reason: "worker turn cancelled", sessionId }); return { status: "stopped", assignmentId: job.assignmentId }; };
262
285
  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
286
  const workspace = await handoff.prepare(job);
264
287
  if (!workspace?.root) throw new Error("Git handoff did not supply a worker root");
265
288
  let lastObservation = null;
266
289
  for (;;) {
267
290
  if (signal.aborted) throw new Error("Job lease renewal failed");
291
+ if (cancelSignal?.aborted) return cancelled();
268
292
  const snapshot = validateSnapshot(await readTask({ taskId: job.taskId }), job.taskId);
269
293
  const item = snapshot.task.tasks?.find((candidate) => candidate.id === job.itemId);
270
294
  if (!item || item.status === "done") {
@@ -282,11 +306,13 @@ export async function runFleetWorker({ repository, workerId = randomUUID(), work
282
306
  }
283
307
  const prompt = await packet({ job, snapshot, workspace, turns });
284
308
  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) => {
309
+ let result;
310
+ 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
311
  if (typeof id !== "string" || !id) throw new Error("Invalid worker session ID");
287
312
  if (sessionId && sessionId !== id) throw new Error("Worker session changed during the same assignment");
288
313
  sessionId = id;
289
- }, onEvent });
314
+ }, onEvent }); }
315
+ catch (error) { if (!signal.aborted && cancelSignal?.aborted) return cancelled(); throw error; }
290
316
  if (result?.sessionId && sessionId && result.sessionId !== sessionId) throw new Error("Worker session changed during the same assignment");
291
317
  if (result?.sessionId && !sessionId) {
292
318
  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,20 @@ 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) and
83
+ // per-task runtime data (worker container index and logs) live in the lead
84
+ // checkout but are never part of a handoff or integration.
85
+ const LOOP_OPERATIONAL_PREFIX = ".agents/data/loop/";
86
+ const TASK_RUNTIME_PATTERN = /^\.agents\/data\/tasks\/[^/]+\/runtime\//;
87
+ /** True for repository-relative paths the loop writes operationally
88
+ * (`.agents/data/loop/` and `.agents/data/tasks/<id>/runtime/`); such paths are
89
+ * ignored by clean checks and working-tree change snapshots. */
90
+ export const isLoopOperationalPath = (path) => typeof path === "string" && (path.startsWith(LOOP_OPERATIONAL_PREFIX) || TASK_RUNTIME_PATTERN.test(path));
81
91
  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");
92
+ // Not via git(): its trim() would eat the leading status column of the first row.
93
+ 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"] });
94
+ const dirty = status.split("\0").filter(Boolean).map((row) => row.slice(3)).filter((path) => !isLoopOperationalPath(path));
95
+ if (dirty.length) throw new Error("Git worktree must be clean before handoff or integration");
83
96
  }
84
97
  function normaliseReport(job) {
85
98
  const report = job.report ?? job;
@@ -214,7 +227,7 @@ export function createWorkerGitHandoff({ root: inputRoot, worktreesRoot: inputWo
214
227
  const done = typeof result?.resultText === "string" && result.resultText.split(/\r?\n/).some((line) => line.trim() === "AI-DLC-ITEM-COMPLETE");
215
228
  return { done, fingerprint: worktreeFingerprint(workspace.root, changed) };
216
229
  },
217
- async finish({ job, workspace }) {
230
+ async finish({ job, workspace, result }) {
218
231
  const branch = validBranch(job.branch);
219
232
  if (git(workspace.root, "symbolic-ref", "--short", "HEAD") !== branch) throw new Error("Worker switched away from assigned branch");
220
233
  const ownedPaths = scopes(job.ownedPaths);
@@ -222,7 +235,8 @@ export function createWorkerGitHandoff({ root: inputRoot, worktreesRoot: inputWo
222
235
  checkScope(dirty, ownedPaths);
223
236
  if (dirty.length) {
224
237
  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")}`);
238
+ const message = extractCommitMessage(result?.resultText, { itemId: job.itemId, ownedPaths });
239
+ git(workspace.root, "-c", "user.name=AI-DLC worker", "-c", "user.email=aidlc-worker@localhost", "commit", "-m", message);
226
240
  }
227
241
  const commitSha = git(workspace.root, "rev-parse", "HEAD");
228
242
  const base = validSha(job.baseCommit, "base commit");
@@ -259,7 +273,7 @@ export function createIsolatedWorkerGitHandoff(options) {
259
273
  const done = typeof result?.resultText === "string" && result.resultText.split(/\r?\n/).some((line) => line.trim() === "AI-DLC-ITEM-COMPLETE");
260
274
  return {done, fingerprint};
261
275
  },
262
- async finish({job, workspace}) {
276
+ async finish({job, workspace, result}) {
263
277
  const changed=inspectWorkerSnapshot(workspace.root, workspace.baseline, job.ownedPaths);
264
278
  if (!changed.length) throw new Error("Worker produced no changes");
265
279
  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 +290,8 @@ export function createIsolatedWorkerGitHandoff(options) {
276
290
  if (!existsSync(source)) { rmSync(target,{force:true}); continue; }
277
291
  copyFileSync(source,target); chmodSync(target,lstatSync(source).mode & 0o777);
278
292
  }
279
- const result=await host.finish({job,workspace:workspace.checkout});
280
- rmSync(workspace.root,{recursive:true,force:true}); return result;
293
+ const report=await host.finish({job,workspace:workspace.checkout,result});
294
+ rmSync(workspace.root,{recursive:true,force:true}); return report;
281
295
  },
282
296
  async discard({workspace}) { rmSync(workspace.root,{recursive:true,force:true}); return host.discard({workspace:workspace.checkout}); }
283
297
  };