@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.
Files changed (198) hide show
  1. package/DEVELOPMENT.md +14 -13
  2. package/README.md +37 -43
  3. package/dist/assets/.agents/aidlc/docker/Dockerfile.claude +7 -0
  4. package/dist/assets/.agents/aidlc/docker/Dockerfile.codex +9 -0
  5. package/dist/assets/.agents/aidlc/docker/Dockerfile.opencode +8 -0
  6. package/dist/assets/.agents/aidlc/orchestrator.md +2 -2
  7. package/dist/assets/.agents/aidlc/phase-clarify.md +1 -1
  8. package/dist/assets/.agents/aidlc/phase-plan.md +2 -2
  9. package/dist/assets/.agents/aidlc/schemas/config.schema.json +2 -1
  10. package/dist/assets/.agents/aidlc/schemas/state.schema.json +2 -0
  11. package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +94 -48
  12. package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +5 -4
  13. package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +261 -0
  14. package/dist/assets/.agents/aidlc/scripts/lib/loop-commit-message.mjs +45 -0
  15. package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +206 -0
  16. package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +100 -14
  17. package/dist/assets/.agents/aidlc/scripts/lib/loop-env.mjs +279 -0
  18. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +105 -32
  19. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +17 -1
  20. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +24 -7
  21. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +42 -16
  22. package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +20 -6
  23. package/dist/assets/.agents/aidlc/scripts/lib/loop-holder.mjs +376 -0
  24. package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +33 -7
  25. package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +67 -61
  26. package/dist/assets/.agents/aidlc/scripts/lib/loop-leases.mjs +268 -0
  27. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +116 -28
  28. package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +114 -12
  29. package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +117 -0
  30. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +16 -56
  31. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +149 -6
  32. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +9 -1
  33. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +44 -6
  34. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-image.mjs +64 -0
  35. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-log.mjs +158 -0
  36. package/dist/assets/.agents/aidlc/scripts/lib/repo-head.mjs +22 -0
  37. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +68 -2
  38. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime-migrate.mjs +75 -0
  39. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime.mjs +97 -0
  40. package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +168 -73
  41. package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +19 -7
  42. package/dist/assets/.agents/aidlc/scripts/loop.cmd +101 -0
  43. package/dist/assets/.agents/aidlc/scripts/loop.mjs +2 -1
  44. package/dist/assets/.agents/aidlc/scripts/loop.sh +71 -0
  45. package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +10 -4
  46. package/dist/assets/.agents/aidlc/scripts/state.mjs +16 -8
  47. package/dist/assets/.agents/aidlc/templates/design.md +9 -0
  48. package/dist/assets/.agents/aidlc/templates/intent.md +4 -0
  49. package/dist/assets/.agents/aidlc/templates/workplan.md +1 -1
  50. package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +1 -1
  51. package/dist/assets/ui/bulma.min.css +3 -0
  52. package/dist/assets/ui/changes.js +83 -0
  53. package/dist/assets/ui/common.js +664 -0
  54. package/dist/assets/ui/dashboard.css +636 -0
  55. package/dist/assets/ui/dashboard.js +77 -0
  56. package/dist/assets/ui/detail.js +159 -0
  57. package/dist/assets/ui/live-log.js +332 -0
  58. package/dist/services/aidlc-coordination/Dockerfile +11 -3
  59. package/dist/services/aidlc-coordination/compose.yaml +12 -0
  60. package/dist/services/aidlc-coordination/package-lock.json +3 -1
  61. package/dist/services/aidlc-coordination/package.json +9 -4
  62. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +68 -2
  63. package/dist/services/aidlc-coordination/src/repository.mjs +66 -0
  64. package/dist/services/aidlc-coordination/src/server.mjs +9 -3
  65. package/dist/services/aidlc-coordination/test/fleet.test.mjs +21 -0
  66. package/dist/services/aidlc-coordination/test/server-health-auth.test.mjs +67 -0
  67. package/dist/src/adapters.d.ts +0 -3
  68. package/dist/src/adapters.js +3 -5
  69. package/dist/src/cli/argv.d.ts +12 -0
  70. package/dist/src/cli/argv.js +68 -0
  71. package/dist/src/cli/commands.d.ts +8 -0
  72. package/dist/src/cli/commands.js +159 -0
  73. package/dist/src/cli/loop-launcher.d.ts +47 -0
  74. package/dist/src/cli/loop-launcher.js +322 -0
  75. package/dist/src/cli/options.d.ts +25 -0
  76. package/dist/src/cli/options.js +32 -0
  77. package/dist/src/cli/prompt.d.ts +9 -0
  78. package/dist/src/cli/prompt.js +66 -0
  79. package/dist/src/cli.js +28 -191
  80. package/dist/src/gate.js +3 -2
  81. package/dist/src/installer.d.ts +14 -7
  82. package/dist/src/installer.js +42 -143
  83. package/dist/src/legacy.js +1 -0
  84. package/dist/src/managed-content.d.ts +7 -0
  85. package/dist/src/managed-content.js +15 -4
  86. package/dist/src/managed-planning.d.ts +65 -0
  87. package/dist/src/managed-planning.js +219 -0
  88. package/dist/src/mcp-setup.js +3 -1
  89. package/dist/src/model/config-contracts.d.ts +65 -0
  90. package/dist/src/model/config-contracts.js +1 -0
  91. package/dist/src/model/install-contracts.d.ts +29 -0
  92. package/dist/src/model/install-contracts.js +1 -0
  93. package/dist/src/model/lifecycle-enums.d.ts +13 -0
  94. package/dist/src/model/lifecycle-enums.js +1 -0
  95. package/dist/src/model/loop.d.ts +20 -0
  96. package/dist/src/model/loop.js +1 -0
  97. package/dist/src/model/manifest.d.ts +16 -0
  98. package/dist/src/model/manifest.js +1 -0
  99. package/dist/src/model/records.d.ts +142 -0
  100. package/dist/src/model/records.js +1 -0
  101. package/dist/src/model.d.ts +11 -264
  102. package/dist/src/model.js +1 -1
  103. package/dist/src/profiles.d.ts +2 -0
  104. package/dist/src/profiles.js +53 -9
  105. package/dist/src/state/archive-record.d.ts +9 -0
  106. package/dist/src/state/archive-record.js +26 -0
  107. package/dist/src/state/commands.d.ts +18 -0
  108. package/dist/src/state/commands.js +27 -0
  109. package/dist/src/state/domain.d.ts +9 -0
  110. package/dist/src/state/domain.js +9 -0
  111. package/dist/src/state/guards.d.ts +7 -0
  112. package/dist/src/state/guards.js +17 -0
  113. package/dist/src/state/io.d.ts +10 -0
  114. package/dist/src/state/io.js +35 -0
  115. package/dist/src/state/items.d.ts +3 -4
  116. package/dist/src/state/items.js +58 -57
  117. package/dist/src/state/lessons.js +14 -7
  118. package/dist/src/state/lifecycle.js +2 -2
  119. package/dist/src/state/lock.js +7 -6
  120. package/dist/src/state/memory.js +24 -12
  121. package/dist/src/state/{archive.js → migrate.js} +7 -7
  122. package/dist/src/state/partition.d.ts +15 -0
  123. package/dist/src/state/partition.js +90 -0
  124. package/dist/src/state/paths.d.ts +11 -0
  125. package/dist/src/state/paths.js +12 -0
  126. package/dist/src/state/render.js +9 -6
  127. package/dist/src/state/store.js +9 -15
  128. package/dist/src/state/transitions.js +9 -7
  129. package/dist/src/state/validate.js +55 -7
  130. package/dist/src/state.d.ts +16 -11
  131. package/dist/src/state.js +16 -11
  132. package/dist/src/task-ui.d.ts +33 -0
  133. package/dist/src/task-ui.js +33 -0
  134. package/dist/src/ui/assets.d.ts +14 -0
  135. package/dist/src/ui/assets.js +45 -0
  136. package/dist/src/ui/changes.d.ts +83 -0
  137. package/dist/src/ui/changes.js +498 -0
  138. package/dist/src/ui/containers.d.ts +71 -0
  139. package/dist/src/ui/containers.js +214 -0
  140. package/dist/src/ui/daemon.d.ts +75 -0
  141. package/dist/src/ui/daemon.js +232 -0
  142. package/dist/src/ui/data.d.ts +42 -0
  143. package/dist/src/ui/data.js +89 -0
  144. package/dist/src/ui/docs.d.ts +13 -0
  145. package/dist/src/ui/docs.js +39 -0
  146. package/dist/src/ui/files.d.ts +3 -0
  147. package/dist/src/ui/files.js +20 -0
  148. package/dist/src/ui/http.d.ts +14 -0
  149. package/dist/src/ui/http.js +80 -0
  150. package/dist/src/ui/live.d.ts +5 -0
  151. package/dist/src/ui/live.js +43 -0
  152. package/dist/src/ui/markdown.d.ts +2 -0
  153. package/dist/src/ui/markdown.js +127 -0
  154. package/dist/src/ui/options.d.ts +48 -0
  155. package/dist/src/ui/options.js +81 -0
  156. package/dist/src/ui/security.d.ts +34 -0
  157. package/dist/src/ui/security.js +38 -0
  158. package/dist/src/ui/server.d.ts +39 -0
  159. package/dist/src/ui/server.js +347 -0
  160. package/dist/src/ui/sse.d.ts +14 -0
  161. package/dist/src/ui/sse.js +281 -0
  162. package/dist/src/ui/views/board.d.ts +7 -0
  163. package/dist/src/ui/views/board.js +131 -0
  164. package/dist/src/ui/views/changes.d.ts +19 -0
  165. package/dist/src/ui/views/changes.js +154 -0
  166. package/dist/src/ui/views/components.d.ts +56 -0
  167. package/dist/src/ui/views/components.js +113 -0
  168. package/dist/src/ui/views/docs.d.ts +15 -0
  169. package/dist/src/ui/views/docs.js +64 -0
  170. package/dist/src/ui/views/layout.d.ts +44 -0
  171. package/dist/src/ui/views/layout.js +233 -0
  172. package/dist/src/ui/views/task.d.ts +25 -0
  173. package/dist/src/ui/views/task.js +320 -0
  174. package/dist/src/upgrade-board.js +10 -10
  175. package/dist/src/upgrade.d.ts +0 -1
  176. package/dist/src/upgrade.js +24 -85
  177. package/dist/src/workflow.d.ts +4 -0
  178. package/dist/src/workflow.js +36 -4
  179. package/docs/README.md +41 -22
  180. package/docs/architecture.md +101 -37
  181. package/docs/command-reference.md +289 -52
  182. package/docs/configuration.md +70 -30
  183. package/docs/coordination-service.md +91 -30
  184. package/docs/design-decisions.md +87 -0
  185. package/docs/development.md +124 -49
  186. package/docs/loop-local-run.md +228 -0
  187. package/docs/loop-runbook.md +214 -0
  188. package/docs/operating-workflow.md +55 -42
  189. package/docs/testing-and-release.md +72 -49
  190. package/docs/troubleshooting.md +221 -0
  191. package/package.json +4 -1
  192. package/dist/src/ledger.d.ts +0 -22
  193. package/dist/src/ledger.js +0 -119
  194. package/dist/src/state/shared.d.ts +0 -29
  195. package/dist/src/state/shared.js +0 -71
  196. package/dist/src/tracker.d.ts +0 -36
  197. package/dist/src/tracker.js +0 -44
  198. /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 { OPENCODE_CONTAINER_ERROR, workerDockerInvocation } from "./loop-worker-docker.mjs";
