@tea-agent/loop-agent 0.3.0 → 0.4.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 (119) hide show
  1. package/AGENTS.md +135 -133
  2. package/CHANGELOG.md +88 -63
  3. package/README.md +171 -168
  4. package/bin/agent-worker.js +22 -0
  5. package/bin/loop-agent.js +21 -21
  6. package/dist/commands/init.js +457 -457
  7. package/dist/commands/loop-benchmark.js +11 -11
  8. package/dist/commands/pi-reuse-benchmark.js +16 -16
  9. package/dist/executors/cursor-executor.js +1 -1
  10. package/dist/executors/dag-pi-executor.js +8 -1
  11. package/dist/task/runtime.js +27 -27
  12. package/dist/worker/cli.js +119 -0
  13. package/dist/worker/loop-agent/command-result.js +1 -0
  14. package/dist/worker/loop-agent/loop-agent-client.js +105 -0
  15. package/dist/worker/loop-agent/parse-json.js +14 -0
  16. package/dist/worker/materialize/harness-task-materializer.js +157 -0
  17. package/dist/worker/pool/failure-routing.js +98 -0
  18. package/dist/worker/pool/run-store.js +117 -0
  19. package/dist/worker/pool/types.js +1 -0
  20. package/dist/worker/preflight.js +108 -0
  21. package/dist/worker/profile-mapping.js +76 -0
  22. package/dist/worker/progress-reporter.js +81 -0
  23. package/dist/worker/report/morning-report.js +69 -0
  24. package/dist/worker/repos/repo-resolver.js +23 -0
  25. package/dist/worker/run-task/run-task.js +359 -0
  26. package/dist/worker/runner/run-ready.js +216 -0
  27. package/dist/worker/task-graph/acceptance-schema.js +25 -0
  28. package/dist/worker/task-graph/ready-queue.js +23 -0
  29. package/dist/worker/task-graph/task-graph-schema.js +28 -0
  30. package/dist/worker/task-graph/types.js +1 -0
  31. package/dist/worker/task-graph/validate.js +188 -0
  32. package/dist/worker/task-spec/complexity-mapping.js +8 -0
  33. package/dist/worker/task-spec/schema.js +116 -0
  34. package/dist/worker/task-spec/types.js +1 -0
  35. package/dist/worker/task-spec/validate.js +352 -0
  36. package/dist/workflows/dag/canvas-observer.js +275 -275
  37. package/docs/README.md +65 -61
  38. package/docs/agent-dag-recovery-playbook.md +184 -184
  39. package/docs/agent-dag-runner.md +42 -42
  40. package/docs/architecture/runtime-boundaries.md +147 -147
  41. package/docs/cursor-executor-usage.md +25 -25
  42. package/docs/decisions/README.md +3 -3
  43. package/docs/design/README.md +36 -36
  44. package/docs/development-principles.md +73 -71
  45. package/docs/dynamic-workflow-dag-engine-roadmap.md +1749 -1749
  46. package/docs/exec-plans/README.md +6 -6
  47. package/docs/exec-plans/active/README.md +7 -7
  48. package/docs/exec-plans/completed/README.md +19 -11
  49. package/docs/feature-workflow.md +186 -186
  50. package/docs/harness-methodology-debugging.md +153 -153
  51. package/docs/harness-methodology-tdd.md +130 -130
  52. package/docs/harness-methodology-verification.md +27 -27
  53. package/docs/loop-agent-harness.md +42 -42
  54. package/docs/production-readiness.md +96 -96
  55. package/docs/progress/README.md +3 -3
  56. package/docs/reports/README.md +5 -5
  57. package/docs/skills/README.md +6 -6
  58. package/docs/skills/vetted-skill-registry.md +22 -22
  59. package/docs/templates/adr.md +60 -60
  60. package/docs/templates/agent-dag-authority-surface-audit.prompt.md +94 -94
  61. package/docs/templates/agent-dag-decision-envelope.schema.json +213 -213
  62. package/docs/templates/agent-dag-decision-gate-dogfood-report.md +117 -117
  63. package/docs/templates/agent-dag-decision-gate.prompt.md +246 -246
  64. package/docs/templates/agent-dag-process-supervisor.prompt.md +98 -98
  65. package/docs/templates/agent-dag-report.schema.json +454 -454
  66. package/docs/templates/agent-dag-review-verdict.prompt.md +68 -68
  67. package/docs/templates/agent-dag.base.json +195 -195
  68. package/docs/templates/agent-dag.final-verification.json +190 -190
  69. package/docs/templates/agent-dag.schema.json +316 -316
  70. package/docs/templates/agent-dag.supervised-implementation.json +500 -500
  71. package/docs/templates/exec-plan.md +64 -64
  72. package/docs/templates/feature-spec.md +53 -53
  73. package/docs/templates/hybrid-dag.json +193 -193
  74. package/docs/templates/production-readiness-checklist.md +57 -57
  75. package/docs/templates/progress-log.md +17 -17
  76. package/docs/templates/project-start-checklist.md +9 -9
  77. package/docs/templates/qa-report.md +48 -48
  78. package/docs/templates/sprint-contract.md +29 -29
  79. package/docs/verification-matrix.md +41 -41
  80. package/examples/decision-gate-agent-dag.json +123 -123
  81. package/examples/example-dag.json +51 -51
  82. package/examples/hybrid-loop-agent-dag.json +194 -194
  83. package/harness.json +89 -89
  84. package/package.json +60 -58
  85. package/skills/ai-engineering-context/SKILL.md +48 -48
  86. package/skills/code-review-core/SKILL.md +20 -20
  87. package/skills/codebase-scout/SKILL.md +19 -19
  88. package/skills/loop-agent/SKILL.md +147 -145
  89. package/skills/loop-agent/references/README.md +67 -67
  90. package/skills/loop-agent/references/command-reference.md +368 -340
  91. package/skills/loop-agent/references/harness-policy.md +259 -258
  92. package/skills/loop-agent/references/hybrid-dag.md +216 -216
  93. package/skills/loop-agent/references/learned/README.md +21 -21
  94. package/skills/loop-agent/references/long-running-loop.md +59 -59
  95. package/skills/loop-agent/references/model-routing.md +36 -36
  96. package/skills/loop-agent/references/multi-worktree.md +54 -54
  97. package/skills/loop-agent/references/one-shot-runs.md +85 -85
  98. package/skills/loop-agent/references/orchestrator-and-interventions.md +169 -169
  99. package/skills/loop-agent/references/pi-prompt.md +23 -23
  100. package/skills/loop-agent/references/pi-subagent-assisted-mode.md +81 -81
  101. package/skills/loop-agent/references/post-implementation-and-patterns.md +44 -44
  102. package/skills/loop-agent/references/task-workflow.md +84 -84
  103. package/skills/loop-agent/references/verification-and-failure-handling.md +128 -128
  104. package/skills/requesting-code-review/SKILL.md +101 -101
  105. package/skills/requesting-code-review/code-reviewer.md +168 -168
  106. package/skills/systematic-debugging/CREATION-LOG.md +119 -119
  107. package/skills/systematic-debugging/SKILL.md +296 -296
  108. package/skills/systematic-debugging/condition-based-waiting-example.ts +158 -158
  109. package/skills/systematic-debugging/condition-based-waiting.md +115 -115
  110. package/skills/systematic-debugging/defense-in-depth.md +122 -122
  111. package/skills/systematic-debugging/find-polluter.sh +63 -63
  112. package/skills/systematic-debugging/root-cause-tracing.md +169 -169
  113. package/skills/systematic-debugging/test-academic.md +14 -14
  114. package/skills/systematic-debugging/test-pressure-1.md +58 -58
  115. package/skills/systematic-debugging/test-pressure-2.md +68 -68
  116. package/skills/systematic-debugging/test-pressure-3.md +69 -69
  117. package/skills/test-driven-development/SKILL.md +20 -20
  118. package/skills/verification-before-completion/SKILL.md +154 -154
  119. package/skills/webapp-testing/SKILL.md +19 -19
