@tea-agent/loop-agent 0.16.1-beta.1 → 0.16.1

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 (93) hide show
  1. package/AGENTS.md +4 -8
  2. package/CHANGELOG.md +59 -16
  3. package/README.md +76 -299
  4. package/dist/application/evaluation/alias.js +184 -0
  5. package/dist/application/evaluation/budget.js +192 -0
  6. package/dist/application/evaluation/campaign-hash.js +47 -0
  7. package/dist/application/evaluation/campaign-matrix.js +372 -0
  8. package/dist/application/evaluation/campaign-scorecard.js +135 -0
  9. package/dist/application/evaluation/campaign.js +370 -0
  10. package/dist/application/evaluation/candidate.js +23 -6
  11. package/dist/application/evaluation/corpus-hash.js +38 -0
  12. package/dist/application/evaluation/corpus.js +56 -0
  13. package/dist/application/evaluation/experiment.js +294 -0
  14. package/dist/application/evaluation/ignition.js +198 -0
  15. package/dist/application/evaluation/integrity-audit.js +162 -0
  16. package/dist/application/evaluation/outer-loop.js +132 -0
  17. package/dist/application/evaluation/pi-cell-executor.js +39 -0
  18. package/dist/application/evaluation/private-verifier.js +46 -0
  19. package/dist/application/evaluation/promotion-policy.js +151 -0
  20. package/dist/application/evaluation/proposer.js +98 -0
  21. package/dist/application/evaluation/types.js +522 -0
  22. package/dist/cli/command-definitions.js +19 -3
  23. package/dist/commands/dag-reconcile-run.js +3 -116
  24. package/dist/commands/eval.js +1176 -13
  25. package/dist/commands/init.js +7 -1
  26. package/dist/executors/dag-pi-executor.js +4 -44
  27. package/dist/executors/shell-executor.js +1 -1
  28. package/dist/infrastructure/evaluation/alias-store.js +199 -0
  29. package/dist/infrastructure/evaluation/campaign-store.js +154 -0
  30. package/dist/infrastructure/evaluation/corpus-store.js +181 -0
  31. package/dist/infrastructure/evaluation/experiment-store.js +124 -0
  32. package/dist/infrastructure/evaluation/ignition-store.js +82 -0
  33. package/dist/infrastructure/evaluation/private-verifier-store.js +145 -0
  34. package/dist/infrastructure/evaluation/proposer-store.js +78 -0
  35. package/dist/records/promotion.js +3 -1
  36. package/dist/worker/cli.js +83 -0
  37. package/dist/worker/delivery/git-transaction.js +75 -0
  38. package/dist/worker/delivery/verification-bundle.js +13 -2
  39. package/dist/worker/feature/review.js +3 -2
  40. package/dist/worker/observe/static/dag-helpers.js +0 -62
  41. package/dist/worker/observe/static/styles.css +18 -55
  42. package/dist/worker/observe/static/views/dag.js +13 -5
  43. package/dist/worker/outcomes/adapters.js +4 -1
  44. package/dist/worker/outcomes/declared-artifacts.js +103 -0
  45. package/dist/worker/outcomes/evidence-tokens.js +29 -0
  46. package/dist/worker/outcomes/gate.js +10 -11
  47. package/dist/worker/outcomes/projector.js +30 -4
  48. package/dist/worker/outcomes/types.js +3 -0
  49. package/dist/worker/pool/reconcile.js +285 -0
  50. package/dist/worker/run-task/run-task.js +81 -4
  51. package/dist/worker/runner/run-ready.js +25 -2
  52. package/dist/worker/task-graph/ready-planner.js +14 -8
  53. package/dist/worker/task-graph/task-graph-schema.js +5 -3
  54. package/dist/workflows/dag/budget-enforcement.js +67 -0
  55. package/dist/workflows/dag/context-policy.js +137 -0
  56. package/dist/workflows/dag/failure-routing.js +7 -0
  57. package/dist/workflows/dag/frontend-implementation-contract.js +0 -77
  58. package/dist/workflows/dag/init-hybrid.js +33 -53
  59. package/dist/workflows/dag/knowledge-curator.js +3 -0
  60. package/dist/workflows/dag/node-execution.js +11 -4
  61. package/dist/workflows/dag/prompt.js +1 -1
  62. package/dist/workflows/dag/reconcile-run.js +121 -0
  63. package/dist/workflows/dag/report.js +12 -0
  64. package/dist/workflows/dag/runner.js +43 -16
  65. package/dist/workflows/dag/skill-snapshot.js +11 -7
  66. package/dist/workflows/dag/types.js +18 -1
  67. package/dist/workflows/dag/validate.js +15 -1
  68. package/docs/README.md +3 -1
  69. package/docs/architecture/runtime-boundaries.md +3 -2
  70. package/docs/init-surface.manifest.json +4 -0
  71. package/docs/local-development-environment.md +52 -0
  72. package/docs/templates/agent-dag.schema.json +0 -5
  73. package/docs/templates/agent-dag.supervised-implementation.json +23 -4
  74. package/docs/templates/branch-merge-report.md +14 -0
  75. package/docs/templates/evaluation/campaign-budget-v1.json +12 -0
  76. package/docs/templates/evaluation/campaign-dogfood-v0.json +24 -0
  77. package/docs/templates/evaluation/campaign-evidence-v1.json +44 -0
  78. package/docs/templates/evaluation/context-policy-baseline-v1.json +17 -0
  79. package/docs/templates/evaluation/context-policy-role-specialized-v1.json +28 -0
  80. package/docs/templates/evaluation/corpus-dogfood-v0.manifest.json +118 -0
  81. package/docs/templates/evaluation/matrix-dag-dry-run-v1.json +21 -0
  82. package/docs/templates/evaluation/matrix-fixture-v1.json +10 -0
  83. package/docs/templates/evaluation/private-verifier-dogfood-v0.json +16 -0
  84. package/docs/templates/product-line/AGENTS.md +1 -0
  85. package/docs/templates/product-line/README.md +17 -0
  86. package/docs/templates/product-line/acceptance.yaml +9 -0
  87. package/docs/templates/product-line/feature.yaml +11 -0
  88. package/docs/templates/product-line/task-graph.yaml +8 -0
  89. package/docs/templates/product-line/task.yaml +4 -0
  90. package/package.json +2 -1
  91. package/skills/frontend-implementation/references/node-contracts.md +3 -3
  92. package/skills/loop-agent/references/command-reference.md +5 -0
  93. package/skills/loop-agent/references/hybrid-dag.md +3 -3
