@tea-agent/loop-agent 0.25.4 → 0.25.6

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 (40) hide show
  1. package/AGENTS.md +6 -0
  2. package/CHANGELOG.md +60 -0
  3. package/dist/commands/client-recovery.js +209 -62
  4. package/dist/commands/init.js +68 -129
  5. package/dist/executors/dag-pi-executor.js +80 -15
  6. package/dist/executors/model-routing.js +1 -1
  7. package/dist/executors/shell-executor.js +127 -0
  8. package/dist/executors/shell-write-guard.js +21 -7
  9. package/dist/worker/console/repo-fingerprint.js +7 -1
  10. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +1281 -0
  11. package/dist/workflows/dag/backend-test-case-manifest.js +59 -1
  12. package/dist/workflows/dag/backend-test-markdown-workflow.js +236 -16
  13. package/dist/workflows/dag/convergence/controller.js +134 -9
  14. package/dist/workflows/dag/frontend-test-l5-report.js +138 -0
  15. package/dist/workflows/dag/init-hybrid.js +270 -80
  16. package/dist/workflows/dag/node-execution.js +64 -11
  17. package/dist/workflows/dag/prompt.js +118 -4
  18. package/dist/workflows/dag/retry-policy.js +5 -4
  19. package/dist/workflows/dag/scheduler.js +32 -5
  20. package/dist/workflows/dag/types.js +7 -4
  21. package/dist/workflows/dag/validate.js +3 -2
  22. package/docs/architecture/dag-execution.md +7 -4
  23. package/docs/architecture/runtime-boundaries.md +1 -1
  24. package/docs/init-surface.manifest.json +3 -1
  25. package/docs/templates/README.md +1 -0
  26. package/docs/templates/agent-dag.base.json +1 -1
  27. package/docs/templates/agent-dag.final-verification.json +1 -1
  28. package/docs/templates/agent-dag.supervised-implementation.json +1 -1
  29. package/docs/templates/backend-test-dag.json +41 -14
  30. package/docs/templates/frontend-test-dag.json +32 -2
  31. package/docs/templates/hybrid-dag.json +1 -1
  32. package/docs/templates/init-managed-agents.md +137 -0
  33. package/examples/decision-gate-agent-dag.json +1 -1
  34. package/examples/example-dag.json +1 -1
  35. package/examples/hybrid-loop-agent-dag.json +1 -1
  36. package/harness.json +1 -1
  37. package/package.json +1 -1
  38. package/skills/loop-agent/references/command-reference.md +5 -4
  39. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  40. package/skills/loop-agent/references/model-routing.md +1 -1
@@ -8,6 +8,16 @@ const DEFAULT_CONVERGENCE_CHAIN_NODE_IDS = [
8
8
  "repair-pi",
9
9
  "hard-verify-shell",
10
10
  ];
