@felixdotgo/aidlc-kit 0.0.1 → 0.1.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 (92) hide show
  1. package/DEVELOPMENT.md +1 -1
  2. package/README.md +5 -4
  3. package/dist/assets/.agents/aidlc/conventions.md +8 -8
  4. package/dist/assets/.agents/aidlc/orchestrator.md +15 -10
  5. package/dist/assets/.agents/aidlc/phase-build.md +7 -7
  6. package/dist/assets/.agents/aidlc/phase-clarify.md +3 -3
  7. package/dist/assets/.agents/aidlc/phase-plan.md +6 -6
  8. package/dist/assets/.agents/aidlc/phase-wrap.md +1 -1
  9. package/dist/assets/.agents/aidlc/scripts/assignment.mjs +36 -0
  10. package/dist/assets/.agents/aidlc/scripts/context.mjs +2 -0
  11. package/dist/assets/.agents/aidlc/scripts/gate-check.mjs +2 -0
  12. package/dist/assets/.agents/aidlc/scripts/gate-view.mjs +2 -0
  13. package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +181 -0
  14. package/dist/assets/.agents/aidlc/scripts/lib/authority.mjs +3 -0
  15. package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +8 -1
  16. package/dist/assets/.agents/aidlc/scripts/lib/feature-store.mjs +70 -0
  17. package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +215 -0
  18. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +183 -0
  19. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +100 -0
  20. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +58 -0
  21. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +314 -0
  22. package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +279 -0
  23. package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +51 -0
  24. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-artifacts.mjs +63 -0
  25. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-broker.mjs +53 -0
  26. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-local-broker.mjs +44 -0
  27. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-sandbox.mjs +88 -0
  28. package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +89 -0
  29. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-gate-receipt.mjs +32 -0
  30. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +161 -0
  31. package/dist/assets/.agents/aidlc/scripts/lib/loop-planner.mjs +13 -0
  32. package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +254 -0
  33. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +131 -0
  34. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +55 -0
  35. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +101 -0
  36. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +32 -0
  37. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-snapshot.mjs +65 -0
  38. package/dist/assets/.agents/aidlc/scripts/lib/remote-state-client.mjs +112 -0
  39. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +39 -4
  40. package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +90 -0
  41. package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +58 -0
  42. package/dist/assets/.agents/aidlc/scripts/loop.mjs +36 -0
  43. package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +309 -0
  44. package/dist/assets/.agents/aidlc/scripts/render.mjs +2 -0
  45. package/dist/assets/.agents/aidlc/scripts/state.mjs +67 -11
  46. package/dist/assets/.agents/aidlc/scripts/task-next.mjs +2 -0
  47. package/dist/assets/.agents/aidlc/templates/model-contract.md +3 -3
  48. package/dist/assets/.agents/aidlc/templates/workplan.md +2 -2
  49. package/dist/assets/.agents/skills/aidlc-build/SKILL.md +2 -2
  50. package/dist/assets/.agents/skills/aidlc-clarify/SKILL.md +2 -2
  51. package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +2 -2
  52. package/dist/assets/.agents/skills/aidlc-wrap/SKILL.md +1 -1
  53. package/dist/services/aidlc-coordination/.env.fleet.example +11 -0
  54. package/dist/services/aidlc-coordination/compose.yaml +23 -2
  55. package/dist/services/aidlc-coordination/package-lock.json +1 -0
  56. package/dist/services/aidlc-coordination/package.json +2 -1
  57. package/dist/services/aidlc-coordination/src/coordination.mjs +1 -1
  58. package/dist/services/aidlc-coordination/src/fleet-client.mjs +36 -0
  59. package/dist/services/aidlc-coordination/src/fleet-run.mjs +18 -0
  60. package/dist/services/aidlc-coordination/src/fleet.mjs +233 -0
  61. package/dist/services/aidlc-coordination/src/git-gateway.mjs +50 -0
  62. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +39 -4
  63. package/dist/services/aidlc-coordination/src/repository.mjs +167 -4
  64. package/dist/services/aidlc-coordination/src/server.mjs +53 -9
  65. package/dist/services/aidlc-coordination/test/fleet.test.mjs +351 -0
  66. package/dist/services/aidlc-coordination/test/git-gateway.test.mjs +75 -0
  67. package/dist/services/aidlc-coordination/test/supervisor-fencing.test.mjs +59 -0
  68. package/dist/src/adapters.js +25 -5
  69. package/dist/src/cli.js +66 -6
  70. package/dist/src/gate.js +3 -3
  71. package/dist/src/installer.d.ts +5 -1
  72. package/dist/src/installer.js +114 -30
  73. package/dist/src/managed-content.js +1 -1
  74. package/dist/src/model.d.ts +15 -0
  75. package/dist/src/model.js +1 -1
  76. package/dist/src/profiles.d.ts +3 -1
  77. package/dist/src/profiles.js +26 -3
  78. package/dist/src/state.d.ts +7 -0
  79. package/dist/src/state.js +54 -3
  80. package/dist/src/upgrade.d.ts +2 -2
  81. package/dist/src/upgrade.js +39 -4
  82. package/dist/src/workflow.d.ts +1 -0
  83. package/dist/src/workflow.js +2 -1
  84. package/docs/README.md +2 -2
  85. package/docs/architecture.md +6 -1
  86. package/docs/command-reference.md +63 -5
  87. package/docs/configuration.md +20 -1
  88. package/docs/coordination-service.md +54 -1
  89. package/docs/development.md +1 -1
  90. package/docs/operating-workflow.md +50 -14
  91. package/docs/testing-and-release.md +47 -4
  92. package/package.json +2 -2
