@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
@@ -6,11 +6,15 @@ 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
- const states = new Set(["running", "gate", "paused", "blocked", "terminal", "complete"]);
17
+ const states = new Set(["running", "gate", "paused", "blocked", "terminal", "complete", "stopped"]);
14
18
  const hash = (value) => createHash("sha256").update(value).digest("hex");
15
19
  const iso = () => new Date().toISOString();
16
20
  const bounded = (value, name, max) => { const n = Number(value); if (!Number.isSafeInteger(n) || n < 1 || n > max) throw new Error(`${name} must be an integer from 1 to ${max}`); return n; };
@@ -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 {
@@ -152,9 +160,31 @@ export const classifyAction = (action) => {
152
160
  };
153
161
 
154
162
  function approvalCount(task) { return task.evidence.filter((item) => item.kind === "approval" && item.result === "pass").length; }
163
+ function pendingFeedback(task) {
164
+ const evidence = Array.isArray(task?.evidence) ? task.evidence : [];
165
+ const pending = [];
166
+ for (let i = 0; i < evidence.length; i++) {
167
+ const entry = evidence[i];
168
+ if (entry?.kind !== "diagnostic" || typeof entry.detail !== "string") continue;
169
+ const match = entry.detail.match(/^Cancelled gate wait at (G0_confirm|G1_review|G2_codereview): /);
170
+ if (!match) continue;
171
+ const approvedAfter = evidence.slice(i + 1).some((later) => later?.kind === "approval" && later.result === "pass" && later.gate === match[1]);
172
+ if (!approvedAfter) pending.push(entry.detail);
173
+ }
174
+ return pending;
175
+ }
155
176
  function outputStop(record, action, gateView) { return { taskId: record.taskId, runId: record.runId, status: record.status, iteration: record.iteration, sessionId: record.sessionId, stopReason: record.stopReason, nextAction: action, ...(gateView ? { gateView } : {}) }; }
156
177
 
157
- export async function runLoop({ root: rootInput, taskId, agent, mode = "run", limits: limitInput, driver, onEvent = () => {}, preflight = false, precondition }) {
178
+ /**
179
+ * `signal` requests a graceful stop at the next turn boundary; `cancelSignal`
180
+ * additionally cancels the in-flight agent turn. Either way the run ends as
181
+ * `stopped` (the canonical task is NOT paused) and a later resume continues
182
+ * from fresh canonical nextAction. A failure observed while stopping is a
183
+ * shutdown side effect (a terminal Ctrl-C also reaches the agent child), not a
184
+ * transport failure, so it never pauses the task.
185
+ */
186
+ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", limits: limitInput, driver, dispatchBuild, onEvent = () => {}, preflight = false, precondition, signal: stopSignal, cancelSignal }) {
187
+ const stopping = () => Boolean(stopSignal?.aborted || cancelSignal?.aborted);
158
188
  const root = validateRoot(rootInput);
159
189
  // --- Validate mode and load/create the loop record -------------------------
160
190
  let record = readLoopRecord(root, taskId);
@@ -171,7 +201,16 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
171
201
  if (preflight) checkAgent(record?.agent ?? agent, root);
172
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() };
173
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 }); }
174
- 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
+ }
175
214
  try {
176
215
  let current = readCanonical(root, taskId);
177
216
  if (precondition) {
@@ -186,6 +225,10 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
186
225
  envelope(root, "state.mjs", ["task", "status", taskId, "--status", "active"]);
187
226
  record.pausedByLoop = false;
188
227
  current = readCanonical(root, taskId);
228
+ } else if (mode === "resume" && record.pausedByLoop && current.task.status === "active") {
229
+ // A human (CLI or dashboard) already re-activated the task: the loop no
230
+ // longer owns that pause, so a later human pause must not be auto-undone.
231
+ record.pausedByLoop = false;
189
232
  }
190
233
  const initial = fingerprint(root, current.task, current.action);
191
234
  if (mode === "resume" && record.lastFingerprint && record.lastFingerprint !== initial.digest) onEvent({ type: "context_changed", message: "Canonical task context changed since the last checkpoint; continuing from fresh nextAction", previousCheckpointAt: record.checkpoint?.capturedAt ?? null });
@@ -193,7 +236,12 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
193
236
  record.checkpoint = checkpoint(current.task, current.action, initial.tree, record.iteration);
194
237
  record.status = "running"; record.stopReason = null; record.updatedAt = iso(); writeLoopRecord(root, record);
195
238
  // --- Main turn loop --------------------------------------------------------
239
+ // maxTurns bounds one runLoop invocation (a supervised run), not the task's
240
+ // lifetime: record.iteration stays a cumulative observability counter, so a
241
+ // long multi-gate task is not permanently re-paused on every resume.
242
+ let runTurns = 0;
196
243
  for (;;) {
244
+ lease.assertOwned();
197
245
  const kind = classifyAction(current.action);
198
246
  if (kind !== "run_phase") {
199
247
  record.status = kind === "await_user" ? "gate" : kind;
@@ -202,39 +250,82 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
202
250
  const gateView = kind === "await_user" ? cli(root, "gate-view.mjs", [taskId]).trim() : undefined;
203
251
  return outputStop(record, current.action, gateView);
204
252
  }
205
- if (record.iteration >= limits.maxTurns) return pause("turn limit reached");
253
+ if (stopping()) return stop(cancelSignal?.aborted ? "daemon cancelled the run" : "daemon shutdown");
254
+ if (runTurns >= limits.maxTurns) return pause("turn limit reached");
255
+ if (dispatchBuild && current.task.phase === "build" && current.task.tasks.some((item) => item.status === "todo" || item.status === "in_progress")) {
256
+ try {
257
+ await dispatchBuild({ root, taskId, task: current.task, action: current.action, record });
258
+ } catch (error) {
259
+ if (stopping()) return stop("daemon shutdown interrupted build dispatch");
260
+ return pause(`build dispatch failure: ${error.message}`);
261
+ }
262
+ const refreshed = readCanonical(root, taskId);
263
+ const signal = fingerprint(root, refreshed.task, refreshed.action);
264
+ record.iteration += 1; runTurns += 1;
265
+ record.stallTurns = signal.digest === record.lastFingerprint ? record.stallTurns + 1 : 0;
266
+ record.lastFingerprint = signal.digest;
267
+ record.checkpoint = checkpoint(refreshed.task, refreshed.action, signal.tree, record.iteration);
268
+ record.updatedAt = iso(); writeLoopRecord(root, record);
269
+ current = refreshed;
270
+ if (stopping()) continue; // the loop top records `stopped` (or the reached stop) instead of a stall pause
271
+ if (classifyAction(current.action) === "run_phase" && record.stallTurns >= limits.maxStallTurns) return pause(`${record.stallTurns} consecutive turns without observable progress`);
272
+ continue;
273
+ }
206
274
  let prompt;
207
275
  try {
208
276
  const packet = JSON.parse(cli(root, "context.mjs", [taskId, "--phase", current.action.phase, ...(current.action.itemId ? ["--item", current.action.itemId] : []), "--format", "json"]));
209
277
  if (typeof packet?.content !== "string" || !packet.content.trim()) throw new Error("Context packet was empty or invalid");
210
278
  prompt = packet.content;
211
279
  } catch {
280
+ // A terminal Ctrl-C also kills the context.mjs child: that is a shutdown, not a broken packet.
281
+ if (stopping()) return stop("daemon shutdown interrupted context generation");
212
282
  // No driver has started for this turn, so it is safe to resume after repairing context.
213
283
  // Do not persist arbitrary subprocess output or malformed context in the stop reason.
214
284
  return pause("context packet generation or validation failed; inspect context.mjs and resume after repair");
215
285
  }
286
+ const feedback = pendingFeedback(current.task);
287
+ if (feedback.length) {
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}`;
289
+ }
216
290
  const beforeApprovals = approvalCount(current.task);
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 });
217
295
  let observedId = record.sessionId;
218
296
  try {
219
- 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, 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); } } });
220
298
  if (!observedId && result?.sessionId) { observedId = result.sessionId; record.sessionId = observedId; record.updatedAt = iso(); writeLoopRecord(root, record); }
221
299
  if (!observedId) throw new Error("Agent turn ended without a resumable session id");
222
300
  } catch (error) {
223
301
  current = readCanonical(root, taskId);
224
302
  if (classifyAction(current.action) === "await_user") continue;
303
+ if (stopping()) return stop(cancelSignal?.aborted ? "daemon cancelled the in-flight turn" : "daemon shutdown interrupted the turn");
225
304
  return pause(`agent transport failure: ${error.message}`);
226
305
  }
306
+ leadEvent({ type: "turn_end", iteration: record.iteration });
227
307
  const refreshed = readCanonical(root, taskId);
228
308
  if (approvalCount(refreshed.task) !== beforeApprovals) return pause("Approval evidence changed during an agent turn");
229
309
  const signal = fingerprint(root, refreshed.task, refreshed.action);
230
- record.iteration += 1;
310
+ record.iteration += 1; runTurns += 1;
231
311
  record.stallTurns = signal.digest === record.lastFingerprint ? record.stallTurns + 1 : 0;
232
312
  record.lastFingerprint = signal.digest;
233
313
  record.checkpoint = checkpoint(refreshed.task, refreshed.action, signal.tree, record.iteration);
234
314
  record.updatedAt = iso(); writeLoopRecord(root, record);
235
315
  current = refreshed;
316
+ if (stopping()) continue; // the loop top records `stopped` (or the reached stop) instead of a stall pause
236
317
  if (classifyAction(current.action) === "run_phase" && record.stallTurns >= limits.maxStallTurns) return pause(`${record.stallTurns} consecutive turns without observable progress`);
237
318
  }
319
+ function stop(reason) {
320
+ const stopped = readCanonical(root, taskId);
321
+ const signal = fingerprint(root, stopped.task, stopped.action);
322
+ record.status = "stopped"; record.stopReason = reason;
323
+ record.lastFingerprint = signal.digest;
324
+ record.checkpoint = checkpoint(stopped.task, stopped.action, signal.tree, record.iteration, reason);
325
+ record.updatedAt = iso(); writeLoopRecord(root, record);
326
+ onEvent({ type: "loop_stopped", taskId, reason });
327
+ return outputStop(record, stopped.action);
328
+ }
238
329
  function pause(reason) {
239
330
  const fresh = readCanonical(root, taskId);
240
331
  record.status = "paused"; record.stopReason = reason;
@@ -249,12 +340,23 @@ export async function runLoop({ root: rootInput, taskId, agent, mode = "run", li
249
340
  record.updatedAt = iso(); writeLoopRecord(root, record);
250
341
  return outputStop(record, stopped.action);
251
342
  }
252
- } 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
+ }
253
353
  }
254
354
 
255
355
  export function loopStatus(rootInput, taskId) {
256
356
  const root = validateRoot(rootInput);
257
357
  const record = readLoopRecord(root, taskId);
258
358
  const current = readCanonical(root, taskId);
259
- 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 };
260
362
  }
@@ -0,0 +1,117 @@
1
+ import { appendFileSync, lstatSync, mkdirSync, renameSync, statSync } from "node:fs";
2
+ import { basename, dirname, resolve } from "node:path";
3
+ import { isTaskId, taskRuntimePath } from "./task-runtime.mjs";
4
+ import { migrateTaskRuntime } from "./task-runtime-migrate.mjs";
5
+
6
+ /** The event-log sidecar rotates to `<path>.1` (one generation kept) once it
7
+ * reaches this size, so a 24/7 daemon cannot grow it without bound. */
8
+ export const EVENT_LOG_MAX_BYTES = 5 * 1024 * 1024;
9
+ /** Per-task history files (`.agents/data/tasks/<taskId>/runtime/events.jsonl`)
10
+ * rotate the same way at a smaller bound, since one exists for every task. */
11
+ export const TASK_EVENT_LOG_MAX_BYTES = 1024 * 1024;
12
+
13
+ let preparedDirectory;
14
+ const migratedTasks = new Set();
15
+
16
+ const rotate = (path, limit) => {
17
+ try { if (statSync(path).size >= limit) renameSync(path, `${path}.1`); }
18
+ catch (error) { if (error?.code !== "ENOENT") throw error; }
19
+ };
20
+
21
+ /** True when `path` is missing or a real (non-symlink) entry of the expected kind. */
22
+ const safeEntry = (path, directory) => {
23
+ try {
24
+ const stat = lstatSync(path);
25
+ return !stat.isSymbolicLink() && (directory ? stat.isDirectory() : stat.isFile());
26
+ } catch (error) { if (error?.code === "ENOENT") return true; throw error; }
27
+ };
28
+
29
+ /** Path of the per-task history file (`.agents/data/tasks/<taskId>/runtime/events.jsonl`),
30
+ * or null when `root` is not a string or the id is not a canonical task id. */
31
+ export const taskEventLogPath = (root, taskId) => {
32
+ if (typeof root !== "string" || !root || !isTaskId(taskId)) return null;
33
+ try { return taskRuntimePath(root, taskId, "events.jsonl"); } catch { return null; }
34
+ };
35
+
36
+ /** The project root a global event sidecar belongs to: the sidecar is always
37
+ * `<root>/.agents/data/loop/<file>`; any other location has no task history. */
38
+ export const loopRootFromEventLog = (logPath) => {
39
+ if (typeof logPath !== "string" || !logPath) return null;
40
+ const loopDir = dirname(resolve(logPath));
41
+ const data = dirname(loopDir), agents = dirname(data);
42
+ return basename(loopDir) === "loop" && basename(data) === "data" && basename(agents) === ".agents" ? dirname(agents) : null;
43
+ };
44
+
45
+ /** Append one line to a task's history. Refuses symlinked (or non-regular)
46
+ * directories and files so a planted link cannot redirect the write. A
47
+ * pre-runtime `.agents/data/loop/events/<taskId>.jsonl` is moved under
48
+ * `runtime/` before the first append of this process. */
49
+ function appendTaskEventLog(root, taskId, line) {
50
+ const path = taskEventLogPath(root, taskId);
51
+ if (!path) return;
52
+ if (!migratedTasks.has(path)) { migratedTasks.add(path); migrateTaskRuntime(root, taskId, { names: ["events.jsonl", "events.jsonl.1"] }); }
53
+ const directory = dirname(path);
54
+ if (!safeEntry(dirname(directory), true) || !safeEntry(directory, true)) return;
55
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
56
+ if (!safeEntry(directory, true) || !safeEntry(path, false) || !safeEntry(`${path}.1`, false)) return;
57
+ rotate(path, TASK_EVENT_LOG_MAX_BYTES);
58
+ appendFileSync(path, line);
59
+ }
60
+
61
+ /** Append one structured event to the optional UI event-log sidecar (JSONL)
62
+ * and, when it carries a valid `taskId`, to that task's history file. Each
63
+ * record gets an `at` timestamp; directories are created on demand. */
64
+ function appendEventLog(event) {
65
+ const path = process.env.AIDLC_LOOP_EVENT_LOG;
66
+ if (!path) return;
67
+ let line;
68
+ try {
69
+ const directory = dirname(path);
70
+ if (preparedDirectory !== directory) { mkdirSync(directory, { recursive: true }); preparedDirectory = directory; }
71
+ rotate(path, EVENT_LOG_MAX_BYTES);
72
+ const record = event && typeof event === "object" && !Array.isArray(event) && !("at" in event) ? { at: new Date().toISOString(), ...event } : event;
73
+ line = `${JSON.stringify(record)}\n`;
74
+ appendFileSync(path, line);
75
+ } catch { /* best-effort: never break the loop on a log write failure */ }
76
+ try { if (line && typeof event?.taskId === "string") appendTaskEventLog(loopRootFromEventLog(path), event.taskId, line); }
77
+ catch { /* best-effort, independently of the global log */ }
78
+ }
79
+
80
+ const withSummary = (event, separator) => typeof event.summary === "string" && event.summary ? `${separator}${event.summary}` : "";
81
+
82
+ /** Render foreground loop progress to stderr without polluting stdout JSON. */
83
+ export function streamLog(event) {
84
+ appendEventLog(event);
85
+ if (!event || typeof event.type !== "string") return;
86
+ switch (event.type) {
87
+ case "turn_start":
88
+ process.stderr.write(`\n━━ turn ${Number(event.iteration ?? 0) + 1} · phase ${event.phase ?? "?"} · agent ${event.agent ?? "?"} ━━\n`);
89
+ break;
90
+ case "turn_end":
91
+ process.stderr.write(`━━ turn done ━━\n`);
92
+ break;
93
+ case "agent_text":
94
+ process.stderr.write(`${event.text ?? ""}\n`);
95
+ break;
96
+ case "agent_tool":
97
+ process.stderr.write(` › tool: ${event.tool ?? "?"}${withSummary(event, " · ")}\n`);
98
+ break;
99
+ case "lifecycle_command":
100
+ process.stderr.write(` › command ${event.status ?? "?"} (exit ${event.exitCode ?? "?"})${withSummary(event, ": ")}\n`);
101
+ break;
102
+ case "daemon_turn_error":
103
+ process.stderr.write(` ! ${event.taskId ?? "?"}: ${event.message ?? "error"} (retry in ${Math.round(Number(event.retryInMs ?? 0) / 1000)}s)\n`);
104
+ break;
105
+ case "loop_stopped":
106
+ process.stderr.write(` ■ ${event.taskId ?? "?"} stopped: ${event.reason ?? ""}\n`);
107
+ break;
108
+ case "gate":
109
+ process.stderr.write(`\n${event.gateView ?? `Gate ${event.gate ?? "?"} reached — approve from the trusted terminal.`}\n`);
110
+ break;
111
+ case "worker_image_build":
112
+ process.stderr.write(`\n▶ building worker image ${event.image ?? "?"} (provider ${event.provider ?? "?"})…\n`);
113
+ break;
114
+ default:
115
+ break;
116
+ }
117
+ }
@@ -1,61 +1,28 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
- import { assertLoopPath } from "./loop-lease.mjs";
4
+ import { acquireTaskLease, assertLoopPath } from "./loop-lease.mjs";
5
5
  import { validateRoot } from "./loop-runtime.mjs";
6
6
  import { acquireStateLock, loadTask, nextAction } from "./store.mjs";
7
7
  import { recoverLeadArtifact } from "./loop-lead-artifacts.mjs";
8
8
 
9
9
  const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
10
10
  const checkAuthority = (authority) => { if (authority !== "local") throw new Error("local supervisor adapter requires local authority"); };
11
- const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (error) { return error?.code !== "ESRCH"; } };
12
- const wait = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
13
-
14
- function readLease(path, taskId) {
15
- try {
16
- const lease = JSON.parse(readFileSync(path, "utf8"));
17
- if (lease?.taskId !== taskId || !Number.isSafeInteger(lease.pid) || lease.pid < 1 || typeof lease.token !== "string" || !lease.token) throw new Error("invalid fields");
18
- return lease;
19
- } catch (error) { if (error?.code === "ENOENT") return null; throw new Error(`Corrupt supervisor lease for ${taskId}: ${error.message}`); }
20
- }
21
-
22
- function writeExclusive(path, value) {
23
- const fd = openSync(path, "wx", 0o600);
24
- try { writeFileSync(fd, `${JSON.stringify(value)}\n`); fsyncSync(fd); } finally { closeSync(fd); }
25
- }
26
-
27
- /** Local durable checkpoint + separate PID-fenced supervisor lock.
28
- * The lock uses a distinct task key from the runner's lease so humans can approve.
11
+ /** Local durable checkpoint + the task lease (`loop/<task>.lease`).
12
+ * The supervisor holds the lease with activity "waiting" between runs, so a
13
+ * human can approve gates; the runLoop it drives shares the same handle and
14
+ * flips activity to "turn" while a run executes (see loop-lease.mjs).
29
15
  * This adapter intentionally does not implement runTurn or perform approvals.
30
16
  */
31
- export function createLocalSupervisorAdapter({ root: rootInput, taskId }) {
17
+ export function createLocalSupervisorAdapter({ root: rootInput, taskId, onWarning }) {
32
18
  const root = validateRoot(rootInput);
33
19
  const checkpointPath = assertLoopPath(root, taskId, ".supervisor.json");
34
- const leasePath = assertLoopPath(root, taskId, ".supervisor.lease");
35
- const guardPath = assertLoopPath(root, taskId, ".supervisor.guard");
36
- let token;
37
- const withGuard = (action) => {
38
- assertLoopPath(root, taskId, ".supervisor.guard");
39
- mkdirSync(dirname(guardPath), { recursive: true });
40
- const guard = { taskId, pid: process.pid, token: randomUUID() }, deadline = Date.now() + 5000;
41
- for (;;) {
42
- try { writeExclusive(guardPath, guard); break; }
43
- catch (error) {
44
- if (error?.code !== "EEXIST") throw error;
45
- const existing = readLease(guardPath, taskId);
46
- if (!existing) continue;
47
- if (!alive(existing.pid)) { if (readLease(guardPath, taskId)?.token === existing.token) { try { unlinkSync(guardPath); } catch (e) { if (e?.code !== "ENOENT") throw e; } } continue; }
48
- if (Date.now() >= deadline) throw new Error(`Timed out waiting for supervisor guard: ${taskId}`);
49
- wait(10);
50
- }
51
- }
52
- try { return action(); } finally { if (readLease(guardPath, taskId)?.token === guard.token) unlinkSync(guardPath); }
53
- };
20
+ let lease;
54
21
  const checkScope = (input) => { checkAuthority(input?.authority); if (input?.taskId !== taskId) throw new Error("supervisor task mismatch"); };
55
22
  const assertOwned = async () => {
56
- assertLoopPath(root, taskId, ".supervisor.lease");
57
- const current = readLease(leasePath, taskId);
58
- if (!token || !current || current.token !== token || current.pid !== process.pid) throw new Error("local supervisor ownership lost");
23
+ if (!lease) throw new Error("local supervisor ownership lost: the task lease is not held");
24
+ try { lease.assertOwned(); }
25
+ catch (error) { throw new Error(`local supervisor ownership lost: ${error.message}`); }
59
26
  };
60
27
  const readCheckpoint = () => {
61
28
  assertLoopPath(root, taskId, ".supervisor.json");
@@ -72,27 +39,20 @@ export function createLocalSupervisorAdapter({ root: rootInput, taskId }) {
72
39
  return {
73
40
  async acquireSupervisor(input) {
74
41
  checkScope(input);
75
- if (token) throw new Error("local supervisor adapter is already acquired");
42
+ if (lease) throw new Error("local supervisor adapter is already acquired");
76
43
  if (input.signal?.aborted) throw new Error("supervisor already aborted");
77
- const newToken = randomUUID();
78
- withGuard(() => {
79
- assertLoopPath(root, taskId, ".supervisor.lease");
80
- const existing = readLease(leasePath, taskId);
81
- if (existing && alive(existing.pid)) throw new Error(`Task ${taskId} already has a live supervisor (pid ${existing.pid})`);
82
- if (existing && readLease(leasePath, taskId)?.token === existing.token) unlinkSync(leasePath);
83
- writeExclusive(leasePath, { taskId, pid: process.pid, token: newToken });
84
- });
85
- token = newToken;
86
- return { assertOwned, release: async () => { if (token !== newToken) return; withGuard(() => { if (readLease(leasePath, taskId)?.token === newToken) unlinkSync(leasePath); }); token = undefined; } };
44
+ const handle = acquireTaskLease(root, taskId, { activity: "waiting", label: "supervisor", purpose: "local loop supervisor", onWarning });
45
+ lease = handle;
46
+ return { assertOwned, release: async () => { if (lease !== handle) return; try { handle.release(); } finally { lease = undefined; } } };
87
47
  },
88
48
  async readTask(input) {
89
49
  checkScope({ ...input, authority: "local" });
90
50
  const release = acquireStateLock(root);
91
51
  try {
92
- if (token) await assertOwned();
52
+ if (lease) await assertOwned();
93
53
  const task = loadTask(root, taskId);
94
54
  if (!task || task.id !== taskId) throw new Error("invalid local canonical task snapshot");
95
- if (token) {
55
+ if (lease) {
96
56
  await recoverLeadArtifact({ root, taskId, task, assertOwned });
97
57
  await assertOwned();
98
58
  } else {