@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.
- package/CHANGELOG.md +24 -0
- package/dist/application/dag/generate-task-dag.js +33 -0
- package/dist/commands/task-source-prepare.js +6 -0
- package/dist/executors/shell-executor.js +111 -0
- package/dist/executors/shell-presets.js +12 -4
- package/dist/task/config-types.js +6 -0
- package/dist/task/contract/constants.js +1 -0
- package/dist/task/contract/project.js +8 -0
- package/dist/task/contract/schema.js +1 -0
- package/dist/task/frontend-preflight.js +131 -0
- package/dist/task/runtime.js +2 -4
- package/dist/task/source-prepare/build-draft.js +9 -0
- package/dist/task/source-prepare/completeness.js +1 -1
- package/dist/worker/observability/read-model.js +134 -0
- package/dist/worker/observe/static/state.js +61 -0
- package/dist/worker/observe/static/styles.css +8 -0
- package/dist/worker/observe/static/views/dag-graph.js +107 -31
- package/dist/worker/observe/static/views/dag-inspector.js +374 -157
- package/dist/worker/observe/static/views/dag.js +4 -11
- package/dist/workflows/dag/backend-test-pytest-collection.js +277 -0
- package/dist/workflows/dag/convergence/controller.js +110 -21
- package/dist/workflows/dag/frontend-implementation-contract.js +218 -17
- package/dist/workflows/dag/frontend-review-context.js +7 -1
- package/dist/workflows/dag/frontend-verification-trace.js +14 -3
- package/dist/workflows/dag/frontend-worktree-diff.js +14 -3
- package/dist/workflows/dag/init-hybrid.js +96 -34
- package/dist/workflows/dag/output-protocol.js +180 -7
- package/dist/workflows/dag/runner.js +141 -52
- package/dist/workflows/dag/types.js +4 -0
- package/dist/workflows/dag/validate.js +3 -2
- package/docs/templates/backend-test-dag.json +100 -8
- package/package.json +1 -1
- 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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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 (
|
|
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
|
|
150
|
-
*
|
|
151
|
-
* not
|
|
152
|
-
* the
|
|
153
|
-
* and
|
|
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
|
-
//
|
|
162
|
-
//
|
|
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 =
|
|
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,
|