@tea-agent/loop-agent 0.31.0 → 0.31.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.31.1] - 2026-08-09
6
+
7
+ ### 重点更新
8
+
9
+ - 后端测试生成新增确定性冲突预检与执行就绪性校验,在执行前精准拦截潜在错误并安全自动修复局部缺陷
10
+ - 更精细地区分多种写入失败状态(如思考耗尽、部分写入等),避免无效重试并优先恢复可用进度
11
+ - 收紧测试参数校验与结果真实性,准确区分失败与错误,并前置语法门禁防止生成错误代码
12
+ - 优化 16 个节点的执行成本,将多个审查与计划环节改为确定性交接,在保持质量的同时显著降低资源消耗
13
+
14
+ ### 新增
15
+
16
+ - 为 `dag rerun-task` 新增确定性 generated-output-conflict 预检,执行前扫描并拦截与磁盘已有文件的冲突,提供精确修复建议
17
+ - writer 执行新增 `writer-thinking-exhausted` 终态分类,精准识别模型思考耗尽且无实际写入的情况,避免无限重试
18
+ - 后端 pytest 模块新增 self-contained 静态导入门禁,在收集前拦截对生成共享命名空间的非法导入
19
+ - 新增 `backend-test-execution-readiness-v1` 执行就绪预检,在收集通过后验证传递 fixture/plugin 注册,支持对局部缺陷的安全自动修复
20
+
21
+ ### 改进
22
+
23
+ - 在已确认存在实质变更且完整性校验通过时,允许确定性推断缺失的实现结果,避免因模型漏报而中断已完成的流程
24
+ - 收紧 scenario-param 结论判定,全量不可判定显示 `UNAVAILABLE`,混合情况显示 `PARTIAL`,并支持精确到具体传输目标的校验与修复
25
+ - 大幅降低 16 节点运行成本:将部分审查与计划环节改为确定性静态交接,统一质量度量并优化模块聚合策略
26
+ - 在修复写入前新增 `ast.parse` 语法门禁并修正尾逗号算法,严格禁止生成 `,,` 等错误语法污染收集流程
27
+
28
+ ### 修复
29
+
30
+ - 修复 pytest 结果真实性问题:setup/teardown 错误不再被误报为 collection error,准确区分并独立展示 Failed 与 Error
31
+ - 修复结果合同丢失标识的问题,现完整保留原 pytest node ID、Case ID 与 TP ID,并明确记录业务测试体的实际执行数
32
+
5
33
  ## [0.31.0] - 2026-08-08
6
34
 
7
35
  ### 重点更新
@@ -11,6 +11,55 @@ import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelai
11
11
  import { isWriterEmptyDiffRetryCandidate, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, } from "../workflows/dag/retry-policy.js";
12
12
  import { assessBackendTestMdPlanCompleteness, assessBackendTestMdWriterCompleteness, assessBackendTestPytestPlanCompleteness, assessBackendTestPytestWriterCompleteness, assessBackendTestShardChildCompleteness, backendTestWriterProgressRoleForTask, classifyBackendTestWriterCompletenessFailure, isBackendTestCompletenessRetryCandidate, isBackendTestMdPlanTask, isBackendTestPytestPlanTask, isBackendTestShardChildTask, writeBackendTestWriterProgressArtifacts, } from "../workflows/dag/backend-test-writer-completeness.js";
13
13
  import { redactSecrets, truncateUtf8Preview } from "../shared/preview.js";
14
+ /**
15
+ * Writer classification for a length-stopped thinking-only attempt: the model
16
+ * exhausted its output budget thinking but never issued a write/edit tool call
17
+ * and produced zero attributed diff. This is a terminal, non-retryable
18
+ * diagnosis (the same prompt + model will hit the same budget wall); the
19
+ * recommendation is a model switch plus a fresh run. It must NOT mask a
20
+ * recoverable partial-write-set (incomplete-write-set) upgrade.
21
+ */
22
+ export const WRITER_THINKING_EXHAUSTED_CATEGORY = "writer-thinking-exhausted";
23
+ function readWriterThinkingExhaustionEvidence(result) {
24
+ const wider = result;
25
+ return {
26
+ ...(typeof wider.stopReason === "string"
27
+ ? { stopReason: wider.stopReason }
28
+ : {}),
29
+ ...(typeof wider.thinkingObserved === "boolean"
30
+ ? { thinkingObserved: wider.thinkingObserved }
31
+ : {}),
32
+ ...(typeof wider.writeToolCallCount === "number"
33
+ ? { writeToolCallCount: wider.writeToolCallCount }
34
+ : {}),
35
+ };
36
+ }
37
+ /**
38
+ * Pure classification predicate for writer-thinking-exhausted (AC-002).
39
+ * Requires ALL of: empty-output base category, stopReason exactly "length",
40
+ * thinking observed, zero write/edit tool calls, and a confirmed empty
41
+ * run-attributed diff. Recoverable partial writes stay incomplete-write-set
42
+ * because the caller only consults this predicate when the completeness gate
43
+ * did not upgrade the category.
44
+ */
45
+ export function isWriterThinkingExhausted(result, mapped, changeManifestChangedFiles) {
46
+ if (mapped.ok)
47
+ return false;
48
+ if (mapped.failureCategory !== "empty-output")
49
+ return false;
50
+ const evidence = readWriterThinkingExhaustionEvidence(result);
51
+ if (evidence.stopReason !== "length")
52
+ return false;
53
+ if (evidence.thinkingObserved !== true)
54
+ return false;
55
+ if ((evidence.writeToolCallCount ?? 0) !== 0)
56
+ return false;
57
+ if (changeManifestChangedFiles === undefined)
58
+ return false;
59
+ if (changeManifestChangedFiles.length !== 0)
60
+ return false;
61
+ return true;
62
+ }
14
63
  export const DAG_PI_READONLY_TOOLS = ["read", "grep", "find", "ls"];
