@felixdotgo/aidlc-kit 0.1.1 → 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 +11 -13
- 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/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 +15 -3
- 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 +40 -15
- package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +14 -6
- package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +23 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +42 -15
- package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +75 -5
- 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/store.mjs +57 -2
- 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/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 +57 -2
- 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 +3 -5
- 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 +25 -191
- package/dist/src/gate.js +3 -2
- package/dist/src/installer.d.ts +14 -7
- package/dist/src/installer.js +38 -141
- 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 +2 -0
- package/dist/src/profiles.js +53 -9
- 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 +3 -4
- package/dist/src/state/items.js +58 -57
- package/dist/src/state/lessons.js +14 -7
- package/dist/src/state/lifecycle.js +2 -2
- package/dist/src/state/lock.js +7 -6
- package/dist/src/state/memory.js +24 -12
- package/dist/src/state/{archive.js → migrate.js} +7 -7
- 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.js +9 -6
- package/dist/src/state/store.js +9 -15
- package/dist/src/state/transitions.js +9 -7
- package/dist/src/state/validate.js +50 -7
- package/dist/src/state.d.ts +16 -11
- package/dist/src/state.js +16 -11
- package/dist/src/task-ui.d.ts +54 -0
- package/dist/src/task-ui.js +1570 -0
- package/dist/src/upgrade-board.js +10 -10
- package/dist/src/upgrade.d.ts +0 -1
- package/dist/src/upgrade.js +19 -83
- package/dist/src/workflow.js +3 -3
- package/docs/README.md +6 -2
- package/docs/architecture.md +9 -8
- package/docs/command-reference.md +81 -20
- package/docs/configuration.md +28 -8
- package/docs/coordination-service.md +18 -6
- package/docs/design-decisions.md +83 -0
- package/docs/development.md +27 -2
- package/docs/loop-local-run.md +495 -0
- package/docs/loop-runbook.md +235 -0
- package/docs/operating-workflow.md +2 -2
- package/docs/testing-and-release.md +5 -1
- package/docs/troubleshooting.md +125 -0
- package/package.json +4 -1
- package/dist/src/ledger.d.ts +0 -22
- package/dist/src/ledger.js +0 -119
- package/dist/src/state/shared.d.ts +0 -29
- package/dist/src/state/shared.js +0 -71
- package/dist/src/tracker.d.ts +0 -36
- package/dist/src/tracker.js +0 -44
- /package/dist/src/state/{archive.d.ts → migrate.d.ts} +0 -0
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* for multi-host AI-DLC execution.
|
|
4
4
|
*/
|
|
5
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
-
import { readyTaskItems, formatGateView } from "./store.mjs";
|
|
6
|
+
import { readyTaskItems, formatGateView, pathsOverlap } from "./store.mjs";
|
|
7
7
|
import { isLoopProvider, LOOP_PROVIDER_LIST } from "./loop-drivers.mjs";
|
|
8
8
|
|
|
9
9
|
export const FLEET_LIMITS = Object.freeze({ maxTurns: 12, turnTimeoutMs: 600_000, maxStallTurns: 2, maxWorkers: 4, leaseMs: 300_000, heartbeatMs: 60_000 });
|
|
@@ -37,9 +37,15 @@ function readyItems(task) {
|
|
|
37
37
|
const ready = new Set(readyTaskItems(task).ready);
|
|
38
38
|
return items.filter((item) => item.status === "todo" && ready.has(item.id));
|
|
39
39
|
}
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
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); };
|
|
43
49
|
|
|
44
50
|
function key(...parts) { return digest(parts); }
|
|
45
51
|
|
|
@@ -113,8 +119,13 @@ async function withHeartbeat(renew, intervalMs, work) {
|
|
|
113
119
|
/**
|
|
114
120
|
* One fenced coordinator invocation. The repository is the remote authority;
|
|
115
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.
|
|
116
127
|
*/
|
|
117
|
-
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 = () => {} }) {
|
|
118
129
|
assertId(taskId, "task ID"); assertId(ownerId, "owner ID");
|
|
119
130
|
if (!Array.isArray(workerIds) || new Set(workerIds).size !== workerIds.length) throw new Error("workerIds must be distinct IDs");
|
|
120
131
|
for (const workerId of workerIds) assertId(workerId, "worker ID");
|
|
@@ -139,15 +150,19 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
|
|
|
139
150
|
const readySeen = new Map();
|
|
140
151
|
try {
|
|
141
152
|
return await withHeartbeat(() => renewLead({ taskId, ownerId, epoch, leaseMs: limits.leaseMs }), limits.heartbeatMs, async (leaseSignal) => {
|
|
142
|
-
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 });
|
|
143
157
|
for (;;) {
|
|
144
|
-
if (
|
|
158
|
+
if (leaseLost.aborted) throw new Error("Lead lease renewal failed");
|
|
159
|
+
if (stopping()) return stopped(cancelSignal?.aborted ? "cancel requested" : "stop requested");
|
|
145
160
|
if (Date.now() - startedAt >= limits.maxTurns * limits.turnTimeoutMs) return { status: "paused", taskId, reason: "coordinator wall-time limit reached" };
|
|
146
161
|
snapshot = validateSnapshot(await readTask({ taskId }), taskId);
|
|
147
162
|
const kind = actionKind(snapshot.nextAction);
|
|
148
163
|
if (kind === "await_user") {
|
|
149
164
|
const reconciled = await reconcileFleetJobs({ repository, taskId, leadEpoch: epoch, snapshot, handoff, allowCodeOnlyRetry, quiesceMs: limits.turnTimeoutMs + limits.heartbeatMs + 1000 });
|
|
150
|
-
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; }
|
|
151
166
|
if (reconciled.pending.length) return { status: "reconcile", taskId, revision: snapshot.revision, pending: reconciled.pending };
|
|
152
167
|
const gate = snapshot.nextAction.gate;
|
|
153
168
|
const baseReview = await (gateView ?? ((value) => formatGateView(value.task, [], "markdown")))(snapshot);
|
|
@@ -163,7 +178,7 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
|
|
|
163
178
|
const before = digest({ revision: snapshot.revision, action: snapshot.nextAction });
|
|
164
179
|
if (kind === "dispatch" || (kind === "run_phase" && snapshot.task.phase === "build")) {
|
|
165
180
|
const reconciled = await reconcileFleetJobs({ repository, taskId, leadEpoch: epoch, snapshot, handoff, allowCodeOnlyRetry, quiesceMs: limits.turnTimeoutMs + limits.heartbeatMs + 1000 });
|
|
166
|
-
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; }
|
|
167
182
|
if (reconciled.pending.length) {
|
|
168
183
|
const verified = reconciled.pending.filter((entry) => entry.artifact?.valid);
|
|
169
184
|
if (verified.length && typeof handoff?.integrate === "function" && typeof completeItem === "function") {
|
|
@@ -212,7 +227,7 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
|
|
|
212
227
|
}
|
|
213
228
|
}
|
|
214
229
|
onEvent({ type: "jobs_active", taskId, assignments: active.map((job) => job.assignmentId) });
|
|
215
|
-
await wait(pollMs);
|
|
230
|
+
await wait(pollMs, signal, stopSignal);
|
|
216
231
|
continue;
|
|
217
232
|
}
|
|
218
233
|
}
|
|
@@ -220,7 +235,11 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
|
|
|
220
235
|
if (typeof packet !== "function" || typeof driver !== "function") throw new Error("Fleet lead requires packet and driver adapters");
|
|
221
236
|
const prompt = await packet(snapshot, epoch);
|
|
222
237
|
if (typeof prompt !== "string" || !prompt) throw new Error("Empty fleet lead packet");
|
|
223
|
-
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
|
+
}
|
|
224
243
|
turns++;
|
|
225
244
|
const after = validateSnapshot(await readTask({ taskId }), taskId);
|
|
226
245
|
const nextSignal = digest({ revision: after.revision, action: after.nextAction });
|
|
@@ -234,8 +253,10 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
|
|
|
234
253
|
}
|
|
235
254
|
}
|
|
236
255
|
|
|
237
|
-
/** A worker claims one attempt, persists its exact CLI session, and reports a Git artifact.
|
|
238
|
-
|
|
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 = () => {} }) {
|
|
239
260
|
assertId(workerId, "worker ID");
|
|
240
261
|
if (workerAgent !== undefined && !isLoopProvider(workerAgent)) throw new Error(`workerAgent must be ${LOOP_PROVIDER_LIST}`);
|
|
241
262
|
const limits = validateFleetLimits(inputLimits);
|
|
@@ -259,12 +280,14 @@ export async function runFleetWorker({ repository, workerId = randomUUID(), work
|
|
|
259
280
|
let turns = 0, stalls = 0;
|
|
260
281
|
const report = async (outcome) => reportJob({ ...identity, report: outcome, idempotencyKey: key("job-report", job.assignmentId, job.attempt, job.epoch, outcome.status, outcome.commitSha ?? "") });
|
|
261
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 }; };
|
|
262
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");
|
|
263
285
|
const workspace = await handoff.prepare(job);
|
|
264
286
|
if (!workspace?.root) throw new Error("Git handoff did not supply a worker root");
|
|
265
287
|
let lastObservation = null;
|
|
266
288
|
for (;;) {
|
|
267
289
|
if (signal.aborted) throw new Error("Job lease renewal failed");
|
|
290
|
+
if (cancelSignal?.aborted) return cancelled();
|
|
268
291
|
const snapshot = validateSnapshot(await readTask({ taskId: job.taskId }), job.taskId);
|
|
269
292
|
const item = snapshot.task.tasks?.find((candidate) => candidate.id === job.itemId);
|
|
270
293
|
if (!item || item.status === "done") {
|
|
@@ -282,11 +305,13 @@ export async function runFleetWorker({ repository, workerId = randomUUID(), work
|
|
|
282
305
|
}
|
|
283
306
|
const prompt = await packet({ job, snapshot, workspace, turns });
|
|
284
307
|
if (typeof prompt !== "string" || !prompt) throw new Error("Empty worker packet");
|
|
285
|
-
|
|
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) => {
|
|
286
310
|
if (typeof id !== "string" || !id) throw new Error("Invalid worker session ID");
|
|
287
311
|
if (sessionId && sessionId !== id) throw new Error("Worker session changed during the same assignment");
|
|
288
312
|
sessionId = id;
|
|
289
|
-
}, onEvent });
|
|
313
|
+
}, onEvent }); }
|
|
314
|
+
catch (error) { if (!signal.aborted && cancelSignal?.aborted) return cancelled(); throw error; }
|
|
290
315
|
if (result?.sessionId && sessionId && result.sessionId !== sessionId) throw new Error("Worker session changed during the same assignment");
|
|
291
316
|
if (result?.sessionId && !sessionId) {
|
|
292
317
|
sessionId = result.sessionId;
|
|
@@ -7,6 +7,7 @@ import { execFileSync } from "node:child_process";
|
|
|
7
7
|
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync } from "node:fs";
|
|
8
8
|
import { join, resolve } from "node:path";
|
|
9
9
|
import { isLoopProvider } from "./loop-drivers.mjs";
|
|
10
|
+
import { extractCommitMessage } from "./loop-commit-message.mjs";
|
|
10
11
|
import { createWorkerSnapshot, inspectWorkerSnapshot } from "./loop-worker-snapshot.mjs";
|
|
11
12
|
|
|
12
13
|
const SHA = /^[0-9a-f]{40,64}$/;
|
|
@@ -78,8 +79,14 @@ function fetchBranch(root, remote, branch) {
|
|
|
78
79
|
function ancestor(root, base, head) {
|
|
79
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 */
|
|
80
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/";
|
|
81
85
|
function assertClean(root) {
|
|
82
|
-
|
|
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");
|
|
83
90
|
}
|
|
84
91
|
function normaliseReport(job) {
|
|
85
92
|
const report = job.report ?? job;
|
|
@@ -214,7 +221,7 @@ export function createWorkerGitHandoff({ root: inputRoot, worktreesRoot: inputWo
|
|
|
214
221
|
const done = typeof result?.resultText === "string" && result.resultText.split(/\r?\n/).some((line) => line.trim() === "AI-DLC-ITEM-COMPLETE");
|
|
215
222
|
return { done, fingerprint: worktreeFingerprint(workspace.root, changed) };
|
|
216
223
|
},
|
|
217
|
-
async finish({ job, workspace }) {
|
|
224
|
+
async finish({ job, workspace, result }) {
|
|
218
225
|
const branch = validBranch(job.branch);
|
|
219
226
|
if (git(workspace.root, "symbolic-ref", "--short", "HEAD") !== branch) throw new Error("Worker switched away from assigned branch");
|
|
220
227
|
const ownedPaths = scopes(job.ownedPaths);
|
|
@@ -222,7 +229,8 @@ export function createWorkerGitHandoff({ root: inputRoot, worktreesRoot: inputWo
|
|
|
222
229
|
checkScope(dirty, ownedPaths);
|
|
223
230
|
if (dirty.length) {
|
|
224
231
|
git(workspace.root, "add", "-A", "--", ...dirty);
|
|
225
|
-
|
|
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);
|
|
226
234
|
}
|
|
227
235
|
const commitSha = git(workspace.root, "rev-parse", "HEAD");
|
|
228
236
|
const base = validSha(job.baseCommit, "base commit");
|
|
@@ -259,7 +267,7 @@ export function createIsolatedWorkerGitHandoff(options) {
|
|
|
259
267
|
const done = typeof result?.resultText === "string" && result.resultText.split(/\r?\n/).some((line) => line.trim() === "AI-DLC-ITEM-COMPLETE");
|
|
260
268
|
return {done, fingerprint};
|
|
261
269
|
},
|
|
262
|
-
async finish({job, workspace}) {
|
|
270
|
+
async finish({job, workspace, result}) {
|
|
263
271
|
const changed=inspectWorkerSnapshot(workspace.root, workspace.baseline, job.ownedPaths);
|
|
264
272
|
if (!changed.length) throw new Error("Worker produced no changes");
|
|
265
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");
|
|
@@ -276,8 +284,8 @@ export function createIsolatedWorkerGitHandoff(options) {
|
|
|
276
284
|
if (!existsSync(source)) { rmSync(target,{force:true}); continue; }
|
|
277
285
|
copyFileSync(source,target); chmodSync(target,lstatSync(source).mode & 0o777);
|
|
278
286
|
}
|
|
279
|
-
const
|
|
280
|
-
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;
|
|
281
289
|
},
|
|
282
290
|
async discard({workspace}) { rmSync(workspace.root,{recursive:true,force:true}); return host.discard({workspace:workspace.checkout}); }
|
|
283
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");
|
|
@@ -39,27 +40,46 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
|
|
|
39
40
|
for (const job of jobs) {
|
|
40
41
|
if (Array.isArray(job.ownedPaths)) job.ownedPaths = [...new Set(job.ownedPaths.map((path) => (typeof path === "string" ? path.replace(/\/+$/, "") : path)).filter((path) => path))];
|
|
41
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.
|
|
42
45
|
if (ids.has(job.itemId) || workers.has(job.workerId)) throw new Error("Items and workers must be distinct within a batch");
|
|
43
46
|
ids.add(job.itemId); workers.add(job.workerId);
|
|
44
|
-
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); }
|
|
45
48
|
}
|
|
46
|
-
|
|
47
|
-
if (process.platform !== "linux" || !docker || typeof docker.image !== "string" || !docker.credentials || typeof docker.credentials !== "object") throw new Error("Linux Docker worker isolation and explicit image and provider credentials are required");
|
|
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
|
+
};
|
|
48
55
|
const uid = process.getuid?.(), gid = process.getgid?.();
|
|
49
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
|
+
}
|
|
50
63
|
for (const job of jobs) {
|
|
51
64
|
const keys = docker.credentials[job.agent];
|
|
52
|
-
workerDockerInvocation({ workspace: root, image:
|
|
65
|
+
workerDockerInvocation({ workspace: root, image: resolvedImages[job.agent], command: job.agent, args: [], credentialEnv: keys, hostEnv: process.env, uid, gid });
|
|
53
66
|
}
|
|
54
67
|
const dockerEnv = { PATH: process.env.PATH ?? "/usr/bin:/bin" };
|
|
55
68
|
const dockerCheck = (...args) => execFileSync("docker", args, { env: dockerEnv, stdio: "ignore", timeout: 30000 });
|
|
56
|
-
|
|
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-"));
|
|
57
73
|
try {
|
|
58
74
|
dockerCheck("info");
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
+
}
|
|
62
81
|
} catch { throw new Error("Docker engine, image, or provider CLI preflight failed"); }
|
|
82
|
+
finally { rmSync(preflightWorkspace, { recursive: true, force: true }); }
|
|
63
83
|
clean(root);
|
|
64
84
|
const base = git(root, "rev-parse", "HEAD").trim();
|
|
65
85
|
const initialBranch = git(root, "symbolic-ref", "--short", "HEAD").trim();
|
|
@@ -86,6 +106,10 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
|
|
|
86
106
|
const execute = async ({ job, assignment, workspace, snapshot, baseline, branch }) => {
|
|
87
107
|
let renewalError;
|
|
88
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 });
|
|
89
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)));
|
|
90
114
|
try {
|
|
91
115
|
const home=mkdtempSync(join(tmpdir(), "aidlc-local-home-")); chmodSync(home,0o700);
|
|
@@ -95,10 +119,10 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
|
|
|
95
119
|
if (renewalError || controller.signal.aborted) throw renewalError ?? new Error("Worker lease lost");
|
|
96
120
|
const containerName = `aidlc-worker-${randomUUID()}`;
|
|
97
121
|
const spawnProcess = (command, args, options) => {
|
|
98
|
-
const invocation = workerDockerInvocation({ workspace: snapshot, image, command, args, credentialEnv: docker.credentials[job.agent], hostEnv: process.env, uid, gid, containerName, sessionHome:home });
|
|
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 });
|
|
99
123
|
return spawn(invocation.command, invocation.args, { ...options, cwd: snapshot, env: invocation.env, shell: false });
|
|
100
124
|
};
|
|
101
|
-
try { result = await runAgentTurn({ agent: job.agent, root: "/workspace", sessionId, spawnProcess, prompt: `${job.prompt}\n\nOnly modify assigned paths: ${job.ownedPaths.join(", ")}. Do not modify AI-DLC lifecycle state or approve gates. End with AI-DLC-ITEM-COMPLETE on its own line.`, timeoutMs, signal: controller.signal, onSessionId: async (id) => { await heartbeat(root, taskId, assignment.id, leaseMs, id); } }); }
|
|
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); } }); }
|
|
102
126
|
finally {
|
|
103
127
|
try { dockerCheck("rm", "-f", containerName); }
|
|
104
128
|
catch {
|
|
@@ -136,12 +160,15 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
|
|
|
136
160
|
// Fold any commits made by the agent into one scoped assignment commit.
|
|
137
161
|
git(workspace, "reset", "--soft", base);
|
|
138
162
|
git(workspace, "add", "-A", "--", ...changed);
|
|
139
|
-
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
|
+
}
|
|
140
167
|
const commit = git(workspace, "rev-parse", "HEAD").trim(), committed = commitPaths(workspace, base, commit);
|
|
141
168
|
if (commit === base || !committed.length || !scoped(committed, job.ownedPaths)) throw new Error("Worker commit is outside assignment scope");
|
|
142
169
|
await report(root, taskId, assignment.id, { commit, changedPaths: committed, checkpoint: `provider=${job.agent}` });
|
|
143
170
|
return { assignmentId: assignment.id, itemId: job.itemId, workerId: job.workerId, agent: job.agent, baseCommit: base, branch, commit, changedPaths: committed, workspace };
|
|
144
|
-
} finally { clearInterval(timer); }
|
|
171
|
+
} finally { clearInterval(timer); signal?.removeEventListener("abort", onCancel); }
|
|
145
172
|
};
|
|
146
173
|
const results = await Promise.allSettled(prepared.map(execute));
|
|
147
174
|
const failure = results.find((result) => result.status === "rejected");
|
|
@@ -10,7 +10,7 @@ import { acquireLoopLease, assertLoopPath, readLiveLease } from "./loop-lease.mj
|
|
|
10
10
|
import { isLoopProvider, LOOP_PROVIDER_LIST } from "./loop-drivers.mjs";
|
|
11
11
|
|
|
12
12
|
export const DEFAULT_LIMITS = Object.freeze({ maxTurns: 12, turnTimeoutMs: 600000, maxStallTurns: 2 });
|
|
13
|
-
const states = new Set(["running", "gate", "paused", "blocked", "terminal", "complete"]);
|
|
13
|
+
const states = new Set(["running", "gate", "paused", "blocked", "terminal", "complete", "stopped"]);
|
|
14
14
|
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
15
15
|
const iso = () => new Date().toISOString();
|
|
16
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; };
|
|
@@ -152,9 +152,31 @@ export const classifyAction = (action) => {
|
|
|
152
152
|
};
|
|
153
153
|
|
|
154
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
|
+
}
|
|
155
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 } : {}) }; }
|
|
156
169
|
|
|
157
|
-
|
|
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);
|
|
158
180
|
const root = validateRoot(rootInput);
|
|
159
181
|
// --- Validate mode and load/create the loop record -------------------------
|
|
160
182
|
let record = readLoopRecord(root, taskId);
|
|
@@ -186,6 +208,10 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
|
|
|
186
208
|
envelope(root, "state.mjs", ["task", "status", taskId, "--status", "active"]);
|
|
187
209
|
record.pausedByLoop = false;
|
|
188
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;
|
|
189
215
|
}
|
|
190
216
|
const initial = fingerprint(root, current.task, current.action);
|
|
191
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 });
|
|
@@ -193,6 +219,10 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
|
|
|
193
219
|
record.checkpoint = checkpoint(current.task, current.action, initial.tree, record.iteration);
|
|
194
220
|
record.status = "running"; record.stopReason = null; record.updatedAt = iso(); writeLoopRecord(root, record);
|
|
195
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;
|
|
196
226
|
for (;;) {
|
|
197
227
|
const kind = classifyAction(current.action);
|
|
198
228
|
if (kind !== "run_phase") {
|
|
@@ -202,39 +232,79 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
|
|
|
202
232
|
const gateView = kind === "await_user" ? cli(root, "gate-view.mjs", [taskId]).trim() : undefined;
|
|
203
233
|
return outputStop(record, current.action, gateView);
|
|
204
234
|
}
|
|
205
|
-
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
|
+
}
|
|
206
256
|
let prompt;
|
|
207
257
|
try {
|
|
208
258
|
const packet = JSON.parse(cli(root, "context.mjs", [taskId, "--phase", current.action.phase, ...(current.action.itemId ? ["--item", current.action.itemId] : []), "--format", "json"]));
|
|
209
259
|
if (typeof packet?.content !== "string" || !packet.content.trim()) throw new Error("Context packet was empty or invalid");
|
|
210
260
|
prompt = packet.content;
|
|
211
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");
|
|
212
264
|
// No driver has started for this turn, so it is safe to resume after repairing context.
|
|
213
265
|
// Do not persist arbitrary subprocess output or malformed context in the stop reason.
|
|
214
266
|
return pause("context packet generation or validation failed; inspect context.mjs and resume after repair");
|
|
215
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
|
+
}
|
|
216
272
|
const beforeApprovals = approvalCount(current.task);
|
|
273
|
+
onEvent({ type: "turn_start", taskId, iteration: record.iteration, phase: current.action.phase, agent: record.agent });
|
|
217
274
|
let observedId = record.sessionId;
|
|
218
275
|
try {
|
|
219
|
-
const result = await driver({ agent: record.agent, root, sessionId: record.sessionId ?? undefined, prompt: `${prompt}\n\nLoop supervisor: complete the current authorized phase or item. Use canonical lifecycle commands for evidence and gates. Never approve a human gate. Stop at a gate and report progress.`, timeoutMs: limits.turnTimeoutMs, onSessionId: (id) => { if (typeof id !== "string" || !id) throw new Error("Invalid agent session id"); observedId = id; record.sessionId = id; record.updatedAt = iso(); writeLoopRecord(root, record); }, onEvent: (event) => { onEvent(event); if (event?.type === "lifecycle_command") { const observed = readCanonical(root, taskId); const signal = fingerprint(root, observed.task, observed.action); record.checkpoint = checkpoint(observed.task, observed.action, signal.tree, record.iteration); record.updatedAt = iso(); writeLoopRecord(root, record); } } });
|
|
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); } } });
|
|
220
277
|
if (!observedId && result?.sessionId) { observedId = result.sessionId; record.sessionId = observedId; record.updatedAt = iso(); writeLoopRecord(root, record); }
|
|
221
278
|
if (!observedId) throw new Error("Agent turn ended without a resumable session id");
|
|
222
279
|
} catch (error) {
|
|
223
280
|
current = readCanonical(root, taskId);
|
|
224
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");
|
|
225
283
|
return pause(`agent transport failure: ${error.message}`);
|
|
226
284
|
}
|
|
285
|
+
onEvent({ type: "turn_end", taskId, iteration: record.iteration });
|
|
227
286
|
const refreshed = readCanonical(root, taskId);
|
|
228
287
|
if (approvalCount(refreshed.task) !== beforeApprovals) return pause("Approval evidence changed during an agent turn");
|
|
229
288
|
const signal = fingerprint(root, refreshed.task, refreshed.action);
|
|
230
|
-
record.iteration += 1;
|
|
289
|
+
record.iteration += 1; runTurns += 1;
|
|
231
290
|
record.stallTurns = signal.digest === record.lastFingerprint ? record.stallTurns + 1 : 0;
|
|
232
291
|
record.lastFingerprint = signal.digest;
|
|
233
292
|
record.checkpoint = checkpoint(refreshed.task, refreshed.action, signal.tree, record.iteration);
|
|
234
293
|
record.updatedAt = iso(); writeLoopRecord(root, record);
|
|
235
294
|
current = refreshed;
|
|
295
|
+
if (stopping()) continue; // the loop top records `stopped` (or the reached stop) instead of a stall pause
|
|
236
296
|
if (classifyAction(current.action) === "run_phase" && record.stallTurns >= limits.maxStallTurns) return pause(`${record.stallTurns} consecutive turns without observable progress`);
|
|
237
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
|
+
}
|
|
238
308
|
function pause(reason) {
|
|
239
309
|
const fresh = readCanonical(root, taskId);
|
|
240
310
|
record.status = "paused"; record.stopReason = reason;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** The event-log sidecar rotates to `<path>.1` (one generation kept) once it
|
|
5
|
+
* reaches this size, so a 24/7 daemon cannot grow it without bound. */
|
|
6
|
+
export const EVENT_LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
let preparedDirectory;
|
|
9
|
+
|
|
10
|
+
/** Append one structured event to the optional UI event-log sidecar (JSONL).
|
|
11
|
+
* Each record gets an `at` timestamp; the directory is created on demand. */
|
|
12
|
+
function appendEventLog(event) {
|
|
13
|
+
const path = process.env.AIDLC_LOOP_EVENT_LOG;
|
|
14
|
+
if (!path) return;
|
|
15
|
+
try {
|
|
16
|
+
const directory = dirname(path);
|
|
17
|
+
if (preparedDirectory !== directory) { mkdirSync(directory, { recursive: true }); preparedDirectory = directory; }
|
|
18
|
+
try { if (statSync(path).size >= EVENT_LOG_MAX_BYTES) renameSync(path, `${path}.1`); }
|
|
19
|
+
catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
20
|
+
const record = event && typeof event === "object" && !Array.isArray(event) && !("at" in event) ? { at: new Date().toISOString(), ...event } : event;
|
|
21
|
+
appendFileSync(path, `${JSON.stringify(record)}\n`);
|
|
22
|
+
} catch { /* best-effort: never break the loop on a log write failure */ }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Render foreground loop progress to stderr without polluting stdout JSON. */
|
|
26
|
+
export function streamLog(event) {
|
|
27
|
+
appendEventLog(event);
|
|
28
|
+
if (!event || typeof event.type !== "string") return;
|
|
29
|
+
switch (event.type) {
|
|
30
|
+
case "turn_start":
|
|
31
|
+
process.stderr.write(`\n━━ turn ${Number(event.iteration ?? 0) + 1} · phase ${event.phase ?? "?"} · agent ${event.agent ?? "?"} ━━\n`);
|
|
32
|
+
break;
|
|
33
|
+
case "turn_end":
|
|
34
|
+
process.stderr.write(`━━ turn done ━━\n`);
|
|
35
|
+
break;
|
|
36
|
+
case "agent_text":
|
|
37
|
+
process.stderr.write(`${event.text ?? ""}\n`);
|
|
38
|
+
break;
|
|
39
|
+
case "agent_tool":
|
|
40
|
+
process.stderr.write(` › tool: ${event.tool ?? "?"}\n`);
|
|
41
|
+
break;
|
|
42
|
+
case "lifecycle_command":
|
|
43
|
+
process.stderr.write(` › command ${event.status ?? "?"} (exit ${event.exitCode ?? "?"})\n`);
|
|
44
|
+
break;
|
|
45
|
+
case "daemon_turn_error":
|
|
46
|
+
process.stderr.write(` ! ${event.taskId ?? "?"}: ${event.message ?? "error"} (retry in ${Math.round(Number(event.retryInMs ?? 0) / 1000)}s)\n`);
|
|
47
|
+
break;
|
|
48
|
+
case "loop_stopped":
|
|
49
|
+
process.stderr.write(` ■ ${event.taskId ?? "?"} stopped: ${event.reason ?? ""}\n`);
|
|
50
|
+
break;
|
|
51
|
+
case "gate":
|
|
52
|
+
process.stderr.write(`\n${event.gateView ?? `Gate ${event.gate ?? "?"} reached — approve from the trusted terminal.`}\n`);
|
|
53
|
+
break;
|
|
54
|
+
case "worker_image_build":
|
|
55
|
+
process.stderr.write(`\n▶ building worker image ${event.image ?? "?"} (provider ${event.provider ?? "?"})…\n`);
|
|
56
|
+
break;
|
|
57
|
+
default:
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|