@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,376 @@
1
+ // One holder-file primitive for every local loop lease and lock.
2
+ //
3
+ // Vocabulary (shown to users):
4
+ // lock — short mutex held only while a file is read-modified-written.
5
+ // lease — process ownership (the loop daemon, a task's loop run).
6
+ // claim — a worker's ownership of an item/paths (local assignment, fleet job).
7
+ //
8
+ // A holder file is JSON v2:
9
+ // {schemaVersion:2, kind:"lease"|"lock", scope, pid, host, command, purpose,
10
+ // activity, token, startedAt, heartbeatAt, expiresAt}
11
+ // It is live when expiresAt is in the future AND, on the same host, its pid is
12
+ // alive. A holder on another host is judged by TTL only. v1 files (written by
13
+ // older versions) are still read; their liveness is the old pid probe.
14
+ // Reclaims, heartbeats, and releases are serialized by `<path>.guard`.
15
+ import { randomUUID } from "node:crypto";
16
+ import { closeSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
17
+ import { hostname } from "node:os";
18
+ import { basename, dirname } from "node:path";
19
+
20
+ export const LEASE_TTL_MS = 60000;
21
+ export const LEASE_HEARTBEAT_MS = 15000;
22
+ export const LOCK_TTL_MS = 30000;
23
+ export const GUARD_STALE_MS = 10000;
24
+ export const GUARD_WAIT_MS = 5000;
25
+ export const LEASES_COMMAND = "node .agents/aidlc/scripts/loop-entry.mjs leases";
26
+ export const CLEAR_STALE_COMMAND = `${LEASES_COMMAND} --clear-stale`;
27
+
28
+ const KINDS = new Set(["lease", "lock"]);
29
+ const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
30
+ const iso = (ms) => new Date(ms).toISOString();
31
+ const isIso = (value) => typeof value === "string" && !Number.isNaN(Date.parse(value));
32
+ const ignoreMissing = (action) => { try { return action(); } catch (error) { if (error?.code !== "ENOENT") throw error; return undefined; } };
33
+
34
+ export const currentHost = () => hostname();
35
+
36
+ export function pidAlive(pid) {
37
+ try { process.kill(pid, 0); return true; }
38
+ catch (error) { return error?.code !== "ESRCH"; }
39
+ }
40
+
41
+ export function formatAge(ms) {
42
+ if (!Number.isFinite(ms)) return "unknown";
43
+ const seconds = Math.max(0, Math.round(ms / 1000));
44
+ if (seconds < 120) return `${seconds}s`;
45
+ const minutes = Math.round(seconds / 60);
46
+ if (minutes < 120) return `${minutes}m`;
47
+ return `${Math.round(minutes / 60)}h`;
48
+ }
49
+
50
+ function defaultCommand() {
51
+ const script = process.argv[1] ? basename(process.argv[1]) : "node";
52
+ return [script, ...process.argv.slice(2, 4)].join(" ").slice(0, 120);
53
+ }
54
+
55
+ /** Error raised by acquire when a holder blocks the caller. `code` is
56
+ * HOLDER_BUSY (live holder) or HOLDER_CORRUPT (unreadable, recent file). */
57
+ export class HolderError extends Error {
58
+ constructor(message, code, entry) { super(message); this.name = "HolderError"; this.code = code; this.entry = entry; }
59
+ }
60
+
61
+ function validV2(value) {
62
+ return value && value.schemaVersion === 2 && KINDS.has(value.kind) && typeof value.scope === "string" && value.scope
63
+ && Number.isSafeInteger(value.pid) && value.pid > 0 && typeof value.host === "string" && value.host
64
+ && typeof value.token === "string" && value.token && isIso(value.startedAt) && isIso(value.heartbeatAt) && isIso(value.expiresAt)
65
+ && (value.activity === null || value.activity === undefined || typeof value.activity === "string");
66
+ }
67
+
68
+ // Any older holder shape that carries a positive pid and a token (loop lease
69
+ // v1, supervisor lease, daemon lock v1, assignment lock). Liveness is pid-only.
70
+ function validV1(value) {
71
+ return value && value.schemaVersion !== 2 && Number.isSafeInteger(value.pid) && value.pid > 0 && typeof value.token === "string" && value.token;
72
+ }
73
+
74
+ /** Inspect a holder file without changing it.
75
+ * Returns `{state: "absent"|"live"|"stale"|"corrupt", path, holder, version,
76
+ * reason, ageMs, reclaimable}`. `reclaimable` is true for stale holders and for
77
+ * corrupt files older than `ttlMs` (default: lease TTL). */
78
+ export function readHolder(path, { ttlMs = LEASE_TTL_MS, now = Date.now() } = {}) {
79
+ let stat;
80
+ try { stat = lstatSync(path); }
81
+ catch (error) { if (error?.code === "ENOENT") return { state: "absent", path, holder: null, version: null, reason: "no holder file", ageMs: null, reclaimable: false }; throw error; }
82
+ if (stat.isSymbolicLink()) throw new Error(`Holder file is a symlink: ${path}`);
83
+ const ageMs = now - stat.mtimeMs;
84
+ let raw;
85
+ try { raw = readFileSync(path, "utf8"); }
86
+ catch (error) { if (error?.code === "ENOENT") return { state: "absent", path, holder: null, version: null, reason: "no holder file", ageMs: null, reclaimable: false }; throw error; }
87
+ let value;
88
+ try { value = JSON.parse(raw); } catch { value = null; }
89
+ if (validV2(value)) {
90
+ const expired = Date.parse(value.expiresAt) <= now;
91
+ const sameHost = value.host === currentHost();
92
+ const dead = sameHost && !pidAlive(value.pid);
93
+ const reason = expired ? `expired ${formatAge(now - Date.parse(value.expiresAt))} ago` : dead ? `pid ${value.pid} is not running` : "held";
94
+ const state = expired || dead ? "stale" : "live";
95
+ return { state, path, holder: value, version: 2, reason, ageMs, reclaimable: state === "stale" };
96
+ }
97
+ if (validV1(value)) {
98
+ const live = pidAlive(value.pid);
99
+ return { state: live ? "live" : "stale", path, holder: value, version: 1, reason: live ? "held (v1 file, pid probe only)" : `pid ${value.pid} is not running (v1 file)`, ageMs, reclaimable: !live };
100
+ }
101
+ return { state: "corrupt", path, holder: null, version: null, reason: raw.trim() ? "unreadable holder file" : "empty holder file", ageMs, reclaimable: ageMs > ttlMs };
102
+ }
103
+
104
+ /** Human explanation of a `readHolder` entry: `{ summary, remedy }`. */
105
+ export function explainHolder(entry) {
106
+ const holder = entry?.holder;
107
+ if (!entry || entry.state === "absent") return { summary: `No holder at ${entry?.path ?? "(unknown)"}`, remedy: null };
108
+ if (entry.state === "corrupt") {
109
+ return {
110
+ summary: `Corrupt holder file ${entry.path} (modified ${formatAge(entry.ageMs)} ago)`,
111
+ remedy: entry.reclaimable ? `It is older than its TTL: reclaimed automatically on the next acquire, or run: ${CLEAR_STALE_COMMAND}` : `It becomes reclaimable once older than its TTL; if no loop process is running, run: ${CLEAR_STALE_COMMAND} after that`,
112
+ };
113
+ }
114
+ const kind = holder.kind ?? "holder";
115
+ const scope = holder.scope ?? (holder.taskId ? `task:${holder.taskId}` : basename(entry.path));
116
+ const where = holder.host ? ` on ${holder.host}` : "";
117
+ const detail = [holder.command, holder.activity].filter(Boolean).join(", ");
118
+ const since = holder.startedAt ?? holder.createdAt;
119
+ const parts = [`${kind} ${scope} is held by pid ${holder.pid}${where}${detail ? ` (${detail})` : ""}`];
120
+ if (since) parts.push(`since ${typeof since === "number" ? iso(since) : since}`);
121
+ if (holder.expiresAt) parts.push(`expires ${holder.expiresAt}`);
122
+ if (entry.state === "stale") {
123
+ return { summary: `Stale ${parts.join(", ")} — ${entry.reason}`, remedy: `Reclaimed automatically on the next acquire, or run: ${CLEAR_STALE_COMMAND}` };
124
+ }
125
+ return { summary: parts.join(", "), remedy: `Inspect: ${LEASES_COMMAND}` };
126
+ }
127
+
128
+ /** One-line message for a holder that blocks the caller. */
129
+ export function describeHolder(entry) {
130
+ const { summary, remedy } = explainHolder(entry);
131
+ return remedy ? `${summary}. ${remedy}` : summary;
132
+ }
133
+
134
+ // ---------------------------------------------------------------- guard ----
135
+
136
+ /** Inspect a `<path>.guard` file: null when absent, else `{ live, token, ageMs }`. */
137
+ export function readGuard(path, now = Date.now()) { return guardLive(path, now); }
138
+
139
+ function guardLive(path, now) {
140
+ let stat;
141
+ try { stat = lstatSync(path); } catch (error) { if (error?.code === "ENOENT") return null; throw error; }
142
+ const ageMs = now - stat.mtimeMs;
143
+ let pid = null, token = null;
144
+ try {
145
+ const value = JSON.parse(readFileSync(path, "utf8"));
146
+ if (Number.isSafeInteger(value)) pid = value;
147
+ else if (value && Number.isSafeInteger(value.pid)) { pid = value.pid; token = typeof value.token === "string" ? value.token : null; }
148
+ } catch (error) { if (error?.code === "ENOENT") return null; }
149
+ // A guard is live only while its pid runs and it is younger than 10s; an
150
+ // unreadable guard is judged by age alone.
151
+ const live = ageMs < GUARD_STALE_MS && (pid === null || pidAlive(pid));
152
+ return { live, token, ageMs };
153
+ }
154
+
155
+ /** Run `action` while holding `<path>.guard`. Waits up to 5s for a live guard;
156
+ * an abandoned guard (dead pid or older than 10s) is removed. */
157
+ export function withHolderGuard(path, action, { waitMs = GUARD_WAIT_MS } = {}) {
158
+ const guard = `${path}.guard`;
159
+ mkdirSync(dirname(guard), { recursive: true });
160
+ const token = randomUUID();
161
+ const deadline = Date.now() + waitMs;
162
+ for (;;) {
163
+ try {
164
+ const fd = openSync(guard, "wx", 0o600);
165
+ try { writeFileSync(fd, `${JSON.stringify({ pid: process.pid, token, createdAt: new Date().toISOString() })}\n`); }
166
+ finally { closeSync(fd); }
167
+ break;
168
+ } catch (error) {
169
+ if (error?.code !== "EEXIST") throw error;
170
+ const observed = guardLive(guard, Date.now());
171
+ if (!observed) continue;
172
+ if (!observed.live) {
173
+ const again = guardLive(guard, Date.now());
174
+ if (again && !again.live && again.token === observed.token) ignoreMissing(() => unlinkSync(guard));
175
+ continue;
176
+ }
177
+ if (Date.now() >= deadline) throw new Error(`Timed out waiting for holder guard ${guard}; if no loop process is running, run: ${CLEAR_STALE_COMMAND}`);
178
+ sleep(10);
179
+ }
180
+ }
181
+ try { return action(); }
182
+ finally {
183
+ let current = null;
184
+ try { current = JSON.parse(readFileSync(guard, "utf8")); } catch { current = null; }
185
+ if (current?.token === token) ignoreMissing(() => unlinkSync(guard));
186
+ }
187
+ }
188
+
189
+ // ---------------------------------------------------------------- write ----
190
+
191
+ // A complete, fsynced temp file next to `path`; removed again if writing fails.
192
+ function writeTemporary(path, value) {
193
+ const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
194
+ const fd = openSync(temporary, "wx", 0o600);
195
+ try { writeFileSync(fd, `${JSON.stringify(value)}\n`); fsyncSync(fd); }
196
+ catch (error) { closeSync(fd); ignoreMissing(() => unlinkSync(temporary)); throw error; }
197
+ closeSync(fd);
198
+ return temporary;
199
+ }
200
+
201
+ function createExclusive(path, value) {
202
+ // Write a complete temp file, then hard-link it into place so readers never
203
+ // observe a partial holder and an existing holder is never overwritten.
204
+ const temporary = writeTemporary(path, value);
205
+ try { linkSync(temporary, path); }
206
+ catch (error) {
207
+ if (error?.code === "EEXIST") throw error;
208
+ // Filesystems without hard links: fall back to an exclusive create.
209
+ const direct = openSync(path, "wx", 0o600);
210
+ try { writeFileSync(direct, `${JSON.stringify(value)}\n`); fsyncSync(direct); } finally { closeSync(direct); }
211
+ } finally { ignoreMissing(() => unlinkSync(temporary)); }
212
+ }
213
+
214
+ function replaceAtomic(path, value) {
215
+ const temporary = writeTemporary(path, value);
216
+ try { renameSync(temporary, path); }
217
+ catch (error) { ignoreMissing(() => unlinkSync(temporary)); throw error; }
218
+ }
219
+
220
+ function readOwnToken(path) {
221
+ try {
222
+ const value = JSON.parse(readFileSync(path, "utf8"));
223
+ return validV2(value) ? value : null;
224
+ } catch { return null; }
225
+ }
226
+
227
+ /** Remove a stale or old-corrupt holder after re-reading it under the guard.
228
+ * Never removes a live holder. Returns the removed entry or null. */
229
+ export function clearStaleHolder(path, { ttlMs = LEASE_TTL_MS } = {}) {
230
+ return withHolderGuard(path, () => {
231
+ const entry = readHolder(path, { ttlMs });
232
+ if (!entry.reclaimable) return null;
233
+ ignoreMissing(() => unlinkSync(path));
234
+ return entry;
235
+ });
236
+ }
237
+
238
+ /** Acquire a holder file.
239
+ * Options: path, kind ("lease"|"lock"), scope, ttlMs, heartbeatMs (leases),
240
+ * purpose, command, activity, waitMs (keep retrying a live holder), onWarning,
241
+ * onLost(reason) (called once when a heartbeat finds the holder taken over or
242
+ * removed), busyMessage(entry) to customize the HOLDER_BUSY error text.
243
+ * Returns `{ token, path, holder(), heartbeat(), setActivity(a), assertOwned(),
244
+ * release() }`. */
245
+ export function acquireHolder({
246
+ path, kind, scope, ttlMs = kind === "lock" ? LOCK_TTL_MS : LEASE_TTL_MS,
247
+ heartbeatMs = kind === "lease" ? LEASE_HEARTBEAT_MS : 0, purpose = "", command = defaultCommand(),
248
+ activity = null, waitMs = 0, onWarning = () => {}, onLost = () => {}, busyMessage = null,
249
+ }) {
250
+ if (!KINDS.has(kind)) throw new Error(`Invalid holder kind: ${kind}`);
251
+ if (typeof scope !== "string" || !scope) throw new Error("Holder scope is required");
252
+ if (!Number.isSafeInteger(ttlMs) || ttlMs < 1000) throw new Error("Holder ttlMs must be at least 1000");
253
+ mkdirSync(dirname(path), { recursive: true });
254
+ const token = randomUUID();
255
+ const startedAt = new Date().toISOString();
256
+ let current = null;
257
+ let lost = null;
258
+ // The last requested activity. Every renewal writes it, so a setActivity
259
+ // whose write failed (guard timeout) still converges on the next heartbeat.
260
+ let desiredActivity = activity;
261
+ let timer = null;
262
+ const build = (patch = {}) => {
263
+ const now = Date.now();
264
+ return { schemaVersion: 2, kind, scope, pid: process.pid, host: currentHost(), command, purpose, activity, token, startedAt, ...current, ...patch, activity: desiredActivity, heartbeatAt: iso(now), expiresAt: iso(now + ttlMs) };
265
+ };
266
+ const deadline = Date.now() + waitMs;
267
+ for (;;) {
268
+ const outcome = withHolderGuard(path, () => {
269
+ const entry = readHolder(path, { ttlMs });
270
+ if (entry.state === "live") return { blocked: entry };
271
+ if (entry.state === "corrupt" && !entry.reclaimable) {
272
+ throw new HolderError(`Corrupt ${kind} file ${path} (modified ${formatAge(entry.ageMs)} ago); it is reclaimed automatically once older than ${formatAge(ttlMs)}. If no loop process is running, run: ${CLEAR_STALE_COMMAND} after that`, "HOLDER_CORRUPT", entry);
273
+ }
274
+ if (entry.state === "stale" && entry.version === 2 && entry.holder.scope !== scope) throw new Error(`Conflicting holder scope in ${path}: ${entry.holder.scope} (expected ${scope})`);
275
+ if (entry.state !== "absent") {
276
+ ignoreMissing(() => unlinkSync(path));
277
+ onWarning({ type: "holder_reclaimed", path, previous: entry.holder, reason: entry.reason });
278
+ }
279
+ const value = build();
280
+ createExclusive(path, value);
281
+ return { value };
282
+ });
283
+ if (outcome.value) { current = outcome.value; break; }
284
+ if (Date.now() >= deadline) {
285
+ const message = busyMessage ? busyMessage(outcome.blocked) : describeHolder(outcome.blocked);
286
+ throw new HolderError(message, "HOLDER_BUSY", outcome.blocked);
287
+ }
288
+ sleep(10);
289
+ }
290
+
291
+ const lose = (reason) => {
292
+ const first = lost === null;
293
+ lost ??= reason;
294
+ if (timer) { clearInterval(timer); timer = null; }
295
+ if (first && reason !== "released") { try { onLost(reason); } catch (error) { onWarning({ type: "holder_lost_callback_failed", path, error: error.message }); } }
296
+ };
297
+ const renew = (patch) => withHolderGuard(path, () => {
298
+ const onDisk = readOwnToken(path);
299
+ if (!onDisk || onDisk.token !== token || onDisk.pid !== process.pid) {
300
+ const entry = readHolder(path, { ttlMs });
301
+ lose(entry.state === "absent" ? `${kind} file ${path} was removed` : `${kind} ${scope} was taken over: ${describeHolder(entry)}`);
302
+ return false;
303
+ }
304
+ const next = build(patch);
305
+ replaceAtomic(path, next);
306
+ current = next;
307
+ return true;
308
+ });
309
+ const handle = {
310
+ token,
311
+ path,
312
+ holder: () => current,
313
+ heartbeat() {
314
+ if (lost) return false;
315
+ try { return renew(); }
316
+ catch (error) { onWarning({ type: "holder_heartbeat_failed", path, error: error.message }); return false; }
317
+ },
318
+ setActivity(next) {
319
+ handle.assertOwned();
320
+ desiredActivity = next;
321
+ if (!renew()) handle.assertOwned();
322
+ },
323
+ assertOwned() {
324
+ if (lost) throw new Error(`${kind} ownership lost: ${lost}`);
325
+ const onDisk = readOwnToken(path);
326
+ if (!onDisk || onDisk.token !== token || onDisk.pid !== process.pid) {
327
+ const entry = readHolder(path, { ttlMs });
328
+ lose(entry.state === "absent" ? `${kind} file ${path} was removed` : `${kind} ${scope} was taken over: ${describeHolder(entry)}`);
329
+ throw new Error(`${kind} ownership lost: ${lost}`);
330
+ }
331
+ // An expired holder can be reclaimed by anyone; renew under the guard
332
+ // (which also re-checks the token) before allowing a mutation.
333
+ if (Date.parse(onDisk.expiresAt) <= Date.now() && !renew()) throw new Error(`${kind} ownership lost: ${lost}`);
334
+ },
335
+ release() {
336
+ if (timer) { clearInterval(timer); timer = null; }
337
+ if (lost === "released") return;
338
+ try {
339
+ withHolderGuard(path, () => {
340
+ if (readOwnToken(path)?.token === token) ignoreMissing(() => unlinkSync(path));
341
+ });
342
+ } finally { lost = "released"; }
343
+ },
344
+ };
345
+ if (heartbeatMs > 0) {
346
+ timer = setInterval(() => { handle.heartbeat(); }, heartbeatMs);
347
+ timer.unref?.();
348
+ }
349
+ return handle;
350
+ }
351
+
352
+ // ---------------------------------------------------------------- fleet ----
353
+
354
+ const FLEET_EXPLANATIONS = [
355
+ [/stale or expired lead claim/, "the lead lease (run_claim) is no longer valid: the `loop start` process on the lead machine stopped, lost its connection, or another lead took over; restart the loop on the lead machine"],
356
+ [/stale or expired bound supervisor claim|stale or expired supervisor claim/, "the fleet supervisor lease expired or was taken over; only one `loop start` may supervise a task, restart it on one machine"],
357
+ [/supervisor or prior lead claim is held/, "another `loop start` still holds the supervisor or lead lease for this task; stop it or wait for its lease to expire (5 minutes without heartbeat)"],
358
+ [/lead claim is held/, "another lead holds the lead lease (run_claim) for this task; stop it or wait for its lease to expire (5 minutes without heartbeat)"],
359
+ [/stale or expired job claim/, "this worker's job claim expired or was reassigned; its result is not accepted, the lead must reconcile the job"],
360
+ [/expired job lease requires lead reconciliation|job requires lead reconciliation/, "the job claim expired before the worker reported; the lead must reconcile the job before it can be retried"],
361
+ [/job lease is held/, "a worker still holds the job claim for this item; wait for it to report or for its claim to expire"],
362
+ [/job lead lease is stale/, "the job was created under an older lead lease; the current lead must reconcile it"],
363
+ [/item already has an active job/, "a job claim for this item is already active; reconcile it before creating another"],
364
+ ];
365
+
366
+ /** Wrap a coordination-service FleetConflict message with an explanation and
367
+ * an inspect command, keeping the original text in parentheses. Unknown
368
+ * messages are returned unchanged. */
369
+ export function explainFleetConflict(message) {
370
+ const text = String(message ?? "");
371
+ if (text.includes(`Inspect: ${LEASES_COMMAND}`)) return text;
372
+ for (const [pattern, explanation] of FLEET_EXPLANATIONS) {
373
+ if (pattern.test(text)) return `Fleet conflict: ${explanation}. Inspect: ${LEASES_COMMAND} (${text})`;
374
+ }
375
+ return text;
376
+ }
@@ -1,12 +1,34 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
+ import { ensureTaskRuntimeDir, resolveTaskRuntimeRead } from "./task-runtime.mjs";
5
+ import { migrateTaskRuntime } from "./task-runtime-migrate.mjs";
4
6
 
5
7
  const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
6
8
  const digest = (value) => createHash("sha256").update(value).digest("hex");
7
9
 
10
+ const firstLine = (text) => {
11
+ if (typeof text !== "string") return "";
12
+ return text.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0) ?? "";
13
+ };
14
+
15
+ const slugify = (text) => {
16
+ const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+/, "").slice(0, 127).replace(/-+$/, "");
17
+ return slug || "loop-task";
18
+ };
19
+
20
+ /** Derive a memorable task id (kebab-case slug) and a descriptive title from the
21
+ * requirement's first non-empty line, replacing the opaque `loop-<uuid>` id and
22
+ * the unhelpful `Loop task <id>` title. */
23
+ export function deriveTaskIdentity(requirement) {
24
+ // The dashboard editor writes Markdown: drop a leading heading/list/quote marker,
25
+ // bold markers, and backticks ("__" stays: it is common in identifiers) so "# Fix login" titles the task "Fix login".
26
+ const line = firstLine(requirement).replace(/^(?:#{1,6}(?:\s+|$)|[-*+]\s+|\d+[.)]\s+|>\s*)/, "").replace(/(\*\*|`)/g, "").trim();
27
+ return { slug: slugify(line), title: line ? line.slice(0, 256) : "Loop task" };
28
+ }
29
+
8
30
  /** Prepare a durable, idempotent intake without treating it as lifecycle state. */
9
- export async function prepareLoopIntake({ root, taskId, requirement, authority, createTask, readTask }) {
31
+ export async function prepareLoopIntake({ root, taskId, requirement, authority, createTask, readTask, title }) {
10
32
  if (!ID.test(taskId ?? "")) throw new Error("Invalid task ID");
11
33
  if (!["local", "fleet"].includes(authority)) throw new Error("Invalid loop authority");
12
34
  if (typeof requirement !== "string" || !requirement.trim() || Buffer.byteLength(requirement) > 8192) throw new Error("Requirement must contain 1–8192 bytes");
@@ -21,7 +43,11 @@ export async function prepareLoopIntake({ root, taskId, requirement, authority,
21
43
  if (!existsSync(directory)) mkdirSync(directory, { mode: 0o700 });
22
44
  const stat = lstatSync(directory);
23
45
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Unsafe task directory");
24
- const recordPath = join(directory, "loop-intake.json");
46
+ // Intake files live under runtime/ (the task directory root is for review
47
+ // artifacts); a pre-runtime loop-intake.json/requirement.md moves there first.
48
+ migrateTaskRuntime(project, taskId, { names: ["intake.json", "requirement.md"] });
49
+ ensureTaskRuntimeDir(project, taskId);
50
+ const recordPath = resolveTaskRuntimeRead(project, taskId, "intake.json");
25
51
  const record = { schemaVersion: 1, taskId, authority, requirementHash: digest(requirement) };
26
52
  if (!existsSync(recordPath) && await readTask(taskId)) throw new Error("Existing task has no matching loop intake record");
27
53
  try { writeFileSync(recordPath, `${JSON.stringify(record)}\n`, { flag: "wx", mode: 0o600 }); }
@@ -32,20 +58,20 @@ export async function prepareLoopIntake({ root, taskId, requirement, authority,
32
58
  let current; try { current = JSON.parse(readFileSync(recordPath, "utf8")); } catch { throw new Error("Invalid intake record"); }
33
59
  if (JSON.stringify(current) !== JSON.stringify(record)) throw new Error("Existing intake has different authority or requirement");
34
60
  }
35
- const requirementPath = join(directory, "requirement.md");
61
+ const requirementPath = resolveTaskRuntimeRead(project, taskId, "requirement.md");
36
62
  try { writeFileSync(requirementPath, requirement, { flag: "wx", mode: 0o600 }); }
37
63
  catch (error) {
38
64
  if (error?.code !== "EEXIST") throw error;
39
65
  const existing = lstatSync(requirementPath);
40
66
  if (!existing.isFile() || existing.isSymbolicLink() || digest(readFileSync(requirementPath, "utf8")) !== record.requirementHash) throw new Error("Existing requirement does not match intake");
41
67
  }
42
- const title = `Loop task ${taskId}`;
68
+ const titleText = typeof title === "string" && title.trim() ? title.trim().slice(0, 256) : deriveTaskIdentity(requirement).title;
43
69
  let task = await readTask(taskId);
44
70
  if (!task) {
45
- try { await createTask({ taskId, title }); }
46
- catch (error) { task = await readTask(taskId); if (!task || task.title !== title) throw error; }
71
+ try { await createTask({ taskId, title: titleText }); }
72
+ catch (error) { task = await readTask(taskId); if (!task || task.title !== titleText) throw error; }
47
73
  task ??= await readTask(taskId);
48
74
  }
49
- if (!task || task.id !== taskId || task.title !== title) throw new Error("Existing task conflicts with intake");
75
+ if (!task || task.id !== taskId || task.title !== titleText) throw new Error("Existing task conflicts with intake");
50
76
  return { taskId, authority, requirementPath };
51
77
  }
@@ -1,9 +1,8 @@
1
- import { randomUUID } from "node:crypto";
2
- import { closeSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
- import { dirname, join, resolve } from "node:path";
1
+ import { lstatSync, unlinkSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { acquireHolder, clearStaleHolder, describeHolder, GUARD_STALE_MS, LEASE_TTL_MS, readHolder } from "./loop-holder.mjs";
4
4
 
5
5
  const idPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
6
- const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
7
6
  export function assertLoopPath(root, taskId, suffix = ".json") {
8
7
  if (!idPattern.test(taskId)) throw new Error(`Invalid task id: ${taskId}`);
9
8
  const base = resolve(root);
@@ -17,73 +16,80 @@ export function assertLoopPath(root, taskId, suffix = ".json") {
17
16
  }
18
17
 
19
18
  export const loopLeasePath = (root, taskId) => assertLoopPath(root, taskId, ".lease");
20
- const guardPath = (root, taskId) => assertLoopPath(root, taskId, ".lease.guard");
21
19
 
22
- function parseLease(path) {
23
- let value;
24
- try { value = JSON.parse(readFileSync(path, "utf8")); }
25
- catch (error) { throw new Error(`Corrupt loop lease: ${path}: ${error.message}`); }
26
- if (!value || value.schemaVersion !== 1 || !idPattern.test(value.taskId) || typeof value.runId !== "string" || !value.runId || !Number.isInteger(value.pid) || value.pid < 1 || typeof value.token !== "string" || !value.token || typeof value.createdAt !== "string" || Number.isNaN(Date.parse(value.createdAt))) throw new Error(`Corrupt loop lease: ${path}`);
27
- return value;
20
+ // Task lease handles held by this process, so a supervisor's lease is shared
21
+ // with the runLoop it drives instead of being acquired twice.
22
+ const held = new Map();
23
+ const heldKey = (root, taskId) => `${resolve(root)}\0${taskId}`;
24
+
25
+ function checkScope(entry, taskId) {
26
+ const holder = entry.holder;
27
+ if (!holder) return;
28
+ const scope = entry.version === 2 ? holder.scope : holder.taskId ? `task:${holder.taskId}` : null;
29
+ if (scope && scope !== `task:${taskId}`) throw new Error(`Conflicting loop lease in ${entry.path}: ${scope}`);
30
+ }
31
+
32
+ /** Inspect the task lease (`loop/<task>.lease`) without changing it; see
33
+ * `readHolder` in loop-holder.mjs. */
34
+ export function readTaskLease(root, taskId) {
35
+ const entry = readHolder(loopLeasePath(root, taskId), { ttlMs: LEASE_TTL_MS });
36
+ checkScope(entry, taskId);
37
+ return entry;
28
38
  }
29
39
 
30
- function alive(pid) {
31
- try { process.kill(pid, 0); return true; }
32
- catch (error) { if (error?.code === "ESRCH") return false; return true; }
40
+ /** The live task lease holder while a loop turn runs (activity "turn"), else
41
+ * null. A supervisor waiting at a human gate (activity "waiting") does not
42
+ * count, so gate approval stays available. A v1 runner lease counts when its
43
+ * pid is alive. A corrupt lease fails closed. */
44
+ export function readLiveLease(root, taskId) {
45
+ const entry = readTaskLease(root, taskId);
46
+ if (entry.state === "corrupt") throw new Error(`Corrupt loop lease: ${describeHolder(entry)}`);
47
+ if (entry.state !== "live") return null;
48
+ if (entry.version === 2 && entry.holder.activity !== "turn") return null;
49
+ return entry.holder;
33
50
  }
34
51
 
35
- function withGuard(root, taskId, action) {
36
- const path = guardPath(root, taskId);
37
- mkdirSync(dirname(path), { recursive: true });
38
- const guard = { schemaVersion: 1, taskId, runId: "guard", pid: process.pid, token: randomUUID(), createdAt: new Date().toISOString() };
39
- const deadline = Date.now() + 5000;
40
- for (;;) {
41
- try {
42
- const fd = openSync(path, "wx", 0o600);
43
- try { writeFileSync(fd, `${JSON.stringify(guard)}\n`); fsyncSync(fd); } finally { closeSync(fd); }
44
- break;
45
- } catch (error) {
46
- if (error?.code !== "EEXIST") throw error;
47
- const current = parseLease(path);
48
- if (current.taskId !== taskId) throw new Error(`Conflicting loop lease guard: ${path}`);
49
- if (!alive(current.pid)) { try { if (parseLease(path).token === current.token) unlinkSync(path); } catch (unlinkError) { if (unlinkError?.code !== "ENOENT") throw unlinkError; } continue; }
50
- if (Date.now() >= deadline) throw new Error(`Timed out waiting for loop lease guard: ${taskId}`);
51
- sleep(10);
52
- }
52
+ // Older versions kept a separate `<task>.supervisor.lease` (+ `.supervisor.guard`).
53
+ function migrateLegacySupervisorLease(root, taskId) {
54
+ const legacy = assertLoopPath(root, taskId, ".supervisor.lease");
55
+ const entry = readHolder(legacy, { ttlMs: LEASE_TTL_MS });
56
+ if (entry.state === "live") throw new Error(`A loop from a previous aidlc-kit version is still running for ${taskId} (pid ${entry.holder.pid}, ${legacy}); stop the loop daemon and any \`loop start\` before upgrading, then retry`);
57
+ if (entry.state !== "absent") {
58
+ const cleared = clearStaleHolder(legacy, { ttlMs: LEASE_TTL_MS });
59
+ if (!cleared && readHolder(legacy, { ttlMs: LEASE_TTL_MS }).state !== "absent") throw new Error(`${describeHolder(entry)} (legacy supervisor lease)`);
53
60
  }
54
- try { return action(); }
55
- finally { if (parseLease(path).token === guard.token) unlinkSync(path); }
61
+ const legacyGuard = assertLoopPath(root, taskId, ".supervisor.guard");
62
+ try { if (Date.now() - lstatSync(legacyGuard).mtimeMs > GUARD_STALE_MS) unlinkSync(legacyGuard); }
63
+ catch (error) { if (error?.code !== "ENOENT") throw error; }
56
64
  }
57
65
 
58
- export function readLiveLease(root, taskId) {
66
+ /** Acquire the task lease. `activity` is "waiting" (supervisor between runs,
67
+ * human may approve gates) or "turn" (a loop run is executing; gate approval is
68
+ * refused). `label` names the caller in the busy error. Returns the holder
69
+ * handle (`assertOwned`, `setActivity`, `heartbeat`, `release`). */
70
+ export function acquireTaskLease(root, taskId, { activity = "waiting", label = "loop", purpose = `loop ${activity}`, onWarning } = {}) {
59
71
  const path = loopLeasePath(root, taskId);
60
- try { lstatSync(path); } catch (error) { if (error?.code === "ENOENT") return null; throw error; }
61
- const lease = parseLease(path);
62
- if (lease.taskId !== taskId) throw new Error(`Conflicting loop lease: ${path}`);
63
- return alive(lease.pid) ? lease : null;
72
+ const key = heldKey(root, taskId);
73
+ if (held.has(key)) throw new Error(`Task ${taskId} already has a live ${label} in this process: ${describeHolder(readTaskLease(root, taskId))}`);
74
+ migrateLegacySupervisorLease(root, taskId);
75
+ const handle = acquireHolder({
76
+ path, kind: "lease", scope: `task:${taskId}`, activity, purpose, onWarning,
77
+ busyMessage: (entry) => `Task ${taskId} already has a live ${label} (pid ${entry.holder.pid}): ${describeHolder(entry)}`,
78
+ });
79
+ const release = handle.release;
80
+ handle.release = () => { if (held.get(key) === handle) held.delete(key); release(); };
81
+ held.set(key, handle);
82
+ return handle;
64
83
  }
65
84
 
85
+ /** The task lease handle this process already holds, or null. */
86
+ export function heldTaskLease(root, taskId) {
87
+ return held.get(heldKey(root, taskId)) ?? null;
88
+ }
89
+
90
+ /** Compatibility wrapper: acquire the task lease for a loop run (activity
91
+ * "turn") and return its release function. */
66
92
  export function acquireLoopLease(root, taskId, runId) {
67
- const path = loopLeasePath(root, taskId);
68
- mkdirSync(dirname(path), { recursive: true });
69
- const lease = { schemaVersion: 1, taskId, runId, pid: process.pid, token: randomUUID(), createdAt: new Date().toISOString() };
70
- return withGuard(root, taskId, () => {
71
- for (let attempt = 0; attempt < 4; attempt++) {
72
- try {
73
- const fd = openSync(path, "wx", 0o600);
74
- try { writeFileSync(fd, `${JSON.stringify(lease)}\n`); fsyncSync(fd); }
75
- finally { closeSync(fd); }
76
- return () => withGuard(root, taskId, () => { try { if (parseLease(path).token === lease.token) unlinkSync(path); } catch (error) { if (error?.code !== "ENOENT") throw error; } });
77
- } catch (error) {
78
- if (error?.code !== "EEXIST") throw error;
79
- const existing = parseLease(path);
80
- if (existing.taskId !== taskId) throw new Error(`Conflicting loop lease: ${path}`);
81
- if (alive(existing.pid)) throw new Error(`Task ${taskId} already has a live loop runner (pid ${existing.pid})`);
82
- // A dead owner may be reclaimed. A competing reclaimer may win; retry with wx.
83
- try { if (parseLease(path).token === existing.token) unlinkSync(path); }
84
- catch (unlinkError) { if (unlinkError?.code !== "ENOENT") throw unlinkError; }
85
- }
86
- }
87
- throw new Error(`Could not acquire loop lease for ${taskId}`);
88
- });
93
+ const handle = acquireTaskLease(root, taskId, { activity: "turn", label: "loop runner", purpose: `loop run ${runId}` });
94
+ return () => handle.release();
89
95
  }