@felixdotgo/aidlc-kit 0.2.0 → 0.3.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 +13 -26
- package/README.md +35 -39
- package/dist/assets/.agents/aidlc/orchestrator.md +1 -1
- package/dist/assets/.agents/aidlc/schemas/state.schema.json +2 -0
- package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +94 -48
- package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +5 -4
- package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +21 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +52 -78
- package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +88 -14
- package/dist/assets/.agents/aidlc/scripts/lib/loop-env.mjs +279 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +20 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +10 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +2 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +3 -2
- package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +9 -3
- package/dist/assets/.agents/aidlc/scripts/lib/loop-holder.mjs +376 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +11 -3
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +67 -61
- package/dist/assets/.agents/aidlc/scripts/lib/loop-leases.mjs +268 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +78 -17
- package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +42 -10
- package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +66 -9
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +16 -56
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +12 -6
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +14 -2
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-log.mjs +158 -0
- package/dist/assets/.agents/aidlc/scripts/lib/repo-head.mjs +22 -0
- package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +11 -0
- package/dist/assets/.agents/aidlc/scripts/lib/task-runtime-migrate.mjs +75 -0
- package/dist/assets/.agents/aidlc/scripts/lib/task-runtime.mjs +97 -0
- package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +87 -4
- package/dist/assets/.agents/aidlc/scripts/loop.cmd +101 -0
- package/dist/assets/.agents/aidlc/scripts/loop.sh +71 -0
- package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +10 -4
- package/dist/assets/.agents/aidlc/scripts/state.mjs +16 -8
- package/dist/assets/ui/changes.js +83 -0
- package/dist/assets/ui/common.js +664 -0
- package/dist/assets/ui/dashboard.css +281 -59
- package/dist/assets/ui/dashboard.js +77 -0
- package/dist/assets/ui/detail.js +159 -0
- package/dist/assets/ui/live-log.js +332 -0
- package/dist/services/aidlc-coordination/package.json +1 -1
- package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +11 -0
- package/dist/src/cli/argv.js +10 -4
- package/dist/src/cli/commands.js +52 -22
- package/dist/src/cli/loop-launcher.d.ts +47 -0
- package/dist/src/cli/loop-launcher.js +322 -0
- package/dist/src/cli.js +3 -0
- package/dist/src/installer.js +4 -2
- package/dist/src/model/records.d.ts +4 -0
- package/dist/src/state/validate.js +5 -0
- package/dist/src/task-ui.d.ts +31 -52
- package/dist/src/task-ui.js +25 -1562
- package/dist/src/ui/assets.d.ts +14 -0
- package/dist/src/ui/assets.js +45 -0
- package/dist/src/ui/changes.d.ts +83 -0
- package/dist/src/ui/changes.js +498 -0
- package/dist/src/ui/containers.d.ts +71 -0
- package/dist/src/ui/containers.js +214 -0
- package/dist/src/ui/daemon.d.ts +75 -0
- package/dist/src/ui/daemon.js +232 -0
- package/dist/src/ui/data.d.ts +42 -0
- package/dist/src/ui/data.js +89 -0
- package/dist/src/ui/docs.d.ts +13 -0
- package/dist/src/ui/docs.js +39 -0
- package/dist/src/ui/files.d.ts +3 -0
- package/dist/src/ui/files.js +20 -0
- package/dist/src/ui/http.d.ts +14 -0
- package/dist/src/ui/http.js +80 -0
- package/dist/src/ui/live.d.ts +5 -0
- package/dist/src/ui/live.js +43 -0
- package/dist/src/ui/markdown.d.ts +2 -0
- package/dist/src/ui/markdown.js +127 -0
- package/dist/src/ui/options.d.ts +48 -0
- package/dist/src/ui/options.js +81 -0
- package/dist/src/ui/security.d.ts +34 -0
- package/dist/src/ui/security.js +38 -0
- package/dist/src/ui/server.d.ts +39 -0
- package/dist/src/ui/server.js +347 -0
- package/dist/src/ui/sse.d.ts +14 -0
- package/dist/src/ui/sse.js +281 -0
- package/dist/src/ui/views/board.d.ts +7 -0
- package/dist/src/ui/views/board.js +131 -0
- package/dist/src/ui/views/changes.d.ts +19 -0
- package/dist/src/ui/views/changes.js +154 -0
- package/dist/src/ui/views/components.d.ts +56 -0
- package/dist/src/ui/views/components.js +113 -0
- package/dist/src/ui/views/docs.d.ts +15 -0
- package/dist/src/ui/views/docs.js +64 -0
- package/dist/src/ui/views/layout.d.ts +44 -0
- package/dist/src/ui/views/layout.js +233 -0
- package/dist/src/ui/views/task.d.ts +25 -0
- package/dist/src/ui/views/task.js +320 -0
- package/dist/src/upgrade.js +5 -2
- package/dist/src/workflow.d.ts +4 -0
- package/dist/src/workflow.js +33 -1
- package/docs/README.md +48 -33
- package/docs/architecture.md +101 -38
- package/docs/command-reference.md +269 -93
- package/docs/configuration.md +62 -42
- package/docs/coordination-service.md +85 -36
- package/docs/design-decisions.md +35 -31
- package/docs/development.md +111 -61
- package/docs/loop-local-run.md +135 -402
- package/docs/loop-runbook.md +84 -105
- package/docs/operating-workflow.md +55 -42
- package/docs/testing-and-release.md +70 -51
- package/docs/troubleshooting.md +165 -69
- package/package.json +1 -1
|
@@ -1,27 +1,84 @@
|
|
|
1
|
-
import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
2
|
-
import { dirname } from "node:path";
|
|
1
|
+
import { appendFileSync, lstatSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, resolve } from "node:path";
|
|
3
|
+
import { isTaskId, taskRuntimePath } from "./task-runtime.mjs";
|
|
4
|
+
import { migrateTaskRuntime } from "./task-runtime-migrate.mjs";
|
|
3
5
|
|
|
4
6
|
/** The event-log sidecar rotates to `<path>.1` (one generation kept) once it
|
|
5
7
|
* reaches this size, so a 24/7 daemon cannot grow it without bound. */
|
|
6
8
|
export const EVENT_LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
9
|
+
/** Per-task history files (`.agents/data/tasks/<taskId>/runtime/events.jsonl`)
|
|
10
|
+
* rotate the same way at a smaller bound, since one exists for every task. */
|
|
11
|
+
export const TASK_EVENT_LOG_MAX_BYTES = 1024 * 1024;
|
|
7
12
|
|
|
8
13
|
let preparedDirectory;
|
|
14
|
+
const migratedTasks = new Set();
|
|
9
15
|
|
|
10
|
-
|
|
11
|
-
|
|
16
|
+
const rotate = (path, limit) => {
|
|
17
|
+
try { if (statSync(path).size >= limit) renameSync(path, `${path}.1`); }
|
|
18
|
+
catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** True when `path` is missing or a real (non-symlink) entry of the expected kind. */
|
|
22
|
+
const safeEntry = (path, directory) => {
|
|
23
|
+
try {
|
|
24
|
+
const stat = lstatSync(path);
|
|
25
|
+
return !stat.isSymbolicLink() && (directory ? stat.isDirectory() : stat.isFile());
|
|
26
|
+
} catch (error) { if (error?.code === "ENOENT") return true; throw error; }
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Path of the per-task history file (`.agents/data/tasks/<taskId>/runtime/events.jsonl`),
|
|
30
|
+
* or null when `root` is not a string or the id is not a canonical task id. */
|
|
31
|
+
export const taskEventLogPath = (root, taskId) => {
|
|
32
|
+
if (typeof root !== "string" || !root || !isTaskId(taskId)) return null;
|
|
33
|
+
try { return taskRuntimePath(root, taskId, "events.jsonl"); } catch { return null; }
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** The project root a global event sidecar belongs to: the sidecar is always
|
|
37
|
+
* `<root>/.agents/data/loop/<file>`; any other location has no task history. */
|
|
38
|
+
export const loopRootFromEventLog = (logPath) => {
|
|
39
|
+
if (typeof logPath !== "string" || !logPath) return null;
|
|
40
|
+
const loopDir = dirname(resolve(logPath));
|
|
41
|
+
const data = dirname(loopDir), agents = dirname(data);
|
|
42
|
+
return basename(loopDir) === "loop" && basename(data) === "data" && basename(agents) === ".agents" ? dirname(agents) : null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Append one line to a task's history. Refuses symlinked (or non-regular)
|
|
46
|
+
* directories and files so a planted link cannot redirect the write. A
|
|
47
|
+
* pre-runtime `.agents/data/loop/events/<taskId>.jsonl` is moved under
|
|
48
|
+
* `runtime/` before the first append of this process. */
|
|
49
|
+
function appendTaskEventLog(root, taskId, line) {
|
|
50
|
+
const path = taskEventLogPath(root, taskId);
|
|
51
|
+
if (!path) return;
|
|
52
|
+
if (!migratedTasks.has(path)) { migratedTasks.add(path); migrateTaskRuntime(root, taskId, { names: ["events.jsonl", "events.jsonl.1"] }); }
|
|
53
|
+
const directory = dirname(path);
|
|
54
|
+
if (!safeEntry(dirname(directory), true) || !safeEntry(directory, true)) return;
|
|
55
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
56
|
+
if (!safeEntry(directory, true) || !safeEntry(path, false) || !safeEntry(`${path}.1`, false)) return;
|
|
57
|
+
rotate(path, TASK_EVENT_LOG_MAX_BYTES);
|
|
58
|
+
appendFileSync(path, line);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Append one structured event to the optional UI event-log sidecar (JSONL)
|
|
62
|
+
* and, when it carries a valid `taskId`, to that task's history file. Each
|
|
63
|
+
* record gets an `at` timestamp; directories are created on demand. */
|
|
12
64
|
function appendEventLog(event) {
|
|
13
65
|
const path = process.env.AIDLC_LOOP_EVENT_LOG;
|
|
14
66
|
if (!path) return;
|
|
67
|
+
let line;
|
|
15
68
|
try {
|
|
16
69
|
const directory = dirname(path);
|
|
17
70
|
if (preparedDirectory !== directory) { mkdirSync(directory, { recursive: true }); preparedDirectory = directory; }
|
|
18
|
-
|
|
19
|
-
catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
71
|
+
rotate(path, EVENT_LOG_MAX_BYTES);
|
|
20
72
|
const record = event && typeof event === "object" && !Array.isArray(event) && !("at" in event) ? { at: new Date().toISOString(), ...event } : event;
|
|
21
|
-
|
|
73
|
+
line = `${JSON.stringify(record)}\n`;
|
|
74
|
+
appendFileSync(path, line);
|
|
22
75
|
} catch { /* best-effort: never break the loop on a log write failure */ }
|
|
76
|
+
try { if (line && typeof event?.taskId === "string") appendTaskEventLog(loopRootFromEventLog(path), event.taskId, line); }
|
|
77
|
+
catch { /* best-effort, independently of the global log */ }
|
|
23
78
|
}
|
|
24
79
|
|
|
80
|
+
const withSummary = (event, separator) => typeof event.summary === "string" && event.summary ? `${separator}${event.summary}` : "";
|
|
81
|
+
|
|
25
82
|
/** Render foreground loop progress to stderr without polluting stdout JSON. */
|
|
26
83
|
export function streamLog(event) {
|
|
27
84
|
appendEventLog(event);
|
|
@@ -37,10 +94,10 @@ export function streamLog(event) {
|
|
|
37
94
|
process.stderr.write(`${event.text ?? ""}\n`);
|
|
38
95
|
break;
|
|
39
96
|
case "agent_tool":
|
|
40
|
-
process.stderr.write(` › tool: ${event.tool ?? "?"}\n`);
|
|
97
|
+
process.stderr.write(` › tool: ${event.tool ?? "?"}${withSummary(event, " · ")}\n`);
|
|
41
98
|
break;
|
|
42
99
|
case "lifecycle_command":
|
|
43
|
-
process.stderr.write(` › command ${event.status ?? "?"} (exit ${event.exitCode ?? "?"})\n`);
|
|
100
|
+
process.stderr.write(` › command ${event.status ?? "?"} (exit ${event.exitCode ?? "?"})${withSummary(event, ": ")}\n`);
|
|
44
101
|
break;
|
|
45
102
|
case "daemon_turn_error":
|
|
46
103
|
process.stderr.write(` ! ${event.taskId ?? "?"}: ${event.message ?? "error"} (retry in ${Math.round(Number(event.retryInMs ?? 0) / 1000)}s)\n`);
|
|
@@ -1,61 +1,28 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
-
import { assertLoopPath } from "./loop-lease.mjs";
|
|
4
|
+
import { acquireTaskLease, assertLoopPath } from "./loop-lease.mjs";
|
|
5
5
|
import { validateRoot } from "./loop-runtime.mjs";
|
|
6
6
|
import { acquireStateLock, loadTask, nextAction } from "./store.mjs";
|
|
7
7
|
import { recoverLeadArtifact } from "./loop-lead-artifacts.mjs";
|
|
8
8
|
|
|
9
9
|
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
10
10
|
const checkAuthority = (authority) => { if (authority !== "local") throw new Error("local supervisor adapter requires local authority"); };
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
try {
|
|
16
|
-
const lease = JSON.parse(readFileSync(path, "utf8"));
|
|
17
|
-
if (lease?.taskId !== taskId || !Number.isSafeInteger(lease.pid) || lease.pid < 1 || typeof lease.token !== "string" || !lease.token) throw new Error("invalid fields");
|
|
18
|
-
return lease;
|
|
19
|
-
} catch (error) { if (error?.code === "ENOENT") return null; throw new Error(`Corrupt supervisor lease for ${taskId}: ${error.message}`); }
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function writeExclusive(path, value) {
|
|
23
|
-
const fd = openSync(path, "wx", 0o600);
|
|
24
|
-
try { writeFileSync(fd, `${JSON.stringify(value)}\n`); fsyncSync(fd); } finally { closeSync(fd); }
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Local durable checkpoint + separate PID-fenced supervisor lock.
|
|
28
|
-
* The lock uses a distinct task key from the runner's lease so humans can approve.
|
|
11
|
+
/** Local durable checkpoint + the task lease (`loop/<task>.lease`).
|
|
12
|
+
* The supervisor holds the lease with activity "waiting" between runs, so a
|
|
13
|
+
* human can approve gates; the runLoop it drives shares the same handle and
|
|
14
|
+
* flips activity to "turn" while a run executes (see loop-lease.mjs).
|
|
29
15
|
* This adapter intentionally does not implement runTurn or perform approvals.
|
|
30
16
|
*/
|
|
31
|
-
export function createLocalSupervisorAdapter({ root: rootInput, taskId }) {
|
|
17
|
+
export function createLocalSupervisorAdapter({ root: rootInput, taskId, onWarning }) {
|
|
32
18
|
const root = validateRoot(rootInput);
|
|
33
19
|
const checkpointPath = assertLoopPath(root, taskId, ".supervisor.json");
|
|
34
|
-
|
|
35
|
-
const guardPath = assertLoopPath(root, taskId, ".supervisor.guard");
|
|
36
|
-
let token;
|
|
37
|
-
const withGuard = (action) => {
|
|
38
|
-
assertLoopPath(root, taskId, ".supervisor.guard");
|
|
39
|
-
mkdirSync(dirname(guardPath), { recursive: true });
|
|
40
|
-
const guard = { taskId, pid: process.pid, token: randomUUID() }, deadline = Date.now() + 5000;
|
|
41
|
-
for (;;) {
|
|
42
|
-
try { writeExclusive(guardPath, guard); break; }
|
|
43
|
-
catch (error) {
|
|
44
|
-
if (error?.code !== "EEXIST") throw error;
|
|
45
|
-
const existing = readLease(guardPath, taskId);
|
|
46
|
-
if (!existing) continue;
|
|
47
|
-
if (!alive(existing.pid)) { if (readLease(guardPath, taskId)?.token === existing.token) { try { unlinkSync(guardPath); } catch (e) { if (e?.code !== "ENOENT") throw e; } } continue; }
|
|
48
|
-
if (Date.now() >= deadline) throw new Error(`Timed out waiting for supervisor guard: ${taskId}`);
|
|
49
|
-
wait(10);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
try { return action(); } finally { if (readLease(guardPath, taskId)?.token === guard.token) unlinkSync(guardPath); }
|
|
53
|
-
};
|
|
20
|
+
let lease;
|
|
54
21
|
const checkScope = (input) => { checkAuthority(input?.authority); if (input?.taskId !== taskId) throw new Error("supervisor task mismatch"); };
|
|
55
22
|
const assertOwned = async () => {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
23
|
+
if (!lease) throw new Error("local supervisor ownership lost: the task lease is not held");
|
|
24
|
+
try { lease.assertOwned(); }
|
|
25
|
+
catch (error) { throw new Error(`local supervisor ownership lost: ${error.message}`); }
|
|
59
26
|
};
|
|
60
27
|
const readCheckpoint = () => {
|
|
61
28
|
assertLoopPath(root, taskId, ".supervisor.json");
|
|
@@ -72,27 +39,20 @@ export function createLocalSupervisorAdapter({ root: rootInput, taskId }) {
|
|
|
72
39
|
return {
|
|
73
40
|
async acquireSupervisor(input) {
|
|
74
41
|
checkScope(input);
|
|
75
|
-
if (
|
|
42
|
+
if (lease) throw new Error("local supervisor adapter is already acquired");
|
|
76
43
|
if (input.signal?.aborted) throw new Error("supervisor already aborted");
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const existing = readLease(leasePath, taskId);
|
|
81
|
-
if (existing && alive(existing.pid)) throw new Error(`Task ${taskId} already has a live supervisor (pid ${existing.pid})`);
|
|
82
|
-
if (existing && readLease(leasePath, taskId)?.token === existing.token) unlinkSync(leasePath);
|
|
83
|
-
writeExclusive(leasePath, { taskId, pid: process.pid, token: newToken });
|
|
84
|
-
});
|
|
85
|
-
token = newToken;
|
|
86
|
-
return { assertOwned, release: async () => { if (token !== newToken) return; withGuard(() => { if (readLease(leasePath, taskId)?.token === newToken) unlinkSync(leasePath); }); token = undefined; } };
|
|
44
|
+
const handle = acquireTaskLease(root, taskId, { activity: "waiting", label: "supervisor", purpose: "local loop supervisor", onWarning });
|
|
45
|
+
lease = handle;
|
|
46
|
+
return { assertOwned, release: async () => { if (lease !== handle) return; try { handle.release(); } finally { lease = undefined; } } };
|
|
87
47
|
},
|
|
88
48
|
async readTask(input) {
|
|
89
49
|
checkScope({ ...input, authority: "local" });
|
|
90
50
|
const release = acquireStateLock(root);
|
|
91
51
|
try {
|
|
92
|
-
if (
|
|
52
|
+
if (lease) await assertOwned();
|
|
93
53
|
const task = loadTask(root, taskId);
|
|
94
54
|
if (!task || task.id !== taskId) throw new Error("invalid local canonical task snapshot");
|
|
95
|
-
if (
|
|
55
|
+
if (lease) {
|
|
96
56
|
await recoverLeadArtifact({ root, taskId, task, assertOwned });
|
|
97
57
|
await assertOwned();
|
|
98
58
|
} else {
|
|
@@ -9,7 +9,7 @@ import { runSupervisor } from "./loop-supervisor.mjs";
|
|
|
9
9
|
import { createLocalSupervisorAdapter } from "./loop-supervisor-local.mjs";
|
|
10
10
|
import { pathsOverlap, readyTaskItems, selectBuildBatch } from "./store.mjs";
|
|
11
11
|
import { runLocalWorkerBatch } from "./loop-local-workers.mjs";
|
|
12
|
-
import { reconcileLocalAssignments } from "./assignment-store.mjs";
|
|
12
|
+
import { ASSIGNMENT_STATE_LABELS, effectiveAssignmentState, reconcileLocalAssignments } from "./assignment-store.mjs";
|
|
13
13
|
import { ensureWorkerImage, defaultWorkerImageTag } from "./loop-worker-image.mjs";
|
|
14
14
|
import { workerCredentialKeys } from "./loop-worker-docker.mjs";
|
|
15
15
|
|
|
@@ -26,7 +26,7 @@ export function createLocalLoopSupervisor({ root: rootInput, taskId, agent, limi
|
|
|
26
26
|
const root = validateRoot(rootInput);
|
|
27
27
|
if (!isLoopProvider(agent)) throw new Error(`local lead agent must be ${LOOP_PROVIDER_LIST}`);
|
|
28
28
|
if (typeof driver !== "function" || typeof runner !== "function" || typeof onEvent !== "function") throw new Error("invalid local supervisor callbacks");
|
|
29
|
-
const adapter = createLocalSupervisorAdapter({ root, taskId });
|
|
29
|
+
const adapter = createLocalSupervisorAdapter({ root, taskId, onWarning: onEvent });
|
|
30
30
|
const getRecord = () => readLoopRecord(root, taskId);
|
|
31
31
|
const execute = async (mode) => {
|
|
32
32
|
requireLocalAuthority();
|
|
@@ -98,8 +98,14 @@ export function planLocalBuildBatch({ taskId, task, workers, assignments = [] })
|
|
|
98
98
|
if (!selected.length) {
|
|
99
99
|
// Every ready item is held back by an assignment; never auto-release it.
|
|
100
100
|
const blocking = active.filter((entry) => ready.some((itemId) => entry.itemId === itemId || pathsOverlap(byId.get(itemId).ownedPaths, entry.ownedPaths ?? [])));
|
|
101
|
-
const
|
|
102
|
-
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
const details = blocking.map((entry) => {
|
|
103
|
+
const state = entry.leaseExpiresAt ? effectiveAssignmentState(entry, now) : entry.state;
|
|
104
|
+
const claim = [entry.workerId ? `worker ${entry.workerId}` : null, `${ASSIGNMENT_STATE_LABELS[state] ?? state}`, entry.leaseExpiresAt ? `claim expires ${entry.leaseExpiresAt}` : null].filter(Boolean).join(", ");
|
|
105
|
+
return `${entry.state === "leased" ? "Active" : "Unresolved"} assignment ${entry.id} (${entry.state}) for ${entry.itemId} holds ${(entry.ownedPaths ?? []).join(", ")} [${claim}]`;
|
|
106
|
+
}).join("; ");
|
|
107
|
+
const releaseTarget = blocking.length === 1 ? blocking[0].id : "<assignment-id>";
|
|
108
|
+
throw new Error(`${details || `No ready build item of ${taskId} could be dispatched`}; inspect it (node .agents/aidlc/scripts/assignment.mjs show ${taskId}), then run node .agents/aidlc/scripts/assignment.mjs reconcile ${taskId} once the item is done, or node .agents/aidlc/scripts/assignment.mjs release ${taskId} ${releaseTarget} to drop the claim (discards the worker's unintegrated result)`);
|
|
103
109
|
}
|
|
104
110
|
const jobs = selected.map((itemId, index) => {
|
|
105
111
|
const [workerId, agent] = providers[index];
|
|
@@ -133,7 +139,7 @@ export async function dispatchLocalBuild({ root, taskId, task, workers, images =
|
|
|
133
139
|
for (const provider of new Set(jobs.map((job) => job.agent))) {
|
|
134
140
|
resolvedImages[provider] = ensureImage({ root, provider, image: images[provider] ?? defaultWorkerImageTag(provider), onEvent });
|
|
135
141
|
}
|
|
136
|
-
const completed = await runBatch({ root, taskId, jobs, worktreesRoot, docker: { images: resolvedImages, credentials }, signal: cancelSignal });
|
|
142
|
+
const completed = await runBatch({ root, taskId, jobs, worktreesRoot, docker: { images: resolvedImages, credentials }, onEvent, signal: cancelSignal });
|
|
137
143
|
for (const result of completed) markDone(root, taskId, result.itemId);
|
|
138
144
|
}
|
|
139
145
|
|
|
@@ -151,7 +157,7 @@ export function createIsolatedLocalSupervisor({ root: rootInput, taskId, agent,
|
|
|
151
157
|
if (!isLoopProvider(agent)) throw new Error(`local lead agent must be ${LOOP_PROVIDER_LIST}`);
|
|
152
158
|
if (!workers || typeof workers !== "object" || Array.isArray(workers) || !Object.keys(workers).length) throw new Error("Loop requires a configured lead and worker providers");
|
|
153
159
|
for (const [id, provider] of Object.entries(workers)) if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id) || !isLoopProvider(provider)) throw new Error(`invalid local worker: ${id}`);
|
|
154
|
-
const adapter = createLocalSupervisorAdapter({ root, taskId });
|
|
160
|
+
const adapter = createLocalSupervisorAdapter({ root, taskId, onWarning: onEvent });
|
|
155
161
|
const getRecord = () => readLoopRecord(root, taskId);
|
|
156
162
|
const images = Object.fromEntries([...new Set(Object.values(workers))].map((provider) => [provider, docker?.images?.[provider] ?? docker?.image ?? workerImages?.[provider] ?? process.env.AIDLC_WORKER_DOCKER_IMAGE ?? defaultWorkerImageTag(provider)]));
|
|
157
163
|
const credentials = docker?.credentials ?? Object.fromEntries([...new Set(Object.values(workers))].map((provider) => [provider, workerCredentialKeys(provider)]));
|
|
@@ -10,6 +10,8 @@ import { isLoopProvider } from './loop-drivers.mjs';
|
|
|
10
10
|
* the lead sandbox, not here. */
|
|
11
11
|
export const OPENCODE_CONTAINER_ERROR = 'OpenCode workers require file-based credentials and are not available in isolated containers yet';
|
|
12
12
|
|
|
13
|
+
const LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
14
|
+
|
|
13
15
|
/** OpenCode's data/config roots, redirected into the worker's tmpfs HOME so the
|
|
14
16
|
* container never reads the host ~/.local/share/opencode or ~/.config/opencode. */
|
|
15
17
|
const OPENCODE_XDG_ENV = [
|
|
@@ -33,7 +35,7 @@ export function workerCredentialKeys(agent, env = process.env) {
|
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
/** Build a Docker invocation. This function never accepts a shell command or credentials in arguments. */
|
|
36
|
-
export function workerDockerInvocation({ workspace, image, command, args, credentialEnv, hostEnv, uid, gid, containerName, sessionHome }) {
|
|
38
|
+
export function workerDockerInvocation({ workspace, image, command, args, credentialEnv, hostEnv, uid, gid, containerName, sessionHome, labels }) {
|
|
37
39
|
const root = realpathSync(workspace);
|
|
38
40
|
if (/[\x00-\x1f,]/.test(root)) throw new Error('Invalid Docker workspace path');
|
|
39
41
|
if (typeof image !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,255}$/.test(image)) throw new Error('Explicit Docker image is required');
|
|
@@ -43,6 +45,16 @@ export function workerDockerInvocation({ workspace, image, command, args, creden
|
|
|
43
45
|
const allowed = command === 'codex' ? ['OPENAI_API_KEY'] : command === 'claude' ? ['ANTHROPIC_API_KEY'] : ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENCODE_API_KEY'];
|
|
44
46
|
if (credentialEnv.some((key) => !allowed.includes(key)) || new Set(credentialEnv).size !== credentialEnv.length) throw new Error('Unsupported worker credential environment');
|
|
45
47
|
if (containerName !== undefined && (typeof containerName !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(containerName))) throw new Error('Invalid container name');
|
|
48
|
+
// Optional `--label key=value` pairs (aidlc.task/item/worker/assignment) so the
|
|
49
|
+
// dashboard can `docker ps --filter label=`; keys and values are plain identifiers.
|
|
50
|
+
const labelArgs = [];
|
|
51
|
+
if (labels !== undefined) {
|
|
52
|
+
if (!labels || typeof labels !== 'object' || Array.isArray(labels)) throw new Error('Invalid container labels');
|
|
53
|
+
for (const [key, value] of Object.entries(labels)) {
|
|
54
|
+
if (!LABEL_PATTERN.test(key) || typeof value !== 'string' || !LABEL_PATTERN.test(value)) throw new Error(`Invalid container label: ${key}`);
|
|
55
|
+
labelArgs.push('--label', `${key}=${value}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
46
58
|
let homeMount;
|
|
47
59
|
if (sessionHome !== undefined) {
|
|
48
60
|
const info = lstatSync(sessionHome);
|
|
@@ -54,5 +66,5 @@ export function workerDockerInvocation({ workspace, image, command, args, creden
|
|
|
54
66
|
const env = { PATH: hostEnv.PATH ?? '/usr/bin:/bin' };
|
|
55
67
|
for (const key of credentialEnv) env[key] = hostEnv[key];
|
|
56
68
|
const opencodeEnv = command === 'opencode' ? [...OPENCODE_XDG_ENV, 'OPENCODE_CONFIG_CONTENT={}'].flatMap((entry) => ['--env', entry]) : [];
|
|
57
|
-
return { command: 'docker', args: ['run', '--rm', '--interactive', ...(containerName ? ['--name', containerName] : []), '--read-only', '--user', `${uid}:${gid}`, '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--pids-limit', '256', '--memory', '2g', '--cpus', '2', '--tmpfs', '/tmp:rw,nosuid,nodev,size=256m', ...(homeMount ? ['--mount', homeMount] : ['--tmpfs', `/home/worker:rw,nosuid,nodev,size=128m,uid=${uid},gid=${gid}`]), '--env', 'HOME=/home/worker', '--workdir', '/workspace', '--mount', `type=bind,src=${root},dst=/workspace`, ...credentialEnv.flatMap((key) => ['--env', key]), ...opencodeEnv, '--entrypoint', command, image, ...args], env };
|
|
69
|
+
return { command: 'docker', args: ['run', '--rm', '--interactive', ...(containerName ? ['--name', containerName] : []), ...labelArgs, '--read-only', '--user', `${uid}:${gid}`, '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--pids-limit', '256', '--memory', '2g', '--cpus', '2', '--tmpfs', '/tmp:rw,nosuid,nodev,size=256m', ...(homeMount ? ['--mount', homeMount] : ['--tmpfs', `/home/worker:rw,nosuid,nodev,size=128m,uid=${uid},gid=${gid}`]), '--env', 'HOME=/home/worker', '--workdir', '/workspace', '--mount', `type=bind,src=${root},dst=/workspace`, ...credentialEnv.flatMap((key) => ['--env', key]), ...opencodeEnv, '--entrypoint', command, image, ...args], env };
|
|
58
70
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-task record of worker containers and a raw tee of their output.
|
|
3
|
+
*
|
|
4
|
+
* Both the local loop (`loop-local-workers.mjs`) and fleet workers
|
|
5
|
+
* (`loop-fleet-cli.mjs`) write the same structure under the task's own data
|
|
6
|
+
* directory, so the dashboard lists containers identically in every mode:
|
|
7
|
+
*
|
|
8
|
+
* .agents/data/tasks/<taskId>/runtime/workers/index.json one entry per container (turn)
|
|
9
|
+
* .agents/data/tasks/<taskId>/runtime/workers/<assignmentId>.log raw stdout+stderr, all turns
|
|
10
|
+
*
|
|
11
|
+
* The log is capped at WORKER_LOG_MAX_BYTES and rotates once to `.log.1`
|
|
12
|
+
* (overwritten), the same rule as the per-task event log. Writes never throw
|
|
13
|
+
* into the caller: the first failure is reported through `onError`, later ones
|
|
14
|
+
* are swallowed. The `runtime/` directory is operational and never committed.
|
|
15
|
+
*/
|
|
16
|
+
import { closeSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
17
|
+
import { hostname } from "node:os";
|
|
18
|
+
import { join, resolve } from "node:path";
|
|
19
|
+
import { taskRuntimeDir } from "./task-runtime.mjs";
|
|
20
|
+
|
|
21
|
+
export const WORKER_LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
22
|
+
export const WORKER_INDEX_MAX_ENTRIES = 500;
|
|
23
|
+
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
24
|
+
const MODES = new Set(["local", "fleet"]);
|
|
25
|
+
const safeId = (value, label) => { if (typeof value !== "string" || !ID_PATTERN.test(value)) throw new Error(`Invalid ${label}`); return value; };
|
|
26
|
+
const iso = () => new Date().toISOString();
|
|
27
|
+
|
|
28
|
+
/** True when `path` is missing or a real (non-symlink) entry. */
|
|
29
|
+
const refuseSymlink = (path) => {
|
|
30
|
+
try { if (lstatSync(path).isSymbolicLink()) throw new Error(`Worker log path crosses symlink: ${path}`); }
|
|
31
|
+
catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** `.agents/data/tasks/<taskId>/runtime/workers`, refusing symlinked components. */
|
|
35
|
+
export function workerLogDirectory(root, taskId) {
|
|
36
|
+
const path = join(taskRuntimeDir(root, safeId(taskId, "task id")), "workers");
|
|
37
|
+
refuseSymlink(path);
|
|
38
|
+
return path;
|
|
39
|
+
}
|
|
40
|
+
export const workerIndexPath = (root, taskId) => join(workerLogDirectory(root, taskId), "index.json");
|
|
41
|
+
export function workerLogPath(root, taskId, assignmentId, generation = 0) {
|
|
42
|
+
safeId(assignmentId, "assignment id");
|
|
43
|
+
const path = join(workerLogDirectory(root, taskId), `${assignmentId}.log${generation ? `.${generation}` : ""}`);
|
|
44
|
+
refuseSymlink(path);
|
|
45
|
+
return path;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const validEntry = (entry) => entry && typeof entry === "object" && !Array.isArray(entry) && typeof entry.name === "string" && typeof entry.assignmentId === "string" && typeof entry.startedAt === "string";
|
|
49
|
+
|
|
50
|
+
/** Read a task's container index; a missing or corrupt file reads as empty. */
|
|
51
|
+
export function readWorkerIndex(root, taskId) {
|
|
52
|
+
let raw;
|
|
53
|
+
try { raw = readFileSync(workerIndexPath(root, taskId), "utf8"); }
|
|
54
|
+
catch (error) { if (error?.code === "ENOENT") return []; throw error; }
|
|
55
|
+
try { const value = JSON.parse(raw); return Array.isArray(value) ? value.filter(validEntry) : []; }
|
|
56
|
+
catch { return []; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function writeWorkerIndex(directory, entries) {
|
|
60
|
+
const path = join(directory, "index.json"), temp = join(directory, `index.json.${process.pid}.${Date.now().toString(36)}.tmp`);
|
|
61
|
+
const trimmed = entries.length > WORKER_INDEX_MAX_ENTRIES ? entries.slice(entries.length - WORKER_INDEX_MAX_ENTRIES) : entries;
|
|
62
|
+
const fd = openSync(temp, "w", 0o644);
|
|
63
|
+
try { writeSync(fd, `${JSON.stringify(trimmed, null, 2)}\n`); fsyncSync(fd); }
|
|
64
|
+
finally { closeSync(fd); }
|
|
65
|
+
try { renameSync(temp, path); }
|
|
66
|
+
catch (error) { try { unlinkSync(temp); } catch { /* best effort */ } throw error; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Open the container record and raw log for one assignment.
|
|
71
|
+
* Returns `{ turnStart(containerName, turn), tee(child), turnEnd(exitCode), close() }`.
|
|
72
|
+
* `turnStart` appends `--- turn <N> container <name> <ISO> ---` and adds an index
|
|
73
|
+
* entry; `tee` appends every stdout/stderr chunk unmodified; `turnEnd` appends a
|
|
74
|
+
* trailer and records `endedAt`/`exitCode`; `close` ends a dangling turn
|
|
75
|
+
* (exitCode null) and releases the file descriptor. Methods never throw.
|
|
76
|
+
*/
|
|
77
|
+
export function openWorkerLog({ root, taskId, assignmentId, itemId, workerId, agent, mode, onError, maxBytes = WORKER_LOG_MAX_BYTES } = {}) {
|
|
78
|
+
if (typeof root !== "string" || !root) throw new Error("Worker log root is required");
|
|
79
|
+
safeId(taskId, "task id"); safeId(assignmentId, "assignment id"); safeId(itemId, "item id"); safeId(workerId, "worker id"); safeId(agent, "agent");
|
|
80
|
+
if (!MODES.has(mode)) throw new Error("Worker log mode must be local or fleet");
|
|
81
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error("Invalid worker log size cap");
|
|
82
|
+
const base = resolve(root), host = hostname(), logName = `${assignmentId}.log`;
|
|
83
|
+
let fd = null, size = 0, reported = false, current = null, closed = false;
|
|
84
|
+
const report = (error) => {
|
|
85
|
+
if (reported) return;
|
|
86
|
+
reported = true;
|
|
87
|
+
if (typeof onError === "function") { try { onError(error instanceof Error ? error : new Error(String(error))); } catch { /* diagnostics never throw */ } }
|
|
88
|
+
};
|
|
89
|
+
const guard = (fn) => { try { fn(); } catch (error) { report(error); } };
|
|
90
|
+
const directory = () => { const path = workerLogDirectory(base, taskId); mkdirSync(path, { recursive: true }); refuseSymlink(path); return path; };
|
|
91
|
+
const open = () => {
|
|
92
|
+
if (fd !== null) return;
|
|
93
|
+
const path = workerLogPath(base, taskId, assignmentId);
|
|
94
|
+
directory();
|
|
95
|
+
fd = openSync(path, "a", 0o644);
|
|
96
|
+
try { size = lstatSync(path).size; } catch { size = 0; }
|
|
97
|
+
};
|
|
98
|
+
const write = (chunk) => {
|
|
99
|
+
if (closed) return;
|
|
100
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");
|
|
101
|
+
if (!bytes.length) return;
|
|
102
|
+
open();
|
|
103
|
+
if (size > 0 && size + bytes.length > maxBytes) {
|
|
104
|
+
closeSync(fd); fd = null;
|
|
105
|
+
const path = workerLogPath(base, taskId, assignmentId);
|
|
106
|
+
refuseSymlink(workerLogPath(base, taskId, assignmentId, 1));
|
|
107
|
+
renameSync(path, `${path}.1`);
|
|
108
|
+
open();
|
|
109
|
+
}
|
|
110
|
+
let offset = 0;
|
|
111
|
+
while (offset < bytes.length) offset += writeSync(fd, bytes, offset, bytes.length - offset);
|
|
112
|
+
size += bytes.length;
|
|
113
|
+
};
|
|
114
|
+
const updateIndex = (mutate) => {
|
|
115
|
+
const dir = directory();
|
|
116
|
+
const entries = readWorkerIndex(base, taskId);
|
|
117
|
+
writeWorkerIndex(dir, mutate(entries));
|
|
118
|
+
};
|
|
119
|
+
const finish = (exitCode) => {
|
|
120
|
+
if (!current) return;
|
|
121
|
+
const entry = current; current = null;
|
|
122
|
+
const endedAt = iso(), code = Number.isSafeInteger(exitCode) ? exitCode : null;
|
|
123
|
+
try { write(`--- end turn ${entry.turn} container ${entry.name} exit ${code === null ? "signal" : code} ${endedAt} ---\n`); }
|
|
124
|
+
catch (error) { report(error); }
|
|
125
|
+
updateIndex((entries) => {
|
|
126
|
+
const index = entries.findIndex((candidate) => candidate.name === entry.name && candidate.assignmentId === assignmentId && candidate.turn === entry.turn);
|
|
127
|
+
const record = { ...(index === -1 ? entry.record : entries[index]), endedAt, exitCode: code };
|
|
128
|
+
if (index === -1) entries.push(record); else entries[index] = record;
|
|
129
|
+
return entries;
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
return {
|
|
133
|
+
turnStart(containerName, turn) {
|
|
134
|
+
guard(() => {
|
|
135
|
+
if (closed) throw new Error("Worker log is closed");
|
|
136
|
+
safeId(containerName, "container name");
|
|
137
|
+
if (!Number.isSafeInteger(turn) || turn < 0) throw new Error("Invalid worker turn");
|
|
138
|
+
if (current) finish(null);
|
|
139
|
+
const startedAt = iso();
|
|
140
|
+
const record = { name: containerName, assignmentId, itemId, workerId, agent, mode, host, turn, startedAt, log: logName };
|
|
141
|
+
current = { name: containerName, turn, record };
|
|
142
|
+
write(`--- turn ${turn} container ${containerName} ${startedAt} ---\n`);
|
|
143
|
+
updateIndex((entries) => { entries.push(record); return entries; });
|
|
144
|
+
});
|
|
145
|
+
},
|
|
146
|
+
tee(child) {
|
|
147
|
+
const listener = (chunk) => guard(() => write(chunk));
|
|
148
|
+
for (const stream of [child?.stdout, child?.stderr]) { if (stream && typeof stream.on === "function") stream.on("data", listener); }
|
|
149
|
+
return child;
|
|
150
|
+
},
|
|
151
|
+
turnEnd(exitCode) { guard(() => finish(exitCode)); },
|
|
152
|
+
close() {
|
|
153
|
+
guard(() => finish(null));
|
|
154
|
+
closed = true;
|
|
155
|
+
if (fd !== null) { const handle = fd; fd = null; try { closeSync(handle); } catch (error) { report(error); } }
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the repository HEAD commit for lifecycle wrappers (state.mjs, remote-state.mjs).
|
|
3
|
+
* The lifecycle core never runs git; wrappers call this and then store.mjs#recordTaskCommit.
|
|
4
|
+
*/
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
const shaPattern = /^[0-9a-f]{40,64}$/;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Returns the full HEAD commit SHA of the git repository at `root`, or `undefined` when git is
|
|
11
|
+
* unavailable, `root` is not inside a repository, or the repository has no commits. Never throws.
|
|
12
|
+
*/
|
|
13
|
+
export const headCommit = (root, { timeout = 5000 } = {}) => {
|
|
14
|
+
if (typeof root !== "string" || !root.trim()) return undefined;
|
|
15
|
+
try {
|
|
16
|
+
const output = execFileSync("git", ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], { cwd: root, shell: false, timeout, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", maxBuffer: 4096 });
|
|
17
|
+
const sha = String(output).trim().toLowerCase();
|
|
18
|
+
return shaPattern.test(sha) ? sha : undefined;
|
|
19
|
+
} catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
@@ -12,6 +12,8 @@ const phaseGate = (phase) => phase === "clarify" ? "G0_confirm" : phase === "pla
|
|
|
12
12
|
const iso = (value) => typeof value === "string" && Number.isFinite(Date.parse(value)) && new Date(Date.parse(value)).toISOString() === value;
|
|
13
13
|
const text = (value) => typeof value === "string" && Boolean(value.trim());
|
|
14
14
|
const idPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
15
|
+
const commitPattern = /^[0-9a-f]{7,64}$/;
|
|
16
|
+
const commitFields = ["baseCommit", "finalCommit"];
|
|
15
17
|
const digest = (content) => createHash("sha256").update(content).digest("hex");
|
|
16
18
|
const archiveRecordPath = (id) => `.agents/data/state/archive/${id}.json`;
|
|
17
19
|
const taskRecordContent = (task) => `${JSON.stringify(task, null, 2)}\n`;
|
|
@@ -74,6 +76,7 @@ const validateTask = (id, task) => {
|
|
|
74
76
|
if (task.closure && (!text(task.closure.reason) || !text(task.closure.source) || !iso(task.closure.recordedAt))) throw new Error(`Invalid task closure: ${id}`);
|
|
75
77
|
if ((task.status === "superseded") !== Boolean(task.successorTaskId) || (task.status === "closed" && task.successorTaskId)) throw new Error(`Invalid task successor: ${id}`);
|
|
76
78
|
if (task.predecessorTaskId !== undefined && !text(task.predecessorTaskId)) throw new Error(`Invalid task predecessor: ${id}`);
|
|
79
|
+
for (const field of commitFields) if (task[field] !== undefined && !(typeof task[field] === "string" && commitPattern.test(task[field]))) throw new Error(`Invalid task ${field}: ${id}`);
|
|
77
80
|
for (const path of Object.values(task.artifacts ?? {})) if (path) {
|
|
78
81
|
const cleaned = normalize(path);
|
|
79
82
|
if (isAbsolute(cleaned) || cleaned === ".." || cleaned.startsWith(`..${separator}`) || !cleaned.startsWith(`.agents${separator}data${separator}tasks${separator}`)) throw new Error(`Invalid artifact path for ${id}: ${path}`);
|
|
@@ -278,6 +281,14 @@ export const transitionTask = (state, id, target) => {
|
|
|
278
281
|
task.phase = target; task.gate = phaseGate(target); task.status = target === "done" ? "done" : "active"; delete task.legacyG2Wait; task.updatedAt = new Date().toISOString(); return task;
|
|
279
282
|
};
|
|
280
283
|
|
|
284
|
+
/** Record a git SHA on a task. Wrappers resolve the SHA (lib/repo-head.mjs); the core never runs git. */
|
|
285
|
+
export const recordTaskCommit = (task, field, sha) => {
|
|
286
|
+
if (!task || typeof task !== "object") throw new Error("recordTaskCommit requires a task");
|
|
287
|
+
if (!commitFields.includes(field)) throw new Error(`Unknown task commit field: ${field}`);
|
|
288
|
+
if (typeof sha !== "string" || !commitPattern.test(sha)) throw new Error(`Invalid ${field}: expected a lowercase hex git SHA (7-64 chars)`);
|
|
289
|
+
task[field] = sha; task.updatedAt = new Date().toISOString(); return task;
|
|
290
|
+
};
|
|
291
|
+
|
|
281
292
|
export const readyTaskItems = (task) => {
|
|
282
293
|
const byId = new Map(task.tasks.map((item) => [item.id, item]));
|
|
283
294
|
if (byId.size !== task.tasks.length) throw new Error(`Duplicate execution item: ${task.id}`);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time, idempotent move of per-task runtime files from the layout of older
|
|
3
|
+
* aidlc-kit versions (`.agents/data/loop/assignments/<task>.json`,
|
|
4
|
+
* `.agents/data/loop/events/<task>.jsonl`, `.agents/data/loop/<task>.json`,
|
|
5
|
+
* `.agents/data/tasks/<task>/loop-intake.json`, `.../requirement.md`) to
|
|
6
|
+
* `.agents/data/tasks/<task>/runtime/` (see task-runtime.mjs).
|
|
7
|
+
*
|
|
8
|
+
* Rules: a file is renamed only when its destination does not exist (a
|
|
9
|
+
* destination that already exists is reported `skipped` and the source is left
|
|
10
|
+
* for inspection); nothing is ever deleted or overwritten; only regular files
|
|
11
|
+
* move (symlinks are reported `skipped`); a rename across filesystems falls
|
|
12
|
+
* back to copy + unlink. Leases, locks and guards stay where they are: locks
|
|
13
|
+
* are short mutexes that expire on their own, leases belong to
|
|
14
|
+
* `.agents/data/loop/`. Every writer calls `migrateTaskRuntime` for its own
|
|
15
|
+
* file before its first write, the local daemon calls `migrateAllTaskRuntime`
|
|
16
|
+
* once at start, and `loop-entry.mjs migrate-data` runs it on demand.
|
|
17
|
+
*/
|
|
18
|
+
import { copyFileSync, lstatSync, readdirSync, renameSync, unlinkSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { ensureTaskRuntimeDir, isRegularFile, isTaskId, legacyTaskRuntimePath, MIGRATED_TASK_RUNTIME_FILES, taskRuntimePath } from "./task-runtime.mjs";
|
|
21
|
+
|
|
22
|
+
const exists = (path) => { try { lstatSync(path); return true; } catch (error) { if (error?.code === "ENOENT") return false; throw error; } };
|
|
23
|
+
|
|
24
|
+
const move = (from, to) => {
|
|
25
|
+
try { renameSync(from, to); }
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (error?.code !== "EXDEV") throw error;
|
|
28
|
+
copyFileSync(from, to);
|
|
29
|
+
unlinkSync(from);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Move the legacy runtime files of one task. `names` limits the files
|
|
34
|
+
* considered (default: every migrated file). Returns one entry per legacy file
|
|
35
|
+
* found: `{ taskId, name, from, to, action: "moved" | "skipped" | "would-move", reason? }`. */
|
|
36
|
+
export function migrateTaskRuntime(root, taskId, { dryRun = false, names = MIGRATED_TASK_RUNTIME_FILES } = {}) {
|
|
37
|
+
const entries = [];
|
|
38
|
+
for (const name of names) {
|
|
39
|
+
let from, to;
|
|
40
|
+
try { from = legacyTaskRuntimePath(root, taskId, name); if (!exists(from)) continue; to = taskRuntimePath(root, taskId, name); }
|
|
41
|
+
catch (error) { entries.push({ taskId, name, from: from ?? null, to: to ?? null, action: "skipped", reason: error.message }); continue; }
|
|
42
|
+
const entry = { taskId, name, from, to };
|
|
43
|
+
if (!isRegularFile(from)) { entries.push({ ...entry, action: "skipped", reason: "source is not a regular file" }); continue; }
|
|
44
|
+
if (exists(to)) { entries.push({ ...entry, action: "skipped", reason: "destination already exists" }); continue; }
|
|
45
|
+
if (dryRun) { entries.push({ ...entry, action: "would-move" }); continue; }
|
|
46
|
+
try {
|
|
47
|
+
ensureTaskRuntimeDir(root, taskId);
|
|
48
|
+
move(from, to);
|
|
49
|
+
entries.push({ ...entry, action: "moved" });
|
|
50
|
+
} catch (error) { entries.push({ ...entry, action: "skipped", reason: error.message }); }
|
|
51
|
+
}
|
|
52
|
+
return entries;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const listDir = (dir) => { try { return readdirSync(dir).sort(); } catch (error) { if (error?.code === "ENOENT") return []; throw error; } };
|
|
56
|
+
|
|
57
|
+
/** Task ids that still have a legacy runtime file somewhere under `.agents/data`. */
|
|
58
|
+
export function legacyTaskRuntimeIds(root) {
|
|
59
|
+
const data = join(root, ".agents", "data");
|
|
60
|
+
const ids = new Set();
|
|
61
|
+
const add = (value) => { if (isTaskId(value)) ids.add(value); };
|
|
62
|
+
for (const name of listDir(join(data, "loop", "assignments"))) { const match = /^(.+)\.json$/.exec(name); if (match) add(match[1]); }
|
|
63
|
+
for (const name of listDir(join(data, "loop", "events"))) { const match = /^(.+)\.jsonl(?:\.1)?$/.exec(name); if (match) add(match[1]); }
|
|
64
|
+
// `<task>.json` run records only: `<task>.supervisor.json` (lead checkpoint), `<task>.gate-receipt.json`, `ui.json` and `cli.json` stay in the loop directory.
|
|
65
|
+
for (const name of listDir(join(data, "loop"))) { const match = /^(.+)\.json$/.exec(name); if (match && !["ui", "cli"].includes(match[1]) && !/\.(supervisor|gate-receipt)$/.test(match[1])) add(match[1]); }
|
|
66
|
+
for (const name of listDir(join(data, "tasks"))) { if (exists(join(data, "tasks", name, "loop-intake.json")) || exists(join(data, "tasks", name, "requirement.md"))) add(name); }
|
|
67
|
+
return [...ids].sort();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Migrate every task with legacy runtime files. Returns `{ dryRun, entries, moved, skipped }`. */
|
|
71
|
+
export function migrateAllTaskRuntime(root, { dryRun = false } = {}) {
|
|
72
|
+
const entries = [];
|
|
73
|
+
for (const taskId of legacyTaskRuntimeIds(root)) entries.push(...migrateTaskRuntime(root, taskId, { dryRun }));
|
|
74
|
+
return { dryRun, entries, moved: entries.filter((entry) => entry.action === "moved").length, skipped: entries.filter((entry) => entry.action === "skipped").length };
|
|
75
|
+
}
|