8
- import { isLoopProvider, runAgentTurn } from "./loop-drivers.mjs";
9
- import { claimLocalAssignment, heartbeatLocalAssignment, reportLocalAssignment, releaseLocalAssignment } from "./assignment-store.mjs";
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 deliberately left for reconciliation, not auto-retried.
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) => overlaps(path, prior))) throw new Error(`Overlapping assignment scope: ${path}`); scopes.push(path); }
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
- if (jobs.some((job) => job.agent === "opencode")) throw new Error(OPENCODE_CONTAINER_ERROR);
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: docker.image, command: job.agent, args: [], credentialEnv: keys, hostEnv: process.env, uid, gid });
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
- let image;
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
- image=execFileSync("docker",["image","inspect","--format","{{.Id}}",docker.image],{env:dockerEnv,encoding:"utf8",stdio:["ignore","pipe","ignore"],timeout:30000}).trim();
60
- if (!/^sha256:[a-f0-9]{64}$/.test(image)) throw new Error("Docker worker image lacks immutable image ID");
61
- for (const agent of new Set(jobs.map((job) => job.agent))) dockerCheck("run", "--rm", "--network", "none", "--read-only", "--user", `${uid}:${gid}`, "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--entrypoint", agent, image, "--version");
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 ({ job, assignment, workspace, snapshot, baseline, branch }) => {
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 ?? new Error("Worker lease lost");
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
- return spawn(invocation.command, invocation.args, { ...options, cwd: snapshot, env: invocation.env, shell: false });
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
- try { result = await runAgentTurn({ agent: job.agent, root: "/workspace", sessionId, spawnProcess, prompt: `${job.prompt}\n\nOnly modify assigned paths: ${job.ownedPaths.join(", ")}. Do not modify AI-DLC lifecycle state or approve gates. End with AI-DLC-ITEM-COMPLETE on its own line.`, timeoutMs, signal: controller.signal, onSessionId: async (id) => { await heartbeat(root, taskId, assignment.id, leaseMs, id); } }); }
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 new Error("Docker cleanup uncertain: engine unavailable"); }
106
- try { dockerCheck("inspect", containerName); throw new Error("Docker cleanup uncertain: container remains"); }
107
- catch (error) { if (error.message === "Docker cleanup uncertain: container remains") throw 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 ?? new Error("Worker lease lost");
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 ?? new Error("Worker lease lost");
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()) git(workspace, "-c", "user.name=AI-DLC worker", "-c", "user.email=aidlc-worker@localhost", "commit", "-m", `AI-DLC ${taskId}/${job.itemId}`);
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) throw new Error(`Local worker batch requires reconciliation: ${failure.reason instanceof Error ? failure.reason.message : String(failure.reason)}`);
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
- await onEvent({ type: "job_integrated", taskId, itemId: result.itemId, assignmentId: result.assignmentId, commit: result.integrationCommit });
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
  }