@tea-agent/loop-agent 0.26.1 → 0.26.3

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 (37) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/application/dag/generate-task-dag.js +33 -0
  3. package/dist/commands/task-source-prepare.js +6 -0
  4. package/dist/executors/dag-pi-executor.js +156 -9
  5. package/dist/executors/shell-executor.js +111 -0
  6. package/dist/executors/shell-presets.js +12 -4
  7. package/dist/executors/shell-write-guard.js +145 -12
  8. package/dist/task/config-types.js +6 -0
  9. package/dist/task/contract/constants.js +1 -0
  10. package/dist/task/contract/project.js +8 -0
  11. package/dist/task/contract/schema.js +1 -0
  12. package/dist/task/frontend-preflight.js +131 -0
  13. package/dist/task/runtime.js +2 -4
  14. package/dist/task/source-prepare/build-draft.js +9 -0
  15. package/dist/task/source-prepare/completeness.js +1 -1
  16. package/dist/worker/observability/read-model.js +134 -0
  17. package/dist/worker/observe/static/state.js +61 -0
  18. package/dist/worker/observe/static/styles.css +8 -0
  19. package/dist/worker/observe/static/views/dag-graph.js +107 -31
  20. package/dist/worker/observe/static/views/dag-inspector.js +374 -157
  21. package/dist/worker/observe/static/views/dag.js +4 -11
  22. package/dist/workflows/dag/backend-test-pytest-collection.js +277 -0
  23. package/dist/workflows/dag/convergence/controller.js +110 -21
  24. package/dist/workflows/dag/frontend-implementation-contract.js +218 -17
  25. package/dist/workflows/dag/frontend-repair.js +29 -29
  26. package/dist/workflows/dag/frontend-review-context.js +7 -1
  27. package/dist/workflows/dag/frontend-verification-trace.js +26 -5
  28. package/dist/workflows/dag/frontend-worktree-diff.js +14 -3
  29. package/dist/workflows/dag/governance-profile.js +1 -1
  30. package/dist/workflows/dag/init-hybrid.js +115 -39
  31. package/dist/workflows/dag/output-protocol.js +180 -7
  32. package/dist/workflows/dag/runner.js +141 -52
  33. package/dist/workflows/dag/types.js +5 -1
  34. package/dist/workflows/dag/validate.js +3 -2
  35. package/docs/templates/backend-test-dag.json +100 -8
  36. package/package.json +1 -1
  37. package/skills/loop-agent/references/hybrid-dag.md +1 -1
@@ -20,7 +20,7 @@ import { relocateConvergenceArtifactPaths, relocateRunArtifactPaths, } from "./u
20
20
  import { createSkillSnapshot, prepareSkillSnapshotForContinuation, writeSkillSnapshot, } from "./skill-snapshot.js";
21
21
  import { captureWorkspaceCheckpoint, WORKSPACE_CHECKPOINT_START_REL, WORKSPACE_CHECKPOINT_TERMINAL_REL, writeWorkspaceCheckpoint, } from "./workspace-checkpoint.js";
22
22
  import { buildNodePrompt, buildNodePromptWithResolvedSkillInstructions, executeDagNode, } from "./node-execution.js";
23
- import { runConvergencePassController, shouldEnableDagConvergence, } from "./convergence/controller.js";
23
+ import { runConvergencePassController, } from "./convergence/controller.js";
24
24
  import { executeDagRanksOnce, isConditionSkippedReason } from "./scheduler.js";
25
25
  import { topoSortToRanks } from "./topo.js";
26
26
  import { parseDagSpec, resolveModelForTask, } from "./types.js";
