@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.
- package/DEVELOPMENT.md +1 -1
- package/README.md +5 -4
- 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/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 +8 -1
- package/dist/assets/.agents/aidlc/scripts/lib/feature-store.mjs +70 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +215 -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 +314 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +279 -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 +161 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-planner.mjs +13 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +254 -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 +65 -0
- package/dist/assets/.agents/aidlc/scripts/lib/remote-state-client.mjs +112 -0
- package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +39 -4
- 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 +309 -0
- package/dist/assets/.agents/aidlc/scripts/render.mjs +2 -0
- package/dist/assets/.agents/aidlc/scripts/state.mjs +67 -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 +39 -4
- 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 +25 -5
- package/dist/src/cli.js +66 -6
- package/dist/src/gate.js +3 -3
- package/dist/src/installer.d.ts +5 -1
- package/dist/src/installer.js +114 -30
- package/dist/src/managed-content.js +1 -1
- package/dist/src/model.d.ts +15 -0
- package/dist/src/model.js +1 -1
- package/dist/src/profiles.d.ts +3 -1
- package/dist/src/profiles.js +26 -3
- package/dist/src/state.d.ts +7 -0
- package/dist/src/state.js +54 -3
- package/dist/src/upgrade.d.ts +2 -2
- package/dist/src/upgrade.js +39 -4
- package/dist/src/workflow.d.ts +1 -0
- package/dist/src/workflow.js +2 -1
- package/docs/README.md +2 -2
- package/docs/architecture.md +6 -1
- package/docs/command-reference.md +63 -5
- package/docs/configuration.md +20 -1
- package/docs/coordination-service.md +54 -1
- package/docs/development.md +1 -1
- package/docs/operating-workflow.md +50 -14
- package/docs/testing-and-release.md +47 -4
- package/package.json +2 -2
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { acquireLoopLease, assertLoopPath, readLiveLease } from "./loop-lease.mjs";
|
|
6
|
+
import { isLoopProvider, LOOP_PROVIDER_LIST } from "./loop-drivers.mjs";
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_LIMITS = Object.freeze({ maxTurns: 12, turnTimeoutMs: 600000, maxStallTurns: 2 });
|
|
9
|
+
const states = new Set(["running", "gate", "paused", "blocked", "terminal", "complete"]);
|
|
10
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
11
|
+
const iso = () => new Date().toISOString();
|
|
12
|
+
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; };
|
|
13
|
+
|
|
14
|
+
export function validateLimits(input = {}) {
|
|
15
|
+
return {
|
|
16
|
+
maxTurns: bounded(input.maxTurns ?? DEFAULT_LIMITS.maxTurns, "maxTurns", 1000),
|
|
17
|
+
turnTimeoutMs: bounded(input.turnTimeoutMs ?? DEFAULT_LIMITS.turnTimeoutMs, "turnTimeoutMs", 3600000),
|
|
18
|
+
maxStallTurns: bounded(input.maxStallTurns ?? DEFAULT_LIMITS.maxStallTurns, "maxStallTurns", 20)
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function validateRoot(root) {
|
|
23
|
+
const absolute = resolve(root);
|
|
24
|
+
if (!existsSync(absolute) || !statSync(absolute).isDirectory()) throw new Error(`Invalid project root: ${absolute}`);
|
|
25
|
+
for (const part of [".agents", ".agents/aidlc", ".agents/aidlc/scripts", ".agents/data"]) {
|
|
26
|
+
const path = join(absolute, part);
|
|
27
|
+
const stat = lstatSync(path);
|
|
28
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Invalid installed workflow path: ${path}`);
|
|
29
|
+
}
|
|
30
|
+
return absolute;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const loopRecordPath = (root, taskId) => assertLoopPath(root, taskId);
|
|
34
|
+
|
|
35
|
+
function validateRecord(value, taskId) {
|
|
36
|
+
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}`);
|
|
37
|
+
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}`);
|
|
38
|
+
validateLimits(value.limits);
|
|
39
|
+
if (value.lastFingerprint !== null && (typeof value.lastFingerprint !== "string" || !/^[a-f0-9]{64}$/.test(value.lastFingerprint))) throw new Error(`Corrupt loop fingerprint for ${taskId}`);
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function readLoopRecord(root, taskId) {
|
|
44
|
+
const path = loopRecordPath(root, taskId);
|
|
45
|
+
try { lstatSync(path); } catch (error) { if (error?.code === "ENOENT") return null; throw error; }
|
|
46
|
+
let value;
|
|
47
|
+
try { value = JSON.parse(readFileSync(path, "utf8")); }
|
|
48
|
+
catch (error) { throw new Error(`Corrupt loop run record for ${taskId}: ${error.message}`); }
|
|
49
|
+
return validateRecord(value, taskId);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function writeLoopRecord(root, record) {
|
|
53
|
+
validateRecord(record, record.taskId);
|
|
54
|
+
const path = loopRecordPath(root, record.taskId);
|
|
55
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
56
|
+
const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
|
57
|
+
let fd;
|
|
58
|
+
try {
|
|
59
|
+
fd = openSync(temporary, "wx", 0o600);
|
|
60
|
+
writeFileSync(fd, `${JSON.stringify(record, null, 2)}\n`);
|
|
61
|
+
fsyncSync(fd);
|
|
62
|
+
closeSync(fd); fd = undefined;
|
|
63
|
+
renameSync(temporary, path);
|
|
64
|
+
const dir = openSync(dirname(path), "r"); try { fsyncSync(dir); } finally { closeSync(dir); }
|
|
65
|
+
} finally {
|
|
66
|
+
if (fd !== undefined) closeSync(fd);
|
|
67
|
+
if (existsSync(temporary)) unlinkSync(temporary);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function cli(root, script, args) {
|
|
72
|
+
return execFileSync(process.execPath, [join(root, ".agents/aidlc/scripts", script), ...args, "--root", root], { cwd: root, encoding: "utf8", timeout: 30000, maxBuffer: 2 * 1024 * 1024 });
|
|
73
|
+
}
|
|
74
|
+
function checkAgent(agent, root) {
|
|
75
|
+
try { execFileSync(agent, ["--version"], { cwd: root, encoding: "utf8", timeout: 5000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"] }); }
|
|
76
|
+
catch (error) { throw new Error(`Agent CLI ${agent} is unavailable: ${error?.code ?? error?.message ?? "unknown error"}`); }
|
|
77
|
+
}
|
|
78
|
+
function envelope(root, script, args) {
|
|
79
|
+
const result = JSON.parse(cli(root, script, args));
|
|
80
|
+
if (result?.ok !== true) throw new Error(`${script} did not return a valid result`);
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
export function readCanonical(root, taskId) {
|
|
84
|
+
const next = envelope(root, "state.mjs", ["task", "next", taskId]);
|
|
85
|
+
const shown = envelope(root, "state.mjs", ["task", "show", taskId]);
|
|
86
|
+
const task = shown.result;
|
|
87
|
+
if (!task || task.id !== taskId || next.result?.id !== taskId || !next.nextAction?.classification) throw new Error(`Conflicting canonical task state for ${taskId}`);
|
|
88
|
+
return { task, action: next.nextAction };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function safeArtifact(root, relative) {
|
|
92
|
+
if (typeof relative !== "string" || !relative.startsWith(".agents/data/tasks/")) return null;
|
|
93
|
+
const target = resolve(root, relative);
|
|
94
|
+
if (!target.startsWith(`${root}/`)) return null;
|
|
95
|
+
let cursor = root;
|
|
96
|
+
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; } }
|
|
97
|
+
return target;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function worktreeSignal(root) {
|
|
101
|
+
let raw;
|
|
102
|
+
try { raw = execFileSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: root, encoding: "buffer", timeout: 10000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }); }
|
|
103
|
+
catch { return { kind: "unknown", reason: "git status unavailable or oversized" }; }
|
|
104
|
+
const entries = raw.toString("utf8").split("\0").filter(Boolean);
|
|
105
|
+
if (entries.length > 128) return { kind: "unknown", reason: "too many changed files" };
|
|
106
|
+
const files = [];
|
|
107
|
+
let bytes = 0;
|
|
108
|
+
for (let i = 0; i < entries.length; i++) {
|
|
109
|
+
const entry = entries[i];
|
|
110
|
+
const status = entry.slice(0, 2);
|
|
111
|
+
let name = entry.slice(3);
|
|
112
|
+
if (/^[RC]/.test(status) || /[RC]$/.test(status)) name = entries[++i] ?? name;
|
|
113
|
+
if (name.startsWith(".agents/data/loop/")) continue;
|
|
114
|
+
const path = resolve(root, name);
|
|
115
|
+
if (!path.startsWith(`${root}/`)) return { kind: "unknown", reason: "unsafe changed path" };
|
|
116
|
+
try {
|
|
117
|
+
const stat = lstatSync(path);
|
|
118
|
+
if (!stat.isFile() || stat.size > 1024 * 1024 || bytes + stat.size > 4 * 1024 * 1024) return { kind: "unknown", reason: "changed file unreadable or oversized" };
|
|
119
|
+
bytes += stat.size;
|
|
120
|
+
files.push([name, status, hash(readFileSync(path))]);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error?.code === "ENOENT") files.push([name, status, "deleted"]);
|
|
123
|
+
else return { kind: "unknown", reason: "changed file unreadable" };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { kind: "known", digest: hash(JSON.stringify(files.sort((a, b) => a[0].localeCompare(b[0])))) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function fingerprint(root, task, action) {
|
|
130
|
+
const artifacts = {};
|
|
131
|
+
for (const [key, relative] of Object.entries(task.artifacts ?? {})) {
|
|
132
|
+
const path = safeArtifact(root, relative);
|
|
133
|
+
if (!path) { artifacts[key] = "unknown"; continue; }
|
|
134
|
+
try { const stat = statSync(path); artifacts[key] = stat.size > 1024 * 1024 ? "unknown" : hash(readFileSync(path)); }
|
|
135
|
+
catch (error) { artifacts[key] = error?.code === "ENOENT" ? "missing" : "unknown"; }
|
|
136
|
+
}
|
|
137
|
+
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 } };
|
|
138
|
+
const tree = worktreeSignal(root);
|
|
139
|
+
return { digest: hash(JSON.stringify({ projection, tree: tree.kind === "known" ? tree.digest : null })), tree };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function checkpoint(task, action, signal, iteration, reason = null) {
|
|
143
|
+
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() };
|
|
144
|
+
}
|
|
145
|
+
export const classifyAction = (action) => {
|
|
146
|
+
if (!["run_phase", "await_user", "blocked", "terminal", "complete"].includes(action?.classification)) throw new Error(`Unknown nextAction classification: ${action?.classification}`);
|
|
147
|
+
return action.classification;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
function approvalCount(task) { return task.evidence.filter((item) => item.kind === "approval" && item.result === "pass").length; }
|
|
151
|
+
function outputStop(record, action, gateView) { return { taskId: record.taskId, runId: record.runId, status: record.status, iteration: record.iteration, sessionId: record.sessionId, stopReason: record.stopReason, nextAction: action, ...(gateView ? { gateView } : {}) }; }
|
|
152
|
+
|
|
153
|
+
export async function runLoop({ root: rootInput, taskId, agent, mode = "run", limits: limitInput, driver, onEvent = () => {}, preflight = false, precondition }) {
|
|
154
|
+
const root = validateRoot(rootInput);
|
|
155
|
+
let record = readLoopRecord(root, taskId);
|
|
156
|
+
if (mode === "run") {
|
|
157
|
+
if (record) throw new Error(`Loop record already exists for ${taskId}; use resume`);
|
|
158
|
+
if (!isLoopProvider(agent)) throw new Error(`--agent must be ${LOOP_PROVIDER_LIST}`);
|
|
159
|
+
} else if (mode === "resume") {
|
|
160
|
+
if (!record) throw new Error(`No loop record for ${taskId}; use run`);
|
|
161
|
+
if (["complete", "terminal"].includes(record.status)) throw new Error(`Cannot resume ${record.status} loop`);
|
|
162
|
+
if (agent && agent !== record.agent) throw new Error("Resume must use the recorded agent");
|
|
163
|
+
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");
|
|
164
|
+
} else throw new Error(`Invalid loop mode: ${mode}`);
|
|
165
|
+
const limits = validateLimits({ ...(record?.limits ?? {}), ...(limitInput ?? {}) });
|
|
166
|
+
if (preflight) checkAgent(record?.agent ?? agent, root);
|
|
167
|
+
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() };
|
|
168
|
+
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 }); }
|
|
169
|
+
const release = acquireLoopLease(root, taskId, record.runId);
|
|
170
|
+
try {
|
|
171
|
+
let current = readCanonical(root, taskId);
|
|
172
|
+
if (precondition) {
|
|
173
|
+
const expectedHash = hash(JSON.stringify(current.task));
|
|
174
|
+
const evidenceHash = hash(JSON.stringify(current.task.evidence));
|
|
175
|
+
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");
|
|
176
|
+
}
|
|
177
|
+
if (mode === "resume" && current.task.status === "paused") {
|
|
178
|
+
if (!record.pausedByLoop || current.task.handoff) throw new Error("Task was paused outside this loop; explicit user handling is required");
|
|
179
|
+
current = readCanonical(root, taskId);
|
|
180
|
+
if (current.task.status !== "paused") throw new Error("Task state changed during resume");
|
|
181
|
+
envelope(root, "state.mjs", ["task", "status", taskId, "--status", "active"]);
|
|
182
|
+
record.pausedByLoop = false;
|
|
183
|
+
current = readCanonical(root, taskId);
|
|
184
|
+
}
|
|
185
|
+
const initial = fingerprint(root, current.task, current.action);
|
|
186
|
+
if (mode === "resume" && record.lastFingerprint && record.lastFingerprint !== initial.digest) onEvent({ type: "context_changed", message: "Canonical task context changed since the last checkpoint; continuing from fresh nextAction", previousCheckpointAt: record.checkpoint?.capturedAt ?? null });
|
|
187
|
+
record.lastFingerprint = initial.digest;
|
|
188
|
+
record.checkpoint = checkpoint(current.task, current.action, initial.tree, record.iteration);
|
|
189
|
+
record.status = "running"; record.stopReason = null; record.updatedAt = iso(); writeLoopRecord(root, record);
|
|
190
|
+
for (;;) {
|
|
191
|
+
const kind = classifyAction(current.action);
|
|
192
|
+
if (kind !== "run_phase") {
|
|
193
|
+
record.status = kind === "await_user" ? "gate" : kind;
|
|
194
|
+
record.stopReason = current.action.reason ?? kind;
|
|
195
|
+
record.updatedAt = iso(); writeLoopRecord(root, record);
|
|
196
|
+
const gateView = kind === "await_user" ? cli(root, "gate-view.mjs", [taskId]).trim() : undefined;
|
|
197
|
+
return outputStop(record, current.action, gateView);
|
|
198
|
+
}
|
|
199
|
+
if (record.iteration >= limits.maxTurns) return pause("turn limit reached");
|
|
200
|
+
let prompt;
|
|
201
|
+
try {
|
|
202
|
+
const packet = JSON.parse(cli(root, "context.mjs", [taskId, "--phase", current.action.phase, ...(current.action.itemId ? ["--item", current.action.itemId] : []), "--format", "json"]));
|
|
203
|
+
if (typeof packet?.content !== "string" || !packet.content.trim()) throw new Error("Context packet was empty or invalid");
|
|
204
|
+
prompt = packet.content;
|
|
205
|
+
} catch {
|
|
206
|
+
// No driver has started for this turn, so it is safe to resume after repairing context.
|
|
207
|
+
// Do not persist arbitrary subprocess output or malformed context in the stop reason.
|
|
208
|
+
return pause("context packet generation or validation failed; inspect context.mjs and resume after repair");
|
|
209
|
+
}
|
|
210
|
+
const beforeApprovals = approvalCount(current.task);
|
|
211
|
+
let observedId = record.sessionId;
|
|
212
|
+
try {
|
|
213
|
+
const result = await driver({ agent: record.agent, root, sessionId: record.sessionId ?? undefined, prompt: `${prompt}\n\nLoop supervisor: complete the current authorized phase or item. Use canonical lifecycle commands for evidence and gates. Never approve a human gate. Stop at a gate and report progress.`, timeoutMs: limits.turnTimeoutMs, onSessionId: (id) => { if (typeof id !== "string" || !id) throw new Error("Invalid agent session id"); observedId = id; record.sessionId = id; record.updatedAt = iso(); writeLoopRecord(root, record); }, onEvent: (event) => { onEvent(event); if (event?.type === "lifecycle_command") { const observed = readCanonical(root, taskId); const signal = fingerprint(root, observed.task, observed.action); record.checkpoint = checkpoint(observed.task, observed.action, signal.tree, record.iteration); record.updatedAt = iso(); writeLoopRecord(root, record); } } });
|
|
214
|
+
if (!observedId && result?.sessionId) { observedId = result.sessionId; record.sessionId = observedId; record.updatedAt = iso(); writeLoopRecord(root, record); }
|
|
215
|
+
if (!observedId) throw new Error("Agent turn ended without a resumable session id");
|
|
216
|
+
} catch (error) {
|
|
217
|
+
current = readCanonical(root, taskId);
|
|
218
|
+
if (classifyAction(current.action) === "await_user") continue;
|
|
219
|
+
return pause(`agent transport failure: ${error.message}`);
|
|
220
|
+
}
|
|
221
|
+
const refreshed = readCanonical(root, taskId);
|
|
222
|
+
if (approvalCount(refreshed.task) !== beforeApprovals) return pause("Approval evidence changed during an agent turn");
|
|
223
|
+
const signal = fingerprint(root, refreshed.task, refreshed.action);
|
|
224
|
+
record.iteration += 1;
|
|
225
|
+
record.stallTurns = signal.digest === record.lastFingerprint ? record.stallTurns + 1 : 0;
|
|
226
|
+
record.lastFingerprint = signal.digest;
|
|
227
|
+
record.checkpoint = checkpoint(refreshed.task, refreshed.action, signal.tree, record.iteration);
|
|
228
|
+
record.updatedAt = iso(); writeLoopRecord(root, record);
|
|
229
|
+
current = refreshed;
|
|
230
|
+
if (classifyAction(current.action) === "run_phase" && record.stallTurns >= limits.maxStallTurns) return pause(`${record.stallTurns} consecutive turns without observable progress`);
|
|
231
|
+
}
|
|
232
|
+
function pause(reason) {
|
|
233
|
+
const fresh = readCanonical(root, taskId);
|
|
234
|
+
record.status = "paused"; record.stopReason = reason;
|
|
235
|
+
if (fresh.task.status === "active" && classifyAction(fresh.action) === "run_phase") {
|
|
236
|
+
envelope(root, "state.mjs", ["task", "status", taskId, "--status", "paused", "--mode", "audited", "--reason", reason, "--source", `loop run ${record.runId}`]);
|
|
237
|
+
record.pausedByLoop = true;
|
|
238
|
+
}
|
|
239
|
+
const stopped = readCanonical(root, taskId);
|
|
240
|
+
const signal = fingerprint(root, stopped.task, stopped.action);
|
|
241
|
+
record.lastFingerprint = signal.digest;
|
|
242
|
+
record.checkpoint = checkpoint(stopped.task, stopped.action, signal.tree, record.iteration, reason);
|
|
243
|
+
record.updatedAt = iso(); writeLoopRecord(root, record);
|
|
244
|
+
return outputStop(record, stopped.action);
|
|
245
|
+
}
|
|
246
|
+
} finally { release(); }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function loopStatus(rootInput, taskId) {
|
|
250
|
+
const root = validateRoot(rootInput);
|
|
251
|
+
const record = readLoopRecord(root, taskId);
|
|
252
|
+
const current = readCanonical(root, taskId);
|
|
253
|
+
return { record, liveLease: readLiveLease(root, taskId), nextAction: current.action };
|
|
254
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { assertLoopPath } from "./loop-lease.mjs";
|
|
5
|
+
import { validateRoot } from "./loop-runtime.mjs";
|
|
6
|
+
import { acquireStateLock, loadTask, nextAction } from "./store.mjs";
|
|
7
|
+
import { recoverLeadArtifact } from "./loop-lead-artifacts.mjs";
|
|
8
|
+
|
|
9
|
+
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
10
|
+
const checkAuthority = (authority) => { if (authority !== "local") throw new Error("local supervisor adapter requires local authority"); };
|
|
11
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (error) { return error?.code !== "ESRCH"; } };
|
|
12
|
+
const wait = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
13
|
+
|
|
14
|
+
function readLease(path, taskId) {
|
|
15
|
+
try {
|
|
16
|
+
const lease = JSON.parse(readFileSync(path, "utf8"));
|
|
17
|
+
if (lease?.taskId !== taskId || !Number.isSafeInteger(lease.pid) || lease.pid < 1 || typeof lease.token !== "string" || !lease.token) throw new Error("invalid fields");
|
|
18
|
+
return lease;
|
|
19
|
+
} catch (error) { if (error?.code === "ENOENT") return null; throw new Error(`Corrupt supervisor lease for ${taskId}: ${error.message}`); }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeExclusive(path, value) {
|
|
23
|
+
const fd = openSync(path, "wx", 0o600);
|
|
24
|
+
try { writeFileSync(fd, `${JSON.stringify(value)}\n`); fsyncSync(fd); } finally { closeSync(fd); }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Local durable checkpoint + separate PID-fenced supervisor lock.
|
|
28
|
+
* The lock uses a distinct task key from the runner's lease so humans can approve.
|
|
29
|
+
* This adapter intentionally does not implement runTurn or perform approvals.
|
|
30
|
+
*/
|
|
31
|
+
export function createLocalSupervisorAdapter({ root: rootInput, taskId }) {
|
|
32
|
+
const root = validateRoot(rootInput);
|
|
33
|
+
const checkpointPath = assertLoopPath(root, taskId, ".supervisor.json");
|
|
34
|
+
const leasePath = assertLoopPath(root, taskId, ".supervisor.lease");
|
|
35
|
+
const guardPath = assertLoopPath(root, taskId, ".supervisor.guard");
|
|
36
|
+
let token;
|
|
37
|
+
const withGuard = (action) => {
|
|
38
|
+
assertLoopPath(root, taskId, ".supervisor.guard");
|
|
39
|
+
mkdirSync(dirname(guardPath), { recursive: true });
|
|
40
|
+
const guard = { taskId, pid: process.pid, token: randomUUID() }, deadline = Date.now() + 5000;
|
|
41
|
+
for (;;) {
|
|
42
|
+
try { writeExclusive(guardPath, guard); break; }
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error?.code !== "EEXIST") throw error;
|
|
45
|
+
const existing = readLease(guardPath, taskId);
|
|
46
|
+
if (!existing) continue;
|
|
47
|
+
if (!alive(existing.pid)) { if (readLease(guardPath, taskId)?.token === existing.token) { try { unlinkSync(guardPath); } catch (e) { if (e?.code !== "ENOENT") throw e; } } continue; }
|
|
48
|
+
if (Date.now() >= deadline) throw new Error(`Timed out waiting for supervisor guard: ${taskId}`);
|
|
49
|
+
wait(10);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
try { return action(); } finally { if (readLease(guardPath, taskId)?.token === guard.token) unlinkSync(guardPath); }
|
|
53
|
+
};
|
|
54
|
+
const checkScope = (input) => { checkAuthority(input?.authority); if (input?.taskId !== taskId) throw new Error("supervisor task mismatch"); };
|
|
55
|
+
const assertOwned = async () => {
|
|
56
|
+
assertLoopPath(root, taskId, ".supervisor.lease");
|
|
57
|
+
const current = readLease(leasePath, taskId);
|
|
58
|
+
if (!token || !current || current.token !== token || current.pid !== process.pid) throw new Error("local supervisor ownership lost");
|
|
59
|
+
};
|
|
60
|
+
const readCheckpoint = () => {
|
|
61
|
+
assertLoopPath(root, taskId, ".supervisor.json");
|
|
62
|
+
try {
|
|
63
|
+
const value = JSON.parse(readFileSync(checkpointPath, "utf8"));
|
|
64
|
+
if (!value || value.version !== 1 || value.taskId !== taskId || value.authority !== "local") throw new Error("wrong task or authority");
|
|
65
|
+
return value;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error?.code === "ENOENT") return null;
|
|
68
|
+
throw new Error(`Corrupt supervisor checkpoint for ${taskId}: ${error.message}`);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const syncDir = () => { const dir = openSync(dirname(checkpointPath), "r"); try { fsyncSync(dir); } finally { closeSync(dir); } };
|
|
72
|
+
return {
|
|
73
|
+
async acquireSupervisor(input) {
|
|
74
|
+
checkScope(input);
|
|
75
|
+
if (token) throw new Error("local supervisor adapter is already acquired");
|
|
76
|
+
if (input.signal?.aborted) throw new Error("supervisor already aborted");
|
|
77
|
+
const newToken = randomUUID();
|
|
78
|
+
withGuard(() => {
|
|
79
|
+
assertLoopPath(root, taskId, ".supervisor.lease");
|
|
80
|
+
const existing = readLease(leasePath, taskId);
|
|
81
|
+
if (existing && alive(existing.pid)) throw new Error(`Task ${taskId} already has a live supervisor (pid ${existing.pid})`);
|
|
82
|
+
if (existing && readLease(leasePath, taskId)?.token === existing.token) unlinkSync(leasePath);
|
|
83
|
+
writeExclusive(leasePath, { taskId, pid: process.pid, token: newToken });
|
|
84
|
+
});
|
|
85
|
+
token = newToken;
|
|
86
|
+
return { assertOwned, release: async () => { if (token !== newToken) return; withGuard(() => { if (readLease(leasePath, taskId)?.token === newToken) unlinkSync(leasePath); }); token = undefined; } };
|
|
87
|
+
},
|
|
88
|
+
async readTask(input) {
|
|
89
|
+
checkScope({ ...input, authority: "local" });
|
|
90
|
+
const release = acquireStateLock(root);
|
|
91
|
+
try {
|
|
92
|
+
if (token) await assertOwned();
|
|
93
|
+
const task = loadTask(root, taskId);
|
|
94
|
+
if (!task || task.id !== taskId) throw new Error("invalid local canonical task snapshot");
|
|
95
|
+
if (token) {
|
|
96
|
+
await recoverLeadArtifact({ root, taskId, task, assertOwned });
|
|
97
|
+
await assertOwned();
|
|
98
|
+
} else {
|
|
99
|
+
const journal = join(root, ".agents", "data", "tasks", taskId, ".lead-artifact-journal.json");
|
|
100
|
+
try { lstatSync(journal); throw new Error("Pending lead artifact journal requires supervised recovery"); }
|
|
101
|
+
catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
102
|
+
}
|
|
103
|
+
return { task, nextAction: nextAction(task, root) };
|
|
104
|
+
} finally { release(); }
|
|
105
|
+
},
|
|
106
|
+
async loadCheckpoint(input) { checkScope(input); await assertOwned(); return readCheckpoint(); },
|
|
107
|
+
async saveCheckpoint(input) {
|
|
108
|
+
checkScope(input); await assertOwned();
|
|
109
|
+
if (!input.checkpoint || input.checkpoint.taskId !== taskId || input.checkpoint.authority !== "local") throw new Error("invalid local checkpoint");
|
|
110
|
+
if (!same(readCheckpoint(), input.previous ?? null)) throw new Error("supervisor checkpoint conflict");
|
|
111
|
+
mkdirSync(dirname(checkpointPath), { recursive: true });
|
|
112
|
+
const temporary = `${checkpointPath}.tmp-${process.pid}-${randomUUID()}`;
|
|
113
|
+
let fd;
|
|
114
|
+
try {
|
|
115
|
+
fd = openSync(temporary, "wx", 0o600); writeFileSync(fd, `${JSON.stringify(input.checkpoint)}\n`); fsyncSync(fd); closeSync(fd); fd = undefined;
|
|
116
|
+
await assertOwned();
|
|
117
|
+
if (!same(readCheckpoint(), input.previous ?? null)) throw new Error("supervisor checkpoint conflict");
|
|
118
|
+
renameSync(temporary, checkpointPath); syncDir();
|
|
119
|
+
} finally {
|
|
120
|
+
if (fd !== undefined) closeSync(fd);
|
|
121
|
+
if (existsSync(temporary)) unlinkSync(temporary);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
async clearCheckpoint(input) {
|
|
125
|
+
checkScope(input); await assertOwned();
|
|
126
|
+
const current = readCheckpoint();
|
|
127
|
+
if (!same(current, input.checkpoint)) throw new Error("supervisor checkpoint conflict");
|
|
128
|
+
if (current) { unlinkSync(checkpointPath); syncDir(); }
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { requireLocalAuthority } from "./authority.mjs";
|
|
3
|
+
import { runAgentTurn, isLoopProvider, LOOP_PROVIDER_LIST } from "./loop-drivers.mjs";
|
|
4
|
+
import { readLoopRecord, runLoop, validateRoot } from "./loop-runtime.mjs";
|
|
5
|
+
import { runSupervisor } from "./loop-supervisor.mjs";
|
|
6
|
+
import { createLocalSupervisorAdapter } from "./loop-supervisor-local.mjs";
|
|
7
|
+
|
|
8
|
+
const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Bind the canonical local store, distinct supervisor lease and the existing
|
|
12
|
+
* leased runner. The caller owns process supervision and human gate approval.
|
|
13
|
+
* Abort stops polling or the next turn; the current runLoop call is bounded by
|
|
14
|
+
* its existing timeouts and is not cancelled by this wrapper.
|
|
15
|
+
*/
|
|
16
|
+
export function createLocalLoopSupervisor({ root: rootInput, taskId, agent, limits, driver = runAgentTurn, preflight = true, pollMs, signal, onEvent = () => {}, runner = runLoop } = {}) {
|
|
17
|
+
requireLocalAuthority();
|
|
18
|
+
const root = validateRoot(rootInput);
|
|
19
|
+
if (!isLoopProvider(agent)) throw new Error(`local lead agent must be ${LOOP_PROVIDER_LIST}`);
|
|
20
|
+
if (typeof driver !== "function" || typeof runner !== "function" || typeof onEvent !== "function") throw new Error("invalid local supervisor callbacks");
|
|
21
|
+
const adapter = createLocalSupervisorAdapter({ root, taskId });
|
|
22
|
+
const getRecord = () => readLoopRecord(root, taskId);
|
|
23
|
+
const execute = async (mode) => {
|
|
24
|
+
requireLocalAuthority();
|
|
25
|
+
const record = getRecord();
|
|
26
|
+
if (mode === "run" && record) throw new Error("Local loop record already exists; use resume");
|
|
27
|
+
if (mode === "resume" && !record) throw new Error("Local loop record missing; use start");
|
|
28
|
+
if (record && record.agent !== agent) throw new Error("Resume must use the recorded lead agent");
|
|
29
|
+
let nextMode = mode;
|
|
30
|
+
return runSupervisor({ taskId, authority: "local", ...adapter, pollMs, signal, onEvent,
|
|
31
|
+
runTurn: async ({ precondition }) => {
|
|
32
|
+
if (signal?.aborted) return { status: "stopped", taskId };
|
|
33
|
+
requireLocalAuthority();
|
|
34
|
+
let expected = precondition;
|
|
35
|
+
if (!expected) {
|
|
36
|
+
const snapshot = await adapter.readTask({ taskId });
|
|
37
|
+
if (snapshot.task.status !== "active" || snapshot.nextAction.classification !== "run_phase") return { status: "blocked", taskId, reason: "Canonical state changed before runner acquired lease" };
|
|
38
|
+
expected = { taskId, phase: snapshot.task.phase, taskHash: digest(snapshot.task), evidenceHash: digest(snapshot.task.evidence), evidenceCount: snapshot.task.evidence.length };
|
|
39
|
+
}
|
|
40
|
+
const result = await runner({ root, taskId, agent, mode: nextMode, limits, driver, preflight, precondition: expected, onEvent });
|
|
41
|
+
nextMode = "resume";
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
start: () => execute("run"),
|
|
48
|
+
resume: () => execute("resume"),
|
|
49
|
+
async status() {
|
|
50
|
+
requireLocalAuthority();
|
|
51
|
+
const snapshot = await adapter.readTask({ taskId });
|
|
52
|
+
return { taskId, authority: "local", record: getRecord(), nextAction: snapshot.nextAction };
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
3
|
+
|
|
4
|
+
const TARGET = { G0_confirm: "plan", G1_review: "build", G2_codereview: "wrap" };
|
|
5
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
6
|
+
const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
7
|
+
const required = (value, name) => { if (typeof value !== "function") throw new Error(`${name} is required`); return value; };
|
|
8
|
+
const validId = (value, name) => { if (typeof value !== "string" || !ID.test(value)) throw new Error(`invalid ${name}`); };
|
|
9
|
+
|
|
10
|
+
function validateSnapshot(snapshot, taskId) {
|
|
11
|
+
if (!snapshot || snapshot.task?.id !== taskId || !Array.isArray(snapshot.task.evidence) || !snapshot.nextAction || !["run_phase", "await_user", "blocked", "terminal", "complete"].includes(snapshot.nextAction.classification)) throw new Error("invalid canonical task snapshot");
|
|
12
|
+
return snapshot;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Checkpoints contain a digest, not the task evidence or user-provided approval text. */
|
|
16
|
+
export function makeGateCheckpoint({ taskId, authority, snapshot, result }) {
|
|
17
|
+
validId(taskId, "task ID");
|
|
18
|
+
if (!["local", "fleet"].includes(authority)) throw new Error("invalid authority");
|
|
19
|
+
validateSnapshot(snapshot, taskId);
|
|
20
|
+
const gate = result?.gate ?? result?.nextAction?.gate;
|
|
21
|
+
if (!TARGET[gate] || snapshot.task.gate !== gate || snapshot.task.status !== "blocked_on_user" || snapshot.nextAction.classification !== "await_user" || (result.taskId && result.taskId !== taskId)) throw new Error("gate changed before supervisor checkpoint; inspect state manually");
|
|
22
|
+
if (authority === "fleet" && (!result.snapshotId || !Number.isSafeInteger(snapshot.revision))) throw new Error("fleet gate requires snapshot ID and revision");
|
|
23
|
+
return { version: 1, taskId, authority, gate, phase: snapshot.task.phase, taskHash: hash(snapshot.task), evidenceCount: snapshot.task.evidence.length, evidenceHash: hash(snapshot.task.evidence), ...(authority === "fleet" ? { snapshotId: result.snapshotId, revision: snapshot.revision } : {}) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Return wait/resume/blocked only from fresh canonical state, never grant approval. */
|
|
27
|
+
export async function checkGateCheckpoint({ checkpoint, snapshot, verifyFleetApproval, verifyLocalApproval }) {
|
|
28
|
+
if (!checkpoint || checkpoint.version !== 1 || !TARGET[checkpoint.gate] || !["local", "fleet"].includes(checkpoint.authority) || !Number.isSafeInteger(checkpoint.evidenceCount) || checkpoint.evidenceCount < 0 || typeof checkpoint.evidenceHash !== "string" || typeof checkpoint.taskHash !== "string") throw new Error("invalid gate checkpoint");
|
|
29
|
+
validId(checkpoint.taskId, "checkpoint task ID");
|
|
30
|
+
validateSnapshot(snapshot, checkpoint.taskId);
|
|
31
|
+
const { task, nextAction } = snapshot;
|
|
32
|
+
if (task.evidence.length < checkpoint.evidenceCount || hash(task.evidence.slice(0, checkpoint.evidenceCount)) !== checkpoint.evidenceHash) return { status: "blocked", reason: "gate evidence changed since checkpoint" };
|
|
33
|
+
if (task.status === "paused" || task.handoff || ["blocked", "terminal", "complete"].includes(nextAction.classification)) return { status: "blocked", reason: "task is paused, blocked, or terminal" };
|
|
34
|
+
if (task.phase === checkpoint.phase && task.gate === checkpoint.gate && task.status === "blocked_on_user" && nextAction.classification === "await_user") {
|
|
35
|
+
if (checkpoint.authority === "fleet" && hash(task) !== checkpoint.taskHash) return { status: "blocked", reason: "fleet review snapshot is stale" };
|
|
36
|
+
return { status: "wait" };
|
|
37
|
+
}
|
|
38
|
+
const appended = task.evidence.slice(checkpoint.evidenceCount);
|
|
39
|
+
const approvals = appended.filter((entry) => entry.kind === "approval" && entry.gate === checkpoint.gate && entry.result === "pass" && typeof entry.source === "string" && entry.source.trim());
|
|
40
|
+
if (task.phase !== TARGET[checkpoint.gate] || task.status !== "active" || nextAction.classification !== "run_phase" || approvals.length !== 1) return { status: "blocked", reason: "gate changed without its matching approval" };
|
|
41
|
+
if (checkpoint.authority === "local" && verifyLocalApproval && !await verifyLocalApproval({ checkpoint, snapshot })) return { status: "blocked", reason: "local approval lacks the matching host receipt" };
|
|
42
|
+
if (checkpoint.authority === "fleet") {
|
|
43
|
+
if (!checkpoint.snapshotId || !Number.isSafeInteger(checkpoint.revision) || !Number.isSafeInteger(snapshot.revision) || snapshot.revision <= checkpoint.revision) return { status: "blocked", reason: "fleet gate revision is stale" };
|
|
44
|
+
if (!await required(verifyFleetApproval, "verifyFleetApproval")({ checkpoint, snapshot })) return { status: "blocked", reason: "fleet approval does not match review snapshot" };
|
|
45
|
+
}
|
|
46
|
+
return { status: "resume", precondition: { taskId: checkpoint.taskId, gate: checkpoint.gate, phase: task.phase, taskHash: hash(task), evidenceCount: task.evidence.length, evidenceHash: hash(task.evidence), ...(checkpoint.authority === "fleet" ? { revision: snapshot.revision, snapshotId: checkpoint.snapshotId } : {}) } };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Supervises short runner invocations. runTurn MUST acquire its existing exclusive
|
|
51
|
+
* loop lease/lead epoch, reread canonical state, and enforce the supplied precondition
|
|
52
|
+
* before doing any work; if no precondition is supplied it MUST still verify
|
|
53
|
+
* run_phase under the lease. It MUST release the lease before returning a gate. Fleet
|
|
54
|
+
* runTurn additionally reconciles uncertain jobs and Git before dispatch. The
|
|
55
|
+
* checkpoint adapter MUST persist atomically, be scoped to task+authority, and be
|
|
56
|
+
* concurrency safe. acquireSupervisor MUST provide an independently fenced,
|
|
57
|
+
* renewable exclusive supervisor lease that does not block human gate approval.
|
|
58
|
+
* No callback is allowed to approve a human gate.
|
|
59
|
+
*/
|
|
60
|
+
export async function runSupervisor({ taskId, authority, acquireSupervisor, readTask, runTurn, loadCheckpoint, saveCheckpoint, clearCheckpoint, verifyFleetApproval, verifyLocalApproval, pollMs = 5000, signal, onEvent = () => {} }) {
|
|
61
|
+
validId(taskId, "task ID");
|
|
62
|
+
if (!["local", "fleet"].includes(authority)) throw new Error("invalid authority");
|
|
63
|
+
for (const [name, value] of Object.entries({ acquireSupervisor, readTask, runTurn, loadCheckpoint, saveCheckpoint, clearCheckpoint, onEvent })) required(value, name);
|
|
64
|
+
if (!Number.isSafeInteger(pollMs) || pollMs < 1 || pollMs > 300000) throw new Error("invalid poll interval");
|
|
65
|
+
const ownership = await acquireSupervisor({ taskId, authority, signal });
|
|
66
|
+
if (!ownership || typeof ownership.assertOwned !== "function" || typeof ownership.release !== "function") throw new Error("supervisor ownership must provide assertOwned and release");
|
|
67
|
+
try {
|
|
68
|
+
let checkpoint = await loadCheckpoint({ taskId, authority });
|
|
69
|
+
if (checkpoint && (checkpoint.taskId !== taskId || checkpoint.authority !== authority)) throw new Error("checkpoint authority or task mismatch");
|
|
70
|
+
while (!signal?.aborted) {
|
|
71
|
+
const snapshot = validateSnapshot(await readTask({ taskId }), taskId);
|
|
72
|
+
let precondition;
|
|
73
|
+
if (checkpoint) {
|
|
74
|
+
const verdict = await checkGateCheckpoint({ checkpoint, snapshot, verifyFleetApproval, verifyLocalApproval });
|
|
75
|
+
if (verdict.status === "blocked") return { status: "blocked", taskId, reason: verdict.reason };
|
|
76
|
+
if (verdict.status === "wait") {
|
|
77
|
+
try { await sleep(pollMs, undefined, { signal }); } catch (error) { if (error?.name !== "AbortError") throw error; }
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
precondition = verdict.precondition;
|
|
81
|
+
} else if (snapshot.nextAction.classification !== "run_phase") {
|
|
82
|
+
return { status: snapshot.nextAction.classification === "await_user" ? "blocked" : snapshot.nextAction.classification, taskId, reason: snapshot.nextAction.classification === "await_user" ? "gate checkpoint missing; inspect the gate before resuming" : snapshot.nextAction.reason };
|
|
83
|
+
}
|
|
84
|
+
// Leave the durable gate checkpoint intact until the next stop is saved. A
|
|
85
|
+
// crash during a turn then cannot cause an unverified automatic restart.
|
|
86
|
+
await ownership.assertOwned();
|
|
87
|
+
const result = await runTurn({ taskId, authority, precondition, signal });
|
|
88
|
+
if (result?.status !== "gate") {
|
|
89
|
+
if (checkpoint && ["complete", "terminal"].includes(result?.status)) await clearCheckpoint({ taskId, authority, checkpoint });
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
const fresh = validateSnapshot(await readTask({ taskId }), taskId);
|
|
93
|
+
const next = makeGateCheckpoint({ taskId, authority, snapshot: fresh, result });
|
|
94
|
+
await ownership.assertOwned();
|
|
95
|
+
await saveCheckpoint({ taskId, authority, checkpoint: next, previous: checkpoint });
|
|
96
|
+
checkpoint = next;
|
|
97
|
+
await onEvent({ type: "gate", taskId, gate: next.gate, ...(next.snapshotId ? { snapshotId: next.snapshotId } : {}), gateView: result.gateView });
|
|
98
|
+
}
|
|
99
|
+
return { status: "stopped", taskId };
|
|
100
|
+
} finally { await ownership.release(); }
|
|
101
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { lstatSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { sep } from 'node:path';
|
|
3
|
+
import { isLoopProvider } from './loop-drivers.mjs';
|
|
4
|
+
|
|
5
|
+
/** Container workers receive credentials as environment keys only. OpenCode
|
|
6
|
+
* authenticates from a file-based data store, so it fails closed here. */
|
|
7
|
+
export const OPENCODE_CONTAINER_ERROR = 'OpenCode workers require file-based credentials and are not available in isolated containers yet';
|
|
8
|
+
|
|
9
|
+
/** Build a Docker invocation. This function never accepts a shell command or credentials in arguments. */
|
|
10
|
+
export function workerDockerInvocation({ workspace, image, command, args, credentialEnv, hostEnv, uid, gid, containerName, sessionHome }) {
|
|
11
|
+
const root = realpathSync(workspace);
|
|
12
|
+
if (/[\x00-\x1f,]/.test(root)) throw new Error('Invalid Docker workspace path');
|
|
13
|
+
if (typeof image !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,255}$/.test(image)) throw new Error('Explicit Docker image is required');
|
|
14
|
+
if (command === 'opencode') throw new Error(OPENCODE_CONTAINER_ERROR);
|
|
15
|
+
if (!isLoopProvider(command) || !Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) throw new Error('Invalid worker provider command');
|
|
16
|
+
if (!Number.isSafeInteger(uid) || uid < 1 || !Number.isSafeInteger(gid) || gid < 1) throw new Error('Non-root worker uid and gid are required');
|
|
17
|
+
if (!hostEnv || typeof hostEnv !== 'object' || !Array.isArray(credentialEnv) || !credentialEnv.length || credentialEnv.some((key) => !/^[A-Z][A-Z0-9_]*$/.test(key) || typeof hostEnv[key] !== 'string' || !hostEnv[key])) throw new Error('Explicit worker provider credentials are required');
|
|
18
|
+
const allowed = command === 'codex' ? ['OPENAI_API_KEY'] : ['ANTHROPIC_API_KEY'];
|
|
19
|
+
if (credentialEnv.some((key) => !allowed.includes(key)) || new Set(credentialEnv).size !== credentialEnv.length) throw new Error('Unsupported worker credential environment');
|
|
20
|
+
if (containerName !== undefined && (typeof containerName !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(containerName))) throw new Error('Invalid container name');
|
|
21
|
+
let homeMount;
|
|
22
|
+
if (sessionHome !== undefined) {
|
|
23
|
+
const info = lstatSync(sessionHome);
|
|
24
|
+
if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== uid || (info.mode & 0o077)) throw new Error('Private worker session HOME is required');
|
|
25
|
+
const home = realpathSync(sessionHome);
|
|
26
|
+
if (/[\x00-\x1f,]/.test(home) || home === root || home.startsWith(`${root}${sep}`) || root.startsWith(`${home}${sep}`)) throw new Error('Worker session HOME must be separate from workspace');
|
|
27
|
+
homeMount = `type=bind,src=${home},dst=/home/worker`;
|
|
28
|
+
}
|
|
29
|
+
const env = { PATH: hostEnv.PATH ?? '/usr/bin:/bin' };
|
|
30
|
+
for (const key of credentialEnv) env[key] = hostEnv[key];
|
|
31
|
+
return { command: 'docker', args: ['run', '--rm', '--interactive', ...(containerName ? ['--name', containerName] : []), '--read-only', '--user', `${uid}:${gid}`, '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--pids-limit', '256', '--memory', '2g', '--cpus', '2', '--tmpfs', '/tmp:rw,nosuid,nodev,size=256m', ...(homeMount ? ['--mount', homeMount] : ['--tmpfs', `/home/worker:rw,nosuid,nodev,size=128m,uid=${uid},gid=${gid}`]), '--env', 'HOME=/home/worker', '--workdir', '/workspace', '--mount', `type=bind,src=${root},dst=/workspace`, ...credentialEnv.flatMap((key) => ['--env', key]), '--entrypoint', command, image, ...args], env };
|
|
32
|
+
}
|