@tea-agent/loop-agent 0.26.1 → 0.26.2

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 (33) hide show
  1. package/CHANGELOG.md +24 -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/shell-executor.js +111 -0
  5. package/dist/executors/shell-presets.js +12 -4
  6. package/dist/task/config-types.js +6 -0
  7. package/dist/task/contract/constants.js +1 -0
  8. package/dist/task/contract/project.js +8 -0
  9. package/dist/task/contract/schema.js +1 -0
  10. package/dist/task/frontend-preflight.js +131 -0
  11. package/dist/task/runtime.js +2 -4
  12. package/dist/task/source-prepare/build-draft.js +9 -0
  13. package/dist/task/source-prepare/completeness.js +1 -1
  14. package/dist/worker/observability/read-model.js +134 -0
  15. package/dist/worker/observe/static/state.js +61 -0
  16. package/dist/worker/observe/static/styles.css +8 -0
  17. package/dist/worker/observe/static/views/dag-graph.js +107 -31
  18. package/dist/worker/observe/static/views/dag-inspector.js +374 -157
  19. package/dist/worker/observe/static/views/dag.js +4 -11
  20. package/dist/workflows/dag/backend-test-pytest-collection.js +277 -0
  21. package/dist/workflows/dag/convergence/controller.js +110 -21
  22. package/dist/workflows/dag/frontend-implementation-contract.js +218 -17
  23. package/dist/workflows/dag/frontend-review-context.js +7 -1
  24. package/dist/workflows/dag/frontend-verification-trace.js +14 -3
  25. package/dist/workflows/dag/frontend-worktree-diff.js +14 -3
  26. package/dist/workflows/dag/init-hybrid.js +96 -34
  27. package/dist/workflows/dag/output-protocol.js +180 -7
  28. package/dist/workflows/dag/runner.js +141 -52
  29. package/dist/workflows/dag/types.js +4 -0
  30. package/dist/workflows/dag/validate.js +3 -2
  31. package/docs/templates/backend-test-dag.json +100 -8
  32. package/package.json +1 -1
  33. package/skills/loop-agent/references/hybrid-dag.md +1 -1
@@ -271,7 +271,9 @@ export async function renderDagDetail(dagRunId, initial = true) {
271
271
  ranksEl.appendChild(el("h3", null, "DAG 依赖图"));
272
272
  ranksEl.appendChild(el("p", "empty", UI_TEXT.noNodes));
273
273
  } else if (reuseShell && existingGraphWrap?.parentNode === ranksEl) {
274
- // reuse-dag-graph-wrap: keep wrap identity across polls when possible.
274
+ // reuse-dag-graph-wrap + viewport identity: keep wrap/viewport alive.
275
+ // Defer content rebuild only while the graph resize gesture is active;
276
+ // even without defer, renderDagGraph reuses the same viewport node.
275
277
  if (!graphGestureActive) {
276
278
  renderDagGraph(dag, dagRunId, existingGraphWrap);
277
279
  }
@@ -328,16 +330,7 @@ export async function renderDagDetail(dagRunId, initial = true) {
328
330
  };
329
331
  });
330
332
  const nodeTable = buildTable(
331
- [
332
- "序号",
333
- "节点 ID",
334
- "依赖层",
335
- "执行方式",
336
- "模型",
337
- "状态",
338
- "耗时",
339
- "备注",
340
- ],
333
+ ["序号", "节点 ID", "依赖层", "执行方式", "模型", "状态", "耗时", "备注"],
341
334
  rows,
342
335
  );
343
336
  nodeTable.classList.add("dag-node-table");
