@felixdotgo/aidlc-kit 0.1.0 → 0.2.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 +14 -0
- package/README.md +14 -6
- package/dist/assets/.agents/aidlc/docker/Dockerfile.claude +7 -0
- package/dist/assets/.agents/aidlc/docker/Dockerfile.codex +9 -0
- package/dist/assets/.agents/aidlc/docker/Dockerfile.opencode +8 -0
- package/dist/assets/.agents/aidlc/orchestrator.md +1 -1
- package/dist/assets/.agents/aidlc/phase-clarify.md +1 -1
- package/dist/assets/.agents/aidlc/phase-plan.md +2 -2
- package/dist/assets/.agents/aidlc/schemas/config.schema.json +2 -1
- package/dist/assets/.agents/aidlc/schemas/manifest.schema.json +1 -1
- package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +5 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +245 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-commit-message.mjs +45 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +232 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +21 -4
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +86 -28
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +7 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +22 -6
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +46 -15
- package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +22 -9
- package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +23 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +43 -15
- package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +82 -6
- package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +60 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +142 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +9 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +31 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-image.mjs +64 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-snapshot.mjs +3 -2
- package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +59 -4
- package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +85 -73
- package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +19 -7
- package/dist/assets/.agents/aidlc/scripts/loop.mjs +2 -1
- package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +2 -0
- package/dist/assets/.agents/aidlc/scripts/state.mjs +4 -0
- package/dist/assets/.agents/aidlc/templates/design.md +9 -0
- package/dist/assets/.agents/aidlc/templates/intent.md +4 -0
- package/dist/assets/.agents/aidlc/templates/workplan.md +1 -1
- package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +1 -1
- package/dist/assets/ui/bulma.min.css +3 -0
- package/dist/assets/ui/dashboard.css +414 -0
- package/dist/services/aidlc-coordination/Dockerfile +11 -3
- package/dist/services/aidlc-coordination/compose.yaml +12 -0
- package/dist/services/aidlc-coordination/package-lock.json +3 -1
- package/dist/services/aidlc-coordination/package.json +9 -4
- package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +59 -4
- package/dist/services/aidlc-coordination/src/repository.mjs +66 -0
- package/dist/services/aidlc-coordination/src/server.mjs +9 -3
- package/dist/services/aidlc-coordination/test/fleet.test.mjs +21 -0
- package/dist/services/aidlc-coordination/test/server-health-auth.test.mjs +67 -0
- package/dist/src/adapters.d.ts +0 -3
- package/dist/src/adapters.js +11 -6
- package/dist/src/cli/argv.d.ts +12 -0
- package/dist/src/cli/argv.js +62 -0
- package/dist/src/cli/commands.d.ts +8 -0
- package/dist/src/cli/commands.js +129 -0
- package/dist/src/cli/options.d.ts +25 -0
- package/dist/src/cli/options.js +32 -0
- package/dist/src/cli/prompt.d.ts +9 -0
- package/dist/src/cli/prompt.js +66 -0
- package/dist/src/cli.js +67 -217
- package/dist/src/context.js +2 -0
- package/dist/src/gate.d.ts +1 -0
- package/dist/src/gate.js +51 -37
- package/dist/src/installer.d.ts +19 -7
- package/dist/src/installer.js +111 -178
- package/dist/src/legacy.js +1 -0
- package/dist/src/managed-content.d.ts +7 -0
- package/dist/src/managed-content.js +15 -4
- package/dist/src/managed-planning.d.ts +65 -0
- package/dist/src/managed-planning.js +219 -0
- package/dist/src/mcp-setup.js +3 -1
- package/dist/src/model/config-contracts.d.ts +65 -0
- package/dist/src/model/config-contracts.js +1 -0
- package/dist/src/model/install-contracts.d.ts +29 -0
- package/dist/src/model/install-contracts.js +1 -0
- package/dist/src/model/lifecycle-enums.d.ts +13 -0
- package/dist/src/model/lifecycle-enums.js +1 -0
- package/dist/src/model/loop.d.ts +20 -0
- package/dist/src/model/loop.js +1 -0
- package/dist/src/model/manifest.d.ts +16 -0
- package/dist/src/model/manifest.js +1 -0
- package/dist/src/model/records.d.ts +138 -0
- package/dist/src/model/records.js +1 -0
- package/dist/src/model.d.ts +11 -264
- package/dist/src/model.js +1 -1
- package/dist/src/profiles.d.ts +8 -0
- package/dist/src/profiles.js +63 -8
- package/dist/src/project-migration.d.ts +1 -0
- package/dist/src/project-migration.js +7 -0
- package/dist/src/state/archive-record.d.ts +9 -0
- package/dist/src/state/archive-record.js +26 -0
- package/dist/src/state/commands.d.ts +18 -0
- package/dist/src/state/commands.js +27 -0
- package/dist/src/state/domain.d.ts +9 -0
- package/dist/src/state/domain.js +9 -0
- package/dist/src/state/guards.d.ts +7 -0
- package/dist/src/state/guards.js +17 -0
- package/dist/src/state/io.d.ts +10 -0
- package/dist/src/state/io.js +35 -0
- package/dist/src/state/items.d.ts +24 -0
- package/dist/src/state/items.js +147 -0
- package/dist/src/state/lessons.d.ts +7 -0
- package/dist/src/state/lessons.js +74 -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 +125 -0
- package/dist/src/state/memory.d.ts +8 -0
- package/dist/src/state/memory.js +100 -0
- package/dist/src/state/migrate.d.ts +17 -0
- package/dist/src/state/migrate.js +60 -0
- package/dist/src/state/partition.d.ts +15 -0
- package/dist/src/state/partition.js +90 -0
- package/dist/src/state/paths.d.ts +11 -0
- package/dist/src/state/paths.js +12 -0
- package/dist/src/state/render.d.ts +26 -0
- package/dist/src/state/render.js +32 -0
- package/dist/src/state/store.d.ts +4 -0
- package/dist/src/state/store.js +75 -0
- package/dist/src/state/transitions.d.ts +21 -0
- package/dist/src/state/transitions.js +95 -0
- package/dist/src/state/validate.d.ts +4 -0
- package/dist/src/state/validate.js +143 -0
- package/dist/src/state.d.ts +20 -117
- package/dist/src/state.js +20 -815
- package/dist/src/task-ui.d.ts +54 -0
- package/dist/src/task-ui.js +1570 -0
- package/dist/src/upgrade-board.d.ts +5 -0
- package/dist/src/upgrade-board.js +89 -0
- package/dist/src/upgrade.d.ts +3 -5
- package/dist/src/upgrade.js +174 -255
- package/dist/src/workflow.js +4 -4
- package/docs/README.md +6 -2
- package/docs/architecture.md +12 -8
- package/docs/command-reference.md +83 -19
- package/docs/configuration.md +30 -7
- package/docs/coordination-service.md +20 -5
- package/docs/design-decisions.md +83 -0
- package/docs/development.md +41 -2
- package/docs/loop-local-run.md +495 -0
- package/docs/loop-runbook.md +235 -0
- package/docs/operating-workflow.md +5 -2
- package/docs/testing-and-release.md +11 -7
- package/docs/troubleshooting.md +125 -0
- package/package.json +5 -2
- package/dist/src/ledger.d.ts +0 -22
- package/dist/src/ledger.js +0 -119
- package/dist/src/tracker.d.ts +0 -29
- package/dist/src/tracker.js +0 -41
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet coordination: job reconciliation, coordinator loop, and worker loop
|
|
3
|
+
* for multi-host AI-DLC execution.
|
|
4
|
+
*/
|
|
1
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { readyTaskItems, formatGateView } from "./store.mjs";
|
|
6
|
+
import { readyTaskItems, formatGateView, pathsOverlap } from "./store.mjs";
|
|
3
7
|
import { isLoopProvider, LOOP_PROVIDER_LIST } from "./loop-drivers.mjs";
|
|
4
8
|
|
|
5
9
|
export const FLEET_LIMITS = Object.freeze({ maxTurns: 12, turnTimeoutMs: 600_000, maxStallTurns: 2, maxWorkers: 4, leaseMs: 300_000, heartbeatMs: 60_000 });
|
|
@@ -33,9 +37,15 @@ function readyItems(task) {
|
|
|
33
37
|
const ready = new Set(readyTaskItems(task).ready);
|
|
34
38
|
return items.filter((item) => item.status === "todo" && ready.has(item.id));
|
|
35
39
|
}
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
const
|
|
40
|
+
/** Sleep `ms`, resolving early (never rejecting) once any given signal aborts. */
|
|
41
|
+
const wait = (ms, ...signals) => new Promise((resolve) => {
|
|
42
|
+
const live = signals.filter(Boolean);
|
|
43
|
+
if (live.some((signal) => signal.aborted)) return resolve();
|
|
44
|
+
const done = () => { clearTimeout(timer); for (const signal of live) signal.removeEventListener("abort", done); resolve(); };
|
|
45
|
+
const timer = setTimeout(done, ms);
|
|
46
|
+
for (const signal of live) signal.addEventListener("abort", done, { once: true });
|
|
47
|
+
});
|
|
48
|
+
const anySignal = (...signals) => { const live = signals.filter(Boolean); return live.length === 1 ? live[0] : AbortSignal.any(live); };
|
|
39
49
|
|
|
40
50
|
function key(...parts) { return digest(parts); }
|
|
41
51
|
|
|
@@ -109,13 +119,19 @@ async function withHeartbeat(renew, intervalMs, work) {
|
|
|
109
119
|
/**
|
|
110
120
|
* One fenced coordinator invocation. The repository is the remote authority;
|
|
111
121
|
* no local lifecycle file or PID lease is consulted in fleet mode.
|
|
122
|
+
*
|
|
123
|
+
* `supervisorSignal` reports a lost supervisor lease (aborts the turn and
|
|
124
|
+
* fails). `stopSignal` requests a graceful stop at the next turn boundary;
|
|
125
|
+
* `cancelSignal` additionally aborts the in-flight lead turn. Both end as
|
|
126
|
+
* `{ status: "stopped" }` without changing canonical task state.
|
|
112
127
|
*/
|
|
113
|
-
export async function runFleetCoordinator({ repository, taskId, ownerId = randomUUID(), workerIds = [], workerProviders, reservedJobs = [], allowCodeOnlyRetry = false, limits: inputLimits, pollMs = 5000, pickupTimeoutMs: inputPickupTimeout, packet, driver, gateView, handoff, completeItem, claimedLead, supervisorSignal, onEvent = () => {} }) {
|
|
128
|
+
export async function runFleetCoordinator({ repository, taskId, ownerId = randomUUID(), workerIds = [], workerProviders, reservedJobs = [], allowCodeOnlyRetry = false, limits: inputLimits, pollMs = 5000, pickupTimeoutMs: inputPickupTimeout, packet, driver, gateView, handoff, completeItem, claimedLead, supervisorSignal, stopSignal, cancelSignal, onEvent = () => {} }) {
|
|
114
129
|
assertId(taskId, "task ID"); assertId(ownerId, "owner ID");
|
|
115
130
|
if (!Array.isArray(workerIds) || new Set(workerIds).size !== workerIds.length) throw new Error("workerIds must be distinct IDs");
|
|
116
131
|
for (const workerId of workerIds) assertId(workerId, "worker ID");
|
|
117
132
|
if (workerProviders !== undefined && (!workerProviders || typeof workerProviders !== "object" || Array.isArray(workerProviders) || Object.keys(workerProviders).length !== workerIds.length || workerIds.some((workerId) => !isLoopProvider(workerProviders[workerId])) || Object.keys(workerProviders).some((workerId) => !workerIds.includes(workerId)))) throw new Error(`workerProviders must map each configured worker ID to ${LOOP_PROVIDER_LIST}`);
|
|
118
133
|
const limits = validateFleetLimits(inputLimits);
|
|
134
|
+
// --- Validate limits and normalize inputs ---------------------------------
|
|
119
135
|
integer(pollMs, "pollMs", 300000);
|
|
120
136
|
const pickupTimeoutMs = integer(inputPickupTimeout ?? limits.leaseMs, "pickupTimeoutMs", 3_600_000);
|
|
121
137
|
const readTask = required(repository, "readTask");
|
|
@@ -134,15 +150,19 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
|
|
|
134
150
|
const readySeen = new Map();
|
|
135
151
|
try {
|
|
136
152
|
return await withHeartbeat(() => renewLead({ taskId, ownerId, epoch, leaseMs: limits.leaseMs }), limits.heartbeatMs, async (leaseSignal) => {
|
|
137
|
-
const
|
|
153
|
+
const leaseLost = anySignal(leaseSignal, supervisorSignal);
|
|
154
|
+
const signal = anySignal(leaseLost, cancelSignal);
|
|
155
|
+
const stopping = () => Boolean(stopSignal?.aborted || cancelSignal?.aborted);
|
|
156
|
+
const stopped = (reason) => ({ status: "stopped", taskId, reason, revision: snapshot.revision });
|
|
138
157
|
for (;;) {
|
|
139
|
-
if (
|
|
158
|
+
if (leaseLost.aborted) throw new Error("Lead lease renewal failed");
|
|
159
|
+
if (stopping()) return stopped(cancelSignal?.aborted ? "cancel requested" : "stop requested");
|
|
140
160
|
if (Date.now() - startedAt >= limits.maxTurns * limits.turnTimeoutMs) return { status: "paused", taskId, reason: "coordinator wall-time limit reached" };
|
|
141
161
|
snapshot = validateSnapshot(await readTask({ taskId }), taskId);
|
|
142
162
|
const kind = actionKind(snapshot.nextAction);
|
|
143
163
|
if (kind === "await_user") {
|
|
144
164
|
const reconciled = await reconcileFleetJobs({ repository, taskId, leadEpoch: epoch, snapshot, handoff, allowCodeOnlyRetry, quiesceMs: limits.turnTimeoutMs + limits.heartbeatMs + 1000 });
|
|
145
|
-
if (reconciled.pending.length && reconciled.pending.every((entry) => entry.waitUntil)) { await wait(pollMs); continue; }
|
|
165
|
+
if (reconciled.pending.length && reconciled.pending.every((entry) => entry.waitUntil)) { await wait(pollMs, signal, stopSignal); continue; }
|
|
146
166
|
if (reconciled.pending.length) return { status: "reconcile", taskId, revision: snapshot.revision, pending: reconciled.pending };
|
|
147
167
|
const gate = snapshot.nextAction.gate;
|
|
148
168
|
const baseReview = await (gateView ?? ((value) => formatGateView(value.task, [], "markdown")))(snapshot);
|
|
@@ -158,7 +178,7 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
|
|
|
158
178
|
const before = digest({ revision: snapshot.revision, action: snapshot.nextAction });
|
|
159
179
|
if (kind === "dispatch" || (kind === "run_phase" && snapshot.task.phase === "build")) {
|
|
160
180
|
const reconciled = await reconcileFleetJobs({ repository, taskId, leadEpoch: epoch, snapshot, handoff, allowCodeOnlyRetry, quiesceMs: limits.turnTimeoutMs + limits.heartbeatMs + 1000 });
|
|
161
|
-
if (reconciled.pending.length && reconciled.pending.every((entry) => entry.waitUntil)) { await wait(pollMs); continue; }
|
|
181
|
+
if (reconciled.pending.length && reconciled.pending.every((entry) => entry.waitUntil)) { await wait(pollMs, signal, stopSignal); continue; }
|
|
162
182
|
if (reconciled.pending.length) {
|
|
163
183
|
const verified = reconciled.pending.filter((entry) => entry.artifact?.valid);
|
|
164
184
|
if (verified.length && typeof handoff?.integrate === "function" && typeof completeItem === "function") {
|
|
@@ -207,7 +227,7 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
|
|
|
207
227
|
}
|
|
208
228
|
}
|
|
209
229
|
onEvent({ type: "jobs_active", taskId, assignments: active.map((job) => job.assignmentId) });
|
|
210
|
-
await wait(pollMs);
|
|
230
|
+
await wait(pollMs, signal, stopSignal);
|
|
211
231
|
continue;
|
|
212
232
|
}
|
|
213
233
|
}
|
|
@@ -215,7 +235,11 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
|
|
|
215
235
|
if (typeof packet !== "function" || typeof driver !== "function") throw new Error("Fleet lead requires packet and driver adapters");
|
|
216
236
|
const prompt = await packet(snapshot, epoch);
|
|
217
237
|
if (typeof prompt !== "string" || !prompt) throw new Error("Empty fleet lead packet");
|
|
218
|
-
await driver({ taskId, snapshot, leadEpoch: epoch, prompt, timeoutMs: limits.turnTimeoutMs, signal });
|
|
238
|
+
try { await driver({ taskId, snapshot, leadEpoch: epoch, prompt, timeoutMs: limits.turnTimeoutMs, signal }); }
|
|
239
|
+
catch (error) {
|
|
240
|
+
if (!leaseLost.aborted && cancelSignal?.aborted) return stopped("cancelled the in-flight lead turn");
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
219
243
|
turns++;
|
|
220
244
|
const after = validateSnapshot(await readTask({ taskId }), taskId);
|
|
221
245
|
const nextSignal = digest({ revision: after.revision, action: after.nextAction });
|
|
@@ -229,11 +253,14 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
|
|
|
229
253
|
}
|
|
230
254
|
}
|
|
231
255
|
|
|
232
|
-
/** A worker claims one attempt, persists its exact CLI session, and reports a Git artifact.
|
|
233
|
-
|
|
256
|
+
/** A worker claims one attempt, persists its exact CLI session, and reports a Git artifact.
|
|
257
|
+
* `cancelSignal` aborts the in-flight worker turn; the attempt is then abandoned
|
|
258
|
+
* (service state `uncertain`, recoverable by the lead) and the result is `stopped`. */
|
|
259
|
+
export async function runFleetWorker({ repository, workerId = randomUUID(), workerAgent, preflight, limits: inputLimits, driver, packet, handoff, cancelSignal, onEvent = () => {} }) {
|
|
234
260
|
assertId(workerId, "worker ID");
|
|
235
261
|
if (workerAgent !== undefined && !isLoopProvider(workerAgent)) throw new Error(`workerAgent must be ${LOOP_PROVIDER_LIST}`);
|
|
236
262
|
const limits = validateFleetLimits(inputLimits);
|
|
263
|
+
// --- Validate limits and bind adapter methods ------------------------------
|
|
237
264
|
const claimJob = required(repository, "claimJob");
|
|
238
265
|
const renewJob = required(repository, "renewJob");
|
|
239
266
|
const checkpointJob = required(repository, "checkpointJob");
|
|
@@ -253,12 +280,14 @@ export async function runFleetWorker({ repository, workerId = randomUUID(), work
|
|
|
253
280
|
let turns = 0, stalls = 0;
|
|
254
281
|
const report = async (outcome) => reportJob({ ...identity, report: outcome, idempotencyKey: key("job-report", job.assignmentId, job.attempt, job.epoch, outcome.status, outcome.commitSha ?? "") });
|
|
255
282
|
return withHeartbeat(() => renewJob({ ...identity, leaseMs: limits.leaseMs }), limits.heartbeatMs, async (signal) => {
|
|
283
|
+
const cancelled = async () => { await report({ status: "uncertain", reason: "worker turn cancelled", sessionId }); return { status: "stopped", assignmentId: job.assignmentId }; };
|
|
256
284
|
if (typeof driver !== "function" || typeof packet !== "function" || typeof handoff?.prepare !== "function" || typeof handoff?.observe !== "function" || typeof handoff?.finish !== "function") throw new Error("Worker requires driver, packet, and Git handoff adapters");
|
|
257
285
|
const workspace = await handoff.prepare(job);
|
|
258
286
|
if (!workspace?.root) throw new Error("Git handoff did not supply a worker root");
|
|
259
287
|
let lastObservation = null;
|
|
260
288
|
for (;;) {
|
|
261
289
|
if (signal.aborted) throw new Error("Job lease renewal failed");
|
|
290
|
+
if (cancelSignal?.aborted) return cancelled();
|
|
262
291
|
const snapshot = validateSnapshot(await readTask({ taskId: job.taskId }), job.taskId);
|
|
263
292
|
const item = snapshot.task.tasks?.find((candidate) => candidate.id === job.itemId);
|
|
264
293
|
if (!item || item.status === "done") {
|
|
@@ -276,11 +305,13 @@ export async function runFleetWorker({ repository, workerId = randomUUID(), work
|
|
|
276
305
|
}
|
|
277
306
|
const prompt = await packet({ job, snapshot, workspace, turns });
|
|
278
307
|
if (typeof prompt !== "string" || !prompt) throw new Error("Empty worker packet");
|
|
279
|
-
|
|
308
|
+
let result;
|
|
309
|
+
try { result = await driver({ agent: job.agent, root: workspace.root, sessionId: sessionId ?? undefined, prompt: `${prompt}\n\nWorker scope: modify only assigned paths. Do not mutate AI-DLC lifecycle state or approve any gate. When the scoped implementation is ready for commit, end the final answer with a Conventional Commits message on its own line as AI-DLC-COMMIT: type(scope): subject (for example AI-DLC-COMMIT: docs(ui): add web UI improvement proposal), followed by AI-DLC-ITEM-COMPLETE on its own line.`, timeoutMs: limits.turnTimeoutMs, signal: anySignal(signal, cancelSignal), onSessionId: async (id) => {
|
|
280
310
|
if (typeof id !== "string" || !id) throw new Error("Invalid worker session ID");
|
|
281
311
|
if (sessionId && sessionId !== id) throw new Error("Worker session changed during the same assignment");
|
|
282
312
|
sessionId = id;
|
|
283
|
-
}, onEvent });
|
|
313
|
+
}, onEvent }); }
|
|
314
|
+
catch (error) { if (!signal.aborted && cancelSignal?.aborted) return cancelled(); throw error; }
|
|
284
315
|
if (result?.sessionId && sessionId && result.sessionId !== sessionId) throw new Error("Worker session changed during the same assignment");
|
|
285
316
|
if (result?.sessionId && !sessionId) {
|
|
286
317
|
sessionId = result.sessionId;
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git handoff helpers: create lead/worker/isolated worktrees, sync the
|
|
3
|
+
* integration branch, and produce scoped change reports.
|
|
4
|
+
*/
|
|
1
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
6
|
import { execFileSync } from "node:child_process";
|
|
3
7
|
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync } from "node:fs";
|
|
4
8
|
import { join, resolve } from "node:path";
|
|
5
9
|
import { isLoopProvider } from "./loop-drivers.mjs";
|
|
10
|
+
import { extractCommitMessage } from "./loop-commit-message.mjs";
|
|
6
11
|
import { createWorkerSnapshot, inspectWorkerSnapshot } from "./loop-worker-snapshot.mjs";
|
|
7
12
|
|
|
8
13
|
const SHA = /^[0-9a-f]{40,64}$/;
|
|
@@ -30,9 +35,9 @@ function validPath(value, label = "path") {
|
|
|
30
35
|
}
|
|
31
36
|
function scopes(input) {
|
|
32
37
|
if (!Array.isArray(input) || input.length === 0) throw new Error("Assignment requires nonempty ownedPaths");
|
|
33
|
-
return [...new Set(input.map((path) => validPath(path, "owned path")))].sort();
|
|
38
|
+
return [...new Set(input.map((path) => validPath(path, "owned path").replace(/\/+$/, "")))].filter(Boolean).sort();
|
|
34
39
|
}
|
|
35
|
-
function inScope(path, ownedPaths) { return ownedPaths.some((scope) => path === scope ||
|
|
40
|
+
function inScope(path, ownedPaths) { return ownedPaths.some((scope) => path === scope || path.startsWith(`${scope}/`)); }
|
|
36
41
|
function changedPaths(root, base, head) {
|
|
37
42
|
const raw = execFileSync("git", ["diff", "--no-renames", "--name-only", "-z", base, head], { cwd: root, encoding: "buffer", timeout: 30_000, maxBuffer: 4 * 1024 * 1024 });
|
|
38
43
|
return raw.toString("utf8").split("\0").filter(Boolean).map((path) => validPath(path, "changed path")).sort();
|
|
@@ -72,10 +77,16 @@ function fetchBranch(root, remote, branch) {
|
|
|
72
77
|
return git(root, "rev-parse", remoteRef(remote, branch));
|
|
73
78
|
}
|
|
74
79
|
function ancestor(root, base, head) {
|
|
75
|
-
try { git(root, "merge-base", "--is-ancestor", base, head); return true; } catch { return false; }
|
|
80
|
+
try { git(root, "merge-base", "--is-ancestor", base, head); return true; } catch { return false; } /* intentional: non-git/non-worktree is treated as not clean */
|
|
76
81
|
}
|
|
82
|
+
// Host-local loop operational files (daemon lock/log, UI event sidecar) live in
|
|
83
|
+
// the lead checkout but are never part of a handoff or integration.
|
|
84
|
+
const LOOP_OPERATIONAL_PREFIX = ".agents/data/loop/";
|
|
77
85
|
function assertClean(root) {
|
|
78
|
-
|
|
86
|
+
// Not via git(): its trim() would eat the leading status column of the first row.
|
|
87
|
+
const status = execFileSync("git", ["status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"], { cwd: root, encoding: "utf8", timeout: 30_000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
|
|
88
|
+
const dirty = status.split("\0").filter(Boolean).map((row) => row.slice(3)).filter((path) => !path.startsWith(LOOP_OPERATIONAL_PREFIX));
|
|
89
|
+
if (dirty.length) throw new Error("Git worktree must be clean before handoff or integration");
|
|
79
90
|
}
|
|
80
91
|
function normaliseReport(job) {
|
|
81
92
|
const report = job.report ?? job;
|
|
@@ -87,6 +98,7 @@ function normaliseReport(job) {
|
|
|
87
98
|
|
|
88
99
|
/** The coordinator calls this from its protected integration checkout. */
|
|
89
100
|
export function createLeadGitHandoff({ root: inputRoot, integrationBranch, remote = "origin", agent = "codex" }) {
|
|
101
|
+
// --- Validate inputs and resolve the integration checkout ------------------
|
|
90
102
|
const root = resolve(inputRoot);
|
|
91
103
|
validBranch(integrationBranch); validId(remote, "remote");
|
|
92
104
|
if (!isLoopProvider(agent)) throw new Error("Invalid worker agent");
|
|
@@ -209,7 +221,7 @@ export function createWorkerGitHandoff({ root: inputRoot, worktreesRoot: inputWo
|
|
|
209
221
|
const done = typeof result?.resultText === "string" && result.resultText.split(/\r?\n/).some((line) => line.trim() === "AI-DLC-ITEM-COMPLETE");
|
|
210
222
|
return { done, fingerprint: worktreeFingerprint(workspace.root, changed) };
|
|
211
223
|
},
|
|
212
|
-
async finish({ job, workspace }) {
|
|
224
|
+
async finish({ job, workspace, result }) {
|
|
213
225
|
const branch = validBranch(job.branch);
|
|
214
226
|
if (git(workspace.root, "symbolic-ref", "--short", "HEAD") !== branch) throw new Error("Worker switched away from assigned branch");
|
|
215
227
|
const ownedPaths = scopes(job.ownedPaths);
|
|
@@ -217,7 +229,8 @@ export function createWorkerGitHandoff({ root: inputRoot, worktreesRoot: inputWo
|
|
|
217
229
|
checkScope(dirty, ownedPaths);
|
|
218
230
|
if (dirty.length) {
|
|
219
231
|
git(workspace.root, "add", "-A", "--", ...dirty);
|
|
220
|
-
|
|
232
|
+
const message = extractCommitMessage(result?.resultText, { itemId: job.itemId, ownedPaths });
|
|
233
|
+
git(workspace.root, "-c", "user.name=AI-DLC worker", "-c", "user.email=aidlc-worker@localhost", "commit", "-m", message);
|
|
221
234
|
}
|
|
222
235
|
const commitSha = git(workspace.root, "rev-parse", "HEAD");
|
|
223
236
|
const base = validSha(job.baseCommit, "base commit");
|
|
@@ -254,7 +267,7 @@ export function createIsolatedWorkerGitHandoff(options) {
|
|
|
254
267
|
const done = typeof result?.resultText === "string" && result.resultText.split(/\r?\n/).some((line) => line.trim() === "AI-DLC-ITEM-COMPLETE");
|
|
255
268
|
return {done, fingerprint};
|
|
256
269
|
},
|
|
257
|
-
async finish({job, workspace}) {
|
|
270
|
+
async finish({job, workspace, result}) {
|
|
258
271
|
const changed=inspectWorkerSnapshot(workspace.root, workspace.baseline, job.ownedPaths);
|
|
259
272
|
if (!changed.length) throw new Error("Worker produced no changes");
|
|
260
273
|
if (worktreePaths(workspace.checkout.root).length || git(workspace.checkout.root,"rev-parse","HEAD") !== job.baseCommit) throw new Error("Host checkout changed during worker run");
|
|
@@ -271,8 +284,8 @@ export function createIsolatedWorkerGitHandoff(options) {
|
|
|
271
284
|
if (!existsSync(source)) { rmSync(target,{force:true}); continue; }
|
|
272
285
|
copyFileSync(source,target); chmodSync(target,lstatSync(source).mode & 0o777);
|
|
273
286
|
}
|
|
274
|
-
const
|
|
275
|
-
rmSync(workspace.root,{recursive:true,force:true}); return
|
|
287
|
+
const report=await host.finish({job,workspace:workspace.checkout,result});
|
|
288
|
+
rmSync(workspace.root,{recursive:true,force:true}); return report;
|
|
276
289
|
},
|
|
277
290
|
async discard({workspace}) { rmSync(workspace.root,{recursive:true,force:true}); return host.discard({workspace:workspace.checkout}); }
|
|
278
291
|
};
|
|
@@ -5,8 +5,26 @@ import { join, resolve } from "node:path";
|
|
|
5
5
|
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
6
6
|
const digest = (value) => createHash("sha256").update(value).digest("hex");
|
|
7
7
|
|
|
8
|
+
const firstLine = (text) => {
|
|
9
|
+
if (typeof text !== "string") return "";
|
|
10
|
+
return text.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0) ?? "";
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const slugify = (text) => {
|
|
14
|
+
const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+/, "").slice(0, 127).replace(/-+$/, "");
|
|
15
|
+
return slug || "loop-task";
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Derive a memorable task id (kebab-case slug) and a descriptive title from the
|
|
19
|
+
* requirement's first non-empty line, replacing the opaque `loop-<uuid>` id and
|
|
20
|
+
* the unhelpful `Loop task <id>` title. */
|
|
21
|
+
export function deriveTaskIdentity(requirement) {
|
|
22
|
+
const line = firstLine(requirement);
|
|
23
|
+
return { slug: slugify(line), title: line ? line.slice(0, 256) : "Loop task" };
|
|
24
|
+
}
|
|
25
|
+
|
|
8
26
|
/** Prepare a durable, idempotent intake without treating it as lifecycle state. */
|
|
9
|
-
export async function prepareLoopIntake({ root, taskId, requirement, authority, createTask, readTask }) {
|
|
27
|
+
export async function prepareLoopIntake({ root, taskId, requirement, authority, createTask, readTask, title }) {
|
|
10
28
|
if (!ID.test(taskId ?? "")) throw new Error("Invalid task ID");
|
|
11
29
|
if (!["local", "fleet"].includes(authority)) throw new Error("Invalid loop authority");
|
|
12
30
|
if (typeof requirement !== "string" || !requirement.trim() || Buffer.byteLength(requirement) > 8192) throw new Error("Requirement must contain 1–8192 bytes");
|
|
@@ -39,13 +57,13 @@ export async function prepareLoopIntake({ root, taskId, requirement, authority,
|
|
|
39
57
|
const existing = lstatSync(requirementPath);
|
|
40
58
|
if (!existing.isFile() || existing.isSymbolicLink() || digest(readFileSync(requirementPath, "utf8")) !== record.requirementHash) throw new Error("Existing requirement does not match intake");
|
|
41
59
|
}
|
|
42
|
-
const
|
|
60
|
+
const titleText = typeof title === "string" && title.trim() ? title.trim().slice(0, 256) : deriveTaskIdentity(requirement).title;
|
|
43
61
|
let task = await readTask(taskId);
|
|
44
62
|
if (!task) {
|
|
45
|
-
try { await createTask({ taskId, title }); }
|
|
46
|
-
catch (error) { task = await readTask(taskId); if (!task || task.title !==
|
|
63
|
+
try { await createTask({ taskId, title: titleText }); }
|
|
64
|
+
catch (error) { task = await readTask(taskId); if (!task || task.title !== titleText) throw error; }
|
|
47
65
|
task ??= await readTask(taskId);
|
|
48
66
|
}
|
|
49
|
-
if (!task || task.id !== taskId || task.title !==
|
|
67
|
+
if (!task || task.id !== taskId || task.title !== titleText) throw new Error("Existing task conflicts with intake");
|
|
50
68
|
return { taskId, authority, requirementPath };
|
|
51
69
|
}
|
|
@@ -4,15 +4,16 @@ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
|
4
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { createWorkerSnapshot, inspectWorkerSnapshot } from "./loop-worker-snapshot.mjs";
|
|
7
|
-
import {
|
|
7
|
+
import { workerDockerInvocation } from "./loop-worker-docker.mjs";
|
|
8
8
|
import { isLoopProvider, runAgentTurn } from "./loop-drivers.mjs";
|
|
9
|
+
import { extractCommitMessage } from "./loop-commit-message.mjs";
|
|
10
|
+
import { pathOverlaps } from "./store.mjs";
|
|
9
11
|
import { claimLocalAssignment, heartbeatLocalAssignment, reportLocalAssignment, releaseLocalAssignment } from "./assignment-store.mjs";
|
|
10
12
|
|
|
11
13
|
const git = (root, ...args) => execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30000 });
|
|
12
14
|
const lines = (value) => value.split("\0").filter(Boolean);
|
|
13
15
|
const identifier = (value) => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value);
|
|
14
16
|
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
17
|
const scoped = (paths, scopes) => paths.every((path) => safePath(path) && scopes.some((scope) => path === scope || path.startsWith(`${scope}/`)));
|
|
17
18
|
const statusPaths = (root) => {
|
|
18
19
|
// --no-renames yields one entry per path, including both sides of a rename.
|
|
@@ -29,7 +30,7 @@ const clean = (root) => { if (statusPaths(root).some((path) => !path.startsWith(
|
|
|
29
30
|
* state: the caller alone marks canonical items done after inspecting results.
|
|
30
31
|
* A failed batch is deliberately left for reconciliation, not auto-retried.
|
|
31
32
|
*/
|
|
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
|
+
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 = () => {}, signal } = {}) {
|
|
33
34
|
const root = resolve(inputRoot), worktreesRoot = resolve(inputWorktreesRoot);
|
|
34
35
|
if (!identifier(taskId) || !Array.isArray(jobs) || !jobs.length) throw new Error("Valid task and non-empty jobs are required");
|
|
35
36
|
if (root === worktreesRoot || worktreesRoot.startsWith(`${root}${sep}`)) throw new Error("Worker worktree root must be outside the integration checkout");
|
|
@@ -37,28 +38,48 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
|
|
|
37
38
|
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
39
|
const ids = new Set(), workers = new Set(), scopes = [];
|
|
39
40
|
for (const job of jobs) {
|
|
41
|
+
if (Array.isArray(job.ownedPaths)) job.ownedPaths = [...new Set(job.ownedPaths.map((path) => (typeof path === "string" ? path.replace(/\/+$/, "") : path)).filter((path) => path))];
|
|
40
42
|
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");
|
|
43
|
+
// Last-resort guards: the dispatcher partitions batches (selectBuildBatch),
|
|
44
|
+
// so plan data should never reach these.
|
|
41
45
|
if (ids.has(job.itemId) || workers.has(job.workerId)) throw new Error("Items and workers must be distinct within a batch");
|
|
42
46
|
ids.add(job.itemId); workers.add(job.workerId);
|
|
43
|
-
for (const path of job.ownedPaths) { if (scopes.some((prior) =>
|
|
47
|
+
for (const path of job.ownedPaths) { if (scopes.some((prior) => pathOverlaps(path, prior))) throw new Error(`Overlapping assignment scope: ${path}`); scopes.push(path); }
|
|
44
48
|
}
|
|
45
|
-
|
|
46
|
-
if (process.platform !== "linux" || !docker || typeof docker.image !== "string" || !docker.credentials || typeof docker.credentials !== "object") throw new Error("Linux Docker worker isolation and explicit image and provider credentials are required");
|
|
49
|
+
const hasImages = docker && typeof docker.images === "object" && !Array.isArray(docker.images);
|
|
50
|
+
if (process.platform !== "linux" || !docker || (typeof docker.image !== "string" && !hasImages) || !docker.credentials || typeof docker.credentials !== "object") throw new Error("Linux Docker worker isolation and explicit image and provider credentials are required");
|
|
51
|
+
const resolveImage = (agent) => {
|
|
52
|
+
const specific = hasImages && typeof docker.images[agent] === "string" && docker.images[agent] ? docker.images[agent] : undefined;
|
|
53
|
+
return specific ?? docker.image;
|
|
54
|
+
};
|
|
47
55
|
const uid = process.getuid?.(), gid = process.getgid?.();
|
|
48
56
|
if (!uid || !gid) throw new Error("Docker workers require a non-root host user");
|
|
57
|
+
const resolvedImages = {};
|
|
58
|
+
for (const agent of new Set(jobs.map((job) => job.agent))) {
|
|
59
|
+
const agentImage = resolveImage(agent);
|
|
60
|
+
if (typeof agentImage !== "string" || !agentImage) throw new Error(`Worker Docker image is required for provider ${agent}`);
|
|
61
|
+
resolvedImages[agent] = agentImage;
|
|
62
|
+
}
|
|
49
63
|
for (const job of jobs) {
|
|
50
64
|
const keys = docker.credentials[job.agent];
|
|
51
|
-
workerDockerInvocation({ workspace: root, image:
|
|
65
|
+
workerDockerInvocation({ workspace: root, image: resolvedImages[job.agent], command: job.agent, args: [], credentialEnv: keys, hostEnv: process.env, uid, gid });
|
|
52
66
|
}
|
|
53
67
|
const dockerEnv = { PATH: process.env.PATH ?? "/usr/bin:/bin" };
|
|
54
68
|
const dockerCheck = (...args) => execFileSync("docker", args, { env: dockerEnv, stdio: "ignore", timeout: 30000 });
|
|
55
|
-
|
|
69
|
+
// Run each provider CLI through workerDockerInvocation (not a bare `docker run`)
|
|
70
|
+
// so OpenCode's HOME/XDG roots are redirected to a writable tmpfs. A raw
|
|
71
|
+
// invocation fails under `--read-only` with `EROFS: mkdir '/home/node/.local'`.
|
|
72
|
+
const preflightWorkspace = mkdtempSync(join(tmpdir(), "aidlc-worker-preflight-"));
|
|
56
73
|
try {
|
|
57
74
|
dockerCheck("info");
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
75
|
+
for (const [agent, agentImage] of Object.entries(resolvedImages)) {
|
|
76
|
+
const id=execFileSync("docker",["image","inspect","--format","{{.Id}}",agentImage],{env:dockerEnv,encoding:"utf8",stdio:["ignore","pipe","ignore"],timeout:30000}).trim();
|
|
77
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(id)) throw new Error("Docker worker image lacks immutable image ID");
|
|
78
|
+
const invocation = workerDockerInvocation({ workspace: preflightWorkspace, image: agentImage, command: agent, args: ["--version"], credentialEnv: docker.credentials[agent], hostEnv: process.env, uid, gid });
|
|
79
|
+
execFileSync(invocation.command, invocation.args, { env: invocation.env, stdio: "ignore", timeout: 30000 });
|
|
80
|
+
}
|
|
61
81
|
} catch { throw new Error("Docker engine, image, or provider CLI preflight failed"); }
|
|
82
|
+
finally { rmSync(preflightWorkspace, { recursive: true, force: true }); }
|
|
62
83
|
clean(root);
|
|
63
84
|
const base = git(root, "rev-parse", "HEAD").trim();
|
|
64
85
|
const initialBranch = git(root, "symbolic-ref", "--short", "HEAD").trim();
|
|
@@ -85,6 +106,10 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
|
|
|
85
106
|
const execute = async ({ job, assignment, workspace, snapshot, baseline, branch }) => {
|
|
86
107
|
let renewalError;
|
|
87
108
|
const controller = new AbortController();
|
|
109
|
+
// An external cancel (daemon shutdown stage 2) aborts every worker turn the
|
|
110
|
+
// same way a lost lease does; the batch then fails and is not integrated.
|
|
111
|
+
const onCancel = () => { renewalError ??= new Error("Worker batch cancelled by daemon shutdown"); controller.abort(); };
|
|
112
|
+
if (signal?.aborted) onCancel(); else signal?.addEventListener("abort", onCancel, { once: true });
|
|
88
113
|
const timer = setInterval(() => { Promise.resolve().then(() => heartbeat(root, taskId, assignment.id, leaseMs)).catch((error) => { renewalError = error; controller.abort(); }); }, Math.max(500, Math.floor(leaseMs / 3)));
|
|
89
114
|
try {
|
|
90
115
|
const home=mkdtempSync(join(tmpdir(), "aidlc-local-home-")); chmodSync(home,0o700);
|
|
@@ -94,10 +119,10 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
|
|
|
94
119
|
if (renewalError || controller.signal.aborted) throw renewalError ?? new Error("Worker lease lost");
|
|
95
120
|
const containerName = `aidlc-worker-${randomUUID()}`;
|
|
96
121
|
const spawnProcess = (command, args, options) => {
|
|
97
|
-
const invocation = workerDockerInvocation({ workspace: snapshot, image, command, args, credentialEnv: docker.credentials[job.agent], hostEnv: process.env, uid, gid, containerName, sessionHome:home });
|
|
122
|
+
const invocation = workerDockerInvocation({ workspace: snapshot, image: resolvedImages[job.agent], command, args, credentialEnv: docker.credentials[job.agent], hostEnv: process.env, uid, gid, containerName, sessionHome:home });
|
|
98
123
|
return spawn(invocation.command, invocation.args, { ...options, cwd: snapshot, env: invocation.env, shell: false });
|
|
99
124
|
};
|
|
100
|
-
try { result = await runAgentTurn({ agent: job.agent, root: "/workspace", sessionId, spawnProcess, prompt: `${job.prompt}\n\nOnly modify assigned paths: ${job.ownedPaths.join(", ")}. Do not modify AI-DLC lifecycle state or approve gates. End with AI-DLC-ITEM-COMPLETE on its own line.`, timeoutMs, signal: controller.signal, onSessionId: async (id) => { await heartbeat(root, taskId, assignment.id, leaseMs, id); } }); }
|
|
125
|
+
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 a Conventional Commits message on its own line as AI-DLC-COMMIT: type(scope): subject (for example AI-DLC-COMMIT: docs(ui): add web UI improvement proposal), followed by AI-DLC-ITEM-COMPLETE on its own line.`, timeoutMs, signal: controller.signal, onSessionId: async (id) => { await heartbeat(root, taskId, assignment.id, leaseMs, id); } }); }
|
|
101
126
|
finally {
|
|
102
127
|
try { dockerCheck("rm", "-f", containerName); }
|
|
103
128
|
catch {
|
|
@@ -135,12 +160,15 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
|
|
|
135
160
|
// Fold any commits made by the agent into one scoped assignment commit.
|
|
136
161
|
git(workspace, "reset", "--soft", base);
|
|
137
162
|
git(workspace, "add", "-A", "--", ...changed);
|
|
138
|
-
if (git(workspace, "diff", "--cached", "--name-only").trim())
|
|
163
|
+
if (git(workspace, "diff", "--cached", "--name-only").trim()) {
|
|
164
|
+
const message = extractCommitMessage(result?.resultText, { itemId: job.itemId, ownedPaths: job.ownedPaths });
|
|
165
|
+
git(workspace, "-c", "user.name=AI-DLC worker", "-c", "user.email=aidlc-worker@localhost", "commit", "-m", message);
|
|
166
|
+
}
|
|
139
167
|
const commit = git(workspace, "rev-parse", "HEAD").trim(), committed = commitPaths(workspace, base, commit);
|
|
140
168
|
if (commit === base || !committed.length || !scoped(committed, job.ownedPaths)) throw new Error("Worker commit is outside assignment scope");
|
|
141
169
|
await report(root, taskId, assignment.id, { commit, changedPaths: committed, checkpoint: `provider=${job.agent}` });
|
|
142
170
|
return { assignmentId: assignment.id, itemId: job.itemId, workerId: job.workerId, agent: job.agent, baseCommit: base, branch, commit, changedPaths: committed, workspace };
|
|
143
|
-
} finally { clearInterval(timer); }
|
|
171
|
+
} finally { clearInterval(timer); signal?.removeEventListener("abort", onCancel); }
|
|
144
172
|
};
|
|
145
173
|
const results = await Promise.allSettled(prepared.map(execute));
|
|
146
174
|
const failure = results.find((result) => result.status === "rejected");
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local loop runtime: record management, checkpointing, turn orchestration,
|
|
3
|
+
* and pause/resume handling for a single lead.
|
|
4
|
+
*/
|
|
1
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
6
|
import { execFileSync } from "node:child_process";
|
|
3
7
|
import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
@@ -6,7 +10,7 @@ import { acquireLoopLease, assertLoopPath, readLiveLease } from "./loop-lease.mj
|
|
|
6
10
|
import { isLoopProvider, LOOP_PROVIDER_LIST } from "./loop-drivers.mjs";
|
|
7
11
|
|
|
8
12
|
export const DEFAULT_LIMITS = Object.freeze({ maxTurns: 12, turnTimeoutMs: 600000, maxStallTurns: 2 });
|
|
9
|
-
const states = new Set(["running", "gate", "paused", "blocked", "terminal", "complete"]);
|
|
13
|
+
const states = new Set(["running", "gate", "paused", "blocked", "terminal", "complete", "stopped"]);
|
|
10
14
|
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
11
15
|
const iso = () => new Date().toISOString();
|
|
12
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; };
|
|
@@ -148,10 +152,33 @@ export const classifyAction = (action) => {
|
|
|
148
152
|
};
|
|
149
153
|
|
|
150
154
|
function approvalCount(task) { return task.evidence.filter((item) => item.kind === "approval" && item.result === "pass").length; }
|
|
155
|
+
function pendingFeedback(task) {
|
|
156
|
+
const evidence = Array.isArray(task?.evidence) ? task.evidence : [];
|
|
157
|
+
const pending = [];
|
|
158
|
+
for (let i = 0; i < evidence.length; i++) {
|
|
159
|
+
const entry = evidence[i];
|
|
160
|
+
if (entry?.kind !== "diagnostic" || typeof entry.detail !== "string") continue;
|
|
161
|
+
const match = entry.detail.match(/^Cancelled gate wait at (G0_confirm|G1_review|G2_codereview): /);
|
|
162
|
+
if (!match) continue;
|
|
163
|
+
const approvedAfter = evidence.slice(i + 1).some((later) => later?.kind === "approval" && later.result === "pass" && later.gate === match[1]);
|
|
164
|
+
if (!approvedAfter) pending.push(entry.detail);
|
|
165
|
+
}
|
|
166
|
+
return pending;
|
|
167
|
+
}
|
|
151
168
|
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
169
|
|
|
153
|
-
|
|
170
|
+
/**
|
|
171
|
+
* `signal` requests a graceful stop at the next turn boundary; `cancelSignal`
|
|
172
|
+
* additionally cancels the in-flight agent turn. Either way the run ends as
|
|
173
|
+
* `stopped` (the canonical task is NOT paused) and a later resume continues
|
|
174
|
+
* from fresh canonical nextAction. A failure observed while stopping is a
|
|
175
|
+
* shutdown side effect (a terminal Ctrl-C also reaches the agent child), not a
|
|
176
|
+
* transport failure, so it never pauses the task.
|
|
177
|
+
*/
|
|
178
|
+
export async function runLoop({ root: rootInput, taskId, agent, mode = "run", limits: limitInput, driver, dispatchBuild, onEvent = () => {}, preflight = false, precondition, signal: stopSignal, cancelSignal }) {
|
|
179
|
+
const stopping = () => Boolean(stopSignal?.aborted || cancelSignal?.aborted);
|
|
154
180
|
const root = validateRoot(rootInput);
|
|
181
|
+
// --- Validate mode and load/create the loop record -------------------------
|
|
155
182
|
let record = readLoopRecord(root, taskId);
|
|
156
183
|
if (mode === "run") {
|
|
157
184
|
if (record) throw new Error(`Loop record already exists for ${taskId}; use resume`);
|
|
@@ -181,13 +208,22 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
|
|
|
181
208
|
envelope(root, "state.mjs", ["task", "status", taskId, "--status", "active"]);
|
|
182
209
|
record.pausedByLoop = false;
|
|
183
210
|
current = readCanonical(root, taskId);
|
|
211
|
+
} else if (mode === "resume" && record.pausedByLoop && current.task.status === "active") {
|
|
212
|
+
// A human (CLI or dashboard) already re-activated the task: the loop no
|
|
213
|
+
// longer owns that pause, so a later human pause must not be auto-undone.
|
|
214
|
+
record.pausedByLoop = false;
|
|
184
215
|
}
|
|
185
216
|
const initial = fingerprint(root, current.task, current.action);
|
|
186
217
|
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
218
|
record.lastFingerprint = initial.digest;
|
|
188
219
|
record.checkpoint = checkpoint(current.task, current.action, initial.tree, record.iteration);
|
|
189
220
|
record.status = "running"; record.stopReason = null; record.updatedAt = iso(); writeLoopRecord(root, record);
|
|
190
|
-
|
|
221
|
+
// --- Main turn loop --------------------------------------------------------
|
|
222
|
+
// maxTurns bounds one runLoop invocation (a supervised run), not the task's
|
|
223
|
+
// lifetime: record.iteration stays a cumulative observability counter, so a
|
|
224
|
+
// long multi-gate task is not permanently re-paused on every resume.
|
|
225
|
+
let runTurns = 0;
|
|
226
|
+
for (;;) {
|
|
191
227
|
const kind = classifyAction(current.action);
|
|
192
228
|
if (kind !== "run_phase") {
|
|
193
229
|
record.status = kind === "await_user" ? "gate" : kind;
|
|
@@ -196,39 +232,79 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
|
|
|
196
232
|
const gateView = kind === "await_user" ? cli(root, "gate-view.mjs", [taskId]).trim() : undefined;
|
|
197
233
|
return outputStop(record, current.action, gateView);
|
|
198
234
|
}
|
|
199
|
-
if (
|
|
235
|
+
if (stopping()) return stop(cancelSignal?.aborted ? "daemon cancelled the run" : "daemon shutdown");
|
|
236
|
+
if (runTurns >= limits.maxTurns) return pause("turn limit reached");
|
|
237
|
+
if (dispatchBuild && current.task.phase === "build" && current.task.tasks.some((item) => item.status === "todo" || item.status === "in_progress")) {
|
|
238
|
+
try {
|
|
239
|
+
await dispatchBuild({ root, taskId, task: current.task, action: current.action, record });
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (stopping()) return stop("daemon shutdown interrupted build dispatch");
|
|
242
|
+
return pause(`build dispatch failure: ${error.message}`);
|
|
243
|
+
}
|
|
244
|
+
const refreshed = readCanonical(root, taskId);
|
|
245
|
+
const signal = fingerprint(root, refreshed.task, refreshed.action);
|
|
246
|
+
record.iteration += 1; runTurns += 1;
|
|
247
|
+
record.stallTurns = signal.digest === record.lastFingerprint ? record.stallTurns + 1 : 0;
|
|
248
|
+
record.lastFingerprint = signal.digest;
|
|
249
|
+
record.checkpoint = checkpoint(refreshed.task, refreshed.action, signal.tree, record.iteration);
|
|
250
|
+
record.updatedAt = iso(); writeLoopRecord(root, record);
|
|
251
|
+
current = refreshed;
|
|
252
|
+
if (stopping()) continue; // the loop top records `stopped` (or the reached stop) instead of a stall pause
|
|
253
|
+
if (classifyAction(current.action) === "run_phase" && record.stallTurns >= limits.maxStallTurns) return pause(`${record.stallTurns} consecutive turns without observable progress`);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
200
256
|
let prompt;
|
|
201
257
|
try {
|
|
202
258
|
const packet = JSON.parse(cli(root, "context.mjs", [taskId, "--phase", current.action.phase, ...(current.action.itemId ? ["--item", current.action.itemId] : []), "--format", "json"]));
|
|
203
259
|
if (typeof packet?.content !== "string" || !packet.content.trim()) throw new Error("Context packet was empty or invalid");
|
|
204
260
|
prompt = packet.content;
|
|
205
261
|
} catch {
|
|
262
|
+
// A terminal Ctrl-C also kills the context.mjs child: that is a shutdown, not a broken packet.
|
|
263
|
+
if (stopping()) return stop("daemon shutdown interrupted context generation");
|
|
206
264
|
// No driver has started for this turn, so it is safe to resume after repairing context.
|
|
207
265
|
// Do not persist arbitrary subprocess output or malformed context in the stop reason.
|
|
208
266
|
return pause("context packet generation or validation failed; inspect context.mjs and resume after repair");
|
|
209
267
|
}
|
|
268
|
+
const feedback = pendingFeedback(current.task);
|
|
269
|
+
if (feedback.length) {
|
|
270
|
+
prompt = `Human feedback (revise the current review artifact accordingly, then re-present the gate):\n${feedback.map((text) => `- ${text}`).join("\n")}\n\n${prompt}`;
|
|
271
|
+
}
|
|
210
272
|
const beforeApprovals = approvalCount(current.task);
|
|
273
|
+
onEvent({ type: "turn_start", taskId, iteration: record.iteration, phase: current.action.phase, agent: record.agent });
|
|
211
274
|
let observedId = record.sessionId;
|
|
212
275
|
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); } } });
|
|
276
|
+
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, ...(cancelSignal ? { signal: cancelSignal } : {}), 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
277
|
if (!observedId && result?.sessionId) { observedId = result.sessionId; record.sessionId = observedId; record.updatedAt = iso(); writeLoopRecord(root, record); }
|
|
215
278
|
if (!observedId) throw new Error("Agent turn ended without a resumable session id");
|
|
216
279
|
} catch (error) {
|
|
217
280
|
current = readCanonical(root, taskId);
|
|
218
281
|
if (classifyAction(current.action) === "await_user") continue;
|
|
282
|
+
if (stopping()) return stop(cancelSignal?.aborted ? "daemon cancelled the in-flight turn" : "daemon shutdown interrupted the turn");
|
|
219
283
|
return pause(`agent transport failure: ${error.message}`);
|
|
220
284
|
}
|
|
285
|
+
onEvent({ type: "turn_end", taskId, iteration: record.iteration });
|
|
221
286
|
const refreshed = readCanonical(root, taskId);
|
|
222
287
|
if (approvalCount(refreshed.task) !== beforeApprovals) return pause("Approval evidence changed during an agent turn");
|
|
223
288
|
const signal = fingerprint(root, refreshed.task, refreshed.action);
|
|
224
|
-
record.iteration += 1;
|
|
289
|
+
record.iteration += 1; runTurns += 1;
|
|
225
290
|
record.stallTurns = signal.digest === record.lastFingerprint ? record.stallTurns + 1 : 0;
|
|
226
291
|
record.lastFingerprint = signal.digest;
|
|
227
292
|
record.checkpoint = checkpoint(refreshed.task, refreshed.action, signal.tree, record.iteration);
|
|
228
293
|
record.updatedAt = iso(); writeLoopRecord(root, record);
|
|
229
294
|
current = refreshed;
|
|
295
|
+
if (stopping()) continue; // the loop top records `stopped` (or the reached stop) instead of a stall pause
|
|
230
296
|
if (classifyAction(current.action) === "run_phase" && record.stallTurns >= limits.maxStallTurns) return pause(`${record.stallTurns} consecutive turns without observable progress`);
|
|
231
297
|
}
|
|
298
|
+
function stop(reason) {
|
|
299
|
+
const stopped = readCanonical(root, taskId);
|
|
300
|
+
const signal = fingerprint(root, stopped.task, stopped.action);
|
|
301
|
+
record.status = "stopped"; record.stopReason = reason;
|
|
302
|
+
record.lastFingerprint = signal.digest;
|
|
303
|
+
record.checkpoint = checkpoint(stopped.task, stopped.action, signal.tree, record.iteration, reason);
|
|
304
|
+
record.updatedAt = iso(); writeLoopRecord(root, record);
|
|
305
|
+
onEvent({ type: "loop_stopped", taskId, reason });
|
|
306
|
+
return outputStop(record, stopped.action);
|
|
307
|
+
}
|
|
232
308
|
function pause(reason) {
|
|
233
309
|
const fresh = readCanonical(root, taskId);
|
|
234
310
|
record.status = "paused"; record.stopReason = reason;
|