@@ -0,0 +1,285 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { executeDagReconcileRun } from "../../workflows/dag/reconcile-run.js";
4
+ import { assessDagRunLiveness, assessDagRunRecoveryEligibility, locateDagRun, readDagRunState, } from "../../workflows/dag/lifecycle.js";
5
+ import { appendTaskPoolEvent, findRunByWorkerRunId, getTaskPoolRoot, readTaskPoolState, writeTaskPoolState, } from "./run-store.js";
6
+ const RECOVERABLE_LIVENESS = new Set([
7
+ "active",
8
+ "node-quiet",
9
+ "stale",
10
+ ]);
11
+ export async function reconcileTaskPoolAbandon(input) {
12
+ const reason = input.reason.trim();
13
+ if (!reason)
14
+ throw new Error("task reconcile abandon requires --reason <text>");
15
+ const ref = { featureId: input.featureId, taskId: input.taskId };
16
+ const current = await readTaskPoolState(input.repoRoot, ref);
17
+ if (!current) {
18
+ throw new Error(`cannot reconcile ${input.taskId}: Task Pool state does not exist for ${input.featureId}/${input.taskId}`);
19
+ }
20
+ if (current.status === "Done") {
21
+ throw new Error(`cannot reconcile ${input.taskId}: Task Pool status is Done`);
22
+ }
23
+ const { run, dagLocated } = await validateWorkerDagAssociation({
24
+ repoRoot: input.repoRoot,
25
+ featureId: input.featureId,
26
+ taskId: input.taskId,
27
+ workerRunId: input.workerRunId,
28
+ dagRunId: input.dagRunId,
29
+ });
30
+ const dagState = await readDagRunState(dagLocated.runDir);
31
+ const liveness = assessDagRunLiveness({ state: dagState });
32
+ const eligibility = assessDagRunRecoveryEligibility({
33
+ lifecycle: dagLocated.lifecycle,
34
+ state: dagState,
35
+ liveness: liveness.status,
36
+ });
37
+ const alreadyAbandoned = dagLocated.lifecycle === "completed" &&
38
+ (dagState.status === "abandoned" || dagState.reconciliation?.action === "abandon");
39
+ const poolAlreadyFailed = current.status === "Failed" &&
40
+ current.workerRunId === input.workerRunId &&
41
+ current.operatorRecovery?.action === "task-reconcile-abandon";
42
+ if (poolAlreadyFailed && alreadyAbandoned) {
43
+ const evidencePath = current.operatorRecovery?.evidencePath ??
44
+ recoveryEvidencePath(input.repoRoot, input.featureId, input.taskId, input.workerRunId);
45
+ return {
46
+ schemaVersion: 1,
47
+ status: "Failed",
48
+ taskId: input.taskId,
49
+ featureId: input.featureId,
50
+ workerRunId: input.workerRunId,
51
+ dagRunId: input.dagRunId,
52
+ idempotent: true,
53
+ evidencePath,
54
+ };
55
+ }
56
+ assertAbandonAllowed({
57
+ eligibility,
58
+ liveness: liveness.status,
59
+ forceAbandonRecoverable: input.forceAbandonRecoverable === true,
60
+ dagRunId: input.dagRunId,
61
+ });
62
+ let dagReconcile;
63
+ if (!alreadyAbandoned) {
64
+ dagReconcile = await executeDagReconcileRun(input.repoRoot, [
65
+ "--run-id",
66
+ input.dagRunId,
67
+ "--action",
68
+ "abandon",
69
+ "--reason",
70
+ reason,
71
+ ]);
72
+ }
73
+ const at = (input.now ?? new Date()).toISOString();
74
+ const evidencePath = await writeRecoveryEvidence(input.repoRoot, {
75
+ kind: "task-reconcile-abandon",
76
+ featureId: input.featureId,
77
+ taskId: input.taskId,
78
+ workerRunId: input.workerRunId,
79
+ dagRunId: input.dagRunId,
80
+ reason,
81
+ at,
82
+ runRecordPath: run.runRecordPath,
83
+ dagReconcile,
84
+ });
85
+ const operatorRecovery = {
86
+ action: "task-reconcile-abandon",
87
+ reason,
88
+ workerRunId: input.workerRunId,
89
+ dagRunId: input.dagRunId,
90
+ abandonedWorkerRunId: input.workerRunId,
91
+ at,
92
+ evidencePath: path.relative(input.repoRoot, evidencePath).split(path.sep).join("/"),
93
+ };
94
+ await projectTaskPoolFailed(input.repoRoot, current, {
95
+ featureId: input.featureId,
96
+ taskId: input.taskId,
97
+ workerRunId: input.workerRunId,
98
+ updatedAt: at,
99
+ operatorRecovery,
100
+ });
101
+ await appendTaskPoolEvent(input.repoRoot, {
102
+ schemaVersion: 1,
103
+ at,
104
+ type: "task-reconcile-abandon",
105
+ featureId: input.featureId,
106
+ taskId: input.taskId,
107
+ workerRunId: input.workerRunId,
108
+ dagRunId: input.dagRunId,
109
+ reason,
110
+ idempotent: false,
111
+ evidencePath: operatorRecovery.evidencePath,
112
+ });
113
+ return {
114
+ schemaVersion: 1,
115
+ status: "Failed",
116
+ taskId: input.taskId,
117
+ featureId: input.featureId,
118
+ workerRunId: input.workerRunId,
119
+ dagRunId: input.dagRunId,
120
+ idempotent: false,
121
+ ...(dagReconcile ? { dagReconcile } : {}),
122
+ evidencePath: operatorRecovery.evidencePath,
123
+ };
124
+ }
125
+ export async function markTaskPoolFailed(input) {
126
+ const reason = input.reason.trim();
127
+ if (!reason)
128
+ throw new Error("pool mark-failed requires --reason <text>");
129
+ await validateWorkerRunOwnership({
130
+ repoRoot: input.repoRoot,
131
+ featureId: input.featureId,
132
+ taskId: input.taskId,
133
+ workerRunId: input.workerRunId,
134
+ });
135
+ const ref = { featureId: input.featureId, taskId: input.taskId };
136
+ const current = await readTaskPoolState(input.repoRoot, ref);
137
+ if (!current) {
138
+ throw new Error(`cannot mark-failed ${input.taskId}: Task Pool state does not exist for ${input.featureId}/${input.taskId}`);
139
+ }
140
+ if (current.status === "Done") {
141
+ throw new Error(`cannot mark-failed ${input.taskId}: Task Pool status is Done`);
142
+ }
143
+ const at = (input.now ?? new Date()).toISOString();
144
+ const evidencePath = recoveryEvidencePath(input.repoRoot, input.featureId, input.taskId, input.workerRunId);
145
+ const relativeEvidencePath = path.relative(input.repoRoot, evidencePath).split(path.sep).join("/");
146
+ if (current.status === "Failed" &&
147
+ current.workerRunId === input.workerRunId &&
148
+ current.operatorRecovery?.action === "pool-mark-failed") {
149
+ return {
150
+ schemaVersion: 1,
151
+ status: "Failed",
152
+ taskId: input.taskId,
153
+ featureId: input.featureId,
154
+ workerRunId: input.workerRunId,
155
+ idempotent: true,
156
+ evidencePath: current.operatorRecovery.evidencePath ?? relativeEvidencePath,
157
+ };
158
+ }
159
+ await writeRecoveryEvidence(input.repoRoot, {
160
+ kind: "pool-mark-failed",
161
+ featureId: input.featureId,
162
+ taskId: input.taskId,
163
+ workerRunId: input.workerRunId,
164
+ reason,
165
+ at,
166
+ });
167
+ const operatorRecovery = {
168
+ action: "pool-mark-failed",
169
+ reason,
170
+ workerRunId: input.workerRunId,
171
+ at,
172
+ evidencePath: relativeEvidencePath,
173
+ note: "emergency Task Pool projection; does not mutate DAG lifecycle",
174
+ };
175
+ await projectTaskPoolFailed(input.repoRoot, current, {
176
+ featureId: input.featureId,
177
+ taskId: input.taskId,
178
+ workerRunId: input.workerRunId,
179
+ updatedAt: at,
180
+ operatorRecovery,
181
+ });
182
+ await appendTaskPoolEvent(input.repoRoot, {
183
+ schemaVersion: 1,
184
+ at,
185
+ type: "task-pool-mark-failed",
186
+ featureId: input.featureId,
187
+ taskId: input.taskId,
188
+ workerRunId: input.workerRunId,
189
+ reason,
190
+ idempotent: false,
191
+ evidencePath: relativeEvidencePath,
192
+ });
193
+ return {
194
+ schemaVersion: 1,
195
+ status: "Failed",
196
+ taskId: input.taskId,
197
+ featureId: input.featureId,
198
+ workerRunId: input.workerRunId,
199
+ idempotent: false,
200
+ evidencePath: relativeEvidencePath,
201
+ };
202
+ }
203
+ async function validateWorkerDagAssociation(input) {
204
+ const run = await validateWorkerRunOwnership(input);
205
+ const dagLocated = await locateDagRun(input.repoRoot, input.dagRunId);
206
+ if (!dagLocated) {
207
+ throw new Error(`dag run not found: ${input.dagRunId}`);
208
+ }
209
+ const dagState = await readDagRunState(dagLocated.runDir);
210
+ if (dagState.runId !== input.dagRunId) {
211
+ throw new Error(`dag run identity mismatch: expected ${input.dagRunId}, found ${dagState.runId}`);
212
+ }
213
+ const linked = await proveWorkerDagLink(input.repoRoot, run, input.workerRunId, input.dagRunId);
214
+ if (!linked) {
215
+ throw new Error(`worker run ${input.workerRunId} is not associated with dag run ${input.dagRunId}`);
216
+ }
217
+ return { run, dagLocated: { ...dagLocated, runId: input.dagRunId } };
218
+ }
219
+ async function validateWorkerRunOwnership(input) {
220
+ const run = await findRunByWorkerRunId(input.repoRoot, input.workerRunId);
221
+ if (!run) {
222
+ throw new Error(`worker run not found: ${input.workerRunId}`);
223
+ }
224
+ if (run.featureId !== input.featureId || run.taskId !== input.taskId) {
225
+ throw new Error(`worker run ownership mismatch: expected ${input.featureId}/${input.taskId}, found ${run.featureId}/${run.taskId}`);
226
+ }
227
+ return run;
228
+ }
229
+ async function proveWorkerDagLink(repoRoot, run, workerRunId, dagRunId) {
230
+ if (dagRunId === workerRunId)
231
+ return true;
232
+ if (!run.runRecordPath)
233
+ return false;
234
+ try {
235
+ const absolutePath = path.isAbsolute(run.runRecordPath)
236
+ ? run.runRecordPath
237
+ : path.join(repoRoot, run.runRecordPath);
238
+ const record = JSON.parse(await readFile(absolutePath, "utf-8"));
239
+ for (const command of record.commands ?? []) {
240
+ const args = command.args ?? [];
241
+ const runIdIndex = args.indexOf("--run-id");
242
+ if (runIdIndex >= 0 && args[runIdIndex + 1] === dagRunId)
243
+ return true;
244
+ }
245
+ }
246
+ catch {
247
+ return false;
248
+ }
249
+ return false;
250
+ }
251
+ function assertAbandonAllowed(input) {
252
+ if (input.forceAbandonRecoverable)
253
+ return;
254
+ if (input.eligibility.canResume) {
255
+ throw new Error(`dag run ${input.dagRunId} is recoverable via resume; refuse abandon or pass --force-abandon-recoverable with documented operator intent`);
256
+ }
257
+ if (RECOVERABLE_LIVENESS.has(input.liveness)) {
258
+ throw new Error(`dag run ${input.dagRunId} liveness is ${input.liveness}; refuse abandon or pass --force-abandon-recoverable with documented operator intent`);
259
+ }
260
+ }
261
+ async function projectTaskPoolFailed(repoRoot, current, next) {
262
+ await writeTaskPoolState(repoRoot, {
263
+ schemaVersion: 2,
264
+ featureId: next.featureId,
265
+ taskId: next.taskId,
266
+ status: "Failed",
267
+ updatedAt: next.updatedAt,
268
+ workerRunId: next.workerRunId,
269
+ ...(current.lastRunRecordPath ? { lastRunRecordPath: current.lastRunRecordPath } : {}),
270
+ ...(current.failure ? { failure: current.failure } : {}),
271
+ ...(current.retryOfWorkerRunId ? { retryOfWorkerRunId: current.retryOfWorkerRunId } : {}),
272
+ ...(current.retryRequestedAt ? { retryRequestedAt: current.retryRequestedAt } : {}),
273
+ ...(current.retryReason ? { retryReason: current.retryReason } : {}),
274
+ operatorRecovery: next.operatorRecovery,
275
+ });
276
+ }
277
+ function recoveryEvidencePath(repoRoot, featureId, taskId, workerRunId) {
278
+ return path.join(getTaskPoolRoot(repoRoot), "artifacts", "recovery", featureId, taskId, `${workerRunId}.json`);
279
+ }
280
+ async function writeRecoveryEvidence(repoRoot, payload) {
281
+ const evidencePath = recoveryEvidencePath(repoRoot, payload.featureId, payload.taskId, payload.workerRunId);
282
+ await mkdir(path.dirname(evidencePath), { recursive: true });
283
+ await writeFile(evidencePath, `${JSON.stringify({ schemaVersion: 1, ...payload }, null, 2)}\n`, "utf-8");
284
+ return evidencePath;
285
+ }
@@ -124,6 +124,7 @@ export async function runTaskSpec(options) {
124
124
  });