11
+ /**
12
+ * Review chain node ids that the supervised convergence loop may also observe.
13
+ * When these are part of the active convergence chain (via
14
+ * `spec.convergence.chainNodeIds`), a legitimate review `request-revision`
15
+ * re-enters the same bounded repair-reverify-review loop instead of only
16
+ * blocking closeout. Review protocol-invalid failures keep using their own
17
+ * protocol recovery; only a safe `request-revision` drives code repair here.
18
+ */
19
+ const REVIEW_GATE_NODE_ID = "review-gate-shell";
20
+ const REVIEW_VERDICT_NODE_ID = "review-verdict-recovery-pi";
11
21
  const CONVERGENCE_NON_RETRY_FAILURES = new Set([
12
22
  "timeout",
13
23
  "spawn-error",
@@ -17,6 +27,11 @@ const CONVERGENCE_NON_RETRY_FAILURES = new Set([
17
27
  "human-rejected",
18
28
  "decision-gate-requires-human",
19
29
  ]);
30
+ const REVIEW_SOURCE_NON_RETRY_FAILURES = new Set([
31
+ ...CONVERGENCE_NON_RETRY_FAILURES,
32
+ "protocol-invalid",
33
+ "invalid-output",
34
+ ]);
20
35
  export function shouldEnableDagConvergence(spec) {
21
36
  return (process.env.HARNESS_DAG_CONVERGENCE !== "off" &&
22
37
  spec.convergence?.enabled === true);
@@ -33,10 +48,22 @@ export async function runConvergencePassController(input) {
33
48
  convergence.terminalReason = "unsupported-dag-shape";
34
49
  return { retry: false };
35
50
  }
51
+ const chain = getConvergenceChain(input.spec);
52
+ const observesReview = chain.includes(REVIEW_GATE_NODE_ID);
36
53
  const hardVerify = input.state.nodes["hard-verify-shell"];
37
54
  if (!hardVerify)
38
55
  return { retry: false };
39
- if (hardVerify.status === "FINISHED") {
56
+ if (hardVerify.status === "ERROR") {
57
+ return handleHardVerifyFailure(input, hardVerify);
58
+ }
59
+ if (hardVerify.status !== "FINISHED")
60
+ return { retry: false };
61
+ // Hard verification passed. When the chain observes a review gate, success
62
+ // is gated on the review verdict: a legitimate `request-revision` re-enters
63
+ // the same bounded repair-reverify-review loop (AC3/AC4). Non-supervised
64
+ // DAGs without a review gate in the chain keep the original hard-verify-pass
65
+ // terminal behavior.
66
+ if (!observesReview) {
40
67
  convergence.terminalReason = "hard-verify-pass";
41
68
  await appendConvergenceKnowledgePattern({
42
69
  cwd: input.cwd,
@@ -44,8 +71,29 @@ export async function runConvergencePassController(input) {
44
71
  });
45
72
  return { retry: false };
46
73
  }
47
- if (hardVerify.status !== "ERROR")
74
+ const reviewGate = input.state.nodes[REVIEW_GATE_NODE_ID];
75
+ if (!reviewGate)
48
76
  return { retry: false };
77
+ if (reviewGate.status === "FINISHED") {
78
+ convergence.terminalReason = "review-pass";
79
+ await appendConvergenceKnowledgePattern({
80
+ cwd: input.cwd,
81
+ state: input.state,
82
+ });
83
+ return { retry: false };
84
+ }
85
+ if (reviewGate.status === "ERROR") {
86
+ return handleReviewRequestRevision(input, reviewGate);
87
+ }
88
+ // Review gate still PENDING/SKIPPED mid-rank: wait for the next loop.
89
+ return { retry: false };
90
+ }
91
+ /**
92
+ * Hard verification failed. Apply the existing non-retry / regression /
93
+ * max-passes guards, then reset the convergence chain for another pass.
94
+ */
95
+ async function handleHardVerifyFailure(input, hardVerify) {
96
+ const convergence = input.state.convergence;
49
97
  const currentPass = convergence.currentPass || 1;
50
98
  const hardFailure = hardVerify.failureCategory ?? "unknown";
51
99
  const passRecord = await buildConvergencePassRecord({
@@ -97,6 +145,69 @@ export async function runConvergencePassController(input) {
97
145
  await input.persistState();
98
146
  return { retry: true };
99
147
  }
148
+ /**
149
+ * Hard verification passed but the review gate blocked closeout with a
150
+ * legitimate `request-revision` (review-gate-shell ERRORs when the verdict is
151
+ * not `pass`). Re-enter the same bounded recovery chain so repair can address
152
+ * the review findings, then re-verify and re-review. Review protocol-invalid
153
+ * and invalid-output sources, plus safety failures such as auth/write-guard,
154
+ * stay fail-closed and never enter code repair.
155
+ */
156
+ async function handleReviewRequestRevision(input, reviewGate) {
157
+ const convergence = input.state.convergence;
158
+ const currentPass = convergence.currentPass || 1;
159
+ const reviewNode = input.state.nodes["review-pi"];
160
+ const reviewVerdictNode = input.state.nodes[REVIEW_VERDICT_NODE_ID];
161
+ // The gate commonly reports only nonzero-exit. Inspect the complete review
162
+ // chain so recovery output cannot launder provider/safety/protocol failures
163
+ // into automatic code repair.
164
+ const reviewFailure = [reviewGate, reviewVerdictNode, reviewNode]
165
+ .map((node) => node?.failureCategory)
166
+ .find((category) => category && REVIEW_SOURCE_NON_RETRY_FAILURES.has(category));
167
+ const legitimateRequestRevision = reviewNode?.status === "FINISHED" &&
168
+ parseProcessVerdict(reviewNode) === "request-revision" &&
169
+ reviewVerdictNode?.status === "FINISHED" &&
170
+ parseProcessVerdict(reviewVerdictNode) === "request-revision";
171
+ if (reviewFailure || !legitimateRequestRevision) {
172
+ const passRecord = await buildConvergencePassRecord({
173
+ pass: currentPass,
174
+ status: "terminal",
175
+ reason: "non-retry-failure",
176
+ state: input.state,
177
+ runDir: input.runDir,
178
+ spec: input.spec,
179
+ });
180
+ convergence.passHistory.push(passRecord);
181
+ convergence.terminalReason = "non-retry-failure";
182
+ await input.persistState();
183
+ return { retry: false };
184
+ }
185
+ const passRecord = await buildConvergencePassRecord({
186
+ pass: currentPass,
187
+ status: "retrying",
188
+ reason: "review-request-revision",
189
+ state: input.state,
190
+ runDir: input.runDir,
191
+ spec: input.spec,
192
+ });
193
+ if (currentPass >= convergence.maxPasses) {
194
+ passRecord.status = "terminal";
195
+ passRecord.reason = "max-passes";
196
+ convergence.passHistory.push(passRecord);
197
+ convergence.terminalReason = "max-passes";
198
+ await input.persistState();
199
+ return { retry: false };
200
+ }
201
+ convergence.passHistory.push(passRecord);
202
+ convergence.currentPass = currentPass + 1;
203
+ await resetConvergenceNodesForNextPass({
204
+ spec: input.spec,
205
+ state: input.state,
206
+ tasksById: input.tasksById,
207
+ });
208
+ await input.persistState();
209
+ return { retry: true };
210
+ }
100
211
  function getConvergenceChain(spec) {
101
212
  return spec.convergence?.chainNodeIds ?? DEFAULT_CONVERGENCE_CHAIN_NODE_IDS;
102
213
  }
@@ -107,6 +218,8 @@ function hasConvergenceChain(tasksById, spec) {
107
218
  async function buildConvergencePassRecord(input) {
108
219
  const hardVerify = input.state.nodes["hard-verify-shell"];
109
220
  const processSupervisor = input.state.nodes["process-supervisor-pi"];
221
+ const reviewVerdictNode = input.state.nodes[REVIEW_VERDICT_NODE_ID];
222
+ const reviewGate = input.state.nodes[REVIEW_GATE_NODE_ID];
110
223
  const verifyEvidence = hardVerify?.verifyEvidence;
111
224
  return {
112
225
  pass: input.pass,
@@ -123,6 +236,9 @@ async function buildConvergencePassRecord(input) {
123
236
  verifyCommandCount: verifyEvidence?.commandCount,
124
237
  verifyCommandLabels: verifyEvidence?.commandLabels,
125
238
  shellSuccessCount: countSuccessfulShellCommands(hardVerify?.stdout),
239
+ reviewVerdict: parseProcessVerdict(reviewVerdictNode),
240
+ reviewGateStatus: reviewGate?.status,
241
+ reviewFailureCategory: reviewGate?.failureCategory,
126
242
  artifactRefs: await preserveConvergencePassArtifacts({
127
243
  pass: input.pass,
128
244
  state: input.state,
@@ -241,10 +357,17 @@ function extractSupervisorStructuredBlock(node) {
241
357
  // Legacy compatibility only. New supervisor prompts must emit REPAIR_ARTIFACT_JSON.
242
358
  const text = `${node?.assistantText ?? ""}\n${node?.stdout ?? ""}`;
243
359
  const block = {};
244
- for (const key of ["FAILURE_CLASS", "FIX_SCOPE", "INVARIANT"]) {
245
- const match = text.match(new RegExp(`^${key}:\\s*(.+)$`, "im"));
246
- if (match?.[1])
247
- block[key] = match[1].trim();
360
+ const acceptedKeys = new Set(["FAILURE_CLASS", "FIX_SCOPE", "INVARIANT"]);
361
+ for (const line of text.split(/\r?\n/)) {
362
+ const separatorIndex = line.indexOf(":");
363
+ if (separatorIndex <= 0)
364
+ continue;
365
+ const key = line.slice(0, separatorIndex).trim().toUpperCase();
366
+ if (!acceptedKeys.has(key))
367
+ continue;
368
+ const value = line.slice(separatorIndex + 1).trim();
369
+ if (value)
370
+ block[key] = value;
248
371
  }
249
372
  return block;
250
373
  }
@@ -252,9 +375,11 @@ async function resetConvergenceNodesForNextPass(input) {
252
375
  const chain = getConvergenceChain(input.spec);
253
376
  const resetIds = new Set(chain);
254
377
  for (const id of collectTransitiveDescendantTaskIds(input.spec, "hard-verify-shell")) {
255
- const node = input.state.nodes[id];
256
- if (node?.status === "SKIPPED")
257
- resetIds.add(id);
378
+ // A new repair pass invalidates every downstream result, including nodes
379
+ // that already FINISHED (for example authority audit or failure-aware
380
+ // closeout). Reset the complete controlled descendant closure so the next
381
+ // pass cannot reuse stale governance or handoff evidence.
382
+ resetIds.add(id);
258
383
  }
259
384
  for (const id of resetIds) {
260
385
  const task = input.tasksById.get(id);
@@ -0,0 +1,138 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { frontendTestResultContractSchema, } from "./frontend-test-result-contract.js";
4
+ function ratioMetric(numerator, denominator, threshold, label) {
5
+ if (denominator === 0) {
6
+ return { numerator, denominator, ratio: null, threshold, status: "unavailable", reason: `${label}-denominator-is-zero` };
7
+ }
8
+ const ratio = numerator / denominator;
9
+ return {
10
+ numerator,
11
+ denominator,
12
+ ratio,
13
+ threshold,
14
+ status: ratio >= threshold ? "pass" : "fail",
15
+ reason: ratio >= threshold ? null : `${label}-below-threshold`,
16
+ };
17
+ }
18
+ function unavailable(reason, threshold) {
19
+ return { numerator: null, denominator: null, ratio: null, threshold, status: "unavailable", reason };
20
+ }
21
+ /** Compute the shared L-5 gates from the authoritative frontend result contract. */
22
+ export function computeFrontendL5ReportMetrics(result) {
23
+ const executed = result.totals.passed + result.totals.failed;
24
+ const acTotal = result.acceptanceCoverage.covered.length + result.acceptanceCoverage.missing.length;
25
+ const automationExecuted = executed;
26
+ const criticalRiskCount = result.acceptanceCoverage.missing.length + result.advisoryFindings.filter((finding) => /critical|blocking|unsafe|missing-ac/i.test(finding.ruleId)).length;
27
+ const metrics = {
28
+ passRate: ratioMetric(result.totals.passed, executed, 1, "pass-rate"),
29
+ acCoverage: ratioMetric(result.acceptanceCoverage.covered.length, acTotal, 1, "ac-coverage"),
30
+ automationCoverage: ratioMetric(automationExecuted, result.totals.cases, 0.9, "automation-coverage"),
31
+ lineCoverage: unavailable("frontend-code-coverage-missing", 0.8),
32
+ branchCoverage: unavailable("frontend-code-coverage-missing", 0.7),
33
+ skipped: {
34
+ numerator: result.totals.blocked,
35
+ denominator: result.totals.blocked,
36
+ ratio: result.totals.blocked === 0 ? 1 : 0,
37
+ threshold: 1,
38
+ status: result.totals.blocked === 0 ? "pass" : "fail",
39
+ reason: result.totals.blocked === 0 ? null : "skipped-tests-present",
40
+ },
41
+ criticalRisks: {
42
+ numerator: criticalRiskCount,
43
+ denominator: criticalRiskCount,
44
+ ratio: criticalRiskCount === 0 ? 1 : 0,
45
+ threshold: 1,
46
+ status: criticalRiskCount === 0 ? "pass" : "fail",
47
+ reason: criticalRiskCount === 0 ? null : "critical-risk-present",
48
+ },
49
+ };
50
+ const blockingItems = Object.entries(metrics)
51
+ .filter(([, metric]) => metric.status !== "pass")
52
+ .map(([name, metric]) => `${name}:${metric.reason ?? metric.status}`);
53
+ return {
54
+ status: blockingItems.length === 0 ? "ready" : "not-ready",
55
+ metrics,
56
+ blockingItems,
57
+ };
58
+ }
59
+ function escapeHtml(value) {
60
+ return String(value ?? "")
61
+ .replaceAll("&", "&amp;")
62
+ .replaceAll("<", "&lt;")
63
+ .replaceAll(">", "&gt;")
64
+ .replaceAll('"', "&quot;")
65
+ .replaceAll("'", "&#39;");
66
+ }
67
+ function metricValue(metric) {
68
+ if (metric.ratio === null)
69
+ return "unavailable";
70
+ return `${(metric.ratio * 100).toFixed(1)}%`;
71
+ }
72
+ function metricRow(label, metric) {
73
+ return `| ${label} | ${metric.numerator ?? "-"} / ${metric.denominator ?? "-"} | ${metricValue(metric)} | ${metric.status.toUpperCase()} | ${metric.reason ?? "-"} |`;
74
+ }
75
+ function renderMarkdown(result, metrics) {
76
+ const m = metrics.metrics;
77
+ return [
78
+ "# 前端测试 L-5 报告",
79
+ "",
80
+ `- L-5 结论:**${metrics.status === "ready" ? "READY" : "NOT READY"}**`,
81
+ `- Result v1 outcome:${result.outcome}`,
82
+ "",
83
+ "| 指标 | 分子 / 分母 | 比例 | 状态 | 原因 |",
84
+ "| --- | ---: | ---: | --- | --- |",
85
+ metricRow("测试通过率", m.passRate),
86
+ metricRow("AC 验收覆盖", m.acCoverage),
87
+ metricRow("自动化覆盖率", m.automationCoverage),
88
+ metricRow("Line 代码覆盖", m.lineCoverage),
89
+ metricRow("Branch 代码覆盖", m.branchCoverage),
90
+ metricRow("阻塞/跳过用例", m.skipped),
91
+ metricRow("Critical 风险", m.criticalRisks),
92
+ "",
93
+ "## 判定规则",
94
+ "",
95
+ "测试通过率=100%、AC 覆盖=100%、自动化覆盖率≥90%、Line≥80%、Branch≥70%、阻塞/跳过=0 且无 Critical 风险时才为 READY。",
96
+ "前端 DAG 当前没有可信的 line/branch 代码覆盖产物,因此这两项保持 unavailable,不得推断。",
97
+ "",
98
+ "## 阻塞项",
99
+ "",
100
+ ...(metrics.blockingItems.length ? metrics.blockingItems.map((item) => `- ${item}`) : ["- 无"]),
101
+ "",
102
+ ].join("\n");
103
+ }
104
+ function renderHtml(result, metrics) {
105
+ const rows = Object.entries(metrics.metrics)
106
+ .map(([name, metric]) => `<tr><td>${escapeHtml(name)}</td><td>${metric.numerator ?? "-"} / ${metric.denominator ?? "-"}</td><td>${escapeHtml(metricValue(metric))}</td><td class="${metric.status}">${escapeHtml(metric.status.toUpperCase())}</td><td>${escapeHtml(metric.reason ?? "-")}</td></tr>`)
107
+ .join("");
108
+ const blocking = metrics.blockingItems.length
109
+ ? metrics.blockingItems.map((item) => `<li>${escapeHtml(item)}</li>`).join("")
110
+ : "<li>无</li>";
111
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none';style-src 'unsafe-inline'"><title>前端测试 L-5 报告</title><style>body{font:15px system-ui,"Microsoft YaHei",sans-serif;background:#f5f7fb;color:#172033;margin:0}main{max-width:1100px;margin:auto;padding:32px}.card{background:#fff;border-radius:14px;padding:24px;margin:16px 0;box-shadow:0 6px 24px #10182812}.decision{font-size:28px;font-weight:800;color:${metrics.status === "ready" ? "#067647" : "#b42318"}}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:12px;border-bottom:1px solid #e4e7ec}.pass{color:#067647}.fail{color:#b42318}.unavailable{color:#946200}code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}</style></head><body><main><section class="card"><h1>前端测试 L-5 报告</h1><div class="decision">${metrics.status === "ready" ? "READY" : "NOT READY"}</div><p>Result v1 outcome:<code>${escapeHtml(result.outcome)}</code></p></section><section class="card"><h2>指标</h2><table><thead><tr><th>指标</th><th>分子 / 分母</th><th>比例</th><th>状态</th><th>原因</th></tr></thead><tbody>${rows}</tbody></table></section><section class="card"><h2>阻塞项</h2><ul>${blocking}</ul><p>前端 DAG 当前没有可信的 line/branch 代码覆盖产物,因此这两项为 unavailable,不得推断。</p></section></main></body></html>`;
112
+ }
113
+ async function writePairAtomic(markdownPath, markdown, htmlPath, html) {
114
+ await mkdir(path.dirname(markdownPath), { recursive: true });
115
+ const nonce = `${process.pid}-${Date.now()}`;
116
+ const markdownTemp = `${markdownPath}.${nonce}.tmp`;
117
+ const htmlTemp = `${htmlPath}.${nonce}.tmp`;
118
+ try {
119
+ await writeFile(markdownTemp, markdown, "utf8");
120
+ await writeFile(htmlTemp, html, "utf8");
121
+ await rename(markdownTemp, markdownPath);
122
+ await rename(htmlTemp, htmlPath);
123
+ }
124
+ finally {
125
+ await rm(markdownTemp, { force: true });
126
+ await rm(htmlTemp, { force: true });
127
+ }
128
+ }
129
+ export async function renderFrontendTestL5Report(input) {
130
+ const resultPath = path.join(input.runDir, "contracts", "frontend-test-result.json");
131
+ const result = frontendTestResultContractSchema.parse(JSON.parse(await readFile(resultPath, "utf8")));
132
+ const metrics = computeFrontendL5ReportMetrics(result);
133
+ const outputDir = path.join(input.workspaceRoot, "testcase", "frontend", "reports");
134
+ const markdownPath = path.join(outputDir, "frontend-test-l5-dashboard.md");
135
+ const htmlPath = path.join(outputDir, "frontend-test-l5-dashboard.html");
136
+ await writePairAtomic(markdownPath, renderMarkdown(result, metrics), htmlPath, renderHtml(result, metrics));
137
+ return { metrics, markdownPath, htmlPath };
138
+ }