@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
|
@@ -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
|
+
|
|
@@ -4,15 +4,17 @@ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
|
4
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { createWorkerSnapshot, inspectWorkerSnapshot } from "./loop-worker-snapshot.mjs";
|
|
7
|
-
import {
|
|
8
|
-
import { isLoopProvider, runAgentTurn } from "./loop-drivers.mjs";
|
|
9
|
-
import {
|
|
7
|
+
import { workerDockerInvocation } from "./loop-worker-docker.mjs";
|
|
8
|
+
import { isLoopProvider, runAgentTurn, summarize } from "./loop-drivers.mjs";
|
|
9
|
+
import { extractCommitMessage } from "./loop-commit-message.mjs";
|
|
10
|
+
import { pathOverlaps } from "./store.mjs";
|
|
11
|
+
import { claimLocalAssignment, heartbeatLocalAssignment, recordLocalIntegration, reportLocalAssignment, releaseLocalAssignment } from "./assignment-store.mjs";
|
|
12
|
+
import { openWorkerLog } from "./loop-worker-log.mjs";
|
|
10
13
|
|
|
11
14
|
const git = (root, ...args) => execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30000 });
|
|
12
15
|
const lines = (value) => value.split("\0").filter(Boolean);
|
|
13
16
|
const identifier = (value) => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value);
|
|
14
17
|
const safePath = (path) => typeof path === "string" && path.length > 0 && !isAbsolute(path) && !path.includes("\\") && !path.split("/").some((part) => !part || part === "." || part === ".." || part === ".git" || part === ".agents") && !/[\x00-\x1f]/.test(path);
|
|
15
|
-
const overlaps = (a, b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
16
18
|
const scoped = (paths, scopes) => paths.every((path) => safePath(path) && scopes.some((scope) => path === scope || path.startsWith(`${scope}/`)));
|
|
17
19
|
const statusPaths = (root) => {
|
|
18
20
|
// --no-renames yields one entry per path, including both sides of a rename.
|
|
@@ -22,14 +24,40 @@ const commitPaths = (root, base, head) => lines(git(root, "diff", "--name-only",
|
|
|
22
24
|
// Workflow state and assignment records are lead-owned and never in worker
|
|
23
25
|
// scopes. Their ordinary updates must not block integrating code-only commits.
|
|
24
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
|
+
}
|
|
25
51
|
|
|
26
52
|
/**
|
|
27
53
|
* Execute a batch of independent local jobs in isolated worktrees, then integrate
|
|
28
54
|
* verified scoped commits sequentially. Assignment metadata is not lifecycle
|
|
29
55
|
* state: the caller alone marks canonical items done after inspecting results.
|
|
30
|
-
* 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.
|
|
31
59
|
*/
|
|
32
|
-
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 = () => {} }) {
|
|
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 } = {}) {
|
|
33
61
|
const root = resolve(inputRoot), worktreesRoot = resolve(inputWorktreesRoot);
|
|
34
62
|
if (!identifier(taskId) || !Array.isArray(jobs) || !jobs.length) throw new Error("Valid task and non-empty jobs are required");
|
|
35
63
|
if (root === worktreesRoot || worktreesRoot.startsWith(`${root}${sep}`)) throw new Error("Worker worktree root must be outside the integration checkout");
|
|
@@ -39,27 +67,46 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
|
|
|
39
67
|
for (const job of jobs) {
|
|
40
68
|
if (Array.isArray(job.ownedPaths)) job.ownedPaths = [...new Set(job.ownedPaths.map((path) => (typeof path === "string" ? path.replace(/\/+$/, "") : path)).filter((path) => path))];
|
|
41
69
|
if (!identifier(job.itemId) || !identifier(job.workerId) || !isLoopProvider(job.agent) || typeof job.prompt !== "string" || !job.prompt.trim() || !Array.isArray(job.ownedPaths) || !job.ownedPaths.length || job.ownedPaths.length > 64 || !job.ownedPaths.every(safePath)) throw new Error("Invalid local job");
|
|
70
|
+
// Last-resort guards: the dispatcher partitions batches (selectBuildBatch),
|
|
71
|
+
// so plan data should never reach these.
|
|
42
72
|
if (ids.has(job.itemId) || workers.has(job.workerId)) throw new Error("Items and workers must be distinct within a batch");
|
|
43
73
|
ids.add(job.itemId); workers.add(job.workerId);
|
|
44
|
-
for (const path of job.ownedPaths) { if (scopes.some((prior) =>
|
|
74
|
+
for (const path of job.ownedPaths) { if (scopes.some((prior) => pathOverlaps(path, prior))) throw new Error(`Overlapping assignment scope: ${path}`); scopes.push(path); }
|
|
45
75
|
}
|
|
46
|
-
|
|
47
|
-
if (process.platform !== "linux" || !docker || typeof docker.image !== "string" || !docker.credentials || typeof docker.credentials !== "object") throw new Error("Linux Docker worker isolation and explicit image and provider credentials are required");
|
|
76
|
+
const hasImages = docker && typeof docker.images === "object" && !Array.isArray(docker.images);
|
|
77
|
+
if (process.platform !== "linux" || !docker || (typeof docker.image !== "string" && !hasImages) || !docker.credentials || typeof docker.credentials !== "object") throw new Error("Linux Docker worker isolation and explicit image and provider credentials are required");
|
|
78
|
+
const resolveImage = (agent) => {
|
|
79
|
+
const specific = hasImages && typeof docker.images[agent] === "string" && docker.images[agent] ? docker.images[agent] : undefined;
|
|
80
|
+
return specific ?? docker.image;
|
|
81
|
+
};
|
|
48
82
|
const uid = process.getuid?.(), gid = process.getgid?.();
|
|
49
83
|
if (!uid || !gid) throw new Error("Docker workers require a non-root host user");
|
|
84
|
+
const resolvedImages = {};
|
|
85
|
+
for (const agent of new Set(jobs.map((job) => job.agent))) {
|
|
86
|
+
const agentImage = resolveImage(agent);
|
|
87
|
+
if (typeof agentImage !== "string" || !agentImage) throw new Error(`Worker Docker image is required for provider ${agent}`);
|
|
88
|
+
resolvedImages[agent] = agentImage;
|
|
89
|
+
}
|
|
50
90
|
for (const job of jobs) {
|
|
51
91
|
const keys = docker.credentials[job.agent];
|
|
52
|
-
workerDockerInvocation({ workspace: root, image:
|
|
92
|
+
workerDockerInvocation({ workspace: root, image: resolvedImages[job.agent], command: job.agent, args: [], credentialEnv: keys, hostEnv: process.env, uid, gid });
|
|
53
93
|
}
|
|
54
94
|
const dockerEnv = { PATH: process.env.PATH ?? "/usr/bin:/bin" };
|
|
55
95
|
const dockerCheck = (...args) => execFileSync("docker", args, { env: dockerEnv, stdio: "ignore", timeout: 30000 });
|
|
56
|
-
|
|
96
|
+
// Run each provider CLI through workerDockerInvocation (not a bare `docker run`)
|
|
97
|
+
// so OpenCode's HOME/XDG roots are redirected to a writable tmpfs. A raw
|
|
98
|
+
// invocation fails under `--read-only` with `EROFS: mkdir '/home/node/.local'`.
|
|
99
|
+
const preflightWorkspace = mkdtempSync(join(tmpdir(), "aidlc-worker-preflight-"));
|
|
57
100
|
try {
|
|
58
101
|
dockerCheck("info");
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
102
|
+
for (const [agent, agentImage] of Object.entries(resolvedImages)) {
|
|
103
|
+
const id=execFileSync("docker",["image","inspect","--format","{{.Id}}",agentImage],{env:dockerEnv,encoding:"utf8",stdio:["ignore","pipe","ignore"],timeout:30000}).trim();
|
|
104
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(id)) throw new Error("Docker worker image lacks immutable image ID");
|
|
105
|
+
const invocation = workerDockerInvocation({ workspace: preflightWorkspace, image: agentImage, command: agent, args: ["--version"], credentialEnv: docker.credentials[agent], hostEnv: process.env, uid, gid });
|
|
106
|
+
execFileSync(invocation.command, invocation.args, { env: invocation.env, stdio: "ignore", timeout: 30000 });
|
|
107
|
+
}
|
|
62
108
|
} catch { throw new Error("Docker engine, image, or provider CLI preflight failed"); }
|
|
109
|
+
finally { rmSync(preflightWorkspace, { recursive: true, force: true }); }
|
|
63
110
|
clean(root);
|
|
64
111
|
const base = git(root, "rev-parse", "HEAD").trim();
|
|
65
112
|
const initialBranch = git(root, "symbolic-ref", "--short", "HEAD").trim();
|
|
@@ -83,31 +130,58 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
|
|
|
83
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); }
|
|
84
131
|
throw error;
|
|
85
132
|
}
|
|
86
|
-
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
|
+
};
|
|
87
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}`);
|
|
88
146
|
const controller = new AbortController();
|
|
147
|
+
// An external cancel (daemon shutdown stage 2) aborts every worker turn the
|
|
148
|
+
// same way a lost lease does; the batch then fails and is not integrated.
|
|
149
|
+
const onCancel = () => { renewalError ??= new Error("Worker batch cancelled by daemon shutdown"); controller.abort(); };
|
|
150
|
+
if (signal?.aborted) onCancel(); else signal?.addEventListener("abort", onCancel, { once: true });
|
|
89
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 };
|
|
90
160
|
try {
|
|
91
161
|
const home=mkdtempSync(join(tmpdir(), "aidlc-local-home-")); chmodSync(home,0o700);
|
|
92
162
|
let sessionId, previousFingerprint, stalls=0, result;
|
|
93
163
|
try {
|
|
94
164
|
for (let turn=0; turn<maxTurns; turn++) {
|
|
95
|
-
if (renewalError || controller.signal.aborted) throw renewalError ??
|
|
165
|
+
if (renewalError || controller.signal.aborted) throw renewalError ?? leaseLost();
|
|
96
166
|
const containerName = `aidlc-worker-${randomUUID()}`;
|
|
97
167
|
const spawnProcess = (command, args, options) => {
|
|
98
|
-
const invocation = workerDockerInvocation({ workspace: snapshot, image, command, args, credentialEnv: docker.credentials[job.agent], hostEnv: process.env, uid, gid, containerName, sessionHome:home });
|
|
99
|
-
|
|
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;
|
|
100
172
|
};
|
|
101
|
-
|
|
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); } }); }
|
|
102
176
|
finally {
|
|
103
177
|
try { dockerCheck("rm", "-f", containerName); }
|
|
104
178
|
catch {
|
|
105
|
-
try { dockerCheck("info"); } catch { throw
|
|
106
|
-
try { dockerCheck("inspect", containerName); throw
|
|
107
|
-
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; }
|
|
108
182
|
}
|
|
109
183
|
}
|
|
110
|
-
if (renewalError || controller.signal.aborted) throw renewalError ??
|
|
184
|
+
if (renewalError || controller.signal.aborted) throw renewalError ?? leaseLost();
|
|
111
185
|
sessionId=result.sessionId;
|
|
112
186
|
const changed=inspectWorkerSnapshot(snapshot,baseline,job.ownedPaths);
|
|
113
187
|
if (result.resultText?.split(/\r?\n/).some((line) => line.trim() === "AI-DLC-ITEM-COMPLETE")) break;
|
|
@@ -121,7 +195,7 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
|
|
|
121
195
|
const snapshotChanges = inspectWorkerSnapshot(snapshot, baseline, job.ownedPaths);
|
|
122
196
|
if (!snapshotChanges.length) throw new Error(`Worker ${job.workerId} changed no files`);
|
|
123
197
|
await heartbeat(root, taskId, assignment.id, leaseMs);
|
|
124
|
-
if (renewalError || controller.signal.aborted) throw renewalError ??
|
|
198
|
+
if (renewalError || controller.signal.aborted) throw renewalError ?? leaseLost();
|
|
125
199
|
for (const path of snapshotChanges) {
|
|
126
200
|
const destination = join(workspace, path), from = join(snapshot, path);
|
|
127
201
|
let parent = workspace;
|
|
@@ -136,16 +210,26 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
|
|
|
136
210
|
// Fold any commits made by the agent into one scoped assignment commit.
|
|
137
211
|
git(workspace, "reset", "--soft", base);
|
|
138
212
|
git(workspace, "add", "-A", "--", ...changed);
|
|
139
|
-
if (git(workspace, "diff", "--cached", "--name-only").trim())
|
|
213
|
+
if (git(workspace, "diff", "--cached", "--name-only").trim()) {
|
|
214
|
+
const message = extractCommitMessage(result?.resultText, { itemId: job.itemId, ownedPaths: job.ownedPaths });
|
|
215
|
+
git(workspace, "-c", "user.name=AI-DLC worker", "-c", "user.email=aidlc-worker@localhost", "commit", "-m", message);
|
|
216
|
+
}
|
|
140
217
|
const commit = git(workspace, "rev-parse", "HEAD").trim(), committed = commitPaths(workspace, base, commit);
|
|
141
218
|
if (commit === base || !committed.length || !scoped(committed, job.ownedPaths)) throw new Error("Worker commit is outside assignment scope");
|
|
142
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(", ")}`) });
|
|
143
222
|
return { assignmentId: assignment.id, itemId: job.itemId, workerId: job.workerId, agent: job.agent, baseCommit: base, branch, commit, changedPaths: committed, workspace };
|
|
144
|
-
} finally { clearInterval(timer); }
|
|
223
|
+
} finally { clearInterval(timer); signal?.removeEventListener("abort", onCancel); workerLog?.close(); }
|
|
145
224
|
};
|
|
146
225
|
const results = await Promise.allSettled(prepared.map(execute));
|
|
147
226
|
const failure = results.find((result) => result.status === "rejected");
|
|
148
|
-
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
|
+
}
|
|
149
233
|
const completed = results.map((result) => result.value);
|
|
150
234
|
let expectedHead = base;
|
|
151
235
|
for (const result of completed) {
|
|
@@ -156,7 +240,11 @@ export async function runLocalWorkerBatch({ root: inputRoot, taskId, jobs, workt
|
|
|
156
240
|
git(root, "-c", "user.name=AI-DLC integrator", "-c", "user.email=aidlc-integrator@localhost", "cherry-pick", result.commit);
|
|
157
241
|
result.integrationCommit = git(root, "rev-parse", "HEAD").trim();
|
|
158
242
|
expectedHead = result.integrationCommit;
|
|
159
|
-
|
|
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)}`) });
|
|
160
248
|
}
|
|
161
249
|
return completed;
|
|
162
250
|
}
|