@felixdotgo/aidlc-kit 0.1.1 → 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 +14 -13
- package/README.md +37 -43
- 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 +2 -2
- package/dist/assets/.agents/aidlc/phase-clarify.md +1 -1
- package/dist/assets/.agents/aidlc/phase-plan.md +2 -2
- package/dist/assets/.agents/aidlc/schemas/config.schema.json +2 -1
- package/dist/assets/.agents/aidlc/schemas/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 +261 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-commit-message.mjs +45 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +206 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +100 -14
- package/dist/assets/.agents/aidlc/scripts/lib/loop-env.mjs +279 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +105 -32
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +17 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +24 -7
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +42 -16
- package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +20 -6
- package/dist/assets/.agents/aidlc/scripts/lib/loop-holder.mjs +376 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +33 -7
- 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 +116 -28
- package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +114 -12
- package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +117 -0
- 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 +149 -6
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +9 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +44 -6
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-image.mjs +64 -0
- 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 +68 -2
- 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 +168 -73
- package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +19 -7
- package/dist/assets/.agents/aidlc/scripts/loop.cmd +101 -0
- package/dist/assets/.agents/aidlc/scripts/loop.mjs +2 -1
- 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/.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/changes.js +83 -0
- package/dist/assets/ui/common.js +664 -0
- package/dist/assets/ui/dashboard.css +636 -0
- 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/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 +68 -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 +68 -0
- package/dist/src/cli/commands.d.ts +8 -0
- package/dist/src/cli/commands.js +159 -0
- package/dist/src/cli/loop-launcher.d.ts +47 -0
- package/dist/src/cli/loop-launcher.js +322 -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 +28 -191
- package/dist/src/gate.js +3 -2
- package/dist/src/installer.d.ts +14 -7
- package/dist/src/installer.js +42 -143
- 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 +142 -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 +55 -7
- package/dist/src/state.d.ts +16 -11
- package/dist/src/state.js +16 -11
- package/dist/src/task-ui.d.ts +33 -0
- package/dist/src/task-ui.js +33 -0
- 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-board.js +10 -10
- package/dist/src/upgrade.d.ts +0 -1
- package/dist/src/upgrade.js +24 -85
- package/dist/src/workflow.d.ts +4 -0
- package/dist/src/workflow.js +36 -4
- package/docs/README.md +41 -22
- package/docs/architecture.md +101 -37
- package/docs/command-reference.md +289 -52
- package/docs/configuration.md +70 -30
- package/docs/coordination-service.md +91 -30
- package/docs/design-decisions.md +87 -0
- package/docs/development.md +124 -49
- package/docs/loop-local-run.md +228 -0
- package/docs/loop-runbook.md +214 -0
- package/docs/operating-workflow.md +55 -42
- package/docs/testing-and-release.md +72 -49
- package/docs/troubleshooting.md +221 -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
|
@@ -1,24 +1,32 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
2
5
|
import { requireLocalAuthority } from "./authority.mjs";
|
|
3
6
|
import { runAgentTurn, isLoopProvider, LOOP_PROVIDER_LIST } from "./loop-drivers.mjs";
|
|
4
7
|
import { readLoopRecord, runLoop, validateRoot } from "./loop-runtime.mjs";
|
|
5
8
|
import { runSupervisor } from "./loop-supervisor.mjs";
|
|
6
9
|
import { createLocalSupervisorAdapter } from "./loop-supervisor-local.mjs";
|
|
10
|
+
import { pathsOverlap, readyTaskItems, selectBuildBatch } from "./store.mjs";
|
|
11
|
+
import { runLocalWorkerBatch } from "./loop-local-workers.mjs";
|
|
12
|
+
import { ASSIGNMENT_STATE_LABELS, effectiveAssignmentState, reconcileLocalAssignments } from "./assignment-store.mjs";
|
|
13
|
+
import { ensureWorkerImage, defaultWorkerImageTag } from "./loop-worker-image.mjs";
|
|
14
|
+
import { workerCredentialKeys } from "./loop-worker-docker.mjs";
|
|
7
15
|
|
|
8
16
|
const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
9
17
|
|
|
10
18
|
/**
|
|
11
19
|
* Bind the canonical local store, distinct supervisor lease and the existing
|
|
12
20
|
* leased runner. The caller owns process supervision and human gate approval.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
21
|
+
* `signal` stops polling and asks runLoop to stop at its next turn boundary;
|
|
22
|
+
* `cancelSignal` also cancels the in-flight agent turn. Both end as `stopped`.
|
|
15
23
|
*/
|
|
16
|
-
export function createLocalLoopSupervisor({ root: rootInput, taskId, agent, limits, driver = runAgentTurn, preflight = true, pollMs, signal, onEvent = () => {}, runner = runLoop } = {}) {
|
|
24
|
+
export function createLocalLoopSupervisor({ root: rootInput, taskId, agent, limits, driver = runAgentTurn, preflight = true, pollMs, signal, cancelSignal, untilStop = false, onEvent = () => {}, runner = runLoop } = {}) {
|
|
17
25
|
requireLocalAuthority();
|
|
18
26
|
const root = validateRoot(rootInput);
|
|
19
27
|
if (!isLoopProvider(agent)) throw new Error(`local lead agent must be ${LOOP_PROVIDER_LIST}`);
|
|
20
28
|
if (typeof driver !== "function" || typeof runner !== "function" || typeof onEvent !== "function") throw new Error("invalid local supervisor callbacks");
|
|
21
|
-
const adapter = createLocalSupervisorAdapter({ root, taskId });
|
|
29
|
+
const adapter = createLocalSupervisorAdapter({ root, taskId, onWarning: onEvent });
|
|
22
30
|
const getRecord = () => readLoopRecord(root, taskId);
|
|
23
31
|
const execute = async (mode) => {
|
|
24
32
|
requireLocalAuthority();
|
|
@@ -27,7 +35,7 @@ export function createLocalLoopSupervisor({ root: rootInput, taskId, agent, limi
|
|
|
27
35
|
if (mode === "resume" && !record) throw new Error("Local loop record missing; use start");
|
|
28
36
|
if (record && record.agent !== agent) throw new Error("Resume must use the recorded lead agent");
|
|
29
37
|
let nextMode = mode;
|
|
30
|
-
return runSupervisor({ taskId, authority: "local", ...adapter, pollMs, signal, onEvent,
|
|
38
|
+
return runSupervisor({ taskId, authority: "local", ...adapter, pollMs, signal, untilStop, onEvent,
|
|
31
39
|
runTurn: async ({ precondition }) => {
|
|
32
40
|
if (signal?.aborted) return { status: "stopped", taskId };
|
|
33
41
|
requireLocalAuthority();
|
|
@@ -37,7 +45,142 @@ export function createLocalLoopSupervisor({ root: rootInput, taskId, agent, limi
|
|
|
37
45
|
if (snapshot.task.status !== "active" || snapshot.nextAction.classification !== "run_phase") return { status: "blocked", taskId, reason: "Canonical state changed before runner acquired lease" };
|
|
38
46
|
expected = { taskId, phase: snapshot.task.phase, taskHash: digest(snapshot.task), evidenceHash: digest(snapshot.task.evidence), evidenceCount: snapshot.task.evidence.length };
|
|
39
47
|
}
|
|
40
|
-
const result = await runner({ root, taskId, agent, mode: nextMode, limits, driver, preflight, precondition: expected, onEvent });
|
|
48
|
+
const result = await runner({ root, taskId, agent, mode: nextMode, limits, driver, preflight, precondition: expected, onEvent, signal, cancelSignal });
|
|
49
|
+
nextMode = "resume";
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
start: () => execute("run"),
|
|
56
|
+
resume: () => execute("resume"),
|
|
57
|
+
async status() {
|
|
58
|
+
requireLocalAuthority();
|
|
59
|
+
const snapshot = await adapter.readTask({ taskId });
|
|
60
|
+
return { taskId, authority: "local", record: getRecord(), nextAction: snapshot.nextAction };
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
const itemPrompt = (root, taskId, itemId) => {
|
|
67
|
+
const packet = JSON.parse(execFileSync(process.execPath, [join(root, ".agents/aidlc/scripts/context.mjs"), taskId, "--phase", "build", "--item", itemId, "--format", "json", "--root", root], { cwd: root, encoding: "utf8", timeout: 30000, maxBuffer: 2 * 1024 * 1024 }));
|
|
68
|
+
if (typeof packet?.content !== "string" || !packet.content.trim()) throw new Error(`Context packet for ${itemId} was empty or invalid`);
|
|
69
|
+
return `${packet.content}\n\nOnly modify the assigned owned paths. 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.`;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const ACTIVE_ASSIGNMENT_STATES = new Set(["leased", "reported", "uncertain"]);
|
|
73
|
+
const UNRESOLVED_ASSIGNMENT_STATES = new Set(["reported", "uncertain"]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Pure batch planner for local build dispatch. Selects at most one item per
|
|
77
|
+
* worker from the dependency-ready items, skipping (deferring) items whose
|
|
78
|
+
* owned paths overlap each other or an active assignment, and items whose own
|
|
79
|
+
* prior assignment is unresolved. Overlap never throws: an overlapping item
|
|
80
|
+
* simply waits for a later batch. Throws only for a plan error (ready item
|
|
81
|
+
* without owned paths) or when every ready item is blocked by an active
|
|
82
|
+
* assignment, naming those assignments so a human can inspect them.
|
|
83
|
+
*/
|
|
84
|
+
export function planLocalBuildBatch({ taskId, task, workers, assignments = [] }) {
|
|
85
|
+
const ready = readyTaskItems(task).ready;
|
|
86
|
+
if (!ready.length) return { jobs: [], deferred: [] };
|
|
87
|
+
const providers = Object.entries(workers ?? {});
|
|
88
|
+
if (!providers.length) throw new Error("Loop requires a configured lead and worker providers");
|
|
89
|
+
const byId = new Map(task.tasks.map((item) => [item.id, item]));
|
|
90
|
+
for (const itemId of ready) {
|
|
91
|
+
const item = byId.get(itemId);
|
|
92
|
+
if (!item || !Array.isArray(item.ownedPaths) || !item.ownedPaths.length) throw new Error(`Build item ${itemId} requires an owned path scope for Docker dispatch`);
|
|
93
|
+
}
|
|
94
|
+
const active = assignments.filter((entry) => ACTIVE_ASSIGNMENT_STATES.has(entry.state));
|
|
95
|
+
const busyPaths = active.flatMap((entry) => entry.ownedPaths ?? []);
|
|
96
|
+
const unresolvedItems = [...new Set(active.filter((entry) => UNRESOLVED_ASSIGNMENT_STATES.has(entry.state) && byId.get(entry.itemId)?.status !== "done").map((entry) => entry.itemId))];
|
|
97
|
+
const { selected, deferred } = selectBuildBatch(task, { capacity: providers.length, busyPaths, exclude: unresolvedItems });
|
|
98
|
+
if (!selected.length) {
|
|
99
|
+
// Every ready item is held back by an assignment; never auto-release it.
|
|
100
|
+
const blocking = active.filter((entry) => ready.some((itemId) => entry.itemId === itemId || pathsOverlap(byId.get(itemId).ownedPaths, entry.ownedPaths ?? [])));
|
|
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)`);
|
|
109
|
+
}
|
|
110
|
+
const jobs = selected.map((itemId, index) => {
|
|
111
|
+
const [workerId, agent] = providers[index];
|
|
112
|
+
return { itemId, workerId, agent, ownedPaths: [...byId.get(itemId).ownedPaths] };
|
|
113
|
+
});
|
|
114
|
+
return { jobs, deferred };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const markItemDone = (root, taskId, itemId) => {
|
|
118
|
+
const envelope = JSON.parse(execFileSync(process.execPath, [join(root, ".agents/aidlc/scripts/state.mjs"), "task", "item", taskId, itemId, "--status", "done", "--root", root], { cwd: root, encoding: "utf8", timeout: 30000, maxBuffer: 2 * 1024 * 1024 }));
|
|
119
|
+
if (envelope?.ok !== true) throw new Error(`Failed to mark item ${itemId} done`);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** Fan dependency-ready build items out to Docker-isolated workers, then record
|
|
123
|
+
* each integrated item as done. No worker ever marks canonical progress itself.
|
|
124
|
+
* Overlapping or excess ready items are deferred to a later batch, not failed.
|
|
125
|
+
* The trailing hooks exist for tests; production uses the defaults. */
|
|
126
|
+
export async function dispatchLocalBuild({ root, taskId, task, workers, images = {}, credentials, worktreesRoot, onEvent = () => {}, cancelSignal, runBatch = runLocalWorkerBatch, ensureImage = ensureWorkerImage, prompt = itemPrompt, markDone = markItemDone }) {
|
|
127
|
+
// Reconcile completed items first so their owned paths are released for the
|
|
128
|
+
// next batch: an integrated item's assignment stays `reported` (still active)
|
|
129
|
+
// until reconciled, and overlapping owned paths would otherwise block the next
|
|
130
|
+
// claim even though the item is already done.
|
|
131
|
+
const { assignments } = reconcileLocalAssignments(root, taskId);
|
|
132
|
+
const { jobs: planned, deferred } = planLocalBuildBatch({ taskId, task, workers, assignments });
|
|
133
|
+
if (!planned.length) return;
|
|
134
|
+
for (const { id, reason } of deferred) await onEvent({ type: "build_deferred", taskId, itemId: id, reason });
|
|
135
|
+
const jobs = planned.map((job) => ({ ...job, prompt: prompt(root, taskId, job.itemId) }));
|
|
136
|
+
// Auto-build any missing per-provider image before dispatch so the user never
|
|
137
|
+
// has to build the worker image by hand.
|
|
138
|
+
const resolvedImages = {};
|
|
139
|
+
for (const provider of new Set(jobs.map((job) => job.agent))) {
|
|
140
|
+
resolvedImages[provider] = ensureImage({ root, provider, image: images[provider] ?? defaultWorkerImageTag(provider), onEvent });
|
|
141
|
+
}
|
|
142
|
+
const completed = await runBatch({ root, taskId, jobs, worktreesRoot, docker: { images: resolvedImages, credentials }, onEvent, signal: cancelSignal });
|
|
143
|
+
for (const result of completed) markDone(root, taskId, result.itemId);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Local end-to-end supervisor: host-executed lead (Codex, Claude Code, or
|
|
148
|
+
* OpenCode) for clarify/plan/wrap, and Docker-isolated workers for build items.
|
|
149
|
+
* Reuses the local supervisor adapter + runSupervisor and the leased runLoop
|
|
150
|
+
* with a build dispatcher wired to runLocalWorkerBatch. Workers only ever
|
|
151
|
+
* receive environment credentials; the host lead and the trusted terminal own
|
|
152
|
+
* gates and lifecycle state.
|
|
153
|
+
*/
|
|
154
|
+
export function createIsolatedLocalSupervisor({ root: rootInput, taskId, agent, workers = {}, limits, docker, worktreesRoot, workerImages = {}, pollMs, signal, cancelSignal, untilStop = false, onEvent = () => {}, runner = runLoop, driver = runAgentTurn, preflight = true } = {}) {
|
|
155
|
+
requireLocalAuthority();
|
|
156
|
+
const root = validateRoot(rootInput);
|
|
157
|
+
if (!isLoopProvider(agent)) throw new Error(`local lead agent must be ${LOOP_PROVIDER_LIST}`);
|
|
158
|
+
if (!workers || typeof workers !== "object" || Array.isArray(workers) || !Object.keys(workers).length) throw new Error("Loop requires a configured lead and worker providers");
|
|
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}`);
|
|
160
|
+
const adapter = createLocalSupervisorAdapter({ root, taskId, onWarning: onEvent });
|
|
161
|
+
const getRecord = () => readLoopRecord(root, taskId);
|
|
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)]));
|
|
163
|
+
const credentials = docker?.credentials ?? Object.fromEntries([...new Set(Object.values(workers))].map((provider) => [provider, workerCredentialKeys(provider)]));
|
|
164
|
+
const worktrees = resolve(worktreesRoot ?? process.env.AIDLC_LOCAL_WORKTREES_ROOT ?? join(tmpdir(), "aidlc-local-worktrees"));
|
|
165
|
+
const execute = async (mode) => {
|
|
166
|
+
requireLocalAuthority();
|
|
167
|
+
const record = getRecord();
|
|
168
|
+
if (mode === "run" && record) throw new Error("Local loop record already exists; use resume");
|
|
169
|
+
if (mode === "resume" && !record) throw new Error("Local loop record missing; use start");
|
|
170
|
+
if (record && record.agent !== agent) throw new Error("Resume must use the recorded lead agent");
|
|
171
|
+
let nextMode = mode;
|
|
172
|
+
return runSupervisor({ taskId, authority: "local", ...adapter, pollMs, signal, untilStop, onEvent,
|
|
173
|
+
runTurn: async ({ precondition }) => {
|
|
174
|
+
if (signal?.aborted) return { status: "stopped", taskId };
|
|
175
|
+
requireLocalAuthority();
|
|
176
|
+
let expected = precondition;
|
|
177
|
+
if (!expected) {
|
|
178
|
+
const snapshot = await adapter.readTask({ taskId });
|
|
179
|
+
if (snapshot.task.status !== "active" || snapshot.nextAction.classification !== "run_phase") return { status: "blocked", taskId, reason: "Canonical state changed before runner acquired lease" };
|
|
180
|
+
expected = { taskId, phase: snapshot.task.phase, taskHash: digest(snapshot.task), evidenceHash: digest(snapshot.task.evidence), evidenceCount: snapshot.task.evidence.length };
|
|
181
|
+
}
|
|
182
|
+
const dispatchBuild = async ({ root, taskId, task }) => dispatchLocalBuild({ root, taskId, task, workers, images, credentials, worktreesRoot: worktrees, onEvent, cancelSignal });
|
|
183
|
+
const result = await runner({ root, taskId, agent, mode: nextMode, limits, driver, dispatchBuild, preflight, precondition: expected, onEvent, signal, cancelSignal });
|
|
41
184
|
nextMode = "resume";
|
|
42
185
|
return result;
|
|
43
186
|
}
|
|
@@ -37,6 +37,8 @@ export async function checkGateCheckpoint({ checkpoint, snapshot, verifyFleetApp
|
|
|
37
37
|
}
|
|
38
38
|
const appended = task.evidence.slice(checkpoint.evidenceCount);
|
|
39
39
|
const approvals = appended.filter((entry) => entry.kind === "approval" && entry.gate === checkpoint.gate && entry.result === "pass" && typeof entry.source === "string" && entry.source.trim());
|
|
40
|
+
const cancelled = appended.some((entry) => entry.kind === "diagnostic" && entry.result === "pass" && typeof entry.source === "string" && entry.source.trim() && typeof entry.detail === "string" && entry.detail.startsWith(`Cancelled gate wait at ${checkpoint.gate}: `));
|
|
41
|
+
if (task.phase === checkpoint.phase && task.gate === checkpoint.gate && task.status === "active" && nextAction.classification === "run_phase" && approvals.length === 0 && cancelled) return { status: "rejected", precondition: { taskId: checkpoint.taskId, gate: checkpoint.gate, phase: task.phase, taskHash: hash(task), evidenceCount: task.evidence.length, evidenceHash: hash(task.evidence) } };
|
|
40
42
|
if (task.phase !== TARGET[checkpoint.gate] || task.status !== "active" || nextAction.classification !== "run_phase" || approvals.length !== 1) return { status: "blocked", reason: "gate changed without its matching approval" };
|
|
41
43
|
if (checkpoint.authority === "local" && verifyLocalApproval && !await verifyLocalApproval({ checkpoint, snapshot })) return { status: "blocked", reason: "local approval lacks the matching host receipt" };
|
|
42
44
|
if (checkpoint.authority === "fleet") {
|
|
@@ -57,7 +59,7 @@ export async function checkGateCheckpoint({ checkpoint, snapshot, verifyFleetApp
|
|
|
57
59
|
* renewable exclusive supervisor lease that does not block human gate approval.
|
|
58
60
|
* No callback is allowed to approve a human gate.
|
|
59
61
|
*/
|
|
60
|
-
export async function runSupervisor({ taskId, authority, acquireSupervisor, readTask, runTurn, loadCheckpoint, saveCheckpoint, clearCheckpoint, verifyFleetApproval, verifyLocalApproval, pollMs = 5000, signal, onEvent = () => {} }) {
|
|
62
|
+
export async function runSupervisor({ taskId, authority, acquireSupervisor, readTask, runTurn, loadCheckpoint, saveCheckpoint, clearCheckpoint, verifyFleetApproval, verifyLocalApproval, pollMs = 5000, signal, untilStop = false, onEvent = () => {} }) {
|
|
61
63
|
validId(taskId, "task ID");
|
|
62
64
|
if (!["local", "fleet"].includes(authority)) throw new Error("invalid authority");
|
|
63
65
|
for (const [name, value] of Object.entries({ acquireSupervisor, readTask, runTurn, loadCheckpoint, saveCheckpoint, clearCheckpoint, onEvent })) required(value, name);
|
|
@@ -74,9 +76,14 @@ export async function runSupervisor({ taskId, authority, acquireSupervisor, read
|
|
|
74
76
|
const verdict = await checkGateCheckpoint({ checkpoint, snapshot, verifyFleetApproval, verifyLocalApproval });
|
|
75
77
|
if (verdict.status === "blocked") return { status: "blocked", taskId, reason: verdict.reason };
|
|
76
78
|
if (verdict.status === "wait") {
|
|
79
|
+
if (untilStop) return { status: "gate", taskId, gate: checkpoint.gate };
|
|
77
80
|
try { await sleep(pollMs, undefined, { signal }); } catch (error) { if (error?.name !== "AbortError") throw error; }
|
|
78
81
|
continue;
|
|
79
82
|
}
|
|
83
|
+
if (verdict.status === "rejected") {
|
|
84
|
+
await clearCheckpoint({ taskId, authority, checkpoint });
|
|
85
|
+
checkpoint = null;
|
|
86
|
+
}
|
|
80
87
|
precondition = verdict.precondition;
|
|
81
88
|
} else if (snapshot.nextAction.classification !== "run_phase") {
|
|
82
89
|
return { status: snapshot.nextAction.classification === "await_user" ? "blocked" : snapshot.nextAction.classification, taskId, reason: snapshot.nextAction.classification === "await_user" ? "gate checkpoint missing; inspect the gate before resuming" : snapshot.nextAction.reason };
|
|
@@ -94,6 +101,7 @@ export async function runSupervisor({ taskId, authority, acquireSupervisor, read
|
|
|
94
101
|
await ownership.assertOwned();
|
|
95
102
|
await saveCheckpoint({ taskId, authority, checkpoint: next, previous: checkpoint });
|
|
96
103
|
checkpoint = next;
|
|
104
|
+
if (untilStop) return { status: "gate", taskId, gate: next.gate, ...(next.snapshotId ? { snapshotId: next.snapshotId } : {}), gateView: result.gateView };
|
|
97
105
|
await onEvent({ type: "gate", taskId, gate: next.gate, ...(next.snapshotId ? { snapshotId: next.snapshotId } : {}), gateView: result.gateView });
|
|
98
106
|
}
|
|
99
107
|
return { status: "stopped", taskId };
|
|
@@ -2,22 +2,59 @@ import { lstatSync, realpathSync } from 'node:fs';
|
|
|
2
2
|
import { sep } from 'node:path';
|
|
3
3
|
import { isLoopProvider } from './loop-drivers.mjs';
|
|
4
4
|
|
|
5
|
-
/** Container workers receive credentials as environment keys only. OpenCode
|
|
6
|
-
*
|
|
5
|
+
/** Container workers receive credentials as environment keys only. OpenCode can
|
|
6
|
+
* authenticate from environment variables too, so the worker path redirects its
|
|
7
|
+
* XDG data/config roots into the worker's ephemeral HOME instead of failing.
|
|
8
|
+
* The isolated LEAD container still rejects OpenCode (it needs a file-based
|
|
9
|
+
* session store that host-brokered proposals do not use); that check lives in
|
|
10
|
+
* the lead sandbox, not here. */
|
|
7
11
|
export const OPENCODE_CONTAINER_ERROR = 'OpenCode workers require file-based credentials and are not available in isolated containers yet';
|
|
8
12
|
|
|
13
|
+
const LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
14
|
+
|
|
15
|
+
/** OpenCode's data/config roots, redirected into the worker's tmpfs HOME so the
|
|
16
|
+
* container never reads the host ~/.local/share/opencode or ~/.config/opencode. */
|
|
17
|
+
const OPENCODE_XDG_ENV = [
|
|
18
|
+
'XDG_DATA_HOME=/home/worker/.local/share',
|
|
19
|
+
'XDG_CONFIG_HOME=/home/worker/.config',
|
|
20
|
+
'XDG_CACHE_HOME=/home/worker/.cache',
|
|
21
|
+
'XDG_STATE_HOME=/home/worker/.local/state',
|
|
22
|
+
'OPENCODE_DB=/home/worker/.local/share/opencode/opencode.db'
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** Environment credential keys a containerized worker of `agent` receives.
|
|
26
|
+
* OpenCode authenticates from the environment like codex/claude; its key
|
|
27
|
+
* follows the configured model's provider (opencode-go → OPENCODE_API_KEY). */
|
|
28
|
+
export function workerCredentialKeys(agent, env = process.env) {
|
|
29
|
+
if (agent === 'codex') return ['OPENAI_API_KEY'];
|
|
30
|
+
if (agent === 'claude') return ['ANTHROPIC_API_KEY'];
|
|
31
|
+
const model = env?.AIDLC_OPENCODE_MODEL ?? '';
|
|
32
|
+
if (model.startsWith('openai/')) return ['OPENAI_API_KEY'];
|
|
33
|
+
if (model.startsWith('anthropic/')) return ['ANTHROPIC_API_KEY'];
|
|
34
|
+
return ['OPENCODE_API_KEY'];
|
|
35
|
+
}
|
|
36
|
+
|
|
9
37
|
/** Build a Docker invocation. This function never accepts a shell command or credentials in arguments. */
|
|
10
|
-
export function workerDockerInvocation({ workspace, image, command, args, credentialEnv, hostEnv, uid, gid, containerName, sessionHome }) {
|
|
38
|
+
export function workerDockerInvocation({ workspace, image, command, args, credentialEnv, hostEnv, uid, gid, containerName, sessionHome, labels }) {
|
|
11
39
|
const root = realpathSync(workspace);
|
|
12
40
|
if (/[\x00-\x1f,]/.test(root)) throw new Error('Invalid Docker workspace path');
|
|
13
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');
|
|
14
|
-
if (command === 'opencode') throw new Error(OPENCODE_CONTAINER_ERROR);
|
|
15
42
|
if (!isLoopProvider(command) || !Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) throw new Error('Invalid worker provider command');
|
|
16
43
|
if (!Number.isSafeInteger(uid) || uid < 1 || !Number.isSafeInteger(gid) || gid < 1) throw new Error('Non-root worker uid and gid are required');
|
|
17
44
|
if (!hostEnv || typeof hostEnv !== 'object' || !Array.isArray(credentialEnv) || !credentialEnv.length || credentialEnv.some((key) => !/^[A-Z][A-Z0-9_]*$/.test(key) || typeof hostEnv[key] !== 'string' || !hostEnv[key])) throw new Error('Explicit worker provider credentials are required');
|
|
18
|
-
const allowed = command === 'codex' ? ['OPENAI_API_KEY'] : ['ANTHROPIC_API_KEY'];
|
|
45
|
+
const allowed = command === 'codex' ? ['OPENAI_API_KEY'] : command === 'claude' ? ['ANTHROPIC_API_KEY'] : ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENCODE_API_KEY'];
|
|
19
46
|
if (credentialEnv.some((key) => !allowed.includes(key)) || new Set(credentialEnv).size !== credentialEnv.length) throw new Error('Unsupported worker credential environment');
|
|
20
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
|
+
}
|
|
21
58
|
let homeMount;
|
|
22
59
|
if (sessionHome !== undefined) {
|
|
23
60
|
const info = lstatSync(sessionHome);
|
|
@@ -28,5 +65,6 @@ export function workerDockerInvocation({ workspace, image, command, args, creden
|
|
|
28
65
|
}
|
|
29
66
|
const env = { PATH: hostEnv.PATH ?? '/usr/bin:/bin' };
|
|
30
67
|
for (const key of credentialEnv) env[key] = hostEnv[key];
|
|
31
|
-
|
|
68
|
+
const opencodeEnv = command === 'opencode' ? [...OPENCODE_XDG_ENV, 'OPENCODE_CONFIG_CONTENT={}'].flatMap((entry) => ['--env', entry]) : [];
|
|
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 };
|
|
32
70
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { isLoopProvider } from "./loop-drivers.mjs";
|
|
5
|
+
|
|
6
|
+
/** Image tag must satisfy the same shape the Docker worker invocation accepts. */
|
|
7
|
+
const IMAGE_TAG = /^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,255}$/;
|
|
8
|
+
|
|
9
|
+
/** Deterministic default tag for a provider: `aidlc-worker-<provider>:local`. */
|
|
10
|
+
export const defaultWorkerImageTag = (provider) => `aidlc-worker-${provider}:local`;
|
|
11
|
+
|
|
12
|
+
/** Validate an explicit image tag the way `workerDockerInvocation` will. */
|
|
13
|
+
export const validWorkerImageTag = (tag) => typeof tag === "string" && IMAGE_TAG.test(tag);
|
|
14
|
+
|
|
15
|
+
/** Resolve the shipped Dockerfile for a provider inside an installed project. */
|
|
16
|
+
export const workerDockerfilePath = (root, provider) => join(root, ".agents", "aidlc", "docker", `Dockerfile.${provider}`);
|
|
17
|
+
|
|
18
|
+
const dockerEnv = (env = process.env) => ({ PATH: env.PATH ?? "/usr/bin:/bin" });
|
|
19
|
+
|
|
20
|
+
/** True when a local image with that tag already exists in the Docker daemon. */
|
|
21
|
+
export function dockerImageExists(image, env = process.env) {
|
|
22
|
+
try {
|
|
23
|
+
execFileSync("docker", ["image", "inspect", "--format", "{{.Id}}", image], { env: dockerEnv(env), encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 30000 });
|
|
24
|
+
return true;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Build a worker image from the shipped Dockerfile. Streams progress to the
|
|
31
|
+
* terminal (stdio inherit) and returns the built tag. Never accepts a shell. */
|
|
32
|
+
export function buildWorkerImage({ root, provider, image, env = process.env, onEvent = () => {} }) {
|
|
33
|
+
if (!isLoopProvider(provider)) throw new Error(`provider must be codex, claude, or opencode`);
|
|
34
|
+
if (!validWorkerImageTag(image)) throw new Error("Explicit Docker image is required");
|
|
35
|
+
const dockerfile = workerDockerfilePath(root, provider);
|
|
36
|
+
if (!existsSync(dockerfile)) throw new Error(`Worker Dockerfile is missing: ${dockerfile}`);
|
|
37
|
+
onEvent({ type: "worker_image_build", provider, image });
|
|
38
|
+
execFileSync("docker", ["build", "-f", dockerfile, "-t", image, join(root, ".agents", "aidlc", "docker")], { env: dockerEnv(env), stdio: "inherit" });
|
|
39
|
+
return image;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Build the image only when it is missing; otherwise return the existing tag. */
|
|
43
|
+
export function ensureWorkerImage({ root, provider, image, env = process.env, onEvent = () => {} }) {
|
|
44
|
+
if (dockerImageExists(image, env)) return image;
|
|
45
|
+
return buildWorkerImage({ root, provider, image, env, onEvent });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Persist the built tag into `config.loop.workerImage[provider]` so the local
|
|
49
|
+
* supervisor can reuse it without a manual `export`. No-op when the project
|
|
50
|
+
* config does not exist yet; credentials are never persisted. */
|
|
51
|
+
export function recordWorkerImage({ root, provider, image }) {
|
|
52
|
+
if (!isLoopProvider(provider) || !validWorkerImageTag(image)) throw new Error("Cannot record an invalid worker image");
|
|
53
|
+
const configPath = join(root, ".agents", "config.json");
|
|
54
|
+
if (!existsSync(configPath)) return;
|
|
55
|
+
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
56
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) return;
|
|
57
|
+
const loop = (config.loop && typeof config.loop === "object" && !Array.isArray(config.loop)) ? config.loop : {};
|
|
58
|
+
const workerImage = (loop.workerImage && typeof loop.workerImage === "object" && !Array.isArray(loop.workerImage)) ? loop.workerImage : {};
|
|
59
|
+
if (workerImage[provider] === image) return;
|
|
60
|
+
workerImage[provider] = image;
|
|
61
|
+
loop.workerImage = workerImage;
|
|
62
|
+
config.loop = loop;
|
|
63
|
+
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
64
|
+
}
|
|
@@ -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
|
+
};
|