@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
|
@@ -6,11 +6,15 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
6
6
|
import { execFileSync } from "node:child_process";
|
|
7
7
|
import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { dirname, join, resolve } from "node:path";
|
|
9
|
-
import {
|
|
9
|
+
import { acquireTaskLease, heldTaskLease, readLiveLease, readTaskLease } from "./loop-lease.mjs";
|
|
10
|
+
import { resolveTaskRuntimeRead, taskRuntimePath } from "./task-runtime.mjs";
|
|
11
|
+
import { migrateTaskRuntime } from "./task-runtime-migrate.mjs";
|
|
12
|
+
import { explainHolder } from "./loop-holder.mjs";
|
|
10
13
|
import { isLoopProvider, LOOP_PROVIDER_LIST } from "./loop-drivers.mjs";
|
|
14
|
+
import { isLoopOperationalPath } from "./loop-git.mjs";
|
|
11
15
|
|
|
12
16
|
export const DEFAULT_LIMITS = Object.freeze({ maxTurns: 12, turnTimeoutMs: 600000, maxStallTurns: 2 });
|
|
13
|
-
const states = new Set(["running", "gate", "paused", "blocked", "terminal", "complete"]);
|
|
17
|
+
const states = new Set(["running", "gate", "paused", "blocked", "terminal", "complete", "stopped"]);
|
|
14
18
|
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
15
19
|
const iso = () => new Date().toISOString();
|
|
16
20
|
const bounded = (value, name, max) => { const n = Number(value); if (!Number.isSafeInteger(n) || n < 1 || n > max) throw new Error(`${name} must be an integer from 1 to ${max}`); return n; };
|
|
@@ -34,7 +38,10 @@ export function validateRoot(root) {
|
|
|
34
38
|
return absolute;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
|
|
41
|
+
/** Write location of the loop run record (`.agents/data/tasks/<task>/runtime/run.json`). */
|
|
42
|
+
export const loopRecordPath = (root, taskId) => taskRuntimePath(root, taskId, "run.json");
|
|
43
|
+
/** Read location: the runtime record, else the pre-runtime `.agents/data/loop/<task>.json`. */
|
|
44
|
+
export const loopRecordReadPath = (root, taskId) => resolveTaskRuntimeRead(root, taskId, "run.json");
|
|
38
45
|
|
|
39
46
|
function validateRecord(value, taskId) {
|
|
40
47
|
if (!value || value.schemaVersion !== 1 || value.taskId !== taskId || typeof value.runId !== "string" || !value.runId || !isLoopProvider(value.agent) || !states.has(value.status) || !Number.isSafeInteger(value.iteration) || value.iteration < 0 || !Number.isSafeInteger(value.stallTurns) || value.stallTurns < 0 || (value.sessionId !== null && (typeof value.sessionId !== "string" || !value.sessionId)) || !value.limits || !value.updatedAt || Number.isNaN(Date.parse(value.updatedAt))) throw new Error(`Corrupt loop run record for ${taskId}`);
|
|
@@ -45,7 +52,7 @@ function validateRecord(value, taskId) {
|
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
export function readLoopRecord(root, taskId) {
|
|
48
|
-
const path =
|
|
55
|
+
const path = loopRecordReadPath(root, taskId);
|
|
49
56
|
try { lstatSync(path); } catch (error) { if (error?.code === "ENOENT") return null; throw error; }
|
|
50
57
|
let value;
|
|
51
58
|
try { value = JSON.parse(readFileSync(path, "utf8")); }
|
|
@@ -55,6 +62,7 @@ export function readLoopRecord(root, taskId) {
|
|
|
55
62
|
|
|
56
63
|
export function writeLoopRecord(root, record) {
|
|
57
64
|
validateRecord(record, record.taskId);
|
|
65
|
+
migrateTaskRuntime(root, record.taskId, { names: ["run.json"] });
|
|
58
66
|
const path = loopRecordPath(root, record.taskId);
|
|
59
67
|
mkdirSync(dirname(path), { recursive: true });
|
|
60
68
|
const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
|
@@ -114,7 +122,7 @@ function worktreeSignal(root) {
|
|
|
114
122
|
const status = entry.slice(0, 2);
|
|
115
123
|
let name = entry.slice(3);
|
|
116
124
|
if (/^[RC]/.test(status) || /[RC]$/.test(status)) name = entries[++i] ?? name;
|
|
117
|
-
if (name
|
|
125
|
+
if (isLoopOperationalPath(name)) continue;
|
|
118
126
|
const path = resolve(root, name);
|
|
119
127
|
if (!path.startsWith(`${root}/`)) return { kind: "unknown", reason: "unsafe changed path" };
|
|
120
128
|
try {
|
|
@@ -152,9 +160,31 @@ export const classifyAction = (action) => {
|
|
|
152
160
|
};
|
|
153
161
|
|
|
154
162
|
function approvalCount(task) { return task.evidence.filter((item) => item.kind === "approval" && item.result === "pass").length; }
|
|
163
|
+
function pendingFeedback(task) {
|
|
164
|
+
const evidence = Array.isArray(task?.evidence) ? task.evidence : [];
|
|
165
|
+
const pending = [];
|
|
166
|
+
for (let i = 0; i < evidence.length; i++) {
|
|
167
|
+
const entry = evidence[i];
|
|
168
|
+
if (entry?.kind !== "diagnostic" || typeof entry.detail !== "string") continue;
|
|
169
|
+
const match = entry.detail.match(/^Cancelled gate wait at (G0_confirm|G1_review|G2_codereview): /);
|
|
170
|
+
if (!match) continue;
|
|
171
|
+
const approvedAfter = evidence.slice(i + 1).some((later) => later?.kind === "approval" && later.result === "pass" && later.gate === match[1]);
|
|
172
|
+
if (!approvedAfter) pending.push(entry.detail);
|
|
173
|
+
}
|
|
174
|
+
return pending;
|
|
175
|
+
}
|
|
155
176
|
function outputStop(record, action, gateView) { return { taskId: record.taskId, runId: record.runId, status: record.status, iteration: record.iteration, sessionId: record.sessionId, stopReason: record.stopReason, nextAction: action, ...(gateView ? { gateView } : {}) }; }
|
|
156
177
|
|
|
157
|
-
|
|
178
|
+
/**
|
|
179
|
+
* `signal` requests a graceful stop at the next turn boundary; `cancelSignal`
|
|
180
|
+
* additionally cancels the in-flight agent turn. Either way the run ends as
|
|
181
|
+
* `stopped` (the canonical task is NOT paused) and a later resume continues
|
|
182
|
+
* from fresh canonical nextAction. A failure observed while stopping is a
|
|
183
|
+
* shutdown side effect (a terminal Ctrl-C also reaches the agent child), not a
|
|
184
|
+
* transport failure, so it never pauses the task.
|
|
185
|
+
*/
|
|
186
|
+
export async function runLoop({ root: rootInput, taskId, agent, mode = "run", limits: limitInput, driver, dispatchBuild, onEvent = () => {}, preflight = false, precondition, signal: stopSignal, cancelSignal }) {
|
|
187
|
+
const stopping = () => Boolean(stopSignal?.aborted || cancelSignal?.aborted);
|
|
158
188
|
const root = validateRoot(rootInput);
|
|
159
189
|
// --- Validate mode and load/create the loop record -------------------------
|
|
160
190
|
let record = readLoopRecord(root, taskId);
|
|
@@ -171,7 +201,16 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
|
|
|
171
201
|
if (preflight) checkAgent(record?.agent ?? agent, root);
|
|
172
202
|
if (!record) record = { schemaVersion: 1, taskId, runId: randomUUID(), agent, status: "running", sessionId: null, iteration: 0, stallTurns: 0, limits, lastFingerprint: null, checkpoint: null, stopReason: null, pausedByLoop: false, updatedAt: iso() };
|
|
173
203
|
else { const prior = { iteration: record.iteration, status: record.status, stopReason: record.stopReason, limits: record.limits }; record.limits = limits; record.stallTurns = 0; onEvent({ type: "resume", prior }); }
|
|
174
|
-
|
|
204
|
+
// The task lease: shared with the supervisor that drives this run (activity
|
|
205
|
+
// flips waiting -> turn -> waiting), or acquired here for a manual
|
|
206
|
+
// `loop.mjs run|resume` (activity turn). While activity is "turn",
|
|
207
|
+
// `state.mjs gate approve` refuses to approve.
|
|
208
|
+
const shared = heldTaskLease(root, taskId);
|
|
209
|
+
const lease = shared ?? acquireTaskLease(root, taskId, { activity: "turn", label: "loop runner", purpose: `loop run ${record.runId}`, onWarning: onEvent });
|
|
210
|
+
if (shared) {
|
|
211
|
+
try { shared.setActivity("turn"); }
|
|
212
|
+
catch (error) { try { shared.setActivity("waiting"); } catch { /* the next heartbeat writes the requested activity */ } throw error; }
|
|
213
|
+
}
|
|
175
214
|
try {
|
|
176
215
|
let current = readCanonical(root, taskId);
|
|
177
216
|
if (precondition) {
|
|
@@ -186,6 +225,10 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
|
|
|
186
225
|
envelope(root, "state.mjs", ["task", "status", taskId, "--status", "active"]);
|
|
187
226
|
record.pausedByLoop = false;
|
|
188
227
|
current = readCanonical(root, taskId);
|
|
228
|
+
} else if (mode === "resume" && record.pausedByLoop && current.task.status === "active") {
|
|
229
|
+
// A human (CLI or dashboard) already re-activated the task: the loop no
|
|
230
|
+
// longer owns that pause, so a later human pause must not be auto-undone.
|
|
231
|
+
record.pausedByLoop = false;
|
|
189
232
|
}
|
|
190
233
|
const initial = fingerprint(root, current.task, current.action);
|
|
191
234
|
if (mode === "resume" && record.lastFingerprint && record.lastFingerprint !== initial.digest) onEvent({ type: "context_changed", message: "Canonical task context changed since the last checkpoint; continuing from fresh nextAction", previousCheckpointAt: record.checkpoint?.capturedAt ?? null });
|
|
@@ -193,7 +236,12 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
|
|
|
193
236
|
record.checkpoint = checkpoint(current.task, current.action, initial.tree, record.iteration);
|
|
194
237
|
record.status = "running"; record.stopReason = null; record.updatedAt = iso(); writeLoopRecord(root, record);
|
|
195
238
|
// --- Main turn loop --------------------------------------------------------
|
|
239
|
+
// maxTurns bounds one runLoop invocation (a supervised run), not the task's
|
|
240
|
+
// lifetime: record.iteration stays a cumulative observability counter, so a
|
|
241
|
+
// long multi-gate task is not permanently re-paused on every resume.
|
|
242
|
+
let runTurns = 0;
|
|
196
243
|
for (;;) {
|
|
244
|
+
lease.assertOwned();
|
|
197
245
|
const kind = classifyAction(current.action);
|
|
198
246
|
if (kind !== "run_phase") {
|
|
199
247
|
record.status = kind === "await_user" ? "gate" : kind;
|
|
@@ -202,39 +250,82 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
|
|
|
202
250
|
const gateView = kind === "await_user" ? cli(root, "gate-view.mjs", [taskId]).trim() : undefined;
|
|
203
251
|
return outputStop(record, current.action, gateView);
|
|
204
252
|
}
|
|
205
|
-
if (
|
|
253
|
+
if (stopping()) return stop(cancelSignal?.aborted ? "daemon cancelled the run" : "daemon shutdown");
|
|
254
|
+
if (runTurns >= limits.maxTurns) return pause("turn limit reached");
|
|
255
|
+
if (dispatchBuild && current.task.phase === "build" && current.task.tasks.some((item) => item.status === "todo" || item.status === "in_progress")) {
|
|
256
|
+
try {
|
|
257
|
+
await dispatchBuild({ root, taskId, task: current.task, action: current.action, record });
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (stopping()) return stop("daemon shutdown interrupted build dispatch");
|
|
260
|
+
return pause(`build dispatch failure: ${error.message}`);
|
|
261
|
+
}
|
|
262
|
+
const refreshed = readCanonical(root, taskId);
|
|
263
|
+
const signal = fingerprint(root, refreshed.task, refreshed.action);
|
|
264
|
+
record.iteration += 1; runTurns += 1;
|
|
265
|
+
record.stallTurns = signal.digest === record.lastFingerprint ? record.stallTurns + 1 : 0;
|
|
266
|
+
record.lastFingerprint = signal.digest;
|
|
267
|
+
record.checkpoint = checkpoint(refreshed.task, refreshed.action, signal.tree, record.iteration);
|
|
268
|
+
record.updatedAt = iso(); writeLoopRecord(root, record);
|
|
269
|
+
current = refreshed;
|
|
270
|
+
if (stopping()) continue; // the loop top records `stopped` (or the reached stop) instead of a stall pause
|
|
271
|
+
if (classifyAction(current.action) === "run_phase" && record.stallTurns >= limits.maxStallTurns) return pause(`${record.stallTurns} consecutive turns without observable progress`);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
206
274
|
let prompt;
|
|
207
275
|
try {
|
|
208
276
|
const packet = JSON.parse(cli(root, "context.mjs", [taskId, "--phase", current.action.phase, ...(current.action.itemId ? ["--item", current.action.itemId] : []), "--format", "json"]));
|
|
209
277
|
if (typeof packet?.content !== "string" || !packet.content.trim()) throw new Error("Context packet was empty or invalid");
|
|
210
278
|
prompt = packet.content;
|
|
211
279
|
} catch {
|
|
280
|
+
// A terminal Ctrl-C also kills the context.mjs child: that is a shutdown, not a broken packet.
|
|
281
|
+
if (stopping()) return stop("daemon shutdown interrupted context generation");
|
|
212
282
|
// No driver has started for this turn, so it is safe to resume after repairing context.
|
|
213
283
|
// Do not persist arbitrary subprocess output or malformed context in the stop reason.
|
|
214
284
|
return pause("context packet generation or validation failed; inspect context.mjs and resume after repair");
|
|
215
285
|
}
|
|
286
|
+
const feedback = pendingFeedback(current.task);
|
|
287
|
+
if (feedback.length) {
|
|
288
|
+
prompt = `Human feedback (revise the current review artifact accordingly, then re-present the gate):\n${feedback.map((text) => `- ${text}`).join("\n")}\n\n${prompt}`;
|
|
289
|
+
}
|
|
216
290
|
const beforeApprovals = approvalCount(current.task);
|
|
291
|
+
// Live-log context: every event of this lead turn names its actor, phase, and item.
|
|
292
|
+
const turnContext = { taskId, actor: "lead", phase: current.action.phase, ...(typeof current.action.itemId === "string" && current.action.itemId ? { itemId: current.action.itemId } : {}) };
|
|
293
|
+
const leadEvent = (event) => onEvent(event && typeof event === "object" && !Array.isArray(event) ? { ...turnContext, ...event } : event);
|
|
294
|
+
leadEvent({ type: "turn_start", iteration: record.iteration, agent: record.agent });
|
|
217
295
|
let observedId = record.sessionId;
|
|
218
296
|
try {
|
|
219
|
-
const result = await driver({ agent: record.agent, root, sessionId: record.sessionId ?? undefined, prompt: `${prompt}\n\nLoop supervisor: complete the current authorized phase or item. Use canonical lifecycle commands for evidence and gates. Never approve a human gate. Stop at a gate and report progress.`, timeoutMs: limits.turnTimeoutMs, onSessionId: (id) => { if (typeof id !== "string" || !id) throw new Error("Invalid agent session id"); observedId = id; record.sessionId = id; record.updatedAt = iso(); writeLoopRecord(root, record); }, onEvent: (event) => {
|
|
297
|
+
const result = await driver({ agent: record.agent, root, sessionId: record.sessionId ?? undefined, prompt: `${prompt}\n\nLoop supervisor: complete the current authorized phase or item. Use canonical lifecycle commands for evidence and gates. Never approve a human gate. Stop at a gate and report progress.`, timeoutMs: limits.turnTimeoutMs, ...(cancelSignal ? { signal: cancelSignal } : {}), onSessionId: (id) => { if (typeof id !== "string" || !id) throw new Error("Invalid agent session id"); observedId = id; record.sessionId = id; record.updatedAt = iso(); writeLoopRecord(root, record); }, onEvent: (event) => { leadEvent(event); if (event?.type === "lifecycle_command") { const observed = readCanonical(root, taskId); const signal = fingerprint(root, observed.task, observed.action); record.checkpoint = checkpoint(observed.task, observed.action, signal.tree, record.iteration); record.updatedAt = iso(); writeLoopRecord(root, record); } } });
|
|
220
298
|
if (!observedId && result?.sessionId) { observedId = result.sessionId; record.sessionId = observedId; record.updatedAt = iso(); writeLoopRecord(root, record); }
|
|
221
299
|
if (!observedId) throw new Error("Agent turn ended without a resumable session id");
|
|
222
300
|
} catch (error) {
|
|
223
301
|
current = readCanonical(root, taskId);
|
|
224
302
|
if (classifyAction(current.action) === "await_user") continue;
|
|
303
|
+
if (stopping()) return stop(cancelSignal?.aborted ? "daemon cancelled the in-flight turn" : "daemon shutdown interrupted the turn");
|
|
225
304
|
return pause(`agent transport failure: ${error.message}`);
|
|
226
305
|
}
|
|
306
|
+
leadEvent({ type: "turn_end", iteration: record.iteration });
|
|
227
307
|
const refreshed = readCanonical(root, taskId);
|
|
228
308
|
if (approvalCount(refreshed.task) !== beforeApprovals) return pause("Approval evidence changed during an agent turn");
|
|
229
309
|
const signal = fingerprint(root, refreshed.task, refreshed.action);
|
|
230
|
-
record.iteration += 1;
|
|
310
|
+
record.iteration += 1; runTurns += 1;
|
|
231
311
|
record.stallTurns = signal.digest === record.lastFingerprint ? record.stallTurns + 1 : 0;
|
|
232
312
|
record.lastFingerprint = signal.digest;
|
|
233
313
|
record.checkpoint = checkpoint(refreshed.task, refreshed.action, signal.tree, record.iteration);
|
|
234
314
|
record.updatedAt = iso(); writeLoopRecord(root, record);
|
|
235
315
|
current = refreshed;
|
|
316
|
+
if (stopping()) continue; // the loop top records `stopped` (or the reached stop) instead of a stall pause
|
|
236
317
|
if (classifyAction(current.action) === "run_phase" && record.stallTurns >= limits.maxStallTurns) return pause(`${record.stallTurns} consecutive turns without observable progress`);
|
|
237
318
|
}
|
|
319
|
+
function stop(reason) {
|
|
320
|
+
const stopped = readCanonical(root, taskId);
|
|
321
|
+
const signal = fingerprint(root, stopped.task, stopped.action);
|
|
322
|
+
record.status = "stopped"; record.stopReason = reason;
|
|
323
|
+
record.lastFingerprint = signal.digest;
|
|
324
|
+
record.checkpoint = checkpoint(stopped.task, stopped.action, signal.tree, record.iteration, reason);
|
|
325
|
+
record.updatedAt = iso(); writeLoopRecord(root, record);
|
|
326
|
+
onEvent({ type: "loop_stopped", taskId, reason });
|
|
327
|
+
return outputStop(record, stopped.action);
|
|
328
|
+
}
|
|
238
329
|
function pause(reason) {
|
|
239
330
|
const fresh = readCanonical(root, taskId);
|
|
240
331
|
record.status = "paused"; record.stopReason = reason;
|
|
@@ -249,12 +340,23 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
|
|
|
249
340
|
record.updatedAt = iso(); writeLoopRecord(root, record);
|
|
250
341
|
return outputStop(record, stopped.action);
|
|
251
342
|
}
|
|
252
|
-
} finally {
|
|
343
|
+
} finally {
|
|
344
|
+
if (!shared) lease.release();
|
|
345
|
+
else {
|
|
346
|
+
// Back to "waiting" so a human can approve the gate this run stopped at.
|
|
347
|
+
// If the write fails (guard timeout), the requested activity is kept and
|
|
348
|
+
// written by the next heartbeat; a lost lease is reported by the
|
|
349
|
+
// supervisor's next assertOwned.
|
|
350
|
+
try { shared.setActivity("waiting"); } catch (error) { onEvent({ type: "diagnostic", message: `Task lease not yet returned to waiting (retried by the next heartbeat): ${error.message}` }); }
|
|
351
|
+
}
|
|
352
|
+
}
|
|
253
353
|
}
|
|
254
354
|
|
|
255
355
|
export function loopStatus(rootInput, taskId) {
|
|
256
356
|
const root = validateRoot(rootInput);
|
|
257
357
|
const record = readLoopRecord(root, taskId);
|
|
258
358
|
const current = readCanonical(root, taskId);
|
|
259
|
-
|
|
359
|
+
const entry = readTaskLease(root, taskId);
|
|
360
|
+
const lease = { state: entry.state, path: entry.path, holder: entry.holder, reason: entry.reason, ...explainHolder(entry) };
|
|
361
|
+
return { record, liveLease: entry.state === "corrupt" ? null : readLiveLease(root, taskId), lease, nextAction: current.action };
|
|
260
362
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { appendFileSync, lstatSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, resolve } from "node:path";
|
|
3
|
+
import { isTaskId, taskRuntimePath } from "./task-runtime.mjs";
|
|
4
|
+
import { migrateTaskRuntime } from "./task-runtime-migrate.mjs";
|
|
5
|
+
|
|
6
|
+
/** The event-log sidecar rotates to `<path>.1` (one generation kept) once it
|
|
7
|
+
* reaches this size, so a 24/7 daemon cannot grow it without bound. */
|
|
8
|
+
export const EVENT_LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
9
|
+
/** Per-task history files (`.agents/data/tasks/<taskId>/runtime/events.jsonl`)
|
|
10
|
+
* rotate the same way at a smaller bound, since one exists for every task. */
|
|
11
|
+
export const TASK_EVENT_LOG_MAX_BYTES = 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
let preparedDirectory;
|
|
14
|
+
const migratedTasks = new Set();
|
|
15
|
+
|
|
16
|
+
const rotate = (path, limit) => {
|
|
17
|
+
try { if (statSync(path).size >= limit) renameSync(path, `${path}.1`); }
|
|
18
|
+
catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** True when `path` is missing or a real (non-symlink) entry of the expected kind. */
|
|
22
|
+
const safeEntry = (path, directory) => {
|
|
23
|
+
try {
|
|
24
|
+
const stat = lstatSync(path);
|
|
25
|
+
return !stat.isSymbolicLink() && (directory ? stat.isDirectory() : stat.isFile());
|
|
26
|
+
} catch (error) { if (error?.code === "ENOENT") return true; throw error; }
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Path of the per-task history file (`.agents/data/tasks/<taskId>/runtime/events.jsonl`),
|
|
30
|
+
* or null when `root` is not a string or the id is not a canonical task id. */
|
|
31
|
+
export const taskEventLogPath = (root, taskId) => {
|
|
32
|
+
if (typeof root !== "string" || !root || !isTaskId(taskId)) return null;
|
|
33
|
+
try { return taskRuntimePath(root, taskId, "events.jsonl"); } catch { return null; }
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** The project root a global event sidecar belongs to: the sidecar is always
|
|
37
|
+
* `<root>/.agents/data/loop/<file>`; any other location has no task history. */
|
|
38
|
+
export const loopRootFromEventLog = (logPath) => {
|
|
39
|
+
if (typeof logPath !== "string" || !logPath) return null;
|
|
40
|
+
const loopDir = dirname(resolve(logPath));
|
|
41
|
+
const data = dirname(loopDir), agents = dirname(data);
|
|
42
|
+
return basename(loopDir) === "loop" && basename(data) === "data" && basename(agents) === ".agents" ? dirname(agents) : null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Append one line to a task's history. Refuses symlinked (or non-regular)
|
|
46
|
+
* directories and files so a planted link cannot redirect the write. A
|
|
47
|
+
* pre-runtime `.agents/data/loop/events/<taskId>.jsonl` is moved under
|
|
48
|
+
* `runtime/` before the first append of this process. */
|
|
49
|
+
function appendTaskEventLog(root, taskId, line) {
|
|
50
|
+
const path = taskEventLogPath(root, taskId);
|
|
51
|
+
if (!path) return;
|
|
52
|
+
if (!migratedTasks.has(path)) { migratedTasks.add(path); migrateTaskRuntime(root, taskId, { names: ["events.jsonl", "events.jsonl.1"] }); }
|
|
53
|
+
const directory = dirname(path);
|
|
54
|
+
if (!safeEntry(dirname(directory), true) || !safeEntry(directory, true)) return;
|
|
55
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
56
|
+
if (!safeEntry(directory, true) || !safeEntry(path, false) || !safeEntry(`${path}.1`, false)) return;
|
|
57
|
+
rotate(path, TASK_EVENT_LOG_MAX_BYTES);
|
|
58
|
+
appendFileSync(path, line);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Append one structured event to the optional UI event-log sidecar (JSONL)
|
|
62
|
+
* and, when it carries a valid `taskId`, to that task's history file. Each
|
|
63
|
+
* record gets an `at` timestamp; directories are created on demand. */
|
|
64
|
+
function appendEventLog(event) {
|
|
65
|
+
const path = process.env.AIDLC_LOOP_EVENT_LOG;
|
|
66
|
+
if (!path) return;
|
|
67
|
+
let line;
|
|
68
|
+
try {
|
|
69
|
+
const directory = dirname(path);
|
|
70
|
+
if (preparedDirectory !== directory) { mkdirSync(directory, { recursive: true }); preparedDirectory = directory; }
|
|
71
|
+
rotate(path, EVENT_LOG_MAX_BYTES);
|
|
72
|
+
const record = event && typeof event === "object" && !Array.isArray(event) && !("at" in event) ? { at: new Date().toISOString(), ...event } : event;
|
|
73
|
+
line = `${JSON.stringify(record)}\n`;
|
|
74
|
+
appendFileSync(path, line);
|
|
75
|
+
} catch { /* best-effort: never break the loop on a log write failure */ }
|
|
76
|
+
try { if (line && typeof event?.taskId === "string") appendTaskEventLog(loopRootFromEventLog(path), event.taskId, line); }
|
|
77
|
+
catch { /* best-effort, independently of the global log */ }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const withSummary = (event, separator) => typeof event.summary === "string" && event.summary ? `${separator}${event.summary}` : "";
|
|
81
|
+
|
|
82
|
+
/** Render foreground loop progress to stderr without polluting stdout JSON. */
|
|
83
|
+
export function streamLog(event) {
|
|
84
|
+
appendEventLog(event);
|
|
85
|
+
if (!event || typeof event.type !== "string") return;
|
|
86
|
+
switch (event.type) {
|
|
87
|
+
case "turn_start":
|
|
88
|
+
process.stderr.write(`\n━━ turn ${Number(event.iteration ?? 0) + 1} · phase ${event.phase ?? "?"} · agent ${event.agent ?? "?"} ━━\n`);
|
|
89
|
+
break;
|
|
90
|
+
case "turn_end":
|
|
91
|
+
process.stderr.write(`━━ turn done ━━\n`);
|
|
92
|
+
break;
|
|
93
|
+
case "agent_text":
|
|
94
|
+
process.stderr.write(`${event.text ?? ""}\n`);
|
|
95
|
+
break;
|
|
96
|
+
case "agent_tool":
|
|
97
|
+
process.stderr.write(` › tool: ${event.tool ?? "?"}${withSummary(event, " · ")}\n`);
|
|
98
|
+
break;
|
|
99
|
+
case "lifecycle_command":
|
|
100
|
+
process.stderr.write(` › command ${event.status ?? "?"} (exit ${event.exitCode ?? "?"})${withSummary(event, ": ")}\n`);
|
|
101
|
+
break;
|
|
102
|
+
case "daemon_turn_error":
|
|
103
|
+
process.stderr.write(` ! ${event.taskId ?? "?"}: ${event.message ?? "error"} (retry in ${Math.round(Number(event.retryInMs ?? 0) / 1000)}s)\n`);
|
|
104
|
+
break;
|
|
105
|
+
case "loop_stopped":
|
|
106
|
+
process.stderr.write(` ■ ${event.taskId ?? "?"} stopped: ${event.reason ?? ""}\n`);
|
|
107
|
+
break;
|
|
108
|
+
case "gate":
|
|
109
|
+
process.stderr.write(`\n${event.gateView ?? `Gate ${event.gate ?? "?"} reached — approve from the trusted terminal.`}\n`);
|
|
110
|
+
break;
|
|
111
|
+
case "worker_image_build":
|
|
112
|
+
process.stderr.write(`\n▶ building worker image ${event.image ?? "?"} (provider ${event.provider ?? "?"})…\n`);
|
|
113
|
+
break;
|
|
114
|
+
default:
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -1,61 +1,28 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
-
import { assertLoopPath } from "./loop-lease.mjs";
|
|
4
|
+
import { acquireTaskLease, assertLoopPath } from "./loop-lease.mjs";
|
|
5
5
|
import { validateRoot } from "./loop-runtime.mjs";
|
|
6
6
|
import { acquireStateLock, loadTask, nextAction } from "./store.mjs";
|
|
7
7
|
import { recoverLeadArtifact } from "./loop-lead-artifacts.mjs";
|
|
8
8
|
|
|
9
9
|
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
10
10
|
const checkAuthority = (authority) => { if (authority !== "local") throw new Error("local supervisor adapter requires local authority"); };
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
try {
|
|
16
|
-
const lease = JSON.parse(readFileSync(path, "utf8"));
|
|
17
|
-
if (lease?.taskId !== taskId || !Number.isSafeInteger(lease.pid) || lease.pid < 1 || typeof lease.token !== "string" || !lease.token) throw new Error("invalid fields");
|
|
18
|
-
return lease;
|
|
19
|
-
} catch (error) { if (error?.code === "ENOENT") return null; throw new Error(`Corrupt supervisor lease for ${taskId}: ${error.message}`); }
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function writeExclusive(path, value) {
|
|
23
|
-
const fd = openSync(path, "wx", 0o600);
|
|
24
|
-
try { writeFileSync(fd, `${JSON.stringify(value)}\n`); fsyncSync(fd); } finally { closeSync(fd); }
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Local durable checkpoint + separate PID-fenced supervisor lock.
|
|
28
|
-
* The lock uses a distinct task key from the runner's lease so humans can approve.
|
|
11
|
+
/** Local durable checkpoint + the task lease (`loop/<task>.lease`).
|
|
12
|
+
* The supervisor holds the lease with activity "waiting" between runs, so a
|
|
13
|
+
* human can approve gates; the runLoop it drives shares the same handle and
|
|
14
|
+
* flips activity to "turn" while a run executes (see loop-lease.mjs).
|
|
29
15
|
* This adapter intentionally does not implement runTurn or perform approvals.
|
|
30
16
|
*/
|
|
31
|
-
export function createLocalSupervisorAdapter({ root: rootInput, taskId }) {
|
|
17
|
+
export function createLocalSupervisorAdapter({ root: rootInput, taskId, onWarning }) {
|
|
32
18
|
const root = validateRoot(rootInput);
|
|
33
19
|
const checkpointPath = assertLoopPath(root, taskId, ".supervisor.json");
|
|
34
|
-
|
|
35
|
-
const guardPath = assertLoopPath(root, taskId, ".supervisor.guard");
|
|
36
|
-
let token;
|
|
37
|
-
const withGuard = (action) => {
|
|
38
|
-
assertLoopPath(root, taskId, ".supervisor.guard");
|
|
39
|
-
mkdirSync(dirname(guardPath), { recursive: true });
|
|
40
|
-
const guard = { taskId, pid: process.pid, token: randomUUID() }, deadline = Date.now() + 5000;
|
|
41
|
-
for (;;) {
|
|
42
|
-
try { writeExclusive(guardPath, guard); break; }
|
|
43
|
-
catch (error) {
|
|
44
|
-
if (error?.code !== "EEXIST") throw error;
|
|
45
|
-
const existing = readLease(guardPath, taskId);
|
|
46
|
-
if (!existing) continue;
|
|
47
|
-
if (!alive(existing.pid)) { if (readLease(guardPath, taskId)?.token === existing.token) { try { unlinkSync(guardPath); } catch (e) { if (e?.code !== "ENOENT") throw e; } } continue; }
|
|
48
|
-
if (Date.now() >= deadline) throw new Error(`Timed out waiting for supervisor guard: ${taskId}`);
|
|
49
|
-
wait(10);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
try { return action(); } finally { if (readLease(guardPath, taskId)?.token === guard.token) unlinkSync(guardPath); }
|
|
53
|
-
};
|
|
20
|
+
let lease;
|
|
54
21
|
const checkScope = (input) => { checkAuthority(input?.authority); if (input?.taskId !== taskId) throw new Error("supervisor task mismatch"); };
|
|
55
22
|
const assertOwned = async () => {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
23
|
+
if (!lease) throw new Error("local supervisor ownership lost: the task lease is not held");
|
|
24
|
+
try { lease.assertOwned(); }
|
|
25
|
+
catch (error) { throw new Error(`local supervisor ownership lost: ${error.message}`); }
|
|
59
26
|
};
|
|
60
27
|
const readCheckpoint = () => {
|
|
61
28
|
assertLoopPath(root, taskId, ".supervisor.json");
|
|
@@ -72,27 +39,20 @@ export function createLocalSupervisorAdapter({ root: rootInput, taskId }) {
|
|
|
72
39
|
return {
|
|
73
40
|
async acquireSupervisor(input) {
|
|
74
41
|
checkScope(input);
|
|
75
|
-
if (
|
|
42
|
+
if (lease) throw new Error("local supervisor adapter is already acquired");
|
|
76
43
|
if (input.signal?.aborted) throw new Error("supervisor already aborted");
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const existing = readLease(leasePath, taskId);
|
|
81
|
-
if (existing && alive(existing.pid)) throw new Error(`Task ${taskId} already has a live supervisor (pid ${existing.pid})`);
|
|
82
|
-
if (existing && readLease(leasePath, taskId)?.token === existing.token) unlinkSync(leasePath);
|
|
83
|
-
writeExclusive(leasePath, { taskId, pid: process.pid, token: newToken });
|
|
84
|
-
});
|
|
85
|
-
token = newToken;
|
|
86
|
-
return { assertOwned, release: async () => { if (token !== newToken) return; withGuard(() => { if (readLease(leasePath, taskId)?.token === newToken) unlinkSync(leasePath); }); token = undefined; } };
|
|
44
|
+
const handle = acquireTaskLease(root, taskId, { activity: "waiting", label: "supervisor", purpose: "local loop supervisor", onWarning });
|
|
45
|
+
lease = handle;
|
|
46
|
+
return { assertOwned, release: async () => { if (lease !== handle) return; try { handle.release(); } finally { lease = undefined; } } };
|
|
87
47
|
},
|
|
88
48
|
async readTask(input) {
|
|
89
49
|
checkScope({ ...input, authority: "local" });
|
|
90
50
|
const release = acquireStateLock(root);
|
|
91
51
|
try {
|
|
92
|
-
if (
|
|
52
|
+
if (lease) await assertOwned();
|
|
93
53
|
const task = loadTask(root, taskId);
|
|
94
54
|
if (!task || task.id !== taskId) throw new Error("invalid local canonical task snapshot");
|
|
95
|
-
if (
|
|
55
|
+
if (lease) {
|
|
96
56
|
await recoverLeadArtifact({ root, taskId, task, assertOwned });
|
|
97
57
|
await assertOwned();
|
|
98
58
|
} else {
|