@@ -0,0 +1,359 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { formatDuration, noopProgressReporter } from "../progress-reporter.js";
5
+ import { materializeTaskSpec, } from "../materialize/harness-task-materializer.js";
6
+ import { preflightTargetRepo } from "../preflight.js";
7
+ export const DEFAULT_RUN_DAG_TIMEOUT_MS = 1_800_000;
8
+ export const MAX_WORKER_TIMEOUT_MS = 7_200_000;
9
+ export async function runTaskSpec(options) {
10
+ const now = options.now ?? new Date();
11
+ const workerRunId = options.workerRunId ?? buildWorkerRunId(options.taskSpec.id, now);
12
+ const commands = [];
13
+ const client = new RecordingRunTaskClient(options.client, commands);
14
+ const progress = options.progress ?? noopProgressReporter;
15
+ if (options.preflight !== false) {
16
+ progress.step("preflight: version + inspect + docs-audit + git-status" + (options.preflight && typeof options.preflight === "object" && options.preflight.runCheckRepo ? " + check-repo" : ""));
17
+ const preflightStartedAt = Date.now();
18
+ const preflight = await preflightTargetRepo({
19
+ repoRoot: options.repoRoot,
20
+ client,
21
+ ...(typeof options.preflight === "object" ? options.preflight : {}),
22
+ });
23
+ if (!preflight.ok) {
24
+ progress.note(`preflight FAILED: ${preflight.code}`);
25
+ throw new Error(`target repo preflight failed: ${preflight.code}: ${preflight.message}`);
26
+ }
27
+ progress.step(`preflight ok in ${formatDuration(Date.now() - preflightStartedAt)}`);
28
+ }
29
+ progress.step("materialize harness task");
30
+ const materializeStartedAt = Date.now();
31
+ const materializeManifest = await materializeTaskSpec({
32
+ repoRoot: options.repoRoot,
33
+ taskSpec: options.taskSpec,
34
+ taskSpecPath: options.taskSpecPath,
35
+ client,
36
+ now,
37
+ });
38
+ progress.step(`materialized ${materializeManifest.harnessTaskId} in ${formatDuration(Date.now() - materializeStartedAt)}`);
39
+ const taskArtifactsDir = path.join(options.repoRoot, ".harness", "tasks", materializeManifest.harnessTaskId, "artifacts");
40
+ await mkdir(taskArtifactsDir, { recursive: true });
41
+ const dagPath = path.join(taskArtifactsDir, `${workerRunId}-dag.json`);
42
+ const runRecordPath = path.join(taskArtifactsDir, "worker-run-record.json");
43
+ progress.step("dag run-task: generate DAG");
44
+ const dagGenStartedAt = Date.now();
45
+ await runRequiredCommand(options.repoRoot, client, "dag-run-task", [
46
+ "dag",
47
+ "run-task",
48
+ materializeManifest.harnessTaskId,
49
+ "--profile",
50
+ materializeManifest.loopAgentProfile,
51
+ "--strict-models",
52
+ "--output",
53
+ dagPath,
54
+ "--cwd",
55
+ options.repoRoot,
56
+ ...noCursorArgs(options.taskSpec),
57
+ ]);
58
+ progress.step(`dag generated in ${formatDuration(Date.now() - dagGenStartedAt)}`);
59
+ if (options.piModel) {
60
+ progress.step(`pi-model override: rewriting executorModels.pi → ${options.piModel}`);
61
+ await applyPiModelOverride(dagPath, options.piModel);
62
+ }
63
+ progress.step("dag validate");
64
+ const validateStartedAt = Date.now();
65
+ await runRequiredCommand(options.repoRoot, client, "dag-validate", [
66
+ "dag",
67
+ "validate",
68
+ "--dag",
69
+ dagPath,
70
+ ...strictModelsArgs(options.piModel),
71
+ "--strict-governance",
72
+ "--spine-task",
73
+ materializeManifest.harnessTaskId,
74
+ ...forbidCursorArgs(options.taskSpec),
75
+ ]);
76
+ progress.step(`dag validated in ${formatDuration(Date.now() - validateStartedAt)}`);
77
+ progress.step("run-dag: executing DAG nodes (this is the long step)");
78
+ const runDagStartedAt = Date.now();
79
+ await runRequiredCommand(options.repoRoot, client, "run-dag", [
80
+ "run-dag",
81
+ "--dag",
82
+ dagPath,
83
+ "--cwd",
84
+ options.repoRoot,
85
+ "--run-id",
86
+ workerRunId,
87
+ ...maxConcurrentArgs(options.taskSpec),
88
+ ...noCursorArgs(options.taskSpec),
89
+ ], true, resolveRunDagTimeoutMs(options.taskSpec));
90
+ progress.step(`run-dag finished in ${formatDuration(Date.now() - runDagStartedAt)}`);
91
+ const reportJson = await client.run([
92
+ "dag",
93
+ "report",
94
+ "--run-id",
95
+ workerRunId,
96
+ "--lifecycle",
97
+ "all",
98
+ "--json",
99
+ ], {
100
+ cwd: options.repoRoot,
101
+ artifactName: "dag-report-json",
102
+ expectJson: true,
103
+ });
104
+ const reportDecision = decideFromReport(workerRunId, reportJson);
105
+ await client.run([
106
+ "dag",
107
+ "report",
108
+ "--run-id",
109
+ workerRunId,
110
+ "--lifecycle",
111
+ "all",
112
+ "--markdown",
113
+ ], {
114
+ cwd: options.repoRoot,
115
+ artifactName: "dag-report-markdown",
116
+ });
117
+ const status = reportDecision.succeeded ? "succeeded" : "failed";
118
+ progress.step(`report decision: ${status} (${reportDecision.reason}${reportDecision.runStatus ? `, status=${reportDecision.runStatus}` : ""})`);
119
+ const failureArtifacts = reportDecision.succeeded
120
+ ? undefined
121
+ : await collectFailureArtifacts({
122
+ client,
123
+ repoRoot: options.repoRoot,
124
+ workerRunId,
125
+ taskArtifactsDir,
126
+ });
127
+ if (reportDecision.succeeded) {
128
+ progress.step("promote + closeout");
129
+ await runRequiredCommand(options.repoRoot, client, "promote-run", ["promote-run", materializeManifest.harnessTaskId, "--run-id", workerRunId], true);
130
+ await runRequiredCommand(options.repoRoot, client, "closeout-task", ["closeout", "task", materializeManifest.harnessTaskId], true);
131
+ }
132
+ const record = {
133
+ schemaVersion: 1,
134
+ status,
135
+ workerRunId,
136
+ businessId: options.taskSpec.id,
137
+ harnessTaskId: materializeManifest.harnessTaskId,
138
+ featureId: options.taskSpec.feature_id,
139
+ loopAgentProfile: materializeManifest.loopAgentProfile,
140
+ dagPath,
141
+ runRecordPath,
142
+ materializeManifest,
143
+ reportDecision,
144
+ commands,
145
+ ...(failureArtifacts ? { failureArtifacts } : {}),
146
+ };
147
+ await writeFile(runRecordPath, `${JSON.stringify(record, null, 2)}\n`, "utf-8");
148
+ return {
149
+ status,
150
+ workerRunId,
151
+ businessId: options.taskSpec.id,
152
+ harnessTaskId: materializeManifest.harnessTaskId,
153
+ runRecordPath,
154
+ dagPath,
155
+ reportDecision,
156
+ ...(failureArtifacts ? { failureArtifacts } : {}),
157
+ };
158
+ }
159
+ export function buildWorkerRunId(businessId, now) {
160
+ const date = now.toISOString().slice(0, 10).replace(/-/g, "");
161
+ const hash = createHash("sha256")
162
+ .update(`${businessId}:${now.toISOString()}`)
163
+ .digest("hex")
164
+ .slice(0, 6);
165
+ return `wr-${date}-${businessId}-${hash}`;
166
+ }
167
+ async function runRequiredCommand(repoRoot, client, artifactName, args, expectJson = true, timeoutMs) {
168
+ const result = await client.run(args, {
169
+ cwd: repoRoot,
170
+ artifactName,
171
+ expectJson,
172
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
173
+ });
174
+ if (!result.ok) {
175
+ throw new Error(`loop-agent command failed: ${args.join(" ")}`);
176
+ }
177
+ return result;
178
+ }
179
+ function resolveRunDagTimeoutMs(taskSpec) {
180
+ return Math.min(taskSpec.worker.timeout_ms ?? DEFAULT_RUN_DAG_TIMEOUT_MS, MAX_WORKER_TIMEOUT_MS);
181
+ }
182
+ async function collectFailureArtifacts(input) {
183
+ const doctor = await input.client.run(["dag", "doctor", "--run-id", input.workerRunId, "--markdown"], {
184
+ cwd: input.repoRoot,
185
+ artifactName: "dag-doctor-markdown",
186
+ });
187
+ const closeoutDraftPath = path.join(input.repoRoot, ".task-pool", "failure-handoffs", `${input.workerRunId}-failure-closeout-draft.md`);
188
+ await input.client.run([
189
+ "dag",
190
+ "closeout-draft",
191
+ "--run-id",
192
+ input.workerRunId,
193
+ "--output",
194
+ closeoutDraftPath,
195
+ ], {
196
+ cwd: input.repoRoot,
197
+ artifactName: "dag-closeout-draft",
198
+ });
199
+ const reportMarkdown = input.client instanceof RecordingRunTaskClient
200
+ ? input.client.findArtifact("dag-report-markdown")
201
+ : undefined;
202
+ return {
203
+ reportMarkdownArtifactPath: reportMarkdown?.result.artifacts.stdoutPath ?? "",
204
+ doctorMarkdownArtifactPath: doctor.artifacts.stdoutPath,
205
+ closeoutDraftPath,
206
+ };
207
+ }
208
+ function decideFromReport(workerRunId, result) {
209
+ if (!result.ok || !result.json) {
210
+ return {
211
+ succeeded: false,
212
+ reason: "report-json-unavailable",
213
+ };
214
+ }
215
+ const runs = readObjectArray(result.json, "runs");
216
+ const run = runs.find((candidate) => readString(candidate, "runId") === workerRunId) ??
217
+ runs[0];
218
+ if (!run) {
219
+ return {
220
+ succeeded: false,
221
+ reason: "report-run-missing",
222
+ };
223
+ }
224
+ const runStatus = readString(run, "status");
225
+ const nodes = readObjectArray(run, "nodes");
226
+ const failedNode = nodes.find((node) => {
227
+ const status = readString(node, "status");
228
+ const failureCategory = readString(node, "failureCategory");
229
+ return (status === "ERROR" ||
230
+ status === "SKIPPED" ||
231
+ (Boolean(failureCategory) && failureCategory !== "success"));
232
+ });
233
+ if (runStatus !== "completed" && runStatus !== "finished") {
234
+ return {
235
+ succeeded: false,
236
+ reason: "run-not-completed",
237
+ runStatus,
238
+ primaryFailure: readProperty(run, "primaryFailure"),
239
+ };
240
+ }
241
+ if (failedNode) {
242
+ return {
243
+ succeeded: false,
244
+ reason: "node-failure",
245
+ runStatus,
246
+ primaryFailure: readProperty(run, "primaryFailure") ?? failedNode,
247
+ };
248
+ }
249
+ return {
250
+ succeeded: true,
251
+ reason: "report-completed",
252
+ runStatus,
253
+ primaryFailure: readProperty(run, "primaryFailure"),
254
+ };
255
+ }
256
+ class RecordingRunTaskClient {
257
+ delegate;
258
+ records;
259
+ constructor(delegate, records) {
260
+ this.delegate = delegate;
261
+ this.records = records;
262
+ }
263
+ async run(args, options) {
264
+ const result = await this.delegate.run(args, options);
265
+ this.records.push({
266
+ name: commandRecordName(args, options.artifactName),
267
+ artifactName: options.artifactName,
268
+ args,
269
+ result: {
270
+ ok: result.ok,
271
+ exitCode: result.exitCode,
272
+ durationMs: result.durationMs,
273
+ timedOut: result.timedOut,
274
+ artifacts: result.artifacts,
275
+ ...(result.parseFailure ? { parseFailure: result.parseFailure } : {}),
276
+ },
277
+ });
278
+ return result;
279
+ }
280
+ async runExternal(command, args, options) {
281
+ const result = await this.delegate.runExternal(command, args, options);
282
+ this.records.push({
283
+ name: options.artifactName,
284
+ artifactName: options.artifactName,
285
+ args: [command, ...args],
286
+ result: {
287
+ ok: result.ok,
288
+ exitCode: result.exitCode,
289
+ durationMs: result.durationMs,
290
+ timedOut: result.timedOut,
291
+ artifacts: result.artifacts,
292
+ ...(result.parseFailure ? { parseFailure: result.parseFailure } : {}),
293
+ },
294
+ });
295
+ return result;
296
+ }
297
+ findArtifact(artifactName) {
298
+ return this.records.find((record) => record.artifactName === artifactName);
299
+ }
300
+ }
301
+ function commandRecordName(args, artifactName) {
302
+ if (args[0] === "new-task")
303
+ return "new-task";
304
+ return artifactName;
305
+ }
306
+ function noCursorArgs(taskSpec) {
307
+ return taskSpec.loop_agent.no_cursor ? ["--no-cursor"] : [];
308
+ }
309
+ function forbidCursorArgs(taskSpec) {
310
+ return taskSpec.loop_agent.no_cursor ? ["--forbid-executor", "cursor"] : [];
311
+ }
312
+ function maxConcurrentArgs(taskSpec) {
313
+ return ["--max-concurrent", String(taskSpec.loop_agent.max_concurrent)];
314
+ }
315
+ /**
316
+ * When a smoke `piModel` override is active, drop `--strict-models` so the
317
+ * non-canonical `executorModels.pi` rewrite passes `dag validate`. Governance,
318
+ * forbidden-executor and spine audits are still enforced by their own flags.
319
+ */
320
+ function strictModelsArgs(piModel) {
321
+ return piModel ? [] : ["--strict-models"];
322
+ }
323
+ /**
324
+ * Rewrite `executorModels.pi.{LOW,MED,HIGH}` in an already-generated DAG JSON
325
+ * so every pi executor node resolves to the smoke override model. Cursor / shell
326
+ * slots are left untouched.
327
+ */
328
+ export async function applyPiModelOverride(dagPath, piModel) {
329
+ const raw = await readFile(dagPath, "utf-8");
330
+ const spec = JSON.parse(raw);
331
+ spec.executorModels = {
332
+ ...(spec.executorModels?.cursor ? { cursor: spec.executorModels.cursor } : {}),
333
+ pi: {
334
+ LOW: piModel,
335
+ MED: piModel,
336
+ HIGH: piModel,
337
+ },
338
+ };
339
+ await writeFile(dagPath, `${JSON.stringify(spec, null, 2)}\n`, "utf-8");
340
+ }
341
+ function readObjectArray(value, key) {
342
+ if (!value || typeof value !== "object")
343
+ return [];
344
+ const child = value[key];
345
+ if (!Array.isArray(child))
346
+ return [];
347
+ return child.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item));
348
+ }
349
+ function readString(value, key) {
350
+ if (!value || typeof value !== "object")
351
+ return undefined;
352
+ const child = value[key];
353
+ return typeof child === "string" ? child : undefined;
354
+ }
355
+ function readProperty(value, key) {
356
+ if (!value || typeof value !== "object")
357
+ return undefined;
358
+ return value[key];
359
+ }
@@ -0,0 +1,216 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import YAML from "yaml";
5
+ import { deriveFailureRoute } from "../pool/failure-routing.js";
6
+ import { findRunByWorkerRunId, getTaskPoolRoot, readAllTaskPoolStates, recordTaskPoolRun, writeTaskPoolState, } from "../pool/run-store.js";
7
+ import { runTaskSpec, } from "../run-task/run-task.js";
8
+ import { formatDuration, noopProgressReporter } from "../progress-reporter.js";
9
+ import { computeReadyQueue } from "../task-graph/ready-queue.js";
10
+ import { taskGraphSpecSchema } from "../task-graph/task-graph-schema.js";
11
+ import { taskSpecSchema } from "../task-spec/schema.js";
12
+ export async function runReadyTasks(options) {
13
+ const now = options.now ?? new Date();
14
+ const startedAt = now.toISOString();
15
+ const batchRunId = options.batchRunId ?? buildBatchRunId(now);
16
+ const graph = await loadTaskGraph(options.featureDir);
17
+ const states = await readAllTaskPoolStates(options.repoRoot);
18
+ const readyTaskIds = computeReadyQueue(graph, toGraphState(states)).filter((taskId) => isPoolReady(states[taskId]));
19
+ const limitedTaskIds = readyTaskIds.slice(0, options.limit ?? readyTaskIds.length);
20
+ const tasks = [];
21
+ const runner = options.runTask ?? runTaskSpec;
22
+ const progress = options.progress ?? noopProgressReporter;
23
+ const total = limitedTaskIds.length;
24
+ let index = 0;
25
+ const batchStartedAt = Date.now();
26
+ progress.batch(`batch ${batchRunId}: feature=${graph.feature_id}, ${total} task${total === 1 ? "" : "s"} ready → starting`);
27
+ for (const taskId of limitedTaskIds) {
28
+ index += 1;
29
+ const node = graph.nodes.find((candidate) => candidate.id === taskId);
30
+ const taskSpecPath = path.join(options.featureDir, "tasks", node?.task ?? `${taskId}.yaml`);
31
+ const taskSpec = await loadTaskSpec(taskSpecPath);
32
+ const workerRunId = options.workerRunIdForTask?.(taskId, taskSpec) ??
33
+ buildStableWorkerRunId(taskId, taskSpec, now);
34
+ if (workerRunId) {
35
+ const existing = await findRunByWorkerRunId(options.repoRoot, workerRunId);
36
+ if (existing) {
37
+ progress.task(`task ${index}/${total} ${taskId}: reuse existing run ${workerRunId}`);
38
+ tasks.push({
39
+ taskId,
40
+ workerRunId,
41
+ status: "reused",
42
+ runRecordPath: existing.runRecordPath,
43
+ });
44
+ continue;
45
+ }
46
+ }
47
+ const taskStartedAt = Date.now();
48
+ progress.task(`task ${index}/${total} ${taskId} "${taskSpec.title}" (${workerRunId})`);
49
+ progress.step(`preflight → materialize → dag-run-task → validate → run-dag → report`);
50
+ let result;
51
+ try {
52
+ await writeTaskPoolState(options.repoRoot, {
53
+ taskId,
54
+ status: "Running",
55
+ updatedAt: new Date().toISOString(),
56
+ workerRunId,
57
+ });
58
+ result = await runner({
59
+ repoRoot: options.repoRoot,
60
+ taskSpec,
61
+ taskSpecPath,
62
+ client: options.client,
63
+ now,
64
+ workerRunId,
65
+ preflight: {
66
+ runCheckRepo: options.runCheckRepo,
67
+ ...(options.checkRepoCommand ? { checkRepoCommand: options.checkRepoCommand } : {}),
68
+ },
69
+ progress,
70
+ ...(options.piModel ? { piModel: options.piModel } : {}),
71
+ });
72
+ }
73
+ catch (error) {
74
+ const message = errorMessage(error);
75
+ progress.note(`task ${taskId}: ERROR ${message}`);
76
+ try {
77
+ await recordTaskPoolRun({
78
+ repoRoot: options.repoRoot,
79
+ run: {
80
+ schemaVersion: 1,
81
+ batchRunId,
82
+ workerRunId,
83
+ taskId,
84
+ featureId: taskSpec.feature_id,
85
+ status: "run-error",
86
+ recordedAt: new Date().toISOString(),
87
+ error: message,
88
+ },
89
+ });
90
+ }
91
+ catch (recordError) {
92
+ tasks.push({
93
+ taskId,
94
+ workerRunId,
95
+ status: "record-error",
96
+ error: errorMessage(recordError),
97
+ });
98
+ continue;
99
+ }
100
+ tasks.push({
101
+ taskId,
102
+ workerRunId,
103
+ status: "run-error",
104
+ error: message,
105
+ });
106
+ continue;
107
+ }
108
+ const failure = deriveFailureRoute(result);
109
+ if (result.status === "succeeded") {
110
+ progress.step(`✓ ${taskId} succeeded in ${formatDuration(Date.now() - taskStartedAt)} (promoted + closed out)`);
111
+ }
112
+ else {
113
+ progress.step(`✗ ${taskId} failed in ${formatDuration(Date.now() - taskStartedAt)} — see failure artifacts`);
114
+ }
115
+ const run = {
116
+ schemaVersion: 1,
117
+ batchRunId,
118
+ workerRunId: result.workerRunId,
119
+ taskId: result.businessId,
120
+ featureId: taskSpec.feature_id,
121
+ status: result.status,
122
+ harnessTaskId: result.harnessTaskId,
123
+ runRecordPath: result.runRecordPath,
124
+ dagPath: result.dagPath,
125
+ recordedAt: new Date().toISOString(),
126
+ ...(failure ? { failure } : {}),
127
+ ...(result.failureArtifacts ? { failureArtifacts: result.failureArtifacts } : {}),
128
+ };
129
+ try {
130
+ await recordTaskPoolRun({ repoRoot: options.repoRoot, run });
131
+ tasks.push({
132
+ taskId,
133
+ workerRunId: result.workerRunId,
134
+ status: result.status,
135
+ runRecordPath: result.runRecordPath,
136
+ });
137
+ }
138
+ catch (error) {
139
+ tasks.push({
140
+ taskId,
141
+ workerRunId: result.workerRunId,
142
+ status: "record-error",
143
+ error: errorMessage(error),
144
+ runRecordPath: result.runRecordPath,
145
+ });
146
+ }
147
+ }
148
+ const summary = summarize(tasks);
149
+ const batchRunPath = path.join(getTaskPoolRoot(options.repoRoot), "runs", `${batchRunId}.json`);
150
+ const output = {
151
+ schemaVersion: 1,
152
+ status: summary.recordErrors > 0 || summary.runErrors > 0 ? "failed" : "completed",
153
+ batchRunId,
154
+ featureId: graph.feature_id,
155
+ startedAt,
156
+ summary,
157
+ tasks,
158
+ batchRunPath,
159
+ };
160
+ await mkdir(path.dirname(batchRunPath), { recursive: true });
161
+ await writeFile(batchRunPath, `${JSON.stringify(output, null, 2)}\n`, "utf-8");
162
+ progress.batch(`batch ${batchRunId} ${output.status}: ${summary.succeeded} succeeded, ${summary.failed} failed, ${summary.reused} reused in ${formatDuration(Date.now() - batchStartedAt)} → ${batchRunPath}`);
163
+ return output;
164
+ }
165
+ export function buildBatchRunId(now) {
166
+ return `batch-${now.toISOString().replace(/[-:.]/g, "").slice(0, 15)}`;
167
+ }
168
+ export function buildStableWorkerRunId(taskId, taskSpec, now) {
169
+ const date = now.toISOString().slice(0, 10).replace(/-/g, "");
170
+ const slug = taskId
171
+ .toLowerCase()
172
+ .replace(/[^a-z0-9]+/g, "-")
173
+ .replace(/^-+|-+$/g, "");
174
+ const hash = createHash("sha256")
175
+ .update(`${taskSpec.feature_id}:${taskSpec.id}:${taskSpec.title}`)
176
+ .digest("hex")
177
+ .slice(0, 10);
178
+ return `wr-${date}-${slug}-${hash}`;
179
+ }
180
+ async function loadTaskGraph(featureDir) {
181
+ const graphPath = path.join(featureDir, "tasks", "task-graph.yaml");
182
+ return taskGraphSpecSchema.parse(YAML.parse(await readFile(graphPath, "utf-8")));
183
+ }
184
+ async function loadTaskSpec(taskSpecPath) {
185
+ return taskSpecSchema.parse(YAML.parse(await readFile(taskSpecPath, "utf-8")));
186
+ }
187
+ function toGraphState(states) {
188
+ const graphState = {};
189
+ for (const [taskId, state] of Object.entries(states)) {
190
+ if (state.status === "Done")
191
+ graphState[taskId] = "completed";
192
+ if (state.status === "Running" || state.status === "Queued")
193
+ graphState[taskId] = "running";
194
+ if (state.status === "Failed")
195
+ graphState[taskId] = "failed";
196
+ if (state.status === "Blocked")
197
+ graphState[taskId] = "blocked";
198
+ }
199
+ return graphState;
200
+ }
201
+ function isPoolReady(state) {
202
+ return !state || state.status === "Ready";
203
+ }
204
+ function summarize(tasks) {
205
+ return {
206
+ total: tasks.length,
207
+ succeeded: tasks.filter((task) => task.status === "succeeded").length,
208
+ failed: tasks.filter((task) => task.status === "failed").length,
209
+ reused: tasks.filter((task) => task.status === "reused").length,
210
+ recordErrors: tasks.filter((task) => task.status === "record-error").length,
211
+ runErrors: tasks.filter((task) => task.status === "run-error").length,
212
+ };
213
+ }
214
+ function errorMessage(error) {
215
+ return error instanceof Error ? error.message : String(error);
216
+ }
@@ -0,0 +1,25 @@
1
+ import { z } from "zod";
2
+ export const acceptanceItemSchema = z
3
+ .object({
4
+ id: z.string().min(1),
5
+ title: z.string().min(1),
6
+ priority: z.enum(["must", "should", "could", "wont"]).optional().default("must"),
7
+ type: z.string().min(1),
8
+ given: z.string().min(1),
9
+ when: z.string().min(1),
10
+ then: z.string().min(1),
11
+ verification: z
12
+ .object({
13
+ expected_task_refs: z.array(z.string().min(1)).min(1),
14
+ suggested_tests: z.array(z.string().min(1)).optional().default([]),
15
+ })
16
+ .strict(),
17
+ })
18
+ .strict();
19
+ export const acceptanceSpecSchema = z
20
+ .object({
21
+ schema_version: z.literal(1),
22
+ feature_id: z.string().min(1),
23
+ acceptance: z.array(acceptanceItemSchema).min(1),
24
+ })
25
+ .strict();
@@ -0,0 +1,23 @@
1
+ const TERMINAL_OR_ACTIVE = new Set([
2
+ "running",
3
+ "completed",
4
+ "failed",
5
+ "blocked",
6
+ ]);
7
+ export function computeReadyQueue(graph, state) {
8
+ return graph.nodes
9
+ .filter((node) => {
10
+ const ownStatus = getStatus(state[node.id]);
11
+ if (ownStatus && TERMINAL_OR_ACTIVE.has(ownStatus))
12
+ return false;
13
+ return node.depends_on.every((dependencyId) => getStatus(state[dependencyId]) === "completed");
14
+ })
15
+ .map((node) => node.id);
16
+ }
17
+ function getStatus(value) {
18
+ if (!value)
19
+ return undefined;
20
+ if (typeof value === "string")
21
+ return value;
22
+ return value.status;
23
+ }
@@ -0,0 +1,28 @@
1
+ import { z } from "zod";
2
+ import { taskSpecTypeSchema } from "../task-spec/schema.js";
3
+ export const taskGraphNodeSchema = z
4
+ .object({
5
+ id: z.string().min(1),
6
+ task: z.string().min(1),
7
+ type: taskSpecTypeSchema,
8
+ depends_on: z.array(z.string().min(1)).optional().default([]),
9
+ })
10
+ .strict();
11
+ export const taskGraphSpecSchema = z
12
+ .object({
13
+ schema_version: z.literal(1),
14
+ feature_id: z.string().min(1),
15
+ nodes: z.array(taskGraphNodeSchema).min(1),
16
+ parallel_policy: z
17
+ .object({
18
+ max_parallel_tasks: z.number().int().positive().optional().default(1),
19
+ disallow_same_file_parallel_writes: z.boolean().optional().default(true),
20
+ })
21
+ .strict()
22
+ .optional()
23
+ .default({
24
+ max_parallel_tasks: 1,
25
+ disallow_same_file_parallel_writes: true,
26
+ }),
27
+ })
28
+ .strict();
@@ -0,0 +1 @@
1
+ export {};