@@ -142,11 +142,11 @@ export function createInitialRunState(spec, opts, ranks, runId = opts.runId ?? "
142
142
  ranks,
143
143
  nodes,
144
144
  ...(spec.evaluation ? { evaluation: { ...spec.evaluation } } : {}),
145
- ...(shouldEnableDagConvergence(spec)
145
+ ...(spec.convergence
146
146
  ? {
147
147
  convergence: {
148
- enabled: true,
149
- maxPasses: Math.max(1, spec.convergence?.maxPasses ?? 3),
148
+ enabled: spec.convergence.enabled === true,
149
+ maxPasses: Math.max(1, spec.convergence.maxPasses ?? 3),
150
150
  currentPass: 1,
151
151
  passHistory: [],
152
152
  },
@@ -186,7 +186,7 @@ export async function runDag(spec, opts) {
186
186
  // Candidate identity is validated before run-id allocation or directory creation.
187
187
  await assertEvaluationBindingPreflight(opts.cwd, spec.evaluation);
188
188
  // Phase 0.5: new writer execution requires DagSpec v4 + live taskContractBinding match.
189
- const { assertDagSpecAllowsNewWriterExecution, assertTaskContractBindingConsistent } = await import("./task-contract-binding.js");
189
+ const { assertDagSpecAllowsNewWriterExecution, assertTaskContractBindingConsistent, } = await import("./task-contract-binding.js");
190
190
  assertDagSpecAllowsNewWriterExecution(spec);
191
191
  await assertTaskContractBindingConsistent({ repoRoot: opts.cwd, spec });
192
192
  const runningIdentity = resolveRunningControllerIdentity();
@@ -323,7 +323,7 @@ export async function resumeDagRun(opts) {
323
323
  assertFrozenEvaluationBinding(spec, state);
324
324
  assertFrozenBudget(spec.budget, state.budget, state.runId);
325
325
  // Phase 0.5: refuse resume of pre-v4 writer DAGs; revalidate live binding.
326
- const { assertDagSpecAllowsNewWriterExecution, assertTaskContractBindingConsistent } = await import("./task-contract-binding.js");
326
+ const { assertDagSpecAllowsNewWriterExecution, assertTaskContractBindingConsistent, } = await import("./task-contract-binding.js");
327
327
  assertDagSpecAllowsNewWriterExecution(spec);
328
328
  await assertTaskContractBindingConsistent({ repoRoot: opts.cwd, spec });
329
329
  // Runtime contract + controller identity must be re-verified before executing
@@ -472,52 +472,63 @@ async function executeDagCheckpoint(input) {
472
472
  runDir: dynamicInput.runDir,
473
473
  });
474
474
  };
475
- let pausedByNodeId;
476
- while (true) {
477
- pausedByNodeId = await executeDagRanksOnce({
478
- state,
479
- ranks,
475
+ const deferTerminalCloseout = state.convergence !== undefined && tasksById.has("closeout-pi");
476
+ const convergenceRanks = deferTerminalCloseout
477
+ ? ranks
478
+ .map((rank) => rank.filter((nodeId) => nodeId !== "closeout-pi"))
479
+ .filter((rank) => rank.length > 0)
480
+ : ranks;
481
+ const terminalCloseoutRanks = deferTerminalCloseout
482
+ ? ranks
483
+ .map((rank) => rank.filter((nodeId) => nodeId === "closeout-pi"))
484
+ .filter((rank) => rank.length > 0)
485
+ : [];
486
+ const executeRanks = (selectedRanks) => executeDagRanksOnce({
487
+ state,
488
+ ranks: selectedRanks,
489
+ tasksById,
490
+ maxConcurrent,
491
+ persistState,
492
+ createExecuteNodeForRank: (rankWriterNodeIds) => buildRankAwareExecuteNode({
493
+ baseExecuteNode,
494
+ customExecuteNode: input.executeNode,
495
+ rankWriterNodeIds,
480
496
  tasksById,
481
- maxConcurrent,
482
- persistState,
483
- createExecuteNodeForRank: (rankWriterNodeIds) => buildRankAwareExecuteNode({
484
- baseExecuteNode,
485
- customExecuteNode: input.executeNode,
486
- rankWriterNodeIds,
497
+ meta: { runDir, runId: state.runId, spec },
498
+ }),
499
+ executeScheduledNode: async (nodeId, executeNode, onPause) => {
500
+ if (isHardBudgetBreached(state.budgetLedger))
501
+ return;
502
+ const preBreach = preflightBudgetOrBreach(state);
503
+ if (preBreach && isHardBudgetBreached(state.budgetLedger)) {
504
+ await writeBudgetLedgerArtifacts(runDir, state.budgetLedger);
505
+ await persistState();
506
+ return;
507
+ }
508
+ await executeDagNode({
509
+ nodeId,
487
510
  tasksById,
488
- meta: { runDir, runId: state.runId, spec },
489
- }),
490
- executeScheduledNode: async (nodeId, executeNode, onPause) => {
491
- if (isHardBudgetBreached(state.budgetLedger))
492
- return;
493
- const preBreach = preflightBudgetOrBreach(state);
494
- if (preBreach && isHardBudgetBreached(state.budgetLedger)) {
495
- await writeBudgetLedgerArtifacts(runDir, state.budgetLedger);
496
- await persistState();
497
- return;
498
- }
499
- await executeDagNode({
500
- nodeId,
501
- tasksById,
502
- state,
503
- spec,
504
- cwd,
505
- runDir,
506
- executeNode,
507
- executeDynamicNode,
508
- observer: input.observer,
509
- persistState,
510
- onPause,
511
- });
512
- const node = state.nodes[nodeId];
513
- if (node &&
514
- (node.status === "FINISHED" || node.status === "ERROR")) {
515
- recordFinishedNodeBudget(state, node);
516
- await writeBudgetLedgerArtifacts(runDir, state.budgetLedger);
517
- await persistState();
518
- }
519
- },
520
- });
511
+ state,
512
+ spec,
513
+ cwd,
514
+ runDir,
515
+ executeNode,
516
+ executeDynamicNode,
517
+ observer: input.observer,
518
+ persistState,
519
+ onPause,
520
+ });
521
+ const node = state.nodes[nodeId];
522
+ if (node && (node.status === "FINISHED" || node.status === "ERROR")) {
523
+ recordFinishedNodeBudget(state, node);
524
+ await writeBudgetLedgerArtifacts(runDir, state.budgetLedger);
525
+ await persistState();
526
+ }
527
+ },
528
+ });
529
+ let pausedByNodeId;
530
+ while (true) {
531
+ pausedByNodeId = await executeRanks(convergenceRanks);
521
532
  if (pausedByNodeId) {
522
533
  break;
523
534
  }
@@ -541,6 +552,16 @@ async function executeDagCheckpoint(input) {
541
552
  }
542
553
  break;
543
554
  }
555
+ if (!pausedByNodeId &&
556
+ terminalCloseoutRanks.length > 0 &&
557
+ state.convergence?.terminalReason &&
558
+ !isHardBudgetBreached(state.budgetLedger)) {
559
+ // Convergence closeout is a terminal phase, not an ordinary descendant
560
+ // rank. Running it only after the controller records terminalReason avoids
561
+ // stale success handoffs on intermediate request-revision passes while
562
+ // still producing a truthful failure handoff for max-passes/non-retry.
563
+ pausedByNodeId = await executeRanks(terminalCloseoutRanks);
564
+ }
544
565
  // Stop heartbeats before terminal archive. A late heartbeat writing
545
566
  // state.json under completed/ trips the completed-facts write guard and
546
567
  // makes run-dag exit non-zero after every node already finished.
@@ -599,11 +620,79 @@ async function notifyRunObserver(observer, event, state) {
599
620
  // Observers are derived views; they must not affect canonical DAG execution.
600
621
  }
601
622
  }
623
+ const SUCCESSFUL_CONVERGENCE_TERMINAL_REASONS = new Set([
624
+ "review-pass",
625
+ "hard-verify-pass",
626
+ ]);
627
+ /**
628
+ * Intermediate soft-verify failures may be excluded from terminal error
629
+ * aggregation only when repair + final hard verify succeeded and, when a review
630
+ * gate is present, convergence ended in review-pass. Historical node status is
631
+ * never rewritten.
632
+ */
633
+ function collectSupersededIntermediateFailures(state) {
634
+ const supersededIds = new Set();
635
+ const supersededFailures = [];
636
+ const soft = state.nodes["soft-verify-shell"];
637
+ if (!soft || soft.status !== "ERROR") {
638
+ return { supersededIds, supersededFailures };
639
+ }
640
+ const repair = state.nodes["repair-pi"];
641
+ const hard = state.nodes["hard-verify-shell"];
642
+ if (repair?.status !== "FINISHED" || hard?.status !== "FINISHED") {
643
+ return { supersededIds, supersededFailures };
644
+ }
645
+ const observesReview = Boolean(state.nodes["review-gate-shell"]);
646
+ const terminalReason = state.convergence?.terminalReason;
647
+ if (observesReview) {
648
+ if (terminalReason !== "review-pass") {
649
+ return { supersededIds, supersededFailures };
650
+ }
651
+ }
652
+ else if (terminalReason && terminalReason !== "hard-verify-pass") {
653
+ return { supersededIds, supersededFailures };
654
+ }
655
+ // Evidence: at least one successful repair pass after soft failure, with hard verify finished in terminal state.
656
+ const hasRepairEvidence = !state.convergence?.enabled ||
657
+ state.convergence.passHistory.some((pass) => pass.reason === "hard-verify-failed" ||
658
+ pass.reason === "review-request-revision" ||
659
+ pass.status === "retrying") ||
660
+ repair.status === "FINISHED";
661
+ if (!hasRepairEvidence) {
662
+ return { supersededIds, supersededFailures };
663
+ }
664
+ supersededIds.add("soft-verify-shell");
665
+ supersededFailures.push({
666
+ nodeId: "soft-verify-shell",
667
+ preservedStatus: "ERROR",
668
+ supersededBy: ["repair-pi", "hard-verify-shell"],
669
+ pass: state.convergence?.currentPass,
670
+ reason: "intermediate-soft-verify-covered-by-repair-and-final-hard",
671
+ });
672
+ return { supersededIds, supersededFailures };
673
+ }
674
+ function isSuccessfulConvergenceTerminal(state) {
675
+ const reason = state.convergence?.terminalReason;
676
+ if (!reason)
677
+ return true;
678
+ return SUCCESSFUL_CONVERGENCE_TERMINAL_REASONS.has(reason);
679
+ }
602
680
  function finalizeTerminalRunStatus(state, taskCount) {
603
681
  const finishedCount = Object.values(state.nodes).filter((n) => n.status === "FINISHED").length;
604
- const errorCount = Object.values(state.nodes).filter((n) => n.status === "ERROR").length;
682
+ const { supersededIds, supersededFailures } = collectSupersededIntermediateFailures(state);
683
+ if (state.convergence && supersededFailures.length > 0) {
684
+ state.convergence.supersededFailures = supersededFailures;
685
+ }
686
+ // Prefer map keys: DagNodeRecord may not always mirror the map key on `.id`.
687
+ const effectiveErrorCount = Object.entries(state.nodes).filter(([nodeId, n]) => n.status === "ERROR" && !supersededIds.has(nodeId)).length;
688
+ const supersededErrorCount = Object.entries(state.nodes).filter(([nodeId, n]) => n.status === "ERROR" && supersededIds.has(nodeId)).length;
605
689
  const conditionSkippedCount = Object.values(state.nodes).filter((n) => n.status === "SKIPPED" && isConditionSkippedReason(n.skippedReason)).length;
606
- if (errorCount === 0 && finishedCount + conditionSkippedCount === taskCount) {
690
+ const successTerminal = isSuccessfulConvergenceTerminal(state);
691
+ // Superseded intermediate ERRORs remain ERROR in history but count as settled
692
+ // for terminal aggregation only (they do not inflate effectiveErrorCount).
693
+ if (effectiveErrorCount === 0 &&
694
+ successTerminal &&
695
+ finishedCount + conditionSkippedCount + supersededErrorCount === taskCount) {
607
696
  state.status = "finished";
608
697
  }
609
698
  else if (finishedCount > 0) {
@@ -79,6 +79,8 @@ export const dagVerdictGateSchema = z.object({
79
79
  .regex(/^[a-z][a-z0-9-]*$/, "fallbackFromNodeIds must be kebab-case"))
80
80
  .optional(),
81
81
  accept: z.array(z.string().min(1)).min(1),
82
+ routingAccept: z.array(z.string().min(1)).optional(),
83
+ source: z.enum(["verdict-line", "json-review-verdict"]).optional(),
82
84
  lineMode: z.enum(["first-non-empty", "first-verdict-line"]).optional(),
83
85
  label: z.string().min(1).optional(),
84
86
  });
@@ -161,7 +163,7 @@ export const dagFrontendVerificationBundleSchema = z
161
163
  mockCommands: z.array(z.string()).default([]),
162
164
  lintCommands: z.array(z.string().min(1)).optional(),
163
165
  staticCommands: z.array(z.string()).min(1),
164
- behaviorCommands: z.array(z.string()).min(1),
166
+ behaviorCommands: z.array(z.string()).default([]),
165
167
  mockEvidence: dagShellVerifyEvidenceSchema.optional(),
166
168
  lintEvidence: dagShellVerifyEvidenceSchema.optional(),
167
169
  staticEvidence: dagShellVerifyEvidenceSchema,
@@ -311,6 +313,8 @@ export const dagBackendTestPipelineSchema = z.enum([
311
313
  "classification-result-context",
312
314
  "markdown-environment",
313
315
  "markdown-cases",
316
+ "markdown-collection-assess",
317
+ "markdown-collection-effective",
314
318
  "markdown-traceability",
315
319
  "markdown-execute-html",
316
320
  "markdown-manifest",
@@ -235,7 +235,8 @@ function validateRepairArtifactGateConfig(task, spec, issues) {
235
235
  candidate.role === "reviewer" &&
236
236
  !candidate.decisionGate?.enabled &&
237
237
  candidate.writePolicy === "read-only" &&
238
- (candidate.outputContract?.includes("VERDICT:") ?? false) &&
238
+ ((candidate.outputContract?.includes("VERDICT:") ?? false) ||
239
+ candidate.outputProtocol?.type === "json-review-verdict") &&
239
240
  // Format recovery / write-set format-repair nodes also emit VERDICT but
240
241
  // are not the primary content review after hard verification.
241
242
  !/(?:-format-repair-pi|-verdict-recovery-pi)$/.test(candidate.id));
@@ -243,7 +244,7 @@ function validateRepairArtifactGateConfig(task, spec, issues) {
243
244
  issues.push({
244
245
  type: "invalid-repair-artifact-gate-config",
245
246
  message: reviewCandidates.length === 0
246
- ? `task ${task.id} hard verification node "${hardVerify.id}" must reach one read-only Pi reviewer with a VERDICT output contract`
247
+ ? `task ${task.id} hard verification node "${hardVerify.id}" must reach one read-only Pi reviewer with a VERDICT or JSON review output contract`
247
248
  : `task ${task.id} hard verification node "${hardVerify.id}" has multiple Pi reviewer nodes (${reviewCandidates.map((candidate) => candidate.id).join(", ")})`,
248
249
  });
249
250
  }
@@ -28,11 +28,11 @@
28
28
  "Root artifacts/ is reserved for explicit exclusive write nodes, not read-only scout/reviewer output",
29
29
  "exclusive implementer nodes must use narrow, concrete writeSet paths; never keep ** or repo root",
30
30
  "Replace REPLACE/WITH/NARROW/IMPLEMENT/PATHS/** with concrete paths before executing the implementation writer",
31
- "backend-test-dag uses exactly 9 real top-level tasks and executes pytest exactly once over only the safe scripts explicitly mapped by final Markdown cases.",
31
+ "backend-test-dag uses exactly 12 real top-level tasks. Pytest collection runs once on the green path and at most twice only when one bounded pre-execution repair is eligible; business pytest test bodies execute exactly once over safe scripts explicitly mapped by final Markdown cases.",
32
32
  "Model nodes produce Markdown and pytest assets, never backend-test business JSON envelopes.",
33
- "Environment, advisory Markdown validation/coverage, advisory traceability/correspondence, canonical manifest, pytest-html, HTML and execution facts are deterministic evidence. Nodes 4 and 6 record findings without blocking nodes 5, 7 or 8; node 7 partial/unavailable does not block node 8.",
33
+ "Environment, advisory Markdown validation/coverage, collection initial/effective facts, advisory traceability/correspondence, canonical manifest, pytest-html, HTML and execution facts are deterministic evidence. Node 4 quality findings stay advisory; nodes 6/8 form the fail-closed collection authorization; node 9 traceability findings stay advisory; node 10 partial/unavailable manifest does not block node 11 when effective collection remains fresh.",
34
34
  "Only Markdown case generation/review may read source facts; pytest generation must not read source/**.",
35
- "Functional case IDs use canonical BE-<MODULE>-<NNN> with exactly three digits and no alphabetic suffix. Every Test Point has exactly one variant/assertion/cross-cutting binding; only variant bindings create pytest parameter items. Production code/config, skip/xfail, repair and rerun are forbidden."
35
+ "Functional case IDs use canonical BE-<MODULE>-<NNN> with exactly three digits and no alphabetic suffix. Every Test Point has exactly one variant/assertion/cross-cutting binding; only variant bindings create pytest parameter items. Production code/config, skip/xfail, execution-result repair and business pytest rerun are forbidden; the only repair is one pre-execution collection-proven generated-test asset repair."
36
36
  ],
37
37
  "defaults": {
38
38
  "executor": "pi",
@@ -206,7 +206,7 @@
206
206
  "subtask_prompt": "Convert testcase/md/** to pytest using upstream environment and advisory validation evidence plus only bounded pytest config/conftest. A FAIL advisory report does not authorize inventing missing behavior; use the final Markdown facts that are present.\n\nEnsure every final Markdown Case ID appears in exactly one primary pytest test function or pytest test class method region, using the exact `primary symbol` declared by Markdown. The symbol must start with `test_BE_<MODULE>_<NNN>_` so every parameterized collected item remains associated with its Case. Module-level functions and class-based pytest methods are both supported. Only `变体测试点` may use stable `pytest.param(..., id=\"TP-...\")` IDs, and every atomic variant ID must appear exactly once with a genuine input/state/outcome change. Use `pytest.param(..., id=...)` for every row; do not use decorator-level `ids=[...]`, generated suffixes, or IDs that extend/shorten the exact Markdown TP. Do not parameterize `场景断言测试点` or `横切证据测试点`; execute all assertion checkpoints within the same business journey/item and use shared helpers for cross-cutting evidence. The primary symbol docstring must contain exact metadata lines `Case-ID: BE-...`, `Assertion-Test-Points: TP-...;TP-...` and `Cross-Cutting-Test-Points: TP-...;TP-...` (use `none` when empty). No Test Point may be invented, renamed, omitted or bound in two modes. The generated pytest collection shape must equal the Markdown prediction `sum(max(1, variant count per Case))`; keep it at or below the task's explicit budget by removing duplicate execution, never by collapsing multiple parameter rows under a coarse family TP. Assertions come only from 预期结果 and setup comes only from 前置条件/测试数据/自动化映射.\n\nName each generated pytest file so it corresponds one-to-one with its source Markdown module file: for each `testcase/md/<module>.md` (excluding README.md), emit exactly one `testcase/test_<module>.py`. The <module> stem is the Markdown filename without the `.md` extension, lowercased and with non-alphanumeric characters replaced by underscores. For example, `testcase/md/resource_notes.md` maps to `testcase/test_resource_notes.py`, `testcase/md/health.md` maps to `testcase/test_health.py`, `testcase/md/BE-HEALTH.md` maps to `testcase/test_be_health.py`, and `testcase/md/order-api.md` maps to `testcase/test_order_api.py`. If Markdown automation mapping names a different path than this module stem path, still write the module stem path and do not invent prefixes such as `test_be_*` unless the module filename itself normalizes to that stem. Never merge multiple Markdown modules into one pytest file, never split one module across several files, and never invent pytest filenames unrelated to the Markdown modules.\n\nGenerate a reusable HTTP logging helper (or equivalent client wrapper) and call it for every interface request. The request log must include method, URL/path, and request parameters (query plus JSON/body/payload summary). The response log must include status code and response result (JSON/text/body summary), and both records must be visible in pytest stdout/stderr without changing assertions.\n\nHTTP response header names are case-insensitive. If the helper stores a lower-case normalized header map, every Content-Type or other header assertion must query the lower-case key (for example `content-type`) or use an explicitly case-insensitive accessor; never call a case-sensitive plain dict with `Content-Type` when the stored key is lower-case. Preserve the actual media-type assertion rather than dropping it.\n\nCompare timestamps and other semantically equivalent protocol values by parsed meaning, not byte-for-byte serialization. In particular, normalize valid ISO-8601 instants before equality/order assertions so differences such as omitted trailing fractional seconds do not create TestBug failures; preserve exact-string assertions only when the Markdown explicitly requires representation equality.\n\nBefore logging, recursively redact sensitive keys and header values including authorization, proxy-authorization, cookie, set-cookie, token, password, secret, api key and credentials. Never print full Authorization/Cookie values. Apply bounded truncation to serialized request and response bodies (with an explicit truncation marker) so large payloads cannot flood pytest or report artifacts.\n\nDo not read source/**, add cases, reassign ACs, modify conftest/config/production code, use skip/xfail, swallow assertions, execute pytest, or emit JSON. For best-effort cleanup, catch only the narrow transport exception actually raised by the selected HTTP client (for example `requests.RequestException` or `urllib.error.URLError`); never use bare `except`, `Exception`, or `BaseException` with `pass`."
207
207
  },
208
208
  {
209
- "id": "backend-test-traceability-gate-shell",
209
+ "id": "assess-backend-pytest-collection-shell",
210
210
  "depends_on": [
211
211
  "generate-backend-pytest-pi"
212
212
  ],
@@ -223,8 +223,100 @@
223
223
  ".harness/dag-runs/**",
224
224
  "artifacts/**"
225
225
  ],
226
- "outputContract": "Run-owned reports/backend-test-traceability.md, reports/backend-test-markdown-pytest-correspondence.md and contracts/backend-test-markdown-pytest-correspondence-facts.json with PASS/FAIL/UNAVAILABLE correspondence facts.",
227
- "subtask_prompt": "Deterministically scan only Markdown-mapped pytest scripts. Keep the existing traceability/logging checks and produce a bidirectional Markdown module/Case/Test Point ↔ pytest file/primary symbol correspondence analysis. Map variant Test Points from stable parameter IDs, assertion Test Points from the primary symbol docstring, and cross-cutting Test Points from the primary symbol evidence binding. Report 1:1, 1:0, 1:N, 0:1, script/primary-symbol mismatch, missing Case ID, missing variant parameter IDs, missing assertion/cross-cutting bindings, duplicate modes and extra bindings. Human and machine evidence must come from the same facts. Findings are advisory and never block pytest.",
226
+ "outputContract": "Run-owned reports/backend-test-pytest-collection-initial.md and contracts/backend-test-pytest-collection-initial.json with bounded diagnostics, asset hashes, collected item IDs and deterministic repair eligibility.",
227
+ "subtask_prompt": "Run pytest collection only over final Markdown-mapped scripts before any business test body execution. Materialize hash-bound PASS/REPAIRABLE/BLOCKED facts. Only generated testcase-local syntax/import inconsistencies are repairable; dependency, plugin, production-module, environment, safety and unknown failures remain blocked.",
228
+ "shell": {
229
+ "commands": [],
230
+ "backendTestPipeline": "markdown-collection-assess",
231
+ "cwd": ".",
232
+ "timeoutMs": 120000
233
+ }
234
+ },
235
+ {
236
+ "id": "repair-backend-pytest-collection-pi",
237
+ "depends_on": [
238
+ "assess-backend-pytest-collection-shell"
239
+ ],
240
+ "runIf": "$.nodes['assess-backend-pytest-collection-shell'].json.repairEligible == true",
241
+ "role": "implementer",
242
+ "executor": "pi",
243
+ "toolProfile": "write",
244
+ "complexity": "HIGH",
245
+ "writePolicy": "exclusive",
246
+ "writeSet": [
247
+ "testcase/**/test_*.py",
248
+ "testcase/**/helpers/**",
249
+ "testcase/**/factories/**"
250
+ ],
251
+ "allowedPaths": [
252
+ "testcase/**",
253
+ "docs/test-reports/**"
254
+ ],
255
+ "forbiddenPaths": [
256
+ ".harness/**",
257
+ ".harness/dag-runs/**",
258
+ "artifacts/**",
259
+ "testcase/md/**",
260
+ "conftest.py",
261
+ "pytest.ini",
262
+ "pyproject.toml",
263
+ "setup.cfg"
264
+ ],
265
+ "writerOutcomePolicy": {
266
+ "type": "implementation-outcome-v1"
267
+ },
268
+ "outputContract": "First non-empty line is IMPLEMENTATION_OUTCOME: changed|already-satisfied|blocked, followed by a concise repair summary. Modify only generated pytest scripts/helpers/factories and preserve every Markdown Case, Test Point, primary symbol and assertion meaning.",
269
+ "subtask_prompt": "Repair the generated backend pytest asset as one bounded program using the direct upstream collection assessment. This is the only repair attempt and happens before any business test body execution.\n\nFix only collection-proven generated testcase-local syntax, module path, missing symbol, circular import, fixture-name, decorator or parameterization inconsistencies. Inspect all affected importers and providers so the repair is cross-file consistent.\n\nPreserve final testcase/md/** semantics, every Case ID, Rule/Test Point binding, primary symbol, parameter ID, expected status/body/schema assertion, HTTP logging, redaction and truncation behavior.\n\nDo not read task source/** or reinterpret requirements. Do not modify Markdown, conftest, pytest config, production code or dependencies.\n\nDo not add skip/skipif/xfail, remove tests, reduce collected items, loosen assertions, swallow exceptions, use try/except ImportError fallback, mutate sys.path/PYTHONPATH, or replace the real API with mocks.\n\nDo not execute pytest; the deterministic effective collection gate owns the final collection attempt."
270
+ },
271
+ {
272
+ "id": "effective-backend-pytest-collection-gate-shell",
273
+ "depends_on": [
274
+ "assess-backend-pytest-collection-shell",
275
+ "repair-backend-pytest-collection-pi"
276
+ ],
277
+ "role": "verifier",
278
+ "executor": "shell",
279
+ "complexity": "LOW",
280
+ "writePolicy": "read-only",
281
+ "allowedPaths": [
282
+ "testcase/**",
283
+ "docs/test-reports/**"
284
+ ],
285
+ "forbiddenPaths": [
286
+ ".harness/**",
287
+ ".harness/dag-runs/**",
288
+ "artifacts/**"
289
+ ],
290
+ "outputContract": "Run-owned reports/backend-test-pytest-collection-effective.md and contracts/backend-test-pytest-collection-effective.json proving the exact final assets are collectable; initial PASS is reused, repair path records attempt=1.",
291
+ "subtask_prompt": "If initial collection passed, verify unchanged asset hashes and reuse it without another collection. If the single repair ran, collect the final mapped scripts once and fail closed unless it passes. BLOCKED initial facts, repair failure, final collection failure or hash drift must prevent business pytest execution.",
292
+ "shell": {
293
+ "commands": [],
294
+ "backendTestPipeline": "markdown-collection-effective",
295
+ "cwd": ".",
296
+ "timeoutMs": 120000
297
+ },
298
+ "dependsPolicy": "all-or-condition-skip"
299
+ },
300
+ {
301
+ "id": "backend-test-traceability-gate-shell",
302
+ "depends_on": [
303
+ "effective-backend-pytest-collection-gate-shell"
304
+ ],
305
+ "role": "verifier",
306
+ "executor": "shell",
307
+ "complexity": "LOW",
308
+ "writePolicy": "read-only",
309
+ "allowedPaths": [
310
+ "testcase/**",
311
+ "docs/test-reports/**"
312
+ ],
313
+ "forbiddenPaths": [
314
+ ".harness/**",
315
+ ".harness/dag-runs/**",
316
+ "artifacts/**"
317
+ ],
318
+ "outputContract": "Run-owned reports/backend-test-traceability.md, reports/backend-test-markdown-pytest-correspondence.md and contracts/backend-test-markdown-pytest-correspondence-facts.json with PASS/FAIL/UNAVAILABLE correspondence facts bound after effective collection.",
319
+ "subtask_prompt": "Deterministically scan only Markdown-mapped pytest scripts after the effective hash-bound collection gate. Keep the existing traceability/logging checks and produce a bidirectional Markdown module/Case/Test Point ↔ pytest file/primary symbol correspondence analysis. Map variant Test Points from stable parameter IDs, assertion Test Points from the primary symbol docstring, and cross-cutting Test Points from the primary symbol evidence binding. Report 1:1, 1:0, 1:N, 0:1, script/primary-symbol mismatch, missing Case ID, missing variant parameter IDs, missing assertion/cross-cutting bindings, duplicate modes and extra bindings. Human and machine evidence must come from the same facts. Findings are advisory and never block pytest.",
228
320
  "shell": {
229
321
  "commands": [],
230
322
  "backendTestPipeline": "markdown-traceability",
@@ -278,7 +370,7 @@
278
370
  "artifacts/**"
279
371
  ],
280
372
  "outputContract": "One scoped pytest execution over Markdown-mapped scripts producing a valid pytest-html report with per-case captured output, self-contained reports/backend-test.html, reports/backend-test.md, reports/backend-test-facts.md, a deterministic self-contained reports/backend-test-l5-dashboard.html (machine-computed L-5 metrics, no JSON), and an optional contracts/code-coverage-v1.json when jacocoCoverage is configured (JaCoCo TCP dump → jacoco.xml → parsed; failure-safe); exit 0/1 with valid evidence continues.",
281
- "subtask_prompt": "Resolve the final Markdown Automation Notes/自动化映射 to a unique, safe set of testcase/**/test_*.py targets and execute only those scripts exactly once. Prefer the deterministic module one-to-one path when a mapped script is missing but the module stem file exists. Generate a native pytest-html self-contained report, then render the primary self-contained Chinese HTML report from the same pytest-html plus final Markdown case metadata without rerun. Keep 测试结论 and quality status; make node 4 Markdown validation + case coverage and node 6 traceability + Markdown-to-pytest correspondence expandable to their full escaped details; show each failure overview item with its original pytest message plus deterministic evidence-based reason analysis; list failure/error case cards before the remaining cases while preserving stable order. Each polished per-case result card includes concise scenario, automation test name, result, duration, and redacted bounded HTTP request parameters/response results for both passed and failed cases. Do not render a technical/execution evidence section in HTML; retain auditable paths and hashes in facts.",
373
+ "subtask_prompt": "Resolve the final Markdown Automation Notes/自动化映射 to a unique, safe set of testcase/**/test_*.py targets and execute only those scripts exactly once. Prefer the deterministic module one-to-one path when a mapped script is missing but the module stem file exists. Generate a native pytest-html self-contained report, then render the primary self-contained Chinese HTML report from the same pytest-html plus final Markdown case metadata without rerun. Keep 测试结论 and quality status; make node 4 Markdown validation + case coverage and node 9 traceability + Markdown-to-pytest correspondence expandable to their full escaped details; show each failure overview item with its original pytest message plus deterministic evidence-based reason analysis; list failure/error case cards before the remaining cases while preserving stable order. Each polished per-case result card includes concise scenario, automation test name, result, duration, and redacted bounded HTTP request parameters/response results for both passed and failed cases. Do not render a technical/execution evidence section in HTML; retain auditable paths and hashes in facts.",
282
374
  "shell": {
283
375
  "commands": [
284
376
  "mkdir -p \"${HARNESS_DAG_RUN_DIR}/reports\"; echo \"pytest targets are resolved at runtime from final Markdown 自动化映射\""
@@ -311,7 +403,7 @@
311
403
  "artifacts/**"
312
404
  ],
313
405
  "outputContract": "Final Markdown report and L-5 conclusion under docs/test-reports/**; the deterministic L-5 dashboard at reports/backend-test-l5-dashboard.html is the authoritative visualization and must be linked, not re-rendered; no JSON.",
314
- "subtask_prompt": "Generate the final Markdown report only from authoritative run-owned artifacts. Read node 1 reports/backend-test-environment.md; node 4 backend-md-case-validation.md and backend-test-case-coverage-analysis.md; node 6 backend-test-traceability.md and backend-test-markdown-pytest-correspondence.md; node 7 contracts/backend-test-case-manifest.json; and node 8 backend-test-result.json, backend-test-facts.md, pytest-html/HTML and L-5 dashboard. Do not use node 2/3/5 assistant prose as facts. Do not emit JSON.\n\nUse this exact human-facing section order: 测试结论 → 执行概览 → 质量校验 → 失败分析 → 风险与建议 → 证据与 L-5. Put the decision and key numbers first, use compact tables/bullets, and keep headings concise. Do not paste entire upstream reports, duplicate per-case tables already present in facts, or repeat the same evidence in multiple sections; link to paths/hashes and quote only the findings needed for the conclusion.\n\nThe L-5 metrics and visualization are produced deterministically by node 8 at reports/backend-test-l5-dashboard.html. Link to that dashboard as the authoritative L-5 view. Pytest execution facts come from node 8; coverage/correspondence numbers and materializationStatus come from node 7; detailed coverage findings come from node 4; detailed mapping findings come from node 6. Never recompute these values. If machine manifest and human reports disagree, report evidence inconsistency rather than silently choosing.\n\nAlways state the exact Coverage Scope classification, policy, affected operations, regression floor, completeness claim, PASS/FAIL/UNAVAILABLE status and findings from node 4 case validation + coverage, plus node 6 traceability + correspondence. Affected-scope or affected-operations-full coverage must never be described as whole-API completeness unless every operation is explicitly listed. Their FAIL status does not block pytest, but it must remain visible and must never be rewritten as PASS.\n\nInclude environment, case quality/review, automation mapping, exact pytest facts, failure classification/analysis, risks, regression recommendations, evidence paths/hashes, coverage availability, and L-5 READY/NOT READY. Distinguish Markdown Case count, primary pytest symbol count, collected pytest item count, variant/assertion/cross-cutting Test Point counts and execution amplification; never describe pytest item count as the number of business scenarios.\n\nNever override Shell/pytest-html facts. L-5 requires pass=100%, AC=100%, automation>=90%, line>=80%, branch>=70%, skipped=0 and no blocking Critical risk.\n\nWrite only under docs/test-reports/**."
406
+ "subtask_prompt": "Generate the final Markdown report only from authoritative run-owned artifacts. Read node 1 reports/backend-test-environment.md; node 4 backend-md-case-validation.md and backend-test-case-coverage-analysis.md; nodes 6/8 backend-test-pytest-collection initial/effective reports and facts; node 9 backend-test-traceability.md and backend-test-markdown-pytest-correspondence.md; node 10 contracts/backend-test-case-manifest.json; and node 11 backend-test-result.json, backend-test-facts.md, pytest-html/HTML and L-5 dashboard. Do not use node 2/3/5 assistant prose as facts. Do not emit JSON.\n\nUse this exact human-facing section order: 测试结论 → 执行概览 → 质量校验 → 失败分析 → 风险与建议 → 证据与 L-5. Put the decision and key numbers first, use compact tables/bullets, and keep headings concise. Do not paste entire upstream reports, duplicate per-case tables already present in facts, or repeat the same evidence in multiple sections; link to paths/hashes and quote only the findings needed for the conclusion.\n\nThe L-5 metrics and visualization are produced deterministically by node 11 at reports/backend-test-l5-dashboard.html. Link to that dashboard as the authoritative L-5 view. Pytest execution facts come from node 11; coverage/correspondence numbers and materializationStatus come from node 10; collection authorization comes from nodes 6/8; detailed coverage findings come from node 4; detailed mapping findings come from node 9. Never recompute these values. If machine manifest and human reports disagree, report evidence inconsistency rather than silently choosing.\n\nAlways state the exact Coverage Scope classification, policy, affected operations, regression floor, completeness claim, PASS/FAIL/UNAVAILABLE status and findings from node 4 case validation + coverage, plus node 9 traceability + correspondence. Affected-scope or affected-operations-full coverage must never be described as whole-API completeness unless every operation is explicitly listed. Their FAIL status does not block pytest, but it must remain visible and must never be rewritten as PASS.\n\nInclude environment, case quality/review, automation mapping, exact pytest facts, failure classification/analysis, risks, regression recommendations, evidence paths/hashes, coverage availability, and L-5 READY/NOT READY. Distinguish Markdown Case count, primary pytest symbol count, collected pytest item count, variant/assertion/cross-cutting Test Point counts and execution amplification; never describe pytest item count as the number of business scenarios.\n\nNever override Shell/pytest-html facts. L-5 requires pass=100%, AC=100%, automation>=90%, line>=80%, branch>=70%, skipped=0 and no blocking Critical risk.\n\nWrite only under docs/test-reports/**."
315
407
  }
316
408
  ],
317
409
  "sourceBinding": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.26.1",
3
+ "version": "0.26.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",
@@ -20,7 +20,7 @@
20
20
 
21
21
  > Backend-test Markdown-first:先由确定性环境 Shell 检查 clean env 中 Python/pytest、常见配置、conftest/fixture、test root、server entry 和 HTML renderer,失败时不消耗模型调用。随后 Pi 生成中文 README 索引与模块用例卡片并独立 Review `testcase/md/**`。第 4 节点只把前置条件、操作步骤、预期结果作为必选章节,并检查 Case ID、业务 AC、步骤/预期和占位措辞;不校验需求来源引用有效性或 Markdown sensitive-shaped 内容。pytest writer 为每次真实接口调用记录脱敏、有界的请求 method/URL/参数摘要和响应 status/body 摘要。第 6 节点只扫描每条 Case 明确映射的 pytest 脚本,同时支持模块级函数和 pytest class 方法,并把缺少请求/响应日志、递归脱敏或有界截断证据记录为 advisory。第 4/6 节点均写 PASS/FAIL findings 而不阻断后续;pytest 仍只运行一次,生成 JUnit,并把 Markdown 名称/场景/脚本映射与同一 JUnit 合成为按测试概览、质量校验、失败概览、用例执行明细和技术证据组织的中文 self-contained HTML 与 Markdown facts。最终 Pi 按固定简洁结构汇总 advisory 状态、执行事实和 L-5 结论。active 流程不要求模型生成 backend-test 业务 JSON。
22
22
 
23
- 显式专用 `taskKind` 保持兼容并优先于任务源分类。`backend-test` 选择固定 **8 个真实顶层节点**的 Markdown-first DAG:环境硬门、Markdown cases、独立 Review/修订、第 4 节点 advisory Markdown 校验、pytest 转换、第 6 节点 scoped advisory traceability、单次 pytest + JUnit/HTML/facts、最终 Markdown 报告与 L-5。历史 JSON contract/materializer 可继续读取旧 DAG,但新 runtime/template 不再生成模型业务 JSON。`knowledge-sync` 与 `knowledge-graph-bootstrap` 继续通过各自显式 taskKind 选择知识回写/图谱开荒 DAG。治理等级仍由 `minimal|standard|reviewed|supervised` 推断。
23
+ 显式专用 `taskKind` 保持兼容并优先于任务源分类。`backend-test` 选择固定 **12 个真实顶层节点**的 Markdown-first DAG:环境硬门、Markdown cases、独立 Review/修订、第 4 节点 advisory Markdown 校验、pytest 转换、initial collection assessment、仅 `REPAIRABLE` 时最多一次 generated-test repair、hash-bound effective collection、scoped traceability、facts-only manifest、单次业务 pytest + pytest-html/HTML/facts、最终 Markdown 报告与 L-5。绿色路径 collection 一次,repair 路径两次,但测试体始终只执行一次;依赖/plugin/生产模块/环境/安全/未知 collection error 不得 repair,assertion/API failure不触发 repair 或 rerun。历史 JSON contract/materializer 可继续读取旧 DAG,但新 runtime/template 不再生成模型业务 JSON。`knowledge-sync` 与 `knowledge-graph-bootstrap` 继续通过各自显式 taskKind 选择知识回写/图谱开荒 DAG。治理等级仍由 `minimal|standard|reviewed|supervised` 推断。
24
24
 
25
25
  ### DAG workflow 层级
26
26