@tea-agent/loop-agent 0.31.0 → 0.32.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 (67) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +60 -0
  3. package/README.md +1 -1
  4. package/dist/executors/dag-pi-executor.js +69 -2
  5. package/dist/executors/pi-sdk-executor.js +61 -0
  6. package/dist/executors/shell-executor.js +136 -79
  7. package/dist/shared/operator/capabilities.js +22 -1
  8. package/dist/shared/operator/command-lifecycle.js +94 -0
  9. package/dist/shared/operator/index.js +1 -0
  10. package/dist/worker/cli.js +10 -24
  11. package/dist/worker/console/doctor.js +11 -4
  12. package/dist/worker/console/observe-health-match.js +18 -15
  13. package/dist/worker/console/operator-actions.js +2 -1
  14. package/dist/worker/feature/acceptance-policy.js +227 -0
  15. package/dist/worker/feature/decision-loader.js +1 -1
  16. package/dist/worker/feature/next-action.js +56 -6
  17. package/dist/worker/feature/profile-schema.js +3 -0
  18. package/dist/worker/feature/reducer.js +1 -0
  19. package/dist/worker/feature/review.js +72 -8
  20. package/dist/worker/feature/scaffold.js +14 -0
  21. package/dist/worker/loop-agent/controller-protocol.js +143 -0
  22. package/dist/worker/materialize/harness-task-lifecycle-probe.js +126 -0
  23. package/dist/worker/materialize/harness-task-lineage.js +220 -0
  24. package/dist/worker/materialize/harness-task-materializer.js +350 -80
  25. package/dist/worker/observability/progress-composite.js +1 -0
  26. package/dist/worker/observability/read-model.js +66 -19
  27. package/dist/worker/pool/attempt-identity.js +41 -0
  28. package/dist/worker/pool/attempt-lease.js +184 -0
  29. package/dist/worker/pool/attempt-transition.js +210 -0
  30. package/dist/worker/pool/begin-attempt-with-lease.js +26 -0
  31. package/dist/worker/pool/begin-attempt.js +35 -0
  32. package/dist/worker/pool/failure-routing.js +49 -0
  33. package/dist/worker/pool/recovery-decision.js +163 -0
  34. package/dist/worker/pool/run-owner-store.js +126 -0
  35. package/dist/worker/pool/run-store.js +32 -46
  36. package/dist/worker/pool/runtime-reconcile-inventory.js +127 -0
  37. package/dist/worker/pool/state-projection.js +57 -0
  38. package/dist/worker/run-task/run-task.js +31 -4
  39. package/dist/worker/runner/run-ready.js +64 -14
  40. package/dist/worker/runner/single-task-attempt.js +42 -13
  41. package/dist/worker/task-graph/acceptance-schema.js +3 -0
  42. package/dist/workflows/dag/backend-test-pytest-collection.js +162 -7
  43. package/dist/workflows/dag/backend-test-result-contract.js +105 -67
  44. package/dist/workflows/dag/backend-test-scenario-param.js +92 -30
  45. package/dist/workflows/dag/backend-test-writer-completeness.js +55 -0
  46. package/dist/workflows/dag/init-hybrid.js +44 -47
  47. package/dist/workflows/dag/rerun-task.js +86 -0
  48. package/docs/README.md +3 -1
  49. package/docs/architecture/evolution.md +1 -1
  50. package/docs/operations/README.md +1 -0
  51. package/docs/templates/README.md +1 -0
  52. package/docs/templates/agent-worker-production-readiness-checklist.md +45 -0
  53. package/docs/templates/backend-test-dag.json +40 -60
  54. package/docs/templates/evaluation/agents-map-slim-v1.md +1 -1
  55. package/docs/templates/evaluation/agents-map-verbose-v0.md +1 -1
  56. package/docs/templates/init-managed-agents.md +1 -1
  57. package/docs/templates/product-line/scaffold-samples/backend-only/acceptance.yaml +3 -0
  58. package/docs/templates/product-line/scaffold-samples/backend-only/feature.yaml +11 -0
  59. package/docs/templates/product-line/scaffold-samples/fe-with-api/acceptance.yaml +3 -0
  60. package/docs/templates/product-line/scaffold-samples/fe-with-api/feature.yaml +11 -0
  61. package/docs/templates/product-line/scaffold-samples/frontend-only/acceptance.yaml +3 -0
  62. package/docs/templates/product-line/scaffold-samples/frontend-only/feature.yaml +11 -0
  63. package/package.json +1 -1
  64. package/skills/agent-worker/SKILL.md +1 -1
  65. package/skills/agent-worker/references/agent-worker-operator.md +1 -1
  66. package/skills/loop-agent/references/command-reference.md +2 -2
  67. package/skills/loop-agent/references/harness-policy.md +1 -1
@@ -6,7 +6,7 @@ import { getTaskPaths } from "../../task/runtime.js";
6
6
  import { formatDuration, noopProgressReporter, } from "../progress-reporter.js";
7
7
  import { redactForPreview, truncatePreview } from "../observability/events.js";
