@tea-agent/loop-agent 0.26.3 → 0.26.4
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 +20 -0
- package/dist/executors/dag-pi-executor.js +26 -1
- package/dist/executors/pi-executor.js +233 -147
- package/dist/executors/pi-sdk-executor.js +140 -81
- package/dist/executors/pi-writer-tool-policy.js +266 -0
- package/dist/executors/shell-executor.js +355 -52
- package/dist/governance/document-index-closure.js +164 -0
- package/dist/worker/observe/node-input.js +8 -11
- package/dist/workflows/dag/convergence/controller.js +100 -3
- package/dist/workflows/dag/init-hybrid.js +51 -11
- package/dist/workflows/dag/repair-artifact.js +100 -4
- package/dist/workflows/dag/rerun-plan.js +14 -7
- package/dist/workflows/dag/types.js +6 -0
- package/dist/workflows/dag/workspace-checkpoint.js +42 -0
- package/docs/templates/init-managed-agents.md +3 -3
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/loop-agent/references/orchestrator-and-interventions.md +13 -6
|
@@ -2,9 +2,10 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { writeDagNodeTextArtifact } from "../infrastructure/harness/artifact-store.js";
|
|
5
|
+
import { writeDagNodeJsonArtifact, writeDagNodeTextArtifact, } from "../infrastructure/harness/artifact-store.js";
|
|
6
|
+
import { captureWorkspaceCheckpoint } from "../workflows/dag/workspace-checkpoint.js";
|
|
6
7
|
import { truncateOutput } from "../shared/output-truncation.js";
|
|
7
|
-
import { processTreeSpawnOptions, terminateProcessTree } from "./process-tree.js";
|
|
8
|
+
import { processTreeSpawnOptions, terminateProcessTree, } from "./process-tree.js";
|
|
8
9
|
import { buildRequirementCoverageGateShellCommand, expandShellPreset, buildVerdictGateShellCommand, } from "./shell-presets.js";
|
|
9
10
|
import { materializeBackendTestAnalysisContract } from "../workflows/dag/backend-test-analysis-contract.js";
|
|
10
11
|
import { extractBackendTestContractEnvelope } from "../workflows/dag/backend-test-contract-envelope.js";
|
|
@@ -22,7 +23,7 @@ import { materializeFrontendLintAssessment, materializeFrontendLintBaseline, } f
|
|
|
22
23
|
import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBackendTestTraceabilityGate, } from "../workflows/dag/backend-test-case-manifest.js";
|
|
23
24
|
import { materializeBackendTestExecutionContract } from "../workflows/dag/backend-test-execution-contract.js";
|
|
24
25
|
import { analyzeBackendTestCaseCoverage, analyzeBackendTestMarkdownPytestCorrespondence, materializeBackendTestCaseManifestFromFacts, } from "../workflows/dag/backend-test-case-coverage-analysis.js";
|
|
25
|
-
import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport } from "../workflows/dag/backend-test-result-contract.js";
|
|
26
|
+
import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport, } from "../workflows/dag/backend-test-result-contract.js";
|
|
26
27
|
import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
|
|
27
28
|
import { assessBackendPytestCollection, assertBackendPytestCollectionFresh, buildBackendPytestAssetInventory, materializeEffectiveBackendPytestCollection, readBackendPytestCollectionFacts, writeBackendPytestCollectionArtifacts, } from "../workflows/dag/backend-test-pytest-collection.js";
|
|
28
29
|
import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
|
|
@@ -33,6 +34,29 @@ import { buildShellProcessEnv } from "./shell-verification.js";
|
|
|
33
34
|
import { readRunState } from "../workflows/dag/run-store.js";
|
|
34
35
|
import { readProjectGovernanceContext } from "../workflows/dag/project-governance-context.js";
|
|
35
36
|
import { assertMavenPlanFresh, MavenPlanStaleError, } from "../verification/maven/index.js";
|
|
37
|
+
/** In-memory same-run success cache. Never shared across runIds. */
|
|
38
|
+
const sameRunShellCommandCaches = new Map();
|
|
39
|
+
function shellCommandCacheKey(runId, nodeId) {
|
|
40
|
+
return `${runId}::${nodeId}`;
|
|
41
|
+
}
|
|
42
|
+
function buildShellCommandContractKey(input) {
|
|
43
|
+
return JSON.stringify({
|
|
44
|
+
commands: input.commands,
|
|
45
|
+
cwd: input.cwd,
|
|
46
|
+
timeoutMs: input.timeoutMs,
|
|
47
|
+
envAllowlist: input.envAllowlist ?? [],
|
|
48
|
+
failFast: input.failFast,
|
|
49
|
+
nonZeroExitPolicy: input.nonZeroExitPolicy ?? "fail",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
export function clearSameRunShellCommandCachesForTests() {
|
|
53
|
+
sameRunShellCommandCaches.clear();
|
|
54
|
+
}
|
|
55
|
+
function shouldReuseSuccessfulShellCommands(shell) {
|
|
56
|
+
// AC-005 targets supervised hard verify; also allow any shell node that
|
|
57
|
+
// carries final verifyEvidence so intermediate gates stay fail-closed fresh.
|
|
58
|
+
return shell.verifyEvidence?.phase === "final";
|
|
59
|
+
}
|
|
36
60
|
const DEFAULT_SHELL_TIMEOUT_MS = 300_000;
|
|
37
61
|
const SUMMARY_STDOUT_MAX = 4_000;
|
|
38
62
|
const SUMMARY_STDERR_MAX = 2_000;
|
|
@@ -394,7 +418,13 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
394
418
|
const results = await executePipelineCommands(input, meta);
|
|
395
419
|
if (!results.every((result) => result.ok)) {
|
|
396
420
|
const failure = results.find((result) => !result.ok);
|
|
397
|
-
return {
|
|
421
|
+
return {
|
|
422
|
+
ok: false,
|
|
423
|
+
stdout: results.map((result) => result.stdout).join("\n"),
|
|
424
|
+
stderr: failure.stderr,
|
|
425
|
+
failureCategory: failure.failureCategory,
|
|
426
|
+
durationMs: Date.now() - started,
|
|
427
|
+
};
|
|
398
428
|
}
|
|
399
429
|
const environment = await inspectBackendTestEnvironment({
|
|
400
430
|
workspaceRoot: input.cwd,
|
|
@@ -449,13 +479,20 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
449
479
|
}
|
|
450
480
|
const normalizedBinding = {
|
|
451
481
|
taskId: sourceBinding.taskId,
|
|
452
|
-
requirementPath: sourceBinding.sources.find((s) => s.kind === "requirement")?.path ??
|
|
453
|
-
|
|
454
|
-
|
|
482
|
+
requirementPath: sourceBinding.sources.find((s) => s.kind === "requirement")?.path ??
|
|
483
|
+
"source/requirement.md",
|
|
484
|
+
requirementSha256: sourceBinding.sources.find((s) => s.kind === "requirement")?.sha256 ??
|
|
485
|
+
"0".repeat(64),
|
|
486
|
+
referencePaths: sourceBinding.sources
|
|
487
|
+
.filter((s) => s.kind === "reference")
|
|
488
|
+
.map((s) => s.path),
|
|
455
489
|
requirementIds: sourceBinding.requirementIds,
|
|
456
490
|
};
|
|
457
491
|
try {
|
|
458
|
-
const coverage = await analyzeBackendTestCaseCoverage({
|
|
492
|
+
const coverage = await analyzeBackendTestCaseCoverage({
|
|
493
|
+
workspaceRoot: input.cwd,
|
|
494
|
+
sourceBinding: normalizedBinding,
|
|
495
|
+
});
|
|
459
496
|
const coveragePath = await writeRunReport(meta.runDir, "backend-test-case-coverage-analysis.md", coverage.markdown);
|
|
460
497
|
const contractsDir = path.join(meta.runDir, "contracts");
|
|
461
498
|
await mkdir(contractsDir, { recursive: true });
|
|
@@ -544,7 +581,8 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
544
581
|
return {
|
|
545
582
|
ok: false,
|
|
546
583
|
stdout: result.stdout,
|
|
547
|
-
stderr: result.stderr ||
|
|
584
|
+
stderr: result.stderr ||
|
|
585
|
+
"backend pytest final collection could not start",
|
|
548
586
|
failureCategory: result.failureCategory,
|
|
549
587
|
durationMs: Date.now() - started,
|
|
550
588
|
};
|
|
@@ -609,7 +647,10 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
609
647
|
};
|
|
610
648
|
}
|
|
611
649
|
try {
|
|
612
|
-
const correspondence = await analyzeBackendTestMarkdownPytestCorrespondence({
|
|
650
|
+
const correspondence = await analyzeBackendTestMarkdownPytestCorrespondence({
|
|
651
|
+
workspaceRoot: input.cwd,
|
|
652
|
+
taskId: sourceBinding.taskId,
|
|
653
|
+
});
|
|
613
654
|
const correspondencePath = await writeRunReport(meta.runDir, "backend-test-markdown-pytest-correspondence.md", correspondence.markdown);
|
|
614
655
|
const contractsDir = path.join(meta.runDir, "contracts");
|
|
615
656
|
await mkdir(contractsDir, { recursive: true });
|
|
@@ -640,9 +681,13 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
640
681
|
workspaceRoot: input.cwd,
|
|
641
682
|
sourceBinding: {
|
|
642
683
|
taskId: sourceBinding.taskId,
|
|
643
|
-
requirementPath: sourceBinding.sources.find((s) => s.kind === "requirement")?.path ??
|
|
644
|
-
|
|
645
|
-
|
|
684
|
+
requirementPath: sourceBinding.sources.find((s) => s.kind === "requirement")?.path ??
|
|
685
|
+
"source/requirement.md",
|
|
686
|
+
requirementSha256: sourceBinding.sources.find((s) => s.kind === "requirement")
|
|
687
|
+
?.sha256 ?? "0".repeat(64),
|
|
688
|
+
referencePaths: sourceBinding.sources
|
|
689
|
+
.filter((s) => s.kind === "reference")
|
|
690
|
+
.map((s) => s.path),
|
|
646
691
|
requirementIds: sourceBinding.requirementIds,
|
|
647
692
|
},
|
|
648
693
|
});
|
|
@@ -754,7 +799,9 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
754
799
|
if (!results.every((result) => result.ok)) {
|
|
755
800
|
const failureIndex = results.findIndex((result) => !result.ok);
|
|
756
801
|
const failure = results[failureIndex];
|
|
757
|
-
const combinedStdout = results
|
|
802
|
+
const combinedStdout = results
|
|
803
|
+
.map((result) => result.stdout)
|
|
804
|
+
.join("\n");
|
|
758
805
|
let diagnosticsText = "";
|
|
759
806
|
try {
|
|
760
807
|
diagnosticsText = await readFile(path.join(meta.runDir, "reports", "backend-test-execute-diagnostics.md"), "utf8");
|
|
@@ -887,7 +934,12 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
887
934
|
}
|
|
888
935
|
}
|
|
889
936
|
const l5Metrics = computeL5ReportMetrics({
|
|
890
|
-
result: {
|
|
937
|
+
result: {
|
|
938
|
+
passed: parsed.passed,
|
|
939
|
+
failed: parsed.failed,
|
|
940
|
+
error: parsed.errors,
|
|
941
|
+
skipped: parsed.skipped,
|
|
942
|
+
},
|
|
891
943
|
manifest: manifestForL5,
|
|
892
944
|
coverage: coverageContract,
|
|
893
945
|
criticalRiskCount: parsed.failed + parsed.errors > 0 ? 1 : 0,
|
|
@@ -896,7 +948,12 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
896
948
|
.filter((c) => c.status !== "passed")
|
|
897
949
|
.map((c) => {
|
|
898
950
|
const id = c.name.match(/BE-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}/)?.[0] ?? c.name;
|
|
899
|
-
return {
|
|
951
|
+
return {
|
|
952
|
+
caseId: id,
|
|
953
|
+
title: c.name,
|
|
954
|
+
message: c.message ?? c.status,
|
|
955
|
+
script: `${c.classname.split("::")[0] ?? c.classname}`,
|
|
956
|
+
};
|
|
900
957
|
});
|
|
901
958
|
const l5Html = renderBackendTestL5Dashboard({
|
|
902
959
|
title: meta.spec.title,
|
|
@@ -969,7 +1026,7 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
969
1026
|
if (!parsed ||
|
|
970
1027
|
typeof parsed !== "object" ||
|
|
971
1028
|
Array.isArray(parsed) ||
|
|
972
|
-
!["verdict", "findings", "summary"].every((key) => Object.
|
|
1029
|
+
!["verdict", "findings", "summary"].every((key) => Object.hasOwn(parsed, key))) {
|
|
973
1030
|
return {
|
|
974
1031
|
ok: false,
|
|
975
1032
|
stdout: "",
|
|
@@ -1021,13 +1078,25 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
1021
1078
|
});
|
|
1022
1079
|
outputs.push(`semantic=${review.path}`, formatTraceabilityGateStdout(trace));
|
|
1023
1080
|
const parsed = JSON.parse(await readFile(review.path, "utf8"));
|
|
1024
|
-
return {
|
|
1081
|
+
return {
|
|
1082
|
+
ok: true,
|
|
1083
|
+
stdout: JSON.stringify(parsed),
|
|
1084
|
+
stderr: "",
|
|
1085
|
+
failureCategory: "success",
|
|
1086
|
+
durationMs: Date.now() - started,
|
|
1087
|
+
};
|
|
1025
1088
|
}
|
|
1026
1089
|
else if (pipeline === "execute-parse-initial") {
|
|
1027
1090
|
const results = await executePipelineCommands(input, meta);
|
|
1028
1091
|
if (!results.every((result) => result.ok)) {
|
|
1029
1092
|
const failure = results.find((result) => !result.ok);
|
|
1030
|
-
return {
|
|
1093
|
+
return {
|
|
1094
|
+
ok: false,
|
|
1095
|
+
stdout: results.map((result) => result.stdout).join("\n"),
|
|
1096
|
+
stderr: failure.stderr,
|
|
1097
|
+
failureCategory: failure.failureCategory,
|
|
1098
|
+
durationMs: Date.now() - started,
|
|
1099
|
+
};
|
|
1031
1100
|
}
|
|
1032
1101
|
const artifact = await materializeBackendTestResultFromRunDir({
|
|
1033
1102
|
runDir: meta.runDir,
|
|
@@ -1045,9 +1114,12 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
1045
1114
|
artifactName: "backend-test-classification.json",
|
|
1046
1115
|
outputDir: "contracts",
|
|
1047
1116
|
});
|
|
1048
|
-
const selected = await executePipelineCommands(input, meta, [
|
|
1117
|
+
const selected = await executePipelineCommands(input, meta, [
|
|
1118
|
+
buildBackendTestCanonicalResultFromInitialShellSnippet(),
|
|
1119
|
+
]);
|
|
1049
1120
|
if (!selected.every((result) => result.ok))
|
|
1050
|
-
throw new Error(selected.find((result) => !result.ok)?.stderr ||
|
|
1121
|
+
throw new Error(selected.find((result) => !result.ok)?.stderr ||
|
|
1122
|
+
"canonical result materialization failed");
|
|
1051
1123
|
const readNodeOutput = async (nodeId) => {
|
|
1052
1124
|
const record = JSON.parse(await readFile(path.join(meta.runDir, `${nodeId}.json`), "utf8"));
|
|
1053
1125
|
const text = record.assistantText?.trim() || record.stdout?.trim() || "";
|
|
@@ -1056,12 +1128,17 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
1056
1128
|
return text;
|
|
1057
1129
|
};
|
|
1058
1130
|
const caseReview = await readNodeOutput("review-backend-cases-pi");
|
|
1059
|
-
const reviewVerdicts = caseReview
|
|
1060
|
-
|
|
1131
|
+
const reviewVerdicts = caseReview
|
|
1132
|
+
.split(/\r?\n/)
|
|
1133
|
+
.map((line) => line.trim())
|
|
1134
|
+
.filter((line) => line.startsWith("VERDICT:"));
|
|
1135
|
+
if (reviewVerdicts.length !== 1 ||
|
|
1136
|
+
!["VERDICT: pass", "VERDICT: request-revision"].includes(reviewVerdicts[0])) {
|
|
1061
1137
|
throw new Error("invalid advisory case review evidence: expected exactly one auditable VERDICT line");
|
|
1062
1138
|
}
|
|
1063
1139
|
const traceability = await readNodeOutput("backend-test-traceability-gate-shell");
|
|
1064
|
-
if (!traceability.includes("Traceability gate: pass") ||
|
|
1140
|
+
if (!traceability.includes("Traceability gate: pass") ||
|
|
1141
|
+
!/Matched symbols:\s*\d+/.test(traceability)) {
|
|
1065
1142
|
throw new Error("invalid traceability evidence: deterministic pass summary is missing");
|
|
1066
1143
|
}
|
|
1067
1144
|
const contracts = path.join(meta.runDir, "contracts");
|
|
@@ -1070,7 +1147,10 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
1070
1147
|
result: JSON.parse(await readFile(path.join(contracts, "backend-test-result.json"), "utf8")),
|
|
1071
1148
|
manifest: JSON.parse(await readFile(path.join(contracts, "backend-test-case-manifest.json"), "utf8")),
|
|
1072
1149
|
classification: JSON.parse(await readFile(path.join(contracts, "backend-test-classification.json"), "utf8")),
|
|
1073
|
-
caseReview: {
|
|
1150
|
+
caseReview: {
|
|
1151
|
+
verdict: reviewVerdicts[0].slice("VERDICT: ".length),
|
|
1152
|
+
evidence: caseReview,
|
|
1153
|
+
},
|
|
1074
1154
|
traceability: { status: "pass", evidence: traceability },
|
|
1075
1155
|
};
|
|
1076
1156
|
outputs.push(`classification=${classification.path}`, ...selected.map((result) => result.stdout), JSON.stringify(context, null, 2));
|
|
@@ -1078,10 +1158,22 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
1078
1158
|
else {
|
|
1079
1159
|
throw new Error(`unsupported backend-test pipeline: ${pipeline}`);
|
|
1080
1160
|
}
|
|
1081
|
-
return {
|
|
1161
|
+
return {
|
|
1162
|
+
ok: true,
|
|
1163
|
+
stdout: outputs.filter(Boolean).join("\n"),
|
|
1164
|
+
stderr: "",
|
|
1165
|
+
failureCategory: "success",
|
|
1166
|
+
durationMs: Date.now() - started,
|
|
1167
|
+
};
|
|
1082
1168
|
}
|
|
1083
1169
|
catch (error) {
|
|
1084
|
-
return {
|
|
1170
|
+
return {
|
|
1171
|
+
ok: false,
|
|
1172
|
+
stdout: "",
|
|
1173
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
1174
|
+
failureCategory: "invalid-output",
|
|
1175
|
+
durationMs: Date.now() - started,
|
|
1176
|
+
};
|
|
1085
1177
|
}
|
|
1086
1178
|
}
|
|
1087
1179
|
async function executeBackendTestPipelineWithWriteGuard(input, meta) {
|
|
@@ -1192,9 +1284,21 @@ async function executeFrontendVerificationBundle(input, meta) {
|
|
|
1192
1284
|
const lintBlocked = lintAssessment?.status === "failed" ||
|
|
1193
1285
|
lintAssessment?.status === "unavailable";
|
|
1194
1286
|
const groups = [
|
|
1195
|
-
{
|
|
1196
|
-
|
|
1197
|
-
|
|
1287
|
+
{
|
|
1288
|
+
name: "mock",
|
|
1289
|
+
commands: bundle.mockCommands,
|
|
1290
|
+
labels: bundle.mockEvidence?.commandLabels ?? [],
|
|
1291
|
+
},
|
|
1292
|
+
{
|
|
1293
|
+
name: "static",
|
|
1294
|
+
commands: bundle.staticCommands,
|
|
1295
|
+
labels: bundle.staticEvidence.commandLabels,
|
|
1296
|
+
},
|
|
1297
|
+
{
|
|
1298
|
+
name: "behavior",
|
|
1299
|
+
commands: bundle.behaviorCommands,
|
|
1300
|
+
labels: bundle.behaviorEvidence.commandLabels,
|
|
1301
|
+
},
|
|
1198
1302
|
];
|
|
1199
1303
|
const successfulLabels = new Map();
|
|
1200
1304
|
for (const group of lintBlocked ? [] : groups) {
|
|
@@ -1316,9 +1420,7 @@ async function executeFrontendVerificationBundle(input, meta) {
|
|
|
1316
1420
|
const failureFacts = [];
|
|
1317
1421
|
if (firstFailure || lintSyntheticFailure) {
|
|
1318
1422
|
const failureStdout = firstFailure
|
|
1319
|
-
? [firstFailure.command, firstFailure.stdout]
|
|
1320
|
-
.filter(Boolean)
|
|
1321
|
-
.join("\n")
|
|
1423
|
+
? [firstFailure.command, firstFailure.stdout].filter(Boolean).join("\n")
|
|
1322
1424
|
: "";
|
|
1323
1425
|
failureFacts.push({
|
|
1324
1426
|
nodeId: input.task.id,
|
|
@@ -1374,40 +1476,93 @@ async function executeFrontendTestCaseChecklist(input, meta) {
|
|
|
1374
1476
|
const started = Date.now();
|
|
1375
1477
|
try {
|
|
1376
1478
|
const declaredAcIds = (meta.spec.sourceBinding?.requirementIds ?? []).filter((id) => /^AC(?:-[A-Z0-9]+)+$/i.test(id));
|
|
1377
|
-
const result = await validateFrontendCaseChecklist({
|
|
1479
|
+
const result = await validateFrontendCaseChecklist({
|
|
1480
|
+
workspaceRoot: input.cwd,
|
|
1481
|
+
declaredAcIds,
|
|
1482
|
+
});
|
|
1378
1483
|
if (result.issues.length) {
|
|
1379
|
-
return {
|
|
1484
|
+
return {
|
|
1485
|
+
ok: false,
|
|
1486
|
+
stdout: "",
|
|
1487
|
+
stderr: `frontend-test checklist blocked: ${JSON.stringify(result.issues)}`,
|
|
1488
|
+
failureCategory: "invalid-output",
|
|
1489
|
+
durationMs: Date.now() - started,
|
|
1490
|
+
};
|
|
1380
1491
|
}
|
|
1381
|
-
return {
|
|
1492
|
+
return {
|
|
1493
|
+
ok: true,
|
|
1494
|
+
stdout: `frontend-test checklist ok cases=${result.caseCount} source=${path.basename(result.manifestPath)}`,
|
|
1495
|
+
stderr: "",
|
|
1496
|
+
failureCategory: "success",
|
|
1497
|
+
durationMs: Date.now() - started,
|
|
1498
|
+
};
|
|
1382
1499
|
}
|
|
1383
1500
|
catch (error) {
|
|
1384
|
-
return {
|
|
1501
|
+
return {
|
|
1502
|
+
ok: false,
|
|
1503
|
+
stdout: "",
|
|
1504
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
1505
|
+
failureCategory: "invalid-output",
|
|
1506
|
+
durationMs: Date.now() - started,
|
|
1507
|
+
};
|
|
1385
1508
|
}
|
|
1386
1509
|
}
|
|
1387
1510
|
async function executeFrontendTestL5Report(input, meta) {
|
|
1388
1511
|
const started = Date.now();
|
|
1389
1512
|
try {
|
|
1390
|
-
const output = await renderFrontendTestL5Report({
|
|
1391
|
-
|
|
1513
|
+
const output = await renderFrontendTestL5Report({
|
|
1514
|
+
workspaceRoot: input.cwd,
|
|
1515
|
+
runDir: meta.runDir,
|
|
1516
|
+
});
|
|
1517
|
+
return {
|
|
1518
|
+
ok: true,
|
|
1519
|
+
stdout: `Frontend L-5 report: ${output.htmlPath}\nMarkdown: ${output.markdownPath}\nStatus: ${output.metrics.status}`,
|
|
1520
|
+
stderr: "",
|
|
1521
|
+
failureCategory: "success",
|
|
1522
|
+
durationMs: Date.now() - started,
|
|
1523
|
+
};
|
|
1392
1524
|
}
|
|
1393
1525
|
catch (error) {
|
|
1394
|
-
return {
|
|
1526
|
+
return {
|
|
1527
|
+
ok: false,
|
|
1528
|
+
stdout: "",
|
|
1529
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
1530
|
+
failureCategory: "invalid-output",
|
|
1531
|
+
durationMs: Date.now() - started,
|
|
1532
|
+
};
|
|
1395
1533
|
}
|
|
1396
1534
|
}
|
|
1397
1535
|
async function executeFrontendTestHtmlReport(input, meta) {
|
|
1398
1536
|
const started = Date.now();
|
|
1399
1537
|
try {
|
|
1400
|
-
const output = await renderFrontendTestHtmlReport({
|
|
1401
|
-
|
|
1538
|
+
const output = await renderFrontendTestHtmlReport({
|
|
1539
|
+
workspaceRoot: input.cwd,
|
|
1540
|
+
runDir: meta.runDir,
|
|
1541
|
+
});
|
|
1542
|
+
return {
|
|
1543
|
+
ok: true,
|
|
1544
|
+
stdout: `Frontend test report: ${output.htmlPath}\nMarkdown: ${output.markdownPath}\nOutcome: ${output.outcome}\nCases: ${output.caseCount}`,
|
|
1545
|
+
stderr: "",
|
|
1546
|
+
failureCategory: "success",
|
|
1547
|
+
durationMs: Date.now() - started,
|
|
1548
|
+
};
|
|
1402
1549
|
}
|
|
1403
1550
|
catch (error) {
|
|
1404
|
-
return {
|
|
1551
|
+
return {
|
|
1552
|
+
ok: false,
|
|
1553
|
+
stdout: "",
|
|
1554
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
1555
|
+
failureCategory: "invalid-output",
|
|
1556
|
+
durationMs: Date.now() - started,
|
|
1557
|
+
};
|
|
1405
1558
|
}
|
|
1406
1559
|
}
|
|
1407
1560
|
async function executeFrontendTestEvidenceValidation(input) {
|
|
1408
1561
|
const started = Date.now();
|
|
1409
1562
|
try {
|
|
1410
|
-
const result = await validateFrontendCaseEvidence({
|
|
1563
|
+
const result = await validateFrontendCaseEvidence({
|
|
1564
|
+
workspaceRoot: input.cwd,
|
|
1565
|
+
});
|
|
1411
1566
|
const output = `frontend case evidence validation cases=${result.cases} findings=${result.issues.length}${result.issues.length ? ` issues=${JSON.stringify(result.issues)}` : ""}`;
|
|
1412
1567
|
if (result.hardFail) {
|
|
1413
1568
|
return {
|
|
@@ -1510,10 +1665,22 @@ export async function executeDagShellNode(input, meta) {
|
|
|
1510
1665
|
workspaceRoot: input.cwd,
|
|
1511
1666
|
repoRoot: input.cwd,
|
|
1512
1667
|
});
|
|
1513
|
-
return {
|
|
1668
|
+
return {
|
|
1669
|
+
ok: true,
|
|
1670
|
+
stdout: formatFrontendPrewriteGateStdout(result),
|
|
1671
|
+
stderr: "",
|
|
1672
|
+
failureCategory: "success",
|
|
1673
|
+
durationMs: Date.now() - started,
|
|
1674
|
+
};
|
|
1514
1675
|
}
|
|
1515
1676
|
catch (error) {
|
|
1516
|
-
return {
|
|
1677
|
+
return {
|
|
1678
|
+
ok: false,
|
|
1679
|
+
stdout: "",
|
|
1680
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
1681
|
+
failureCategory: "invalid-output",
|
|
1682
|
+
durationMs: Date.now() - started,
|
|
1683
|
+
};
|
|
1517
1684
|
}
|
|
1518
1685
|
}
|
|
1519
1686
|
if (shell?.frontendLintBaseline) {
|
|
@@ -1530,10 +1697,22 @@ export async function executeDagShellNode(input, meta) {
|
|
|
1530
1697
|
workspaceRoot: input.cwd,
|
|
1531
1698
|
requireBaseline: shell.frontendReviewContext.requireBaseline,
|
|
1532
1699
|
});
|
|
1533
|
-
return {
|
|
1700
|
+
return {
|
|
1701
|
+
ok: true,
|
|
1702
|
+
stdout: formatFrontendReviewContextStdout(result),
|
|
1703
|
+
stderr: "",
|
|
1704
|
+
failureCategory: "success",
|
|
1705
|
+
durationMs: Date.now() - started,
|
|
1706
|
+
};
|
|
1534
1707
|
}
|
|
1535
1708
|
catch (error) {
|
|
1536
|
-
return {
|
|
1709
|
+
return {
|
|
1710
|
+
ok: false,
|
|
1711
|
+
stdout: "",
|
|
1712
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
1713
|
+
failureCategory: "invalid-output",
|
|
1714
|
+
durationMs: Date.now() - started,
|
|
1715
|
+
};
|
|
1537
1716
|
}
|
|
1538
1717
|
}
|
|
1539
1718
|
if (shell?.frontendTestCaseChecklist) {
|
|
@@ -1873,8 +2052,10 @@ export async function executeDagShellNode(input, meta) {
|
|
|
1873
2052
|
}
|
|
1874
2053
|
const cwd = resolveShellCwd(input.cwd, shell.cwd);
|
|
1875
2054
|
const timeoutMs = shell.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS;
|
|
2055
|
+
const failFast = shell.failFast !== false;
|
|
1876
2056
|
const results = [];
|
|
1877
2057
|
let beforeStatus;
|
|
2058
|
+
let workspaceFingerprint;
|
|
1878
2059
|
try {
|
|
1879
2060
|
beforeStatus = await readGitStatusPorcelain(input.cwd);
|
|
1880
2061
|
}
|
|
@@ -1882,8 +2063,78 @@ export async function executeDagShellNode(input, meta) {
|
|
|
1882
2063
|
// Non-git cwd: skip write guard (documented limitation).
|
|
1883
2064
|
beforeStatus = undefined;
|
|
1884
2065
|
}
|
|
2066
|
+
const reuseEligible = shouldReuseSuccessfulShellCommands(shell);
|
|
2067
|
+
let commandContractKey;
|
|
2068
|
+
let cache;
|
|
2069
|
+
if (reuseEligible) {
|
|
2070
|
+
try {
|
|
2071
|
+
const governanceTaskId = meta.spec.sourceBinding?.taskId?.trim();
|
|
2072
|
+
const checkpoint = await captureWorkspaceCheckpoint(input.cwd, {
|
|
2073
|
+
...(governanceTaskId ? { taskId: governanceTaskId } : {}),
|
|
2074
|
+
});
|
|
2075
|
+
workspaceFingerprint = checkpoint.fingerprint;
|
|
2076
|
+
}
|
|
2077
|
+
catch {
|
|
2078
|
+
// Fingerprint unavailable: do not create cache / do not reuse.
|
|
2079
|
+
workspaceFingerprint = undefined;
|
|
2080
|
+
}
|
|
2081
|
+
commandContractKey = buildShellCommandContractKey({
|
|
2082
|
+
commands,
|
|
2083
|
+
cwd,
|
|
2084
|
+
timeoutMs,
|
|
2085
|
+
envAllowlist: shell.envAllowlist,
|
|
2086
|
+
failFast,
|
|
2087
|
+
nonZeroExitPolicy: shell.nonZeroExitPolicy,
|
|
2088
|
+
});
|
|
2089
|
+
const cacheKey = shellCommandCacheKey(meta.runId, input.task.id);
|
|
2090
|
+
const existing = sameRunShellCommandCaches.get(cacheKey);
|
|
2091
|
+
if (existing &&
|
|
2092
|
+
existing.runId === meta.runId &&
|
|
2093
|
+
existing.nodeId === input.task.id &&
|
|
2094
|
+
existing.commandContractKey === commandContractKey &&
|
|
2095
|
+
workspaceFingerprint &&
|
|
2096
|
+
existing.workspaceFingerprint === workspaceFingerprint) {
|
|
2097
|
+
cache = existing;
|
|
2098
|
+
}
|
|
2099
|
+
else {
|
|
2100
|
+
sameRunShellCommandCaches.delete(cacheKey);
|
|
2101
|
+
if (workspaceFingerprint) {
|
|
2102
|
+
cache = {
|
|
2103
|
+
runId: meta.runId,
|
|
2104
|
+
nodeId: input.task.id,
|
|
2105
|
+
commandContractKey,
|
|
2106
|
+
workspaceFingerprint,
|
|
2107
|
+
byCommand: new Map(),
|
|
2108
|
+
};
|
|
2109
|
+
sameRunShellCommandCaches.set(cacheKey, cache);
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
1885
2113
|
for (const command of commands) {
|
|
1886
2114
|
const commandNumber = results.length + 1;
|
|
2115
|
+
// Cache each contractual occurrence independently. A result written for an
|
|
2116
|
+
// earlier duplicate in this round must not satisfy a later occurrence.
|
|
2117
|
+
const commandCacheKey = `${commandNumber}:${command}`;
|
|
2118
|
+
const cached = cache?.byCommand.get(commandCacheKey);
|
|
2119
|
+
if (cached) {
|
|
2120
|
+
results.push({
|
|
2121
|
+
command,
|
|
2122
|
+
cwd: cached.cwd,
|
|
2123
|
+
durationMs: cached.durationMs,
|
|
2124
|
+
exitCode: cached.exitCode,
|
|
2125
|
+
failureCategory: cached.failureCategory,
|
|
2126
|
+
ok: true,
|
|
2127
|
+
stderr: cached.stderr,
|
|
2128
|
+
stderrBytes: cached.stderrBytes,
|
|
2129
|
+
stderrTruncated: cached.stderrTruncated,
|
|
2130
|
+
stdout: cached.stdout,
|
|
2131
|
+
stdoutBytes: cached.stdoutBytes,
|
|
2132
|
+
stdoutTruncated: cached.stdoutTruncated,
|
|
2133
|
+
timedOut: cached.timedOut,
|
|
2134
|
+
reused: true,
|
|
2135
|
+
});
|
|
2136
|
+
continue;
|
|
2137
|
+
}
|
|
1887
2138
|
const result = await executeShellCommand({
|
|
1888
2139
|
command,
|
|
1889
2140
|
cwd,
|
|
@@ -1896,7 +2147,23 @@ export async function executeDagShellNode(input, meta) {
|
|
|
1896
2147
|
},
|
|
1897
2148
|
});
|
|
1898
2149
|
results.push(result);
|
|
1899
|
-
if (
|
|
2150
|
+
if (result.ok && cache) {
|
|
2151
|
+
cache.byCommand.set(commandCacheKey, {
|
|
2152
|
+
ok: true,
|
|
2153
|
+
exitCode: result.exitCode,
|
|
2154
|
+
failureCategory: "success",
|
|
2155
|
+
stdout: result.stdout,
|
|
2156
|
+
stderr: result.stderr,
|
|
2157
|
+
stdoutBytes: result.stdoutBytes,
|
|
2158
|
+
stderrBytes: result.stderrBytes,
|
|
2159
|
+
stdoutTruncated: result.stdoutTruncated,
|
|
2160
|
+
stderrTruncated: result.stderrTruncated,
|
|
2161
|
+
timedOut: result.timedOut,
|
|
2162
|
+
durationMs: result.durationMs,
|
|
2163
|
+
cwd: result.cwd,
|
|
2164
|
+
});
|
|
2165
|
+
}
|
|
2166
|
+
if (!result.ok && failFast)
|
|
1900
2167
|
break;
|
|
1901
2168
|
}
|
|
1902
2169
|
let writeGuardViolations = [];
|
|
@@ -1918,17 +2185,49 @@ export async function executeDagShellNode(input, meta) {
|
|
|
1918
2185
|
];
|
|
1919
2186
|
}
|
|
1920
2187
|
}
|
|
2188
|
+
// Write-guard or workspace mutation invalidates same-run success reuse.
|
|
2189
|
+
if (!writeGuardOk && reuseEligible) {
|
|
2190
|
+
sameRunShellCommandCaches.delete(shellCommandCacheKey(meta.runId, input.task.id));
|
|
2191
|
+
}
|
|
1921
2192
|
const commandsOk = results.every((result) => result.ok);
|
|
1922
2193
|
const recordNonZero = shell.nonZeroExitPolicy === "record";
|
|
1923
2194
|
const ok = (commandsOk || recordNonZero) && writeGuardOk;
|
|
1924
2195
|
const firstFailure = results.find((result) => !result.ok);
|
|
1925
|
-
|
|
2196
|
+
const receipt = {
|
|
2197
|
+
schemaVersion: 1,
|
|
2198
|
+
runId: meta.runId,
|
|
2199
|
+
nodeId: input.task.id,
|
|
2200
|
+
workspaceFingerprint: workspaceFingerprint ?? null,
|
|
2201
|
+
failFast,
|
|
2202
|
+
commands: results.map((result) => ({
|
|
2203
|
+
command: result.command,
|
|
2204
|
+
ok: result.ok,
|
|
2205
|
+
exitCode: result.exitCode,
|
|
2206
|
+
failureCategory: result.failureCategory,
|
|
2207
|
+
reused: result.reused === true,
|
|
2208
|
+
})),
|
|
2209
|
+
};
|
|
2210
|
+
await writeDagNodeJsonArtifact(meta.runDir, input.task.id, "shell-command-receipt.json", receipt);
|
|
2211
|
+
const summaryMarkdown = buildShellResultSummaryMarkdown({
|
|
1926
2212
|
nodeId: input.task.id,
|
|
1927
2213
|
runId: meta.runId,
|
|
1928
2214
|
rootCwd: input.cwd,
|
|
1929
2215
|
results,
|
|
1930
2216
|
writeGuardViolations: writeGuardOk ? undefined : writeGuardViolations,
|
|
1931
|
-
})
|
|
2217
|
+
});
|
|
2218
|
+
const receiptMarkdown = [
|
|
2219
|
+
summaryMarkdown,
|
|
2220
|
+
"",
|
|
2221
|
+
"## Same-run reuse receipt",
|
|
2222
|
+
"",
|
|
2223
|
+
`| workspaceFingerprint | ${workspaceFingerprint ?? "(unavailable)"} |`,
|
|
2224
|
+
`| failFast | ${failFast} |`,
|
|
2225
|
+
"",
|
|
2226
|
+
"| # | ok | exitCode | reused | command |",
|
|
2227
|
+
"|---|----|----------|--------|---------|",
|
|
2228
|
+
...results.map((result, index) => `| ${index + 1} | ${result.ok} | ${result.exitCode ?? "null"} | ${result.reused === true} | ${result.command.replace(/\|/g, "\\|")} |`),
|
|
2229
|
+
].join("\n");
|
|
2230
|
+
await writeDagNodeTextArtifact(meta.runDir, input.task.id, "result.summary.md", receiptMarkdown);
|
|
1932
2231
|
const aggregate = summarizeCommandResults(results);
|
|
1933
2232
|
const stderrParts = [aggregate.stderr];
|
|
1934
2233
|
if (!writeGuardOk) {
|
|
@@ -1946,13 +2245,17 @@ export async function executeDagShellNode(input, meta) {
|
|
|
1946
2245
|
exitCode: result.exitCode,
|
|
1947
2246
|
failureCategory: result.failureCategory,
|
|
1948
2247
|
command: result.command,
|
|
2248
|
+
reused: result.reused === true,
|
|
1949
2249
|
}));
|
|
1950
2250
|
const recordPrefix = recordNonZero && !commandsOk
|
|
1951
2251
|
? `nonZeroExitPolicy=record exitCode=${firstFailure?.exitCode ?? "null"}\n`
|
|
1952
2252
|
: "";
|
|
2253
|
+
const reuseNote = results.some((result) => result.reused)
|
|
2254
|
+
? `same-run-reuse workspaceFingerprint=${workspaceFingerprint ?? "unknown"}\n`
|
|
2255
|
+
: "";
|
|
1953
2256
|
return {
|
|
1954
2257
|
ok,
|
|
1955
|
-
stdout: `${recordPrefix}${aggregate.stdout}`,
|
|
2258
|
+
stdout: `${recordPrefix}${reuseNote}${aggregate.stdout}`,
|
|
1956
2259
|
stderr: stderrParts.filter(Boolean).join("\n\n"),
|
|
1957
2260
|
failureCategory,
|
|
1958
2261
|
durationMs: Date.now() - started,
|