@tea-agent/loop-agent 0.25.4 → 0.25.5
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/AGENTS.md +6 -0
- package/CHANGELOG.md +40 -0
- package/dist/commands/client-recovery.js +209 -62
- package/dist/executors/dag-pi-executor.js +80 -15
- package/dist/executors/model-routing.js +1 -1
- package/dist/executors/shell-executor.js +127 -0
- package/dist/executors/shell-write-guard.js +21 -7
- package/dist/worker/console/repo-fingerprint.js +7 -1
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +964 -0
- package/dist/workflows/dag/backend-test-case-manifest.js +39 -1
- package/dist/workflows/dag/backend-test-markdown-workflow.js +219 -16
- package/dist/workflows/dag/convergence/controller.js +134 -9
- package/dist/workflows/dag/frontend-test-l5-report.js +138 -0
- package/dist/workflows/dag/init-hybrid.js +262 -75
- package/dist/workflows/dag/node-execution.js +64 -11
- package/dist/workflows/dag/prompt.js +118 -4
- package/dist/workflows/dag/retry-policy.js +5 -4
- package/dist/workflows/dag/scheduler.js +32 -5
- package/dist/workflows/dag/types.js +7 -4
- package/dist/workflows/dag/validate.js +3 -2
- package/docs/architecture/dag-execution.md +7 -4
- package/docs/architecture/runtime-boundaries.md +1 -1
- package/docs/templates/agent-dag.base.json +1 -1
- package/docs/templates/agent-dag.final-verification.json +1 -1
- package/docs/templates/agent-dag.supervised-implementation.json +1 -1
- package/docs/templates/backend-test-dag.json +40 -13
- package/docs/templates/frontend-test-dag.json +32 -2
- package/docs/templates/hybrid-dag.json +1 -1
- package/examples/decision-gate-agent-dag.json +1 -1
- package/examples/example-dag.json +1 -1
- package/examples/hybrid-loop-agent-dag.json +1 -1
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/loop-agent/references/hybrid-dag.md +2 -2
- package/skills/loop-agent/references/model-routing.md +1 -1
|
@@ -44,7 +44,7 @@ const gapSchema = z
|
|
|
44
44
|
const caseSchema = z
|
|
45
45
|
.object({
|
|
46
46
|
caseId: z.string().regex(BACKEND_TEST_CASE_ID_PATTERN),
|
|
47
|
-
acIds: z.array(z.string().regex(BACKEND_TEST_AC_ID_PATTERN))
|
|
47
|
+
acIds: z.array(z.string().regex(BACKEND_TEST_AC_ID_PATTERN)),
|
|
48
48
|
title: z.string().min(1),
|
|
49
49
|
category: backendTestCaseCategorySchema,
|
|
50
50
|
automationStatus: backendTestCaseAutomationStatusSchema,
|
|
@@ -96,6 +96,44 @@ export const backendTestCaseManifestSchema = z
|
|
|
96
96
|
requirementIds: z.array(z.string().regex(/^(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*$/)),
|
|
97
97
|
})
|
|
98
98
|
.strict(),
|
|
99
|
+
materializationStatus: z.enum(["available", "partial", "unavailable"]).optional(),
|
|
100
|
+
sourceFactsIssues: z.array(z.string().min(1)).optional(),
|
|
101
|
+
ruleCoverageSummary: z.object({
|
|
102
|
+
explicitAcCount: z.number().int().min(0),
|
|
103
|
+
coveredAcCount: z.number().int().min(0),
|
|
104
|
+
ruleCount: z.number().int().min(0),
|
|
105
|
+
coveredRuleCount: z.number().int().min(0),
|
|
106
|
+
testPointCount: z.number().int().min(0),
|
|
107
|
+
coveredTestPointCount: z.number().int().min(0),
|
|
108
|
+
enumValueCount: z.number().int().min(0),
|
|
109
|
+
coveredEnumValueCount: z.number().int().min(0),
|
|
110
|
+
invalidEquivalenceClassCount: z.number().int().min(0),
|
|
111
|
+
coveredInvalidEquivalenceClassCount: z.number().int().min(0),
|
|
112
|
+
boundaryPointCount: z.number().int().min(0),
|
|
113
|
+
coveredBoundaryPointCount: z.number().int().min(0),
|
|
114
|
+
formatClassCount: z.number().int().min(0),
|
|
115
|
+
coveredFormatClassCount: z.number().int().min(0),
|
|
116
|
+
businessStateCount: z.number().int().min(0),
|
|
117
|
+
coveredBusinessStateCount: z.number().int().min(0),
|
|
118
|
+
gapCount: z.number().int().min(0),
|
|
119
|
+
conflictCount: z.number().int().min(0),
|
|
120
|
+
}).strict().optional(),
|
|
121
|
+
correspondenceSummary: z.object({
|
|
122
|
+
markdownModuleCount: z.number().int().min(0),
|
|
123
|
+
exactModuleCount: z.number().int().min(0),
|
|
124
|
+
markdownCaseCount: z.number().int().min(0),
|
|
125
|
+
exactCorrespondenceCount: z.number().int().min(0),
|
|
126
|
+
missingPytestCount: z.number().int().min(0),
|
|
127
|
+
multiplePytestCount: z.number().int().min(0),
|
|
128
|
+
extraPytestCount: z.number().int().min(0),
|
|
129
|
+
scriptMismatchCount: z.number().int().min(0),
|
|
130
|
+
testPointCount: z.number().int().min(0),
|
|
131
|
+
mappedTestPointCount: z.number().int().min(0),
|
|
132
|
+
}).strict().optional(),
|
|
133
|
+
artifactRefs: z.object({
|
|
134
|
+
caseCoverageFacts: z.object({ path: z.string().min(1), sha256: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict(),
|
|
135
|
+
correspondenceFacts: z.object({ path: z.string().min(1), sha256: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict(),
|
|
136
|
+
}).strict().optional(),
|
|
99
137
|
cases: z.array(caseSchema),
|
|
100
138
|
evidenceGaps: z.array(gapSchema).default([]),
|
|
101
139
|
coverageSummary: z
|
|
@@ -6,6 +6,7 @@ import { createWriteStream } from "node:fs";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import { parseJacocoXml, } from "./backend-test-coverage-contract.js";
|
|
9
|
+
import { backendTestCaseManifestSchema, computeCaseManifestCoverageSummary, } from "./backend-test-case-manifest.js";
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
11
|
// Canonical contract is three digits (BE-MODULE-001). Readers accept legacy/
|
|
11
12
|
// model-drift two-digit suffixes and canonicalize them in memory so one bad
|
|
@@ -247,12 +248,20 @@ function extractSourceReferences(input) {
|
|
|
247
248
|
// rejects them instead of silently accepting a nearby bound prefix.
|
|
248
249
|
return unique([...boundMatches, ...citedPaths]);
|
|
249
250
|
}
|
|
251
|
+
function hasStructuredMarkdownTable(value, headerPattern) {
|
|
252
|
+
const rows = value.split(/\r?\n/).filter((line) => /^\s*\|.*\|\s*$/.test(line));
|
|
253
|
+
if (rows.length < 3 || !headerPattern.test(rows[0]))
|
|
254
|
+
return false;
|
|
255
|
+
return /^\s*\|(?:\s*:?-{3,}:?\s*\|)+\s*$/i.test(rows[1]) && rows.slice(2).some((row) => row.split("|").some((cell) => cell.trim().length > 0));
|
|
256
|
+
}
|
|
250
257
|
function hasNumberedListItem(value) {
|
|
251
258
|
return (/^\s*\d+[.)]\s+\S+/m.test(value) ||
|
|
252
|
-
/^\s*\|\s*\d+\s*\|\s*\S+/m.test(value)
|
|
259
|
+
/^\s*\|\s*\d+\s*\|\s*\S+/m.test(value) ||
|
|
260
|
+
hasStructuredMarkdownTable(value, /(?:步骤|操作|测试点|参数|step|action|operation|test point|parameter)/i));
|
|
253
261
|
}
|
|
254
262
|
function hasAssertableExpectedResult(value) {
|
|
255
|
-
return /^\s*(?:\d+[.)]|[-*+])\s+\S+/m.test(value)
|
|
263
|
+
return (/^\s*(?:\d+[.)]|[-*+])\s+\S+/m.test(value) ||
|
|
264
|
+
hasStructuredMarkdownTable(value, /(?:预期|期望|断言|状态|结果|expected|assert|status|result)/i));
|
|
256
265
|
}
|
|
257
266
|
function resolveBoundSourceReference(input) {
|
|
258
267
|
if (!input.sourceBinding) {
|
|
@@ -879,7 +888,10 @@ function reportOutputLines(value, prefix) {
|
|
|
879
888
|
const trimmed = line.startsWith(underscorePrefix)
|
|
880
889
|
? line.slice(underscorePrefix.length)
|
|
881
890
|
: line.slice(spacePrefix.length);
|
|
882
|
-
|
|
891
|
+
// Preserve a complete single-line JSON payload through parsing. Bounding
|
|
892
|
+
// happens after structural extraction; slicing here can corrupt valid JSON
|
|
893
|
+
// and make a large passed response appear as an empty body.
|
|
894
|
+
return redactBackendTestOutput(trimmed.trim());
|
|
883
895
|
});
|
|
884
896
|
}
|
|
885
897
|
/**
|
|
@@ -896,9 +908,16 @@ export function normalizeBackendTestHttpLogText(value) {
|
|
|
896
908
|
while (i < lines.length) {
|
|
897
909
|
const raw = lines[i];
|
|
898
910
|
const line = raw.trim();
|
|
899
|
-
//
|
|
900
|
-
|
|
901
|
-
|
|
911
|
+
// Canonical and compatible single-line forms. Normalize case, separator,
|
|
912
|
+
// and an optional colon so downstream parsing only consumes the canonical
|
|
913
|
+
// HTTP_REQUEST / HTTP_RESPONSE protocol. Examples accepted here include
|
|
914
|
+
// `HTTP_REQUEST {...}`, `HTTP REQUEST method=...`, and the generated-helper
|
|
915
|
+
// dialect `HTTP request: {...}`.
|
|
916
|
+
const protocolLine = line.match(/^HTTP(?:_|\s+)(REQUEST|RESPONSE)\b\s*:?\s*(.*)$/i);
|
|
917
|
+
if (protocolLine) {
|
|
918
|
+
const canonicalPrefix = `HTTP_${protocolLine[1].toUpperCase()}`;
|
|
919
|
+
const payload = protocolLine[2]?.trim() ?? "";
|
|
920
|
+
out.push(payload ? `${canonicalPrefix} ${payload}` : canonicalPrefix);
|
|
902
921
|
i += 1;
|
|
903
922
|
continue;
|
|
904
923
|
}
|
|
@@ -1000,12 +1019,13 @@ function parseHttpLogPayload(line, kind) {
|
|
|
1000
1019
|
const trimmed = line.trim();
|
|
1001
1020
|
if (!trimmed)
|
|
1002
1021
|
return {};
|
|
1022
|
+
let parsedJson;
|
|
1003
1023
|
let kv = {};
|
|
1004
1024
|
// Try JSON first (legacy HTTP_REQUEST {"method":...} shape).
|
|
1005
1025
|
if (trimmed.startsWith("{")) {
|
|
1006
1026
|
try {
|
|
1007
|
-
|
|
1008
|
-
for (const [key, value] of Object.entries(
|
|
1027
|
+
parsedJson = JSON.parse(trimmed);
|
|
1028
|
+
for (const [key, value] of Object.entries(parsedJson)) {
|
|
1009
1029
|
kv[key.toLowerCase()] = typeof value === "string" ? value : safeStringify(value);
|
|
1010
1030
|
}
|
|
1011
1031
|
}
|
|
@@ -1017,21 +1037,68 @@ function parseHttpLogPayload(line, kind) {
|
|
|
1017
1037
|
kv = extractKvPairs(trimmed);
|
|
1018
1038
|
}
|
|
1019
1039
|
if (kind === "request") {
|
|
1040
|
+
const queryEnvelope = asHttpLogRecord(parsedJson?.query);
|
|
1041
|
+
const method = kv.method ?? httpLogScalar(queryEnvelope?.method);
|
|
1042
|
+
const url = kv.url ?? kv.path ?? httpLogScalar(queryEnvelope?.url) ?? httpLogScalar(queryEnvelope?.path);
|
|
1043
|
+
let body = kv.parameters ?? kv.payload ?? kv.data;
|
|
1044
|
+
if (!body && parsedJson) {
|
|
1045
|
+
const parameters = {};
|
|
1046
|
+
if (queryEnvelope) {
|
|
1047
|
+
const businessQuery = Object.fromEntries(Object.entries(queryEnvelope).filter(([key]) => !["method", "url", "path"].includes(key)));
|
|
1048
|
+
parameters.query = businessQuery;
|
|
1049
|
+
}
|
|
1050
|
+
else if (Object.hasOwn(parsedJson, "query")) {
|
|
1051
|
+
parameters.query = parsedJson.query;
|
|
1052
|
+
}
|
|
1053
|
+
for (const key of ["body", "json"]) {
|
|
1054
|
+
if (Object.hasOwn(parsedJson, key))
|
|
1055
|
+
parameters[key] = parsedJson[key];
|
|
1056
|
+
}
|
|
1057
|
+
if (Object.keys(parameters).length > 0)
|
|
1058
|
+
body = boundedHttpLogBody(parameters);
|
|
1059
|
+
}
|
|
1020
1060
|
return {
|
|
1021
|
-
method
|
|
1022
|
-
url
|
|
1023
|
-
body:
|
|
1061
|
+
method,
|
|
1062
|
+
url,
|
|
1063
|
+
body: boundedHttpLogBody(body ?? kv.body ?? kv.json ?? kv.query),
|
|
1024
1064
|
};
|
|
1025
1065
|
}
|
|
1026
|
-
const
|
|
1066
|
+
const nestedJson = asHttpLogRecord(parsedJson?.json);
|
|
1067
|
+
const nestedTransport = !kv.status_code && !kv.status
|
|
1068
|
+
&& Boolean(nestedJson && (Object.hasOwn(nestedJson, "status_code") || Object.hasOwn(nestedJson, "body")));
|
|
1069
|
+
const status = kv.status_code ?? kv.status
|
|
1070
|
+
?? (nestedTransport ? httpLogScalar(nestedJson?.status_code ?? nestedJson?.status) : undefined);
|
|
1071
|
+
const body = kv.result
|
|
1072
|
+
?? (nestedTransport ? boundedHttpLogBody(nestedJson?.body ?? nestedJson) : kv.json)
|
|
1073
|
+
?? kv.body
|
|
1074
|
+
?? kv.text;
|
|
1027
1075
|
return {
|
|
1028
1076
|
method: kv.method,
|
|
1029
|
-
url: kv.url,
|
|
1077
|
+
url: kv.url ?? kv.path,
|
|
1030
1078
|
status,
|
|
1031
1079
|
statusText: kv.status_text ?? (status ? HTTP_STATUS_TEXT[status] : undefined),
|
|
1032
|
-
body:
|
|
1080
|
+
body: boundedHttpLogBody(body),
|
|
1033
1081
|
};
|
|
1034
1082
|
}
|
|
1083
|
+
function asHttpLogRecord(value) {
|
|
1084
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
1085
|
+
? value
|
|
1086
|
+
: undefined;
|
|
1087
|
+
}
|
|
1088
|
+
function httpLogScalar(value) {
|
|
1089
|
+
return typeof value === "string" || typeof value === "number"
|
|
1090
|
+
? String(value)
|
|
1091
|
+
: undefined;
|
|
1092
|
+
}
|
|
1093
|
+
function boundedHttpLogBody(value) {
|
|
1094
|
+
if (value === undefined)
|
|
1095
|
+
return undefined;
|
|
1096
|
+
const text = typeof value === "string" ? value : safeStringify(value);
|
|
1097
|
+
const maxChars = 4000;
|
|
1098
|
+
return text.length <= maxChars
|
|
1099
|
+
? text
|
|
1100
|
+
: `${text.slice(0, maxChars)}\n… [truncated ${text.length - maxChars} chars]`;
|
|
1101
|
+
}
|
|
1035
1102
|
function extractKvPairs(value) {
|
|
1036
1103
|
const result = {};
|
|
1037
1104
|
// Split on top-level spaces but keep {...}/[...] payloads intact.
|
|
@@ -1242,7 +1309,9 @@ export function renderBackendTestHtml(input) {
|
|
|
1242
1309
|
const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
|
|
1243
1310
|
const catalog = new Map((input.cases ?? []).map((item) => [item.id, item]));
|
|
1244
1311
|
const caseValidation = parseAdvisorySummary(input.caseValidationSummary);
|
|
1312
|
+
const caseCoverage = parseAdvisorySummary(input.caseCoverageSummary);
|
|
1245
1313
|
const traceability = parseAdvisorySummary(input.traceabilitySummary);
|
|
1314
|
+
const correspondence = parseAdvisorySummary(input.correspondenceSummary);
|
|
1246
1315
|
const failures = input.parsed.cases.filter((result) => result.status !== "passed");
|
|
1247
1316
|
const headColor = failed ? { fg: "#b42318", bg: "linear-gradient(135deg,#fef3f2 0,#fee4e2 100%)", border: "#fda29b", icon: "✗" } : { fg: "#067647", bg: "linear-gradient(135deg,#ecfdf3 0,#d1fadf 100%)", border: "#abefc6", icon: "✓" };
|
|
1248
1317
|
const orderedCases = input.parsed.cases
|
|
@@ -1271,7 +1340,7 @@ export function renderBackendTestHtml(input) {
|
|
|
1271
1340
|
const metricCard = (label, value, valueColor = "#172033") => `<div style="padding:14px 16px;border:1px solid #e3e8ef;border-radius:12px;background:#fbfcfe"><span style="color:#667085;font-size:0.78rem">${escapeHtml(label)}</span><div style="font-size:22px;font-weight:700;color:${valueColor}">${value}</div></div>`;
|
|
1272
1341
|
const metrics = `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:12px;margin-top:14px">${metricCard("用例总数", input.parsed.tests)}${metricCard("通过", input.parsed.passed, "#067647")}${metricCard("失败", input.parsed.failed)}${metricCard("错误", input.parsed.errors)}${metricCard("跳过", input.parsed.skipped)}<div style="padding:14px 16px;border:1px solid #e3e8ef;border-radius:12px;background:#fbfcfe"><span style="color:#667085;font-size:0.78rem">通过率</span><div style="font-size:22px;font-weight:700;color:#067647">${(passRate * 100).toFixed(2)}%</div><span style="color:#667085;font-size:0.78rem">耗时 ${formatDuration(input.parsed.durationMs)}</span></div></div>`;
|
|
1273
1342
|
const summaryBanner = `<div style="display:flex;align-items:center;gap:16px;padding:16px 18px;border-radius:14px;background:${headColor.bg};border:1px solid ${headColor.border}"><div style="flex:0 0 auto;width:44px;height:44px;border-radius:999px;background:${headColor.fg};display:flex;align-items:center;justify-content:center;color:#fff;font-size:24px;font-weight:900;box-shadow:0 4px 12px ${headColor.fg}4d">${headColor.icon}</div><div><div style="color:${headColor.fg};font-size:1.05rem;font-weight:800;line-height:1.3">${failed ? "本轮测试未通过" : "本轮测试通过"}</div><div style="color:${headColor.fg};font-size:0.88rem;margin-top:2px">${failed ? `${input.parsed.failed + input.parsed.errors} 条用例失败或错误` : `${input.parsed.passed} 条用例全部执行成功`}</div></div></div>`;
|
|
1274
|
-
const qualityBlock = `<div style="margin-top:18px"><div style="color:#17365d;font-size:16px;font-weight:700;margin:0 4px 6px">质量校验</div><div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;margin-top:8px">${renderQualityCard("Markdown 用例校验", caseValidation, input.caseValidationSummary)}${renderQualityCard("Markdown → pytest 追溯", traceability, input.traceabilitySummary)}</div></div>`;
|
|
1343
|
+
const qualityBlock = `<div style="margin-top:18px"><div style="color:#17365d;font-size:16px;font-weight:700;margin:0 4px 6px">质量校验</div><div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;margin-top:8px">${renderQualityCard("Markdown 用例校验", caseValidation, input.caseValidationSummary)}${renderQualityCard("用例场景覆盖分析", caseCoverage, input.caseCoverageSummary)}${renderQualityCard("Markdown → pytest 追溯", traceability, input.traceabilitySummary)}${renderQualityCard("Markdown → pytest 一一对应", correspondence, input.correspondenceSummary)}</div></div>`;
|
|
1275
1344
|
const failureOverview = failures.length > 0
|
|
1276
1345
|
? `<div style="margin-top:18px"><div style="color:#17365d;font-size:16px;font-weight:700;margin:0 4px 6px">失败概览</div><div style="display:grid;gap:10px;margin-top:8px">${failures.map((result) => {
|
|
1277
1346
|
const caseId = resolveReportCaseId(result, catalog);
|
|
@@ -1381,6 +1450,118 @@ function dumpJacocoExec(host, port, destPath, connectTimeoutMs) {
|
|
|
1381
1450
|
socket.pipe(writeStream);
|
|
1382
1451
|
});
|
|
1383
1452
|
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Deterministically derive a Backend Test Case Manifest v1 from final
|
|
1455
|
+
* Markdown cases (testcase/md/**) and generated pytest scripts
|
|
1456
|
+
* (testcase/test_*.py). No model involvement: AC IDs come from the case
|
|
1457
|
+
* 验收标准/Acceptance Criteria section, automation status comes from whether
|
|
1458
|
+
* a pytest test function references the Case ID. The output feeds
|
|
1459
|
+
* computeCaseManifestCoverageSummary so the L-5 dashboard shows real
|
|
1460
|
+
* AC/automation coverage instead of unavailable.
|
|
1461
|
+
*/
|
|
1462
|
+
export async function deriveBackendTestCaseManifest(input) {
|
|
1463
|
+
const files = await markdownFiles(input.workspaceRoot);
|
|
1464
|
+
const manifestCases = [];
|
|
1465
|
+
for (const file of files) {
|
|
1466
|
+
if (path.basename(file).toLowerCase() === "readme.md")
|
|
1467
|
+
continue;
|
|
1468
|
+
const content = await readFile(file, "utf8");
|
|
1469
|
+
for (const testCase of splitCases(content)) {
|
|
1470
|
+
const acIds = unique(testCase.body.match(AC_ID) ?? []);
|
|
1471
|
+
if (acIds.length === 0)
|
|
1472
|
+
continue; // skip cases without AC binding
|
|
1473
|
+
const mappedScripts = extractMappedPytestScripts(testCase.body);
|
|
1474
|
+
const category = inferCaseCategory(testCase.body);
|
|
1475
|
+
let generated = false;
|
|
1476
|
+
let symbol;
|
|
1477
|
+
let scriptFile;
|
|
1478
|
+
for (const script of mappedScripts) {
|
|
1479
|
+
if (!isSafeBackendPytestScript(script, input.workspaceRoot))
|
|
1480
|
+
continue;
|
|
1481
|
+
const absolute = path.resolve(input.workspaceRoot, script);
|
|
1482
|
+
if (!(await exists(absolute)))
|
|
1483
|
+
continue;
|
|
1484
|
+
const source = await readFile(absolute, "utf8");
|
|
1485
|
+
const match = findCaseIdInPytest(source, testCase.id);
|
|
1486
|
+
if (match) {
|
|
1487
|
+
generated = true;
|
|
1488
|
+
symbol = match;
|
|
1489
|
+
scriptFile = script;
|
|
1490
|
+
break;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
manifestCases.push({
|
|
1494
|
+
caseId: testCase.id,
|
|
1495
|
+
acIds,
|
|
1496
|
+
title: inferCaseTitle(testCase.body, testCase.id),
|
|
1497
|
+
category,
|
|
1498
|
+
automationStatus: generated ? "generated" : "planned",
|
|
1499
|
+
...(scriptFile ? { file: scriptFile } : {}),
|
|
1500
|
+
...(symbol ? { symbol } : {}),
|
|
1501
|
+
...(generated ? {} : { gapReason: "pytest symbol not yet generated or not associated" }),
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
const manifest = backendTestCaseManifestSchema.parse({
|
|
1506
|
+
schemaVersion: 1,
|
|
1507
|
+
sourceBinding: {
|
|
1508
|
+
taskId: input.sourceBinding.taskId,
|
|
1509
|
+
requirementPath: input.sourceBinding.requirementPath,
|
|
1510
|
+
requirementSha256: input.sourceBinding.requirementSha256,
|
|
1511
|
+
referencePaths: input.sourceBinding.referencePaths,
|
|
1512
|
+
requirementIds: input.sourceBinding.requirementIds,
|
|
1513
|
+
},
|
|
1514
|
+
cases: manifestCases,
|
|
1515
|
+
evidenceGaps: [],
|
|
1516
|
+
coverageSummary: computeCaseManifestCoverageSummary({
|
|
1517
|
+
cases: manifestCases,
|
|
1518
|
+
evidenceGaps: [],
|
|
1519
|
+
sourceBinding: input.sourceBinding,
|
|
1520
|
+
}),
|
|
1521
|
+
});
|
|
1522
|
+
return manifest;
|
|
1523
|
+
}
|
|
1524
|
+
function inferCaseCategory(body) {
|
|
1525
|
+
const text = body.toLowerCase();
|
|
1526
|
+
if (/非法|无效|错误|不存在|越权|negative|invalid|unauthorized|forbidden|40[0-9]/.test(text))
|
|
1527
|
+
return "negative";
|
|
1528
|
+
if (/边界|boundary|edge|limit|max|min/.test(text))
|
|
1529
|
+
return "boundary";
|
|
1530
|
+
if (/鉴权|权限|登录|token|auth|permission|role/.test(text))
|
|
1531
|
+
return "auth";
|
|
1532
|
+
if (/状态|流转|state|transition|workflow/.test(text))
|
|
1533
|
+
return "state-transition";
|
|
1534
|
+
if (/超时|timeout/.test(text))
|
|
1535
|
+
return "timeout";
|
|
1536
|
+
if (/并发|concurren/.test(text))
|
|
1537
|
+
return "concurrency";
|
|
1538
|
+
return "positive";
|
|
1539
|
+
}
|
|
1540
|
+
function inferCaseTitle(body, caseId) {
|
|
1541
|
+
// ## BE-XXX-001|中文标题 → extract title after the delimiter
|
|
1542
|
+
const heading = body.split(/\r?\n/, 1)[0] ?? "";
|
|
1543
|
+
const delimiterMatch = heading.match(/[||]\s*(.+)$/);
|
|
1544
|
+
if (delimiterMatch)
|
|
1545
|
+
return delimiterMatch[1].trim();
|
|
1546
|
+
return caseId;
|
|
1547
|
+
}
|
|
1548
|
+
function findCaseIdInPytest(source, caseId) {
|
|
1549
|
+
for (const match of source.matchAll(/^(\s*)(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)) {
|
|
1550
|
+
const symbol = match[2];
|
|
1551
|
+
const region = testFunctionRegion({
|
|
1552
|
+
source,
|
|
1553
|
+
functionIndex: match.index,
|
|
1554
|
+
functionHeaderEnd: match.index + match[0].length,
|
|
1555
|
+
});
|
|
1556
|
+
const ids = new Set(caseIdsInText(region));
|
|
1557
|
+
const fromSymbol = symbolCaseId(symbol);
|
|
1558
|
+
if (fromSymbol)
|
|
1559
|
+
ids.add(fromSymbol);
|
|
1560
|
+
if (ids.has(caseId))
|
|
1561
|
+
return symbol;
|
|
1562
|
+
}
|
|
1563
|
+
return undefined;
|
|
1564
|
+
}
|
|
1384
1565
|
function inferModuleScript(classname) {
|
|
1385
1566
|
// classname like "test_process_definition_list.py::TestX::test_a" → take the file stem.
|
|
1386
1567
|
const first = classname.split("::")[0] ?? classname;
|
|
@@ -1444,7 +1625,9 @@ export function renderBackendTestL5Dashboard(input) {
|
|
|
1444
1625
|
return `<div style="padding:13px 14px;border:1px solid #e4e9f1;border-radius:11px;background:#fbfcfe"><div style="display:flex;justify-content:space-between;align-items:center;gap:8px"><span style="color:#2a3c5a;font-size:12px;font-weight:700;font-family:ui-monospace,SFMono-Regular,Menlo,monospace">${escapeHtml(stat.script)}</span><span style="display:inline-flex;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:800;color:${badge.fg};background:${badge.bg}">${badge.t}</span></div><div style="height:6px;margin:9px 0 6px;background:#edf1f6;border-radius:99px;overflow:hidden;display:flex"><i style="display:block;height:100%;width:${passPct}%;background:#15815c"></i><i style="display:block;height:100%;width:${failPct}%;background:#d34661"></i></div></div>`;
|
|
1445
1626
|
}).join("");
|
|
1446
1627
|
const caseValidation = parseAdvisorySummary(input.caseValidationSummary);
|
|
1628
|
+
const caseCoverage = parseAdvisorySummary(input.caseCoverageSummary);
|
|
1447
1629
|
const traceability = parseAdvisorySummary(input.traceabilitySummary);
|
|
1630
|
+
const correspondence = parseAdvisorySummary(input.correspondenceSummary);
|
|
1448
1631
|
const qRow = (name, status, extra = "") => {
|
|
1449
1632
|
const c = status === "PASS" ? { fg: "#15815c", bg: "#e7f7f0" } : status === "FAIL" ? { fg: "#d34661", bg: "#fff0f3" } : { fg: "#718097", bg: "#eef2f7" };
|
|
1450
1633
|
return `<div style="display:flex;align-items:center;justify-content:space-between;padding:11px 13px;border:1px solid #e4e9f1;border-radius:10px;background:#fbfcfe"><span style="color:#2a3c5a;font-size:13px;font-weight:650">${escapeHtml(name)}</span><span style="font-size:11px;font-weight:800;color:${c.fg};background:${c.bg};padding:2px 8px;border-radius:999px">${status === "Unavailable" ? "UNAVAILABLE" : status}${extra}</span></div>`;
|
|
@@ -1496,7 +1679,9 @@ export function renderBackendTestL5Dashboard(input) {
|
|
|
1496
1679
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
|
1497
1680
|
${qRow("环境硬门(Shell)", "PASS")}
|
|
1498
1681
|
${qRow("Markdown 用例校验(节点4)", caseValidation.status, caseValidation.findings !== undefined ? ` · ${caseValidation.findings}` : "")}
|
|
1682
|
+
${qRow("用例场景覆盖分析(节点4)", caseCoverage.status, caseCoverage.findings !== undefined ? ` · ${caseCoverage.findings}` : "")}
|
|
1499
1683
|
${qRow("Markdown → pytest 追溯(节点6)", traceability.status, traceability.findings !== undefined ? ` · ${traceability.findings}` : "")}
|
|
1684
|
+
${qRow("Markdown → pytest 一一对应(节点6)", correspondence.status, correspondence.findings !== undefined ? ` · ${correspondence.findings}` : "")}
|
|
1500
1685
|
${qRow("HTTP 请求/响应日志(节点6 advisory)", traceability.status)}
|
|
1501
1686
|
${qRow("Markdown 敏感信息", "PASS")}
|
|
1502
1687
|
${qRow("代码覆盖率", m.lineCoverage.status === "unavailable" && m.lineCoverage.reason !== null ? "Unavailable" : "PASS")}
|
|
@@ -1504,14 +1689,16 @@ export function renderBackendTestL5Dashboard(input) {
|
|
|
1504
1689
|
</section>
|
|
1505
1690
|
${failures ? `<section style="background:#fff;border:1px solid #e0e7f0;border-radius:15px;box-shadow:0 7px 20px rgba(25,53,92,.04);padding:22px;margin-top:20px"><div style="display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:12px"><h2 style="margin:0;color:#17365d;font-size:19px">缺陷记录</h2><span style="color:#718097;font-size:13px">${input.failures?.length ?? 0} 条 · 已关联用例与证据</span></div>${failures}</section>` : ""}
|
|
1506
1691
|
<section style="background:#fff;border:1px solid #e0e7f0;border-radius:15px;box-shadow:0 7px 20px rgba(25,53,92,.04);padding:22px;margin-top:20px">
|
|
1507
|
-
<p style="margin:0;color:#718097;font-size:12px">报告由 backend-test DAG 节点
|
|
1692
|
+
<p style="margin:0;color:#718097;font-size:12px">报告由 backend-test DAG 节点 8(execute-backend-pytest-and-html-report-shell)基于本轮真实执行产物确定性渲染。L-5 指标由 <code style="color:#17365d;font:11px ui-monospace,SFMono-Regular,Menlo,monospace">computeL5ReportMetrics</code> 机器计算。判定依据:pass=100%、AC=100%、automation≥90%、line≥80%、branch≥70%、skipped=0、无阻断级 Critical 风险。</p>
|
|
1508
1693
|
</section>
|
|
1509
1694
|
</div></body></html>`;
|
|
1510
1695
|
}
|
|
1511
1696
|
export function renderBackendTestFacts(input) {
|
|
1512
1697
|
const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
|
|
1513
1698
|
const caseValidation = parseAdvisorySummary(input.caseValidationSummary);
|
|
1699
|
+
const caseCoverage = parseAdvisorySummary(input.caseCoverageSummary);
|
|
1514
1700
|
const traceability = parseAdvisorySummary(input.traceabilitySummary);
|
|
1701
|
+
const correspondence = parseAdvisorySummary(input.correspondenceSummary);
|
|
1515
1702
|
const catalog = new Map((input.cases ?? []).map((entry) => [entry.id, entry]));
|
|
1516
1703
|
return ([
|
|
1517
1704
|
"# 后端自动化测试执行报告",
|
|
@@ -1533,7 +1720,9 @@ export function renderBackendTestFacts(input) {
|
|
|
1533
1720
|
"| 校验项 | 状态 | Findings | 首要发现 |",
|
|
1534
1721
|
"|---|---|---:|---|",
|
|
1535
1722
|
`| Markdown 用例校验 | ${caseValidation.status} | ${caseValidation.findings ?? "不可用"} | ${caseValidation.firstFinding.replaceAll("|", "\\|")} |`,
|
|
1723
|
+
`| 用例场景覆盖分析 | ${caseCoverage.status} | ${caseCoverage.findings ?? "不可用"} | ${caseCoverage.firstFinding.replaceAll("|", "\\|")} |`,
|
|
1536
1724
|
`| Markdown → pytest 追溯 | ${traceability.status} | ${traceability.findings ?? "不可用"} | ${traceability.firstFinding.replaceAll("|", "\\|")} |`,
|
|
1725
|
+
`| Markdown → pytest 一一对应 | ${correspondence.status} | ${correspondence.findings ?? "不可用"} | ${correspondence.firstFinding.replaceAll("|", "\\|")} |`,
|
|
1537
1726
|
"",
|
|
1538
1727
|
"## 失败概览",
|
|
1539
1728
|
"",
|
|
@@ -1565,12 +1754,26 @@ export function renderBackendTestFacts(input) {
|
|
|
1565
1754
|
"</details>",
|
|
1566
1755
|
"",
|
|
1567
1756
|
"<details>",
|
|
1757
|
+
"<summary>用例场景覆盖分析原文</summary>",
|
|
1758
|
+
"",
|
|
1759
|
+
input.caseCoverageSummary?.trim() ?? "Unavailable",
|
|
1760
|
+
"",
|
|
1761
|
+
"</details>",
|
|
1762
|
+
"",
|
|
1763
|
+
"<details>",
|
|
1568
1764
|
"<summary>Markdown → pytest 追溯原文</summary>",
|
|
1569
1765
|
"",
|
|
1570
1766
|
input.traceabilitySummary?.trim() ?? "Unavailable",
|
|
1571
1767
|
"",
|
|
1572
1768
|
"</details>",
|
|
1573
1769
|
"",
|
|
1770
|
+
"<details>",
|
|
1771
|
+
"<summary>Markdown → pytest 一一对应原文</summary>",
|
|
1772
|
+
"",
|
|
1773
|
+
input.correspondenceSummary?.trim() ?? "Unavailable",
|
|
1774
|
+
"",
|
|
1775
|
+
"</details>",
|
|
1776
|
+
"",
|
|
1574
1777
|
"## 覆盖率证据",
|
|
1575
1778
|
"",
|
|
1576
1779
|
"- Code coverage: unavailable unless a separate validated coverage artifact exists.",
|
|
@@ -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 === "
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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);
|