@@ -0,0 +1,70 @@
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { nextAction } from "./store.mjs";
4
+
5
+ const idPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
6
+ const checkedId = (id) => { if (!idPattern.test(id ?? "")) throw new Error(`Invalid feature/task id: ${id}`); return id; };
7
+ const directory = (root) => join(root, ".agents/data/features");
8
+ const pathOf = (root, id) => join(directory(root), `${checkedId(id)}.json`);
9
+ const safePath = (root, id) => {
10
+ const dir = directory(root);
11
+ let cursor = root;
12
+ for (const part of [".agents", "data", "features"]) {
13
+ cursor = join(cursor, part);
14
+ if (existsSync(cursor) && lstatSync(cursor).isSymbolicLink()) throw new Error(`Feature path crosses symlink: ${id}`);
15
+ }
16
+ const path = pathOf(root, id);
17
+ if (existsSync(path) && lstatSync(path).isSymbolicLink()) throw new Error(`Feature path crosses symlink: ${id}`);
18
+ return path;
19
+ };
20
+
21
+ export const validateFeature = (feature, state) => {
22
+ if (!feature || feature.schemaVersion !== 1 || !idPattern.test(feature.id ?? "") || typeof feature.integrationBranch !== "string" || !feature.integrationBranch.trim() || !Array.isArray(feature.tasks)) throw new Error("Invalid feature manifest");
23
+ const byId = new Map();
24
+ for (const item of feature.tasks) {
25
+ if (!idPattern.test(item?.id ?? "") || !Array.isArray(item.dependsOn) || new Set(item.dependsOn).size !== item.dependsOn.length || byId.has(item.id)) throw new Error(`Invalid feature task: ${item?.id}`);
26
+ if (!state.tasks[item.id] && !state.archive?.[item.id]) throw new Error(`Unknown feature task: ${item.id}`);
27
+ byId.set(item.id, item);
28
+ }
29
+ const visiting = new Set(); const visited = new Set();
30
+ const visit = (id) => {
31
+ if (visiting.has(id)) throw new Error(`Cyclic feature dependency: ${id}`);
32
+ if (visited.has(id)) return;
33
+ visiting.add(id);
34
+ for (const dep of byId.get(id).dependsOn) {
35
+ if (!byId.has(dep)) throw new Error(`Unknown feature dependency: ${id}/${dep}`);
36
+ visit(dep);
37
+ }
38
+ visiting.delete(id); visited.add(id);
39
+ };
40
+ for (const id of byId.keys()) visit(id);
41
+ return feature;
42
+ };
43
+
44
+ export const loadFeature = (root, id, state) => {
45
+ const path = safePath(root, id);
46
+ return existsSync(path) ? validateFeature(JSON.parse(readFileSync(path, "utf8")), state) : undefined;
47
+ };
48
+ export const listFeatures = (root, state) => existsSync(directory(root)) ? readdirSync(directory(root)).filter((name) => name.endsWith(".json")).map((name) => loadFeature(root, name.slice(0, -5), state)) : [];
49
+ export const saveFeature = (root, feature, state) => {
50
+ validateFeature(feature, state);
51
+ const path = safePath(root, feature.id);
52
+ mkdirSync(directory(root), { recursive: true });
53
+ const temporary = `${path}.tmp-${process.pid}-${Date.now()}`;
54
+ writeFileSync(temporary, `${JSON.stringify(feature, null, 2)}\n`, "utf8");
55
+ renameSync(temporary, path);
56
+ return feature;
57
+ };
58
+ export const readyFeatureTasks = (feature, state) => {
59
+ validateFeature(feature, state);
60
+ const ready = []; const blocked = [];
61
+ for (const item of feature.tasks) {
62
+ const task = state.tasks[item.id] ?? state.archive?.[item.id];
63
+ if (task.status === "done") continue;
64
+ if (task.status === "closed" || task.status === "superseded") { blocked.push({ id: item.id, waitingOn: [], reason: `Task is ${task.status}` }); continue; }
65
+ const waitingOn = item.dependsOn.filter((id) => (state.tasks[id] ?? state.archive?.[id])?.status !== "done");
66
+ if (waitingOn.length) blocked.push({ id: item.id, waitingOn });
67
+ else ready.push({ id: item.id, nextAction: state.tasks[item.id] ? nextAction(state.tasks[item.id]) : { classification: "terminal", outcome: task.status } });
68
+ }
69
+ return { id: feature.id, ready, blocked };
70
+ };
@@ -0,0 +1,215 @@
1
+ import { spawn } from "node:child_process";
2
+ import { accessSync, constants } from "node:fs";
3
+ import { delimiter, join } from "node:path";
4
+
5
+ const MAX_LINE_BYTES = 1024 * 1024;
6
+ const MAX_STDERR_BYTES = 4096;
7
+ const MAX_RESULT_CHARS = 8192;
8
+ const MAX_DIAGNOSTICS = 16;
9
+
10
+ /** One runtime enumeration for every loop provider. Host gates import this
11
+ * list; container paths reject providers that need file-based credentials. */
12
+ export const LOOP_PROVIDERS = Object.freeze(["codex", "claude", "opencode"]);
13
+ export const LOOP_PROVIDER_LIST = `${LOOP_PROVIDERS.slice(0, -1).join(", ")}, or ${LOOP_PROVIDERS[LOOP_PROVIDERS.length - 1]}`;
14
+ export const isLoopProvider = (value) => typeof value === "string" && LOOP_PROVIDERS.includes(value);
15
+ const OPENCODE_MODEL = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*(?:#[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
16
+
17
+ const record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
18
+ const redact = (value) => String(value ?? "")
19
+ .replace(/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY))=([^\s]+)/gi, "$1=[REDACTED]")
20
+ .replace(/\b(?:sk|ghp|github_pat)_[A-Za-z0-9_-]+\b/g, "[REDACTED]")
21
+ .replace(/\s+/g, " ").trim();
22
+ const diagnostic = (value) => redact(value).slice(0, 512);
23
+ const isId = (value) => typeof value === "string" && value.length > 0 && value.length <= 256 && !/[\s\x00-\x1f]/.test(value);
24
+ const emit = async (callback, event) => { if (callback) await callback(event); };
25
+
26
+ // Adapters are registered by trusted application code, never loaded from a
27
+ // project configuration or shell string. Each adapter owns its CLI protocol.
28
+ const adapters = new Map();
29
+ export function registerAgentAdapter(name, adapter) {
30
+ if (typeof name !== "string" || !/^[a-z][a-z0-9-]{0,63}$/.test(name) || adapters.has(name)) throw new Error("invalid or duplicate agent adapter");
31
+ if (!adapter || typeof adapter.command !== "string" || !/^[a-z][a-z0-9-]{0,63}$/.test(adapter.command) || typeof adapter.arguments !== "function" || typeof adapter.parse !== "function" || (adapter.preflight !== undefined && typeof adapter.preflight !== "function") || (adapter.completesOnExit !== undefined && typeof adapter.completesOnExit !== "boolean")) throw new Error("agent adapter requires a command, arguments, parser, and optional preflight");
32
+ adapters.set(name, Object.freeze({ ...adapter }));
33
+ }
34
+ export async function preflightAgentAdapter(name, { env = process.env, root } = {}) {
35
+ const adapter = adapters.get(name);
36
+ if (!adapter) throw new Error(`Unknown agent adapter: ${name}`);
37
+ const paths = (env.PATH ?? process.env.PATH ?? "").split(delimiter);
38
+ if (!paths.some((entry) => { try { accessSync(join(entry || ".", adapter.command), constants.X_OK); return true; } catch { return false; } })) throw new Error(`Worker provider CLI is unavailable: ${name}`);
39
+ if (adapter.preflight) await adapter.preflight({ env, root });
40
+ }
41
+ registerAgentAdapter("codex", {
42
+ command: "codex",
43
+ arguments: ({ root, sessionId }) => ["exec", "--json", "--sandbox", "workspace-write", "--skip-git-repo-check", "-C", root, ...(sessionId ? ["resume", sessionId] : []), "-"],
44
+ input: (prompt) => prompt,
45
+ parse: async (event, context) => {
46
+ if (event.type === "thread.started") await context.captureId(event.thread_id);
47
+ if (event.type === "turn.completed") context.complete();
48
+ if (event.type === "turn.failed" || event.type === "error") throw new Error(`codex reported ${event.type}: ${diagnostic(JSON.stringify(event.error ?? event.message ?? "unknown"))}`);
49
+ const item = record(event.item);
50
+ if (event.type === "item.completed" && item?.type === "agent_message" && typeof item.text === "string") context.result(item.text);
51
+ if (event.type === "item.completed" && item?.type === "command_execution") await context.commandEvent(item.exit_code, item.status);
52
+ }
53
+ });
54
+ registerAgentAdapter("claude", {
55
+ command: "claude",
56
+ arguments: ({ sessionId }) => ["-p", "--verbose", "--input-format", "stream-json", "--output-format", "stream-json", "--permission-mode", "acceptEdits", ...(sessionId ? ["--resume", sessionId] : [])],
57
+ input: (prompt) => `${JSON.stringify({ type: "user", message: { role: "user", content: prompt } })}\n`,
58
+ parse: async (event, context) => {
59
+ if (event.session_id !== undefined) await context.captureId(event.session_id);
60
+ if (event.type === "result") {
61
+ if (event.is_error === true) throw new Error(`claude reported an error: ${diagnostic(event.result)}`);
62
+ context.complete();
63
+ if (typeof event.result === "string") context.result(event.result);
64
+ }
65
+ const message = record(event.message);
66
+ if (Array.isArray(message?.content)) for (const block of message.content) {
67
+ if (!record(block)) continue;
68
+ if (block.type === "tool_use" && typeof block.id === "string" && block.name === "Bash") context.toolUses.set(block.id, true);
69
+ if (block.type === "tool_result" && typeof block.tool_use_id === "string" && context.toolUses.has(block.tool_use_id)) {
70
+ context.toolUses.delete(block.tool_use_id);
71
+ await context.commandEvent(block.is_error === true ? 1 : 0, block.is_error === true ? "failed" : "completed");
72
+ }
73
+ }
74
+ }
75
+ });
76
+
77
+ registerAgentAdapter("opencode", {
78
+ command: "opencode",
79
+ completesOnExit: true,
80
+ arguments: ({ sessionId, prompt, env }) => {
81
+ const model = (env ?? process.env).AIDLC_OPENCODE_MODEL;
82
+ if (typeof model !== "string" || !OPENCODE_MODEL.test(model)) throw new Error("opencode provider requires AIDLC_OPENCODE_MODEL");
83
+ return ["run", "--format", "json", "--standalone", "--auto", ...(sessionId ? ["--session", sessionId] : []), "--model", model, prompt];
84
+ },
85
+ parse: async (event, context) => {
86
+ if (event.sessionID !== undefined) await context.captureId(event.sessionID);
87
+ const part = record(event.part);
88
+ if (event.type === "text" && typeof part?.text === "string") context.result(part.text);
89
+ if (event.type === "tool_use" && part?.tool === "shell") {
90
+ const state = record(part.state);
91
+ const metadata = record(record(state?.metadata)?.metadata);
92
+ await context.commandEvent(metadata?.exit, state?.status === "error" ? "failed" : state?.status);
93
+ }
94
+ if (event.type === "step_finish" && part?.reason === "stop") context.complete();
95
+ }
96
+ });
97
+
98
+ /**
99
+ * Run exactly one agent turn for a registered provider. The returned session ID
100
+ * is the CLI's exact ID; onSessionId observes it immediately. Callers decide when
101
+ * a checkpoint is durable. Transport and protocol failures reject with a bounded,
102
+ * redacted error message.
103
+ */
104
+ export async function runAgentTurn({ agent, root, sessionId, prompt, timeoutMs, onSessionId, onEvent, signal, env, spawnProcess = spawn } = {}) {
105
+ const adapter = adapters.get(agent);
106
+ if (!adapter) throw new Error(`agent must be ${LOOP_PROVIDER_LIST} or a trusted registered adapter`);
107
+ if (typeof root !== "string" || !root) throw new Error("root must be a nonempty path");
108
+ if (typeof prompt !== "string" || !prompt) throw new Error("prompt must be nonempty");
109
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error("timeoutMs must be a positive integer");
110
+ if (sessionId !== undefined && !isId(sessionId)) throw new Error("invalid explicit session ID");
111
+ if (onSessionId !== undefined && typeof onSessionId !== "function") throw new Error("onSessionId must be a function");
112
+ if (onEvent !== undefined && typeof onEvent !== "function") throw new Error("onEvent must be a function");
113
+ if (signal !== undefined && !(signal instanceof AbortSignal)) throw new Error("signal must be an AbortSignal");
114
+ if (signal?.aborted) throw new Error("agent turn aborted before start");
115
+
116
+ const args = adapter.arguments({ root, sessionId, prompt, env });
117
+ if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string")) throw new Error("agent adapter arguments must be an array of strings");
118
+ if (typeof spawnProcess !== "function") throw new Error("spawnProcess must be a function");
119
+ const input = adapter.input?.(prompt);
120
+ if (input !== undefined && typeof input !== "string") throw new Error("agent adapter input must be a string");
121
+ const child = spawnProcess(adapter.command, args, { cwd: root, shell: false, env: { ...(env ?? process.env), PWD: root }, stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"] });
122
+ if (input !== undefined) {
123
+ if (!child.stdin) throw new Error("agent transport lacks stdin");
124
+ child.stdin.on("error", () => {});
125
+ child.stdin.end(input);
126
+ }
127
+ const diagnostics = [];
128
+ let exactId = sessionId;
129
+ let resultText = "";
130
+ let stdoutBuffer = "";
131
+ let stderrText = "";
132
+ let stderrBytes = 0;
133
+ let failed;
134
+ let timedOut = false;
135
+ let aborted = false;
136
+ let completed = false;
137
+ let resultSeen = false;
138
+ let processing = Promise.resolve();
139
+ let killTimer;
140
+ const toolUses = new Map();
141
+ const addDiagnostic = (value) => {
142
+ if (diagnostics.length < MAX_DIAGNOSTICS) diagnostics.push(diagnostic(value));
143
+ };
144
+ const fail = (message) => {
145
+ if (!failed) failed = diagnostic(message);
146
+ child.kill("SIGTERM");
147
+ killTimer ??= setTimeout(() => child.kill("SIGKILL"), 1000);
148
+ };
149
+ const abort = () => { aborted = true; child.kill("SIGTERM"); killTimer ??= setTimeout(() => child.kill("SIGKILL"), 1000); };
150
+ signal?.addEventListener("abort", abort, { once: true });
151
+ if (signal?.aborted) abort();
152
+ const captureId = async (value) => {
153
+ if (!isId(value)) throw new Error(`${agent} reported an invalid session ID`);
154
+ if (exactId && exactId !== value) throw new Error(`${agent} session ID changed during turn`);
155
+ if (!exactId) {
156
+ exactId = value;
157
+ await emit(onSessionId, value);
158
+ }
159
+ };
160
+ const commandEvent = async (exitCode, status) => {
161
+ const safeExitCode = Number.isSafeInteger(exitCode) ? exitCode : null;
162
+ const safeStatus = ["completed", "failed", "in_progress"].includes(status) ? status : "unknown";
163
+ await emit(onEvent, { type: "lifecycle_command", exitCode: safeExitCode, status: safeStatus });
164
+ if ((safeExitCode !== null && safeExitCode !== 0) || safeStatus === "failed") addDiagnostic(`commandFailed exitCode=${String(safeExitCode ?? "unknown")} status=${safeStatus}`);
165
+ };
166
+ const parseEvent = async (line) => {
167
+ let event;
168
+ try { event = JSON.parse(line); } catch { throw new Error(`${agent} emitted malformed JSONL`); }
169
+ if (!record(event) || typeof event.type !== "string") throw new Error(`${agent} emitted malformed event`);
170
+ await adapter.parse(event, { captureId, commandEvent, toolUses, complete: () => { completed = true; resultSeen = true; }, result: (value) => { resultText = value.slice(0, MAX_RESULT_CHARS); } });
171
+ await emit(onEvent, { type: "agent_event", eventType: event.type });
172
+ };
173
+ const queueLine = (line) => {
174
+ processing = processing.then(() => parseEvent(line)).catch((error) => fail(error.message));
175
+ };
176
+ child.stdout.on("data", (chunk) => {
177
+ if (failed) return;
178
+ stdoutBuffer += chunk.toString("utf8");
179
+ if (Buffer.byteLength(stdoutBuffer) > MAX_LINE_BYTES && !stdoutBuffer.includes("\n")) { fail(`${agent} JSONL line exceeded limit`); return; }
180
+ let end;
181
+ while ((end = stdoutBuffer.indexOf("\n")) !== -1) {
182
+ const line = stdoutBuffer.slice(0, end).trim();
183
+ stdoutBuffer = stdoutBuffer.slice(end + 1);
184
+ if (Buffer.byteLength(line) > MAX_LINE_BYTES) { fail(`${agent} JSONL line exceeded limit`); return; }
185
+ if (line) queueLine(line);
186
+ }
187
+ });
188
+ child.stderr.on("data", (chunk) => {
189
+ const remaining = MAX_STDERR_BYTES - stderrBytes;
190
+ if (remaining > 0) { stderrText += chunk.subarray(0, remaining).toString("utf8"); stderrBytes += Math.min(chunk.length, remaining); }
191
+ });
192
+ const timer = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); killTimer ??= setTimeout(() => child.kill("SIGKILL"), 1000); }, timeoutMs);
193
+ timer.unref();
194
+ const exit = await new Promise((resolve) => {
195
+ child.once("error", (error) => resolve({ error }));
196
+ child.once("close", (code, signal) => resolve({ code, signal }));
197
+ });
198
+ clearTimeout(timer);
199
+ clearTimeout(killTimer);
200
+ signal?.removeEventListener("abort", abort);
201
+ if (stdoutBuffer.trim() && !failed) queueLine(stdoutBuffer.trim());
202
+ await processing;
203
+ if (timedOut) throw new Error(`${agent} turn timed out after ${timeoutMs} ms`);
204
+ if (aborted) throw new Error(`${agent} turn aborted`);
205
+ if (exit.error) throw new Error(`${agent} failed to start: ${diagnostic(exit.error.message)}`);
206
+ if (failed) throw new Error(failed);
207
+ if (exit.code !== 0) throw new Error(`${agent} exited ${exit.code ?? exit.signal}: ${diagnostic(stderrText)}`);
208
+ if (!exactId) throw new Error(`${agent} turn ended without an explicit captured session ID`);
209
+ if (agent === "claude" && !resultSeen) throw new Error("claude turn ended without a result event");
210
+ if (!completed && adapter.completesOnExit === true) completed = true;
211
+ if (!completed) throw new Error(`${agent} turn ended without a completion event`);
212
+ if (stderrText.trim()) addDiagnostic(`stderr: ${stderrText}`);
213
+ await emit(onEvent, { type: "turn_completed" });
214
+ return { sessionId: exactId, resultText, diagnostics };
215
+ }
@@ -0,0 +1,183 @@
1
+ import { chmodSync, existsSync, mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { execFileSync, spawn } from "node:child_process";
4
+ import { randomUUID } from "node:crypto";
5
+ import { join, resolve } from "node:path";
6
+ import { isLoopProvider, LOOP_PROVIDER_LIST, runAgentTurn } from "./loop-drivers.mjs";
7
+ import { runFleetCoordinator, runFleetWorker, validateFleetLimits } from "./loop-fleet.mjs";
8
+ import { runFleetSupervisor } from "./loop-fleet-supervisor.mjs";
9
+ import { createFleetRemoteRepository } from "./loop-fleet-remote.mjs";
10
+ import { createLeadGitHandoff, createIsolatedWorkerGitHandoff } from "./loop-git.mjs";
11
+ import { workerDockerInvocation, OPENCODE_CONTAINER_ERROR } from "./loop-worker-docker.mjs";
12
+
13
+ const required = (env, name) => { const value = env[name]; if (typeof value !== "string" || !value.trim()) throw new Error(`${name} is required`); return value; };
14
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
15
+ const provider = (value, name) => { if (!isLoopProvider(value)) throw new Error(`${name} must be ${LOOP_PROVIDER_LIST}`); return value; };
16
+ function workerMapping(env, workerIds, defaultAgent) {
17
+ if (!env.AIDLC_FLEET_WORKER_PROVIDERS) return Object.fromEntries(workerIds.map((id) => [id, defaultAgent]));
18
+ let mapping;
19
+ try { mapping = JSON.parse(env.AIDLC_FLEET_WORKER_PROVIDERS); } catch { throw new Error("AIDLC_FLEET_WORKER_PROVIDERS must be a JSON object"); }
20
+ 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
+ return mapping;
22
+ }
23
+
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 } = {}) {
26
+ const [role, targetId, ...flags] = argv;
27
+ 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
+ const once = flags.includes("--once") || (role === "worker" && targetId === "--once");
29
+ if (role === "worker" && targetId && targetId !== "--once") throw new Error("worker accepts no task ID");
30
+ const root = resolve(required(env, "AIDLC_FLEET_PROJECT_ROOT"));
31
+ const url = required(env, "AIDLC_FLEET_URL");
32
+ const token = required(env, "AIDLC_FLEET_TOKEN");
33
+ const workspace = required(env, "AIDLC_FLEET_WORKSPACE");
34
+ const remote = env.AIDLC_FLEET_GIT_REMOTE ?? "origin";
35
+ const limits = validateFleetLimits({
36
+ ...(env.AIDLC_FLEET_MAX_TURNS ? { maxTurns: Number(env.AIDLC_FLEET_MAX_TURNS) } : {}),
37
+ ...(env.AIDLC_FLEET_TURN_TIMEOUT_MS ? { turnTimeoutMs: Number(env.AIDLC_FLEET_TURN_TIMEOUT_MS) } : {}),
38
+ ...(env.AIDLC_FLEET_MAX_STALL_TURNS ? { maxStallTurns: Number(env.AIDLC_FLEET_MAX_STALL_TURNS) } : {}),
39
+ ...(env.AIDLC_FLEET_MAX_WORKERS ? { maxWorkers: Number(env.AIDLC_FLEET_MAX_WORKERS) } : {}),
40
+ ...(env.AIDLC_FLEET_LEASE_MS ? { leaseMs: Number(env.AIDLC_FLEET_LEASE_MS) } : {}),
41
+ ...(env.AIDLC_FLEET_HEARTBEAT_MS ? { heartbeatMs: Number(env.AIDLC_FLEET_HEARTBEAT_MS) } : {})
42
+ });
43
+ const repository = createFleetRemoteRepository({ callTool, url, token, workspace, root, workerId: env.AIDLC_FLEET_WORKER_ID });
44
+ const pollMs = env.AIDLC_FLEET_POLL_MS ? Number(env.AIDLC_FLEET_POLL_MS) : 5000;
45
+ if (!Number.isSafeInteger(pollMs) || pollMs < 1000 || pollMs > 300000) throw new Error("AIDLC_FLEET_POLL_MS must be 1000..300000");
46
+ 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));
48
+ if (role === "worker") {
49
+ 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");
54
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,255}$/.test(requestedImage)) throw new Error("Invalid Docker worker image");
55
+ 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
+ if (!/^sha256:[a-f0-9]{64}$/.test(image)) throw new Error("Docker worker image lacks immutable image ID");
57
+ const uid = process.getuid?.(), gid = process.getgid?.();
58
+ 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
+ const preflight = async (agent) => {
60
+ const credential = agent === "codex" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
61
+ const empty = mkdtempSync(join(tmpdir(), "aidlc-worker-preflight-"));
62
+ try {
63
+ const invocation = workerDockerInvocation({workspace:empty,image,command:agent,args:["--version"],credentialEnv:[credential],hostEnv:env,uid,gid});
64
+ execFileSync("docker", ["info"], {env:{PATH:invocation.env.PATH},stdio:"ignore",timeout:30000});
65
+ execFileSync("docker", ["image","inspect",image], {env:{PATH:invocation.env.PATH},stdio:"ignore",timeout:30000});
66
+ const name = `aidlc-worker-preflight-${randomUUID()}`;
67
+ const args = invocation.args.filter((arg,index) => !(index===1 && arg==="--rm"));
68
+ args.splice(1,0,"--name",name);
69
+ let failure;
70
+ try { execFileSync(invocation.command, args, {env:invocation.env,stdio:"ignore",timeout:30000}); }
71
+ catch (error) { failure = error; }
72
+ try { execFileSync("docker",["rm","--force",name],{env:{PATH:invocation.env.PATH},stdio:"ignore",timeout:30000}); }
73
+ catch { throw new Error("Could not confirm Docker preflight container removal"); }
74
+ if (failure) throw failure;
75
+ } finally { rmSync(empty,{recursive:true,force:true}); }
76
+ };
77
+ if (workerAgent) await preflight(workerAgent);
78
+ const baseHandoff = createIsolatedWorkerGitHandoff({ root, worktreesRoot: resolve(required(env, "AIDLC_FLEET_WORKTREES_ROOT")), remote });
79
+ 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); } };
81
+ const handoff = {
82
+ ...baseHandoff,
83
+ async prepare(job) {
84
+ const workspace=await baseHandoff.prepare(job);
85
+ 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});
87
+ return workspace;
88
+ },
89
+ async finish(args) { try { return await baseHandoff.finish(args); } finally { cleanupSession(args.workspace); } },
90
+ async discard(args) { try { return await baseHandoff.discard(args); } finally { cleanupSession(args.workspace); } }
91
+ };
92
+ const isolatedDriver = async (turn) => {
93
+ if (turn.agent === "opencode") throw new Error(OPENCODE_CONTAINER_ERROR);
94
+ const session=sessions.get(turn.root);
95
+ 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";
97
+ const name = `aidlc-worker-${randomUUID()}`;
98
+ let started = false;
99
+ 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});
102
+ const dockerArgs=invocation.args.filter((arg,index) => !(index===1 && arg==="--rm"));
103
+ dockerArgs.splice(1,0,"--name",name);
104
+ started = true;
105
+ return spawn(invocation.command,dockerArgs,{...options,cwd:turn.root,env:invocation.env});
106
+ }});
107
+ session.sessionId=result.sessionId;
108
+ return result;
109
+ } finally {
110
+ if (started) {
111
+ try { execFileSync("docker",["rm","--force",name],{env:{PATH:env.PATH ?? "/usr/bin:/bin"},stdio:"ignore",timeout:30000}); }
112
+ catch { throw new Error("Could not confirm isolated Docker worker removal; reconcile before continuing"); }
113
+ }
114
+ }
115
+ };
116
+ for (;;) {
117
+ if (signal?.aborted) return { status: "stopped" };
118
+ let result;
119
+ try { result = await runFleetWorker({ repository, workerId, workerAgent, preflight, limits, driver: isolatedDriver, handoff, packet: async ({ job, snapshot }) => [
120
+ `AI-DLC worker assignment ${job.assignmentId}, attempt ${job.attempt}.`,
121
+ `Task: ${snapshot.task.title} (${job.taskId}). Item: ${job.itemId}.`,
122
+ `Only change these owned paths: ${job.ownedPaths.join(", ")}.`,
123
+ `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.",
125
+ ...(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
+ ].join("\n"), onEvent: emit }); }
127
+ finally { for (const [workspace, entry] of sessions) { rmSync(entry.home,{recursive:true,force:true}); sessions.delete(workspace); } }
128
+ emit(result);
129
+ if (once) return result;
130
+ await pause(pollMs);
131
+ }
132
+ }
133
+ const workerIds = required(env, "AIDLC_FLEET_WORKER_IDS").split(",").map((value) => value.trim()).filter(Boolean);
134
+ const agent = provider(env.AIDLC_FLEET_AGENT ?? "codex", "AIDLC_FLEET_AGENT");
135
+ const workerProviders = workerMapping(env, workerIds, agent);
136
+ const handoff = createLeadGitHandoff({ root, integrationBranch: required(env, "AIDLC_FLEET_INTEGRATION_BRANCH"), remote, agent });
137
+ const remoteState = join(root, ".agents/aidlc/scripts/remote-state.mjs");
138
+ if (!existsSync(remoteState)) throw new Error(`Remote lifecycle CLI is missing: ${remoteState}`);
139
+ const leadTaskFor = (taskId) => ({ ownerId: env.AIDLC_FLEET_LEAD_ID ?? "lead", workerIds, workerProviders, allowCodeOnlyRetry: env.AIDLC_FLEET_CODE_ONLY_RETRY === "1", pollMs, limits, handoff, completeItem: repository.completeItem,
140
+ packet: async (snapshot, leadEpoch) => {
141
+ const fleetEnv = { ...env, AIDLC_FLEET_LEAD_EPOCH: String(leadEpoch), AIDLC_FLEET_LEAD_OWNER_TOKEN: repository.leadOwnerToken() };
142
+ const raw = execFileSync(process.execPath, [remoteState, "phase", "context", taskId], { cwd: root, env: fleetEnv, encoding: "utf8", timeout: 30000, maxBuffer: 2 * 1024 * 1024 });
143
+ const packet = JSON.parse(raw);
144
+ 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.`;
146
+ },
147
+ driver: async ({ leadEpoch, prompt, timeoutMs, signal: turnSignal }) => {
148
+ 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
+ } });
150
+ const runTask = async (taskId, reservedJobs = []) => runFleetCoordinator({ repository, taskId, ...leadTaskFor(taskId), reservedJobs, onEvent: emit });
151
+ if (role === "supervisor") {
152
+ const result = await runFleetSupervisor({ repository, taskId: targetId, ...leadTaskFor(targetId), signal, onEvent: emit, ...(coordinatorOverride ? { coordinator: coordinatorOverride } : {}) });
153
+ emit(result);
154
+ return result;
155
+ }
156
+ for (;;) {
157
+ if (signal?.aborted) return { status: "stopped" };
158
+ if (role === "feature") {
159
+ const feature = await repository.getFeature({ featureId: targetId });
160
+ if (!feature) throw new Error(`Unknown feature ${targetId}`);
161
+ if (feature.integrationBranch !== env.AIDLC_FLEET_INTEGRATION_BRANCH) throw new Error("Feature integration branch differs from configured checkout");
162
+ const readiness = await repository.featureReady({ featureId: targetId });
163
+ const ready = readiness.items.filter((item) => item.ready);
164
+ if (!ready.length && readiness.items.every((item) => !item.blockers.length)) return { status: "complete", featureId: targetId };
165
+ const results = [];
166
+ for (const member of ready) {
167
+ const reservedJobs = (await repository.listAllJobs()).filter((job) => job.taskId !== member.taskId);
168
+ const result = await runTask(member.taskId, reservedJobs);
169
+ results.push(result); emit(result);
170
+ 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
+ }
172
+ const stopped = results.some((entry) => ["blocked", "reconcile", "paused", "terminal"].includes(entry.status));
173
+ 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
+ if (once || result.status === "blocked") return result;
175
+ await pause(pollMs);
176
+ continue;
177
+ }
178
+ const result = await runTask(targetId);
179
+ emit(result);
180
+ if (once || ["terminal", "complete", "blocked", "paused", "reconcile", "gate"].includes(result.status)) return result;
181
+ await pause(pollMs);
182
+ }
183
+ }
@@ -0,0 +1,100 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ const idempotency = () => randomUUID();
4
+
5
+ /** Map the coordination service's versioned MCP tools to the headless runner. */
6
+ export function createFleetRemoteRepository({ callTool, url, token, workspace, root, workerId }) {
7
+ 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
+ let ownerToken;
10
+ let supervisorToken, supervisorEpoch, supervisorTask;
11
+ const fleet = () => call("fleet_get", {});
12
+ const owner = () => { if (!ownerToken) throw new Error("Lead owner token unavailable"); return ownerToken; };
13
+ const mutation = async (name, args, key, retryRevision = false) => {
14
+ const mutationKey = key ?? idempotency();
15
+ for (let attempt = 0; attempt < (retryRevision ? 3 : 1); attempt++) {
16
+ const current = await fleet();
17
+ try { return await call(name, { expectedRevision: current.revision, idempotencyKey: mutationKey, ...args }); }
18
+ catch (error) { if (!retryRevision || error?.code !== "FLEET_CONFLICT" || attempt === 2) throw error; }
19
+ }
20
+ };
21
+ const normalizeJob = (job) => job && ({ ...job, assignmentId: job.id, expiresAt: job.expiresAt });
22
+ return {
23
+ leadOwnerToken: owner,
24
+ claimSupervisor: async ({ taskId, leaseMs, idempotencyKey }) => {
25
+ if (supervisorToken) throw new Error("Supervisor already claimed by this adapter");
26
+ const token = randomUUID() + randomUUID();
27
+ const value = await mutation("supervisor_claim", { taskId, leaseMs, ownerToken: token }, idempotencyKey);
28
+ const claim = value.fleet.supervisors[taskId];
29
+ if (!Number.isSafeInteger(claim?.epoch)) throw new Error("Supervisor claim lacks epoch");
30
+ supervisorToken = token; supervisorEpoch = claim.epoch; supervisorTask = taskId;
31
+ return claim;
32
+ },
33
+ renewSupervisor: async ({ taskId, leaseMs }) => {
34
+ if (taskId !== supervisorTask || !supervisorToken) throw new Error("Supervisor ownership unavailable");
35
+ return mutation("supervisor_heartbeat", { taskId, epoch: supervisorEpoch, leaseMs, ownerToken: supervisorToken }, undefined, true);
36
+ },
37
+ releaseSupervisor: async ({ taskId }) => {
38
+ if (taskId !== supervisorTask || !supervisorToken) throw new Error("Supervisor ownership unavailable");
39
+ const result = await mutation("supervisor_release", { taskId, epoch: supervisorEpoch, ownerToken: supervisorToken });
40
+ supervisorToken = undefined; supervisorEpoch = undefined; supervisorTask = undefined;
41
+ return result;
42
+ },
43
+ readGateCheckpoint: async ({ taskId }) => call("gate_checkpoint_get", { taskId }),
44
+ getFeature: async ({ featureId }) => (await fleet()).fleet.features[featureId] ?? null,
45
+ featureReady: async ({ featureId }) => call("feature_ready", { featureId }),
46
+ listAllJobs: async () => Object.values((await fleet()).fleet.jobs).map(normalizeJob),
47
+ readTask: async ({ taskId }) => {
48
+ const value = await call("task_get", { taskId, clientProjectRoot: root });
49
+ return { task: value.task, nextAction: value.nextAction, revision: value.revision };
50
+ },
51
+ claimLead: async ({ taskId, leaseMs, idempotencyKey, expectedStateRevision, expectedTaskDigest, checkpoint }) => {
52
+ ownerToken = randomUUID() + randomUUID();
53
+ if (supervisorToken && supervisorTask !== taskId) throw new Error("Supervisor task mismatch");
54
+ const binding = supervisorToken ? { supervisorEpoch, supervisorOwnerToken: supervisorToken, expectedStateRevision, expectedTaskDigest, ...(checkpoint?.status === "approved" ? { checkpointSnapshotId: checkpoint.snapshotId, checkpointApprovalRevision: checkpoint.approvalRevision } : {}) } : {};
55
+ const value = await mutation("run_claim", { taskId, leaseMs, ownerToken, ...binding }, idempotencyKey);
56
+ return value.fleet.leads[taskId];
57
+ },
58
+ renewLead: async ({ taskId, epoch, leaseMs }) => mutation("run_heartbeat", { taskId, epoch, leaseMs, ownerToken: owner() }, undefined, true),
59
+ releaseLead: async ({ taskId, epoch }) => mutation("run_release", { taskId, epoch, ownerToken: owner() }),
60
+ listJobs: async ({ taskId }) => Object.values((await fleet()).fleet.jobs).filter((job) => job.taskId === taskId).map(normalizeJob),
61
+ scheduleJob: async ({ taskId, itemId, workerId: assignedWorker, leadEpoch, baseCommit, branch, ownedPaths, agent, idempotencyKey }) => {
62
+ const before = await fleet();
63
+ const prior = Object.values(before.fleet.jobs).filter((job) => job.taskId === taskId && job.itemId === itemId);
64
+ const attempt = Math.max(0, ...prior.map((job) => job.attempt)) + 1;
65
+ const jobId = randomUUID();
66
+ const value = await call("job_schedule", { expectedRevision: before.revision, idempotencyKey, jobId, taskId, itemId, workerId: assignedWorker, attempt, leadEpoch, ownerToken: owner(), baseCommit, branch, ownedPaths, agent });
67
+ return normalizeJob(value.fleet.jobs[jobId]);
68
+ },
69
+ claimJob: async ({ workerId: claimingWorker, leaseMs, idempotencyKey }) => {
70
+ if (workerId && workerId !== claimingWorker) throw new Error("Worker identity does not match configured credential");
71
+ const before = await fleet();
72
+ const ready = Object.values(before.fleet.jobs).find((job) => job.workerId === claimingWorker && job.state === "ready");
73
+ if (!ready) return null;
74
+ const value = await call("job_claim", { expectedRevision: before.revision, idempotencyKey, jobId: ready.id, leaseMs });
75
+ return normalizeJob(value.fleet.jobs[ready.id]);
76
+ },
77
+ renewJob: async ({ assignmentId, epoch, leaseMs }) => mutation("job_heartbeat", { jobId: assignmentId, epoch, leaseMs }, undefined, true),
78
+ checkpointJob: async ({ assignmentId, epoch, sessionId, checkpoint }) => mutation("job_checkpoint", { jobId: assignmentId, epoch, sessionId, checkpoint: JSON.stringify(checkpoint) }, undefined, true),
79
+ reportJob: async ({ assignmentId, epoch, report, idempotencyKey }) => report.status === "reported"
80
+ ? mutation("job_report", { jobId: assignmentId, epoch, commitSha: report.commitSha, changedPaths: report.changedPaths, summary: report.summary ?? "" }, idempotencyKey)
81
+ : mutation("job_abandon", { jobId: assignmentId, epoch, reason: String(report.reason ?? report.status).slice(0, 2000) }, idempotencyKey),
82
+ reconcileJob: async ({ assignmentId, leadEpoch, decision, idempotencyKey }) => mutation("job_reconcile", { jobId: assignmentId, leadEpoch, ownerToken: owner(), state: decision }, idempotencyKey),
83
+ recoverJob: async ({ assignmentId, leadEpoch, commitSha, changedPaths, idempotencyKey }) => mutation("job_recover", { jobId: assignmentId, leadEpoch, ownerToken: owner(), commitSha, changedPaths }, idempotencyKey),
84
+ saveGateSnapshot: async ({ taskId, leadEpoch, expectedRevision, gate, gateView, integrationCommit, idempotencyKey }) => {
85
+ const value = await call("gate_snapshot_put", { taskId, leadEpoch, leadOwnerToken: owner(), checkpoint: true, expectedRevision, gate, reviewText: gateView, ...(integrationCommit ? { integrationCommit } : {}), idempotencyKey });
86
+ return { snapshotId: value.id };
87
+ },
88
+ completeItem: async ({ taskId, itemId, job, integrated, snapshot, leadEpoch }) => {
89
+ const task = structuredClone(snapshot.task);
90
+ const item = task.tasks?.find((entry) => entry.id === itemId);
91
+ if (!item) throw new Error(`Unknown canonical item ${itemId}`);
92
+ if (item.status === "done") return { alreadyDone: true };
93
+ if (!integrated?.integrationCommit || !integrated?.commitSha) throw new Error("Missing verified integration commit");
94
+ item.status = "done";
95
+ task.evidence.push({ kind: "diagnostic", result: "pass", source: `Git integration ${integrated.integrationCommit} for ${job.assignmentId}; worker commit ${integrated.commitSha}`, recordedAt: new Date().toISOString() });
96
+ task.updatedAt = new Date().toISOString();
97
+ return call("state_apply", { expectedRevision: snapshot.revision, idempotencyKey: `item-${job.assignmentId}`, clientProjectRoot: root, leadEpoch, leadOwnerToken: owner(), command: { type: "upsertTask", taskId, task } });
98
+ }
99
+ };
100
+ }
@@ -0,0 +1,58 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { setTimeout as sleep } from "node:timers/promises";
3
+ import { runFleetCoordinator, validateFleetLimits } from "./loop-fleet.mjs";
4
+
5
+ const taskDigest = (task) => createHash("sha256").update(JSON.stringify(task)).digest("hex");
6
+ const targets = { G0_confirm: "plan", G1_review: "build", G2_codereview: "wrap" };
7
+
8
+ /** The service owns the checkpoint and atomically binds each run claim to this
9
+ * supervisor. An unavailable or ambiguous checkpoint never authorizes work. */
10
+ export async function runFleetSupervisor({ repository, taskId, limits: inputLimits, pollMs = 5000, signal, onEvent = () => {}, coordinator = runFleetCoordinator, ...options }) {
11
+ const limits = validateFleetLimits(inputLimits);
12
+ 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
+ for (const name of ["claimSupervisor", "renewSupervisor", "releaseSupervisor", "readGateCheckpoint", "readTask", "claimLead"]) if (typeof repository[name] !== "function") throw new Error(`Fleet supervisor requires ${name}`);
14
+ await repository.claimSupervisor({ taskId, leaseMs: limits.leaseMs, idempotencyKey: randomUUID() });
15
+ const controller = new AbortController();
16
+ const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
17
+ let failure; let pending = Promise.resolve();
18
+ const timer = setInterval(() => { pending = pending.then(() => repository.renewSupervisor({ taskId, leaseMs: limits.leaseMs })).catch((error) => { failure = error; controller.abort(error); }); }, limits.heartbeatMs);
19
+ timer.unref?.();
20
+ try {
21
+ while (!combined.aborted) {
22
+ const snapshot = await repository.readTask({ taskId });
23
+ const checkpoint = await repository.readGateCheckpoint({ taskId });
24
+ if (!snapshot?.task || snapshot.task.id !== taskId || !Number.isSafeInteger(snapshot.revision)) throw new Error("Invalid fleet canonical snapshot");
25
+ const task = snapshot.task;
26
+ if (task.status === "paused" || task.handoff || ["blocked", "terminal"].includes(snapshot.nextAction?.classification)) return { status: "blocked", taskId, reason: "Task is paused, blocked, or terminal" };
27
+ if (snapshot.nextAction?.classification === "complete") return { status: "complete", taskId };
28
+ if (checkpoint?.status === "pending") {
29
+ 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
+ await onEvent({ type: "waiting", taskId, gate: checkpoint.gate, snapshotId: checkpoint.snapshotId });
31
+ try { await sleep(pollMs, undefined, { signal: combined }); } catch (error) { if (error?.name !== "AbortError") throw error; }
32
+ continue;
33
+ }
34
+ 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) {
37
+ // A task snapshot older than the atomic approval is a read race between
38
+ // the two reads, not a mismatch: re-read the canonical task immediately.
39
+ if (Number.isSafeInteger(checkpoint.approvalRevision) && checkpoint.approvalRevision > snapshot.revision) { await onEvent({ type: "resync", taskId, gate: checkpoint.gate }); continue; }
40
+ if (task.phase !== targets[checkpoint.gate] || task.status !== "active" || !Number.isSafeInteger(checkpoint.approvalRevision)) return { status: "blocked", taskId, reason: "Approved checkpoint does not match canonical state" };
41
+ }
42
+ if (snapshot.nextAction?.classification !== "run_phase" || task.status !== "active") return { status: "blocked", taskId, reason: "Task is not actionable or gate checkpoint is missing" };
43
+ await repository.renewSupervisor({ taskId, leaseMs: limits.leaseMs });
44
+ if (combined.aborted) break;
45
+ 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 });
47
+ if (failure) throw failure;
48
+ if (result?.status !== "gate") return result;
49
+ // gate_snapshot_put stores snapshot and checkpoint in one service transaction.
50
+ await onEvent({ type: "gate", taskId, gate: result.gate, snapshotId: result.snapshotId, gateView: result.gateView });
51
+ }
52
+ if (failure) throw new Error(`Supervisor lease renewal failed: ${failure.message}`);
53
+ return { status: "stopped", taskId };
54
+ } finally {
55
+ clearInterval(timer); await pending;
56
+ await repository.releaseSupervisor({ taskId });
57
+ }
58
+ }