@@ -0,0 +1,277 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { z } from "zod";
5
+ const SHA256 = /^[a-f0-9]{64}$/;
6
+ const MAX_DIAGNOSTIC_CHARS = 12_000;
7
+ export const backendPytestCollectionFindingSchema = z.object({
8
+ kind: z.string().min(1),
9
+ classification: z.literal("test-asset-defect"),
10
+ repairability: z.enum(["repairable", "blocked"]),
11
+ detail: z.string().min(1),
12
+ }).strict();
13
+ export const backendPytestCollectionFactsSchema = z.object({
14
+ schemaId: z.literal("backend-test-pytest-collection-v1"),
15
+ phase: z.enum(["initial", "final", "effective"]),
16
+ status: z.enum(["PASS", "REPAIRABLE", "BLOCKED"]),
17
+ repairEligible: z.boolean(),
18
+ repairAttempt: z.number().int().min(0).max(1),
19
+ mappedScripts: z.array(z.string()).min(1),
20
+ assetFiles: z.array(z.string()).min(1),
21
+ inputHashes: z.record(z.string(), z.string().regex(SHA256)),
22
+ pytestExitCode: z.number().int(),
23
+ collectedItemCount: z.number().int().min(0),
24
+ collectedItemIds: z.array(z.string()),
25
+ findings: z.array(backendPytestCollectionFindingSchema),
26
+ stdoutExcerpt: z.string(),
27
+ stderrExcerpt: z.string(),
28
+ collectionSource: z.enum(["initial", "final"]).optional(),
29
+ }).strict();
30
+ function repoRef(workspaceRoot, absolutePath) {
31
+ return path.relative(workspaceRoot, absolutePath).replaceAll(path.sep, "/");
32
+ }
33
+ function sha256(bytes) {
34
+ return createHash("sha256").update(bytes).digest("hex");
35
+ }
36
+ async function walkPythonFiles(root) {
37
+ const results = [];
38
+ const visit = async (current) => {
39
+ let entries;
40
+ try {
41
+ entries = await readdir(current, { withFileTypes: true });
42
+ }
43
+ catch {
44
+ return;
45
+ }
46
+ for (const entry of entries) {
47
+ if (entry.name === "__pycache__" || entry.name === ".pytest_cache")
48
+ continue;
49
+ const absolute = path.join(current, entry.name);
50
+ if (entry.isDirectory())
51
+ await visit(absolute);
52
+ else if (entry.isFile() && entry.name.endsWith(".py"))
53
+ results.push(absolute);
54
+ }
55
+ };
56
+ await visit(root);
57
+ return results;
58
+ }
59
+ export async function buildBackendPytestAssetInventory(workspaceRoot, mappedScripts) {
60
+ const normalizedScripts = [...new Set(mappedScripts.map((value) => value.replaceAll("\\", "/")))].sort();
61
+ if (normalizedScripts.length === 0)
62
+ throw new Error("backend pytest collection requires mapped scripts");
63
+ for (const script of normalizedScripts) {
64
+ if (!/^testcase(?:\/[A-Za-z0-9_.-]+)*\/test_[A-Za-z0-9_.-]+\.py$/.test(script) && !/^testcase\/test_[A-Za-z0-9_.-]+\.py$/.test(script)) {
65
+ throw new Error(`unsafe backend pytest mapped script: ${script}`);
66
+ }
67
+ const absolute = path.resolve(workspaceRoot, script);
68
+ const testcaseRoot = path.resolve(workspaceRoot, "testcase");
69
+ if (absolute !== testcaseRoot && !absolute.startsWith(`${testcaseRoot}${path.sep}`)) {
70
+ throw new Error(`backend pytest mapped script escapes testcase: ${script}`);
71
+ }
72
+ await readFile(absolute);
73
+ }
74
+ const pythonFiles = await walkPythonFiles(path.resolve(workspaceRoot, "testcase"));
75
+ const assetFiles = [...new Set(pythonFiles.map((file) => repoRef(workspaceRoot, file)))].sort();
76
+ const inputHashes = {};
77
+ for (const file of assetFiles)
78
+ inputHashes[file] = sha256(await readFile(path.resolve(workspaceRoot, file)));
79
+ return { mappedScripts: normalizedScripts, assetFiles, inputHashes };
80
+ }
81
+ function bounded(value) {
82
+ const normalized = value.replaceAll(/(Bearer|Basic)\s+[A-Za-z0-9._~+\/-]+/gi, "$1 [REDACTED]");
83
+ return normalized.length <= MAX_DIAGNOSTIC_CHARS
84
+ ? normalized
85
+ : `${normalized.slice(0, MAX_DIAGNOSTIC_CHARS)}\n...[truncated]`;
86
+ }
87
+ function collectionItems(stdout) {
88
+ return [...new Set(stdout.split(/\r?\n/).map((line) => line.trim()).filter((line) => {
89
+ if (!line.includes("::"))
90
+ return false;
91
+ if (/^(ERROR|FAILED|E\s+)/.test(line))
92
+ return false;
93
+ return /^testcase\//.test(line.replaceAll("\\", "/"));
94
+ }).map((line) => line.replaceAll("\\", "/")))];
95
+ }
96
+ function classifyCollectionFailure(output) {
97
+ const normalized = output.replaceAll("\\", "/");
98
+ const blockedPatterns = [
99
+ [/No module named ['\"](?!testcase(?:\.|['\"]))/i, "missing-third-party-module"],
100
+ [/pytest(?:_|-)plugin|pluggy|unknown hook|unrecognized arguments/i, "pytest-plugin-or-config"],
101
+ [/(?:apps|src|server|api)\/[A-Za-z0-9_./-]+\.py/i, "production-module-import"],
102
+ [/permission denied|access is denied|timed? out|not recognized as an internal|no such file or directory.*python/i, "environment-or-spawn"],
103
+ ];
104
+ for (const [pattern, kind] of blockedPatterns) {
105
+ if (pattern.test(normalized))
106
+ return { status: "BLOCKED", kind, detail: "collection failure is outside generated testcase asset repair scope" };
107
+ }
108
+ if (/ImportError:\s*cannot import name/i.test(normalized) && /(?:from ['\"]testcase\.|\/testcase\/)/i.test(normalized)) {
109
+ return { status: "REPAIRABLE", kind: "missing-local-import-symbol", detail: "generated testcase asset imports a missing local symbol" };
110
+ }
111
+ if (/ModuleNotFoundError:\s*No module named ['\"]testcase(?:\.|['\"])/i.test(normalized)) {
112
+ return { status: "REPAIRABLE", kind: "missing-local-testcase-module", detail: "generated testcase asset imports a missing local testcase module" };
113
+ }
114
+ if (/partially initialized module ['\"]testcase\.|circular import/i.test(normalized)) {
115
+ return { status: "REPAIRABLE", kind: "local-circular-import", detail: "generated testcase assets contain a local circular import" };
116
+ }
117
+ if (/SyntaxError|IndentationError|TabError/i.test(normalized) && /testcase\/[A-Za-z0-9_./-]+\.py/i.test(normalized)) {
118
+ return { status: "REPAIRABLE", kind: "generated-python-syntax", detail: "generated testcase Python source is not syntactically collectable" };
119
+ }
120
+ return { status: "BLOCKED", kind: "unclassified-collection-error", detail: "collection failure cannot be safely attributed to generated testcase assets" };
121
+ }
122
+ export function assessBackendPytestCollection(input) {
123
+ const items = collectionItems(input.stdout);
124
+ if (input.exitCode === 0) {
125
+ return backendPytestCollectionFactsSchema.parse({
126
+ schemaId: "backend-test-pytest-collection-v1",
127
+ phase: input.phase,
128
+ status: "PASS",
129
+ repairEligible: false,
130
+ repairAttempt: input.phase === "final" ? 1 : 0,
131
+ mappedScripts: input.inventory.mappedScripts,
132
+ assetFiles: input.inventory.assetFiles,
133
+ inputHashes: input.inventory.inputHashes,
134
+ pytestExitCode: input.exitCode,
135
+ collectedItemCount: items.length,
136
+ collectedItemIds: items,
137
+ findings: [],
138
+ stdoutExcerpt: bounded(input.stdout),
139
+ stderrExcerpt: bounded(input.stderr),
140
+ });
141
+ }
142
+ const classification = classifyCollectionFailure(`${input.stdout}\n${input.stderr}`);
143
+ return backendPytestCollectionFactsSchema.parse({
144
+ schemaId: "backend-test-pytest-collection-v1",
145
+ phase: input.phase,
146
+ status: classification.status,
147
+ repairEligible: input.phase === "initial" && classification.status === "REPAIRABLE",
148
+ repairAttempt: input.phase === "final" ? 1 : 0,
149
+ mappedScripts: input.inventory.mappedScripts,
150
+ assetFiles: input.inventory.assetFiles,
151
+ inputHashes: input.inventory.inputHashes,
152
+ pytestExitCode: input.exitCode,
153
+ collectedItemCount: items.length,
154
+ collectedItemIds: items,
155
+ findings: [{
156
+ kind: classification.kind,
157
+ classification: "test-asset-defect",
158
+ repairability: classification.status === "REPAIRABLE" ? "repairable" : "blocked",
159
+ detail: classification.detail,
160
+ }],
161
+ stdoutExcerpt: bounded(input.stdout),
162
+ stderrExcerpt: bounded(input.stderr),
163
+ });
164
+ }
165
+ export function renderBackendPytestCollectionReport(facts) {
166
+ return [
167
+ `# Backend pytest Collection ${facts.phase}`,
168
+ "",
169
+ "## Status",
170
+ "",
171
+ facts.status,
172
+ "",
173
+ `- Repair eligible: ${facts.repairEligible}`,
174
+ `- Repair attempt: ${facts.repairAttempt}`,
175
+ `- Pytest exit code: ${facts.pytestExitCode}`,
176
+ `- Mapped scripts: ${facts.mappedScripts.length}`,
177
+ `- Bound Python assets: ${facts.assetFiles.length}`,
178
+ `- Collected items: ${facts.collectedItemCount}`,
179
+ "",
180
+ "## Findings",
181
+ "",
182
+ ...(facts.findings.length ? facts.findings.map((finding) => `- ${finding.kind}: ${finding.detail}`) : ["- None"]),
183
+ "",
184
+ "## Collected Items",
185
+ "",
186
+ ...(facts.collectedItemIds.length ? facts.collectedItemIds.map((item) => `- ${item}`) : ["- None"]),
187
+ "",
188
+ "## Bounded stdout",
189
+ "",
190
+ "```text",
191
+ facts.stdoutExcerpt,
192
+ "```",
193
+ "",
194
+ "## Bounded stderr",
195
+ "",
196
+ "```text",
197
+ facts.stderrExcerpt,
198
+ "```",
199
+ "",
200
+ ].join("\n");
201
+ }
202
+ export async function writeBackendPytestCollectionArtifacts(input) {
203
+ const contractsDir = path.join(input.runDir, "contracts");
204
+ const reportsDir = path.join(input.runDir, "reports");
205
+ await mkdir(contractsDir, { recursive: true });
206
+ await mkdir(reportsDir, { recursive: true });
207
+ const factsPath = path.join(contractsDir, `backend-test-pytest-collection-${input.stem}.json`);
208
+ const reportPath = path.join(reportsDir, `backend-test-pytest-collection-${input.stem}.md`);
209
+ await writeFile(factsPath, `${JSON.stringify(input.facts, null, 2)}\n`, "utf8");
210
+ await writeFile(reportPath, renderBackendPytestCollectionReport(input.facts), "utf8");
211
+ return { factsPath, reportPath };
212
+ }
213
+ export async function readBackendPytestCollectionFacts(filePath) {
214
+ return backendPytestCollectionFactsSchema.parse(JSON.parse(await readFile(filePath, "utf8")));
215
+ }
216
+ async function assertBackendPytestRepairSafety(workspaceRoot, initial, final) {
217
+ if (JSON.stringify(initial.mappedScripts) !== JSON.stringify(final.mappedScripts)) {
218
+ throw new Error("backend pytest repair changed mapped script scope");
219
+ }
220
+ const forbidden = [
221
+ [/pytest\.mark\.(?:skip|skipif|xfail)\b|pytest\.(?:skip|xfail)\s*\(/, "skip/xfail"],
222
+ [/sys\.path\s*\.|PYTHONPATH/, "sys.path/PYTHONPATH mutation"],
223
+ [/except\s+ImportError\b/, "ImportError fallback"],
224
+ [/except\s+(?:Exception|BaseException)\b[\s\S]{0,160}?\bpass\b/, "broad exception swallowing"],
225
+ ];
226
+ for (const file of final.assetFiles) {
227
+ const source = await readFile(path.resolve(workspaceRoot, file), "utf8");
228
+ for (const [pattern, label] of forbidden) {
229
+ if (pattern.test(source))
230
+ throw new Error(`backend pytest repair safety blocked ${label}: ${file}`);
231
+ }
232
+ }
233
+ }
234
+ function assertSameInventory(expected, actual) {
235
+ const expectedFiles = Object.keys(expected.inputHashes).sort();
236
+ const actualFiles = Object.keys(actual.inputHashes).sort();
237
+ if (JSON.stringify(expectedFiles) !== JSON.stringify(actualFiles))
238
+ throw new Error("backend pytest collection asset inventory drift");
239
+ for (const file of expectedFiles) {
240
+ if (expected.inputHashes[file] !== actual.inputHashes[file])
241
+ throw new Error(`backend pytest collection hash drift: ${file}`);
242
+ }
243
+ }
244
+ export async function assertBackendPytestCollectionFresh(workspaceRoot, effective) {
245
+ if (effective.phase !== "effective" || effective.status !== "PASS") {
246
+ throw new Error("backend pytest execution requires effective collection PASS facts");
247
+ }
248
+ const current = await buildBackendPytestAssetInventory(workspaceRoot, effective.mappedScripts);
249
+ assertSameInventory(effective, current);
250
+ }
251
+ export async function materializeEffectiveBackendPytestCollection(input) {
252
+ if (input.initial.status === "PASS") {
253
+ const current = await buildBackendPytestAssetInventory(input.workspaceRoot, input.initial.mappedScripts);
254
+ assertSameInventory(input.initial, current);
255
+ return backendPytestCollectionFactsSchema.parse({
256
+ ...input.initial,
257
+ phase: "effective",
258
+ collectionSource: "initial",
259
+ });
260
+ }
261
+ if (input.initial.status === "BLOCKED")
262
+ throw new Error("backend pytest collection is blocked and not repairable");
263
+ if (!input.final)
264
+ throw new Error("backend pytest collection repair requires final collection facts");
265
+ if (input.final.status !== "PASS")
266
+ throw new Error("backend pytest collection still fails after bounded repair");
267
+ await assertBackendPytestRepairSafety(input.workspaceRoot, input.initial, input.final);
268
+ const current = await buildBackendPytestAssetInventory(input.workspaceRoot, input.final.mappedScripts);
269
+ assertSameInventory(input.final, current);
270
+ return backendPytestCollectionFactsSchema.parse({
271
+ ...input.final,
272
+ phase: "effective",
273
+ repairEligible: false,
274
+ repairAttempt: 1,
275
+ collectionSource: "final",
276
+ });
277
+ }
@@ -18,7 +18,14 @@ const DEFAULT_CONVERGENCE_CHAIN_NODE_IDS = [
18
18
  */
19
19
  const REVIEW_GATE_NODE_ID = "review-gate-shell";
20
20
  const REVIEW_VERDICT_NODE_ID = "review-verdict-recovery-pi";
21
- const CONVERGENCE_NON_RETRY_FAILURES = new Set([
21
+ const INITIAL_REVIEW_NODE_ID = "initial-review-pi";
22
+ const INITIAL_REVIEW_GATE_NODE_ID = "initial-review-gate-shell";
23
+ const INITIAL_REVIEW_PASS_NODE_ID = "initial-review-pass-shell";
24
+ // Automatic code repair is authorized only for an ordinary command/test
25
+ // failure. Unknown, protocol, validation, provider, safety, governance, and
26
+ // human-decision categories all fail closed instead of reaching a writer.
27
+ const HARD_VERIFY_REPAIR_ELIGIBLE_FAILURES = new Set(["nonzero-exit"]);
28
+ const REVIEW_SOURCE_NON_RETRY_FAILURES = new Set([
22
29
  "timeout",
23
30
  "spawn-error",
24
31
  "write-guard",
@@ -26,9 +33,6 @@ const CONVERGENCE_NON_RETRY_FAILURES = new Set([
26
33
  "auth",
27
34
  "human-rejected",
28
35
  "decision-gate-requires-human",
29
- ]);
30
- const REVIEW_SOURCE_NON_RETRY_FAILURES = new Set([
31
- ...CONVERGENCE_NON_RETRY_FAILURES,
32
36
  "protocol-invalid",
33
37
  "invalid-output",
34
38
  ]);
@@ -38,26 +42,61 @@ export function shouldEnableDagConvergence(spec) {
38
42
  }
39
43
  export async function runConvergencePassController(input) {
40
44
  const convergence = input.state.convergence;
41
- if (!convergence?.enabled)
45
+ if (!convergence)
42
46
  return { retry: false };
43
47
  if (process.env.HARNESS_DAG_CONVERGENCE === "off") {
44
48
  convergence.terminalReason = "feature-flag-off";
45
49
  return { retry: false };
46
50
  }
51
+ if (!convergence.enabled) {
52
+ return resolveNoRepairTerminal(input);
53
+ }
47
54
  if (!hasConvergenceChain(input.tasksById, input.spec)) {
48
55
  convergence.terminalReason = "unsupported-dag-shape";
49
56
  return { retry: false };
50
57
  }
51
58
  const chain = getConvergenceChain(input.spec);
52
59
  const observesReview = chain.includes(REVIEW_GATE_NODE_ID);
60
+ if (input.tasksById.has(INITIAL_REVIEW_NODE_ID)) {
61
+ const initialReview = input.state.nodes[INITIAL_REVIEW_NODE_ID];
62
+ const initialGate = input.state.nodes[INITIAL_REVIEW_GATE_NODE_ID];
63
+ const initialPass = input.state.nodes[INITIAL_REVIEW_PASS_NODE_ID];
64
+ if (initialReview?.status === "ERROR" || initialGate?.status === "ERROR") {
65
+ convergence.terminalReason = "non-retry-failure";
66
+ return { retry: false };
67
+ }
68
+ if (initialPass?.status === "FINISHED" &&
69
+ parseProcessVerdict(initialReview) === "pass") {
70
+ convergence.terminalReason = "review-pass";
71
+ return { retry: false };
72
+ }
73
+ const repair = input.state.nodes["repair-pi"];
74
+ if (convergence.currentPass === 1 &&
75
+ parseProcessVerdict(initialReview) === "request-revision" &&
76
+ (repair?.status === "FINISHED" || repair?.status === "ERROR")) {
77
+ // Pass 1 is the initial review. The first executed repair chain is pass 2,
78
+ // even though the scheduler completes it before the controller first runs.
79
+ convergence.currentPass = 2;
80
+ }
81
+ if (repair?.status === "ERROR") {
82
+ convergence.terminalReason = "non-retry-failure";
83
+ return { retry: false };
84
+ }
85
+ }
53
86
  const hardVerify = input.state.nodes["hard-verify-shell"];
54
87
  if (!hardVerify)
55
88
  return { retry: false };
56
89
  if (hardVerify.status === "ERROR") {
57
90
  return handleHardVerifyFailure(input, hardVerify);
58
91
  }
59
- if (hardVerify.status !== "FINISHED")
92
+ if (hardVerify.status !== "FINISHED") {
93
+ // The scheduler has already settled every ordinary rank before invoking
94
+ // the controller. PENDING/SKIPPED here therefore means an earlier failure
95
+ // blocked the convergence chain; record a terminal reason so the deferred
96
+ // failure-aware closeout remains reachable.
97
+ convergence.terminalReason = "non-retry-failure";
60
98
  return { retry: false };
99
+ }
61
100
  // Hard verification passed. When the chain observes a review gate, success
62
101
  // is gated on the review verdict: a legitimate `request-revision` re-enters
63
102
  // the same bounded repair-reverify-review loop (AC3/AC4). Non-supervised
@@ -74,6 +113,13 @@ export async function runConvergencePassController(input) {
74
113
  const reviewGate = input.state.nodes[REVIEW_GATE_NODE_ID];
75
114
  if (!reviewGate)
76
115
  return { retry: false };
116
+ // Well-formed request-revision is a convergence routing result: the review
117
+ // gate finishes successfully (routingAccept) rather than shell ERROR.
118
+ // Still re-enter the bounded repair chain when revision evidence is valid.
119
+ if (reviewGate.status === "FINISHED" &&
120
+ isLegitimateReviewRequestRevision(input.state)) {
121
+ return handleReviewRequestRevision(input, reviewGate);
122
+ }
77
123
  if (reviewGate.status === "FINISHED") {
78
124
  convergence.terminalReason = "review-pass";
79
125
  await appendConvergenceKnowledgePattern({
@@ -82,10 +128,50 @@ export async function runConvergencePassController(input) {
82
128
  });
83
129
  return { retry: false };
84
130
  }
131
+ // Compatibility: gates without routingAccept still ERROR on revision.
85
132
  if (reviewGate.status === "ERROR") {
86
133
  return handleReviewRequestRevision(input, reviewGate);
87
134
  }
88
- // Review gate still PENDING/SKIPPED mid-rank: wait for the next loop.
135
+ // The scheduler pass is settled; an unexecuted gate is a terminal upstream
136
+ // blockage, not a reason to leave closeout permanently pending.
137
+ convergence.terminalReason = "non-retry-failure";
138
+ return { retry: false };
139
+ }
140
+ /**
141
+ * `maxFixLoops=0` disables writer re-entry, not terminal adjudication. Review
142
+ * and hard-verify evidence still decide whether closeout is a truthful success
143
+ * or a bounded failure. Existing handlers are safe here because maxPasses=1,
144
+ * so they record the terminal pass without resetting any writer nodes.
145
+ */
146
+ async function resolveNoRepairTerminal(input) {
147
+ const convergence = input.state.convergence;
148
+ const reviewGate = input.state.nodes[REVIEW_GATE_NODE_ID];
149
+ if (reviewGate) {
150
+ if (reviewGate.status === "FINISHED" &&
151
+ isLegitimateReviewRequestRevision(input.state)) {
152
+ return handleReviewRequestRevision(input, reviewGate);
153
+ }
154
+ if (reviewGate.status === "FINISHED") {
155
+ convergence.terminalReason = "review-pass";
156
+ return { retry: false };
157
+ }
158
+ if (reviewGate.status === "ERROR") {
159
+ return handleReviewRequestRevision(input, reviewGate);
160
+ }
161
+ convergence.terminalReason = "non-retry-failure";
162
+ return { retry: false };
163
+ }
164
+ const hardVerify = input.state.nodes["hard-verify-shell"];
165
+ if (hardVerify?.status === "ERROR") {
166
+ return handleHardVerifyFailure(input, hardVerify);
167
+ }
168
+ if (hardVerify?.status === "FINISHED") {
169
+ convergence.terminalReason = "hard-verify-pass";
170
+ return { retry: false };
171
+ }
172
+ convergence.terminalReason = hardVerify
173
+ ? "non-retry-failure"
174
+ : "unsupported-dag-shape";
89
175
  return { retry: false };
90
176
  }
91
177
  /**
@@ -104,7 +190,7 @@ async function handleHardVerifyFailure(input, hardVerify) {
104
190
  runDir: input.runDir,
105
191
  spec: input.spec,
106
192
  });
107
- if (CONVERGENCE_NON_RETRY_FAILURES.has(hardFailure)) {
193
+ if (!HARD_VERIFY_REPAIR_ELIGIBLE_FAILURES.has(hardFailure)) {
108
194
  passRecord.status = "terminal";
109
195
  passRecord.reason = "non-retry-failure";
110
196
  convergence.passHistory.push(passRecord);
@@ -145,29 +231,32 @@ async function handleHardVerifyFailure(input, hardVerify) {
145
231
  await input.persistState();
146
232
  return { retry: true };
147
233
  }
234
+ function isLegitimateReviewRequestRevision(state) {
235
+ const reviewNode = state.nodes["review-pi"];
236
+ const reviewVerdictNode = state.nodes[REVIEW_VERDICT_NODE_ID];
237
+ return (reviewNode?.status === "FINISHED" &&
238
+ parseProcessVerdict(reviewNode) === "request-revision" &&
239
+ reviewVerdictNode?.status === "FINISHED" &&
240
+ parseProcessVerdict(reviewVerdictNode) === "request-revision");
241
+ }
148
242
  /**
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.
243
+ * Hard verification passed but the review path produced a legitimate
244
+ * `request-revision`. With routingAccept the review gate finishes as a routing
245
+ * result (not shell ERROR); older gates may still ERROR. Re-enter the same
246
+ * bounded recovery chain so repair can address the findings, then re-verify
247
+ * and re-review. Protocol/safety failures stay fail-closed.
155
248
  */
156
249
  async function handleReviewRequestRevision(input, reviewGate) {
157
250
  const convergence = input.state.convergence;
158
251
  const currentPass = convergence.currentPass || 1;
159
252
  const reviewNode = input.state.nodes["review-pi"];
160
253
  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.
254
+ // Inspect the complete review chain so recovery output cannot launder
255
+ // provider/safety/protocol failures into automatic code repair.
164
256
  const reviewFailure = [reviewGate, reviewVerdictNode, reviewNode]
165
257
  .map((node) => node?.failureCategory)
166
258
  .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";
259
+ const legitimateRequestRevision = isLegitimateReviewRequestRevision(input.state);
171
260
  if (reviewFailure || !legitimateRequestRevision) {
172
261
  const passRecord = await buildConvergencePassRecord({
173
262
  pass: currentPass,