8
8
  import { controllerIdentityExpectationFailure, resolveControllerIdentity, } from "../loop-agent/loop-agent-client.js";
9
- import { materializeTaskSpec, } from "../materialize/harness-task-materializer.js";
9
+ import { materializeTaskSpecV2, } from "../materialize/harness-task-materializer.js";
10
10
  import { preflightTargetRepo } from "../preflight.js";
11
11
  import { getTaskPoolRoot } from "../pool/run-store.js";
12
12
  import { checkRequiredOutputs } from "../outcomes/gate.js";
@@ -26,7 +26,12 @@ export async function runTaskSpec(options) {
26
26
  const commands = [];
27
27
  const progress = options.progress ?? noopProgressReporter;
28
28
  const taskId = options.taskSpec.id;
29
- const eventCtx = { progress, workerRunId, taskId };
29
+ const eventCtx = {
30
+ progress,
31
+ workerRunId,
32
+ taskId,
33
+ featureId: options.taskSpec.feature_id,
34
+ };
30
35
  const recordingClient = new RecordingRunTaskClient(options.client, commands);
31
36
  const client = new ObservedRunTaskClient(recordingClient, eventCtx);
32
37
  if (options.preflight !== false) {
@@ -57,9 +62,9 @@ export async function runTaskSpec(options) {
57
62
  }
58
63
  const materializeStep = "materialize";
59
64
  const materializeStartedAt = Date.now();
60
- const materializeManifest = await runObservedStep(eventCtx, materializeStep, async () => {
65
+ const materializeResult = await runObservedStep(eventCtx, materializeStep, async () => {
61
66
  progress.step("materialize harness task");
62
- return materializeTaskSpec({
67
+ return materializeTaskSpecV2({
63
68
  repoRoot: options.repoRoot,
64
69
  taskSpec: options.taskSpec,
65
70
  taskSpecPath: options.taskSpecPath,
@@ -67,6 +72,14 @@ export async function runTaskSpec(options) {
67
72
  now,
68
73
  });
69
74
  });
75
+ const materializeManifest = materializeResult.manifest;
76
+ const materializeOutcome = materializeResult.outcome;
77
+ if (materializeOutcome.type === "recovery-required") {
78
+ throw new Error(`materialize recovery required (${materializeOutcome.recovery.recommendedAction}): ${materializeOutcome.recovery.reason}`);
79
+ }
80
+ if (materializeOutcome.type === "resume-existing") {
81
+ progress.note(`materialize resume-existing: revision ${materializeOutcome.revisionId}, run ${materializeOutcome.runId}`);
82
+ }
70
83
  progress.step(`materialized ${materializeManifest.harnessTaskId} in ${formatDuration(Date.now() - materializeStartedAt)}`);
71
84
  const taskArtifactsDir = path.join(options.repoRoot, ".harness", "tasks", materializeManifest.harnessTaskId, "artifacts");
72
85
  await mkdir(taskArtifactsDir, { recursive: true });
@@ -230,6 +243,9 @@ export async function runTaskSpec(options) {
230
243
  dagPath,
231
244
  runRecordPath,
232
245
  materializeManifest,
246
+ materializeOutcome,
247
+ lineageId: materializeManifest.lineageId,
248
+ revisionId: materializeManifest.revisionId,
233
249
  reportDecision,
234
250
  commands,
235
251
  ...(declaredArtifacts ? { declaredArtifacts } : {}),
@@ -317,6 +333,9 @@ export async function runTaskSpec(options) {
317
333
  dagPath,
318
334
  runRecordPath,
319
335
  materializeManifest,
336
+ materializeOutcome,
337
+ lineageId: materializeManifest.lineageId,
338
+ revisionId: materializeManifest.revisionId,
320
339
  reportDecision,
321
340
  commands,
322
341
  ...(declaredArtifacts ? { declaredArtifacts } : {}),
@@ -329,6 +348,7 @@ export async function runTaskSpec(options) {
329
348
  source: "artifact",
330
349
  label: "worker-run-record",
331
350
  workerRunId,
351
+ featureId: options.taskSpec.feature_id,
332
352
  taskId,
333
353
  artifactRefs: {
334
354
  runRecordPath,
@@ -496,6 +516,7 @@ function emitStepStarted(ctx, stepName) {
496
516
  spanId: `step:${ctx.workerRunId}:${stepName}`,
497
517
  parentSpanId: ctx.workerRunId,
498
518
  workerRunId: ctx.workerRunId,
519
+ featureId: ctx.featureId,
499
520
  taskId: ctx.taskId,
500
521
  status: "running",
501
522
  });
@@ -508,6 +529,7 @@ function emitStepFinished(ctx, stepName, status, durationMs) {
508
529
  spanId: `step:${ctx.workerRunId}:${stepName}`,
509
530
  parentSpanId: ctx.workerRunId,
510
531
  workerRunId: ctx.workerRunId,
532
+ featureId: ctx.featureId,
511
533
  taskId: ctx.taskId,
512
534
  status,
513
535
  durationMs,
@@ -760,6 +782,7 @@ class ObservedRunTaskClient {
760
782
  source: "loop-agent-command",
761
783
  label: options.artifactName,
762
784
  workerRunId: this.eventCtx.workerRunId,
785
+ featureId: this.eventCtx.featureId,
763
786
  taskId: this.eventCtx.taskId,
764
787
  pid: info.pid,
765
788
  timeoutMs: options.timeoutMs,
@@ -783,6 +806,7 @@ class ObservedRunTaskClient {
783
806
  source: "loop-agent-command",
784
807
  label: options.artifactName,
785
808
  workerRunId: this.eventCtx.workerRunId,
809
+ featureId: this.eventCtx.featureId,
786
810
  taskId: this.eventCtx.taskId,
787
811
  message: `elapsed ${info.elapsedMs}ms`,
788
812
  });
@@ -795,6 +819,7 @@ class ObservedRunTaskClient {
795
819
  source: "loop-agent-command",
796
820
  label: options.artifactName,
797
821
  workerRunId: this.eventCtx.workerRunId,
822
+ featureId: this.eventCtx.featureId,
798
823
  taskId: this.eventCtx.taskId,
799
824
  exitCode: result.exitCode,
800
825
  timedOut: result.timedOut,
@@ -810,6 +835,7 @@ class ObservedRunTaskClient {
810
835
  source: "loop-agent-command",
811
836
  label: options.artifactName,
812
837
  workerRunId: this.eventCtx.workerRunId,
838
+ featureId: this.eventCtx.featureId,
813
839
  taskId: this.eventCtx.taskId,
814
840
  status: "failed",
815
841
  message: errorMessage(error),
@@ -823,6 +849,7 @@ class ObservedRunTaskClient {
823
849
  source: "loop-agent-command",
824
850
  label,
825
851
  workerRunId: this.eventCtx.workerRunId,
852
+ featureId: this.eventCtx.featureId,
826
853
  taskId: this.eventCtx.taskId,
827
854
  outputPreview: redactForPreview(truncatePreview(chunk)),
828
855
  });
@@ -2,9 +2,13 @@ import { createHash } from "node:crypto";
2
2
  import { readFile, writeFile, mkdir } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import YAML from "yaml";
5
+ import { controllerIdentityAnchor } from "../../shared/package-metadata.js";
5
6
  import { controllerIdentitiesMatch, controllerIdentityExpectationFailure, resolveControllerIdentity, } from "../loop-agent/loop-agent-client.js";
7
+ import { buildPublishedControllerProtocolEnvelope } from "../loop-agent/controller-protocol.js";
6
8
  import { deriveFailureRoute, deriveFailureRouteFromError, } from "../pool/failure-routing.js";
7
9
  import { findRunByWorkerRunId, getTaskPoolRoot, readFeatureTaskPoolStates, recordTaskPoolRun, writeTaskPoolState, } from "../pool/run-store.js";
10
+ import { releaseAttemptLease } from "../pool/attempt-lease.js";
11
+ import { beginWorkerAttemptWithLease } from "../pool/begin-attempt-with-lease.js";
8
12
  import { readVerifiedOutcome } from "../outcomes/store.js";
9
13
  import { runTaskSpec, } from "../run-task/run-task.js";
10
14
  import { formatDuration, noopProgressReporter, } from "../progress-reporter.js";
@@ -100,6 +104,7 @@ export async function runReadyTasks(options) {
100
104
  source: "worker",
101
105
  label: `task ${taskId}: reuse existing run ${workerRunId}`,
102
106
  batchRunId,
107
+ featureId,
103
108
  taskId,
104
109
  workerRunId,
105
110
  status: "reused",
@@ -116,32 +121,44 @@ export async function runReadyTasks(options) {
116
121
  }
117
122
  const taskStartedAt = Date.now();
118
123
  progress.task(`task ${index}/${total} ${taskId} "${taskSpec.title}" (${workerRunId})`);
119
- emit(progress, {
120
- type: "task.started",
121
- source: "worker",
122
- label: `task ${index}/${total} ${taskId}`,
123
- batchRunId,
124
- taskId,
125
- workerRunId,
126
- status: "running",
127
- });
128
124
  progress.step(`materialize → task advance → report`);
129
125
  let result;
126
+ let leaseToken;
130
127
  try {
131
- await writeTaskPoolState(options.repoRoot, {
132
- schemaVersion: 2,
128
+ const begun = await beginWorkerAttemptWithLease({
129
+ repoRoot: options.repoRoot,
130
+ featureId,
131
+ taskId,
132
+ workerRunId,
133
+ batchRunId,
134
+ now,
135
+ ...(controllerIdentity
136
+ ? {
137
+ controllerAnchor: controllerIdentityAnchor(controllerIdentity),
138
+ protocolEnvelope: buildPublishedControllerProtocolEnvelope(controllerIdentity.packageVersion),
139
+ }
140
+ : {}),
141
+ stateExtras: {
142
+ ...(retryOfWorkerRunId ? { retryOfWorkerRunId } : {}),
143
+ },
144
+ });
145
+ leaseToken = begun.leaseRegistration.lease.token;
146
+ emit(progress, {
147
+ type: "task.started",
148
+ source: "worker",
149
+ label: `task ${index}/${total} ${taskId}`,
150
+ batchRunId,
133
151
  featureId,
134
152
  taskId,
135
- status: "Running",
136
- updatedAt: new Date().toISOString(),
137
153
  workerRunId,
138
- ...(retryOfWorkerRunId ? { retryOfWorkerRunId } : {}),
154
+ status: "running",
139
155
  });
140
156
  emit(progress, {
141
157
  type: "state.updated",
142
158
  source: "worker",
143
159
  label: `task pool state: ${taskId} → Running`,
144
160
  batchRunId,
161
+ featureId,
145
162
  taskId,
146
163
  workerRunId,
147
164
  status: "running",
@@ -178,6 +195,7 @@ export async function runReadyTasks(options) {
178
195
  source: "worker",
179
196
  label: `task ${taskId} run-error`,
180
197
  batchRunId,
198
+ featureId,
181
199
  taskId,
182
200
  workerRunId,
183
201
  status: "failed",
@@ -215,6 +233,16 @@ export async function runReadyTasks(options) {
215
233
  }
216
234
  if (recorded)
217
235
  await options.onTaskFinalized?.({ status: "run-error", taskSpec, workerRunId });
236
+ if (leaseToken) {
237
+ await releaseAttemptLease({
238
+ repoRoot: options.repoRoot,
239
+ featureId,
240
+ taskId,
241
+ token: leaseToken,
242
+ workerRunId,
243
+ now,
244
+ }).catch(() => { });
245
+ }
218
246
  tasks.push({
219
247
  taskId,
220
248
  workerRunId,
@@ -236,6 +264,7 @@ export async function runReadyTasks(options) {
236
264
  source: "worker",
237
265
  label: `task ${taskId} ${result.status}`,
238
266
  batchRunId,
267
+ featureId,
239
268
  taskId,
240
269
  workerRunId: result.workerRunId,
241
270
  status: result.status,
@@ -247,6 +276,7 @@ export async function runReadyTasks(options) {
247
276
  source: "worker",
248
277
  label: `failure routed: ${failure.category}`,
249
278
  batchRunId,
279
+ featureId,
250
280
  taskId,
251
281
  workerRunId: result.workerRunId,
252
282
  failureCategory: failure.category,
@@ -299,6 +329,16 @@ export async function runReadyTasks(options) {
299
329
  status: result.status,
300
330
  runRecordPath: result.runRecordPath,
301
331
  });
332
+ if (leaseToken) {
333
+ await releaseAttemptLease({
334
+ repoRoot: options.repoRoot,
335
+ featureId,
336
+ taskId,
337
+ token: leaseToken,
338
+ workerRunId: result.workerRunId,
339
+ now,
340
+ }).catch(() => { });
341
+ }
302
342
  }
303
343
  catch (error) {
304
344
  if (result.status !== "succeeded") {
@@ -344,6 +384,16 @@ export async function runReadyTasks(options) {
344
384
  catch {
345
385
  // Best-effort; batch still reports record-error.
346
386
  }
387
+ if (leaseToken) {
388
+ await releaseAttemptLease({
389
+ repoRoot: options.repoRoot,
390
+ featureId,
391
+ taskId,
392
+ token: leaseToken,
393
+ workerRunId: result.workerRunId,
394
+ now,
395
+ }).catch(() => { });
396
+ }
347
397
  tasks.push({
348
398
  taskId,
349
399
  workerRunId: result.workerRunId,
@@ -1,4 +1,8 @@
1
+ import { controllerIdentityAnchor } from "../../shared/package-metadata.js";
2
+ import { buildPublishedControllerProtocolEnvelope } from "../loop-agent/controller-protocol.js";
3
+ import { releaseAttemptLease } from "../pool/attempt-lease.js";
1
4
  import { deriveFailureRoute, deriveFailureRouteFromError, } from "../pool/failure-routing.js";
5
+ import { beginWorkerAttemptWithLease } from "../pool/begin-attempt-with-lease.js";
2
6
  import { recordTaskPoolRun, writeTaskPoolState } from "../pool/run-store.js";
3
7
  /**
4
8
  * Shared Worker attempt used by batch run-ready and Night Scheduler.
@@ -7,24 +11,33 @@ import { recordTaskPoolRun, writeTaskPoolState } from "../pool/run-store.js";
7
11
  export async function runSingleTaskAttempt(input) {
8
12
  const now = input.ctx.now ?? new Date();
9
13
  const { ctx } = input;
10
- await writeTaskPoolState(ctx.controlRepoRoot, {
11
- schemaVersion: 2,
14
+ const begun = await beginWorkerAttemptWithLease({
15
+ repoRoot: ctx.controlRepoRoot,
12
16
  featureId: ctx.featureId,
13
17
  taskId: ctx.taskId,
14
- status: "Running",
15
- updatedAt: now.toISOString(),
16
18
  workerRunId: ctx.workerRunId,
17
- ...(ctx.night?.nightScheduleId
18
- ? { nightScheduleId: ctx.night.nightScheduleId }
19
- : {}),
20
- ...(ctx.night?.admissionPath
21
- ? { admissionPath: ctx.night.admissionPath }
22
- : {}),
23
- ...(ctx.night?.nightWorktreePath
24
- ? { nightWorktreePath: ctx.night.nightWorktreePath }
19
+ batchRunId: ctx.batchRunId,
20
+ now,
21
+ ...(ctx.controllerIdentity
22
+ ? {
23
+ controllerAnchor: controllerIdentityAnchor(ctx.controllerIdentity),
24
+ protocolEnvelope: buildPublishedControllerProtocolEnvelope(ctx.controllerIdentity.packageVersion),
25
+ }
25
26
  : {}),
26
- ...(ctx.night?.nightBranch ? { nightBranch: ctx.night.nightBranch } : {}),
27
+ stateExtras: {
28
+ ...(ctx.night?.nightScheduleId
29
+ ? { nightScheduleId: ctx.night.nightScheduleId }
30
+ : {}),
31
+ ...(ctx.night?.admissionPath
32
+ ? { admissionPath: ctx.night.admissionPath }
33
+ : {}),
34
+ ...(ctx.night?.nightWorktreePath
35
+ ? { nightWorktreePath: ctx.night.nightWorktreePath }
36
+ : {}),
37
+ ...(ctx.night?.nightBranch ? { nightBranch: ctx.night.nightBranch } : {}),
38
+ },
27
39
  });
40
+ const leaseToken = begun.leaseRegistration.lease.token;
28
41
  let result;
29
42
  try {
30
43
  result = await input.execute(ctx);
@@ -70,6 +83,14 @@ export async function runSingleTaskAttempt(input) {
70
83
  : {}),
71
84
  ...(ctx.night?.nightBranch ? { nightBranch: ctx.night.nightBranch } : {}),
72
85
  });
86
+ await releaseAttemptLease({
87
+ repoRoot: ctx.controlRepoRoot,
88
+ featureId: ctx.featureId,
89
+ taskId: ctx.taskId,
90
+ token: leaseToken,
91
+ workerRunId: ctx.workerRunId,
92
+ now,
93
+ }).catch(() => { });
73
94
  return {
74
95
  status: "run-error",
75
96
  workerRunId: ctx.workerRunId,
@@ -137,6 +158,14 @@ export async function runSingleTaskAttempt(input) {
137
158
  ...(ctx.night?.nightBranch ? { nightBranch: ctx.night.nightBranch } : {}),
138
159
  };
139
160
  await writeTaskPoolState(ctx.controlRepoRoot, poolState);
161
+ await releaseAttemptLease({
162
+ repoRoot: ctx.controlRepoRoot,
163
+ featureId: ctx.featureId,
164
+ taskId: ctx.taskId,
165
+ token: leaseToken,
166
+ workerRunId: ctx.workerRunId,
167
+ now,
168
+ }).catch(() => { });
140
169
  return {
141
170
  status: result.status,
142
171
  workerRunId: result.workerRunId,
@@ -21,6 +21,9 @@ export const acceptanceItemSchema = z
21
21
  implementation_task_refs: z.array(z.string().min(1)).optional(),
22
22
  verification_task_refs: z.array(z.string().min(1)).optional(),
23
23
  required_evidence: z.array(z.string().min(1)).optional(),
24
+ /** Phase 6: ADR 0007 authority / evidence kinds on required ACs. */
25
+ authority: z.string().min(1).optional(),
26
+ required_evidence_kinds: z.array(z.string().min(1)).optional(),
24
27
  integration: z
25
28
  .enum(["not-applicable", "mock-allowed", "real-required"])
26
29
  .optional(),
@@ -11,12 +11,16 @@ export const backendPytestCollectionFindingSchema = z.object({
11
11
  detail: z.string().min(1),
12
12
  }).strict();
13
13
  export const backendPytestCollectionFactsSchema = z.object({
14
- schemaId: z.literal("backend-test-pytest-collection-v2"),
14
+ schemaId: z.literal("backend-test-pytest-collection-v3"),
15
15
  phase: z.enum(["initial", "final", "effective"]),
16
16
  status: z.enum(["PASS", "REPAIRABLE", "BLOCKED"]),
17
17
  repairEligible: z.boolean(),
18
18
  repairAttempt: z.number().int().min(0).max(1),
19
19
  collectionAttempted: z.boolean(),
20
+ fixtureResolutionAttempted: z.boolean(),
21
+ fixtureResolutionStatus: z.enum(["NOT_RUN", "PASS", "REPAIRABLE", "BLOCKED"]),
22
+ fixtureResolutionExitCode: z.number().int().nullable(),
23
+ repairPaths: z.array(z.string()),
20
24
  mappedScripts: z.array(z.string()).min(1),
21
25
  existingMappedScripts: z.array(z.string()),
22
26
  missingMappedScripts: z.array(z.string()),
@@ -28,17 +32,37 @@ export const backendPytestCollectionFactsSchema = z.object({
28
32
  findings: z.array(backendPytestCollectionFindingSchema),
29
33
  stdoutExcerpt: z.string(),
30
34
  stderrExcerpt: z.string(),
35
+ fixtureStdoutExcerpt: z.string(),
36
+ fixtureStderrExcerpt: z.string(),
31
37
  collectionSource: z.enum(["initial", "final"]).optional(),
32
38
  }).strict().superRefine((facts, context) => {
33
39
  if (facts.status === "PASS") {
34
- if (!facts.collectionAttempted || facts.pytestExitCode !== 0 || facts.missingMappedScripts.length > 0 || facts.assetFiles.length === 0) {
35
- context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest collection PASS requires attempted collection, exit 0, complete mapped scripts and bound assets" });
40
+ if (!facts.collectionAttempted || facts.pytestExitCode !== 0 || !facts.fixtureResolutionAttempted || facts.fixtureResolutionStatus !== "PASS" || facts.fixtureResolutionExitCode !== 0 || facts.missingMappedScripts.length > 0 || facts.assetFiles.length === 0) {
41
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest collection PASS requires collection and fixture-resolution PASS, complete mapped scripts and bound assets" });
36
42
  }
37
43
  }
38
44
  if (!facts.collectionAttempted && facts.pytestExitCode !== null) {
39
45
  context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest collection without an attempt cannot have an exit code" });
40
46
  }
47
+ if (!facts.fixtureResolutionAttempted && facts.fixtureResolutionExitCode !== null) {
48
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest fixture resolution without an attempt cannot have an exit code" });
49
+ }
41
50
  });
51
+ export const backendTestExecutionReadinessSchema = z.object({
52
+ schemaId: z.literal("backend-test-execution-readiness-v1"),
53
+ status: z.enum(["PASS", "PARTIAL", "BLOCKED"]),
54
+ collectionStatus: z.literal("PASS"),
55
+ fixtureResolutionStatus: z.literal("PASS"),
56
+ scenarioParamStatus: z.enum(["PASS", "PARTIAL", "FAIL", "UNAVAILABLE"]),
57
+ repairAttempts: z.object({
58
+ collection: z.number().int().min(0).max(1),
59
+ scenarioParam: z.number().int().min(0).max(1),
60
+ }).strict(),
61
+ mappedScripts: z.array(z.string()).min(1),
62
+ collectedItemIds: z.array(z.string()),
63
+ fixtureIssues: z.array(z.string()),
64
+ assetHashes: z.record(z.string(), z.string().regex(SHA256)),
65
+ }).strict();
42
66
  function repoRef(workspaceRoot, absolutePath) {
43
67
  return path.relative(workspaceRoot, absolutePath).replaceAll(path.sep, "/");
44
68
  }
@@ -106,6 +130,28 @@ function collectionItems(stdout) {
106
130
  return /^testcase\//.test(line.replaceAll("\\", "/"));
107
131
  }).map((line) => line.replaceAll("\\", "/")))];
108
132
  }
133
+ function testcasePythonPaths(output) {
134
+ return [...new Set((output.replaceAll("\\", "/").match(/testcase\/[A-Za-z0-9_./-]+\.py/gi) ?? []).map((item) => item.replace(/:\d+$/, "")))].sort();
135
+ }
136
+ function classifyFixtureResolutionFailure(output) {
137
+ const normalized = output.replaceAll("\\", "/");
138
+ const paths = testcasePythonPaths(normalized);
139
+ const generatedProviderPaths = paths.filter((item) => /testcase\/(?:helpers|factories)\//.test(item));
140
+ if (/fixture ['"][^'"]+['"] not found/i.test(normalized) && generatedProviderPaths.length > 0) {
141
+ return {
142
+ status: "REPAIRABLE",
143
+ kind: "missing-generated-fixture",
144
+ detail: "generated pytest fixture dependency or plugin registration is incomplete",
145
+ repairPaths: generatedProviderPaths,
146
+ };
147
+ }
148
+ return {
149
+ status: "BLOCKED",
150
+ kind: "unresolved-fixture-dependency",
151
+ detail: "fixture resolution failed without a safely attributable generated provider",
152
+ repairPaths: paths,
153
+ };
154
+ }
109
155
  function classifyCollectionFailure(output) {
110
156
  const normalized = output.replaceAll("\\", "/");
111
157
  const blockedPatterns = [
@@ -134,14 +180,19 @@ function classifyCollectionFailure(output) {
134
180
  }
135
181
  export function assessBackendPytestCollection(input) {
136
182
  const items = collectionItems(input.stdout);
137
- if (input.exitCode === 0) {
183
+ const fixtureResolution = input.fixtureResolution ?? { exitCode: 0, stdout: "fixture resolution assumed by direct assessor", stderr: "" };
184
+ if (input.exitCode === 0 && fixtureResolution.exitCode === 0) {
138
185
  return backendPytestCollectionFactsSchema.parse({
139
- schemaId: "backend-test-pytest-collection-v2",
186
+ schemaId: "backend-test-pytest-collection-v3",
140
187
  phase: input.phase,
141
188
  status: "PASS",
142
189
  repairEligible: false,
143
190
  repairAttempt: input.phase === "final" ? 1 : 0,
144
191
  collectionAttempted: true,
192
+ fixtureResolutionAttempted: true,
193
+ fixtureResolutionStatus: "PASS",
194
+ fixtureResolutionExitCode: 0,
195
+ repairPaths: [],
145
196
  mappedScripts: input.inventory.mappedScripts,
146
197
  existingMappedScripts: input.inventory.mappedScripts,
147
198
  missingMappedScripts: [],
@@ -153,16 +204,55 @@ export function assessBackendPytestCollection(input) {
153
204
  findings: [],
154
205
  stdoutExcerpt: bounded(input.stdout),
155
206
  stderrExcerpt: bounded(input.stderr),
207
+ fixtureStdoutExcerpt: bounded(fixtureResolution.stdout),
208
+ fixtureStderrExcerpt: bounded(fixtureResolution.stderr),
209
+ });
210
+ }
211
+ if (input.exitCode === 0) {
212
+ const classification = classifyFixtureResolutionFailure(`${fixtureResolution.stdout}\n${fixtureResolution.stderr}`);
213
+ return backendPytestCollectionFactsSchema.parse({
214
+ schemaId: "backend-test-pytest-collection-v3",
215
+ phase: input.phase,
216
+ status: classification.status,
217
+ repairEligible: input.phase === "initial" && classification.status === "REPAIRABLE",
218
+ repairAttempt: input.phase === "final" ? 1 : 0,
219
+ collectionAttempted: true,
220
+ fixtureResolutionAttempted: true,
221
+ fixtureResolutionStatus: classification.status,
222
+ fixtureResolutionExitCode: fixtureResolution.exitCode,
223
+ repairPaths: classification.repairPaths,
224
+ mappedScripts: input.inventory.mappedScripts,
225
+ existingMappedScripts: input.inventory.mappedScripts,
226
+ missingMappedScripts: [],
227
+ assetFiles: input.inventory.assetFiles,
228
+ inputHashes: input.inventory.inputHashes,
229
+ pytestExitCode: input.exitCode,
230
+ collectedItemCount: items.length,
231
+ collectedItemIds: items,
232
+ findings: [{
233
+ kind: classification.kind,
234
+ classification: "test-asset-defect",
235
+ repairability: classification.status === "REPAIRABLE" ? "repairable" : "blocked",
236
+ detail: classification.detail,
237
+ }],
238
+ stdoutExcerpt: bounded(input.stdout),
239
+ stderrExcerpt: bounded(input.stderr),
240
+ fixtureStdoutExcerpt: bounded(fixtureResolution.stdout),
241
+ fixtureStderrExcerpt: bounded(fixtureResolution.stderr),
156
242
  });
157
243
  }
158
244
  const classification = classifyCollectionFailure(`${input.stdout}\n${input.stderr}`);
159
245
  return backendPytestCollectionFactsSchema.parse({
160
- schemaId: "backend-test-pytest-collection-v2",
246
+ schemaId: "backend-test-pytest-collection-v3",
161
247
  phase: input.phase,
162
248
  status: classification.status,
163
249
  repairEligible: input.phase === "initial" && classification.status === "REPAIRABLE",
164
250
  repairAttempt: input.phase === "final" ? 1 : 0,
165
251
  collectionAttempted: true,
252
+ fixtureResolutionAttempted: false,
253
+ fixtureResolutionStatus: "NOT_RUN",
254
+ fixtureResolutionExitCode: null,
255
+ repairPaths: testcasePythonPaths(`${input.stdout}\n${input.stderr}`),
166
256
  mappedScripts: input.inventory.mappedScripts,
167
257
  existingMappedScripts: input.inventory.mappedScripts,
168
258
  missingMappedScripts: [],
@@ -179,16 +269,22 @@ export function assessBackendPytestCollection(input) {
179
269
  }],
180
270
  stdoutExcerpt: bounded(input.stdout),
181
271
  stderrExcerpt: bounded(input.stderr),
272
+ fixtureStdoutExcerpt: "",
273
+ fixtureStderrExcerpt: "",
182
274
  });
183
275
  }
184
276
  export function assessMissingBackendPytestScripts(input) {
185
277
  return backendPytestCollectionFactsSchema.parse({
186
- schemaId: "backend-test-pytest-collection-v2",
278
+ schemaId: "backend-test-pytest-collection-v3",
187
279
  phase: "initial",
188
280
  status: "REPAIRABLE",
189
281
  repairEligible: true,
190
282
  repairAttempt: 0,
191
283
  collectionAttempted: false,
284
+ fixtureResolutionAttempted: false,
285
+ fixtureResolutionStatus: "NOT_RUN",
286
+ fixtureResolutionExitCode: null,
287
+ repairPaths: [...input.missingMappedScripts],
192
288
  mappedScripts: [...input.mappedScripts],
193
289
  existingMappedScripts: [...input.existingMappedScripts],
194
290
  missingMappedScripts: [...input.missingMappedScripts],
@@ -205,6 +301,8 @@ export function assessMissingBackendPytestScripts(input) {
205
301
  })),
206
302
  stdoutExcerpt: "",
207
303
  stderrExcerpt: "",
304
+ fixtureStdoutExcerpt: "",
305
+ fixtureStderrExcerpt: "",
208
306
  });
209
307
  }
210
308
  export function renderBackendPytestCollectionReport(facts) {
@@ -219,6 +317,10 @@ export function renderBackendPytestCollectionReport(facts) {
219
317
  `- Repair attempt: ${facts.repairAttempt}`,
220
318
  `- Collection attempted: ${facts.collectionAttempted}`,
221
319
  `- Pytest exit code: ${facts.pytestExitCode ?? "not-run"}`,
320
+ `- Fixture resolution attempted: ${facts.fixtureResolutionAttempted}`,
321
+ `- Fixture resolution status: ${facts.fixtureResolutionStatus}`,
322
+ `- Fixture resolution exit code: ${facts.fixtureResolutionExitCode ?? "not-run"}`,
323
+ `- Repair paths: ${facts.repairPaths.join(", ") || "none"}`,
222
324
  `- Mapped scripts: ${facts.mappedScripts.length}`,
223
325
  `- Existing mapped scripts: ${facts.existingMappedScripts.length}`,
224
326
  `- Missing mapped scripts: ${facts.missingMappedScripts.length}`,
@@ -245,6 +347,18 @@ export function renderBackendPytestCollectionReport(facts) {
245
347
  facts.stderrExcerpt,
246
348
  "```",
247
349
  "",
350
+ "## Fixture Resolution stdout",
351
+ "",
352
+ "```text",
353
+ facts.fixtureStdoutExcerpt,
354
+ "```",
355
+ "",
356
+ "## Fixture Resolution stderr",
357
+ "",
358
+ "```text",
359
+ facts.fixtureStderrExcerpt,
360
+ "```",
361
+ "",
248
362
  ].join("\n");
249
363
  }
250
364
  export async function writeBackendPytestCollectionArtifacts(input) {
@@ -289,6 +403,47 @@ function assertSameInventory(expected, actual) {
289
403
  throw new Error(`backend pytest collection hash drift: ${file}`);
290
404
  }
291
405
  }
406
+ export async function materializeBackendTestExecutionReadiness(input) {
407
+ if (input.effective.phase !== "effective" || input.effective.status !== "PASS" || input.effective.fixtureResolutionStatus !== "PASS") {
408
+ throw new Error("backend-test execution readiness requires effective collection and fixture-resolution PASS");
409
+ }
410
+ const current = await buildBackendPytestAssetInventory(input.workspaceRoot, input.effective.mappedScripts);
411
+ assertSameInventory(input.effective, current);
412
+ const status = input.scenarioParamStatus === "FAIL"
413
+ ? "BLOCKED"
414
+ : input.scenarioParamStatus === "PASS"
415
+ ? "PASS"
416
+ : "PARTIAL";
417
+ const readiness = backendTestExecutionReadinessSchema.parse({
418
+ schemaId: "backend-test-execution-readiness-v1",
419
+ status,
420
+ collectionStatus: "PASS",
421
+ fixtureResolutionStatus: "PASS",
422
+ scenarioParamStatus: input.scenarioParamStatus,
423
+ repairAttempts: {
424
+ collection: input.effective.repairAttempt,
425
+ scenarioParam: input.scenarioParamRepairAttempt,
426
+ },
427
+ mappedScripts: input.effective.mappedScripts,
428
+ collectedItemIds: input.effective.collectedItemIds,
429
+ fixtureIssues: input.effective.findings.filter((item) => /fixture/i.test(item.kind)).map((item) => item.detail),
430
+ assetHashes: input.effective.inputHashes,
431
+ });
432
+ const contractsDir = path.join(input.runDir, "contracts");
433
+ await mkdir(contractsDir, { recursive: true });
434
+ await writeFile(path.join(contractsDir, "backend-test-execution-readiness.json"), `${JSON.stringify(readiness, null, 2)}\n`, "utf8");
435
+ return readiness;
436
+ }
437
+ export async function readBackendTestExecutionReadiness(filePath) {
438
+ return backendTestExecutionReadinessSchema.parse(JSON.parse(await readFile(filePath, "utf8")));
439
+ }
440
+ export async function assertBackendTestExecutionReadinessFresh(workspaceRoot, readiness) {
441
+ if (!["PASS", "PARTIAL"].includes(readiness.status)) {
442
+ throw new Error(`backend pytest execution readiness is ${readiness.status}`);
443
+ }
444
+ const current = await buildBackendPytestAssetInventory(workspaceRoot, readiness.mappedScripts);
445
+ assertSameInventory({ mappedScripts: readiness.mappedScripts, assetFiles: Object.keys(readiness.assetHashes), inputHashes: readiness.assetHashes }, current);
446
+ }
292
447
  export async function assertBackendPytestCollectionFresh(workspaceRoot, effective) {
293
448
  if (effective.phase !== "effective" || effective.status !== "PASS") {
294
449
  throw new Error("backend pytest execution requires effective collection PASS facts");