@felixdotgo/aidlc-kit 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEVELOPMENT.md +13 -26
- package/README.md +35 -39
- package/dist/assets/.agents/aidlc/orchestrator.md +1 -1
- package/dist/assets/.agents/aidlc/schemas/state.schema.json +2 -0
- package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +94 -48
- package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +5 -4
- package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +21 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +52 -78
- package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +88 -14
- package/dist/assets/.agents/aidlc/scripts/lib/loop-env.mjs +279 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +20 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +10 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +2 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +3 -2
- package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +9 -3
- package/dist/assets/.agents/aidlc/scripts/lib/loop-holder.mjs +376 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +11 -3
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +67 -61
- package/dist/assets/.agents/aidlc/scripts/lib/loop-leases.mjs +268 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +78 -17
- package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +42 -10
- package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +66 -9
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +16 -56
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +12 -6
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +14 -2
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-log.mjs +158 -0
- package/dist/assets/.agents/aidlc/scripts/lib/repo-head.mjs +22 -0
- package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +11 -0
- package/dist/assets/.agents/aidlc/scripts/lib/task-runtime-migrate.mjs +75 -0
- package/dist/assets/.agents/aidlc/scripts/lib/task-runtime.mjs +97 -0
- package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +87 -4
- package/dist/assets/.agents/aidlc/scripts/loop.cmd +101 -0
- package/dist/assets/.agents/aidlc/scripts/loop.sh +71 -0
- package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +10 -4
- package/dist/assets/.agents/aidlc/scripts/state.mjs +16 -8
- package/dist/assets/ui/changes.js +83 -0
- package/dist/assets/ui/common.js +664 -0
- package/dist/assets/ui/dashboard.css +281 -59
- package/dist/assets/ui/dashboard.js +77 -0
- package/dist/assets/ui/detail.js +159 -0
- package/dist/assets/ui/live-log.js +332 -0
- package/dist/services/aidlc-coordination/package.json +1 -1
- package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +11 -0
- package/dist/src/cli/argv.js +10 -4
- package/dist/src/cli/commands.js +52 -22
- package/dist/src/cli/loop-launcher.d.ts +47 -0
- package/dist/src/cli/loop-launcher.js +322 -0
- package/dist/src/cli.js +3 -0
- package/dist/src/installer.js +4 -2
- package/dist/src/model/records.d.ts +4 -0
- package/dist/src/state/validate.js +5 -0
- package/dist/src/task-ui.d.ts +31 -52
- package/dist/src/task-ui.js +25 -1562
- package/dist/src/ui/assets.d.ts +14 -0
- package/dist/src/ui/assets.js +45 -0
- package/dist/src/ui/changes.d.ts +83 -0
- package/dist/src/ui/changes.js +498 -0
- package/dist/src/ui/containers.d.ts +71 -0
- package/dist/src/ui/containers.js +214 -0
- package/dist/src/ui/daemon.d.ts +75 -0
- package/dist/src/ui/daemon.js +232 -0
- package/dist/src/ui/data.d.ts +42 -0
- package/dist/src/ui/data.js +89 -0
- package/dist/src/ui/docs.d.ts +13 -0
- package/dist/src/ui/docs.js +39 -0
- package/dist/src/ui/files.d.ts +3 -0
- package/dist/src/ui/files.js +20 -0
- package/dist/src/ui/http.d.ts +14 -0
- package/dist/src/ui/http.js +80 -0
- package/dist/src/ui/live.d.ts +5 -0
- package/dist/src/ui/live.js +43 -0
- package/dist/src/ui/markdown.d.ts +2 -0
- package/dist/src/ui/markdown.js +127 -0
- package/dist/src/ui/options.d.ts +48 -0
- package/dist/src/ui/options.js +81 -0
- package/dist/src/ui/security.d.ts +34 -0
- package/dist/src/ui/security.js +38 -0
- package/dist/src/ui/server.d.ts +39 -0
- package/dist/src/ui/server.js +347 -0
- package/dist/src/ui/sse.d.ts +14 -0
- package/dist/src/ui/sse.js +281 -0
- package/dist/src/ui/views/board.d.ts +7 -0
- package/dist/src/ui/views/board.js +131 -0
- package/dist/src/ui/views/changes.d.ts +19 -0
- package/dist/src/ui/views/changes.js +154 -0
- package/dist/src/ui/views/components.d.ts +56 -0
- package/dist/src/ui/views/components.js +113 -0
- package/dist/src/ui/views/docs.d.ts +15 -0
- package/dist/src/ui/views/docs.js +64 -0
- package/dist/src/ui/views/layout.d.ts +44 -0
- package/dist/src/ui/views/layout.js +233 -0
- package/dist/src/ui/views/task.d.ts +25 -0
- package/dist/src/ui/views/task.js +320 -0
- package/dist/src/upgrade.js +5 -2
- package/dist/src/workflow.d.ts +4 -0
- package/dist/src/workflow.js +33 -1
- package/docs/README.md +48 -33
- package/docs/architecture.md +101 -38
- package/docs/command-reference.md +269 -93
- package/docs/configuration.md +62 -42
- package/docs/coordination-service.md +85 -36
- package/docs/design-decisions.md +35 -31
- package/docs/development.md +111 -61
- package/docs/loop-local-run.md +135 -402
- package/docs/loop-runbook.md +84 -105
- package/docs/operating-workflow.md +55 -42
- package/docs/testing-and-release.md +70 -51
- package/docs/troubleshooting.md +165 -69
- package/package.json +1 -1
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// `leases` diagnostic: who holds what, until when, what it blocks, and how to
|
|
2
|
+
// clear it. Read-only except `--clear-stale`, which removes only stale or old
|
|
3
|
+
// corrupt lease/lock/guard files after re-reading them under the holder guard.
|
|
4
|
+
// It never removes a live holder, never releases a claim, and never touches the
|
|
5
|
+
// lifecycle state lock (that lock reclaims itself).
|
|
6
|
+
import { lstatSync, readdirSync, unlinkSync } from "node:fs";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
|
+
import { ASSIGNMENT_STATE_LABELS, assignmentReadPath, assignmentRemedy, showLocalAssignments } from "./assignment-store.mjs";
|
|
9
|
+
import { isRegularFile, legacyTaskRuntimePath, taskRuntimePath } from "./task-runtime.mjs";
|
|
10
|
+
import { CLEAR_STALE_COMMAND, clearStaleHolder, explainHolder, formatAge, GUARD_STALE_MS, LEASE_TTL_MS, LOCK_TTL_MS, pidAlive, readGuard, readHolder } from "./loop-holder.mjs";
|
|
11
|
+
|
|
12
|
+
export const LEASES_SCHEMA_VERSION = 1;
|
|
13
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
14
|
+
const STATE_LOCK_STALE_MS = 30000;
|
|
15
|
+
|
|
16
|
+
const loopDir = (root) => join(root, ".agents", "data", "loop");
|
|
17
|
+
const assignmentsDir = (root) => join(loopDir(root), "assignments");
|
|
18
|
+
const tasksDir = (root) => join(root, ".agents", "data", "tasks");
|
|
19
|
+
const isDirectory = (path) => { try { const stat = lstatSync(path); return stat.isDirectory() && !stat.isSymbolicLink(); } catch { return false; } };
|
|
20
|
+
const listDir = (dir) => { try { return readdirSync(dir).sort(); } catch (error) { if (error?.code === "ENOENT") return []; throw error; } };
|
|
21
|
+
const isFile = (path) => { try { const stat = lstatSync(path); return stat.isFile() && !stat.isSymbolicLink(); } catch { return false; } };
|
|
22
|
+
|
|
23
|
+
function holderFields(entry) {
|
|
24
|
+
const holder = entry.holder;
|
|
25
|
+
if (!holder) return { holder: null, heartbeatAt: null, expiresAt: null };
|
|
26
|
+
const since = holder.startedAt ?? holder.createdAt ?? null;
|
|
27
|
+
return {
|
|
28
|
+
holder: { pid: holder.pid, host: holder.host ?? null, command: holder.command ?? null, purpose: holder.purpose ?? null, activity: holder.activity ?? null, startedAt: typeof since === "number" ? new Date(since).toISOString() : since, version: entry.version },
|
|
29
|
+
heartbeatAt: holder.heartbeatAt ?? null,
|
|
30
|
+
expiresAt: holder.expiresAt ?? null,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function remedyFor(entry, liveRemedy) {
|
|
35
|
+
if (entry.state === "live") return liveRemedy;
|
|
36
|
+
return explainHolder(entry).remedy;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function holderEntry({ category, kind, scope, entry, blocks, liveRemedy, ttlMs, clearable = true }) {
|
|
40
|
+
const effectiveBlocks = entry.state === "stale" ? `nothing while stale (the next acquire reclaims it); it was held for: ${blocks}` : blocks;
|
|
41
|
+
return { category, kind, scope, path: entry.path, state: entry.state, reason: entry.reason, ...holderFields(entry), blocks: effectiveBlocks, remedy: remedyFor(entry, liveRemedy), clearable: clearable && entry.reclaimable, ttlMs };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function guardEntry(path, scope, category = "guard") {
|
|
45
|
+
const guard = readGuard(path);
|
|
46
|
+
if (!guard) return null;
|
|
47
|
+
return {
|
|
48
|
+
category, kind: "guard", scope, path, state: guard.live ? "live" : "stale",
|
|
49
|
+
reason: guard.live ? `held for ${formatAge(guard.ageMs)}` : `abandoned (${formatAge(guard.ageMs)} old)`,
|
|
50
|
+
holder: null, heartbeatAt: null, expiresAt: null,
|
|
51
|
+
blocks: "concurrent reclaim/heartbeat/release of the guarded holder (held for milliseconds)",
|
|
52
|
+
remedy: guard.live ? `Short-lived; it is treated as abandoned after ${formatAge(GUARD_STALE_MS)}` : `Removed automatically by the next acquire, or run: ${CLEAR_STALE_COMMAND}`,
|
|
53
|
+
clearable: !guard.live, ttlMs: GUARD_STALE_MS,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function stateLockEntry(root) {
|
|
58
|
+
const path = join(root, ".agents", "data", "state", ".aidlc-state.lock");
|
|
59
|
+
const entry = readHolder(path, { ttlMs: STATE_LOCK_STALE_MS });
|
|
60
|
+
if (entry.state === "absent") return null;
|
|
61
|
+
// The state lock reclaims itself when its pid is gone AND it is >=30s old.
|
|
62
|
+
let state = entry.state;
|
|
63
|
+
if (entry.holder) state = pidAlive(entry.holder.pid) || entry.ageMs < STATE_LOCK_STALE_MS ? "live" : "stale";
|
|
64
|
+
return {
|
|
65
|
+
category: "state-lock", kind: "lock", scope: "lifecycle-state", path, state, reason: entry.holder ? (pidAlive(entry.holder.pid) ? "held" : `pid ${entry.holder.pid} is not running`) : entry.reason,
|
|
66
|
+
...holderFields(entry),
|
|
67
|
+
blocks: "every lifecycle mutation (state.mjs) waits up to 5s for it",
|
|
68
|
+
remedy: state === "live" ? "Short-lived; wait for the lifecycle command to finish" : "Reclaimed automatically by the next lifecycle command (pid gone and at least 30s old); leases never removes it",
|
|
69
|
+
clearable: false, ttlMs: STATE_LOCK_STALE_MS,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function taskLeaseBlocks(taskId, entry) {
|
|
74
|
+
const turn = entry.version === 2 ? entry.holder?.activity === "turn" : entry.version === 1;
|
|
75
|
+
return turn
|
|
76
|
+
? `another loop runner/supervisor for ${taskId}, and state.mjs gate approve ${taskId} (a turn is running)`
|
|
77
|
+
: `another loop runner/supervisor for ${taskId} (gate approval is allowed while it waits)`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function claimState(effective) {
|
|
81
|
+
if (effective === "leased" || effective === "reported") return "live";
|
|
82
|
+
if (effective === "expired" || effective === "uncertain") return "expired";
|
|
83
|
+
return effective;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function collectClaims(root, taskId) {
|
|
87
|
+
const out = [];
|
|
88
|
+
let value, recordPath;
|
|
89
|
+
try { recordPath = assignmentReadPath(root, taskId); value = showLocalAssignments(root, taskId); }
|
|
90
|
+
catch (error) {
|
|
91
|
+
return [{ category: "claim", kind: "claim", scope: `assignments:${taskId}`, path: recordPath ?? join(assignmentsDir(root), `${taskId}.json`), state: "corrupt", reason: error.message, holder: null, heartbeatAt: null, expiresAt: null, blocks: `every assignment command for ${taskId}`, remedy: "Inspect the file by hand; leases never edits assignment records", clearable: false, ttlMs: null }];
|
|
92
|
+
}
|
|
93
|
+
for (const assignment of value.assignments) {
|
|
94
|
+
if (!["leased", "reported", "uncertain"].includes(assignment.state)) continue;
|
|
95
|
+
const effective = assignment.effectiveState;
|
|
96
|
+
out.push({
|
|
97
|
+
category: "claim", kind: "claim", scope: `item:${taskId}/${assignment.itemId}`, path: recordPath,
|
|
98
|
+
state: claimState(effective), reason: ASSIGNMENT_STATE_LABELS[effective] ?? effective,
|
|
99
|
+
holder: { worker: assignment.workerId, assignmentId: assignment.id, attempt: assignment.attempt, ownedPaths: assignment.ownedPaths, persistedState: assignment.state, effectiveState: effective },
|
|
100
|
+
heartbeatAt: assignment.updatedAt ?? null, expiresAt: assignment.leaseExpiresAt,
|
|
101
|
+
blocks: `item ${assignment.itemId} and paths ${assignment.ownedPaths.join(", ")} for other workers`,
|
|
102
|
+
remedy: assignmentRemedy(taskId, assignment),
|
|
103
|
+
clearable: false, ttlMs: null,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Collect every local holder under `.agents/data` (optionally one task). */
|
|
110
|
+
export function collectLocalLeases(rootInput, { taskId } = {}) {
|
|
111
|
+
const root = resolve(rootInput);
|
|
112
|
+
if (taskId !== undefined && !ID.test(taskId)) throw new Error(`Invalid task id: ${taskId}`);
|
|
113
|
+
const entries = [];
|
|
114
|
+
const wanted = (id) => taskId === undefined || id === taskId;
|
|
115
|
+
const daemonPath = join(loopDir(root), "daemon.lock");
|
|
116
|
+
if (taskId === undefined) {
|
|
117
|
+
const daemon = readHolder(daemonPath, { ttlMs: LEASE_TTL_MS });
|
|
118
|
+
if (daemon.state !== "absent") entries.push(holderEntry({ category: "daemon", kind: "lease", scope: "daemon", entry: daemon, blocks: "a second `loop start` (one daemon per project root)", liveRemedy: `Stop that \`loop start\` (Ctrl-C) or wait; if the process is gone the lease expires after ${formatAge(LEASE_TTL_MS)}`, ttlMs: LEASE_TTL_MS }));
|
|
119
|
+
const guard = guardEntry(`${daemonPath}.guard`, "daemon");
|
|
120
|
+
if (guard) entries.push(guard);
|
|
121
|
+
}
|
|
122
|
+
for (const name of listDir(loopDir(root))) {
|
|
123
|
+
const path = join(loopDir(root), name);
|
|
124
|
+
if (!isFile(path)) continue;
|
|
125
|
+
let match;
|
|
126
|
+
if ((match = /^(.+)\.lease$/.exec(name)) && !name.endsWith(".supervisor.lease") && ID.test(match[1]) && wanted(match[1])) {
|
|
127
|
+
const id = match[1];
|
|
128
|
+
const entry = readHolder(path, { ttlMs: LEASE_TTL_MS });
|
|
129
|
+
entries.push(holderEntry({ category: "task", kind: "lease", scope: `task:${id}`, entry, blocks: taskLeaseBlocks(id, entry), liveRemedy: `Stop the owning loop process or wait for it to reach a gate; if the process is gone the lease expires after ${formatAge(LEASE_TTL_MS)}`, ttlMs: LEASE_TTL_MS }));
|
|
130
|
+
} else if ((match = /^(.+)\.supervisor\.lease$/.exec(name)) && ID.test(match[1]) && wanted(match[1])) {
|
|
131
|
+
const id = match[1];
|
|
132
|
+
const entry = readHolder(path, { ttlMs: LEASE_TTL_MS });
|
|
133
|
+
entries.push(holderEntry({ category: "legacy", kind: "lease", scope: `task:${id} (v1 supervisor)`, entry, blocks: `the new loop for ${id} (left by a previous aidlc-kit version)`, liveRemedy: "A loop from the previous version is still running: stop the loop daemon and any `loop start`", ttlMs: LEASE_TTL_MS }));
|
|
134
|
+
} else if ((match = /^(.+)\.supervisor\.guard$/.exec(name)) && ID.test(match[1]) && wanted(match[1])) {
|
|
135
|
+
const guard = guardEntry(path, `task:${match[1]} (v1 supervisor)`, "legacy");
|
|
136
|
+
if (guard) entries.push(guard);
|
|
137
|
+
} else if ((match = /^(.+)\.lease\.guard$/.exec(name)) && ID.test(match[1]) && wanted(match[1])) {
|
|
138
|
+
const guard = guardEntry(path, `task:${match[1]}`);
|
|
139
|
+
if (guard) entries.push(guard);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const taskIds = new Set();
|
|
143
|
+
const assignmentLock = (path, id) => {
|
|
144
|
+
const entry = readHolder(path, { ttlMs: LOCK_TTL_MS });
|
|
145
|
+
entries.push(holderEntry({ category: "assignment-lock", kind: "lock", scope: `assignments:${id}`, entry, blocks: `assignment claim/report/reconcile/release for ${id} (they wait up to 5s)`, liveRemedy: "Short-lived; wait for the assignment command to finish", ttlMs: LOCK_TTL_MS }));
|
|
146
|
+
};
|
|
147
|
+
// Current layout: .agents/data/tasks/<id>/runtime/{assignments.json,assignments.lock}.
|
|
148
|
+
for (const id of listDir(tasksDir(root))) {
|
|
149
|
+
if (!ID.test(id) || !wanted(id) || !isDirectory(join(tasksDir(root), id))) continue;
|
|
150
|
+
let lock, guard, record;
|
|
151
|
+
try { lock = taskRuntimePath(root, id, "assignments.lock"); guard = `${lock}.guard`; record = taskRuntimePath(root, id, "assignments.json"); } catch { continue; }
|
|
152
|
+
if (isFile(lock)) assignmentLock(lock, id);
|
|
153
|
+
const guardFound = isFile(guard) ? guardEntry(guard, `assignments:${id}`) : null;
|
|
154
|
+
if (guardFound) entries.push(guardFound);
|
|
155
|
+
if (isFile(record)) taskIds.add(id);
|
|
156
|
+
}
|
|
157
|
+
// Pre-runtime layout (.agents/data/loop/assignments/<id>.*): a record still
|
|
158
|
+
// here is read as that task's claims until it is migrated; its lock files
|
|
159
|
+
// are short-lived leftovers that `--clear-stale` may remove.
|
|
160
|
+
for (const name of listDir(assignmentsDir(root))) {
|
|
161
|
+
const path = join(assignmentsDir(root), name);
|
|
162
|
+
if (!isFile(path)) continue;
|
|
163
|
+
let match;
|
|
164
|
+
if ((match = /^(.+)\.lock$/.exec(name)) && ID.test(match[1]) && wanted(match[1])) assignmentLock(path, match[1]);
|
|
165
|
+
else if ((match = /^(.+)\.lock\.guard$/.exec(name)) && ID.test(match[1]) && wanted(match[1])) {
|
|
166
|
+
const guard = guardEntry(path, `assignments:${match[1]}`);
|
|
167
|
+
if (guard) entries.push(guard);
|
|
168
|
+
} else if ((match = /^(.+)\.json$/.exec(name)) && ID.test(match[1]) && wanted(match[1])) {
|
|
169
|
+
if (taskIds.has(match[1])) entries.push({ category: "legacy", kind: "leftover", scope: `assignments:${match[1]} (pre-runtime copy)`, path, state: "stale", reason: "superseded by the runtime/ record; left by a previous aidlc-kit version", holder: null, heartbeatAt: null, expiresAt: null, blocks: "nothing (the runtime/ record is authoritative)", remedy: "Inspect and delete by hand, or run: node .agents/aidlc/scripts/loop-entry.mjs migrate-data --dry-run --json", clearable: false, ttlMs: null });
|
|
170
|
+
else taskIds.add(match[1]);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const id of taskIds) entries.push(...collectClaims(root, id));
|
|
174
|
+
const stateLock = stateLockEntry(root);
|
|
175
|
+
if (stateLock) entries.push(stateLock);
|
|
176
|
+
return entries;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Read-only fleet view from `fleet_get`: supervisor lease, lead lease
|
|
180
|
+
* (run_claim) and job claims. Supervisor epoch is shown only when it differs
|
|
181
|
+
* from the lead epoch. */
|
|
182
|
+
export function summarizeFleet(fleet, { taskId, now = Date.now() } = {}) {
|
|
183
|
+
const live = (expiresAt) => (Date.parse(expiresAt ?? "") > now ? "live" : "expired");
|
|
184
|
+
const tasks = new Set([...Object.keys(fleet?.leads ?? {}), ...Object.keys(fleet?.supervisors ?? {})].filter((id) => taskId === undefined || id === taskId));
|
|
185
|
+
const entries = [];
|
|
186
|
+
for (const id of [...tasks].sort()) {
|
|
187
|
+
const lead = fleet.leads?.[id];
|
|
188
|
+
const supervisor = fleet.supervisors?.[id];
|
|
189
|
+
if (lead) entries.push({ category: "fleet-lead", kind: "lease", scope: `task:${id}`, state: live(lead.expiresAt), holder: { actor: lead.actor ?? null, leadEpoch: lead.epoch, ...(supervisor && supervisor.epoch !== lead.epoch ? { supervisorEpoch: supervisor.epoch } : {}) }, expiresAt: lead.expiresAt ?? null, blocks: `another lead for ${id}`, remedy: live(lead.expiresAt) === "live" ? "Stop the `loop start` on the lead machine or wait for the lease to expire (5 minutes without heartbeat)" : "Expired: the next `loop start` takes over" });
|
|
190
|
+
else if (supervisor) entries.push({ category: "fleet-supervisor", kind: "lease", scope: `task:${id}`, state: live(supervisor.expiresAt), holder: { actor: supervisor.actor ?? null, supervisorEpoch: supervisor.epoch }, expiresAt: supervisor.expiresAt ?? null, blocks: `another supervisor for ${id}`, remedy: "Stop the other `loop start` or wait for the lease to expire" });
|
|
191
|
+
}
|
|
192
|
+
for (const job of Object.values(fleet?.jobs ?? {}).sort((a, b) => String(a.id).localeCompare(String(b.id)))) {
|
|
193
|
+
if (taskId !== undefined && job.taskId !== taskId) continue;
|
|
194
|
+
if (!["leased", "uncertain", "reported"].includes(job.state)) continue;
|
|
195
|
+
const state = job.state === "leased" ? live(job.expiresAt) : job.state === "uncertain" ? "expired" : "live";
|
|
196
|
+
entries.push({ category: "fleet-job", kind: "claim", scope: `item:${job.taskId}/${job.itemId}`, state, holder: { worker: job.workerId, jobId: job.id, jobEpoch: job.epoch ?? null, attempt: job.attempt ?? null, persistedState: job.state }, expiresAt: job.expiresAt ?? null, blocks: `item ${job.itemId} for other workers`, remedy: state === "live" ? "Wait for the worker to report" : "The lead reconciles it on its next run" });
|
|
197
|
+
}
|
|
198
|
+
return entries;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Remove stale/old-corrupt entries from `collectLocalLeases`; returns the
|
|
202
|
+
* removed paths. Live holders, claims, and the state lock are never touched. */
|
|
203
|
+
export function clearStaleLeases(entries) {
|
|
204
|
+
const cleared = [];
|
|
205
|
+
for (const entry of entries) {
|
|
206
|
+
if (!entry.clearable || entry.state === "live") continue;
|
|
207
|
+
if (entry.kind === "guard") {
|
|
208
|
+
const observed = readGuard(entry.path);
|
|
209
|
+
const again = observed && !observed.live ? readGuard(entry.path) : null;
|
|
210
|
+
if (again && !again.live && again.token === observed.token) { try { unlinkSync(entry.path); cleared.push(entry.path); } catch (error) { if (error?.code !== "ENOENT") throw error; } }
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (!["lease", "lock"].includes(entry.kind) || !["daemon", "task", "assignment-lock", "legacy"].includes(entry.category)) continue;
|
|
214
|
+
if (clearStaleHolder(entry.path, { ttlMs: entry.ttlMs ?? LEASE_TTL_MS })) cleared.push(entry.path);
|
|
215
|
+
}
|
|
216
|
+
return cleared;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Run the diagnostic. `fleetReader` (optional) returns the `fleet_get` result. */
|
|
220
|
+
export async function runLeases({ root: rootInput, taskId, clearStale = false, fleetReader, now = Date.now() } = {}) {
|
|
221
|
+
const root = resolve(rootInput);
|
|
222
|
+
let local = collectLocalLeases(root, { taskId });
|
|
223
|
+
let cleared = [];
|
|
224
|
+
if (clearStale) { cleared = clearStaleLeases(local); local = collectLocalLeases(root, { taskId }); }
|
|
225
|
+
let fleet = null;
|
|
226
|
+
if (fleetReader) {
|
|
227
|
+
try { const current = await fleetReader(); fleet = { revision: current?.revision ?? null, entries: summarizeFleet(current?.fleet, { taskId, now }), error: null }; }
|
|
228
|
+
catch (error) { fleet = { revision: null, entries: [], error: error.message }; }
|
|
229
|
+
}
|
|
230
|
+
return { schemaVersion: LEASES_SCHEMA_VERSION, root, taskId: taskId ?? null, generatedAt: new Date(now).toISOString(), entries: local, fleet, cleared };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const VOCABULARY = "lock = short mutex while a file is written · lease = process ownership (daemon, task) · claim = a worker's ownership of an item/paths";
|
|
234
|
+
|
|
235
|
+
function line(entry) {
|
|
236
|
+
const who = entry.holder
|
|
237
|
+
? entry.holder.pid !== undefined
|
|
238
|
+
? `pid ${entry.holder.pid}${entry.holder.host ? ` on ${entry.holder.host}` : ""}${entry.holder.command ? ` (${[entry.holder.command, entry.holder.activity].filter(Boolean).join(", ")})` : entry.holder.activity ? ` (${entry.holder.activity})` : ""}`
|
|
239
|
+
: entry.holder.worker !== undefined
|
|
240
|
+
? `worker ${entry.holder.worker}${entry.holder.assignmentId ? `, assignment ${entry.holder.assignmentId}` : ""}${entry.holder.jobId ? `, job ${entry.holder.jobId}` : ""}${entry.holder.jobEpoch ? `, job epoch ${entry.holder.jobEpoch}` : ""}`
|
|
241
|
+
: [entry.holder.actor, entry.holder.leadEpoch ? `lead epoch ${entry.holder.leadEpoch}` : null, entry.holder.supervisorEpoch ? `supervisor epoch ${entry.holder.supervisorEpoch}` : null].filter(Boolean).join(", ")
|
|
242
|
+
: "-";
|
|
243
|
+
const rows = [`${entry.state.toUpperCase().padEnd(7)} ${entry.kind} ${entry.scope} [${entry.category}]`, ` holder: ${who}`];
|
|
244
|
+
if (entry.heartbeatAt || entry.expiresAt) rows.push(` timing: ${entry.heartbeatAt ? `heartbeat ${entry.heartbeatAt}` : ""}${entry.heartbeatAt && entry.expiresAt ? ", " : ""}${entry.expiresAt ? `expires ${entry.expiresAt}` : ""}`);
|
|
245
|
+
if (entry.reason && entry.state !== "live") rows.push(` why: ${entry.reason}`);
|
|
246
|
+
rows.push(` blocks: ${entry.blocks}`);
|
|
247
|
+
if (entry.remedy) rows.push(` fix: ${entry.remedy}`);
|
|
248
|
+
if (entry.path) rows.push(` file: ${entry.path}`);
|
|
249
|
+
return rows.join("\n");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Human-readable report. */
|
|
253
|
+
export function formatLeases(report) {
|
|
254
|
+
const out = [`Loop leases, locks and claims${report.taskId ? ` for ${report.taskId}` : ""} (${report.root})`, VOCABULARY, ""];
|
|
255
|
+
if (report.cleared.length) out.push(`Cleared ${report.cleared.length} stale file(s):`, ...report.cleared.map((path) => ` - ${path}`), "");
|
|
256
|
+
if (!report.entries.length) out.push("No local holders: nothing is locked.");
|
|
257
|
+
else out.push(...report.entries.map(line).join("\n\n").split("\n"));
|
|
258
|
+
if (report.fleet) {
|
|
259
|
+
out.push("", `Fleet (coordination service${report.fleet.revision !== null ? `, revision ${report.fleet.revision}` : ""}):`);
|
|
260
|
+
if (report.fleet.error) out.push(` unavailable: ${report.fleet.error}`);
|
|
261
|
+
else if (!report.fleet.entries.length) out.push(" No fleet leases or claims.");
|
|
262
|
+
else out.push(...report.fleet.entries.map(line).join("\n\n").split("\n").map((row) => ` ${row}`));
|
|
263
|
+
}
|
|
264
|
+
const clearable = report.entries.filter((entry) => entry.clearable).length;
|
|
265
|
+
if (clearable && !report.cleared.length) out.push("", `${clearable} stale entr${clearable === 1 ? "y" : "ies"} can be removed with: ${CLEAR_STALE_COMMAND}`);
|
|
266
|
+
return out.join("\n");
|
|
267
|
+
}
|
|
268
|
+
|
|
@@ -5,10 +5,11 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { createWorkerSnapshot, inspectWorkerSnapshot } from "./loop-worker-snapshot.mjs";
|
|
7
7
|
import { workerDockerInvocation } from "./loop-worker-docker.mjs";
|
|
8
|
-
import { isLoopProvider, runAgentTurn } from "./loop-drivers.mjs";
|
|
8
|
+
import { isLoopProvider, runAgentTurn, summarize } from "./loop-drivers.mjs";
|
|
9
9
|
import { extractCommitMessage } from "./loop-commit-message.mjs";
|
|
10
10
|
import { pathOverlaps } from "./store.mjs";
|
|
11
|
-
import { claimLocalAssignment, heartbeatLocalAssignment, reportLocalAssignment, releaseLocalAssignment } from "./assignment-store.mjs";
|
|
11
|
+
import { claimLocalAssignment, heartbeatLocalAssignment, recordLocalIntegration, reportLocalAssignment, releaseLocalAssignment } from "./assignment-store.mjs";
|
|
12
|
+
import { openWorkerLog } from "./loop-worker-log.mjs";
|
|
12
13
|
|
|
13
14
|
const git = (root, ...args) => execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30000 });
|
|
14
15
|
const lines = (value) => value.split("\0").filter(Boolean);
|
|
@@ -23,14 +24,40 @@ const commitPaths = (root, base, head) => lines(git(root, "diff", "--name-only",
|
|
|
23
24
|
// Workflow state and assignment records are lead-owned and never in worker
|
|
24
25
|
// scopes. Their ordinary updates must not block integrating code-only commits.
|
|
25
26
|
const clean = (root) => { if (statusPaths(root).some((path) => !path.startsWith(".agents/data/"))) throw new Error("Local integration worktree must be clean outside workflow data"); };
|
|
27
|
+
// A worker container that may still be running (and writing) must keep its claim.
|
|
28
|
+
const cleanupUncertain = (message) => Object.assign(new Error(message), { cleanupUncertain: true });
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Release the claims of failed jobs this batch itself stopped, so a timed-out,
|
|
32
|
+
* stalled, or daemon-cancelled worker does not hold its item and owned paths
|
|
33
|
+
* until a human runs `assignment.mjs release`. A claim is released only when the
|
|
34
|
+
* job rejected before reporting and its container was removed for certain; a
|
|
35
|
+
* reported (successful but unintegrated) job, an uncertain container cleanup,
|
|
36
|
+
* or a failed release keeps the claim for reconciliation. Never throws: a
|
|
37
|
+
* release error must not mask the batch failure.
|
|
38
|
+
* `outcomes`: `[{ assignmentId, itemId, reported, settled }]`, `settled` being
|
|
39
|
+
* the job's `Promise.allSettled` entry. Returns `{ released, held }`.
|
|
40
|
+
*/
|
|
41
|
+
export async function releaseFailedAssignments({ root, taskId, outcomes, release }) {
|
|
42
|
+
const released = [], held = [];
|
|
43
|
+
for (const { assignmentId, itemId, reported, settled } of outcomes) {
|
|
44
|
+
if (settled.status === "fulfilled" || reported) { held.push({ itemId, reason: "reported, not integrated" }); continue; }
|
|
45
|
+
if (settled.reason?.cleanupUncertain) { held.push({ itemId, reason: "container cleanup uncertain" }); continue; }
|
|
46
|
+
try { await release(root, taskId, assignmentId); released.push(itemId); }
|
|
47
|
+
catch (error) { held.push({ itemId, reason: `release failed: ${error instanceof Error ? error.message : String(error)}` }); }
|
|
48
|
+
}
|
|
49
|
+
return { released, held };
|
|
50
|
+
}
|
|
26
51
|
|
|
27
52
|
/**
|
|
28
53
|
* Execute a batch of independent local jobs in isolated worktrees, then integrate
|
|
29
54
|
* verified scoped commits sequentially. Assignment metadata is not lifecycle
|
|
30
55
|
* state: the caller alone marks canonical items done after inspecting results.
|
|
31
|
-
* A failed batch is
|
|
56
|
+
* A failed batch is not auto-retried: claims of failed jobs the batch stopped
|
|
57
|
+
* for certain are released (see releaseFailedAssignments); the rest are left
|
|
58
|
+
* for reconciliation.
|
|
32
59
|
*/
|
|
33
|
-
export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, worktreesRoot: inputWorktreesRoot, docker, timeoutMs = 600000, leaseMs = 300000, maxTurns = 12, maxStallTurns = 2, claim = claimLocalAssignment, heartbeat = heartbeatLocalAssignment, report = reportLocalAssignment, release = releaseLocalAssignment, onEvent = () => {}, signal } = {}) {
|
|
60
|
+
export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, worktreesRoot: inputWorktreesRoot, docker, timeoutMs = 600000, leaseMs = 300000, maxTurns = 12, maxStallTurns = 2, claim = claimLocalAssignment, heartbeat = heartbeatLocalAssignment, report = reportLocalAssignment, release = releaseLocalAssignment, recordIntegration = recordLocalIntegration, onEvent = () => {}, signal } = {}) {
|
|
34
61
|
const root = resolve(inputRoot), worktreesRoot = resolve(inputWorktreesRoot);
|
|
35
62
|
if (!identifier(taskId) || !Array.isArray(jobs) || !jobs.length) throw new Error("Valid task and non-empty jobs are required");
|
|
36
63
|
if (root === worktreesRoot || worktreesRoot.startsWith(`${root}${sep}`)) throw new Error("Worker worktree root must be outside the integration checkout");
|
|
@@ -103,35 +130,58 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
|
|
|
103
130
|
for (const entry of prepared) { if (entry.created) git(root, "worktree", "remove", entry.workspace); rmSync(entry.snapshot, { recursive: true, force: true }); await release(root, taskId, entry.assignment.id); }
|
|
104
131
|
throw error;
|
|
105
132
|
}
|
|
106
|
-
const execute = async (
|
|
133
|
+
const execute = async (entry) => {
|
|
134
|
+
const { job, assignment, workspace, snapshot, baseline, branch } = entry;
|
|
135
|
+
// Live-log context: worker events name the worker, its item, and the build phase.
|
|
136
|
+
// Worker output is never logged in full: agent text is reduced to a redacted
|
|
137
|
+
// first-line summary (tool and command events already carry summaries only).
|
|
138
|
+
const workerEvent = (event) => {
|
|
139
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) return onEvent(event);
|
|
140
|
+
const brief = event.type === "agent_text" ? { type: "agent_text", text: summarize(String(event.text ?? "").split(/\r?\n/).find((line) => line.trim()) ?? "") ?? "" } : event;
|
|
141
|
+
return onEvent({ taskId, actor: job.workerId, phase: "build", itemId: job.itemId, ...brief });
|
|
142
|
+
};
|
|
107
143
|
let renewalError;
|
|
144
|
+
// The worker's claim ended (renewal stopped or the turn was aborted) before it finished.
|
|
145
|
+
const leaseLost = () => new Error(`Worker lease lost: the claim on assignment ${assignment.id} (item ${job.itemId}, worker ${job.workerId}, paths ${job.ownedPaths.join(", ")}) ended before the worker finished; its result is not integrated. Run node .agents/aidlc/scripts/assignment.mjs reconcile ${taskId}`);
|
|
108
146
|
const controller = new AbortController();
|
|
109
147
|
// An external cancel (daemon shutdown stage 2) aborts every worker turn the
|
|
110
148
|
// same way a lost lease does; the batch then fails and is not integrated.
|
|
111
149
|
const onCancel = () => { renewalError ??= new Error("Worker batch cancelled by daemon shutdown"); controller.abort(); };
|
|
112
150
|
if (signal?.aborted) onCancel(); else signal?.addEventListener("abort", onCancel, { once: true });
|
|
113
151
|
const timer = setInterval(() => { Promise.resolve().then(() => heartbeat(root, taskId, assignment.id, leaseMs)).catch((error) => { renewalError = error; controller.abort(); }); }, Math.max(500, Math.floor(leaseMs / 3)));
|
|
152
|
+
// Container record and raw output tee under the task's runtime directory
|
|
153
|
+
// (.agents/data/tasks/<task>/runtime/workers). Best-effort: a log failure is
|
|
154
|
+
// reported once as a diagnostic and never fails or delays the worker.
|
|
155
|
+
const logDiagnostic = (error) => { Promise.resolve().then(() => onEvent({ type: "diagnostic", taskId, itemId: job.itemId, message: `Worker log unavailable for assignment ${assignment.id}: ${error instanceof Error ? error.message : String(error)}` })).catch(() => {}); };
|
|
156
|
+
let workerLog = null;
|
|
157
|
+
try { workerLog = openWorkerLog({ root, taskId, assignmentId: assignment.id, itemId: job.itemId, workerId: job.workerId, agent: job.agent, mode: "local", onError: logDiagnostic }); }
|
|
158
|
+
catch (error) { logDiagnostic(error); }
|
|
159
|
+
const labels = { "aidlc.task": taskId, "aidlc.item": job.itemId, "aidlc.worker": job.workerId, "aidlc.assignment": assignment.id };
|
|
114
160
|
try {
|
|
115
161
|
const home=mkdtempSync(join(tmpdir(), "aidlc-local-home-")); chmodSync(home,0o700);
|
|
116
162
|
let sessionId, previousFingerprint, stalls=0, result;
|
|
117
163
|
try {
|
|
118
164
|
for (let turn=0; turn<maxTurns; turn++) {
|
|
119
|
-
if (renewalError || controller.signal.aborted) throw renewalError ??
|
|
165
|
+
if (renewalError || controller.signal.aborted) throw renewalError ?? leaseLost();
|
|
120
166
|
const containerName = `aidlc-worker-${randomUUID()}`;
|
|
121
167
|
const spawnProcess = (command, args, options) => {
|
|
122
|
-
const invocation = workerDockerInvocation({ workspace: snapshot, image: resolvedImages[job.agent], command, args, credentialEnv: docker.credentials[job.agent], hostEnv: process.env, uid, gid, containerName, sessionHome:home });
|
|
123
|
-
|
|
168
|
+
const invocation = workerDockerInvocation({ workspace: snapshot, image: resolvedImages[job.agent], command, args, credentialEnv: docker.credentials[job.agent], hostEnv: process.env, uid, gid, containerName, sessionHome:home, labels });
|
|
169
|
+
const child = spawn(invocation.command, invocation.args, { ...options, cwd: snapshot, env: invocation.env, shell: false });
|
|
170
|
+
if (workerLog) { workerLog.tee(child); child.once("close", (code) => workerLog.turnEnd(code)); }
|
|
171
|
+
return child;
|
|
124
172
|
};
|
|
125
|
-
|
|
173
|
+
workerLog?.turnStart(containerName, turn + 1);
|
|
174
|
+
await workerEvent({ type: "worker_turn_start", iteration: turn, agent: job.agent, summary: summarize(`turn ${turn + 1} on ${job.ownedPaths.join(", ")}`) });
|
|
175
|
+
try { result = await runAgentTurn({ agent: job.agent, root: "/workspace", sessionId, spawnProcess, onEvent: workerEvent, prompt: `${job.prompt}\n\nOnly modify assigned paths: ${job.ownedPaths.join(", ")}. Do not modify AI-DLC lifecycle state or approve gates. End with a Conventional Commits message on its own line as AI-DLC-COMMIT: type(scope): subject (for example AI-DLC-COMMIT: docs(ui): add web UI improvement proposal), followed by AI-DLC-ITEM-COMPLETE on its own line.`, timeoutMs, signal: controller.signal, onSessionId: async (id) => { await heartbeat(root, taskId, assignment.id, leaseMs, id); } }); }
|
|
126
176
|
finally {
|
|
127
177
|
try { dockerCheck("rm", "-f", containerName); }
|
|
128
178
|
catch {
|
|
129
|
-
try { dockerCheck("info"); } catch { throw
|
|
130
|
-
try { dockerCheck("inspect", containerName); throw
|
|
131
|
-
catch (error) { if (error.
|
|
179
|
+
try { dockerCheck("info"); } catch { throw cleanupUncertain("Docker cleanup uncertain: engine unavailable"); }
|
|
180
|
+
try { dockerCheck("inspect", containerName); throw cleanupUncertain("Docker cleanup uncertain: container remains"); }
|
|
181
|
+
catch (error) { if (error.cleanupUncertain) throw error; }
|
|
132
182
|
}
|
|
133
183
|
}
|
|
134
|
-
if (renewalError || controller.signal.aborted) throw renewalError ??
|
|
184
|
+
if (renewalError || controller.signal.aborted) throw renewalError ?? leaseLost();
|
|
135
185
|
sessionId=result.sessionId;
|
|
136
186
|
const changed=inspectWorkerSnapshot(snapshot,baseline,job.ownedPaths);
|
|
137
187
|
if (result.resultText?.split(/\r?\n/).some((line) => line.trim() === "AI-DLC-ITEM-COMPLETE")) break;
|
|
@@ -145,7 +195,7 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
|
|
|
145
195
|
const snapshotChanges = inspectWorkerSnapshot(snapshot, baseline, job.ownedPaths);
|
|
146
196
|
if (!snapshotChanges.length) throw new Error(`Worker ${job.workerId} changed no files`);
|
|
147
197
|
await heartbeat(root, taskId, assignment.id, leaseMs);
|
|
148
|
-
if (renewalError || controller.signal.aborted) throw renewalError ??
|
|
198
|
+
if (renewalError || controller.signal.aborted) throw renewalError ?? leaseLost();
|
|
149
199
|
for (const path of snapshotChanges) {
|
|
150
200
|
const destination = join(workspace, path), from = join(snapshot, path);
|
|
151
201
|
let parent = workspace;
|
|
@@ -167,12 +217,19 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
|
|
|
167
217
|
const commit = git(workspace, "rev-parse", "HEAD").trim(), committed = commitPaths(workspace, base, commit);
|
|
168
218
|
if (commit === base || !committed.length || !scoped(committed, job.ownedPaths)) throw new Error("Worker commit is outside assignment scope");
|
|
169
219
|
await report(root, taskId, assignment.id, { commit, changedPaths: committed, checkpoint: `provider=${job.agent}` });
|
|
220
|
+
entry.reported = true;
|
|
221
|
+
await workerEvent({ type: "worker_reported", commit, changedPaths: committed, summary: summarize(`changed ${committed.length} file(s): ${committed.join(", ")}`) });
|
|
170
222
|
return { assignmentId: assignment.id, itemId: job.itemId, workerId: job.workerId, agent: job.agent, baseCommit: base, branch, commit, changedPaths: committed, workspace };
|
|
171
|
-
} finally { clearInterval(timer); signal?.removeEventListener("abort", onCancel); }
|
|
223
|
+
} finally { clearInterval(timer); signal?.removeEventListener("abort", onCancel); workerLog?.close(); }
|
|
172
224
|
};
|
|
173
225
|
const results = await Promise.allSettled(prepared.map(execute));
|
|
174
226
|
const failure = results.find((result) => result.status === "rejected");
|
|
175
|
-
if (failure)
|
|
227
|
+
if (failure) {
|
|
228
|
+
const cause = failure.reason instanceof Error ? failure.reason.message : String(failure.reason);
|
|
229
|
+
const { released, held } = await releaseFailedAssignments({ root, taskId, release, outcomes: prepared.map((entry, index) => ({ assignmentId: entry.assignment.id, itemId: entry.job.itemId, reported: entry.reported === true, settled: results[index] })) });
|
|
230
|
+
if (!held.length) throw new Error(`Local worker batch failed: ${cause}; released assignment(s) for retry: ${released.join(", ")}`);
|
|
231
|
+
throw new Error(`Local worker batch requires reconciliation: ${cause}; released: ${released.join(", ") || "none"}; still held: ${held.map(({ itemId, reason }) => `${itemId} (${reason})`).join(", ")}`);
|
|
232
|
+
}
|
|
176
233
|
const completed = results.map((result) => result.value);
|
|
177
234
|
let expectedHead = base;
|
|
178
235
|
for (const result of completed) {
|
|
@@ -183,7 +240,11 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
|
|
|
183
240
|
git(root, "-c", "user.name=AI-DLC integrator", "-c", "user.email=aidlc-integrator@localhost", "cherry-pick", result.commit);
|
|
184
241
|
result.integrationCommit = git(root, "rev-parse", "HEAD").trim();
|
|
185
242
|
expectedHead = result.integrationCommit;
|
|
186
|
-
|
|
243
|
+
// Best-effort: the cherry-pick already happened, so a failed record must not
|
|
244
|
+
// fail the batch; the dashboard then falls back to the worker commit.
|
|
245
|
+
try { await recordIntegration(root, taskId, result.assignmentId, result.integrationCommit); }
|
|
246
|
+
catch (error) { await onEvent({ type: "diagnostic", taskId, itemId: result.itemId, message: `Could not record integration commit: ${error instanceof Error ? error.message : String(error)}` }); }
|
|
247
|
+
await onEvent({ type: "job_integrated", taskId, actor: result.workerId, phase: "build", itemId: result.itemId, assignmentId: result.assignmentId, commit: result.integrationCommit, summary: summarize(`integrated ${result.changedPaths.length} file(s) as ${result.integrationCommit.slice(0, 12)}`) });
|
|
187
248
|
}
|
|
188
249
|
return completed;
|
|
189
250
|
}
|
|
@@ -6,8 +6,12 @@ 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
17
|
const states = new Set(["running", "gate", "paused", "blocked", "terminal", "complete", "stopped"]);
|
|
@@ -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 {
|
|
@@ -193,7 +201,16 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
|
|
|
193
201
|
if (preflight) checkAgent(record?.agent ?? agent, root);
|
|
194
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() };
|
|
195
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 }); }
|
|
196
|
-
|
|
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
|
+
}
|
|
197
214
|
try {
|
|
198
215
|
let current = readCanonical(root, taskId);
|
|
199
216
|
if (precondition) {
|
|
@@ -224,6 +241,7 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
|
|
|
224
241
|
// long multi-gate task is not permanently re-paused on every resume.
|
|
225
242
|
let runTurns = 0;
|
|
226
243
|
for (;;) {
|
|
244
|
+
lease.assertOwned();
|
|
227
245
|
const kind = classifyAction(current.action);
|
|
228
246
|
if (kind !== "run_phase") {
|
|
229
247
|
record.status = kind === "await_user" ? "gate" : kind;
|
|
@@ -270,10 +288,13 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
|
|
|
270
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}`;
|
|
271
289
|
}
|
|
272
290
|
const beforeApprovals = approvalCount(current.task);
|
|
273
|
-
|
|
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 });
|
|
274
295
|
let observedId = record.sessionId;
|
|
275
296
|
try {
|
|
276
|
-
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) => {
|
|
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); } } });
|
|
277
298
|
if (!observedId && result?.sessionId) { observedId = result.sessionId; record.sessionId = observedId; record.updatedAt = iso(); writeLoopRecord(root, record); }
|
|
278
299
|
if (!observedId) throw new Error("Agent turn ended without a resumable session id");
|
|
279
300
|
} catch (error) {
|
|
@@ -282,7 +303,7 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
|
|
|
282
303
|
if (stopping()) return stop(cancelSignal?.aborted ? "daemon cancelled the in-flight turn" : "daemon shutdown interrupted the turn");
|
|
283
304
|
return pause(`agent transport failure: ${error.message}`);
|
|
284
305
|
}
|
|
285
|
-
|
|
306
|
+
leadEvent({ type: "turn_end", iteration: record.iteration });
|
|
286
307
|
const refreshed = readCanonical(root, taskId);
|
|
287
308
|
if (approvalCount(refreshed.task) !== beforeApprovals) return pause("Approval evidence changed during an agent turn");
|
|
288
309
|
const signal = fingerprint(root, refreshed.task, refreshed.action);
|
|
@@ -319,12 +340,23 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
|
|
|
319
340
|
record.updatedAt = iso(); writeLoopRecord(root, record);
|
|
320
341
|
return outputStop(record, stopped.action);
|
|
321
342
|
}
|
|
322
|
-
} 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
|
+
}
|
|
323
353
|
}
|
|
324
354
|
|
|
325
355
|
export function loopStatus(rootInput, taskId) {
|
|
326
356
|
const root = validateRoot(rootInput);
|
|
327
357
|
const record = readLoopRecord(root, taskId);
|
|
328
358
|
const current = readCanonical(root, taskId);
|
|
329
|
-
|
|
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 };
|
|
330
362
|
}
|