@felixdotgo/aidlc-kit 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEVELOPMENT.md +14 -0
- package/README.md +11 -13
- package/dist/assets/.agents/aidlc/docker/Dockerfile.claude +7 -0
- package/dist/assets/.agents/aidlc/docker/Dockerfile.codex +9 -0
- package/dist/assets/.agents/aidlc/docker/Dockerfile.opencode +8 -0
- package/dist/assets/.agents/aidlc/orchestrator.md +1 -1
- package/dist/assets/.agents/aidlc/phase-clarify.md +1 -1
- package/dist/assets/.agents/aidlc/phase-plan.md +2 -2
- package/dist/assets/.agents/aidlc/schemas/config.schema.json +2 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +245 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-commit-message.mjs +45 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +232 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +15 -3
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +86 -28
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +7 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +22 -6
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +40 -15
- package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +14 -6
- package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +23 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +42 -15
- package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +75 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +60 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +142 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +9 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +31 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-image.mjs +64 -0
- package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +57 -2
- package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +85 -73
- package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +19 -7
- package/dist/assets/.agents/aidlc/scripts/loop.mjs +2 -1
- package/dist/assets/.agents/aidlc/templates/design.md +9 -0
- package/dist/assets/.agents/aidlc/templates/intent.md +4 -0
- package/dist/assets/.agents/aidlc/templates/workplan.md +1 -1
- package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +1 -1
- package/dist/assets/ui/bulma.min.css +3 -0
- package/dist/assets/ui/dashboard.css +414 -0
- package/dist/services/aidlc-coordination/Dockerfile +11 -3
- package/dist/services/aidlc-coordination/compose.yaml +12 -0
- package/dist/services/aidlc-coordination/package-lock.json +3 -1
- package/dist/services/aidlc-coordination/package.json +9 -4
- package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +57 -2
- package/dist/services/aidlc-coordination/src/repository.mjs +66 -0
- package/dist/services/aidlc-coordination/src/server.mjs +9 -3
- package/dist/services/aidlc-coordination/test/fleet.test.mjs +21 -0
- package/dist/services/aidlc-coordination/test/server-health-auth.test.mjs +67 -0
- package/dist/src/adapters.d.ts +0 -3
- package/dist/src/adapters.js +3 -5
- package/dist/src/cli/argv.d.ts +12 -0
- package/dist/src/cli/argv.js +62 -0
- package/dist/src/cli/commands.d.ts +8 -0
- package/dist/src/cli/commands.js +129 -0
- package/dist/src/cli/options.d.ts +25 -0
- package/dist/src/cli/options.js +32 -0
- package/dist/src/cli/prompt.d.ts +9 -0
- package/dist/src/cli/prompt.js +66 -0
- package/dist/src/cli.js +25 -191
- package/dist/src/gate.js +3 -2
- package/dist/src/installer.d.ts +14 -7
- package/dist/src/installer.js +38 -141
- package/dist/src/legacy.js +1 -0
- package/dist/src/managed-content.d.ts +7 -0
- package/dist/src/managed-content.js +15 -4
- package/dist/src/managed-planning.d.ts +65 -0
- package/dist/src/managed-planning.js +219 -0
- package/dist/src/mcp-setup.js +3 -1
- package/dist/src/model/config-contracts.d.ts +65 -0
- package/dist/src/model/config-contracts.js +1 -0
- package/dist/src/model/install-contracts.d.ts +29 -0
- package/dist/src/model/install-contracts.js +1 -0
- package/dist/src/model/lifecycle-enums.d.ts +13 -0
- package/dist/src/model/lifecycle-enums.js +1 -0
- package/dist/src/model/loop.d.ts +20 -0
- package/dist/src/model/loop.js +1 -0
- package/dist/src/model/manifest.d.ts +16 -0
- package/dist/src/model/manifest.js +1 -0
- package/dist/src/model/records.d.ts +138 -0
- package/dist/src/model/records.js +1 -0
- package/dist/src/model.d.ts +11 -264
- package/dist/src/model.js +1 -1
- package/dist/src/profiles.d.ts +2 -0
- package/dist/src/profiles.js +53 -9
- package/dist/src/state/archive-record.d.ts +9 -0
- package/dist/src/state/archive-record.js +26 -0
- package/dist/src/state/commands.d.ts +18 -0
- package/dist/src/state/commands.js +27 -0
- package/dist/src/state/domain.d.ts +9 -0
- package/dist/src/state/domain.js +9 -0
- package/dist/src/state/guards.d.ts +7 -0
- package/dist/src/state/guards.js +17 -0
- package/dist/src/state/io.d.ts +10 -0
- package/dist/src/state/io.js +35 -0
- package/dist/src/state/items.d.ts +3 -4
- package/dist/src/state/items.js +58 -57
- package/dist/src/state/lessons.js +14 -7
- package/dist/src/state/lifecycle.js +2 -2
- package/dist/src/state/lock.js +7 -6
- package/dist/src/state/memory.js +24 -12
- package/dist/src/state/{archive.js → migrate.js} +7 -7
- package/dist/src/state/partition.d.ts +15 -0
- package/dist/src/state/partition.js +90 -0
- package/dist/src/state/paths.d.ts +11 -0
- package/dist/src/state/paths.js +12 -0
- package/dist/src/state/render.js +9 -6
- package/dist/src/state/store.js +9 -15
- package/dist/src/state/transitions.js +9 -7
- package/dist/src/state/validate.js +50 -7
- package/dist/src/state.d.ts +16 -11
- package/dist/src/state.js +16 -11
- package/dist/src/task-ui.d.ts +54 -0
- package/dist/src/task-ui.js +1570 -0
- package/dist/src/upgrade-board.js +10 -10
- package/dist/src/upgrade.d.ts +0 -1
- package/dist/src/upgrade.js +19 -83
- package/dist/src/workflow.js +3 -3
- package/docs/README.md +6 -2
- package/docs/architecture.md +9 -8
- package/docs/command-reference.md +81 -20
- package/docs/configuration.md +28 -8
- package/docs/coordination-service.md +18 -6
- package/docs/design-decisions.md +83 -0
- package/docs/development.md +27 -2
- package/docs/loop-local-run.md +495 -0
- package/docs/loop-runbook.md +235 -0
- package/docs/operating-workflow.md +2 -2
- package/docs/testing-and-release.md +5 -1
- package/docs/troubleshooting.md +125 -0
- package/package.json +4 -1
- package/dist/src/ledger.d.ts +0 -22
- package/dist/src/ledger.js +0 -119
- package/dist/src/state/shared.d.ts +0 -29
- package/dist/src/state/shared.js +0 -71
- package/dist/src/tracker.d.ts +0 -36
- package/dist/src/tracker.js +0 -44
- /package/dist/src/state/{archive.d.ts → migrate.d.ts} +0 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
+
import { requireLocalAuthority } from "./authority.mjs";
|
|
6
|
+
import { isLoopProvider, LOOP_PROVIDER_LIST } from "./loop-drivers.mjs";
|
|
7
|
+
import { validateLimits, validateRoot, readLoopRecord } from "./loop-runtime.mjs";
|
|
8
|
+
import { createIsolatedLocalSupervisor } from "./loop-supervisor-run-local.mjs";
|
|
9
|
+
import { loadState, nextAction } from "./store.mjs";
|
|
10
|
+
|
|
11
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
12
|
+
|
|
13
|
+
export const DAEMON_POLL_MIN = 1000;
|
|
14
|
+
export const DAEMON_POLL_MAX = 300000;
|
|
15
|
+
export const DEFAULT_DAEMON_POLL_MS = 5000;
|
|
16
|
+
|
|
17
|
+
export function validateDaemonPollMs(value) {
|
|
18
|
+
const n = Number(value);
|
|
19
|
+
if (!Number.isSafeInteger(n) || n < DAEMON_POLL_MIN || n > DAEMON_POLL_MAX) throw new Error(`daemon pollMs must be an integer from ${DAEMON_POLL_MIN} to ${DAEMON_POLL_MAX}`);
|
|
20
|
+
return n;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A task is loop-owned when it was created through loop intake (CLI or web
|
|
24
|
+
* dashboard: `.agents/data/tasks/<id>/loop-intake.json`) or already has a loop
|
|
25
|
+
* run record. Tasks created by an interactive lead are not loop-owned, so the
|
|
26
|
+
* daemon never runs them concurrently with that lead. */
|
|
27
|
+
export function isLoopOwned(root, taskId) {
|
|
28
|
+
if (!ID.test(taskId ?? "")) return false;
|
|
29
|
+
try { if (lstatSync(join(root, ".agents", "data", "tasks", taskId, "loop-intake.json")).isFile()) return true; }
|
|
30
|
+
catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
31
|
+
return readLoopRecord(root, taskId) !== null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Path of the singleton daemon lock (one local daemon per project root). */
|
|
35
|
+
export const daemonLockPath = (root) => join(root, ".agents", "data", "loop", "daemon.lock");
|
|
36
|
+
|
|
37
|
+
const pidAlive = (pid) => {
|
|
38
|
+
try { process.kill(pid, 0); return true; }
|
|
39
|
+
catch (error) { return error?.code !== "ESRCH"; }
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const parseDaemonLock = (path) => {
|
|
43
|
+
let value;
|
|
44
|
+
try { value = JSON.parse(readFileSync(path, "utf8")); }
|
|
45
|
+
catch (error) { if (error?.code === "ENOENT") return null; return { corrupt: true }; }
|
|
46
|
+
if (!value || value.schemaVersion !== 1 || !Number.isSafeInteger(value.pid) || value.pid < 1 || typeof value.token !== "string" || !value.token) return { corrupt: true };
|
|
47
|
+
return value;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Read the daemon lock: `null` when absent, otherwise `{ pid, startedAt, alive }`. */
|
|
51
|
+
export function readDaemonLock(root) {
|
|
52
|
+
const lock = parseDaemonLock(daemonLockPath(root));
|
|
53
|
+
if (!lock) return null;
|
|
54
|
+
if (lock.corrupt) return { pid: null, startedAt: null, alive: false };
|
|
55
|
+
return { pid: lock.pid, startedAt: lock.startedAt ?? null, alive: pidAlive(lock.pid) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Run `action` under a short-lived exclusive guard file so two starters
|
|
59
|
+
* cannot both reclaim the same stale lock (one would delete the other's fresh
|
|
60
|
+
* lock). A guard older than 10s is treated as abandoned. */
|
|
61
|
+
function withReclaimGuard(path, action) {
|
|
62
|
+
const guard = `${path}.guard`;
|
|
63
|
+
const deadline = Date.now() + 5000;
|
|
64
|
+
for (;;) {
|
|
65
|
+
try { writeFileSync(guard, String(process.pid), { flag: "wx", mode: 0o600 }); break; }
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error?.code !== "EEXIST") throw error;
|
|
68
|
+
try { if (Date.now() - lstatSync(guard).mtimeMs > 10000) { unlinkSync(guard); continue; } }
|
|
69
|
+
catch (statError) { if (statError?.code === "ENOENT") continue; throw statError; }
|
|
70
|
+
if (Date.now() >= deadline) throw new Error("Timed out waiting for the loop daemon lock guard");
|
|
71
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
try { return action(); }
|
|
75
|
+
finally { try { unlinkSync(guard); } catch (error) { if (error?.code !== "ENOENT") throw error; } }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Acquire the singleton daemon lock. A lock owned by a dead pid (or a corrupt
|
|
79
|
+
* lock) is reclaimed under a guard after re-checking it is still the same stale
|
|
80
|
+
* lock; a live owner fails closed. Liveness is a pid probe, like the loop and
|
|
81
|
+
* supervisor leases, so a recycled pid can keep a stale lock "alive" until the
|
|
82
|
+
* file is removed by hand. Returns a release function. */
|
|
83
|
+
export function acquireDaemonLock(root) {
|
|
84
|
+
const path = daemonLockPath(root);
|
|
85
|
+
mkdirSync(join(root, ".agents", "data", "loop"), { recursive: true });
|
|
86
|
+
const token = randomUUID();
|
|
87
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
88
|
+
try {
|
|
89
|
+
writeFileSync(path, `${JSON.stringify({ schemaVersion: 1, pid: process.pid, token, startedAt: new Date().toISOString() })}\n`, { flag: "wx", mode: 0o600 });
|
|
90
|
+
return () => {
|
|
91
|
+
const current = parseDaemonLock(path);
|
|
92
|
+
if (current && !current.corrupt && current.token === token) { try { unlinkSync(path); } catch (error) { if (error?.code !== "ENOENT") throw error; } }
|
|
93
|
+
};
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error?.code !== "EEXIST") throw error;
|
|
96
|
+
let stat;
|
|
97
|
+
try { stat = lstatSync(path); } catch (statError) { if (statError?.code === "ENOENT") continue; throw statError; }
|
|
98
|
+
if (stat.isSymbolicLink()) throw new Error(`Daemon lock is a symlink: ${path}`);
|
|
99
|
+
const observed = parseDaemonLock(path);
|
|
100
|
+
if (!observed) continue;
|
|
101
|
+
if (!observed.corrupt && pidAlive(observed.pid)) throw new Error(`Loop daemon already running (pid ${observed.pid})`);
|
|
102
|
+
withReclaimGuard(path, () => {
|
|
103
|
+
const current = parseDaemonLock(path);
|
|
104
|
+
const same = current && (observed.corrupt ? current.corrupt : !current.corrupt && current.token === observed.token);
|
|
105
|
+
if (same) { try { unlinkSync(path); } catch (unlinkError) { if (unlinkError?.code !== "ENOENT") throw unlinkError; } }
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
throw new Error("Could not acquire the loop daemon lock");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Enumerate actionable, loop-owned tasks from canonical state in a
|
|
113
|
+
* deterministic order: every task whose derived nextAction classification is
|
|
114
|
+
* `run_phase` and that `isOwned` accepts, sorted by updatedAt then id so a task
|
|
115
|
+
* that just ran moves to the back of the queue. A loop record that cannot be
|
|
116
|
+
* read counts as owned so its error surfaces through the turn (and backoff)
|
|
117
|
+
* instead of being silently skipped. */
|
|
118
|
+
export function actionableTasks(state, root, { isOwned = (id) => isLoopOwned(root, id) } = {}) {
|
|
119
|
+
const owned = (id) => { try { return isOwned(id); } catch { return true; } };
|
|
120
|
+
return Object.values(state.tasks)
|
|
121
|
+
.filter((task) => nextAction(task, root).classification === "run_phase" && owned(task.id))
|
|
122
|
+
.sort((a, b) => String(a.updatedAt ?? "").localeCompare(String(b.updatedAt ?? "")) || a.id.localeCompare(b.id));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const DAEMON_BACKOFF_MAX_MS = 600000;
|
|
126
|
+
|
|
127
|
+
/** Exponential retry delay for a task whose turn keeps throwing: the poll
|
|
128
|
+
* interval doubled per consecutive failure, capped at 10 minutes. */
|
|
129
|
+
export function backoffDelay(pollMs, failures) {
|
|
130
|
+
if (failures < 1) return 0;
|
|
131
|
+
return Math.min(pollMs * 2 ** Math.min(failures - 1, 30), DAEMON_BACKOFF_MAX_MS);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Run one supervised lead run for an existing task (no requirement intake):
|
|
135
|
+
* a single runLoop invocation that continues until the next gate, block,
|
|
136
|
+
* pause, completion, or stop — bounded by the per-run `maxTurns`. A task with a
|
|
137
|
+
* loop run record is resumed; a freshly intaken task has none and starts. */
|
|
138
|
+
async function runTaskTurn({ root, taskId, agent, workers, workerImages, limits, pollMs, signal, cancelSignal, onEvent }) {
|
|
139
|
+
const supervisor = createIsolatedLocalSupervisor({ root, taskId, agent, workers, workerImages, limits, pollMs, signal, cancelSignal, untilStop: true, onEvent });
|
|
140
|
+
const record = readLoopRecord(root, taskId);
|
|
141
|
+
return record ? supervisor.resume() : supervisor.start();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Local multi-task daemon: a foreground long-running loop (one per project,
|
|
146
|
+
* enforced by the singleton lock) that polls canonical state and, for every
|
|
147
|
+
* actionable loop-owned task, runs one supervised lead run via the isolated
|
|
148
|
+
* local supervisor. Tasks are served one at a time in updatedAt order, so a
|
|
149
|
+
* long run delays the others until it reaches its next stop. A task that
|
|
150
|
+
* reaches a gate, blocks, completes, or terminates stops being actionable and
|
|
151
|
+
* is skipped on the next poll; a task whose run throws is retried with
|
|
152
|
+
* exponential backoff. Gate approval stays human-only. `signal` stops after the
|
|
153
|
+
* current turn; `cancelSignal` also cancels the in-flight agent turn.
|
|
154
|
+
*/
|
|
155
|
+
/**
|
|
156
|
+
* Shared loop daemon (local and fleet): the same singleton lock, round-robin,
|
|
157
|
+
* backoff, two-stage shutdown, and taskId-stamped events over a loop authority
|
|
158
|
+
* from `loop-authority.mjs` (`listActionable` + `runTask`). The authority alone
|
|
159
|
+
* decides which tasks are loop-owned and how one bounded run executes.
|
|
160
|
+
*/
|
|
161
|
+
export async function runLoopDaemon({ root: rootInput, authority, pollMs = DEFAULT_DAEMON_POLL_MS, signal, cancelSignal, onEvent = () => {}, now = Date.now } = {}) {
|
|
162
|
+
const root = validateRoot(rootInput);
|
|
163
|
+
if (!authority || typeof authority.listActionable !== "function" || typeof authority.runTask !== "function") throw new Error("Loop daemon requires a loop authority");
|
|
164
|
+
const poll = validateDaemonPollMs(pollMs);
|
|
165
|
+
const releaseLock = acquireDaemonLock(root);
|
|
166
|
+
try {
|
|
167
|
+
onEvent({ type: "daemon_start", authority: authority.mode ?? "unknown", pid: process.pid });
|
|
168
|
+
return await daemonLoop({ root, poll, signal, cancelSignal, onEvent, now, listActionable: () => authority.listActionable(), turn: ({ taskId, signal: runSignal, cancelSignal: runCancel, onEvent: runEvent }) => authority.runTask({ taskId, signal: runSignal, cancelSignal: runCancel, onEvent: runEvent }) });
|
|
169
|
+
} finally { releaseLock(); }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function runLocalDaemon({ root: rootInput, agent, workers = {}, workerImages = {}, limits, pollMs = DEFAULT_DAEMON_POLL_MS, signal, cancelSignal, onEvent = () => {}, now = Date.now } = {}) {
|
|
173
|
+
requireLocalAuthority();
|
|
174
|
+
const root = validateRoot(rootInput);
|
|
175
|
+
if (!isLoopProvider(agent)) throw new Error(`local lead agent must be ${LOOP_PROVIDER_LIST}`);
|
|
176
|
+
if (!workers || typeof workers !== "object" || Array.isArray(workers) || !Object.keys(workers).length) throw new Error("Loop requires a configured lead and worker providers");
|
|
177
|
+
for (const [id, provider] of Object.entries(workers)) if (!ID.test(id) || !isLoopProvider(provider)) throw new Error(`invalid local worker: ${id}`);
|
|
178
|
+
const poll = validateDaemonPollMs(pollMs);
|
|
179
|
+
// Fail fast on a bad config.loop.limits instead of failing every task turn.
|
|
180
|
+
if (limits !== undefined) validateLimits(limits);
|
|
181
|
+
const releaseLock = acquireDaemonLock(root);
|
|
182
|
+
try {
|
|
183
|
+
return await daemonLoop({ root, agent, workers, workerImages, limits, poll, signal, cancelSignal, onEvent, now, turn: runTaskTurn });
|
|
184
|
+
} finally { releaseLock(); }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Run results that leave the task actionable without progress; retried with backoff like errors. */
|
|
188
|
+
const RETRY_STATUSES = new Set(["blocked", "reconcile"]);
|
|
189
|
+
|
|
190
|
+
/** Poll/run loop. Exported for tests (inject `turn`, `now`, and optionally `listActionable`). */
|
|
191
|
+
export async function daemonLoop({ root, agent, workers, workerImages, limits, poll, signal, cancelSignal, onEvent, now = Date.now, turn = runTaskTurn, isOwned, listActionable }) {
|
|
192
|
+
const backoff = new Map();
|
|
193
|
+
for (;;) {
|
|
194
|
+
if (signal?.aborted) return { status: "stopped" };
|
|
195
|
+
let actionable;
|
|
196
|
+
try { actionable = listActionable ? await listActionable() : actionableTasks(loadState(root), root, isOwned ? { isOwned } : {}); }
|
|
197
|
+
catch (error) {
|
|
198
|
+
// An unreachable authority (e.g. the fleet service) must not stop the daemon.
|
|
199
|
+
onEvent({ type: "daemon_poll_error", message: error instanceof Error ? error.message : String(error) });
|
|
200
|
+
try { await sleep(poll, undefined, { signal }); } catch (sleepError) { if (sleepError?.name === "AbortError") return { status: "stopped" }; throw sleepError; }
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
// Forget backoff for tasks that are no longer actionable (e.g. a human
|
|
204
|
+
// paused or approved them), so they start fresh when they come back.
|
|
205
|
+
for (const id of [...backoff.keys()]) if (!actionable.some((task) => task.id === id)) backoff.delete(id);
|
|
206
|
+
onEvent({ type: "daemon_poll", actionable: actionable.map((task) => task.id), ...(backoff.size ? { backingOff: [...backoff.keys()] } : {}) });
|
|
207
|
+
for (const task of actionable) {
|
|
208
|
+
if (signal?.aborted) return { status: "stopped" };
|
|
209
|
+
const delay = backoff.get(task.id);
|
|
210
|
+
if (delay && now() < delay.nextAt) continue;
|
|
211
|
+
// Stamp every event of this run with its task id so the UI can filter.
|
|
212
|
+
const taskEvent = (event) => onEvent(event && typeof event === "object" && !event.taskId ? { ...event, taskId: task.id } : event);
|
|
213
|
+
try {
|
|
214
|
+
const result = await turn({ root, taskId: task.id, agent, workers, workerImages, limits, pollMs: poll, signal, cancelSignal, onEvent: taskEvent });
|
|
215
|
+
if (result?.status === "gate" && !result.gateEmitted) onEvent({ type: "gate", taskId: task.id, gate: result.gate, gateView: result.gateView });
|
|
216
|
+
onEvent({ type: "daemon_turn", taskId: task.id, status: result?.status ?? "unknown", ...(result?.gate ? { gate: result.gate } : {}), ...(result?.reason ? { reason: result.reason } : {}) });
|
|
217
|
+
if (RETRY_STATUSES.has(result?.status)) {
|
|
218
|
+
const failures = (backoff.get(task.id)?.failures ?? 0) + 1;
|
|
219
|
+
backoff.set(task.id, { failures, nextAt: now() + backoffDelay(poll, failures) });
|
|
220
|
+
} else backoff.delete(task.id);
|
|
221
|
+
} catch (error) {
|
|
222
|
+
// A failing task must not take the daemon down; surface it, back off, move on.
|
|
223
|
+
const failures = (backoff.get(task.id)?.failures ?? 0) + 1;
|
|
224
|
+
const retryInMs = backoffDelay(poll, failures);
|
|
225
|
+
backoff.set(task.id, { failures, nextAt: now() + retryInMs });
|
|
226
|
+
onEvent({ type: "daemon_turn_error", taskId: task.id, message: error instanceof Error ? error.message : String(error), failures, retryInMs });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
try { await sleep(poll, undefined, { signal }); }
|
|
230
|
+
catch (error) { if (error?.name === "AbortError") return { status: "stopped" }; throw error; }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -9,6 +9,7 @@ import { delimiter, join } from "node:path";
|
|
|
9
9
|
const MAX_LINE_BYTES = 1024 * 1024;
|
|
10
10
|
const MAX_STDERR_BYTES = 4096;
|
|
11
11
|
const MAX_RESULT_CHARS = 8192;
|
|
12
|
+
const MAX_STREAM_CHARS = 4096;
|
|
12
13
|
const MAX_DIAGNOSTICS = 16;
|
|
13
14
|
|
|
14
15
|
/** One runtime enumeration for every loop provider. Host gates import this
|
|
@@ -51,7 +52,7 @@ registerAgentAdapter("codex", {
|
|
|
51
52
|
if (event.type === "turn.completed") context.complete();
|
|
52
53
|
if (event.type === "turn.failed" || event.type === "error") throw new Error(`codex reported ${event.type}: ${diagnostic(JSON.stringify(event.error ?? event.message ?? "unknown"))}`);
|
|
53
54
|
const item = record(event.item);
|
|
54
|
-
if (event.type === "item.completed" && item?.type === "agent_message" && typeof item.text === "string") context.result(item.text);
|
|
55
|
+
if (event.type === "item.completed" && item?.type === "agent_message" && typeof item.text === "string") { context.result(item.text); await context.stream(item.text); }
|
|
55
56
|
if (event.type === "item.completed" && item?.type === "command_execution") await context.commandEvent(item.exit_code, item.status);
|
|
56
57
|
}
|
|
57
58
|
});
|
|
@@ -88,8 +89,13 @@ registerAgentAdapter("opencode", {
|
|
|
88
89
|
},
|
|
89
90
|
parse: async (event, context) => {
|
|
90
91
|
if (event.sessionID !== undefined) await context.captureId(event.sessionID);
|
|
92
|
+
if (event.type === "error") {
|
|
93
|
+
const info = record(event.error);
|
|
94
|
+
throw new Error(`opencode reported an error: ${diagnostic(info?.message ?? info?.type ?? JSON.stringify(event.error ?? "unknown"))}`);
|
|
95
|
+
}
|
|
91
96
|
const part = record(event.part);
|
|
92
|
-
if (event.type === "text" && typeof part?.text === "string") context.result(part.text);
|
|
97
|
+
if (event.type === "text" && typeof part?.text === "string") { context.result(part.text); await context.stream(part.text); }
|
|
98
|
+
if (event.type === "tool_use" && typeof part?.tool === "string") await context.tool(part.tool);
|
|
93
99
|
if (event.type === "tool_use" && part?.tool === "shell") {
|
|
94
100
|
const state = record(part.state);
|
|
95
101
|
const metadata = record(record(state?.metadata)?.metadata);
|
|
@@ -168,11 +174,17 @@ export async function runAgentTurn({ agent, root, sessionId, prompt, timeoutMs,
|
|
|
168
174
|
await emit(onEvent, { type: "lifecycle_command", exitCode: safeExitCode, status: safeStatus });
|
|
169
175
|
if ((safeExitCode !== null && safeExitCode !== 0) || safeStatus === "failed") addDiagnostic(`commandFailed exitCode=${String(safeExitCode ?? "unknown")} status=${safeStatus}`);
|
|
170
176
|
};
|
|
177
|
+
const stream = async (text) => {
|
|
178
|
+
if (typeof text === "string" && text.trim()) await emit(onEvent, { type: "agent_text", text: text.slice(0, MAX_STREAM_CHARS) });
|
|
179
|
+
};
|
|
180
|
+
const tool = async (name) => {
|
|
181
|
+
if (typeof name === "string" && name) await emit(onEvent, { type: "agent_tool", tool: name });
|
|
182
|
+
};
|
|
171
183
|
const parseEvent = async (line) => {
|
|
172
184
|
let event;
|
|
173
185
|
try { event = JSON.parse(line); } catch { throw new Error(`${agent} emitted malformed JSONL`); }
|
|
174
186
|
if (!record(event) || typeof event.type !== "string") throw new Error(`${agent} emitted malformed event`);
|
|
175
|
-
await adapter.parse(event, { captureId, commandEvent, toolUses, complete: () => { completed = true; resultSeen = true; }, result: (value) => { resultText = value.slice(0, MAX_RESULT_CHARS); } });
|
|
187
|
+
await adapter.parse(event, { captureId, commandEvent, toolUses, stream, tool, complete: () => { completed = true; resultSeen = true; }, result: (value) => { resultText = value.slice(0, MAX_RESULT_CHARS); } });
|
|
176
188
|
await emit(onEvent, { type: "agent_event", eventType: event.type });
|
|
177
189
|
};
|
|
178
190
|
const queueLine = (line) => {
|
|
@@ -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,32 @@ 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 { defaultWorkerImageTag, ensureWorkerImage, validWorkerImageTag } from "./loop-worker-image.mjs";
|
|
12
13
|
|
|
13
14
|
const required = (env, name) => { const value = env[name]; if (typeof value !== "string" || !value.trim()) throw new Error(`${name} is required`); return value; };
|
|
14
|
-
|
|
15
|
+
/** Sleep `ms`, resolving early once any given signal aborts. */
|
|
16
|
+
const pause = (ms, ...signals) => new Promise((resolve) => {
|
|
17
|
+
const live = signals.filter(Boolean);
|
|
18
|
+
if (live.some((signal) => signal.aborted)) return resolve();
|
|
19
|
+
const done = () => { clearTimeout(timer); for (const signal of live) signal.removeEventListener("abort", done); resolve(); };
|
|
20
|
+
const timer = setTimeout(done, ms);
|
|
21
|
+
for (const signal of live) signal.addEventListener("abort", done, { once: true });
|
|
22
|
+
});
|
|
15
23
|
const provider = (value, name) => { if (!isLoopProvider(value)) throw new Error(`${name} must be ${LOOP_PROVIDER_LIST}`); return value; };
|
|
24
|
+
const pendingFeedback = (task) => {
|
|
25
|
+
const evidence = Array.isArray(task?.evidence) ? task.evidence : [];
|
|
26
|
+
const pending = [];
|
|
27
|
+
for (let i = 0; i < evidence.length; i++) {
|
|
28
|
+
const entry = evidence[i];
|
|
29
|
+
if (entry?.kind !== "diagnostic" || typeof entry.detail !== "string") continue;
|
|
30
|
+
const match = entry.detail.match(/^Cancelled gate wait at (G0_confirm|G1_review|G2_codereview): /);
|
|
31
|
+
if (!match) continue;
|
|
32
|
+
const approvedAfter = evidence.slice(i + 1).some((later) => later?.kind === "approval" && later.result === "pass" && later.gate === match[1]);
|
|
33
|
+
if (!approvedAfter) pending.push(entry.detail);
|
|
34
|
+
}
|
|
35
|
+
return pending;
|
|
36
|
+
};
|
|
16
37
|
function workerMapping(env, workerIds, defaultAgent) {
|
|
17
38
|
if (!env.AIDLC_FLEET_WORKER_PROVIDERS) return Object.fromEntries(workerIds.map((id) => [id, defaultAgent]));
|
|
18
39
|
let mapping;
|
|
@@ -20,9 +41,46 @@ function workerMapping(env, workerIds, defaultAgent) {
|
|
|
20
41
|
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
42
|
return mapping;
|
|
22
43
|
}
|
|
44
|
+
/** This worker's provider: AIDLC_FLEET_WORKER_AGENT, else its entry in
|
|
45
|
+
* AIDLC_FLEET_WORKER_PROVIDERS (keyed by worker ID), else AIDLC_FLEET_AGENT
|
|
46
|
+
* (the lead's provider — only a last resort, so a copied lead env cannot
|
|
47
|
+
* override this worker's own mapping), else undefined. */
|
|
48
|
+
function workerProvider(env, workerId) {
|
|
49
|
+
if (env.AIDLC_FLEET_WORKER_AGENT !== undefined) return provider(env.AIDLC_FLEET_WORKER_AGENT, "AIDLC_FLEET_WORKER_AGENT");
|
|
50
|
+
if (env.AIDLC_FLEET_WORKER_PROVIDERS) {
|
|
51
|
+
let mapping;
|
|
52
|
+
try { mapping = JSON.parse(env.AIDLC_FLEET_WORKER_PROVIDERS); } catch { throw new Error("AIDLC_FLEET_WORKER_PROVIDERS must be a JSON object"); }
|
|
53
|
+
if (!mapping || typeof mapping !== "object" || Array.isArray(mapping)) throw new Error("AIDLC_FLEET_WORKER_PROVIDERS must be a JSON object");
|
|
54
|
+
if (Object.hasOwn(mapping, workerId)) return provider(mapping[workerId], `AIDLC_FLEET_WORKER_PROVIDERS["${workerId}"]`);
|
|
55
|
+
}
|
|
56
|
+
return env.AIDLC_FLEET_AGENT === undefined ? undefined : provider(env.AIDLC_FLEET_AGENT, "AIDLC_FLEET_AGENT");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Worker image tag: explicit AIDLC_WORKER_DOCKER_IMAGE, else the project's
|
|
60
|
+
* `.agents/config.json` `loop.workerImage[provider]`, else the default tag
|
|
61
|
+
* (built from the shipped Dockerfile when missing). */
|
|
62
|
+
function workerImageTag({ env, root, agent, onEvent }) {
|
|
63
|
+
const requested = env.AIDLC_WORKER_DOCKER_IMAGE;
|
|
64
|
+
if (typeof requested === "string" && requested.trim()) return requested;
|
|
65
|
+
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");
|
|
66
|
+
const configPath = join(root, ".agents", "config.json");
|
|
67
|
+
let configured;
|
|
68
|
+
if (existsSync(configPath)) {
|
|
69
|
+
let config;
|
|
70
|
+
try { config = JSON.parse(readFileSync(configPath, "utf8")); } catch { throw new Error(`Invalid project config: ${configPath}`); }
|
|
71
|
+
configured = config?.loop?.workerImage?.[agent];
|
|
72
|
+
if (configured !== undefined && !validWorkerImageTag(configured)) throw new Error(`Invalid loop.workerImage.${agent} in ${configPath}`);
|
|
73
|
+
}
|
|
74
|
+
const image = configured ?? defaultWorkerImageTag(agent);
|
|
75
|
+
return ensureWorkerImage({ root, provider: agent, image, env, onEvent });
|
|
76
|
+
}
|
|
23
77
|
|
|
24
|
-
/** Package-local entrypoint. The service wrapper supplies its MCP HTTP client.
|
|
25
|
-
|
|
78
|
+
/** Package-local entrypoint. The service wrapper supplies its MCP HTTP client.
|
|
79
|
+
* `signal` stops after the current turn; `cancelSignal` also aborts the
|
|
80
|
+
* in-flight agent turn (result status `stopped`). `onEvent(event)` receives
|
|
81
|
+
* every emitted event object instead of `out(JSON.stringify(event))`.
|
|
82
|
+
* `untilStop` applies to role `supervisor` only (see runFleetSupervisor). */
|
|
83
|
+
export async function runFleetCli(argv, { callTool, env = process.env, out = console.log, onEvent, signal, cancelSignal, untilStop = false, coordinator: coordinatorOverride } = {}) {
|
|
26
84
|
const [role, targetId, ...flags] = argv;
|
|
27
85
|
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
86
|
const once = flags.includes("--once") || (role === "worker" && targetId === "--once");
|
|
@@ -44,23 +102,21 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
|
|
|
44
102
|
const pollMs = env.AIDLC_FLEET_POLL_MS ? Number(env.AIDLC_FLEET_POLL_MS) : 5000;
|
|
45
103
|
if (!Number.isSafeInteger(pollMs) || pollMs < 1000 || pollMs > 300000) throw new Error("AIDLC_FLEET_POLL_MS must be 1000..300000");
|
|
46
104
|
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));
|
|
105
|
+
const emit = typeof onEvent === "function" ? (event) => onEvent(event) : (event) => out(JSON.stringify(event));
|
|
106
|
+
const stopRequested = () => Boolean(signal?.aborted || cancelSignal?.aborted);
|
|
48
107
|
if (role === "worker") {
|
|
49
108
|
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");
|
|
109
|
+
const workerAgent = workerProvider(env, workerId);
|
|
110
|
+
const requestedImage = workerImageTag({ env, root, agent: workerAgent, onEvent: emit });
|
|
54
111
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,255}$/.test(requestedImage)) throw new Error("Invalid Docker worker image");
|
|
55
112
|
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
113
|
if (!/^sha256:[a-f0-9]{64}$/.test(image)) throw new Error("Docker worker image lacks immutable image ID");
|
|
57
114
|
const uid = process.getuid?.(), gid = process.getgid?.();
|
|
58
115
|
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
116
|
const preflight = async (agent) => {
|
|
60
|
-
const credential = agent === "codex" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
|
|
61
117
|
const empty = mkdtempSync(join(tmpdir(), "aidlc-worker-preflight-"));
|
|
62
118
|
try {
|
|
63
|
-
const invocation = workerDockerInvocation({workspace:empty,image,command:agent,args:["--version"],credentialEnv:
|
|
119
|
+
const invocation = workerDockerInvocation({workspace:empty,image,command:agent,args:["--version"],credentialEnv:workerCredentialKeys(agent, env),hostEnv:env,uid,gid});
|
|
64
120
|
execFileSync("docker", ["info"], {env:{PATH:invocation.env.PATH},stdio:"ignore",timeout:30000});
|
|
65
121
|
execFileSync("docker", ["image","inspect",image], {env:{PATH:invocation.env.PATH},stdio:"ignore",timeout:30000});
|
|
66
122
|
const name = `aidlc-worker-preflight-${randomUUID()}`;
|
|
@@ -90,15 +146,14 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
|
|
|
90
146
|
async discard(args) { try { return await baseHandoff.discard(args); } finally { cleanupSession(args.workspace); } }
|
|
91
147
|
};
|
|
92
148
|
const isolatedDriver = async (turn) => {
|
|
93
|
-
if (turn.agent === "opencode") throw new Error(OPENCODE_CONTAINER_ERROR);
|
|
94
149
|
const session=sessions.get(turn.root);
|
|
95
150
|
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
|
|
151
|
+
const credentialEnv = workerCredentialKeys(turn.agent, env);
|
|
97
152
|
const name = `aidlc-worker-${randomUUID()}`;
|
|
98
153
|
let started = false;
|
|
99
154
|
try {
|
|
100
|
-
const result = await runAgentTurn({...turn, root:"/workspace", spawnProcess: (command,args,options) => {
|
|
101
|
-
const invocation=workerDockerInvocation({workspace:turn.root,image,command,args,credentialEnv
|
|
155
|
+
const result = await runAgentTurn({...turn, root:"/workspace", env, spawnProcess: (command,args,options) => {
|
|
156
|
+
const invocation=workerDockerInvocation({workspace:turn.root,image,command,args,credentialEnv,hostEnv:env,uid,gid,sessionHome:session.home});
|
|
102
157
|
const dockerArgs=invocation.args.filter((arg,index) => !(index===1 && arg==="--rm"));
|
|
103
158
|
dockerArgs.splice(1,0,"--name",name);
|
|
104
159
|
started = true;
|
|
@@ -114,20 +169,20 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
|
|
|
114
169
|
}
|
|
115
170
|
};
|
|
116
171
|
for (;;) {
|
|
117
|
-
if (
|
|
172
|
+
if (stopRequested()) return { status: "stopped" };
|
|
118
173
|
let result;
|
|
119
|
-
try { result = await runFleetWorker({ repository, workerId, workerAgent, preflight, limits, driver: isolatedDriver, handoff, packet: async ({ job, snapshot }) => [
|
|
174
|
+
try { result = await runFleetWorker({ repository, workerId, workerAgent, preflight, limits, driver: isolatedDriver, handoff, cancelSignal, packet: async ({ job, snapshot }) => [
|
|
120
175
|
`AI-DLC worker assignment ${job.assignmentId}, attempt ${job.attempt}.`,
|
|
121
176
|
`Task: ${snapshot.task.title} (${job.taskId}). Item: ${job.itemId}.`,
|
|
122
177
|
`Only change these owned paths: ${job.ownedPaths.join(", ")}.`,
|
|
123
178
|
`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.",
|
|
179
|
+
"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
180
|
...(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
181
|
].join("\n"), onEvent: emit }); }
|
|
127
182
|
finally { for (const [workspace, entry] of sessions) { rmSync(entry.home,{recursive:true,force:true}); sessions.delete(workspace); } }
|
|
128
183
|
emit(result);
|
|
129
|
-
if (once) return result;
|
|
130
|
-
await pause(pollMs);
|
|
184
|
+
if (once || result.status === "stopped") return result;
|
|
185
|
+
await pause(pollMs, signal, cancelSignal);
|
|
131
186
|
}
|
|
132
187
|
}
|
|
133
188
|
const workerIds = required(env, "AIDLC_FLEET_WORKER_IDS").split(",").map((value) => value.trim()).filter(Boolean);
|
|
@@ -142,19 +197,21 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
|
|
|
142
197
|
const raw = execFileSync(process.execPath, [remoteState, "phase", "context", taskId], { cwd: root, env: fleetEnv, encoding: "utf8", timeout: 30000, maxBuffer: 2 * 1024 * 1024 });
|
|
143
198
|
const packet = JSON.parse(raw);
|
|
144
199
|
if (packet?.ok !== true || typeof packet.result?.content !== "string" || !packet.result.content) throw new Error("Remote phase context is empty or invalid");
|
|
145
|
-
|
|
200
|
+
const feedback = pendingFeedback(snapshot.task);
|
|
201
|
+
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` : "";
|
|
202
|
+
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
203
|
},
|
|
147
204
|
driver: async ({ leadEpoch, prompt, timeoutMs, signal: turnSignal }) => {
|
|
148
205
|
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
206
|
} });
|
|
150
|
-
const runTask = async (taskId, reservedJobs = []) => runFleetCoordinator({ repository, taskId, ...leadTaskFor(taskId), reservedJobs, onEvent: emit });
|
|
207
|
+
const runTask = async (taskId, reservedJobs = []) => runFleetCoordinator({ repository, taskId, ...leadTaskFor(taskId), reservedJobs, stopSignal: signal, cancelSignal, onEvent: emit });
|
|
151
208
|
if (role === "supervisor") {
|
|
152
|
-
const result = await runFleetSupervisor({ repository, taskId: targetId, ...leadTaskFor(targetId), signal, onEvent: emit, ...(coordinatorOverride ? { coordinator: coordinatorOverride } : {}) });
|
|
209
|
+
const result = await runFleetSupervisor({ repository, taskId: targetId, ...leadTaskFor(targetId), signal, cancelSignal, untilStop, onEvent: emit, ...(coordinatorOverride ? { coordinator: coordinatorOverride } : {}) });
|
|
153
210
|
emit(result);
|
|
154
211
|
return result;
|
|
155
212
|
}
|
|
156
213
|
for (;;) {
|
|
157
|
-
if (
|
|
214
|
+
if (stopRequested()) return { status: "stopped" };
|
|
158
215
|
if (role === "feature") {
|
|
159
216
|
const feature = await repository.getFeature({ featureId: targetId });
|
|
160
217
|
if (!feature) throw new Error(`Unknown feature ${targetId}`);
|
|
@@ -167,17 +224,18 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
|
|
|
167
224
|
const reservedJobs = (await repository.listAllJobs()).filter((job) => job.taskId !== member.taskId);
|
|
168
225
|
const result = await runTask(member.taskId, reservedJobs);
|
|
169
226
|
results.push(result); emit(result);
|
|
227
|
+
if (result.status === "stopped") return { status: "stopped", featureId: targetId, results };
|
|
170
228
|
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
229
|
}
|
|
172
230
|
const stopped = results.some((entry) => ["blocked", "reconcile", "paused", "terminal"].includes(entry.status));
|
|
173
231
|
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
232
|
if (once || result.status === "blocked") return result;
|
|
175
|
-
await pause(pollMs);
|
|
233
|
+
await pause(pollMs, signal, cancelSignal);
|
|
176
234
|
continue;
|
|
177
235
|
}
|
|
178
236
|
const result = await runTask(targetId);
|
|
179
237
|
emit(result);
|
|
180
|
-
if (once || ["terminal", "complete", "blocked", "paused", "reconcile", "gate"].includes(result.status)) return result;
|
|
181
|
-
await pause(pollMs);
|
|
238
|
+
if (once || ["terminal", "complete", "blocked", "paused", "reconcile", "gate", "stopped"].includes(result.status)) return result;
|
|
239
|
+
await pause(pollMs, signal, cancelSignal);
|
|
182
240
|
}
|
|
183
241
|
}
|
|
@@ -44,6 +44,13 @@ export function createFleetRemoteRepository({ callTool, url, token, workspace, r
|
|
|
44
44
|
getFeature: async ({ featureId }) => (await fleet()).fleet.features[featureId] ?? null,
|
|
45
45
|
featureReady: async ({ featureId }) => call("feature_ready", { featureId }),
|
|
46
46
|
listAllJobs: async () => Object.values((await fleet()).fleet.jobs).map(normalizeJob),
|
|
47
|
+
/** Every canonical task in the workspace (unordered), for daemon task discovery. */
|
|
48
|
+
listTasks: async () => {
|
|
49
|
+
const value = await call("state_get", {});
|
|
50
|
+
const tasks = value?.state?.tasks;
|
|
51
|
+
if (!tasks || typeof tasks !== "object" || Array.isArray(tasks)) throw new Error("Invalid remote workspace state");
|
|
52
|
+
return Object.values(tasks);
|
|
53
|
+
},
|
|
47
54
|
readTask: async ({ taskId }) => {
|
|
48
55
|
const value = await call("task_get", { taskId, clientProjectRoot: root });
|
|
49
56
|
return { task: value.task, nextAction: value.nextAction, revision: value.revision };
|
|
@@ -6,14 +6,24 @@ const taskDigest = (task) => createHash("sha256").update(JSON.stringify(task)).d
|
|
|
6
6
|
const targets = { G0_confirm: "plan", G1_review: "build", G2_codereview: "wrap" };
|
|
7
7
|
|
|
8
8
|
/** The service owns the checkpoint and atomically binds each run claim to this
|
|
9
|
-
* supervisor. An unavailable or ambiguous checkpoint never authorizes work.
|
|
10
|
-
|
|
9
|
+
* supervisor. An unavailable or ambiguous checkpoint never authorizes work.
|
|
10
|
+
*
|
|
11
|
+
* `signal` stops at the next turn boundary; `cancelSignal` also aborts the
|
|
12
|
+
* in-flight lead turn. Both end as `{ status: "stopped" }`.
|
|
13
|
+
*
|
|
14
|
+
* `untilStop: true` (daemon mode) never polls a human gate: a newly published
|
|
15
|
+
* gate returns `{ status: "gate", taskId, gate, snapshotId, gateView }` and an
|
|
16
|
+
* already pending checkpoint returns `{ status: "waiting", taskId, gate,
|
|
17
|
+
* snapshotId }`. The supervisor lease is released either way; the service
|
|
18
|
+
* accepts the reviewer approval against the pending checkpoint, and a later
|
|
19
|
+
* supervisor resumes from the `approved` checkpoint. */
|
|
20
|
+
export async function runFleetSupervisor({ repository, taskId, limits: inputLimits, pollMs = 5000, signal, cancelSignal, untilStop = false, onEvent = () => {}, coordinator = runFleetCoordinator, ...options }) {
|
|
11
21
|
const limits = validateFleetLimits(inputLimits);
|
|
12
22
|
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
23
|
for (const name of ["claimSupervisor", "renewSupervisor", "releaseSupervisor", "readGateCheckpoint", "readTask", "claimLead"]) if (typeof repository[name] !== "function") throw new Error(`Fleet supervisor requires ${name}`);
|
|
14
24
|
await repository.claimSupervisor({ taskId, leaseMs: limits.leaseMs, idempotencyKey: randomUUID() });
|
|
15
25
|
const controller = new AbortController();
|
|
16
|
-
const combined =
|
|
26
|
+
const combined = AbortSignal.any([signal, cancelSignal, controller.signal].filter(Boolean));
|
|
17
27
|
let failure; let pending = Promise.resolve();
|
|
18
28
|
const timer = setInterval(() => { pending = pending.then(() => repository.renewSupervisor({ taskId, leaseMs: limits.leaseMs })).catch((error) => { failure = error; controller.abort(error); }); }, limits.heartbeatMs);
|
|
19
29
|
timer.unref?.();
|
|
@@ -28,12 +38,17 @@ export async function runFleetSupervisor({ repository, taskId, limits: inputLimi
|
|
|
28
38
|
if (checkpoint?.status === "pending") {
|
|
29
39
|
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
40
|
await onEvent({ type: "waiting", taskId, gate: checkpoint.gate, snapshotId: checkpoint.snapshotId });
|
|
41
|
+
if (untilStop) return { status: "waiting", taskId, gate: checkpoint.gate, snapshotId: checkpoint.snapshotId };
|
|
31
42
|
try { await sleep(pollMs, undefined, { signal: combined }); } catch (error) { if (error?.name !== "AbortError") throw error; }
|
|
32
43
|
continue;
|
|
33
44
|
}
|
|
34
45
|
if (checkpoint?.status === "consumed") return { status: "blocked", taskId, reason: "Consumed checkpoint requires reconciliation before restart" };
|
|
35
|
-
if (checkpoint
|
|
36
|
-
|
|
46
|
+
if (checkpoint?.status === "rejected") {
|
|
47
|
+
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" };
|
|
48
|
+
} else if (checkpoint && checkpoint.status !== "approved") {
|
|
49
|
+
return { status: "blocked", taskId, reason: "Approved checkpoint does not match canonical state" };
|
|
50
|
+
}
|
|
51
|
+
if (checkpoint?.status === "approved") {
|
|
37
52
|
// A task snapshot older than the atomic approval is a read race between
|
|
38
53
|
// the two reads, not a mismatch: re-read the canonical task immediately.
|
|
39
54
|
if (Number.isSafeInteger(checkpoint.approvalRevision) && checkpoint.approvalRevision > snapshot.revision) { await onEvent({ type: "resync", taskId, gate: checkpoint.gate }); continue; }
|
|
@@ -43,11 +58,12 @@ export async function runFleetSupervisor({ repository, taskId, limits: inputLimi
|
|
|
43
58
|
await repository.renewSupervisor({ taskId, leaseMs: limits.leaseMs });
|
|
44
59
|
if (combined.aborted) break;
|
|
45
60
|
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:
|
|
61
|
+
const result = await coordinator({ ...options, repository, taskId, limits, claimedLead: claim, supervisorSignal: controller.signal, stopSignal: signal, cancelSignal, onEvent });
|
|
47
62
|
if (failure) throw failure;
|
|
48
63
|
if (result?.status !== "gate") return result;
|
|
49
64
|
// gate_snapshot_put stores snapshot and checkpoint in one service transaction.
|
|
50
65
|
await onEvent({ type: "gate", taskId, gate: result.gate, snapshotId: result.snapshotId, gateView: result.gateView });
|
|
66
|
+
if (untilStop) return { status: "gate", taskId, gate: result.gate, snapshotId: result.snapshotId, gateView: result.gateView };
|
|
51
67
|
}
|
|
52
68
|
if (failure) throw new Error(`Supervisor lease renewal failed: ${failure.message}`);
|
|
53
69
|
return { status: "stopped", taskId };
|