15
64
  /** Bounded writer tools include bash; edit/write remain policy-wrapped via SDK customTools. */
16
65
  export const DAG_PI_WRITE_TOOLS = [
@@ -664,6 +713,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
664
713
  else {
665
714
  const outcomeValidation = validateWriterImplementationOutcome(mapped.assistantText || mapped.stdout, changeManifestChangedFiles, {
666
715
  requireChangedFiles: input.task.writerOutcomePolicy.requireChangedFiles === true,
716
+ allowMissingChangedOutcomeWhenDiffPresent: isBackendTestCompletenessRetryCandidate(input.task),
667
717
  });
668
718
  if (!outcomeValidation.ok) {
669
719
  writerOutcomeViolation = outcomeValidation.reason;
@@ -739,9 +789,10 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
739
789
  };
740
790
  }
741
791
  }
742
- if (writeGuardOk && !writerOutcomeViolation && !completenessFailure) {
792
+ if (mapped.ok && writeGuardOk && !writerOutcomeViolation && !completenessFailure) {
743
793
  return mapped;
744
794
  }
795
+ const writerThinkingExhausted = isWriterThinkingExhausted(result, mapped, changeManifestChangedFiles);
745
796
  const stderrParts = [mapped.stderr];
746
797
  if (!writeGuardOk) {
747
798
  stderrParts.push(`write guard failed: ${writeGuardViolations.join(", ")}`);
@@ -752,6 +803,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
752
803
  if (completenessFailure) {
753
804
  stderrParts.push(completenessFailure.detail);
754
805
  }
806
+ if (writerThinkingExhausted) {
807
+ stderrParts.push(`${WRITER_THINKING_EXHAUSTED_CATEGORY}: stopReason=length, thinking observed, 0 write tool calls, 0 attributed diff; recommend a model switch and a new run`);
808
+ }
755
809
  if (meta.writeGuardAttribution === "best-effort") {
756
810
  stderrParts.push("write guard note: concurrent rank writers use best-effort per-node attribution; keep same-rank writeSet entries disjoint");
757
811
  }
@@ -783,13 +837,26 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
783
837
  mapped.failureCategory === "invalid-output" ||
784
838
  mapped.failureCategory === WRITER_EMPTY_DIFF_RETRY_CATEGORY)
785
839
  ? INCOMPLETE_WRITE_SET_RETRY_CATEGORY
786
- : mapped.failureCategory,
840
+ : // writer-thinking-exhausted: a length-stopped thinking-only attempt with
841
+ // zero write tool calls and zero attributed diff is terminal and
842
+ // non-retryable; recommend a model switch + fresh run. Only applies on
843
+ // the empty-output base category so provider/transport failures keep
844
+ // their original category, and only when the completeness gate did not
845
+ // upgrade to incomplete-write-set above.
846
+ writerThinkingExhausted
847
+ ? WRITER_THINKING_EXHAUSTED_CATEGORY
848
+ : mapped.failureCategory,
787
849
  durationMs: mapped.durationMs || Date.now() - started,
788
850
  };
789
851
  }
