@felixdotgo/aidlc-kit 0.2.0 → 0.3.0

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