125
125
  progress.step(`run-dag finished in ${formatDuration(Date.now() - runDagStartedAt)}`);
126
126
  const reportStep = "report-decision";
127
+ let capturedReportJson;
127
128
  const reportDecision = await runObservedStep(eventCtx, reportStep, async () => {
128
129
  const reportJson = await client.run([
129
130
  "dag",
@@ -138,6 +139,7 @@ export async function runTaskSpec(options) {
138
139
  artifactName: "dag-report-json",
139
140
  expectJson: true,
140
141
  });
142
+ capturedReportJson = reportJson;
141
143
  const decision = decideFromReport(workerRunId, reportJson);
142
144
  await client.run([
143
145
  "dag",
@@ -153,6 +155,14 @@ export async function runTaskSpec(options) {
153
155
  });
154
156
  return decision;
155
157
  }, { statusForResult: (decision) => decision.succeeded ? "succeeded" : "failed" });
158
+ const dagReportRun = extractDagReportRun(workerRunId, capturedReportJson);
159
+ const declaredArtifacts = options.declaredArtifacts
160
+ ? options.declaredArtifacts.map((entry) => ({
161
+ kind: entry.kind,
162
+ ...(entry.schemaId ? { schemaId: entry.schemaId } : {}),
163
+ ...(entry.path ? { path: entry.path } : {}),
164
+ }))
165
+ : undefined;
156
166
  let status = reportDecision.succeeded ? "succeeded" : "failed";
157
167
  progress.step(`report decision: ${status} (${reportDecision.reason}${reportDecision.runStatus ? `, status=${reportDecision.runStatus}` : ""})`);
158
168
  const failureArtifacts = reportDecision.succeeded
@@ -180,6 +190,7 @@ export async function runTaskSpec(options) {
180
190
  materializeManifest,
181
191
  reportDecision,
182
192
  commands,
193
+ ...(declaredArtifacts ? { declaredArtifacts } : {}),
183
194
  ...(failureArtifacts ? { failureArtifacts } : {}),
184
195
  ...(controllerIdentity ? { controllerIdentity } : {}),
185
196
  };
@@ -202,6 +213,8 @@ export async function runTaskSpec(options) {
202
213
  reportDecision,
203
214
  runRecordPath,
204
215
  dagPath,
216
+ ...(dagReportRun ? { dagReportRun } : {}),
217
+ ...(declaredArtifacts ? { declaredArtifacts } : {}),
205
218
  acceptanceRefs: options.taskSpec.acceptance_refs,
206
219
  now,
207
220
  });
@@ -211,15 +224,34 @@ export async function runTaskSpec(options) {
211
224
  progress.step(`outcome projection failed: ${projection.category} (${projection.reason})`);
212
225
  }
213
226
  else {
214
- const gate = checkRequiredOutputs(projection.envelope, options.taskSpec.outputs.required);
227
+ let envelope = projection.envelope;
228
+ const gate = checkRequiredOutputs(envelope, options.taskSpec.outputs.required);
215
229
  if (reportDecision.succeeded && !gate.passed) {
216
230
  status = "failed";
217
- outcomeFailure = { category: "ContractMismatch", reason: `missing required outputs: ${gate.missing.join(", ")}` };
231
+ outcomeFailure = {
232
+ category: "ContractMismatch",
233
+ reason: `missing required outputs: ${gate.missing.join(", ")}`,
234
+ };
235
+ envelope = {
236
+ ...envelope,
237
+ outcomeStatus: "failed",
238
+ outcomeFailure: {
239
+ category: "ContractMismatch",
240
+ reason: `missing required outputs: ${gate.missing.join(", ")}`,
241
+ missingOutputs: [...gate.missing],
242
+ },
243
+ };
218
244
  progress.step(`required output gate failed: ${gate.missing.join(", ")}`);
219
245
  }
220
- else {
221
- outcome = await writeOutcome(options.repoRoot, projection.envelope);
246
+ else if (envelope.outcomeFailure) {
247
+ outcomeFailure = {
248
+ category: envelope.outcomeFailure.category,
249
+ reason: envelope.outcomeFailure.reason,
250
+ };
222
251
  }
252
+ // Persist succeeded and failed envelopes so read models can inspect
253
+ // outcomeFailure without re-parsing run-record / report decision.
254
+ outcome = await writeOutcome(options.repoRoot, envelope);
223
255
  }
224
256
  if (status === "succeeded" && !options.skipSuccessFinalization) {
225
257
  await runObservedStep(eventCtx, "promote-run", async () => {
@@ -245,6 +277,7 @@ export async function runTaskSpec(options) {
245
277
  materializeManifest,
246
278
  reportDecision,
247
279
  commands,
280
+ ...(declaredArtifacts ? { declaredArtifacts } : {}),
248
281
  ...(failureArtifacts ? { failureArtifacts } : {}),
249
282
  ...(controllerIdentity ? { controllerIdentity } : {}),
250
283
  };
@@ -470,6 +503,50 @@ function decideFromReport(workerRunId, result) {
470
503
  primaryFailure: readProperty(run, "primaryFailure"),
471
504
  };
472
505
  }
506
+ /**
507
+ * Extract the canonical DAG report run fact used by Outcome adapters for
508
+ * structured artifact projection. Returns undefined when the report is missing
509
+ * or does not contain the worker run — adapters then project only canonical
510
+ * runtime artifacts (fail-closed for domain structured kinds).
511
+ */
512
+ function extractDagReportRun(workerRunId, result) {
513
+ if (!result?.ok || !result.json)
514
+ return undefined;
515
+ const runs = readObjectArray(result.json, "runs");
516
+ const run = runs.find((candidate) => readString(candidate, "runId") === workerRunId) ??
517
+ runs[0];
518
+ if (!run)
519
+ return undefined;
520
+ const nodes = readObjectArray(run, "nodes").map((node) => ({
521
+ ...(readString(node, "nodeId") || readString(node, "id")
522
+ ? { id: readString(node, "nodeId") ?? readString(node, "id") }
523
+ : {}),
524
+ ...(readString(node, "status") ? { status: readString(node, "status") } : {}),
525
+ ...(readString(node, "failureCategory")
526
+ ? { failureCategory: readString(node, "failureCategory") }
527
+ : {}),
528
+ ...(readString(node, "stdoutArtifactPath")
529
+ ? { stdoutArtifactPath: readString(node, "stdoutArtifactPath") }
530
+ : {}),
531
+ ...(readString(node, "assistantArtifactPath")
532
+ ? { assistantArtifactPath: readString(node, "assistantArtifactPath") }
533
+ : {}),
534
+ ...(readString(node, "structuredArtifactPath")
535
+ ? { structuredArtifactPath: readString(node, "structuredArtifactPath") }
536
+ : {}),
537
+ ...(readString(node, "structuredArtifactSha256")
538
+ ? { structuredArtifactSha256: readString(node, "structuredArtifactSha256") }
539
+ : {}),
540
+ ...(readString(node, "structuredArtifactSchemaId")
541
+ ? { structuredArtifactSchemaId: readString(node, "structuredArtifactSchemaId") }
542
+ : {}),
543
+ }));
544
+ return {
545
+ ...(readString(run, "runId") ? { runId: readString(run, "runId") } : {}),
546
+ ...(readString(run, "status") ? { status: readString(run, "status") } : {}),
547
+ nodes,
548
+ };
549
+ }
473
550
  class RecordingRunTaskClient {
474
551
  delegate;
475
552
  records;
@@ -158,6 +158,15 @@ export async function runReadyTasks(options) {
158
158
  ...(options.piModel ? { piModel: options.piModel } : {}),
159
159
  ...(controllerIdentity ? { controllerIdentity } : {}),
160
160
  ...(options.controllerExpectation ? { controllerExpectation: options.controllerExpectation } : {}),
161
+ ...(node?.produces && node.produces.length > 0
162
+ ? {
163
+ declaredArtifacts: node.produces.map((entry) => ({
164
+ kind: entry.kind,
165
+ ...(entry.schemaId ? { schemaId: entry.schemaId } : {}),
166
+ ...(entry.path ? { path: entry.path } : {}),
167
+ })),
168
+ }
169
+ : {}),
161
170
  });
162
171
  }
163
172
  catch (error) {
@@ -271,6 +280,17 @@ export async function runReadyTasks(options) {
271
280
  };
272
281
  let recorded = false;
273
282
  try {
283
+ // Checkpoint (onTaskFinalized) must succeed before Task Pool Done for
284
+ // successful runs. Recording Done first left Pool Done with no Git
285
+ // checkpoint when promote/closeout wrote outside allowed_paths.
286
+ if (result.status === "succeeded" && options.onTaskFinalized) {
287
+ await options.onTaskFinalized({
288
+ status: result.status,
289
+ taskSpec,
290
+ workerRunId: result.workerRunId,
291
+ runRecordPath: result.runRecordPath,
292
+ });
293
+ }
274
294
  await recordTaskPoolRun({ repoRoot: options.repoRoot, run });
275
295
  recorded = true;
276
296
  tasks.push({
@@ -281,7 +301,9 @@ export async function runReadyTasks(options) {
281
301
  });
282
302
  }
283
303
  catch (error) {
284
- await options.onTaskFinalized?.({ status: result.status, taskSpec, workerRunId: result.workerRunId, runRecordPath: result.runRecordPath });
304
+ if (result.status !== "succeeded") {
305
+ await options.onTaskFinalized?.({ status: result.status, taskSpec, workerRunId: result.workerRunId, runRecordPath: result.runRecordPath });
306
+ }
285
307
  tasks.push({
286
308
  taskId,
287
309
  workerRunId: result.workerRunId,
@@ -291,8 +313,9 @@ export async function runReadyTasks(options) {
291
313
  });
292
314
  break;
293
315
  }
294
- if (recorded)
316
+ if (recorded && result.status !== "succeeded") {
295
317
  await options.onTaskFinalized?.({ status: result.status, taskSpec, workerRunId: result.workerRunId, runRecordPath: result.runRecordPath });
318
+ }
296
319
  }
297
320
  const summary = summarize(tasks);
298
321
  const batchRunPath = path.join(getTaskPoolRoot(options.repoRoot), "artifacts", batchRunId, "batch-run.json");
@@ -155,12 +155,15 @@ export function planReadyTasks(input) {
155
155
  * Evaluate artifact-gate eligibility for a node whose dependencies are all
156
156
  * `Done`. Returns the first blocking projection, or `undefined` when eligible.
157
157
  *
158
- * Fail-closed: any unverified producer path/hash/schema/feature/producer/source
159
- * binding blocks the consumer as `required-artifact-missing` and never becomes
160
- * Ready. When `outcomes` is not provided, eligibility is skipped (M3 keeps
161
- * callers that have not wired disk injection unchanged). Hash byte-level
162
- * verification is the caller's responsibility before injecting an envelope;
163
- * the planner trusts `envelope.artifacts[].sha256` as a literal.
158
+ * Envelope-level binding only: Feature / producer task / kind / optional
159
+ * schemaId / optional sourceBinding must match the injected Outcome. The
160
+ * planner is pure and synchronous it does **not** re-read artifact files or
161
+ * recompute sha256. Callers must inject envelopes via `readVerifiedOutcome`
162
+ * (path/hash of the outcome JSON). File-level artifact sha256 is enforced at
163
+ * `projectOutcome` and again at Feature Verification Bundle / verify-final /
164
+ * Delivery / Closeout (`verifyArtifactRefs`).
165
+ *
166
+ * When `outcomes` is omitted, this gate is skipped (legacy callers unchanged).
164
167
  */
165
168
  function evaluateArtifactGate(consumes, featureId, states, outcomes, producerKinds) {
166
169
  if (!outcomes || !consumes || consumes.length === 0)
@@ -230,8 +233,11 @@ function evaluateArtifactRef(ref, featureId, states, outcomes) {
230
233
  if (!artifact) {
231
234
  return projection("artifact-kind-absent", `no artifact with kind ${ref.kind}`);
232
235
  }
233
- if (ref.schemaId !== undefined && artifact.kind !== ref.schemaId) {
234
- return projection("schema-mismatch", `artifact kind ${String(artifact.kind)} != schemaId ${ref.schemaId}`);
236
+ if (ref.schemaId !== undefined) {
237
+ const artifactSchemaId = artifact.schemaId ?? artifact.kind;
238
+ if (artifactSchemaId !== ref.schemaId) {
239
+ return projection("schema-mismatch", `artifact schemaId ${String(artifactSchemaId)} != ${ref.schemaId}`);
240
+ }
235
241
  }
236
242
  if (ref.sourceBinding) {
237
243
  const envBinding = envelope.sourceBinding;
@@ -9,9 +9,11 @@ export const artifactDeclSchema = z
9
9
  })
10
10
  .strict();
11
11
  /**
12
- * Consumer-side artifact reference. A node may only trust an upstream Task
13
- * Outcome artifact after deterministic path, hash, schema, feature,
14
- * producer-task and source-binding validation (see ready-planner gate).
12
+ * Consumer-side artifact reference. Ready Planner validates envelope-level
13
+ * Feature / producer / kind / optional schemaId / optional sourceBinding.
14
+ * File-level path+sha256 of artifact bytes is enforced at Outcome projection
15
+ * and Feature Verification Bundle / Delivery / Closeout — not inside the
16
+ * synchronous Ready Planner.
15
17
  */
16
18
  export const artifactRefSchema = z
17
19
  .object({
@@ -0,0 +1,67 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { checkBudgetPreNode, createBudgetLedger, formatBudgetReportMarkdown, isHardBudgetBreached, recordNodeBudgetSample, skipPendingNodesForBudgetBreach, } from "../../application/evaluation/budget.js";
4
+ export function initRunBudgetLedger(state, budget) {
5
+ if (!budget)
6
+ return;
7
+ state.budget = structuredClone(budget);
8
+ state.budgetLedger = createBudgetLedger(budget);
9
+ }
10
+ function wallTimeMs(state, now = Date.now()) {
11
+ return Math.max(0, now - new Date(state.startedAt).getTime());
12
+ }
13
+ function repairPasses(state) {
14
+ return state.convergence?.currentPass ?? 0;
15
+ }
16
+ export function applyHardBudgetBreach(state, breach) {
17
+ if (!state.budgetLedger || state.budgetLedger.mode !== "hard")
18
+ return;
19
+ skipPendingNodesForBudgetBreach(state.nodes, breach);
20
+ state.failureCategory = "budget_breach";
21
+ }
22
+ export function preflightBudgetOrBreach(state) {
23
+ const ledger = state.budgetLedger;
24
+ if (!ledger)
25
+ return undefined;
26
+ const breach = checkBudgetPreNode(ledger, {
27
+ wallTimeMs: wallTimeMs(state),
28
+ repairPasses: repairPasses(state),
29
+ });
30
+ if (breach && isHardBudgetBreached(ledger)) {
31
+ applyHardBudgetBreach(state, breach);
32
+ }
33
+ return breach;
34
+ }
35
+ export function recordFinishedNodeBudget(state, node, contextChars) {
36
+ const ledger = state.budgetLedger;
37
+ if (!ledger)
38
+ return undefined;
39
+ const breach = recordNodeBudgetSample(ledger, {
40
+ nodeId: node.id,
41
+ tokensUsed: node.tokensUsed,
42
+ contextChars,
43
+ wallTimeMs: wallTimeMs(state),
44
+ repairPasses: repairPasses(state),
45
+ });
46
+ if (breach && isHardBudgetBreached(ledger)) {
47
+ applyHardBudgetBreach(state, breach);
48
+ }
49
+ return breach;
50
+ }
51
+ export async function writeBudgetLedgerArtifacts(runDir, ledger) {
52
+ if (!ledger)
53
+ return;
54
+ const jsonPath = path.join(runDir, "budget-ledger.json");
55
+ const mdPath = path.join(runDir, "budget-report.md");
56
+ await writeFile(jsonPath, `${JSON.stringify(ledger, null, 2)}\n`, "utf8");
57
+ await writeFile(mdPath, formatBudgetReportMarkdown(ledger), "utf8");
58
+ }
59
+ export function assertFrozenBudget(specBudget, stateBudget, runId) {
60
+ if (!specBudget && !stateBudget)
61
+ return;
62
+ if (!specBudget ||
63
+ !stateBudget ||
64
+ JSON.stringify(specBudget) !== JSON.stringify(stateBudget)) {
65
+ throw new Error(`dag run ${runId} budget drifted between run.json and state.json; refuse to continue`);
66
+ }
67
+ }