790
852
  export function validateWriterImplementationOutcome(text, changedFiles, options) {
791
853
  const parsed = parseWriterImplementationOutcome(text);
792
854
  const diagnostics = writerOutcomeDiagnostics(text, parsed, changedFiles.length);
855
+ if (parsed.kind === "missing" &&
856
+ options?.allowMissingChangedOutcomeWhenDiffPresent === true &&
857
+ changedFiles.length > 0) {
858
+ return { ok: true, outcome: "changed" };
859
+ }
793
860
  if (parsed.kind !== "valid") {
794
861
  return {
795
862
  ok: false,
@@ -275,6 +275,11 @@ function classifySdkActivityKind(event) {
275
275
  }
276
276
  return "provider";
277
277
  }
278
+ /** Write/edit-style tool names emitted by DAG writer customTools.
279
+ * Used to attribute write-tool activity for writer-thinking-exhausted diagnosis.
280
+ * Kept as a module-local constant so tests stay deterministic regardless of the
281
+ * SDK's tool registration quirks. */
282
+ const SDK_WRITE_TOOL_NAMES = new Set(["edit", "write", "str_replace_editor"]);
278
283
  function createSessionEventAppender(filePath, onSessionEvent) {
279
284
  let chain = Promise.resolve();
280
285
  let dirEnsured = false;
@@ -365,6 +370,25 @@ function extractSdkUsageSample(event) {
365
370
  const responseKey = responseKeyCandidates.find((value) => typeof value === "string" && value.length > 0);
366
371
  return responseKey ? { responseKey, tokens } : { tokens };
367
372
  }
373
+ /** Extract a non-empty stop reason from a terminal SDK event.
374
+ * Probes multiple field shapes across SDK versions so a missing field fails
375
+ * open (undefined) rather than misclassifying an attempt. */
376
+ function readStopReason(event) {
377
+ const message = isRecord(event.message) ? event.message : undefined;
378
+ const candidates = [
379
+ message?.stop_reason,
380
+ message?.stopReason,
381
+ event.stopReason,
382
+ event.stop_reason,
383
+ ];
384
+ for (const candidate of candidates) {
385
+ if (typeof candidate === "string" &&
386
+ candidate.trim().length > 0) {
387
+ return candidate.trim();
388
+ }
389
+ }
390
+ return undefined;
391
+ }
368
392
  function aggregateSdkTokenUsage(samples) {
369
393
  const identified = new Map();
370
394
  let anonymousMaximum = 0;
@@ -415,6 +439,32 @@ export async function executeSingleSdkAttempt(options) {
415
439
  const stdoutPreview = new BoundedTextPreview("stdout");
416
440
  const stdoutCollector = createPiJsonlStreamCollector();
417
441
  const usageSamples = [];
442
+ // Writer-thinking-exhausted diagnosis evidence (AC-002). Collected in the
443
+ // subscribe callback regardless of shouldPersistSessionEvent filtering, so a
444
+ // length-stopped thinking-only attempt with no persisted write tool calls is
445
+ // still distinguishable from generic empty output.
446
+ let stopReason;
447
+ let thinkingObserved = false;
448
+ let writeToolCallCount = 0;
449
+ const observeWriterThinkingExhaustionEvidence = (event) => {
450
+ const type = typeof event.type === "string" ? event.type : "";
451
+ if (type === "thinking_delta") {
452
+ thinkingObserved = true;
453
+ }
454
+ if (type === "tool_execution_start" &&
455
+ typeof event.toolName === "string" &&
456
+ SDK_WRITE_TOOL_NAMES.has(event.toolName)) {
457
+ writeToolCallCount += 1;
458
+ }
459
+ if (type === "turn_end" ||
460
+ type === "message_end" ||
461
+ type === "agent_end" ||
462
+ type === "agent_settled") {
463
+ const candidate = readStopReason(event);
464
+ if (candidate)
465
+ stopReason = candidate;
466
+ }
467
+ };
418
468
  let session;
419
469
  let unsubscribe;
420
470
  let timeoutHandle;
@@ -513,6 +563,11 @@ export async function executeSingleSdkAttempt(options) {
513
563
  const usageSample = extractSdkUsageSample(event);
514
564
  if (usageSample)
515
565
  usageSamples.push(usageSample);
566
+ // Capture writer-thinking-exhausted evidence BEFORE the
567
+ // shouldPersistSessionEvent filter: thinking_delta is intentionally
568
+ // excluded from persisted JSONL, but must still count toward the
569
+ // in-memory thinkingObserved/writeToolCallCount/stopReason evidence.
570
+ observeWriterThinkingExhaustionEvidence(event);
516
571
  if (!shouldPersistSessionEvent(event))
517
572
  return;
518
573
  const line = serializeSessionEvent(event);
@@ -623,5 +678,11 @@ export async function executeSingleSdkAttempt(options) {
623
678
  timedOut,
624
679
  tokensUsed,
625
680
  subagentStats: collected.subagentStats,
681
+ // Writer-thinking-exhausted diagnosis evidence (AC-002). Carried through
682
+ // the object spread in executePiStep so executeDagPiNode can read them
683
+ // via a widened type without modifying PiStepExecutionResult's shape.
684
+ stopReason,
685
+ thinkingObserved,
686
+ writeToolCallCount,
626
687
  };
627
688
  }
@@ -32,7 +32,7 @@ import { analyzeBackendTestCaseCoverage, analyzeBackendTestMarkdownPytestCorresp
32
32
  import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport, } from "../workflows/dag/backend-test-result-contract.js";
33
33
  import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, resolveBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
34
34
  import { applyDeterministicScenarioParamRepairs, assessBackendScenarioParamConsistency, classifyBackendTestFailureWithScenarioParam, readBackendScenarioParamFacts, renderBackendTestFailureAnalysis, writeBackendScenarioParamArtifacts, writeScenarioParamRepairAudit, } from "../workflows/dag/backend-test-scenario-param.js";
35
- import { assessBackendPytestCollection, assessMissingBackendPytestScripts, assertBackendPytestCollectionFresh, buildBackendPytestAssetInventory, materializeEffectiveBackendPytestCollection, readBackendPytestCollectionFacts, writeBackendPytestCollectionArtifacts, } from "../workflows/dag/backend-test-pytest-collection.js";
35
+ import { assessBackendPytestCollection, assessMissingBackendPytestScripts, assertBackendTestExecutionReadinessFresh, buildBackendPytestAssetInventory, materializeBackendTestExecutionReadiness, materializeEffectiveBackendPytestCollection, readBackendPytestCollectionFacts, readBackendTestExecutionReadiness, writeBackendPytestCollectionArtifacts, } from "../workflows/dag/backend-test-pytest-collection.js";
36
36
  import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
37
37
  import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
38
38
  import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
@@ -43,6 +43,57 @@ import { readRunState } from "../workflows/dag/run-store.js";
43
43
  import { resolveDagTaskSourcePath } from "../task/dag-source-paths.js";
44
44
  import { readProjectGovernanceContext } from "../workflows/dag/project-governance-context.js";
45
45
  import { assertMavenPlanFresh, MavenPlanStaleError, } from "../verification/maven/index.js";
46
+ async function runBackendScenarioParamPreflight(input) {
47
+ const outputs = [];
48
+ const initial = await assessBackendScenarioParamConsistency({
49
+ workspaceRoot: input.workspaceRoot,
50
+ phase: "initial",
51
+ repairAttempt: 0,
52
+ strictScenarioParamGate: input.strictScenarioParamGate,
53
+ });
54
+ const initialArtifacts = await writeBackendScenarioParamArtifacts({
55
+ runDir: input.runDir,
56
+ facts: initial.facts,
57
+ markdown: initial.markdown,
58
+ });
59
+ outputs.push(`scenarioParamInitial=${initialArtifacts.reportPath}`);
60
+ let finalFacts;
61
+ let finalMarkdown;
62
+ if (initial.facts.repairEligible) {
63
+ const repair = await applyDeterministicScenarioParamRepairs({
64
+ workspaceRoot: input.workspaceRoot,
65
+ facts: initial.facts,
66
+ });
67
+ const auditPath = await writeScenarioParamRepairAudit({
68
+ runDir: input.runDir,
69
+ audit: repair.audit,
70
+ changedFiles: repair.changedFiles,
71
+ });
72
+ outputs.push(`scenarioParamRepairAudit=${auditPath}`, `scenarioParamRepaired=${repair.repaired.join(",") || "(none)"}`);
73
+ const reassessed = await assessBackendScenarioParamConsistency({
74
+ workspaceRoot: input.workspaceRoot,
75
+ phase: "final",
76
+ repairAttempt: 1,
77
+ strictScenarioParamGate: input.strictScenarioParamGate,
78
+ });
79
+ finalFacts = reassessed.facts;
80
+ finalMarkdown = reassessed.markdown;
81
+ }
82
+ else {
83
+ finalFacts = { ...initial.facts, phase: "final", repairAttempt: 0, repairEligible: false };
84
+ finalMarkdown = initial.markdown.replace("Phase: initial", "Phase: final");
85
+ }
86
+ const finalArtifacts = await writeBackendScenarioParamArtifacts({
87
+ runDir: input.runDir,
88
+ facts: finalFacts,
89
+ markdown: finalMarkdown,
90
+ });
91
+ outputs.push(`scenarioParamFinal=${finalArtifacts.reportPath}`, `scenarioParamStatus=${finalFacts.overallStatus}`);
92
+ if (input.strictScenarioParamGate && ["FAIL", "UNAVAILABLE"].includes(finalFacts.overallStatus)) {
93
+ throw new Error(`backend-test strict scenario-param gate blocked ${finalFacts.overallStatus} before collection`);
94
+ }
95
+ return { facts: finalFacts, outputs };
96
+ }
46
97
  /** In-memory same-run success cache. Never shared across runIds. */
47
98
  const sameRunShellCommandCaches = new Map();
48
99
  function shellCommandCacheKey(runId, workspaceFingerprint, contractKey) {
@@ -612,6 +663,12 @@ async function executeBackendTestPipeline(input, meta) {
612
663
  }
613
664
  else {
614
665
  const mappedScripts = resolution.existingScripts;
666
+ const scenario = await runBackendScenarioParamPreflight({
667
+ workspaceRoot: input.cwd,
668
+ runDir: meta.runDir,
669
+ strictScenarioParamGate: Boolean((meta.spec.globalConstraints ?? []).some((item) => /strictScenarioParamGate\s*=\s*true/i.test(item))),
670
+ });
671
+ outputs.push(...scenario.outputs);
615
672
  const inventory = await buildBackendPytestAssetInventory(input.cwd, mappedScripts);
616
673
  const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
617
674
  const targets = mappedScripts.map(shellQuote).join(" ");
@@ -628,6 +685,23 @@ async function executeBackendTestPipeline(input, meta) {
628
685
  durationMs: Date.now() - started,
629
686
  };
630
687
  }
688
+ let fixtureResolution;
689
+ if ((result.exitCode ?? 2) === 0) {
690
+ const fixtureCommand = `PYTHONDONTWRITEBYTECODE=1 python -m pytest --setup-plan -q -p no:cacheprovider ${targets}`;
691
+ const [fixtureResult] = await executePipelineCommands(input, meta, [fixtureCommand]);
692
+ if (!fixtureResult)
693
+ throw new Error("backend pytest fixture-resolution command did not produce a result");
694
+ if (["spawn-error", "timeout", "termination-unconfirmed"].includes(fixtureResult.failureCategory ?? "")) {
695
+ return {
696
+ ok: false,
697
+ stdout: fixtureResult.stdout,
698
+ stderr: fixtureResult.stderr || "backend pytest fixture resolution could not start",
699
+ failureCategory: fixtureResult.failureCategory,
700
+ durationMs: Date.now() - started,
701
+ };
702
+ }
703
+ fixtureResolution = { exitCode: fixtureResult.exitCode ?? 2, stdout: fixtureResult.stdout, stderr: fixtureResult.stderr };
704
+ }
631
705
  facts = assessBackendPytestCollection({
632
706
  phase: "initial",
633
707
  mappedScripts,
@@ -635,6 +709,7 @@ async function executeBackendTestPipeline(input, meta) {
635
709
  exitCode: result.exitCode ?? 2,
636
710
  stdout: result.stdout,
637
711
  stderr: result.stderr,
712
+ fixtureResolution,
638
713
  });
639
714
  }
640
715
  const artifacts = await writeBackendPytestCollectionArtifacts({
@@ -650,8 +725,10 @@ async function executeBackendTestPipeline(input, meta) {
650
725
  repairEligible: facts.repairEligible,
651
726
  collectionAttempted: facts.collectionAttempted,
652
727
  pytestExitCode: facts.pytestExitCode,
728
+ fixtureResolutionStatus: facts.fixtureResolutionStatus,
653
729
  collectedItemCount: facts.collectedItemCount,
654
730
  missingMappedScripts: facts.missingMappedScripts,
731
+ repairPaths: facts.repairPaths,
655
732
  factsPath: "contracts/backend-test-pytest-collection-initial.json",
656
733
  }),
657
734
  stderr: "",
@@ -669,6 +746,12 @@ async function executeBackendTestPipeline(input, meta) {
669
746
  }
670
747
  else if (initial.status === "REPAIRABLE") {
671
748
  const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
749
+ const scenario = await runBackendScenarioParamPreflight({
750
+ workspaceRoot: input.cwd,
751
+ runDir: meta.runDir,
752
+ strictScenarioParamGate: Boolean((meta.spec.globalConstraints ?? []).some((item) => /strictScenarioParamGate\s*=\s*true/i.test(item))),
753
+ });
754
+ outputs.push(...scenario.outputs);
672
755
  const inventory = await buildBackendPytestAssetInventory(input.cwd, mappedScripts);
673
756
  const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
674
757
  const targets = mappedScripts.map(shellQuote).join(" ");
@@ -686,6 +769,23 @@ async function executeBackendTestPipeline(input, meta) {
686
769
  durationMs: Date.now() - started,
687
770
  };
688
771
  }
772
+ let fixtureResolution;
773
+ if ((result.exitCode ?? 2) === 0) {
774
+ const fixtureCommand = `PYTHONDONTWRITEBYTECODE=1 python -m pytest --setup-plan -q -p no:cacheprovider ${targets}`;
775
+ const [fixtureResult] = await executePipelineCommands(input, meta, [fixtureCommand]);
776
+ if (!fixtureResult)
777
+ throw new Error("backend pytest final fixture-resolution command did not produce a result");
778
+ if (["spawn-error", "timeout", "termination-unconfirmed"].includes(fixtureResult.failureCategory ?? "")) {
779
+ return {
780
+ ok: false,
781
+ stdout: fixtureResult.stdout,
782
+ stderr: fixtureResult.stderr || "backend pytest final fixture resolution could not start",
783
+ failureCategory: fixtureResult.failureCategory,
784
+ durationMs: Date.now() - started,
785
+ };
786
+ }
787
+ fixtureResolution = { exitCode: fixtureResult.exitCode ?? 2, stdout: fixtureResult.stdout, stderr: fixtureResult.stderr };
788
+ }
689
789
  const finalFacts = assessBackendPytestCollection({
690
790
  phase: "final",
691
791
  mappedScripts,
@@ -693,6 +793,7 @@ async function executeBackendTestPipeline(input, meta) {
693
793
  exitCode: result.exitCode ?? 2,
694
794
  stdout: result.stdout,
695
795
  stderr: result.stderr,
796
+ fixtureResolution,
696
797
  });
697
798
  effective = await materializeEffectiveBackendPytestCollection({
698
799
  workspaceRoot: input.cwd,
@@ -708,6 +809,14 @@ async function executeBackendTestPipeline(input, meta) {
708
809
  stem: "effective",
709
810
  facts: effective,
710
811
  });
812
+ const scenarioFacts = await readBackendScenarioParamFacts(path.join(meta.runDir, "contracts", "backend-test-scenario-param-consistency-facts.json"));
813
+ const readiness = await materializeBackendTestExecutionReadiness({
814
+ runDir: meta.runDir,
815
+ workspaceRoot: input.cwd,
816
+ effective,
817
+ scenarioParamStatus: scenarioFacts.overallStatus,
818
+ scenarioParamRepairAttempt: scenarioFacts.repairAttempt,
819
+ });
711
820
  return {
712
821
  ok: true,
713
822
  stdout: JSON.stringify({
@@ -715,7 +824,10 @@ async function executeBackendTestPipeline(input, meta) {
715
824
  collectionSource: effective.collectionSource,
716
825
  repairAttempt: effective.repairAttempt,
717
826
  collectedItemCount: effective.collectedItemCount,
827
+ fixtureResolutionStatus: effective.fixtureResolutionStatus,
828
+ executionReadinessStatus: readiness.status,
718
829
  factsPath: "contracts/backend-test-pytest-collection-effective.json",
830
+ readinessPath: "contracts/backend-test-execution-readiness.json",
719
831
  reportPath: artifacts.reportPath,
720
832
  }),
721
833
  stderr: "",
@@ -770,76 +882,18 @@ async function executeBackendTestPipeline(input, meta) {
770
882
  durationMs: Date.now() - started,
771
883
  };
772
884
  }
773
- // Scenario-param consistency (P1): assess -> deterministic repair <=1 -> reassess final.
885
+ // Scenario-param assessment and deterministic repair run before collection,
886
+ // so effective collection/fixture facts bind the final executable assets.
774
887
  try {
775
- const initialAssessment = await assessBackendScenarioParamConsistency({
776
- workspaceRoot: input.cwd,
777
- phase: "initial",
778
- repairAttempt: 0,
779
- strictScenarioParamGate: Boolean((meta.spec.globalConstraints ?? []).some((item) => /strictScenarioParamGate\s*=\s*true/i.test(item))),
780
- });
781
- const initialArtifacts = await writeBackendScenarioParamArtifacts({
782
- runDir: meta.runDir,
783
- facts: initialAssessment.facts,
784
- markdown: initialAssessment.markdown,
785
- });
786
- outputs.push(`scenarioParamInitial=${initialArtifacts.reportPath}`, `scenarioParamInitialFacts=${initialArtifacts.factsPath}`);
787
- let finalFacts = initialAssessment.facts;
788
- let finalMarkdown = initialAssessment.markdown;
789
- if (initialAssessment.facts.repairEligible) {
790
- const repair = await applyDeterministicScenarioParamRepairs({
791
- workspaceRoot: input.cwd,
792
- facts: initialAssessment.facts,
793
- });
794
- const auditPath = await writeScenarioParamRepairAudit({
795
- runDir: meta.runDir,
796
- audit: repair.audit,
797
- changedFiles: repair.changedFiles,
798
- });
799
- outputs.push(`scenarioParamRepairAudit=${auditPath}`, `scenarioParamRepaired=${repair.repaired.join(",") || "(none)"}`);
800
- const reassessment = await assessBackendScenarioParamConsistency({
801
- workspaceRoot: input.cwd,
802
- phase: "final",
803
- repairAttempt: 1,
804
- strictScenarioParamGate: initialAssessment.facts.strictScenarioParamGate,
805
- });
806
- finalFacts = reassessment.facts;
807
- finalMarkdown = reassessment.markdown;
808
- }
809
- else {
810
- finalFacts = {
811
- ...initialAssessment.facts,
812
- phase: "final",
813
- repairAttempt: 0,
814
- repairEligible: false,
815
- };
816
- finalMarkdown = initialAssessment.markdown.replace("Phase: initial", "Phase: final");
817
- }
818
- const finalArtifacts = await writeBackendScenarioParamArtifacts({
819
- runDir: meta.runDir,
820
- facts: finalFacts,
821
- markdown: finalMarkdown,
822
- });
823
- outputs.push(`scenarioParamFinal=${finalArtifacts.reportPath}`, `scenarioParamFinalFacts=${finalArtifacts.factsPath}`, `scenarioParamMismatch=${finalFacts.summary.mismatchCount}`, finalMarkdown);
824
- if (finalFacts.strictScenarioParamGate &&
825
- finalFacts.summary.mismatchCount > 0) {
826
- return {
827
- ok: false,
828
- stdout: outputs.join("\n\n"),
829
- stderr: "backend-test strict scenario-param gate blocked residual MISMATCH before execute",
830
- failureCategory: "invalid-output",
831
- durationMs: Date.now() - started,
832
- };
833
- }
888
+ const scenarioFactsPath = path.join(meta.runDir, "contracts", "backend-test-scenario-param-consistency-facts.json");
889
+ const scenarioReportPath = path.join(meta.runDir, "reports", "backend-test-scenario-param-consistency.md");
890
+ const scenarioFacts = await readBackendScenarioParamFacts(scenarioFactsPath);
891
+ const scenarioReport = await readFile(scenarioReportPath, "utf8");
892
+ outputs.push(`scenarioParamFinal=${scenarioReportPath}`, `scenarioParamFinalFacts=${scenarioFactsPath}`, `scenarioParamStatus=${scenarioFacts.overallStatus}`, scenarioReport);
834
893
  }
835
894
  catch (error) {
836
895
  const message = error instanceof Error ? error.message : String(error);
837
- const report = "# Backend Test Scenario-Param Consistency\n\n## Status\n\nUNAVAILABLE\n\n## Findings\n\n- Scenario-param analysis crashed: " +
838
- message +
839
- "\n";
840
- const reportPath = await writeRunReport(meta.runDir, "backend-test-scenario-param-consistency.md", report);
841
- outputs.push(`scenarioParam=${reportPath}`, report);
842
- // Advisory by default: do not fail the node on scenario-param infrastructure errors.
896
+ outputs.push("# Backend Test Scenario-Param Consistency\n\n## Status\n\nUNAVAILABLE\n\n## Findings\n\n- Pre-collection scenario-param evidence missing: " + message + "\n");
843
897
  }
844
898
  }
845
899
  else if (pipeline === "markdown-manifest") {
@@ -866,8 +920,8 @@ async function executeBackendTestPipeline(input, meta) {
866
920
  outputs.push(`manifest=${manifestPath}`, `materializationStatus=${manifest.materializationStatus ?? "available"}`, `coverageSummary.explicitAcCount=${summary?.explicitAcCount ?? "unavailable"}`, `coverageSummary.coveredAcCount=${summary?.coveredAcCount ?? "unavailable"}`, `coverageSummary.caseCount=${summary?.caseCount ?? "unavailable"}`, `coverageSummary.generatedCount=${summary?.generatedCount ?? "unavailable"}`, `ruleCoverageSummary.ruleCount=${manifest.ruleCoverageSummary?.ruleCount ?? "unavailable"}`, `correspondenceSummary.exactCorrespondenceCount=${manifest.correspondenceSummary?.exactCorrespondenceCount ?? "unavailable"}`, `correspondenceSummary.primarySymbolCount=${manifest.correspondenceSummary?.primarySymbolCount ?? "unavailable"}`, `correspondenceSummary.testPoints=${manifest.correspondenceSummary?.mappedTestPointCount ?? "unavailable"}/${manifest.correspondenceSummary?.testPointCount ?? "unavailable"}`, `correspondenceSummary.variantTestPointCount=${manifest.correspondenceSummary?.variantTestPointCount ?? "unavailable"}`, `correspondenceSummary.assertionTestPointCount=${manifest.correspondenceSummary?.assertionTestPointCount ?? "unavailable"}`, `correspondenceSummary.crossCuttingTestPointCount=${manifest.correspondenceSummary?.crossCuttingTestPointCount ?? "unavailable"}`, `correspondenceSummary.unclassifiedTestPointCount=${manifest.correspondenceSummary?.unclassifiedTestPointCount ?? "unavailable"}`, `correspondenceSummary.duplicateBindingTestPointCount=${manifest.correspondenceSummary?.duplicateBindingTestPointCount ?? "unavailable"}`);
867
921
  }
868
922
  else if (pipeline === "markdown-execute-html") {
869
- const effectiveCollection = await readBackendPytestCollectionFacts(path.join(meta.runDir, "contracts", "backend-test-pytest-collection-effective.json"));
870
- await assertBackendPytestCollectionFresh(input.cwd, effectiveCollection);
923
+ const executionReadiness = await readBackendTestExecutionReadiness(path.join(meta.runDir, "contracts", "backend-test-execution-readiness.json"));
924
+ await assertBackendTestExecutionReadinessFresh(input.cwd, executionReadiness);
871
925
  const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
872
926
  const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
873
927
  const pytestTargets = mappedScripts.map(shellQuote).join(" ");
@@ -1068,8 +1122,7 @@ async function executeBackendTestPipeline(input, meta) {
1068
1122
  const scenarioParamStatusByToken = new Map();
1069
1123
  try {
1070
1124
  const scenarioFacts = await readBackendScenarioParamFacts(path.join(meta.runDir, "contracts", "backend-test-scenario-param-consistency-facts.json"));
1071
- scenarioParamFinalStatus =
1072
- scenarioFacts.summary.mismatchCount > 0 ? "FAIL" : "PASS";
1125
+ scenarioParamFinalStatus = scenarioFacts.overallStatus;
1073
1126
  scenarioParamRepairAttempt = scenarioFacts.repairAttempt;
1074
1127
  for (const entry of scenarioFacts.entries) {
1075
1128
  scenarioParamStatusByToken.set(entry.caseId, entry.status);
@@ -1085,16 +1138,20 @@ async function executeBackendTestPipeline(input, meta) {
1085
1138
  htmlReportPath: "reports/backend-test.html",
1086
1139
  total: parsed.tests,
1087
1140
  passed: parsed.passed,
1088
- failed: parsed.failed + parsed.errors,
1089
- durationLabel: `${parsed.durationMs ?? 0}ms`,
1141
+ failed: parsed.failed,
1142
+ errors: parsed.errors,
1143
+ durationLabel: `${parsed.durationMs ?? failureCases.reduce((sum, item) => sum + (item.durationMs ?? 0), 0)}ms`,
1090
1144
  environmentSummary: await readFile(path.join(reportsDir, "backend-test-environment.md"), "utf8").catch(() => "unavailable"),
1091
1145
  scenarioParamFinalStatus,
1092
1146
  repairAttempt: scenarioParamRepairAttempt,
1093
1147
  failures: failureCases.map((result) => {
1094
- const caseMatch = result.name.match(/\bBE-[A-Z0-9_-]+-\d{2,3}\b/);
1095
- const tpMatch = result.name.match(/\bTP-[A-Z0-9-]+\b/);
1096
- const caseId = caseMatch?.[0] ?? result.name;
1097
- const scenarioParamStatus = (tpMatch && scenarioParamStatusByToken.get(tpMatch[0])) ||
1148
+ const identityText = `${result.nodeId ?? ""} ${result.name}`;
1149
+ const caseToken = identityText.match(/BE[-_][A-Z0-9_-]+[-_]\d{2,3}/i)?.[0];
1150
+ const caseId = caseToken
1151
+ ? caseToken.replace(/^BE_/i, "BE-").replaceAll("_", "-").toUpperCase()
1152
+ : result.name;
1153
+ const tpToken = identityText.match(/TP-[A-Z0-9-]+/i)?.[0]?.toUpperCase();
1154
+ const scenarioParamStatus = (tpToken && scenarioParamStatusByToken.get(tpToken)) ||
1098
1155
  scenarioParamStatusByToken.get(caseId);
1099
1156
  const evidence = [result.message, result.details]
1100
1157
  .filter(Boolean)
@@ -1102,7 +1159,7 @@ async function executeBackendTestPipeline(input, meta) {
1102
1159
  const statusMatch = /assert\s+(\d{3})\s*==\s*(\d{3})/i.exec(evidence) ||
1103
1160
  /expected[^\d]*(\d{3})[\s\S]{0,40}actual[^\d]*(\d{3})/i.exec(evidence);
1104
1161
  return {
1105
- name: result.name,
1162
+ name: tpToken ?? result.name,
1106
1163
  caseId,
1107
1164
  scenario: cases.find((item) => item.id === caseId)?.scenario ??
1108
1165
  cases.find((item) => item.id === caseId)?.title ??
@@ -1110,7 +1167,7 @@ async function executeBackendTestPipeline(input, meta) {
1110
1167
  expectedCode: statusMatch?.[2] ?? statusMatch?.[1],
1111
1168
  actualCode: statusMatch?.[1] ?? statusMatch?.[2],
1112
1169
  message: result.message || result.status,
1113
- scriptPath: result.classname,
1170
+ scriptPath: result.nodeId ?? result.filePath ?? result.classname,
1114
1171
  durationLabel: result.durationMs !== undefined
1115
1172
  ? `${result.durationMs}ms`
1116
1173
  : undefined,