@felixdotgo/aidlc-kit 0.0.1 → 0.1.1
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.
- package/DEVELOPMENT.md +1 -1
- package/README.md +20 -9
- package/dist/assets/.agents/aidlc/conventions.md +8 -8
- package/dist/assets/.agents/aidlc/orchestrator.md +15 -10
- package/dist/assets/.agents/aidlc/phase-build.md +7 -7
- package/dist/assets/.agents/aidlc/phase-clarify.md +3 -3
- package/dist/assets/.agents/aidlc/phase-plan.md +6 -6
- package/dist/assets/.agents/aidlc/phase-wrap.md +1 -1
- package/dist/assets/.agents/aidlc/schemas/manifest.schema.json +1 -1
- package/dist/assets/.agents/aidlc/scripts/assignment.mjs +36 -0
- package/dist/assets/.agents/aidlc/scripts/context.mjs +2 -0
- package/dist/assets/.agents/aidlc/scripts/gate-check.mjs +2 -0
- package/dist/assets/.agents/aidlc/scripts/gate-view.mjs +2 -0
- package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +181 -0
- package/dist/assets/.agents/aidlc/scripts/lib/authority.mjs +3 -0
- package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +13 -1
- package/dist/assets/.agents/aidlc/scripts/lib/feature-store.mjs +70 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +220 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +183 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +100 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +58 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +320 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +284 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +51 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-artifacts.mjs +63 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-broker.mjs +53 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-local-broker.mjs +44 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-sandbox.mjs +88 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +89 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-local-gate-receipt.mjs +32 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +162 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-planner.mjs +13 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +260 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +131 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +55 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +101 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +32 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-snapshot.mjs +66 -0
- package/dist/assets/.agents/aidlc/scripts/lib/remote-state-client.mjs +112 -0
- package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +41 -6
- package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +90 -0
- package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +58 -0
- package/dist/assets/.agents/aidlc/scripts/loop.mjs +36 -0
- package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +311 -0
- package/dist/assets/.agents/aidlc/scripts/render.mjs +2 -0
- package/dist/assets/.agents/aidlc/scripts/state.mjs +71 -11
- package/dist/assets/.agents/aidlc/scripts/task-next.mjs +2 -0
- package/dist/assets/.agents/aidlc/templates/model-contract.md +3 -3
- package/dist/assets/.agents/aidlc/templates/workplan.md +2 -2
- package/dist/assets/.agents/skills/aidlc-build/SKILL.md +2 -2
- package/dist/assets/.agents/skills/aidlc-clarify/SKILL.md +2 -2
- package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +2 -2
- package/dist/assets/.agents/skills/aidlc-wrap/SKILL.md +1 -1
- package/dist/services/aidlc-coordination/.env.fleet.example +11 -0
- package/dist/services/aidlc-coordination/compose.yaml +23 -2
- package/dist/services/aidlc-coordination/package-lock.json +1 -0
- package/dist/services/aidlc-coordination/package.json +2 -1
- package/dist/services/aidlc-coordination/src/coordination.mjs +1 -1
- package/dist/services/aidlc-coordination/src/fleet-client.mjs +36 -0
- package/dist/services/aidlc-coordination/src/fleet-run.mjs +18 -0
- package/dist/services/aidlc-coordination/src/fleet.mjs +233 -0
- package/dist/services/aidlc-coordination/src/git-gateway.mjs +50 -0
- package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +41 -6
- package/dist/services/aidlc-coordination/src/repository.mjs +167 -4
- package/dist/services/aidlc-coordination/src/server.mjs +53 -9
- package/dist/services/aidlc-coordination/test/fleet.test.mjs +351 -0
- package/dist/services/aidlc-coordination/test/git-gateway.test.mjs +75 -0
- package/dist/services/aidlc-coordination/test/supervisor-fencing.test.mjs +59 -0
- package/dist/src/adapters.js +32 -5
- package/dist/src/cli.js +146 -70
- package/dist/src/context.js +2 -0
- package/dist/src/gate.d.ts +1 -0
- package/dist/src/gate.js +51 -38
- package/dist/src/installer.d.ts +10 -1
- package/dist/src/installer.js +157 -37
- package/dist/src/managed-content.js +1 -1
- package/dist/src/model.d.ts +16 -1
- package/dist/src/model.js +1 -1
- package/dist/src/profiles.d.ts +9 -1
- package/dist/src/profiles.js +37 -3
- package/dist/src/project-migration.d.ts +1 -0
- package/dist/src/project-migration.js +7 -0
- package/dist/src/state/archive.d.ts +17 -0
- package/dist/src/state/archive.js +60 -0
- package/dist/src/state/items.d.ts +25 -0
- package/dist/src/state/items.js +146 -0
- package/dist/src/state/lessons.d.ts +7 -0
- package/dist/src/state/lessons.js +67 -0
- package/dist/src/state/lifecycle.d.ts +5 -0
- package/dist/src/state/lifecycle.js +78 -0
- package/dist/src/state/lock.d.ts +7 -0
- package/dist/src/state/lock.js +124 -0
- package/dist/src/state/memory.d.ts +8 -0
- package/dist/src/state/memory.js +88 -0
- package/dist/src/state/render.d.ts +26 -0
- package/dist/src/state/render.js +29 -0
- package/dist/src/state/shared.d.ts +29 -0
- package/dist/src/state/shared.js +71 -0
- package/dist/src/state/store.d.ts +4 -0
- package/dist/src/state/store.js +81 -0
- package/dist/src/state/transitions.d.ts +21 -0
- package/dist/src/state/transitions.js +93 -0
- package/dist/src/state/validate.d.ts +4 -0
- package/dist/src/state/validate.js +100 -0
- package/dist/src/state.d.ts +15 -110
- package/dist/src/state.js +15 -764
- package/dist/src/tracker.d.ts +7 -0
- package/dist/src/tracker.js +3 -0
- package/dist/src/upgrade-board.d.ts +5 -0
- package/dist/src/upgrade-board.js +89 -0
- package/dist/src/upgrade.d.ts +5 -6
- package/dist/src/upgrade.js +211 -193
- package/dist/src/workflow.d.ts +1 -0
- package/dist/src/workflow.js +3 -2
- package/docs/README.md +4 -4
- package/docs/architecture.md +10 -2
- package/docs/command-reference.md +72 -11
- package/docs/configuration.md +26 -4
- package/docs/coordination-service.md +59 -3
- package/docs/development.md +16 -2
- package/docs/operating-workflow.md +53 -14
- package/docs/testing-and-release.md +53 -10
- package/package.json +3 -3
|
@@ -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,162 @@
|
|
|
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 (Array.isArray(job.ownedPaths)) job.ownedPaths = [...new Set(job.ownedPaths.map((path) => (typeof path === "string" ? path.replace(/\/+$/, "") : path)).filter((path) => path))];
|
|
41
|
+
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");
|
|
42
|
+
if (ids.has(job.itemId) || workers.has(job.workerId)) throw new Error("Items and workers must be distinct within a batch");
|
|
43
|
+
ids.add(job.itemId); workers.add(job.workerId);
|
|
44
|
+
for (const path of job.ownedPaths) { if (scopes.some((prior) => overlaps(path, prior))) throw new Error(`Overlapping assignment scope: ${path}`); scopes.push(path); }
|
|
45
|
+
}
|
|
46
|
+
if (jobs.some((job) => job.agent === "opencode")) throw new Error(OPENCODE_CONTAINER_ERROR);
|
|
47
|
+
if (process.platform !== "linux" || !docker || typeof docker.image !== "string" || !docker.credentials || typeof docker.credentials !== "object") throw new Error("Linux Docker worker isolation and explicit image and provider credentials are required");
|
|
48
|
+
const uid = process.getuid?.(), gid = process.getgid?.();
|
|
49
|
+
if (!uid || !gid) throw new Error("Docker workers require a non-root host user");
|
|
50
|
+
for (const job of jobs) {
|
|
51
|
+
const keys = docker.credentials[job.agent];
|
|
52
|
+
workerDockerInvocation({ workspace: root, image: docker.image, command: job.agent, args: [], credentialEnv: keys, hostEnv: process.env, uid, gid });
|
|
53
|
+
}
|
|
54
|
+
const dockerEnv = { PATH: process.env.PATH ?? "/usr/bin:/bin" };
|
|
55
|
+
const dockerCheck = (...args) => execFileSync("docker", args, { env: dockerEnv, stdio: "ignore", timeout: 30000 });
|
|
56
|
+
let image;
|
|
57
|
+
try {
|
|
58
|
+
dockerCheck("info");
|
|
59
|
+
image=execFileSync("docker",["image","inspect","--format","{{.Id}}",docker.image],{env:dockerEnv,encoding:"utf8",stdio:["ignore","pipe","ignore"],timeout:30000}).trim();
|
|
60
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(image)) throw new Error("Docker worker image lacks immutable image ID");
|
|
61
|
+
for (const agent of new Set(jobs.map((job) => job.agent))) dockerCheck("run", "--rm", "--network", "none", "--read-only", "--user", `${uid}:${gid}`, "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--entrypoint", agent, image, "--version");
|
|
62
|
+
} catch { throw new Error("Docker engine, image, or provider CLI preflight failed"); }
|
|
63
|
+
clean(root);
|
|
64
|
+
const base = git(root, "rev-parse", "HEAD").trim();
|
|
65
|
+
const initialBranch = git(root, "symbolic-ref", "--short", "HEAD").trim();
|
|
66
|
+
mkdirSync(worktreesRoot, { recursive: true });
|
|
67
|
+
const realRoot = realpathSync(root), realWorktreesRoot = realpathSync(worktreesRoot);
|
|
68
|
+
if (realRoot === realWorktreesRoot || realWorktreesRoot.startsWith(`${realRoot}${sep}`)) throw new Error("Worker worktree root must be outside the integration checkout");
|
|
69
|
+
const prepared = [];
|
|
70
|
+
try {
|
|
71
|
+
for (const job of jobs) {
|
|
72
|
+
const token = randomUUID(), branch = `aidlc/local/${token}`, workspace = resolve(worktreesRoot, token), snapshot = resolve(worktreesRoot, `${token}-snapshot`);
|
|
73
|
+
const assignment = await claim(root, { taskId, itemId: job.itemId, workerId: job.workerId, ownedPaths: job.ownedPaths, baseCommit: base, branch, leaseMs });
|
|
74
|
+
prepared.push({ job, assignment, workspace, snapshot, branch, created: false });
|
|
75
|
+
git(root, "worktree", "add", "-b", branch, workspace, base);
|
|
76
|
+
prepared.at(-1).created = true;
|
|
77
|
+
mkdirSync(snapshot);
|
|
78
|
+
const tracked = lines(git(workspace, "ls-files", "-z")).filter((path) => path !== ".agents/data" && !path.startsWith(".agents/data/"));
|
|
79
|
+
prepared.at(-1).baseline = createWorkerSnapshot(workspace, snapshot, tracked);
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
// No worker was started; release only claims whose worktree was never run.
|
|
83
|
+
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); }
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
const execute = async ({ job, assignment, workspace, snapshot, baseline, branch }) => {
|
|
87
|
+
let renewalError;
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
const timer = setInterval(() => { Promise.resolve().then(() => heartbeat(root, taskId, assignment.id, leaseMs)).catch((error) => { renewalError = error; controller.abort(); }); }, Math.max(500, Math.floor(leaseMs / 3)));
|
|
90
|
+
try {
|
|
91
|
+
const home=mkdtempSync(join(tmpdir(), "aidlc-local-home-")); chmodSync(home,0o700);
|
|
92
|
+
let sessionId, previousFingerprint, stalls=0, result;
|
|
93
|
+
try {
|
|
94
|
+
for (let turn=0; turn<maxTurns; turn++) {
|
|
95
|
+
if (renewalError || controller.signal.aborted) throw renewalError ?? new Error("Worker lease lost");
|
|
96
|
+
const containerName = `aidlc-worker-${randomUUID()}`;
|
|
97
|
+
const spawnProcess = (command, args, options) => {
|
|
98
|
+
const invocation = workerDockerInvocation({ workspace: snapshot, image, command, args, credentialEnv: docker.credentials[job.agent], hostEnv: process.env, uid, gid, containerName, sessionHome:home });
|
|
99
|
+
return spawn(invocation.command, invocation.args, { ...options, cwd: snapshot, env: invocation.env, shell: false });
|
|
100
|
+
};
|
|
101
|
+
try { result = await runAgentTurn({ agent: job.agent, root: "/workspace", sessionId, spawnProcess, prompt: `${job.prompt}\n\nOnly modify assigned paths: ${job.ownedPaths.join(", ")}. Do not modify AI-DLC lifecycle state or approve gates. End with AI-DLC-ITEM-COMPLETE on its own line.`, timeoutMs, signal: controller.signal, onSessionId: async (id) => { await heartbeat(root, taskId, assignment.id, leaseMs, id); } }); }
|
|
102
|
+
finally {
|
|
103
|
+
try { dockerCheck("rm", "-f", containerName); }
|
|
104
|
+
catch {
|
|
105
|
+
try { dockerCheck("info"); } catch { throw new Error("Docker cleanup uncertain: engine unavailable"); }
|
|
106
|
+
try { dockerCheck("inspect", containerName); throw new Error("Docker cleanup uncertain: container remains"); }
|
|
107
|
+
catch (error) { if (error.message === "Docker cleanup uncertain: container remains") throw error; }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (renewalError || controller.signal.aborted) throw renewalError ?? new Error("Worker lease lost");
|
|
111
|
+
sessionId=result.sessionId;
|
|
112
|
+
const changed=inspectWorkerSnapshot(snapshot,baseline,job.ownedPaths);
|
|
113
|
+
if (result.resultText?.split(/\r?\n/).some((line) => line.trim() === "AI-DLC-ITEM-COMPLETE")) break;
|
|
114
|
+
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");
|
|
115
|
+
stalls=fingerprint===previousFingerprint ? stalls+1 : 0; previousFingerprint=fingerprint;
|
|
116
|
+
if (stalls >= maxStallTurns) throw new Error(`Worker ${job.workerId} stalled`);
|
|
117
|
+
if (turn===maxTurns-1) throw new Error(`Worker ${job.workerId} exceeded turn limit`);
|
|
118
|
+
}
|
|
119
|
+
} finally { rmSync(home,{recursive:true,force:true}); }
|
|
120
|
+
if (git(workspace, "rev-parse", "HEAD").trim() !== base || statusPaths(workspace).length) throw new Error("Host worker worktree changed during isolated execution");
|
|
121
|
+
const snapshotChanges = inspectWorkerSnapshot(snapshot, baseline, job.ownedPaths);
|
|
122
|
+
if (!snapshotChanges.length) throw new Error(`Worker ${job.workerId} changed no files`);
|
|
123
|
+
await heartbeat(root, taskId, assignment.id, leaseMs);
|
|
124
|
+
if (renewalError || controller.signal.aborted) throw renewalError ?? new Error("Worker lease lost");
|
|
125
|
+
for (const path of snapshotChanges) {
|
|
126
|
+
const destination = join(workspace, path), from = join(snapshot, path);
|
|
127
|
+
let parent = workspace;
|
|
128
|
+
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); } }
|
|
129
|
+
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; }
|
|
130
|
+
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 }); }
|
|
131
|
+
}
|
|
132
|
+
if (git(workspace, "symbolic-ref", "--short", "HEAD").trim() !== branch) throw new Error("Worker changed its assigned branch");
|
|
133
|
+
git(workspace, "merge-base", "--is-ancestor", base, "HEAD");
|
|
134
|
+
const changed = [...new Set([...statusPaths(workspace), ...commitPaths(workspace, base, "HEAD")])];
|
|
135
|
+
if (!changed.length || !scoped(changed, job.ownedPaths)) throw new Error(`Worker ${job.workerId} changed no files or files outside its scope`);
|
|
136
|
+
// Fold any commits made by the agent into one scoped assignment commit.
|
|
137
|
+
git(workspace, "reset", "--soft", base);
|
|
138
|
+
git(workspace, "add", "-A", "--", ...changed);
|
|
139
|
+
if (git(workspace, "diff", "--cached", "--name-only").trim()) git(workspace, "-c", "user.name=AI-DLC worker", "-c", "user.email=aidlc-worker@localhost", "commit", "-m", `AI-DLC ${taskId}/${job.itemId}`);
|
|
140
|
+
const commit = git(workspace, "rev-parse", "HEAD").trim(), committed = commitPaths(workspace, base, commit);
|
|
141
|
+
if (commit === base || !committed.length || !scoped(committed, job.ownedPaths)) throw new Error("Worker commit is outside assignment scope");
|
|
142
|
+
await report(root, taskId, assignment.id, { commit, changedPaths: committed, checkpoint: `provider=${job.agent}` });
|
|
143
|
+
return { assignmentId: assignment.id, itemId: job.itemId, workerId: job.workerId, agent: job.agent, baseCommit: base, branch, commit, changedPaths: committed, workspace };
|
|
144
|
+
} finally { clearInterval(timer); }
|
|
145
|
+
};
|
|
146
|
+
const results = await Promise.allSettled(prepared.map(execute));
|
|
147
|
+
const failure = results.find((result) => result.status === "rejected");
|
|
148
|
+
if (failure) throw new Error(`Local worker batch requires reconciliation: ${failure.reason instanceof Error ? failure.reason.message : String(failure.reason)}`);
|
|
149
|
+
const completed = results.map((result) => result.value);
|
|
150
|
+
let expectedHead = base;
|
|
151
|
+
for (const result of completed) {
|
|
152
|
+
clean(root);
|
|
153
|
+
if (git(root, "symbolic-ref", "--short", "HEAD").trim() !== initialBranch) throw new Error("Integration branch changed during worker run");
|
|
154
|
+
if (git(root, "rev-parse", "HEAD").trim() !== expectedHead) throw new Error("Integration HEAD changed during worker run");
|
|
155
|
+
if (!scoped(commitPaths(root, base, result.commit), jobs.find((job) => job.itemId === result.itemId).ownedPaths)) throw new Error("Worker scope changed before integration");
|
|
156
|
+
git(root, "-c", "user.name=AI-DLC integrator", "-c", "user.email=aidlc-integrator@localhost", "cherry-pick", result.commit);
|
|
157
|
+
result.integrationCommit = git(root, "rev-parse", "HEAD").trim();
|
|
158
|
+
expectedHead = result.integrationCommit;
|
|
159
|
+
await onEvent({ type: "job_integrated", taskId, itemId: result.itemId, assignmentId: result.assignmentId, commit: result.integrationCommit });
|
|
160
|
+
}
|
|
161
|
+
return completed;
|
|
162
|
+
}
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local loop runtime: record management, checkpointing, turn orchestration,
|
|
3
|
+
* and pause/resume handling for a single lead.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import { acquireLoopLease, assertLoopPath, readLiveLease } from "./loop-lease.mjs";
|
|
10
|
+
import { isLoopProvider, LOOP_PROVIDER_LIST } from "./loop-drivers.mjs";
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_LIMITS = Object.freeze({ maxTurns: 12, turnTimeoutMs: 600000, maxStallTurns: 2 });
|
|
13
|
+
const states = new Set(["running", "gate", "paused", "blocked", "terminal", "complete"]);
|
|
14
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
15
|
+
const iso = () => new Date().toISOString();
|
|
16
|
+
const bounded = (value, name, max) => { const n = Number(value); if (!Number.isSafeInteger(n) || n < 1 || n > max) throw new Error(`${name} must be an integer from 1 to ${max}`); return n; };
|
|
17
|
+
|
|
18
|
+
export function validateLimits(input = {}) {
|
|
19
|
+
return {
|
|
20
|
+
maxTurns: bounded(input.maxTurns ?? DEFAULT_LIMITS.maxTurns, "maxTurns", 1000),
|
|
21
|
+
turnTimeoutMs: bounded(input.turnTimeoutMs ?? DEFAULT_LIMITS.turnTimeoutMs, "turnTimeoutMs", 3600000),
|
|
22
|
+
maxStallTurns: bounded(input.maxStallTurns ?? DEFAULT_LIMITS.maxStallTurns, "maxStallTurns", 20)
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function validateRoot(root) {
|
|
27
|
+
const absolute = resolve(root);
|
|
28
|
+
if (!existsSync(absolute) || !statSync(absolute).isDirectory()) throw new Error(`Invalid project root: ${absolute}`);
|
|
29
|
+
for (const part of [".agents", ".agents/aidlc", ".agents/aidlc/scripts", ".agents/data"]) {
|
|
30
|
+
const path = join(absolute, part);
|
|
31
|
+
const stat = lstatSync(path);
|
|
32
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Invalid installed workflow path: ${path}`);
|
|
33
|
+
}
|
|
34
|
+
return absolute;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const loopRecordPath = (root, taskId) => assertLoopPath(root, taskId);
|
|
38
|
+
|
|
39
|
+
function validateRecord(value, taskId) {
|
|
40
|
+
if (!value || value.schemaVersion !== 1 || value.taskId !== taskId || typeof value.runId !== "string" || !value.runId || !isLoopProvider(value.agent) || !states.has(value.status) || !Number.isSafeInteger(value.iteration) || value.iteration < 0 || !Number.isSafeInteger(value.stallTurns) || value.stallTurns < 0 || (value.sessionId !== null && (typeof value.sessionId !== "string" || !value.sessionId)) || !value.limits || !value.updatedAt || Number.isNaN(Date.parse(value.updatedAt))) throw new Error(`Corrupt loop run record for ${taskId}`);
|
|
41
|
+
if (typeof value.pausedByLoop !== "boolean" || (value.stopReason !== null && typeof value.stopReason !== "string") || (value.checkpoint !== null && (typeof value.checkpoint !== "object" || Array.isArray(value.checkpoint))) || (value.status === "paused" && !value.stopReason)) throw new Error(`Corrupt loop run record for ${taskId}`);
|
|
42
|
+
validateLimits(value.limits);
|
|
43
|
+
if (value.lastFingerprint !== null && (typeof value.lastFingerprint !== "string" || !/^[a-f0-9]{64}$/.test(value.lastFingerprint))) throw new Error(`Corrupt loop fingerprint for ${taskId}`);
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readLoopRecord(root, taskId) {
|
|
48
|
+
const path = loopRecordPath(root, taskId);
|
|
49
|
+
try { lstatSync(path); } catch (error) { if (error?.code === "ENOENT") return null; throw error; }
|
|
50
|
+
let value;
|
|
51
|
+
try { value = JSON.parse(readFileSync(path, "utf8")); }
|
|
52
|
+
catch (error) { throw new Error(`Corrupt loop run record for ${taskId}: ${error.message}`); }
|
|
53
|
+
return validateRecord(value, taskId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function writeLoopRecord(root, record) {
|
|
57
|
+
validateRecord(record, record.taskId);
|
|
58
|
+
const path = loopRecordPath(root, record.taskId);
|
|
59
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
60
|
+
const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
|
61
|
+
let fd;
|
|
62
|
+
try {
|
|
63
|
+
fd = openSync(temporary, "wx", 0o600);
|
|
64
|
+
writeFileSync(fd, `${JSON.stringify(record, null, 2)}\n`);
|
|
65
|
+
fsyncSync(fd);
|
|
66
|
+
closeSync(fd); fd = undefined;
|
|
67
|
+
renameSync(temporary, path);
|
|
68
|
+
const dir = openSync(dirname(path), "r"); try { fsyncSync(dir); } finally { closeSync(dir); }
|
|
69
|
+
} finally {
|
|
70
|
+
if (fd !== undefined) closeSync(fd);
|
|
71
|
+
if (existsSync(temporary)) unlinkSync(temporary);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function cli(root, script, args) {
|
|
76
|
+
return execFileSync(process.execPath, [join(root, ".agents/aidlc/scripts", script), ...args, "--root", root], { cwd: root, encoding: "utf8", timeout: 30000, maxBuffer: 2 * 1024 * 1024 });
|
|
77
|
+
}
|
|
78
|
+
function checkAgent(agent, root) {
|
|
79
|
+
try { execFileSync(agent, ["--version"], { cwd: root, encoding: "utf8", timeout: 5000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"] }); }
|
|
80
|
+
catch (error) { throw new Error(`Agent CLI ${agent} is unavailable: ${error?.code ?? error?.message ?? "unknown error"}`); }
|
|
81
|
+
}
|
|
82
|
+
function envelope(root, script, args) {
|
|
83
|
+
const result = JSON.parse(cli(root, script, args));
|
|
84
|
+
if (result?.ok !== true) throw new Error(`${script} did not return a valid result`);
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
export function readCanonical(root, taskId) {
|
|
88
|
+
const next = envelope(root, "state.mjs", ["task", "next", taskId]);
|
|
89
|
+
const shown = envelope(root, "state.mjs", ["task", "show", taskId]);
|
|
90
|
+
const task = shown.result;
|
|
91
|
+
if (!task || task.id !== taskId || next.result?.id !== taskId || !next.nextAction?.classification) throw new Error(`Conflicting canonical task state for ${taskId}`);
|
|
92
|
+
return { task, action: next.nextAction };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function safeArtifact(root, relative) {
|
|
96
|
+
if (typeof relative !== "string" || !relative.startsWith(".agents/data/tasks/")) return null;
|
|
97
|
+
const target = resolve(root, relative);
|
|
98
|
+
if (!target.startsWith(`${root}/`)) return null;
|
|
99
|
+
let cursor = root;
|
|
100
|
+
for (const part of relative.split("/")) { cursor = join(cursor, part); try { if (lstatSync(cursor).isSymbolicLink()) return null; } catch (error) { if (error?.code !== "ENOENT") return null; } }
|
|
101
|
+
return target;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function worktreeSignal(root) {
|
|
105
|
+
let raw;
|
|
106
|
+
try { raw = execFileSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: root, encoding: "buffer", timeout: 10000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }); }
|
|
107
|
+
catch { return { kind: "unknown", reason: "git status unavailable or oversized" }; }
|
|
108
|
+
const entries = raw.toString("utf8").split("\0").filter(Boolean);
|
|
109
|
+
if (entries.length > 128) return { kind: "unknown", reason: "too many changed files" };
|
|
110
|
+
const files = [];
|
|
111
|
+
let bytes = 0;
|
|
112
|
+
for (let i = 0; i < entries.length; i++) {
|
|
113
|
+
const entry = entries[i];
|
|
114
|
+
const status = entry.slice(0, 2);
|
|
115
|
+
let name = entry.slice(3);
|
|
116
|
+
if (/^[RC]/.test(status) || /[RC]$/.test(status)) name = entries[++i] ?? name;
|
|
117
|
+
if (name.startsWith(".agents/data/loop/")) continue;
|
|
118
|
+
const path = resolve(root, name);
|
|
119
|
+
if (!path.startsWith(`${root}/`)) return { kind: "unknown", reason: "unsafe changed path" };
|
|
120
|
+
try {
|
|
121
|
+
const stat = lstatSync(path);
|
|
122
|
+
if (!stat.isFile() || stat.size > 1024 * 1024 || bytes + stat.size > 4 * 1024 * 1024) return { kind: "unknown", reason: "changed file unreadable or oversized" };
|
|
123
|
+
bytes += stat.size;
|
|
124
|
+
files.push([name, status, hash(readFileSync(path))]);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error?.code === "ENOENT") files.push([name, status, "deleted"]);
|
|
127
|
+
else return { kind: "unknown", reason: "changed file unreadable" };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { kind: "known", digest: hash(JSON.stringify(files.sort((a, b) => a[0].localeCompare(b[0])))) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function fingerprint(root, task, action) {
|
|
134
|
+
const artifacts = {};
|
|
135
|
+
for (const [key, relative] of Object.entries(task.artifacts ?? {})) {
|
|
136
|
+
const path = safeArtifact(root, relative);
|
|
137
|
+
if (!path) { artifacts[key] = "unknown"; continue; }
|
|
138
|
+
try { const stat = statSync(path); artifacts[key] = stat.size > 1024 * 1024 ? "unknown" : hash(readFileSync(path)); }
|
|
139
|
+
catch (error) { artifacts[key] = error?.code === "ENOENT" ? "missing" : "unknown"; }
|
|
140
|
+
}
|
|
141
|
+
const projection = { phase: task.phase, status: task.status, gate: task.gate, decisions: task.decisions, items: task.tasks, evidence: task.evidence, artifacts, action: { classification: action.classification, itemId: action.itemId, reason: action.reason } };
|
|
142
|
+
const tree = worktreeSignal(root);
|
|
143
|
+
return { digest: hash(JSON.stringify({ projection, tree: tree.kind === "known" ? tree.digest : null })), tree };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function checkpoint(task, action, signal, iteration, reason = null) {
|
|
147
|
+
return { goal: task.title.slice(0, 500), state: `${task.phase}/${task.status}/${task.gate}`, completed: task.tasks.filter((item) => item.status === "done").map((item) => item.id).slice(0, 30), observations: [`iteration ${iteration}`, `next: ${action.classification}`, `working tree: ${signal.kind}`], hypothesis: null, blockers: reason ? [reason.slice(0, 500)] : [], files: Object.values(task.artifacts ?? {}).slice(0, 10), links: [], nextAction: { classification: action.classification, phase: action.phase, gate: action.gate, itemId: action.itemId, reason: action.reason }, capturedAt: iso() };
|
|
148
|
+
}
|
|
149
|
+
export const classifyAction = (action) => {
|
|
150
|
+
if (!["run_phase", "await_user", "blocked", "terminal", "complete"].includes(action?.classification)) throw new Error(`Unknown nextAction classification: ${action?.classification}`);
|
|
151
|
+
return action.classification;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
function approvalCount(task) { return task.evidence.filter((item) => item.kind === "approval" && item.result === "pass").length; }
|
|
155
|
+
function outputStop(record, action, gateView) { return { taskId: record.taskId, runId: record.runId, status: record.status, iteration: record.iteration, sessionId: record.sessionId, stopReason: record.stopReason, nextAction: action, ...(gateView ? { gateView } : {}) }; }
|
|
156
|
+
|
|
157
|
+
export async function runLoop({ root: rootInput, taskId, agent, mode = "run", limits: limitInput, driver, onEvent = () => {}, preflight = false, precondition }) {
|
|
158
|
+
const root = validateRoot(rootInput);
|
|
159
|
+
// --- Validate mode and load/create the loop record -------------------------
|
|
160
|
+
let record = readLoopRecord(root, taskId);
|
|
161
|
+
if (mode === "run") {
|
|
162
|
+
if (record) throw new Error(`Loop record already exists for ${taskId}; use resume`);
|
|
163
|
+
if (!isLoopProvider(agent)) throw new Error(`--agent must be ${LOOP_PROVIDER_LIST}`);
|
|
164
|
+
} else if (mode === "resume") {
|
|
165
|
+
if (!record) throw new Error(`No loop record for ${taskId}; use run`);
|
|
166
|
+
if (["complete", "terminal"].includes(record.status)) throw new Error(`Cannot resume ${record.status} loop`);
|
|
167
|
+
if (agent && agent !== record.agent) throw new Error("Resume must use the recorded agent");
|
|
168
|
+
if (record.status === "running" && !record.sessionId) throw new Error("Interrupted run has no captured session id; inspect canonical task state before starting a new run");
|
|
169
|
+
} else throw new Error(`Invalid loop mode: ${mode}`);
|
|
170
|
+
const limits = validateLimits({ ...(record?.limits ?? {}), ...(limitInput ?? {}) });
|
|
171
|
+
if (preflight) checkAgent(record?.agent ?? agent, root);
|
|
172
|
+
if (!record) record = { schemaVersion: 1, taskId, runId: randomUUID(), agent, status: "running", sessionId: null, iteration: 0, stallTurns: 0, limits, lastFingerprint: null, checkpoint: null, stopReason: null, pausedByLoop: false, updatedAt: iso() };
|
|
173
|
+
else { const prior = { iteration: record.iteration, status: record.status, stopReason: record.stopReason, limits: record.limits }; record.limits = limits; record.stallTurns = 0; onEvent({ type: "resume", prior }); }
|
|
174
|
+
const release = acquireLoopLease(root, taskId, record.runId);
|
|
175
|
+
try {
|
|
176
|
+
let current = readCanonical(root, taskId);
|
|
177
|
+
if (precondition) {
|
|
178
|
+
const expectedHash = hash(JSON.stringify(current.task));
|
|
179
|
+
const evidenceHash = hash(JSON.stringify(current.task.evidence));
|
|
180
|
+
if (precondition.taskId !== taskId || precondition.phase !== current.task.phase || precondition.taskHash !== expectedHash || precondition.evidenceHash !== evidenceHash || precondition.evidenceCount !== current.task.evidence.length || current.task.status !== "active" || current.action.classification !== "run_phase") throw new Error("Supervisor precondition changed before acquiring loop lease");
|
|
181
|
+
}
|
|
182
|
+
if (mode === "resume" && current.task.status === "paused") {
|
|
183
|
+
if (!record.pausedByLoop || current.task.handoff) throw new Error("Task was paused outside this loop; explicit user handling is required");
|
|
184
|
+
current = readCanonical(root, taskId);
|
|
185
|
+
if (current.task.status !== "paused") throw new Error("Task state changed during resume");
|
|
186
|
+
envelope(root, "state.mjs", ["task", "status", taskId, "--status", "active"]);
|
|
187
|
+
record.pausedByLoop = false;
|
|
188
|
+
current = readCanonical(root, taskId);
|
|
189
|
+
}
|
|
190
|
+
const initial = fingerprint(root, current.task, current.action);
|
|
191
|
+
if (mode === "resume" && record.lastFingerprint && record.lastFingerprint !== initial.digest) onEvent({ type: "context_changed", message: "Canonical task context changed since the last checkpoint; continuing from fresh nextAction", previousCheckpointAt: record.checkpoint?.capturedAt ?? null });
|
|
192
|
+
record.lastFingerprint = initial.digest;
|
|
193
|
+
record.checkpoint = checkpoint(current.task, current.action, initial.tree, record.iteration);
|
|
194
|
+
record.status = "running"; record.stopReason = null; record.updatedAt = iso(); writeLoopRecord(root, record);
|
|
195
|
+
// --- Main turn loop --------------------------------------------------------
|
|
196
|
+
for (;;) {
|
|
197
|
+
const kind = classifyAction(current.action);
|
|
198
|
+
if (kind !== "run_phase") {
|
|
199
|
+
record.status = kind === "await_user" ? "gate" : kind;
|
|
200
|
+
record.stopReason = current.action.reason ?? kind;
|
|
201
|
+
record.updatedAt = iso(); writeLoopRecord(root, record);
|
|
202
|
+
const gateView = kind === "await_user" ? cli(root, "gate-view.mjs", [taskId]).trim() : undefined;
|
|
203
|
+
return outputStop(record, current.action, gateView);
|
|
204
|
+
}
|
|
205
|
+
if (record.iteration >= limits.maxTurns) return pause("turn limit reached");
|
|
206
|
+
let prompt;
|
|
207
|
+
try {
|
|
208
|
+
const packet = JSON.parse(cli(root, "context.mjs", [taskId, "--phase", current.action.phase, ...(current.action.itemId ? ["--item", current.action.itemId] : []), "--format", "json"]));
|
|
209
|
+
if (typeof packet?.content !== "string" || !packet.content.trim()) throw new Error("Context packet was empty or invalid");
|
|
210
|
+
prompt = packet.content;
|
|
211
|
+
} catch {
|
|
212
|
+
// No driver has started for this turn, so it is safe to resume after repairing context.
|
|
213
|
+
// Do not persist arbitrary subprocess output or malformed context in the stop reason.
|
|
214
|
+
return pause("context packet generation or validation failed; inspect context.mjs and resume after repair");
|
|
215
|
+
}
|
|
216
|
+
const beforeApprovals = approvalCount(current.task);
|
|
217
|
+
let observedId = record.sessionId;
|
|
218
|
+
try {
|
|
219
|
+
const result = await driver({ agent: record.agent, root, sessionId: record.sessionId ?? undefined, prompt: `${prompt}\n\nLoop supervisor: complete the current authorized phase or item. Use canonical lifecycle commands for evidence and gates. Never approve a human gate. Stop at a gate and report progress.`, timeoutMs: limits.turnTimeoutMs, onSessionId: (id) => { if (typeof id !== "string" || !id) throw new Error("Invalid agent session id"); observedId = id; record.sessionId = id; record.updatedAt = iso(); writeLoopRecord(root, record); }, onEvent: (event) => { onEvent(event); if (event?.type === "lifecycle_command") { const observed = readCanonical(root, taskId); const signal = fingerprint(root, observed.task, observed.action); record.checkpoint = checkpoint(observed.task, observed.action, signal.tree, record.iteration); record.updatedAt = iso(); writeLoopRecord(root, record); } } });
|
|
220
|
+
if (!observedId && result?.sessionId) { observedId = result.sessionId; record.sessionId = observedId; record.updatedAt = iso(); writeLoopRecord(root, record); }
|
|
221
|
+
if (!observedId) throw new Error("Agent turn ended without a resumable session id");
|
|
222
|
+
} catch (error) {
|
|
223
|
+
current = readCanonical(root, taskId);
|
|
224
|
+
if (classifyAction(current.action) === "await_user") continue;
|
|
225
|
+
return pause(`agent transport failure: ${error.message}`);
|
|
226
|
+
}
|
|
227
|
+
const refreshed = readCanonical(root, taskId);
|
|
228
|
+
if (approvalCount(refreshed.task) !== beforeApprovals) return pause("Approval evidence changed during an agent turn");
|
|
229
|
+
const signal = fingerprint(root, refreshed.task, refreshed.action);
|
|
230
|
+
record.iteration += 1;
|
|
231
|
+
record.stallTurns = signal.digest === record.lastFingerprint ? record.stallTurns + 1 : 0;
|
|
232
|
+
record.lastFingerprint = signal.digest;
|
|
233
|
+
record.checkpoint = checkpoint(refreshed.task, refreshed.action, signal.tree, record.iteration);
|
|
234
|
+
record.updatedAt = iso(); writeLoopRecord(root, record);
|
|
235
|
+
current = refreshed;
|
|
236
|
+
if (classifyAction(current.action) === "run_phase" && record.stallTurns >= limits.maxStallTurns) return pause(`${record.stallTurns} consecutive turns without observable progress`);
|
|
237
|
+
}
|
|
238
|
+
function pause(reason) {
|
|
239
|
+
const fresh = readCanonical(root, taskId);
|
|
240
|
+
record.status = "paused"; record.stopReason = reason;
|
|
241
|
+
if (fresh.task.status === "active" && classifyAction(fresh.action) === "run_phase") {
|
|
242
|
+
envelope(root, "state.mjs", ["task", "status", taskId, "--status", "paused", "--mode", "audited", "--reason", reason, "--source", `loop run ${record.runId}`]);
|
|
243
|
+
record.pausedByLoop = true;
|
|
244
|
+
}
|
|
245
|
+
const stopped = readCanonical(root, taskId);
|
|
246
|
+
const signal = fingerprint(root, stopped.task, stopped.action);
|
|
247
|
+
record.lastFingerprint = signal.digest;
|
|
248
|
+
record.checkpoint = checkpoint(stopped.task, stopped.action, signal.tree, record.iteration, reason);
|
|
249
|
+
record.updatedAt = iso(); writeLoopRecord(root, record);
|
|
250
|
+
return outputStop(record, stopped.action);
|
|
251
|
+
}
|
|
252
|
+
} finally { release(); }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function loopStatus(rootInput, taskId) {
|
|
256
|
+
const root = validateRoot(rootInput);
|
|
257
|
+
const record = readLoopRecord(root, taskId);
|
|
258
|
+
const current = readCanonical(root, taskId);
|
|
259
|
+
return { record, liveLease: readLiveLease(root, taskId), nextAction: current.action };
|
|
260
|
+
}
|