@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,4 +1,4 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { execFileSync, spawn } from "node:child_process";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
@@ -8,11 +8,33 @@ import { runFleetCoordinator, runFleetWorker, validateFleetLimits } from "./loop
|
|
|
8
8
|
import { runFleetSupervisor } from "./loop-fleet-supervisor.mjs";
|
|
9
9
|
import { createFleetRemoteRepository } from "./loop-fleet-remote.mjs";
|
|
10
10
|
import { createLeadGitHandoff, createIsolatedWorkerGitHandoff } from "./loop-git.mjs";
|
|
11
|
-
import { workerDockerInvocation,
|
|
11
|
+
import { workerDockerInvocation, workerCredentialKeys } from "./loop-worker-docker.mjs";
|
|
12
|
+
import { openWorkerLog } from "./loop-worker-log.mjs";
|
|
13
|
+
import { defaultWorkerImageTag, ensureWorkerImage, validWorkerImageTag } from "./loop-worker-image.mjs";
|
|
12
14
|
|
|
13
15
|
const required = (env, name) => { const value = env[name]; if (typeof value !== "string" || !value.trim()) throw new Error(`${name} is required`); return value; };
|
|
14
|
-
|
|
16
|
+
/** Sleep `ms`, resolving early once any given signal aborts. */
|
|
17
|
+
const pause = (ms, ...signals) => new Promise((resolve) => {
|
|
18
|
+
const live = signals.filter(Boolean);
|
|
19
|
+
if (live.some((signal) => signal.aborted)) return resolve();
|
|
20
|
+
const done = () => { clearTimeout(timer); for (const signal of live) signal.removeEventListener("abort", done); resolve(); };
|
|
21
|
+
const timer = setTimeout(done, ms);
|
|
22
|
+
for (const signal of live) signal.addEventListener("abort", done, { once: true });
|
|
23
|
+
});
|
|
15
24
|
const provider = (value, name) => { if (!isLoopProvider(value)) throw new Error(`${name} must be ${LOOP_PROVIDER_LIST}`); return value; };
|
|
25
|
+
const pendingFeedback = (task) => {
|
|
26
|
+
const evidence = Array.isArray(task?.evidence) ? task.evidence : [];
|
|
27
|
+
const pending = [];
|
|
28
|
+
for (let i = 0; i < evidence.length; i++) {
|
|
29
|
+
const entry = evidence[i];
|
|
30
|
+
if (entry?.kind !== "diagnostic" || typeof entry.detail !== "string") continue;
|
|
31
|
+
const match = entry.detail.match(/^Cancelled gate wait at (G0_confirm|G1_review|G2_codereview): /);
|
|
32
|
+
if (!match) continue;
|
|
33
|
+
const approvedAfter = evidence.slice(i + 1).some((later) => later?.kind === "approval" && later.result === "pass" && later.gate === match[1]);
|
|
34
|
+
if (!approvedAfter) pending.push(entry.detail);
|
|
35
|
+
}
|
|
36
|
+
return pending;
|
|
37
|
+
};
|
|
16
38
|
function workerMapping(env, workerIds, defaultAgent) {
|
|
17
39
|
if (!env.AIDLC_FLEET_WORKER_PROVIDERS) return Object.fromEntries(workerIds.map((id) => [id, defaultAgent]));
|
|
18
40
|
let mapping;
|
|
@@ -20,9 +42,46 @@ function workerMapping(env, workerIds, defaultAgent) {
|
|
|
20
42
|
if (!mapping || typeof mapping !== "object" || Array.isArray(mapping) || Object.keys(mapping).length !== workerIds.length || workerIds.some((id) => !Object.hasOwn(mapping, id) || !isLoopProvider(mapping[id])) || Object.keys(mapping).some((id) => !workerIds.includes(id))) throw new Error(`AIDLC_FLEET_WORKER_PROVIDERS must map every configured worker ID to ${LOOP_PROVIDER_LIST}`);
|
|
21
43
|
return mapping;
|
|
22
44
|
}
|
|
45
|
+
/** This worker's provider: AIDLC_FLEET_WORKER_AGENT, else its entry in
|
|
46
|
+
* AIDLC_FLEET_WORKER_PROVIDERS (keyed by worker ID), else AIDLC_FLEET_AGENT
|
|
47
|
+
* (the lead's provider — only a last resort, so a copied lead env cannot
|
|
48
|
+
* override this worker's own mapping), else undefined. */
|
|
49
|
+
function workerProvider(env, workerId) {
|
|
50
|
+
if (env.AIDLC_FLEET_WORKER_AGENT !== undefined) return provider(env.AIDLC_FLEET_WORKER_AGENT, "AIDLC_FLEET_WORKER_AGENT");
|
|
51
|
+
if (env.AIDLC_FLEET_WORKER_PROVIDERS) {
|
|
52
|
+
let mapping;
|
|
53
|
+
try { mapping = JSON.parse(env.AIDLC_FLEET_WORKER_PROVIDERS); } catch { throw new Error("AIDLC_FLEET_WORKER_PROVIDERS must be a JSON object"); }
|
|
54
|
+
if (!mapping || typeof mapping !== "object" || Array.isArray(mapping)) throw new Error("AIDLC_FLEET_WORKER_PROVIDERS must be a JSON object");
|
|
55
|
+
if (Object.hasOwn(mapping, workerId)) return provider(mapping[workerId], `AIDLC_FLEET_WORKER_PROVIDERS["${workerId}"]`);
|
|
56
|
+
}
|
|
57
|
+
return env.AIDLC_FLEET_AGENT === undefined ? undefined : provider(env.AIDLC_FLEET_AGENT, "AIDLC_FLEET_AGENT");
|
|
58
|
+
}
|
|
23
59
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
60
|
+
/** Worker image tag: explicit AIDLC_WORKER_DOCKER_IMAGE, else the project's
|
|
61
|
+
* `.agents/config.json` `loop.workerImage[provider]`, else the default tag
|
|
62
|
+
* (built from the shipped Dockerfile when missing). */
|
|
63
|
+
function workerImageTag({ env, root, agent, onEvent }) {
|
|
64
|
+
const requested = env.AIDLC_WORKER_DOCKER_IMAGE;
|
|
65
|
+
if (typeof requested === "string" && requested.trim()) return requested;
|
|
66
|
+
if (!agent) throw new Error("AIDLC_WORKER_DOCKER_IMAGE is required when the worker provider is unknown; set AIDLC_FLEET_WORKER_AGENT (or this worker's AIDLC_FLEET_WORKER_PROVIDERS entry) to use the default worker image, or build one with `aidlc-kit loop worker-image --provider <provider>` and set the env var");
|
|
67
|
+
const configPath = join(root, ".agents", "config.json");
|
|
68
|
+
let configured;
|
|
69
|
+
if (existsSync(configPath)) {
|
|
70
|
+
let config;
|
|
71
|
+
try { config = JSON.parse(readFileSync(configPath, "utf8")); } catch { throw new Error(`Invalid project config: ${configPath}`); }
|
|
72
|
+
configured = config?.loop?.workerImage?.[agent];
|
|
73
|
+
if (configured !== undefined && !validWorkerImageTag(configured)) throw new Error(`Invalid loop.workerImage.${agent} in ${configPath}`);
|
|
74
|
+
}
|
|
75
|
+
const image = configured ?? defaultWorkerImageTag(agent);
|
|
76
|
+
return ensureWorkerImage({ root, provider: agent, image, env, onEvent });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Package-local entrypoint. The service wrapper supplies its MCP HTTP client.
|
|
80
|
+
* `signal` stops after the current turn; `cancelSignal` also aborts the
|
|
81
|
+
* in-flight agent turn (result status `stopped`). `onEvent(event)` receives
|
|
82
|
+
* every emitted event object instead of `out(JSON.stringify(event))`.
|
|
83
|
+
* `untilStop` applies to role `supervisor` only (see runFleetSupervisor). */
|
|
84
|
+
export async function runFleetCli(argv, { callTool, env = process.env, out = console.log, onEvent, signal, cancelSignal, untilStop = false, coordinator: coordinatorOverride } = {}) {
|
|
26
85
|
const [role, targetId, ...flags] = argv;
|
|
27
86
|
if (!["coordinator", "feature", "worker", "supervisor"].includes(role) || ((role === "coordinator" || role === "feature" || role === "supervisor") && !targetId) || flags.some((arg) => arg !== "--once") || (role === "supervisor" && flags.length) || (role === "worker" && targetId && targetId !== "--once")) throw new Error("Usage: aidlc-fleet coordinator <task-id> [--once] | feature <feature-id> [--once] | supervisor <task-id> | worker [--once]");
|
|
28
87
|
const once = flags.includes("--once") || (role === "worker" && targetId === "--once");
|
|
@@ -44,23 +103,21 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
|
|
|
44
103
|
const pollMs = env.AIDLC_FLEET_POLL_MS ? Number(env.AIDLC_FLEET_POLL_MS) : 5000;
|
|
45
104
|
if (!Number.isSafeInteger(pollMs) || pollMs < 1000 || pollMs > 300000) throw new Error("AIDLC_FLEET_POLL_MS must be 1000..300000");
|
|
46
105
|
if (env.AIDLC_FLEET_CODE_ONLY_RETRY && !["0", "1"].includes(env.AIDLC_FLEET_CODE_ONLY_RETRY)) throw new Error("AIDLC_FLEET_CODE_ONLY_RETRY must be 0 or 1");
|
|
47
|
-
const emit = (event) => out(JSON.stringify(event));
|
|
106
|
+
const emit = typeof onEvent === "function" ? (event) => onEvent(event) : (event) => out(JSON.stringify(event));
|
|
107
|
+
const stopRequested = () => Boolean(signal?.aborted || cancelSignal?.aborted);
|
|
48
108
|
if (role === "worker") {
|
|
49
109
|
const workerId = required(env, "AIDLC_FLEET_WORKER_ID");
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
if (workerAgent === "opencode") throw new Error(OPENCODE_CONTAINER_ERROR);
|
|
53
|
-
const requestedImage = required(env, "AIDLC_WORKER_DOCKER_IMAGE");
|
|
110
|
+
const workerAgent = workerProvider(env, workerId);
|
|
111
|
+
const requestedImage = workerImageTag({ env, root, agent: workerAgent, onEvent: emit });
|
|
54
112
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,255}$/.test(requestedImage)) throw new Error("Invalid Docker worker image");
|
|
55
113
|
const image = execFileSync("docker", ["image", "inspect", "--format", "{{.Id}}", requestedImage], {env:{PATH:env.PATH ?? "/usr/bin:/bin"},encoding:"utf8",timeout:30000,stdio:["ignore","pipe","ignore"]}).trim();
|
|
56
114
|
if (!/^sha256:[a-f0-9]{64}$/.test(image)) throw new Error("Docker worker image lacks immutable image ID");
|
|
57
115
|
const uid = process.getuid?.(), gid = process.getgid?.();
|
|
58
116
|
if (!Number.isSafeInteger(uid) || uid < 1 || !Number.isSafeInteger(gid) || gid < 1) throw new Error("Docker worker requires a non-root host uid and gid");
|
|
59
117
|
const preflight = async (agent) => {
|
|
60
|
-
const credential = agent === "codex" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
|
|
61
118
|
const empty = mkdtempSync(join(tmpdir(), "aidlc-worker-preflight-"));
|
|
62
119
|
try {
|
|
63
|
-
const invocation = workerDockerInvocation({workspace:empty,image,command:agent,args:["--version"],credentialEnv:
|
|
120
|
+
const invocation = workerDockerInvocation({workspace:empty,image,command:agent,args:["--version"],credentialEnv:workerCredentialKeys(agent, env),hostEnv:env,uid,gid});
|
|
64
121
|
execFileSync("docker", ["info"], {env:{PATH:invocation.env.PATH},stdio:"ignore",timeout:30000});
|
|
65
122
|
execFileSync("docker", ["image","inspect",image], {env:{PATH:invocation.env.PATH},stdio:"ignore",timeout:30000});
|
|
66
123
|
const name = `aidlc-worker-preflight-${randomUUID()}`;
|
|
@@ -77,32 +134,45 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
|
|
|
77
134
|
if (workerAgent) await preflight(workerAgent);
|
|
78
135
|
const baseHandoff = createIsolatedWorkerGitHandoff({ root, worktreesRoot: resolve(required(env, "AIDLC_FLEET_WORKTREES_ROOT")), remote });
|
|
79
136
|
const sessions = new Map();
|
|
80
|
-
const
|
|
137
|
+
const dropSession = (key, current) => { current.log?.close(); rmSync(current.home,{recursive:true,force:true}); sessions.delete(key); };
|
|
138
|
+
const cleanupSession = (workspace) => { const current=sessions.get(workspace.root); if(current) dropSession(workspace.root, current); };
|
|
139
|
+
// Same container record and raw output tee as the local loop, written under
|
|
140
|
+
// this worker checkout's .agents/data/tasks/<task>/runtime/workers (the
|
|
141
|
+
// index entry carries this host's name). Best-effort: reported once, never fatal.
|
|
142
|
+
const openLog = (job) => {
|
|
143
|
+
const diagnostic = (error) => emit({ type: "diagnostic", taskId: job.taskId, itemId: job.itemId, message: `Worker log unavailable for assignment ${job.assignmentId}: ${error instanceof Error ? error.message : String(error)}` });
|
|
144
|
+
try { return openWorkerLog({ root, taskId: job.taskId, assignmentId: job.assignmentId, itemId: job.itemId, workerId, agent: job.agent, mode: "fleet", onError: diagnostic }); }
|
|
145
|
+
catch (error) { diagnostic(error); return null; }
|
|
146
|
+
};
|
|
81
147
|
const handoff = {
|
|
82
148
|
...baseHandoff,
|
|
83
149
|
async prepare(job) {
|
|
84
150
|
const workspace=await baseHandoff.prepare(job);
|
|
85
151
|
const home=mkdtempSync(join(tmpdir(),"aidlc-worker-home-")); chmodSync(home,0o700);
|
|
86
|
-
|
|
152
|
+
const labels = { "aidlc.task": job.taskId, "aidlc.item": job.itemId, "aidlc.worker": workerId, "aidlc.assignment": job.assignmentId };
|
|
153
|
+
sessions.set(workspace.root,{home,agent:job.agent,assignmentId:job.assignmentId,attempt:job.attempt,epoch:job.epoch,workerId,image,sessionId:null,labels,log:openLog(job),turns:0});
|
|
87
154
|
return workspace;
|
|
88
155
|
},
|
|
89
156
|
async finish(args) { try { return await baseHandoff.finish(args); } finally { cleanupSession(args.workspace); } },
|
|
90
157
|
async discard(args) { try { return await baseHandoff.discard(args); } finally { cleanupSession(args.workspace); } }
|
|
91
158
|
};
|
|
92
159
|
const isolatedDriver = async (turn) => {
|
|
93
|
-
if (turn.agent === "opencode") throw new Error(OPENCODE_CONTAINER_ERROR);
|
|
94
160
|
const session=sessions.get(turn.root);
|
|
95
161
|
if (!session || session.agent !== turn.agent || (turn.sessionId && turn.sessionId !== session.sessionId)) throw new Error("Docker worker session is not recoverable; reconcile this assignment");
|
|
96
|
-
const
|
|
162
|
+
const credentialEnv = workerCredentialKeys(turn.agent, env);
|
|
97
163
|
const name = `aidlc-worker-${randomUUID()}`;
|
|
98
164
|
let started = false;
|
|
165
|
+
session.turns += 1;
|
|
166
|
+
session.log?.turnStart(name, session.turns);
|
|
99
167
|
try {
|
|
100
|
-
const result = await runAgentTurn({...turn, root:"/workspace", spawnProcess: (command,args,options) => {
|
|
101
|
-
const invocation=workerDockerInvocation({workspace:turn.root,image,command,args,credentialEnv
|
|
168
|
+
const result = await runAgentTurn({...turn, root:"/workspace", env, spawnProcess: (command,args,options) => {
|
|
169
|
+
const invocation=workerDockerInvocation({workspace:turn.root,image,command,args,credentialEnv,hostEnv:env,uid,gid,sessionHome:session.home,labels:session.labels});
|
|
102
170
|
const dockerArgs=invocation.args.filter((arg,index) => !(index===1 && arg==="--rm"));
|
|
103
171
|
dockerArgs.splice(1,0,"--name",name);
|
|
104
172
|
started = true;
|
|
105
|
-
|
|
173
|
+
const child = spawn(invocation.command,dockerArgs,{...options,cwd:turn.root,env:invocation.env});
|
|
174
|
+
if (session.log) { session.log.tee(child); child.once("close", (code) => session.log.turnEnd(code)); }
|
|
175
|
+
return child;
|
|
106
176
|
}});
|
|
107
177
|
session.sessionId=result.sessionId;
|
|
108
178
|
return result;
|
|
@@ -114,20 +184,20 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
|
|
|
114
184
|
}
|
|
115
185
|
};
|
|
116
186
|
for (;;) {
|
|
117
|
-
if (
|
|
187
|
+
if (stopRequested()) return { status: "stopped" };
|
|
118
188
|
let result;
|
|
119
|
-
try { result = await runFleetWorker({ repository, workerId, workerAgent, preflight, limits, driver: isolatedDriver, handoff, packet: async ({ job, snapshot }) => [
|
|
189
|
+
try { result = await runFleetWorker({ repository, workerId, workerAgent, preflight, limits, driver: isolatedDriver, handoff, cancelSignal, packet: async ({ job, snapshot }) => [
|
|
120
190
|
`AI-DLC worker assignment ${job.assignmentId}, attempt ${job.attempt}.`,
|
|
121
191
|
`Task: ${snapshot.task.title} (${job.taskId}). Item: ${job.itemId}.`,
|
|
122
192
|
`Only change these owned paths: ${job.ownedPaths.join(", ")}.`,
|
|
123
193
|
`Base commit: ${job.baseCommit}. Assigned branch: ${job.branch}.`,
|
|
124
|
-
"Complete the scoped item and report remaining risks. The supervisor handles the Git commit and push.",
|
|
194
|
+
"Complete the scoped item and report remaining risks. The supervisor handles the Git commit and push. End the final answer with a Conventional Commits message on its own line as AI-DLC-COMMIT: type(scope): subject (for example AI-DLC-COMMIT: docs(ui): add web UI improvement proposal), followed by AI-DLC-ITEM-COMPLETE on its own line.",
|
|
125
195
|
...(env.AIDLC_FLEET_CODE_ONLY_RETRY === "1" ? ["This assignment is declared code-only: perform no external side effects beyond the supervisor's Git handoff."] : [])
|
|
126
196
|
].join("\n"), onEvent: emit }); }
|
|
127
|
-
finally { for (const [workspace, entry] of sessions)
|
|
197
|
+
finally { for (const [workspace, entry] of sessions) dropSession(workspace, entry); }
|
|
128
198
|
emit(result);
|
|
129
|
-
if (once) return result;
|
|
130
|
-
await pause(pollMs);
|
|
199
|
+
if (once || result.status === "stopped") return result;
|
|
200
|
+
await pause(pollMs, signal, cancelSignal);
|
|
131
201
|
}
|
|
132
202
|
}
|
|
133
203
|
const workerIds = required(env, "AIDLC_FLEET_WORKER_IDS").split(",").map((value) => value.trim()).filter(Boolean);
|
|
@@ -142,19 +212,21 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
|
|
|
142
212
|
const raw = execFileSync(process.execPath, [remoteState, "phase", "context", taskId], { cwd: root, env: fleetEnv, encoding: "utf8", timeout: 30000, maxBuffer: 2 * 1024 * 1024 });
|
|
143
213
|
const packet = JSON.parse(raw);
|
|
144
214
|
if (packet?.ok !== true || typeof packet.result?.content !== "string" || !packet.result.content) throw new Error("Remote phase context is empty or invalid");
|
|
145
|
-
|
|
215
|
+
const feedback = pendingFeedback(snapshot.task);
|
|
216
|
+
const feedbackBlock = feedback.length ? `Human feedback (revise the current review artifact accordingly, then re-present the gate):\n${feedback.map((text) => `- ${text}`).join("\n")}\n\n` : "";
|
|
217
|
+
return `${feedbackBlock}${packet.result.content}\n\nFleet supervisor: canonical nextAction is ${JSON.stringify(snapshot.nextAction)}. Use only remote-state.mjs for lifecycle commands. Never approve a gate.`;
|
|
146
218
|
},
|
|
147
219
|
driver: async ({ leadEpoch, prompt, timeoutMs, signal: turnSignal }) => {
|
|
148
220
|
return runAgentTurn({ agent, root, prompt, timeoutMs, signal: turnSignal, env: { ...env, AIDLC_FLEET_LEAD_EPOCH: String(leadEpoch), AIDLC_FLEET_LEAD_OWNER_TOKEN: repository.leadOwnerToken() }, onEvent: emit });
|
|
149
221
|
} });
|
|
150
|
-
const runTask = async (taskId, reservedJobs = []) => runFleetCoordinator({ repository, taskId, ...leadTaskFor(taskId), reservedJobs, onEvent: emit });
|
|
222
|
+
const runTask = async (taskId, reservedJobs = []) => runFleetCoordinator({ repository, taskId, ...leadTaskFor(taskId), reservedJobs, stopSignal: signal, cancelSignal, onEvent: emit });
|
|
151
223
|
if (role === "supervisor") {
|
|
152
|
-
const result = await runFleetSupervisor({ repository, taskId: targetId, ...leadTaskFor(targetId), signal, onEvent: emit, ...(coordinatorOverride ? { coordinator: coordinatorOverride } : {}) });
|
|
224
|
+
const result = await runFleetSupervisor({ repository, taskId: targetId, ...leadTaskFor(targetId), signal, cancelSignal, untilStop, onEvent: emit, ...(coordinatorOverride ? { coordinator: coordinatorOverride } : {}) });
|
|
153
225
|
emit(result);
|
|
154
226
|
return result;
|
|
155
227
|
}
|
|
156
228
|
for (;;) {
|
|
157
|
-
if (
|
|
229
|
+
if (stopRequested()) return { status: "stopped" };
|
|
158
230
|
if (role === "feature") {
|
|
159
231
|
const feature = await repository.getFeature({ featureId: targetId });
|
|
160
232
|
if (!feature) throw new Error(`Unknown feature ${targetId}`);
|
|
@@ -167,17 +239,18 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
|
|
|
167
239
|
const reservedJobs = (await repository.listAllJobs()).filter((job) => job.taskId !== member.taskId);
|
|
168
240
|
const result = await runTask(member.taskId, reservedJobs);
|
|
169
241
|
results.push(result); emit(result);
|
|
242
|
+
if (result.status === "stopped") return { status: "stopped", featureId: targetId, results };
|
|
170
243
|
if (result.status === "gate") return { status: "gate", featureId: targetId, results, blockedTaskIds: readiness.items.filter((item) => !item.ready && item.blockers.length).map((item) => item.taskId) };
|
|
171
244
|
}
|
|
172
245
|
const stopped = results.some((entry) => ["blocked", "reconcile", "paused", "terminal"].includes(entry.status));
|
|
173
246
|
const result = { status: !ready.length || stopped ? "blocked" : "active", featureId: targetId, results, blockedTaskIds: readiness.items.filter((member) => !member.ready && member.blockers.length).map((member) => member.taskId) };
|
|
174
247
|
if (once || result.status === "blocked") return result;
|
|
175
|
-
await pause(pollMs);
|
|
248
|
+
await pause(pollMs, signal, cancelSignal);
|
|
176
249
|
continue;
|
|
177
250
|
}
|
|
178
251
|
const result = await runTask(targetId);
|
|
179
252
|
emit(result);
|
|
180
|
-
if (once || ["terminal", "complete", "blocked", "paused", "reconcile", "gate"].includes(result.status)) return result;
|
|
181
|
-
await pause(pollMs);
|
|
253
|
+
if (once || ["terminal", "complete", "blocked", "paused", "reconcile", "gate", "stopped"].includes(result.status)) return result;
|
|
254
|
+
await pause(pollMs, signal, cancelSignal);
|
|
182
255
|
}
|
|
183
256
|
}
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { explainFleetConflict } from "./loop-holder.mjs";
|
|
2
3
|
|
|
3
4
|
const idempotency = () => randomUUID();
|
|
4
5
|
|
|
5
6
|
/** Map the coordination service's versioned MCP tools to the headless runner. */
|
|
6
7
|
export function createFleetRemoteRepository({ callTool, url, token, workspace, root, workerId }) {
|
|
7
8
|
if (typeof callTool !== "function" || !url || !token || !workspace || !root) throw new Error("Fleet remote requires MCP callTool, URL, token, workspace, and project root");
|
|
8
|
-
|
|
9
|
+
// A FleetConflict keeps its code/current for retry logic; its message gains
|
|
10
|
+
// a lease/claim explanation and the inspect command (original kept in parentheses).
|
|
11
|
+
const call = async (name, args) => {
|
|
12
|
+
try { return await callTool({ url, token, name, args: { workspace, ...args } }); }
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error?.code === "FLEET_CONFLICT" && typeof error.message === "string") error.message = explainFleetConflict(error.message);
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
9
18
|
let ownerToken;
|
|
10
19
|
let supervisorToken, supervisorEpoch, supervisorTask;
|
|
11
20
|
const fleet = () => call("fleet_get", {});
|
|
@@ -44,6 +53,13 @@ export function createFleetRemoteRepository({ callTool, url, token, workspace, r
|
|
|
44
53
|
getFeature: async ({ featureId }) => (await fleet()).fleet.features[featureId] ?? null,
|
|
45
54
|
featureReady: async ({ featureId }) => call("feature_ready", { featureId }),
|
|
46
55
|
listAllJobs: async () => Object.values((await fleet()).fleet.jobs).map(normalizeJob),
|
|
56
|
+
/** Every canonical task in the workspace (unordered), for daemon task discovery. */
|
|
57
|
+
listTasks: async () => {
|
|
58
|
+
const value = await call("state_get", {});
|
|
59
|
+
const tasks = value?.state?.tasks;
|
|
60
|
+
if (!tasks || typeof tasks !== "object" || Array.isArray(tasks)) throw new Error("Invalid remote workspace state");
|
|
61
|
+
return Object.values(tasks);
|
|
62
|
+
},
|
|
47
63
|
readTask: async ({ taskId }) => {
|
|
48
64
|
const value = await call("task_get", { taskId, clientProjectRoot: root });
|
|
49
65
|
return { task: value.task, nextAction: value.nextAction, revision: value.revision };
|
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
3
|
+
import { explainFleetConflict } from "./loop-holder.mjs";
|
|
3
4
|
import { runFleetCoordinator, validateFleetLimits } from "./loop-fleet.mjs";
|
|
4
5
|
|
|
5
6
|
const taskDigest = (task) => createHash("sha256").update(JSON.stringify(task)).digest("hex");
|
|
6
7
|
const targets = { G0_confirm: "plan", G1_review: "build", G2_codereview: "wrap" };
|
|
7
8
|
|
|
8
9
|
/** The service owns the checkpoint and atomically binds each run claim to this
|
|
9
|
-
* supervisor. An unavailable or ambiguous checkpoint never authorizes work.
|
|
10
|
-
|
|
10
|
+
* supervisor. An unavailable or ambiguous checkpoint never authorizes work.
|
|
11
|
+
*
|
|
12
|
+
* `signal` stops at the next turn boundary; `cancelSignal` also aborts the
|
|
13
|
+
* in-flight lead turn. Both end as `{ status: "stopped" }`.
|
|
14
|
+
*
|
|
15
|
+
* `untilStop: true` (daemon mode) never polls a human gate: a newly published
|
|
16
|
+
* gate returns `{ status: "gate", taskId, gate, snapshotId, gateView }` and an
|
|
17
|
+
* already pending checkpoint returns `{ status: "waiting", taskId, gate,
|
|
18
|
+
* snapshotId }`. The supervisor lease is released either way; the service
|
|
19
|
+
* accepts the reviewer approval against the pending checkpoint, and a later
|
|
20
|
+
* supervisor resumes from the `approved` checkpoint. */
|
|
21
|
+
export async function runFleetSupervisor({ repository, taskId, limits: inputLimits, pollMs = 5000, signal, cancelSignal, untilStop = false, onEvent = () => {}, coordinator = runFleetCoordinator, ...options }) {
|
|
11
22
|
const limits = validateFleetLimits(inputLimits);
|
|
12
23
|
if (!repository || typeof taskId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(taskId) || !Number.isSafeInteger(pollMs) || pollMs < 1 || pollMs > 300000) throw new Error("Invalid fleet supervisor configuration");
|
|
13
24
|
for (const name of ["claimSupervisor", "renewSupervisor", "releaseSupervisor", "readGateCheckpoint", "readTask", "claimLead"]) if (typeof repository[name] !== "function") throw new Error(`Fleet supervisor requires ${name}`);
|
|
14
25
|
await repository.claimSupervisor({ taskId, leaseMs: limits.leaseMs, idempotencyKey: randomUUID() });
|
|
15
26
|
const controller = new AbortController();
|
|
16
|
-
const combined =
|
|
27
|
+
const combined = AbortSignal.any([signal, cancelSignal, controller.signal].filter(Boolean));
|
|
17
28
|
let failure; let pending = Promise.resolve();
|
|
18
29
|
const timer = setInterval(() => { pending = pending.then(() => repository.renewSupervisor({ taskId, leaseMs: limits.leaseMs })).catch((error) => { failure = error; controller.abort(error); }); }, limits.heartbeatMs);
|
|
19
30
|
timer.unref?.();
|
|
@@ -28,12 +39,17 @@ export async function runFleetSupervisor({ repository, taskId, limits: inputLimi
|
|
|
28
39
|
if (checkpoint?.status === "pending") {
|
|
29
40
|
if (task.status !== "blocked_on_user" || task.gate !== checkpoint.gate || taskDigest(task) !== checkpoint.taskDigest || snapshot.nextAction?.classification !== "await_user") return { status: "blocked", taskId, reason: "Pending checkpoint does not match canonical gate" };
|
|
30
41
|
await onEvent({ type: "waiting", taskId, gate: checkpoint.gate, snapshotId: checkpoint.snapshotId });
|
|
42
|
+
if (untilStop) return { status: "waiting", taskId, gate: checkpoint.gate, snapshotId: checkpoint.snapshotId };
|
|
31
43
|
try { await sleep(pollMs, undefined, { signal: combined }); } catch (error) { if (error?.name !== "AbortError") throw error; }
|
|
32
44
|
continue;
|
|
33
45
|
}
|
|
34
46
|
if (checkpoint?.status === "consumed") return { status: "blocked", taskId, reason: "Consumed checkpoint requires reconciliation before restart" };
|
|
35
|
-
if (checkpoint
|
|
36
|
-
|
|
47
|
+
if (checkpoint?.status === "rejected") {
|
|
48
|
+
if (task.status !== "active" || task.phase !== checkpoint.phase || task.gate !== checkpoint.gate || snapshot.nextAction?.classification !== "run_phase") return { status: "blocked", taskId, reason: "Rejected checkpoint does not match canonical state" };
|
|
49
|
+
} else if (checkpoint && checkpoint.status !== "approved") {
|
|
50
|
+
return { status: "blocked", taskId, reason: "Approved checkpoint does not match canonical state" };
|
|
51
|
+
}
|
|
52
|
+
if (checkpoint?.status === "approved") {
|
|
37
53
|
// A task snapshot older than the atomic approval is a read race between
|
|
38
54
|
// the two reads, not a mismatch: re-read the canonical task immediately.
|
|
39
55
|
if (Number.isSafeInteger(checkpoint.approvalRevision) && checkpoint.approvalRevision > snapshot.revision) { await onEvent({ type: "resync", taskId, gate: checkpoint.gate }); continue; }
|
|
@@ -43,13 +59,14 @@ export async function runFleetSupervisor({ repository, taskId, limits: inputLimi
|
|
|
43
59
|
await repository.renewSupervisor({ taskId, leaseMs: limits.leaseMs });
|
|
44
60
|
if (combined.aborted) break;
|
|
45
61
|
const claim = await repository.claimLead({ taskId, leaseMs: limits.leaseMs, expectedStateRevision: snapshot.revision, expectedTaskDigest: taskDigest(task), checkpoint, idempotencyKey: randomUUID() });
|
|
46
|
-
const result = await coordinator({ ...options, repository, taskId, limits, claimedLead: claim, supervisorSignal:
|
|
62
|
+
const result = await coordinator({ ...options, repository, taskId, limits, claimedLead: claim, supervisorSignal: controller.signal, stopSignal: signal, cancelSignal, onEvent });
|
|
47
63
|
if (failure) throw failure;
|
|
48
64
|
if (result?.status !== "gate") return result;
|
|
49
65
|
// gate_snapshot_put stores snapshot and checkpoint in one service transaction.
|
|
50
66
|
await onEvent({ type: "gate", taskId, gate: result.gate, snapshotId: result.snapshotId, gateView: result.gateView });
|
|
67
|
+
if (untilStop) return { status: "gate", taskId, gate: result.gate, snapshotId: result.snapshotId, gateView: result.gateView };
|
|
51
68
|
}
|
|
52
|
-
if (failure) throw new Error(`Supervisor lease renewal failed: ${failure.message}`);
|
|
69
|
+
if (failure) throw new Error(`Supervisor lease renewal failed: ${explainFleetConflict(failure.message)}`);
|
|
53
70
|
return { status: "stopped", taskId };
|
|
54
71
|
} finally {
|
|
55
72
|
clearInterval(timer); await pending;
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* for multi-host AI-DLC execution.
|
|
4
4
|
*/
|
|
5
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
-
import { readyTaskItems, formatGateView } from "./store.mjs";
|
|
6
|
+
import { readyTaskItems, formatGateView, pathsOverlap } from "./store.mjs";
|
|
7
7
|
import { isLoopProvider, LOOP_PROVIDER_LIST } from "./loop-drivers.mjs";
|
|
8
|
+
import { explainFleetConflict, LEASES_COMMAND } from "./loop-holder.mjs";
|
|
8
9
|
|
|
9
10
|
export const FLEET_LIMITS = Object.freeze({ maxTurns: 12, turnTimeoutMs: 600_000, maxStallTurns: 2, maxWorkers: 4, leaseMs: 300_000, heartbeatMs: 60_000 });
|
|
10
11
|
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
@@ -37,9 +38,15 @@ function readyItems(task) {
|
|
|
37
38
|
const ready = new Set(readyTaskItems(task).ready);
|
|
38
39
|
return items.filter((item) => item.status === "todo" && ready.has(item.id));
|
|
39
40
|
}
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
const
|
|
41
|
+
/** Sleep `ms`, resolving early (never rejecting) once any given signal aborts. */
|
|
42
|
+
const wait = (ms, ...signals) => new Promise((resolve) => {
|
|
43
|
+
const live = signals.filter(Boolean);
|
|
44
|
+
if (live.some((signal) => signal.aborted)) return resolve();
|
|
45
|
+
const done = () => { clearTimeout(timer); for (const signal of live) signal.removeEventListener("abort", done); resolve(); };
|
|
46
|
+
const timer = setTimeout(done, ms);
|
|
47
|
+
for (const signal of live) signal.addEventListener("abort", done, { once: true });
|
|
48
|
+
});
|
|
49
|
+
const anySignal = (...signals) => { const live = signals.filter(Boolean); return live.length === 1 ? live[0] : AbortSignal.any(live); };
|
|
43
50
|
|
|
44
51
|
function key(...parts) { return digest(parts); }
|
|
45
52
|
|
|
@@ -105,7 +112,7 @@ async function withHeartbeat(renew, intervalMs, work) {
|
|
|
105
112
|
try {
|
|
106
113
|
const result = await work(abort.signal);
|
|
107
114
|
await pending;
|
|
108
|
-
if (failure) throw new Error(`Fleet lease renewal failed: ${failure.message}`);
|
|
115
|
+
if (failure) throw new Error(`Fleet lease renewal failed: ${explainFleetConflict(failure.message)}`);
|
|
109
116
|
return result;
|
|
110
117
|
} finally { clearInterval(timer); await pending; }
|
|
111
118
|
}
|
|
@@ -113,8 +120,13 @@ async function withHeartbeat(renew, intervalMs, work) {
|
|
|
113
120
|
/**
|
|
114
121
|
* One fenced coordinator invocation. The repository is the remote authority;
|
|
115
122
|
* no local lifecycle file or PID lease is consulted in fleet mode.
|
|
123
|
+
*
|
|
124
|
+
* `supervisorSignal` reports a lost supervisor lease (aborts the turn and
|
|
125
|
+
* fails). `stopSignal` requests a graceful stop at the next turn boundary;
|
|
126
|
+
* `cancelSignal` additionally aborts the in-flight lead turn. Both end as
|
|
127
|
+
* `{ status: "stopped" }` without changing canonical task state.
|
|
116
128
|
*/
|
|
117
|
-
export async function runFleetCoordinator({ repository, taskId, ownerId = randomUUID(), workerIds = [], workerProviders, reservedJobs = [], allowCodeOnlyRetry = false, limits: inputLimits, pollMs = 5000, pickupTimeoutMs: inputPickupTimeout, packet, driver, gateView, handoff, completeItem, claimedLead, supervisorSignal, onEvent = () => {} }) {
|
|
129
|
+
export async function runFleetCoordinator({ repository, taskId, ownerId = randomUUID(), workerIds = [], workerProviders, reservedJobs = [], allowCodeOnlyRetry = false, limits: inputLimits, pollMs = 5000, pickupTimeoutMs: inputPickupTimeout, packet, driver, gateView, handoff, completeItem, claimedLead, supervisorSignal, stopSignal, cancelSignal, onEvent = () => {} }) {
|
|
118
130
|
assertId(taskId, "task ID"); assertId(ownerId, "owner ID");
|
|
119
131
|
if (!Array.isArray(workerIds) || new Set(workerIds).size !== workerIds.length) throw new Error("workerIds must be distinct IDs");
|
|
120
132
|
for (const workerId of workerIds) assertId(workerId, "worker ID");
|
|
@@ -139,15 +151,19 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
|
|
|
139
151
|
const readySeen = new Map();
|
|
140
152
|
try {
|
|
141
153
|
return await withHeartbeat(() => renewLead({ taskId, ownerId, epoch, leaseMs: limits.leaseMs }), limits.heartbeatMs, async (leaseSignal) => {
|
|
142
|
-
const
|
|
154
|
+
const leaseLost = anySignal(leaseSignal, supervisorSignal);
|
|
155
|
+
const signal = anySignal(leaseLost, cancelSignal);
|
|
156
|
+
const stopping = () => Boolean(stopSignal?.aborted || cancelSignal?.aborted);
|
|
157
|
+
const stopped = (reason) => ({ status: "stopped", taskId, reason, revision: snapshot.revision });
|
|
143
158
|
for (;;) {
|
|
144
|
-
if (
|
|
159
|
+
if (leaseLost.aborted) throw new Error(`Lead lease renewal failed: the lead lease (run_claim, lead epoch ${epoch}) for ${taskId} could not be renewed or the supervisor lease was lost; another lead may have taken over or the coordination service is unreachable. Inspect: ${LEASES_COMMAND} ${taskId}`);
|
|
160
|
+
if (stopping()) return stopped(cancelSignal?.aborted ? "cancel requested" : "stop requested");
|
|
145
161
|
if (Date.now() - startedAt >= limits.maxTurns * limits.turnTimeoutMs) return { status: "paused", taskId, reason: "coordinator wall-time limit reached" };
|
|
146
162
|
snapshot = validateSnapshot(await readTask({ taskId }), taskId);
|
|
147
163
|
const kind = actionKind(snapshot.nextAction);
|
|
148
164
|
if (kind === "await_user") {
|
|
149
165
|
const reconciled = await reconcileFleetJobs({ repository, taskId, leadEpoch: epoch, snapshot, handoff, allowCodeOnlyRetry, quiesceMs: limits.turnTimeoutMs + limits.heartbeatMs + 1000 });
|
|
150
|
-
if (reconciled.pending.length && reconciled.pending.every((entry) => entry.waitUntil)) { await wait(pollMs); continue; }
|
|
166
|
+
if (reconciled.pending.length && reconciled.pending.every((entry) => entry.waitUntil)) { await wait(pollMs, signal, stopSignal); continue; }
|
|
151
167
|
if (reconciled.pending.length) return { status: "reconcile", taskId, revision: snapshot.revision, pending: reconciled.pending };
|
|
152
168
|
const gate = snapshot.nextAction.gate;
|
|
153
169
|
const baseReview = await (gateView ?? ((value) => formatGateView(value.task, [], "markdown")))(snapshot);
|
|
@@ -163,7 +179,7 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
|
|
|
163
179
|
const before = digest({ revision: snapshot.revision, action: snapshot.nextAction });
|
|
164
180
|
if (kind === "dispatch" || (kind === "run_phase" && snapshot.task.phase === "build")) {
|
|
165
181
|
const reconciled = await reconcileFleetJobs({ repository, taskId, leadEpoch: epoch, snapshot, handoff, allowCodeOnlyRetry, quiesceMs: limits.turnTimeoutMs + limits.heartbeatMs + 1000 });
|
|
166
|
-
if (reconciled.pending.length && reconciled.pending.every((entry) => entry.waitUntil)) { await wait(pollMs); continue; }
|
|
182
|
+
if (reconciled.pending.length && reconciled.pending.every((entry) => entry.waitUntil)) { await wait(pollMs, signal, stopSignal); continue; }
|
|
167
183
|
if (reconciled.pending.length) {
|
|
168
184
|
const verified = reconciled.pending.filter((entry) => entry.artifact?.valid);
|
|
169
185
|
if (verified.length && typeof handoff?.integrate === "function" && typeof completeItem === "function") {
|
|
@@ -212,7 +228,7 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
|
|
|
212
228
|
}
|
|
213
229
|
}
|
|
214
230
|
onEvent({ type: "jobs_active", taskId, assignments: active.map((job) => job.assignmentId) });
|
|
215
|
-
await wait(pollMs);
|
|
231
|
+
await wait(pollMs, signal, stopSignal);
|
|
216
232
|
continue;
|
|
217
233
|
}
|
|
218
234
|
}
|
|
@@ -220,7 +236,11 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
|
|
|
220
236
|
if (typeof packet !== "function" || typeof driver !== "function") throw new Error("Fleet lead requires packet and driver adapters");
|
|
221
237
|
const prompt = await packet(snapshot, epoch);
|
|
222
238
|
if (typeof prompt !== "string" || !prompt) throw new Error("Empty fleet lead packet");
|
|
223
|
-
await driver({ taskId, snapshot, leadEpoch: epoch, prompt, timeoutMs: limits.turnTimeoutMs, signal });
|
|
239
|
+
try { await driver({ taskId, snapshot, leadEpoch: epoch, prompt, timeoutMs: limits.turnTimeoutMs, signal }); }
|
|
240
|
+
catch (error) {
|
|
241
|
+
if (!leaseLost.aborted && cancelSignal?.aborted) return stopped("cancelled the in-flight lead turn");
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
224
244
|
turns++;
|
|
225
245
|
const after = validateSnapshot(await readTask({ taskId }), taskId);
|
|
226
246
|
const nextSignal = digest({ revision: after.revision, action: after.nextAction });
|
|
@@ -234,8 +254,10 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
|
|
|
234
254
|
}
|
|
235
255
|
}
|
|
236
256
|
|
|
237
|
-
/** A worker claims one attempt, persists its exact CLI session, and reports a Git artifact.
|
|
238
|
-
|
|
257
|
+
/** A worker claims one attempt, persists its exact CLI session, and reports a Git artifact.
|
|
258
|
+
* `cancelSignal` aborts the in-flight worker turn; the attempt is then abandoned
|
|
259
|
+
* (service state `uncertain`, recoverable by the lead) and the result is `stopped`. */
|
|
260
|
+
export async function runFleetWorker({ repository, workerId = randomUUID(), workerAgent, preflight, limits: inputLimits, driver, packet, handoff, cancelSignal, onEvent = () => {} }) {
|
|
239
261
|
assertId(workerId, "worker ID");
|
|
240
262
|
if (workerAgent !== undefined && !isLoopProvider(workerAgent)) throw new Error(`workerAgent must be ${LOOP_PROVIDER_LIST}`);
|
|
241
263
|
const limits = validateFleetLimits(inputLimits);
|
|
@@ -259,12 +281,14 @@ export async function runFleetWorker({ repository, workerId = randomUUID(), work
|
|
|
259
281
|
let turns = 0, stalls = 0;
|
|
260
282
|
const report = async (outcome) => reportJob({ ...identity, report: outcome, idempotencyKey: key("job-report", job.assignmentId, job.attempt, job.epoch, outcome.status, outcome.commitSha ?? "") });
|
|
261
283
|
return withHeartbeat(() => renewJob({ ...identity, leaseMs: limits.leaseMs }), limits.heartbeatMs, async (signal) => {
|
|
284
|
+
const cancelled = async () => { await report({ status: "uncertain", reason: "worker turn cancelled", sessionId }); return { status: "stopped", assignmentId: job.assignmentId }; };
|
|
262
285
|
if (typeof driver !== "function" || typeof packet !== "function" || typeof handoff?.prepare !== "function" || typeof handoff?.observe !== "function" || typeof handoff?.finish !== "function") throw new Error("Worker requires driver, packet, and Git handoff adapters");
|
|
263
286
|
const workspace = await handoff.prepare(job);
|
|
264
287
|
if (!workspace?.root) throw new Error("Git handoff did not supply a worker root");
|
|
265
288
|
let lastObservation = null;
|
|
266
289
|
for (;;) {
|
|
267
290
|
if (signal.aborted) throw new Error("Job lease renewal failed");
|
|
291
|
+
if (cancelSignal?.aborted) return cancelled();
|
|
268
292
|
const snapshot = validateSnapshot(await readTask({ taskId: job.taskId }), job.taskId);
|
|
269
293
|
const item = snapshot.task.tasks?.find((candidate) => candidate.id === job.itemId);
|
|
270
294
|
if (!item || item.status === "done") {
|
|
@@ -282,11 +306,13 @@ export async function runFleetWorker({ repository, workerId = randomUUID(), work
|
|
|
282
306
|
}
|
|
283
307
|
const prompt = await packet({ job, snapshot, workspace, turns });
|
|
284
308
|
if (typeof prompt !== "string" || !prompt) throw new Error("Empty worker packet");
|
|
285
|
-
|
|
309
|
+
let result;
|
|
310
|
+
try { result = await driver({ agent: job.agent, root: workspace.root, sessionId: sessionId ?? undefined, prompt: `${prompt}\n\nWorker scope: modify only assigned paths. Do not mutate AI-DLC lifecycle state or approve any gate. When the scoped implementation is ready for commit, end the final answer with a Conventional Commits message on its own line as AI-DLC-COMMIT: type(scope): subject (for example AI-DLC-COMMIT: docs(ui): add web UI improvement proposal), followed by AI-DLC-ITEM-COMPLETE on its own line.`, timeoutMs: limits.turnTimeoutMs, signal: anySignal(signal, cancelSignal), onSessionId: async (id) => {
|
|
286
311
|
if (typeof id !== "string" || !id) throw new Error("Invalid worker session ID");
|
|
287
312
|
if (sessionId && sessionId !== id) throw new Error("Worker session changed during the same assignment");
|
|
288
313
|
sessionId = id;
|
|
289
|
-
}, onEvent });
|
|
314
|
+
}, onEvent }); }
|
|
315
|
+
catch (error) { if (!signal.aborted && cancelSignal?.aborted) return cancelled(); throw error; }
|
|
290
316
|
if (result?.sessionId && sessionId && result.sessionId !== sessionId) throw new Error("Worker session changed during the same assignment");
|
|
291
317
|
if (result?.sessionId && !sessionId) {
|
|
292
318
|
sessionId = result.sessionId;
|
|
@@ -7,6 +7,7 @@ import { execFileSync } from "node:child_process";
|
|
|
7
7
|
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync } from "node:fs";
|
|
8
8
|
import { join, resolve } from "node:path";
|
|
9
9
|
import { isLoopProvider } from "./loop-drivers.mjs";
|
|
10
|
+
import { extractCommitMessage } from "./loop-commit-message.mjs";
|
|
10
11
|
import { createWorkerSnapshot, inspectWorkerSnapshot } from "./loop-worker-snapshot.mjs";
|
|
11
12
|
|
|
12
13
|
const SHA = /^[0-9a-f]{40,64}$/;
|
|
@@ -78,8 +79,20 @@ function fetchBranch(root, remote, branch) {
|
|
|
78
79
|
function ancestor(root, base, head) {
|
|
79
80
|
try { git(root, "merge-base", "--is-ancestor", base, head); return true; } catch { return false; } /* intentional: non-git/non-worktree is treated as not clean */
|
|
80
81
|
}
|
|
82
|
+
// Host-local loop operational files (daemon lock/log, UI event sidecar) and
|
|
83
|
+
// per-task runtime data (worker container index and logs) live in the lead
|
|
84
|
+
// checkout but are never part of a handoff or integration.
|
|
85
|
+
const LOOP_OPERATIONAL_PREFIX = ".agents/data/loop/";
|
|
86
|
+
const TASK_RUNTIME_PATTERN = /^\.agents\/data\/tasks\/[^/]+\/runtime\//;
|
|
87
|
+
/** True for repository-relative paths the loop writes operationally
|
|
88
|
+
* (`.agents/data/loop/` and `.agents/data/tasks/<id>/runtime/`); such paths are
|
|
89
|
+
* ignored by clean checks and working-tree change snapshots. */
|
|
90
|
+
export const isLoopOperationalPath = (path) => typeof path === "string" && (path.startsWith(LOOP_OPERATIONAL_PREFIX) || TASK_RUNTIME_PATTERN.test(path));
|
|
81
91
|
function assertClean(root) {
|
|
82
|
-
|
|
92
|
+
// Not via git(): its trim() would eat the leading status column of the first row.
|
|
93
|
+
const status = execFileSync("git", ["status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"], { cwd: root, encoding: "utf8", timeout: 30_000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
|
|
94
|
+
const dirty = status.split("\0").filter(Boolean).map((row) => row.slice(3)).filter((path) => !isLoopOperationalPath(path));
|
|
95
|
+
if (dirty.length) throw new Error("Git worktree must be clean before handoff or integration");
|
|
83
96
|
}
|
|
84
97
|
function normaliseReport(job) {
|
|
85
98
|
const report = job.report ?? job;
|
|
@@ -214,7 +227,7 @@ export function createWorkerGitHandoff({ root: inputRoot, worktreesRoot: inputWo
|
|
|
214
227
|
const done = typeof result?.resultText === "string" && result.resultText.split(/\r?\n/).some((line) => line.trim() === "AI-DLC-ITEM-COMPLETE");
|
|
215
228
|
return { done, fingerprint: worktreeFingerprint(workspace.root, changed) };
|
|
216
229
|
},
|
|
217
|
-
async finish({ job, workspace }) {
|
|
230
|
+
async finish({ job, workspace, result }) {
|
|
218
231
|
const branch = validBranch(job.branch);
|
|
219
232
|
if (git(workspace.root, "symbolic-ref", "--short", "HEAD") !== branch) throw new Error("Worker switched away from assigned branch");
|
|
220
233
|
const ownedPaths = scopes(job.ownedPaths);
|
|
@@ -222,7 +235,8 @@ export function createWorkerGitHandoff({ root: inputRoot, worktreesRoot: inputWo
|
|
|
222
235
|
checkScope(dirty, ownedPaths);
|
|
223
236
|
if (dirty.length) {
|
|
224
237
|
git(workspace.root, "add", "-A", "--", ...dirty);
|
|
225
|
-
|
|
238
|
+
const message = extractCommitMessage(result?.resultText, { itemId: job.itemId, ownedPaths });
|
|
239
|
+
git(workspace.root, "-c", "user.name=AI-DLC worker", "-c", "user.email=aidlc-worker@localhost", "commit", "-m", message);
|
|
226
240
|
}
|
|
227
241
|
const commitSha = git(workspace.root, "rev-parse", "HEAD");
|
|
228
242
|
const base = validSha(job.baseCommit, "base commit");
|
|
@@ -259,7 +273,7 @@ export function createIsolatedWorkerGitHandoff(options) {
|
|
|
259
273
|
const done = typeof result?.resultText === "string" && result.resultText.split(/\r?\n/).some((line) => line.trim() === "AI-DLC-ITEM-COMPLETE");
|
|
260
274
|
return {done, fingerprint};
|
|
261
275
|
},
|
|
262
|
-
async finish({job, workspace}) {
|
|
276
|
+
async finish({job, workspace, result}) {
|
|
263
277
|
const changed=inspectWorkerSnapshot(workspace.root, workspace.baseline, job.ownedPaths);
|
|
264
278
|
if (!changed.length) throw new Error("Worker produced no changes");
|
|
265
279
|
if (worktreePaths(workspace.checkout.root).length || git(workspace.checkout.root,"rev-parse","HEAD") !== job.baseCommit) throw new Error("Host checkout changed during worker run");
|
|
@@ -276,8 +290,8 @@ export function createIsolatedWorkerGitHandoff(options) {
|
|
|
276
290
|
if (!existsSync(source)) { rmSync(target,{force:true}); continue; }
|
|
277
291
|
copyFileSync(source,target); chmodSync(target,lstatSync(source).mode & 0o777);
|
|
278
292
|
}
|
|
279
|
-
const
|
|
280
|
-
rmSync(workspace.root,{recursive:true,force:true}); return
|
|
293
|
+
const report=await host.finish({job,workspace:workspace.checkout,result});
|
|
294
|
+
rmSync(workspace.root,{recursive:true,force:true}); return report;
|
|
281
295
|
},
|
|
282
296
|
async discard({workspace}) { rmSync(workspace.root,{recursive:true,force:true}); return host.discard({workspace:workspace.checkout}); }
|
|
283
297
|
};
|