@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,51 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+
5
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
6
+ const digest = (value) => createHash("sha256").update(value).digest("hex");
7
+
8
+ /** Prepare a durable, idempotent intake without treating it as lifecycle state. */
9
+ export async function prepareLoopIntake({ root, taskId, requirement, authority, createTask, readTask }) {
10
+ if (!ID.test(taskId ?? "")) throw new Error("Invalid task ID");
11
+ if (!["local", "fleet"].includes(authority)) throw new Error("Invalid loop authority");
12
+ if (typeof requirement !== "string" || !requirement.trim() || Buffer.byteLength(requirement) > 8192) throw new Error("Requirement must contain 1–8192 bytes");
13
+ if (typeof createTask !== "function" || typeof readTask !== "function") throw new Error("Intake requires lifecycle callbacks");
14
+ const project = resolve(root), base = join(project, ".agents", "data", "tasks");
15
+ for (const path of [join(project, ".agents"), join(project, ".agents", "data"), base]) {
16
+ if (!existsSync(path)) mkdirSync(path);
17
+ const stat = lstatSync(path);
18
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Unsafe intake directory");
19
+ }
20
+ const directory = join(base, taskId);
21
+ if (!existsSync(directory)) mkdirSync(directory, { mode: 0o700 });
22
+ const stat = lstatSync(directory);
23
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Unsafe task directory");
24
+ const recordPath = join(directory, "loop-intake.json");
25
+ const record = { schemaVersion: 1, taskId, authority, requirementHash: digest(requirement) };
26
+ if (!existsSync(recordPath) && await readTask(taskId)) throw new Error("Existing task has no matching loop intake record");
27
+ try { writeFileSync(recordPath, `${JSON.stringify(record)}\n`, { flag: "wx", mode: 0o600 }); }
28
+ catch (error) {
29
+ if (error?.code !== "EEXIST") throw error;
30
+ const currentStat = lstatSync(recordPath);
31
+ if (!currentStat.isFile() || currentStat.isSymbolicLink()) throw new Error("Unsafe intake record");
32
+ let current; try { current = JSON.parse(readFileSync(recordPath, "utf8")); } catch { throw new Error("Invalid intake record"); }
33
+ if (JSON.stringify(current) !== JSON.stringify(record)) throw new Error("Existing intake has different authority or requirement");
34
+ }
35
+ const requirementPath = join(directory, "requirement.md");
36
+ try { writeFileSync(requirementPath, requirement, { flag: "wx", mode: 0o600 }); }
37
+ catch (error) {
38
+ if (error?.code !== "EEXIST") throw error;
39
+ const existing = lstatSync(requirementPath);
40
+ if (!existing.isFile() || existing.isSymbolicLink() || digest(readFileSync(requirementPath, "utf8")) !== record.requirementHash) throw new Error("Existing requirement does not match intake");
41
+ }
42
+ const title = `Loop task ${taskId}`;
43
+ let task = await readTask(taskId);
44
+ if (!task) {
45
+ try { await createTask({ taskId, title }); }
46
+ catch (error) { task = await readTask(taskId); if (!task || task.title !== title) throw error; }
47
+ task ??= await readTask(taskId);
48
+ }
49
+ if (!task || task.id !== taskId || task.title !== title) throw new Error("Existing task conflicts with intake");
50
+ return { taskId, authority, requirementPath };
51
+ }
@@ -0,0 +1,63 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { closeSync, existsSync, fsyncSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ const hash = (data) => createHash("sha256").update(data).digest("hex");
6
+ const digest = (path) => {
7
+ let stat;
8
+ try { stat = lstatSync(path); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
9
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error("Unsafe lead artifact file");
10
+ return hash(readFileSync(path));
11
+ };
12
+ const syncDir = (dir) => { const fd = openSync(dir, "r"); try { fsyncSync(fd); } finally { closeSync(fd); } };
13
+ const hexOrNull = (x) => x === null || (typeof x === "string" && /^[a-f0-9]{64}$/.test(x));
14
+ const safeFile = (path) => { const stat = lstatSync(path); if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error("Unsafe lead artifact journal"); };
15
+ function directory(root, id) {
16
+ let cursor = root;
17
+ for (const part of [".agents", "data", "tasks", id]) { cursor = join(cursor, part); const stat = lstatSync(cursor); if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Unsafe lead artifact directory"); }
18
+ return cursor;
19
+ }
20
+ /** Read the expected digest for a phase packet while holding the same lock. */
21
+ export function leadArtifactDigest({ root, taskId, task, name }) {
22
+ if (!["intent", "design", "workplan"].includes(name) || task.artifacts?.[name] !== `.agents/data/tasks/${taskId}/${name}.md`) throw new Error("Invalid canonical artifact path");
23
+ return digest(join(directory(root, taskId), `${name}.md`));
24
+ }
25
+ /** Must be called while holding the canonical state lock and supervisor fence. */
26
+ export async function recoverLeadArtifact({ root, taskId, task, assertOwned }) {
27
+ let dir;
28
+ try { dir = directory(root, taskId); } catch (error) { if (error.code === "ENOENT") return; throw error; }
29
+ const journal = join(dir, ".lead-artifact-journal.json");
30
+ try { safeFile(journal); } catch (error) { if (error.code === "ENOENT") return; throw error; }
31
+ let entry; try { entry = JSON.parse(readFileSync(journal, "utf8")); } catch { throw new Error("Invalid lead artifact journal"); }
32
+ if (!entry || entry.version !== 1 || !["intent", "design", "workplan"].includes(entry.name) || !hexOrNull(entry.before) || typeof entry.after !== "string" || !/^[a-f0-9]{64}$/.test(entry.after) || typeof entry.temp !== "string" || !/^\.lead-artifact-[0-9a-f-]{36}\.tmp$/.test(entry.temp) || task.artifacts?.[entry.name] !== `.agents/data/tasks/${taskId}/${entry.name}.md`) throw new Error("Invalid lead artifact journal");
33
+ const target = join(dir, `${entry.name}.md`), temp = join(dir, entry.temp), current = digest(target);
34
+ if (current !== entry.before && current !== entry.after) throw new Error("Lead artifact journal conflicts with current file; reconcile manually");
35
+ await assertOwned();
36
+ // Once the target contains the after digest the write committed. Otherwise the
37
+ // intent was not committed; discard the temp so the original can be retried.
38
+ try { safeFile(temp); unlinkSync(temp); } catch (error) { if (error.code !== "ENOENT") throw error; }
39
+ unlinkSync(journal); syncDir(dir);
40
+ }
41
+ /** Compare the reviewed artifact digest, stage a durable intent, then rename.
42
+ * Recovery never guesses when the target was changed by another writer. */
43
+ export async function writeLeadArtifact({ root, taskId, task, operation: op, assertOwned }) {
44
+ const dir = directory(root, taskId);
45
+ if (task.artifacts?.[op.name] !== `.agents/data/tasks/${taskId}/${op.name}.md`) throw new Error("Canonical artifact path is not the expected task path");
46
+ await recoverLeadArtifact({ root, taskId, task, assertOwned });
47
+ const target = join(dir, `${op.name}.md`), before = digest(target);
48
+ if (before !== op.expectedDigest) throw new Error("Stale lead artifact digest");
49
+ const after = hash(Buffer.from(op.content)), name = `.lead-artifact-${randomUUID()}.tmp`, temp = join(dir, name), journal = join(dir, ".lead-artifact-journal.json");
50
+ let fd, journaled = false;
51
+ try {
52
+ fd = openSync(temp, "wx", 0o600); writeFileSync(fd, op.content, "utf8"); fsyncSync(fd); closeSync(fd); fd = undefined;
53
+ await assertOwned();
54
+ fd = openSync(journal, "wx", 0o600); writeFileSync(fd, JSON.stringify({ version: 1, name: op.name, before, after, temp: name }), "utf8"); fsyncSync(fd); closeSync(fd); fd = undefined; journaled = true; syncDir(dir);
55
+ await assertOwned();
56
+ if (digest(target) !== before) throw new Error("Lead artifact changed during commit");
57
+ renameSync(temp, target); syncDir(dir);
58
+ await assertOwned(); unlinkSync(journal); syncDir(dir);
59
+ } finally {
60
+ if (fd !== undefined) closeSync(fd);
61
+ if (!journaled && existsSync(temp)) unlinkSync(temp);
62
+ }
63
+ }
@@ -0,0 +1,53 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isAbsolute } from "node:path";
3
+
4
+ const LIMIT = 65536;
5
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
6
+ const validId = (x) => typeof x === "string" && ID.test(x);
7
+ const record = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
8
+ const exact = (x, keys) => record(x) && Object.keys(x).length === keys.length && keys.every((key) => Object.hasOwn(x, key));
9
+ const text = (x, max) => typeof x === "string" && x.trim().length > 0 && Buffer.byteLength(x) <= max && !x.includes("\0");
10
+ const scope = (x) => text(x, 512) && !isAbsolute(x) && !x.includes("\\") && !/[\x00-\x1f]/.test(x) && x.split("/").every((part) => part && part !== "." && part !== ".." && part !== ".git" && part !== ".agents");
11
+ export const leadTaskDigest = (task) => createHash("sha256").update(JSON.stringify(task)).digest("hex");
12
+
13
+ /** Parse an untrusted, bounded model proposal. This schema never admits approval,
14
+ * evidence, execution commands, phase transitions, or arbitrary artifact paths. */
15
+ export function parseLeadProposal(raw) {
16
+ if (typeof raw !== "string" || Buffer.byteLength(raw) > LIMIT) throw new Error("Invalid or oversized lead proposal");
17
+ let p; try { p = JSON.parse(raw); } catch { throw new Error("Invalid lead proposal JSON"); }
18
+ if (!exact(p, ["version", "taskId", "phase", "taskDigest", "operation"]) || p.version !== 1 || !validId(p.taskId) || !["clarify", "plan"].includes(p.phase) || !/^[a-f0-9]{64}$/.test(p.taskDigest)) throw new Error("Invalid lead proposal envelope");
19
+ const op = p.operation;
20
+ if (op?.kind === "artifact") {
21
+ if (!exact(op, ["kind", "name", "content", "expectedDigest"]) || !({ clarify: ["intent"], plan: ["design", "workplan"] }[p.phase]).includes(op.name) || !text(op.content, 60000) || !(op.expectedDigest === null || (typeof op.expectedDigest === "string" && /^[a-f0-9]{64}$/.test(op.expectedDigest)))) throw new Error("Invalid artifact proposal");
22
+ } else if (op?.kind === "decision") {
23
+ if (p.phase !== "plan" || !exact(op, ["kind", "id", "label", "resolution"]) || !validId(op.id) || !text(op.label, 512) || !text(op.resolution, 4096)) throw new Error("Invalid decision proposal");
24
+ } else if (op?.kind === "item") {
25
+ if (p.phase !== "plan" || !exact(op, ["kind", "id", "label", "dependsOn", "ownedPaths"]) || !validId(op.id) || !text(op.label, 512) || !Array.isArray(op.dependsOn) || op.dependsOn.length > 64 || new Set(op.dependsOn).size !== op.dependsOn.length || !op.dependsOn.every((id) => validId(id) && id !== op.id) || !Array.isArray(op.ownedPaths) || !op.ownedPaths.length || op.ownedPaths.length > 64 || new Set(op.ownedPaths).size !== op.ownedPaths.length || !op.ownedPaths.every(scope)) throw new Error("Invalid item proposal");
26
+ } else throw new Error("Lead operation not allowed");
27
+ return p;
28
+ }
29
+
30
+ /** adapter.withTaskLock MUST hold the canonical lock and supervisor fence through
31
+ * read, validation, one apply, and reading nextAction. The adapter is trusted
32
+ * host code; this module does not expose it to the model or container. */
33
+ export async function applyLeadProposal(raw, adapter) {
34
+ const proposal = parseLeadProposal(raw);
35
+ if (typeof adapter?.withTaskLock !== "function") throw new Error("Atomic fenced broker adapter required");
36
+ return adapter.withTaskLock(proposal.taskId, async (tx) => {
37
+ if (typeof tx?.assertOwned !== "function" || typeof tx?.readTask !== "function" || typeof tx?.nextAction !== "function" || typeof tx?.applyOne !== "function") throw new Error("Incomplete fenced broker transaction");
38
+ await tx.assertOwned();
39
+ const task = await tx.readTask();
40
+ if (!task || task.id !== proposal.taskId || task.status !== "active" || task.phase !== proposal.phase || leadTaskDigest(task) !== proposal.taskDigest) throw new Error("Stale lead proposal");
41
+ const action = await tx.nextAction(task);
42
+ if (action?.classification !== "run_phase" || action.phase !== proposal.phase) throw new Error("Task is not actionable in proposed phase");
43
+ if (proposal.operation.kind === "item") {
44
+ const known = new Set(task.tasks.map((item) => item.id));
45
+ if (proposal.operation.dependsOn.some((id) => !known.has(id))) throw new Error("Unknown item dependency");
46
+ if (known.has(proposal.operation.id)) throw new Error("Item already exists");
47
+ }
48
+ await tx.assertOwned();
49
+ await tx.applyOne(proposal.operation);
50
+ await tx.assertOwned();
51
+ return tx.nextAction(await tx.readTask());
52
+ });
53
+ }
@@ -0,0 +1,44 @@
1
+ import { resolve } from "node:path";
2
+ import { acquireStateLock, loadState, nextAction, readyTaskItems, saveState } from "./store.mjs";
3
+ import { requireLocalAuthority } from "./authority.mjs";
4
+ import { recoverLeadArtifact, writeLeadArtifact } from "./loop-lead-artifacts.mjs";
5
+
6
+ /** Create a trusted host-only adapter. assertOwned comes directly from the live
7
+ * supervisor lease and must remain valid throughout the transaction. */
8
+ export function createLocalLeadBrokerAdapter({ root: inputRoot, taskId, assertOwned }) {
9
+ const root = resolve(inputRoot);
10
+ if (typeof assertOwned !== "function" || typeof taskId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(taskId)) throw new Error("Local broker requires a live supervisor fence and task ID");
11
+ return { async withTaskLock(id, run) {
12
+ requireLocalAuthority();
13
+ if (id !== taskId || typeof run !== "function") throw new Error("Broker task mismatch");
14
+ await assertOwned();
15
+ const release = acquireStateLock(root);
16
+ try {
17
+ await assertOwned();
18
+ const state = loadState(root), task = state.tasks[taskId];
19
+ if (!task) throw new Error("Unknown active broker task");
20
+ await recoverLeadArtifact({ root, taskId, task, assertOwned });
21
+ let applied = false;
22
+ return await run({
23
+ assertOwned,
24
+ readTask: async () => task,
25
+ nextAction: async (value) => nextAction(value, root),
26
+ applyOne: async (op) => {
27
+ if (applied) throw new Error("Broker permits only one mutation per transaction");
28
+ applied = true; await assertOwned();
29
+ if (op.kind === "artifact") {
30
+ await writeLeadArtifact({ root, taskId, task, operation: op, assertOwned });
31
+ } else if (op.kind === "decision") {
32
+ if (task.decisions.some((entry) => entry.id === op.id)) throw new Error("Decision already exists; broker cannot overwrite human decisions");
33
+ task.decisions.push({ id: op.id, label: op.label, status: "unresolved", resolution: op.resolution });
34
+ task.updatedAt = new Date().toISOString(); await assertOwned(); saveState(root, state);
35
+ } else if (op.kind === "item") {
36
+ if (task.tasks.some((entry) => entry.id === op.id)) throw new Error("Item already exists");
37
+ task.tasks.push({ id: op.id, label: op.label, status: "todo", dependsOn: op.dependsOn, ownedPaths: op.ownedPaths });
38
+ readyTaskItems(task); task.updatedAt = new Date().toISOString(); await assertOwned(); saveState(root, state);
39
+ } else throw new Error("Broker operation not allowed");
40
+ }
41
+ });
42
+ } finally { release(); }
43
+ } };
44
+ }
@@ -0,0 +1,88 @@
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { isLoopProvider } from "./loop-drivers.mjs";
3
+ import { OPENCODE_CONTAINER_ERROR, workerDockerInvocation } from "./loop-worker-docker.mjs";
4
+
5
+ /** Invocation only: callers must supply a sanitized, host-owned source snapshot,
6
+ * never the checkout. No host output directory, state, or broker channel is mounted.
7
+ * The bounded provider response carries the proposal back to the trusted host. */
8
+ export function leadDockerInvocation({ snapshot, image, agent, args, credentialEnv, hostEnv, uid, gid, containerName }) {
9
+ const source = realpathSync(snapshot);
10
+ if (!statSync(source).isDirectory()) throw new Error("Lead snapshot must be a directory");
11
+ const invocation = workerDockerInvocation({ workspace: source, image, command: agent, args, credentialEnv, hostEnv, uid, gid, containerName });
12
+ const mount = `type=bind,src=${source},dst=/workspace`;
13
+ const index = invocation.args.indexOf(mount);
14
+ if (index < 0) throw new Error("Missing lead snapshot mount");
15
+ invocation.args[index] = `${mount},readonly`;
16
+ return invocation;
17
+ }
18
+
19
+ /** Create and clean an isolated source-only snapshot for exactly one turn. */
20
+ export async function withLeadSnapshot({ root: inputRoot, snapshotsRoot: inputSnapshotsRoot }, use) {
21
+ const { execFileSync } = await import("node:child_process");
22
+ const { mkdtempSync, mkdirSync, rmSync } = await import("node:fs");
23
+ const { resolve, sep, join } = await import("node:path");
24
+ const { createWorkerSnapshot } = await import("./loop-worker-snapshot.mjs");
25
+ if (typeof use !== "function" || typeof inputRoot !== "string" || typeof inputSnapshotsRoot !== "string") throw new Error("Lead snapshot requires paths and a callback");
26
+ const root = realpathSync(inputRoot);
27
+ const requested = resolve(inputSnapshotsRoot);
28
+ if (requested === root || requested.startsWith(`${root}${sep}`) || root.startsWith(`${requested}${sep}`)) throw new Error("Lead snapshots root must be outside the project");
29
+ mkdirSync(requested, { recursive: true, mode: 0o700 });
30
+ const parent = realpathSync(requested);
31
+ if (parent === root || parent.startsWith(`${root}${sep}`) || root.startsWith(`${parent}${sep}`)) throw new Error("Lead snapshots root resolves inside or above the project");
32
+ const snapshot = mkdtempSync(join(parent, "aidlc-lead-"));
33
+ try {
34
+ const output = execFileSync("git", ["ls-files", "-z"], { cwd: root, encoding: "utf8", timeout: 30000, maxBuffer: 16 * 1024 * 1024 });
35
+ const paths = output.split("\0").filter((path) => path && path !== ".agents/data" && !path.startsWith(".agents/data/"));
36
+ createWorkerSnapshot(root, snapshot, paths);
37
+ return await use(snapshot);
38
+ } finally { rmSync(snapshot, { recursive: true, force: true }); }
39
+ }
40
+
41
+ /** Execute one lead proposal in a separate read-only container. No broker or
42
+ * lifecycle capability is passed to the container. Caller applies the returned
43
+ * proposal separately under its fence and canonical lock. */
44
+ export async function runIsolatedLeadTurn({ root, snapshotsRoot, image, agent, prompt, assertOwned, timeoutMs = 600000, signal }) {
45
+ const { execFileSync, spawn } = await import("node:child_process");
46
+ const { randomUUID } = await import("node:crypto");
47
+ const { runAgentTurn } = await import("./loop-drivers.mjs");
48
+ const { parseLeadProposal } = await import("./loop-lead-broker.mjs");
49
+ if (agent === "opencode") throw new Error(OPENCODE_CONTAINER_ERROR);
50
+ if (process.platform !== "linux" || !isLoopProvider(agent) || typeof prompt !== "string" || !prompt || Buffer.byteLength(prompt) > 262144 || typeof assertOwned !== "function") throw new Error("Invalid isolated lead configuration");
51
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || (signal !== undefined && !(signal instanceof AbortSignal))) throw new Error("Invalid isolated lead timeout or signal");
52
+ const uid = process.getuid?.(), gid = process.getgid?.();
53
+ if (!uid || !gid) throw new Error("Isolated lead requires a non-root Linux host user");
54
+ const credentialEnv = agent === "codex" ? ["OPENAI_API_KEY"] : ["ANTHROPIC_API_KEY"];
55
+ const env = { PATH: process.env.PATH ?? "/usr/bin:/bin" };
56
+ const check = (...args) => execFileSync("docker", args, { env, stdio: "ignore", timeout: 30000 });
57
+ await assertOwned();
58
+ return withLeadSnapshot({ root, snapshotsRoot }, async (snapshot) => {
59
+ // Validation builds an invocation before any agent or task mutation starts.
60
+ leadDockerInvocation({ snapshot, image, agent, args: [], credentialEnv, hostEnv: process.env, uid, gid });
61
+ try { check("info"); check("image", "inspect", image); check("run", "--rm", "--network", "none", "--read-only", "--user", `${uid}:${gid}`, "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--entrypoint", agent, image, "--version"); }
62
+ catch { throw new Error("Docker engine, lead image, or provider CLI preflight failed"); }
63
+ const name = `aidlc-lead-${randomUUID()}`, controller = new AbortController();
64
+ let lost;
65
+ const abort = () => controller.abort();
66
+ signal?.addEventListener("abort", abort, { once: true });
67
+ if (signal?.aborted) controller.abort();
68
+ const timer = setInterval(() => { Promise.resolve().then(assertOwned).catch((error) => { lost = error; controller.abort(); }); }, 1000);
69
+ try {
70
+ await assertOwned();
71
+ const result = await runAgentTurn({ agent, root: "/workspace", prompt, timeoutMs, signal: controller.signal, spawnProcess: (command, args, options) => {
72
+ const invocation = leadDockerInvocation({ snapshot, image, agent: command, args, credentialEnv, hostEnv: process.env, uid, gid, containerName: name });
73
+ return spawn(invocation.command, invocation.args, { ...options, cwd: snapshot, env: invocation.env, shell: false });
74
+ } });
75
+ if (lost || controller.signal.aborted) throw lost ?? new Error("Lead turn aborted");
76
+ await assertOwned();
77
+ return parseLeadProposal(result.resultText);
78
+ } finally {
79
+ clearInterval(timer); signal?.removeEventListener("abort", abort);
80
+ try { check("rm", "-f", name); }
81
+ catch {
82
+ try { check("info"); } catch { throw new Error("Lead container cleanup uncertain: Docker unavailable"); }
83
+ try { check("inspect", name); throw new Error("Lead container cleanup uncertain: container remains"); }
84
+ catch (error) { if (error.message === "Lead container cleanup uncertain: container remains") throw error; }
85
+ }
86
+ }
87
+ });
88
+ }
@@ -0,0 +1,89 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { closeSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+
5
+ const idPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
6
+ const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
7
+ export function assertLoopPath(root, taskId, suffix = ".json") {
8
+ if (!idPattern.test(taskId)) throw new Error(`Invalid task id: ${taskId}`);
9
+ const base = resolve(root);
10
+ let cursor = base;
11
+ for (const part of [".agents", "data", "loop", `${taskId}${suffix}`]) {
12
+ cursor = join(cursor, part);
13
+ try { if (lstatSync(cursor).isSymbolicLink()) throw new Error(`Loop path crosses a symlink: ${cursor}`); }
14
+ catch (error) { if (error?.code !== "ENOENT") throw error; }
15
+ }
16
+ return cursor;
17
+ }
18
+
19
+ export const loopLeasePath = (root, taskId) => assertLoopPath(root, taskId, ".lease");
20
+ const guardPath = (root, taskId) => assertLoopPath(root, taskId, ".lease.guard");
21
+
22
+ function parseLease(path) {
23
+ let value;
24
+ try { value = JSON.parse(readFileSync(path, "utf8")); }
25
+ catch (error) { throw new Error(`Corrupt loop lease: ${path}: ${error.message}`); }
26
+ if (!value || value.schemaVersion !== 1 || !idPattern.test(value.taskId) || typeof value.runId !== "string" || !value.runId || !Number.isInteger(value.pid) || value.pid < 1 || typeof value.token !== "string" || !value.token || typeof value.createdAt !== "string" || Number.isNaN(Date.parse(value.createdAt))) throw new Error(`Corrupt loop lease: ${path}`);
27
+ return value;
28
+ }
29
+
30
+ function alive(pid) {
31
+ try { process.kill(pid, 0); return true; }
32
+ catch (error) { if (error?.code === "ESRCH") return false; return true; }
33
+ }
34
+
35
+ function withGuard(root, taskId, action) {
36
+ const path = guardPath(root, taskId);
37
+ mkdirSync(dirname(path), { recursive: true });
38
+ const guard = { schemaVersion: 1, taskId, runId: "guard", pid: process.pid, token: randomUUID(), createdAt: new Date().toISOString() };
39
+ const deadline = Date.now() + 5000;
40
+ for (;;) {
41
+ try {
42
+ const fd = openSync(path, "wx", 0o600);
43
+ try { writeFileSync(fd, `${JSON.stringify(guard)}\n`); fsyncSync(fd); } finally { closeSync(fd); }
44
+ break;
45
+ } catch (error) {
46
+ if (error?.code !== "EEXIST") throw error;
47
+ const current = parseLease(path);
48
+ if (current.taskId !== taskId) throw new Error(`Conflicting loop lease guard: ${path}`);
49
+ if (!alive(current.pid)) { try { if (parseLease(path).token === current.token) unlinkSync(path); } catch (unlinkError) { if (unlinkError?.code !== "ENOENT") throw unlinkError; } continue; }
50
+ if (Date.now() >= deadline) throw new Error(`Timed out waiting for loop lease guard: ${taskId}`);
51
+ sleep(10);
52
+ }
53
+ }
54
+ try { return action(); }
55
+ finally { if (parseLease(path).token === guard.token) unlinkSync(path); }
56
+ }
57
+
58
+ export function readLiveLease(root, taskId) {
59
+ const path = loopLeasePath(root, taskId);
60
+ try { lstatSync(path); } catch (error) { if (error?.code === "ENOENT") return null; throw error; }
61
+ const lease = parseLease(path);
62
+ if (lease.taskId !== taskId) throw new Error(`Conflicting loop lease: ${path}`);
63
+ return alive(lease.pid) ? lease : null;
64
+ }
65
+
66
+ export function acquireLoopLease(root, taskId, runId) {
67
+ const path = loopLeasePath(root, taskId);
68
+ mkdirSync(dirname(path), { recursive: true });
69
+ const lease = { schemaVersion: 1, taskId, runId, pid: process.pid, token: randomUUID(), createdAt: new Date().toISOString() };
70
+ return withGuard(root, taskId, () => {
71
+ for (let attempt = 0; attempt < 4; attempt++) {
72
+ try {
73
+ const fd = openSync(path, "wx", 0o600);
74
+ try { writeFileSync(fd, `${JSON.stringify(lease)}\n`); fsyncSync(fd); }
75
+ finally { closeSync(fd); }
76
+ return () => withGuard(root, taskId, () => { try { if (parseLease(path).token === lease.token) unlinkSync(path); } catch (error) { if (error?.code !== "ENOENT") throw error; } });
77
+ } catch (error) {
78
+ if (error?.code !== "EEXIST") throw error;
79
+ const existing = parseLease(path);
80
+ if (existing.taskId !== taskId) throw new Error(`Conflicting loop lease: ${path}`);
81
+ if (alive(existing.pid)) throw new Error(`Task ${taskId} already has a live loop runner (pid ${existing.pid})`);
82
+ // A dead owner may be reclaimed. A competing reclaimer may win; retry with wx.
83
+ try { if (parseLease(path).token === existing.token) unlinkSync(path); }
84
+ catch (unlinkError) { if (unlinkError?.code !== "ENOENT") throw unlinkError; }
85
+ }
86
+ }
87
+ throw new Error(`Could not acquire loop lease for ${taskId}`);
88
+ });
89
+ }
@@ -0,0 +1,32 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { closeSync, existsSync, fsyncSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { assertLoopPath } from "./loop-lease.mjs";
5
+ const digest = (x) => createHash("sha256").update(JSON.stringify(x)).digest("hex");
6
+ const read = (path) => {
7
+ const st = lstatSync(path);
8
+ if (!st.isFile() || st.isSymbolicLink() || st.nlink !== 1 || st.size > 16384) throw new Error("Unsafe local gate receipt or checkpoint");
9
+ return JSON.parse(readFileSync(path, "utf8"));
10
+ };
11
+ export function readLocalGateCheckpoint(root, task) {
12
+ const checkpoint = read(assertLoopPath(root, task.id, ".supervisor.json"));
13
+ if (checkpoint?.version !== 1 || checkpoint.taskId !== task.id || checkpoint.authority !== "local" || checkpoint.gate !== task.gate || checkpoint.phase !== task.phase || checkpoint.taskHash !== digest(task)) throw new Error("Supervisor gate checkpoint does not match current review");
14
+ return checkpoint;
15
+ }
16
+ /** Host gate process writes only after canonical approval is persisted. A crash
17
+ * in between is intentionally fail-closed and requires manual reconciliation. */
18
+ export function writeLocalGateReceipt(root, checkpoint, task, reviewHash) {
19
+ const path = assertLoopPath(root, task.id, ".gate-receipt.json"), temp = `${path}.${randomUUID()}.tmp`;
20
+ if (existsSync(path)) read(path);
21
+ const receipt = { version: 1, taskId: task.id, gate: checkpoint.gate, checkpointHash: digest(checkpoint), taskHash: digest(task), reviewHash };
22
+ let fd;
23
+ try { fd = openSync(temp, "wx", 0o600); writeFileSync(fd, JSON.stringify(receipt)); fsyncSync(fd); closeSync(fd); fd = undefined; renameSync(temp, path); const dir = openSync(dirname(path), "r"); try { fsyncSync(dir); } finally { closeSync(dir); } }
24
+ finally { if (fd !== undefined) closeSync(fd); if (existsSync(temp)) unlinkSync(temp); }
25
+ }
26
+ export async function verifyLocalGateReceipt({ root, checkpoint, snapshot }) {
27
+ let receipt;
28
+ try { receipt = read(assertLoopPath(root, checkpoint.taskId, ".gate-receipt.json")); } catch { return false; }
29
+ const approved = snapshot?.task;
30
+ const evidence = approved?.evidence?.slice(checkpoint.evidenceCount) ?? [];
31
+ return receipt?.version === 1 && receipt.taskId === checkpoint.taskId && receipt.gate === checkpoint.gate && receipt.checkpointHash === digest(checkpoint) && receipt.taskHash === digest(approved) && typeof receipt.reviewHash === "string" && /^[a-f0-9]{64}$/.test(receipt.reviewHash) && evidence.some((item) => item.kind === "approval" && item.gate === checkpoint.gate && item.result === "pass" && item.source === `Trusted host terminal: approve (${receipt.reviewHash})`);
32
+ }
@@ -0,0 +1,161 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { chmodSync, copyFileSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { tmpdir } from "node:os";
6
+ import { createWorkerSnapshot, inspectWorkerSnapshot } from "./loop-worker-snapshot.mjs";
7
+ import { OPENCODE_CONTAINER_ERROR, workerDockerInvocation } from "./loop-worker-docker.mjs";
8
+ import { isLoopProvider, runAgentTurn } from "./loop-drivers.mjs";
9
+ import { claimLocalAssignment, heartbeatLocalAssignment, reportLocalAssignment, releaseLocalAssignment } from "./assignment-store.mjs";
10
+
11
+ const git = (root, ...args) => execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30000 });
12
+ const lines = (value) => value.split("\0").filter(Boolean);
13
+ const identifier = (value) => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value);
14
+ const safePath = (path) => typeof path === "string" && path.length > 0 && !isAbsolute(path) && !path.includes("\\") && !path.split("/").some((part) => !part || part === "." || part === ".." || part === ".git" || part === ".agents") && !/[\x00-\x1f]/.test(path);
15
+ const overlaps = (a, b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
16
+ const scoped = (paths, scopes) => paths.every((path) => safePath(path) && scopes.some((scope) => path === scope || path.startsWith(`${scope}/`)));
17
+ const statusPaths = (root) => {
18
+ // --no-renames yields one entry per path, including both sides of a rename.
19
+ return lines(git(root, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all")).map((row) => row.slice(3));
20
+ };
21
+ const commitPaths = (root, base, head) => lines(git(root, "diff", "--name-only", "-z", "--no-renames", base, head, "--"));
22
+ // Workflow state and assignment records are lead-owned and never in worker
23
+ // scopes. Their ordinary updates must not block integrating code-only commits.
24
+ const clean = (root) => { if (statusPaths(root).some((path) => !path.startsWith(".agents/data/"))) throw new Error("Local integration worktree must be clean outside workflow data"); };
25
+
26
+ /**
27
+ * Execute a batch of independent local jobs in isolated worktrees, then integrate
28
+ * verified scoped commits sequentially. Assignment metadata is not lifecycle
29
+ * state: the caller alone marks canonical items done after inspecting results.
30
+ * A failed batch is deliberately left for reconciliation, not auto-retried.
31
+ */
32
+ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, worktreesRoot: inputWorktreesRoot, docker, timeoutMs = 600000, leaseMs = 300000, maxTurns = 12, maxStallTurns = 2, claim = claimLocalAssignment, heartbeat = heartbeatLocalAssignment, report = reportLocalAssignment, release = releaseLocalAssignment, onEvent = () => {} }) {
33
+ const root = resolve(inputRoot), worktreesRoot = resolve(inputWorktreesRoot);
34
+ if (!identifier(taskId) || !Array.isArray(jobs) || !jobs.length) throw new Error("Valid task and non-empty jobs are required");
35
+ if (root === worktreesRoot || worktreesRoot.startsWith(`${root}${sep}`)) throw new Error("Worker worktree root must be outside the integration checkout");
36
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || !Number.isSafeInteger(leaseMs) || leaseMs < 1000 || leaseMs > 3600000) throw new Error("Invalid timeout or lease");
37
+ if (!Number.isSafeInteger(maxTurns) || maxTurns < 1 || maxTurns > 1000 || !Number.isSafeInteger(maxStallTurns) || maxStallTurns < 1 || maxStallTurns > 20) throw new Error("Invalid worker turn or stall limit");
38
+ const ids = new Set(), workers = new Set(), scopes = [];
39
+ for (const job of jobs) {
40
+ if (!identifier(job.itemId) || !identifier(job.workerId) || !isLoopProvider(job.agent) || typeof job.prompt !== "string" || !job.prompt.trim() || !Array.isArray(job.ownedPaths) || !job.ownedPaths.length || job.ownedPaths.length > 64 || !job.ownedPaths.every(safePath)) throw new Error("Invalid local job");
41
+ if (ids.has(job.itemId) || workers.has(job.workerId)) throw new Error("Items and workers must be distinct within a batch");
42
+ ids.add(job.itemId); workers.add(job.workerId);
43
+ for (const path of job.ownedPaths) { if (scopes.some((prior) => overlaps(path, prior))) throw new Error(`Overlapping assignment scope: ${path}`); scopes.push(path); }
44
+ }
45
+ if (jobs.some((job) => job.agent === "opencode")) throw new Error(OPENCODE_CONTAINER_ERROR);
46
+ if (process.platform !== "linux" || !docker || typeof docker.image !== "string" || !docker.credentials || typeof docker.credentials !== "object") throw new Error("Linux Docker worker isolation and explicit image and provider credentials are required");
47
+ const uid = process.getuid?.(), gid = process.getgid?.();
48
+ if (!uid || !gid) throw new Error("Docker workers require a non-root host user");
49
+ for (const job of jobs) {
50
+ const keys = docker.credentials[job.agent];
51
+ workerDockerInvocation({ workspace: root, image: docker.image, command: job.agent, args: [], credentialEnv: keys, hostEnv: process.env, uid, gid });
52
+ }
53
+ const dockerEnv = { PATH: process.env.PATH ?? "/usr/bin:/bin" };
54
+ const dockerCheck = (...args) => execFileSync("docker", args, { env: dockerEnv, stdio: "ignore", timeout: 30000 });
55
+ let image;
56
+ try {
57
+ dockerCheck("info");
58
+ image=execFileSync("docker",["image","inspect","--format","{{.Id}}",docker.image],{env:dockerEnv,encoding:"utf8",stdio:["ignore","pipe","ignore"],timeout:30000}).trim();
59
+ if (!/^sha256:[a-f0-9]{64}$/.test(image)) throw new Error("Docker worker image lacks immutable image ID");
60
+ for (const agent of new Set(jobs.map((job) => job.agent))) dockerCheck("run", "--rm", "--network", "none", "--read-only", "--user", `${uid}:${gid}`, "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--entrypoint", agent, image, "--version");
61
+ } catch { throw new Error("Docker engine, image, or provider CLI preflight failed"); }
62
+ clean(root);
63
+ const base = git(root, "rev-parse", "HEAD").trim();
64
+ const initialBranch = git(root, "symbolic-ref", "--short", "HEAD").trim();
65
+ mkdirSync(worktreesRoot, { recursive: true });
66
+ const realRoot = realpathSync(root), realWorktreesRoot = realpathSync(worktreesRoot);
67
+ if (realRoot === realWorktreesRoot || realWorktreesRoot.startsWith(`${realRoot}${sep}`)) throw new Error("Worker worktree root must be outside the integration checkout");
68
+ const prepared = [];
69
+ try {
70
+ for (const job of jobs) {
71
+ const token = randomUUID(), branch = `aidlc/local/${token}`, workspace = resolve(worktreesRoot, token), snapshot = resolve(worktreesRoot, `${token}-snapshot`);
72
+ const assignment = await claim(root, { taskId, itemId: job.itemId, workerId: job.workerId, ownedPaths: job.ownedPaths, baseCommit: base, branch, leaseMs });
73
+ prepared.push({ job, assignment, workspace, snapshot, branch, created: false });
74
+ git(root, "worktree", "add", "-b", branch, workspace, base);
75
+ prepared.at(-1).created = true;
76
+ mkdirSync(snapshot);
77
+ const tracked = lines(git(workspace, "ls-files", "-z")).filter((path) => path !== ".agents/data" && !path.startsWith(".agents/data/"));
78
+ prepared.at(-1).baseline = createWorkerSnapshot(workspace, snapshot, tracked);
79
+ }
80
+ } catch (error) {
81
+ // No worker was started; release only claims whose worktree was never run.
82
+ for (const entry of prepared) { if (entry.created) git(root, "worktree", "remove", entry.workspace); rmSync(entry.snapshot, { recursive: true, force: true }); await release(root, taskId, entry.assignment.id); }
83
+ throw error;
84
+ }
85
+ const execute = async ({ job, assignment, workspace, snapshot, baseline, branch }) => {
86
+ let renewalError;
87
+ const controller = new AbortController();
88
+ const timer = setInterval(() => { Promise.resolve().then(() => heartbeat(root, taskId, assignment.id, leaseMs)).catch((error) => { renewalError = error; controller.abort(); }); }, Math.max(500, Math.floor(leaseMs / 3)));
89
+ try {
90
+ const home=mkdtempSync(join(tmpdir(), "aidlc-local-home-")); chmodSync(home,0o700);
91
+ let sessionId, previousFingerprint, stalls=0, result;
92
+ try {
93
+ for (let turn=0; turn<maxTurns; turn++) {
94
+ if (renewalError || controller.signal.aborted) throw renewalError ?? new Error("Worker lease lost");
95
+ const containerName = `aidlc-worker-${randomUUID()}`;
96
+ const spawnProcess = (command, args, options) => {
97
+ const invocation = workerDockerInvocation({ workspace: snapshot, image, command, args, credentialEnv: docker.credentials[job.agent], hostEnv: process.env, uid, gid, containerName, sessionHome:home });
98
+ return spawn(invocation.command, invocation.args, { ...options, cwd: snapshot, env: invocation.env, shell: false });
99
+ };
100
+ try { result = await runAgentTurn({ agent: job.agent, root: "/workspace", sessionId, spawnProcess, prompt: `${job.prompt}\n\nOnly modify assigned paths: ${job.ownedPaths.join(", ")}. Do not modify AI-DLC lifecycle state or approve gates. End with AI-DLC-ITEM-COMPLETE on its own line.`, timeoutMs, signal: controller.signal, onSessionId: async (id) => { await heartbeat(root, taskId, assignment.id, leaseMs, id); } }); }
101
+ finally {
102
+ try { dockerCheck("rm", "-f", containerName); }
103
+ catch {
104
+ try { dockerCheck("info"); } catch { throw new Error("Docker cleanup uncertain: engine unavailable"); }
105
+ try { dockerCheck("inspect", containerName); throw new Error("Docker cleanup uncertain: container remains"); }
106
+ catch (error) { if (error.message === "Docker cleanup uncertain: container remains") throw error; }
107
+ }
108
+ }
109
+ if (renewalError || controller.signal.aborted) throw renewalError ?? new Error("Worker lease lost");
110
+ sessionId=result.sessionId;
111
+ const changed=inspectWorkerSnapshot(snapshot,baseline,job.ownedPaths);
112
+ if (result.resultText?.split(/\r?\n/).some((line) => line.trim() === "AI-DLC-ITEM-COMPLETE")) break;
113
+ const fingerprint=createHash("sha256").update(JSON.stringify(changed.map((path) => { try { const full=join(snapshot,path), info=lstatSync(full); return [path,info.mode,createHash("sha256").update(readFileSync(full)).digest("hex")]; } catch (error) { if(error.code === "ENOENT") return [path,null]; throw error; } }))).digest("hex");
114
+ stalls=fingerprint===previousFingerprint ? stalls+1 : 0; previousFingerprint=fingerprint;
115
+ if (stalls >= maxStallTurns) throw new Error(`Worker ${job.workerId} stalled`);
116
+ if (turn===maxTurns-1) throw new Error(`Worker ${job.workerId} exceeded turn limit`);
117
+ }
118
+ } finally { rmSync(home,{recursive:true,force:true}); }
119
+ if (git(workspace, "rev-parse", "HEAD").trim() !== base || statusPaths(workspace).length) throw new Error("Host worker worktree changed during isolated execution");
120
+ const snapshotChanges = inspectWorkerSnapshot(snapshot, baseline, job.ownedPaths);
121
+ if (!snapshotChanges.length) throw new Error(`Worker ${job.workerId} changed no files`);
122
+ await heartbeat(root, taskId, assignment.id, leaseMs);
123
+ if (renewalError || controller.signal.aborted) throw renewalError ?? new Error("Worker lease lost");
124
+ for (const path of snapshotChanges) {
125
+ const destination = join(workspace, path), from = join(snapshot, path);
126
+ let parent = workspace;
127
+ for (const segment of path.split("/").slice(0, -1)) { parent = join(parent, segment); try { const info = lstatSync(parent); if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("Unsafe integration directory"); } catch (error) { if (error.code !== "ENOENT") throw error; mkdirSync(parent); } }
128
+ try { const info = lstatSync(destination); if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1) throw new Error("Unsafe integration target"); } catch (error) { if (error.code !== "ENOENT") throw error; }
129
+ try { const info = lstatSync(from); copyFileSync(from, destination); chmodSync(destination, info.mode & 0o777); } catch (error) { if (error.code !== "ENOENT") throw error; rmSync(destination, { force: true }); }
130
+ }
131
+ if (git(workspace, "symbolic-ref", "--short", "HEAD").trim() !== branch) throw new Error("Worker changed its assigned branch");
132
+ git(workspace, "merge-base", "--is-ancestor", base, "HEAD");
133
+ const changed = [...new Set([...statusPaths(workspace), ...commitPaths(workspace, base, "HEAD")])];
134
+ if (!changed.length || !scoped(changed, job.ownedPaths)) throw new Error(`Worker ${job.workerId} changed no files or files outside its scope`);
135
+ // Fold any commits made by the agent into one scoped assignment commit.
136
+ git(workspace, "reset", "--soft", base);
137
+ git(workspace, "add", "-A", "--", ...changed);
138
+ if (git(workspace, "diff", "--cached", "--name-only").trim()) git(workspace, "-c", "user.name=AI-DLC worker", "-c", "user.email=aidlc-worker@localhost", "commit", "-m", `AI-DLC ${taskId}/${job.itemId}`);
139
+ const commit = git(workspace, "rev-parse", "HEAD").trim(), committed = commitPaths(workspace, base, commit);
140
+ if (commit === base || !committed.length || !scoped(committed, job.ownedPaths)) throw new Error("Worker commit is outside assignment scope");
141
+ await report(root, taskId, assignment.id, { commit, changedPaths: committed, checkpoint: `provider=${job.agent}` });
142
+ return { assignmentId: assignment.id, itemId: job.itemId, workerId: job.workerId, agent: job.agent, baseCommit: base, branch, commit, changedPaths: committed, workspace };
143
+ } finally { clearInterval(timer); }
144
+ };
145
+ const results = await Promise.allSettled(prepared.map(execute));
146
+ const failure = results.find((result) => result.status === "rejected");
147
+ if (failure) throw new Error(`Local worker batch requires reconciliation: ${failure.reason instanceof Error ? failure.reason.message : String(failure.reason)}`);
148
+ const completed = results.map((result) => result.value);
149
+ let expectedHead = base;
150
+ for (const result of completed) {
151
+ clean(root);
152
+ if (git(root, "symbolic-ref", "--short", "HEAD").trim() !== initialBranch) throw new Error("Integration branch changed during worker run");
153
+ if (git(root, "rev-parse", "HEAD").trim() !== expectedHead) throw new Error("Integration HEAD changed during worker run");
154
+ if (!scoped(commitPaths(root, base, result.commit), jobs.find((job) => job.itemId === result.itemId).ownedPaths)) throw new Error("Worker scope changed before integration");
155
+ git(root, "-c", "user.name=AI-DLC integrator", "-c", "user.email=aidlc-integrator@localhost", "cherry-pick", result.commit);
156
+ result.integrationCommit = git(root, "rev-parse", "HEAD").trim();
157
+ expectedHead = result.integrationCommit;
158
+ await onEvent({ type: "job_integrated", taskId, itemId: result.itemId, assignmentId: result.assignmentId, commit: result.integrationCommit });
159
+ }
160
+ return completed;
161
+ }
@@ -0,0 +1,13 @@
1
+ import { nextAction, readyTaskItems } from "./store.mjs";
2
+
3
+ // Scheduling is derived from canonical lifecycle state. Assignments are operational hints only.
4
+ export const planLoop = ({ task, assignments = [], root }) => {
5
+ const action = nextAction(task, root);
6
+ const itemGraph = readyTaskItems(task);
7
+ if (action.classification !== "run_phase" || task.phase !== "build") return { ...action, readyItems: [], blockedItems: itemGraph.blocked };
8
+ const busy = new Set(assignments.filter((entry) => ["leased", "reported", "uncertain"].includes(entry.state) && entry.taskId === task.id).map((entry) => entry.itemId));
9
+ const readyItems = itemGraph.ready.filter((id) => !busy.has(id));
10
+ if (readyItems.length) return { classification: "dispatch", phase: task.phase, gate: task.gate, readyItems, blockedItems: itemGraph.blocked, nextAction: action };
11
+ if (busy.size) return { classification: "reconcile", phase: task.phase, gate: task.gate, readyItems: [], blockedItems: itemGraph.blocked, nextAction: action, reason: "Recover or integrate outstanding assignments" };
12
+ return { ...action, readyItems: [], blockedItems: itemGraph.blocked };
13
+ };