@tea-agent/loop-agent 0.25.3 → 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 +55 -0
- package/dist/application/dag/args.js +21 -1
- package/dist/application/dag/run-dag.js +1 -0
- package/dist/cli/command-definitions.js +1 -1
- package/dist/cli/program.js +82 -63
- package/dist/commands/client-recovery.js +209 -62
- package/dist/commands/init.js +206 -82
- package/dist/commands/run-dag-progress.js +109 -0
- package/dist/commands/run-dag.js +16 -5
- package/dist/executors/dag-pi-executor.js +80 -15
- package/dist/executors/model-routing.js +1 -1
- package/dist/executors/shell-executor.js +159 -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 +306 -30
- package/dist/workflows/dag/backend-test-result-contract.js +35 -9
- package/dist/workflows/dag/convergence/controller.js +134 -9
- package/dist/workflows/dag/frontend-test-case-checklist.js +71 -0
- package/dist/workflows/dag/frontend-test-html-report.js +77 -0
- package/dist/workflows/dag/frontend-test-l5-report.js +138 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +44 -1
- package/dist/workflows/dag/init-hybrid.js +267 -80
- 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 +10 -3
- package/dist/workflows/dag/validate.js +6 -3
- 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.schema.json +6 -0
- package/docs/templates/agent-dag.supervised-implementation.json +1 -1
- package/docs/templates/backend-test-dag.json +40 -13
- package/docs/templates/backend-test-dag.review-cases.prompt.md +1 -1
- package/docs/templates/frontend-test-dag.json +62 -5
- 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 +3 -2
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +2 -1
- package/skills/loop-agent/references/hybrid-dag.md +2 -2
- package/skills/loop-agent/references/model-routing.md +1 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export const DEFAULT_RUN_DAG_PROGRESS_INTERVAL_MS = 30_000;
|
|
2
|
+
function formatDuration(durationMs) {
|
|
3
|
+
const totalSeconds = Math.max(0, Math.floor(durationMs / 1_000));
|
|
4
|
+
const hours = Math.floor(totalSeconds / 3_600);
|
|
5
|
+
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
|
6
|
+
const seconds = totalSeconds % 60;
|
|
7
|
+
if (hours > 0)
|
|
8
|
+
return `${hours}h${String(minutes).padStart(2, "0")}m`;
|
|
9
|
+
if (minutes > 0)
|
|
10
|
+
return `${minutes}m${String(seconds).padStart(2, "0")}s`;
|
|
11
|
+
return `${seconds}s`;
|
|
12
|
+
}
|
|
13
|
+
function countStatuses(state) {
|
|
14
|
+
const counts = { finished: 0, running: 0, pending: 0, error: 0, skipped: 0 };
|
|
15
|
+
for (const node of Object.values(state.nodes)) {
|
|
16
|
+
if (node.status === "FINISHED")
|
|
17
|
+
counts.finished += 1;
|
|
18
|
+
else if (node.status === "RUNNING")
|
|
19
|
+
counts.running += 1;
|
|
20
|
+
else if (node.status === "ERROR")
|
|
21
|
+
counts.error += 1;
|
|
22
|
+
else if (node.status === "SKIPPED")
|
|
23
|
+
counts.skipped += 1;
|
|
24
|
+
else
|
|
25
|
+
counts.pending += 1;
|
|
26
|
+
}
|
|
27
|
+
return `finished=${counts.finished} running=${counts.running} pending=${counts.pending} error=${counts.error} skipped=${counts.skipped}`;
|
|
28
|
+
}
|
|
29
|
+
function nodeElapsedMs(node, now) {
|
|
30
|
+
const startedAt = node.startedAt ? Date.parse(node.startedAt) : Number.NaN;
|
|
31
|
+
return Number.isFinite(startedAt) ? Math.max(0, now - startedAt) : 0;
|
|
32
|
+
}
|
|
33
|
+
function activeNodeSummary(state, now) {
|
|
34
|
+
const running = Object.values(state.nodes).filter((node) => node.status === "RUNNING");
|
|
35
|
+
if (running.length === 0)
|
|
36
|
+
return "nodes=none";
|
|
37
|
+
return running
|
|
38
|
+
.map((node) => {
|
|
39
|
+
const details = [
|
|
40
|
+
`${node.id}(${formatDuration(nodeElapsedMs(node, now))}`,
|
|
41
|
+
node.livenessStatus ? `liveness=${node.livenessStatus}` : undefined,
|
|
42
|
+
node.currentAttempt ? `attempt=${node.currentAttempt}` : undefined,
|
|
43
|
+
]
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.join(" ");
|
|
46
|
+
return `${details})`;
|
|
47
|
+
})
|
|
48
|
+
.join(",");
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Human-readable CLI progress written to stderr. The final run-dag JSON remains
|
|
52
|
+
* the only stdout payload, so machine callers can continue parsing stdout.
|
|
53
|
+
*/
|
|
54
|
+
export function createRunDagProgressObserver(options = {}) {
|
|
55
|
+
const intervalMs = options.intervalMs ?? DEFAULT_RUN_DAG_PROGRESS_INTERVAL_MS;
|
|
56
|
+
const write = options.write ?? ((text) => process.stderr.write(text));
|
|
57
|
+
const now = options.now ?? Date.now;
|
|
58
|
+
let latestState;
|
|
59
|
+
let timer;
|
|
60
|
+
const emit = (message) => {
|
|
61
|
+
try {
|
|
62
|
+
write(`[run-dag] ${message}\n`);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Progress is a derived CLI view and must never decide DAG execution.
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const stop = () => {
|
|
69
|
+
if (timer)
|
|
70
|
+
clearInterval(timer);
|
|
71
|
+
timer = undefined;
|
|
72
|
+
};
|
|
73
|
+
const start = () => {
|
|
74
|
+
if (timer || intervalMs <= 0)
|
|
75
|
+
return;
|
|
76
|
+
timer = setInterval(() => {
|
|
77
|
+
if (!latestState)
|
|
78
|
+
return;
|
|
79
|
+
emit(`heartbeat run=${latestState.runId} ${countStatuses(latestState)} ${activeNodeSummary(latestState, now())}`);
|
|
80
|
+
}, intervalMs);
|
|
81
|
+
timer.unref?.();
|
|
82
|
+
};
|
|
83
|
+
const observer = {
|
|
84
|
+
onRunStart: (state) => {
|
|
85
|
+
latestState = state;
|
|
86
|
+
emit(`started run=${state.runId} nodes=${Object.keys(state.nodes).length} title=${JSON.stringify(state.title)}`);
|
|
87
|
+
start();
|
|
88
|
+
},
|
|
89
|
+
onNodeStart: (nodeId, state) => {
|
|
90
|
+
latestState = state;
|
|
91
|
+
const node = state.nodes[nodeId];
|
|
92
|
+
emit(`node-started run=${state.runId} node=${nodeId} executor=${node?.executor ?? "unknown"} ${countStatuses(state)}`);
|
|
93
|
+
},
|
|
94
|
+
onNodeOutput: (_nodeId, _chunk, state) => {
|
|
95
|
+
latestState = state;
|
|
96
|
+
},
|
|
97
|
+
onNodeFinish: (nodeId, state) => {
|
|
98
|
+
latestState = state;
|
|
99
|
+
const node = state.nodes[nodeId];
|
|
100
|
+
emit(`node-finished run=${state.runId} node=${nodeId} status=${node?.status ?? "unknown"} duration=${formatDuration(node?.durationMs ?? 0)} ${countStatuses(state)}`);
|
|
101
|
+
},
|
|
102
|
+
onRunFinish: (state) => {
|
|
103
|
+
latestState = state;
|
|
104
|
+
stop();
|
|
105
|
+
emit(`finished run=${state.runId} status=${state.status} ${countStatuses(state)}`);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
return { observer, dispose: stop };
|
|
109
|
+
}
|
package/dist/commands/run-dag.js
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { parseRunDagArgs } from "../application/dag/args.js";
|
|
2
2
|
import { runDagUseCase } from "../application/dag/run-dag.js";
|
|
3
|
+
import { createRunDagProgressObserver } from "./run-dag-progress.js";
|
|
3
4
|
export { parseRunDagArgs };
|
|
4
5
|
export async function runRunDag(repoRoot, rawArgs) {
|
|
5
6
|
const parsed = parseRunDagArgs(rawArgs, repoRoot);
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
const { quiet, progressIntervalMs, ...runInput } = parsed;
|
|
8
|
+
const progress = quiet || parsed.dryRun || parsed.initOnly
|
|
9
|
+
? undefined
|
|
10
|
+
: createRunDagProgressObserver({ intervalMs: progressIntervalMs });
|
|
11
|
+
try {
|
|
12
|
+
const result = await runDagUseCase({
|
|
13
|
+
repoRoot,
|
|
14
|
+
...runInput,
|
|
15
|
+
observer: progress?.observer,
|
|
16
|
+
});
|
|
17
|
+
console.log(JSON.stringify(result, null, 2));
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
progress?.dispose();
|
|
21
|
+
}
|
|
11
22
|
}
|
|
@@ -4,6 +4,7 @@ import { writeDagNodeJsonArtifact, writeTextArtifactFile, } from "../infrastruct
|
|
|
4
4
|
import { executePiStep, } from "./pi-executor.js";
|
|
5
5
|
import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
|
|
6
6
|
import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
|
|
7
|
+
import { redactSecrets, truncateUtf8Preview } from "../shared/preview.js";
|
|
7
8
|
export const DAG_PI_READONLY_TOOLS = ["read", "grep", "find", "ls"];
|
|
8
9
|
export const DAG_PI_WRITE_TOOLS = [
|
|
9
10
|
"read",
|
|
@@ -15,6 +16,7 @@ export const DAG_PI_WRITE_TOOLS = [
|
|
|
15
16
|
"ls",
|
|
16
17
|
];
|
|
17
18
|
export const DEFAULT_DAG_PI_PROVIDER = "wizard-local";
|
|
19
|
+
const WRITER_OUTCOME_PROTOCOL_LINE = "IMPLEMENTATION_OUTCOME:";
|
|
18
20
|
export const DAG_PI_MODEL_PROVIDERS = {
|
|
19
21
|
"gpt-5.3-codex-spark": "wizard-local",
|
|
20
22
|
"gpt-5.5": "wizard-local",
|
|
@@ -266,9 +268,14 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
|
|
|
266
268
|
beforeStatus = await readGitStatusPorcelain(input.cwd);
|
|
267
269
|
beforePathFingerprints = await snapshotGitStatusPathFingerprints(input.cwd, snapshotGitStatusPorcelain(beforeStatus));
|
|
268
270
|
}
|
|
269
|
-
catch {
|
|
270
|
-
|
|
271
|
-
|
|
271
|
+
catch (error) {
|
|
272
|
+
return {
|
|
273
|
+
ok: false,
|
|
274
|
+
stdout: "",
|
|
275
|
+
stderr: `writer Git baseline unavailable before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
|
|
276
|
+
failureCategory: "write-guard",
|
|
277
|
+
durationMs: Date.now() - started,
|
|
278
|
+
};
|
|
272
279
|
}
|
|
273
280
|
}
|
|
274
281
|
const reportActivity = input.reportActivity;
|
|
@@ -319,7 +326,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
|
|
|
319
326
|
persona,
|
|
320
327
|
step,
|
|
321
328
|
});
|
|
322
|
-
const mapped = mapPiResultToDagNodeResult(result, input.task.
|
|
329
|
+
const mapped = mapPiResultToDagNodeResult(result, input.task.writerOutcomePolicy
|
|
330
|
+
? WRITER_OUTCOME_PROTOCOL_LINE
|
|
331
|
+
: input.task.firstProtocolLine);
|
|
323
332
|
if (!isWriteTask) {
|
|
324
333
|
return mapped;
|
|
325
334
|
}
|
|
@@ -399,38 +408,94 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
|
|
|
399
408
|
};
|
|
400
409
|
}
|
|
401
410
|
export function validateWriterImplementationOutcome(text, changedFiles) {
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
.find(Boolean);
|
|
406
|
-
const match = firstLine?.match(/^IMPLEMENTATION_OUTCOME: (changed|already-satisfied|blocked)$/);
|
|
407
|
-
if (!match) {
|
|
411
|
+
const parsed = parseWriterImplementationOutcome(text);
|
|
412
|
+
const diagnostics = writerOutcomeDiagnostics(text, parsed, changedFiles.length);
|
|
413
|
+
if (parsed.kind !== "valid") {
|
|
408
414
|
return {
|
|
409
415
|
ok: false,
|
|
410
|
-
reason:
|
|
416
|
+
reason: `writer outcome validation failed: ${parsed.kind} outcome; ${diagnostics}; expected IMPLEMENTATION_OUTCOME: changed, IMPLEMENTATION_OUTCOME: already-satisfied, or IMPLEMENTATION_OUTCOME: blocked`,
|
|
411
417
|
};
|
|
412
418
|
}
|
|
413
|
-
const outcome =
|
|
419
|
+
const outcome = parsed.outcome;
|
|
414
420
|
if (outcome === "blocked") {
|
|
415
421
|
return {
|
|
416
422
|
ok: false,
|
|
417
|
-
reason:
|
|
423
|
+
reason: `writer outcome validation failed: IMPLEMENTATION_OUTCOME: blocked cannot complete successfully; ${diagnostics}`,
|
|
418
424
|
};
|
|
419
425
|
}
|
|
420
426
|
if (outcome === "changed" && changedFiles.length === 0) {
|
|
421
427
|
return {
|
|
422
428
|
ok: false,
|
|
423
|
-
reason:
|
|
429
|
+
reason: `writer outcome validation failed: changed outcome has an empty diff; ${diagnostics}`,
|
|
424
430
|
};
|
|
425
431
|
}
|
|
426
432
|
if (outcome === "already-satisfied" && changedFiles.length > 0) {
|
|
427
433
|
return {
|
|
428
434
|
ok: false,
|
|
429
|
-
reason:
|
|
435
|
+
reason: `writer outcome validation failed: already-satisfied outcome has a non-empty diff; ${diagnostics}`,
|
|
430
436
|
};
|
|
431
437
|
}
|
|
432
438
|
return { ok: true, outcome };
|
|
433
439
|
}
|
|
440
|
+
function parseWriterImplementationOutcome(text) {
|
|
441
|
+
const lines = text.split(/\r?\n/);
|
|
442
|
+
const candidates = [];
|
|
443
|
+
for (const [lineIndex, line] of lines.entries()) {
|
|
444
|
+
const nextLine = lines[lineIndex + 1];
|
|
445
|
+
const normalized = normalizeProtocolLine(line, WRITER_OUTCOME_PROTOCOL_LINE, nextLine);
|
|
446
|
+
if (normalized === undefined)
|
|
447
|
+
continue;
|
|
448
|
+
const value = normalized
|
|
449
|
+
.slice(WRITER_OUTCOME_PROTOCOL_LINE.length)
|
|
450
|
+
.trim();
|
|
451
|
+
const outcome = isWriterImplementationOutcome(value)
|
|
452
|
+
? value
|
|
453
|
+
: undefined;
|
|
454
|
+
candidates.push({
|
|
455
|
+
lineIndex,
|
|
456
|
+
value,
|
|
457
|
+
outcome,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
if (candidates.length === 0) {
|
|
461
|
+
return { kind: "missing", candidates };
|
|
462
|
+
}
|
|
463
|
+
if (candidates.some((candidate) => candidate.outcome === undefined)) {
|
|
464
|
+
return { kind: "unknown", candidates };
|
|
465
|
+
}
|
|
466
|
+
const outcomes = new Set(candidates.map((candidate) => candidate.outcome));
|
|
467
|
+
if (outcomes.size !== 1) {
|
|
468
|
+
return { kind: "conflicting", candidates };
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
kind: "valid",
|
|
472
|
+
outcome: candidates[0].outcome,
|
|
473
|
+
candidates,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
function isWriterImplementationOutcome(value) {
|
|
477
|
+
return (value === "changed" ||
|
|
478
|
+
value === "already-satisfied" ||
|
|
479
|
+
value === "blocked");
|
|
480
|
+
}
|
|
481
|
+
function writerOutcomeDiagnostics(text, parsed, changedFiles) {
|
|
482
|
+
const firstNonEmpty = text
|
|
483
|
+
.split(/\r?\n/)
|
|
484
|
+
.map((line) => line.trim())
|
|
485
|
+
.find(Boolean) ?? "(empty)";
|
|
486
|
+
const candidateSummary = parsed.candidates.length === 0
|
|
487
|
+
? "none"
|
|
488
|
+
: parsed.candidates
|
|
489
|
+
.slice(0, 4)
|
|
490
|
+
.map((candidate) => candidate.outcome
|
|
491
|
+
? `${candidate.outcome}@line${candidate.lineIndex + 1}`
|
|
492
|
+
: `unknown(${boundedWriterDiagnostic(candidate.value)})@line${candidate.lineIndex + 1}`)
|
|
493
|
+
.join(",");
|
|
494
|
+
return `firstNonEmpty=${boundedWriterDiagnostic(firstNonEmpty)}; candidates=${candidateSummary}; changedFiles=${changedFiles}`;
|
|
495
|
+
}
|
|
496
|
+
function boundedWriterDiagnostic(value) {
|
|
497
|
+
return JSON.stringify(truncateUtf8Preview(redactSecrets(value), 160));
|
|
498
|
+
}
|
|
434
499
|
export function mapPiResultToDagNodeResult(result, firstProtocolLine) {
|
|
435
500
|
const assistantText = canonicalizeProtocolFirstLine(result.assistantText, firstProtocolLine);
|
|
436
501
|
return {
|
|
@@ -10,6 +10,9 @@ import { materializeBackendTestAnalysisContract } from "../workflows/dag/backend
|
|
|
10
10
|
import { extractBackendTestContractEnvelope } from "../workflows/dag/backend-test-contract-envelope.js";
|
|
11
11
|
import { materializeFrontendImplementationContract } from "../workflows/dag/frontend-implementation-contract.js";
|
|
12
12
|
import { materializeFrontendTestResult, validateFrontendCaseEvidence, } from "../workflows/dag/frontend-test-result-contract.js";
|
|
13
|
+
import { renderFrontendTestL5Report } from "../workflows/dag/frontend-test-l5-report.js";
|
|
14
|
+
import { validateFrontendCaseChecklist } from "../workflows/dag/frontend-test-case-checklist.js";
|
|
15
|
+
import { renderFrontendTestHtmlReport } from "../workflows/dag/frontend-test-html-report.js";
|
|
13
16
|
import { formatFrontendVerificationTraceStdout, runFrontendVerificationTraceGate, } from "../workflows/dag/frontend-verification-trace.js";
|
|
14
17
|
import { formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "../workflows/dag/frontend-worktree-diff.js";
|
|
15
18
|
import { formatFrontendFailureAssessStdout, formatFrontendRepairContractStdout, runFrontendFailureAssessGate, runFrontendRepairContractGate, } from "../workflows/dag/frontend-repair.js";
|
|
@@ -18,6 +21,7 @@ import { formatFrontendReviewContextStdout, runFrontendReviewContextGate, } from
|
|
|
18
21
|
import { materializeFrontendLintAssessment, materializeFrontendLintBaseline, } from "../workflows/dag/frontend-lint-baseline.js";
|
|
19
22
|
import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBackendTestTraceabilityGate, } from "../workflows/dag/backend-test-case-manifest.js";
|
|
20
23
|
import { materializeBackendTestExecutionContract } from "../workflows/dag/backend-test-execution-contract.js";
|
|
24
|
+
import { analyzeBackendTestCaseCoverage, analyzeBackendTestMarkdownPytestCorrespondence, materializeBackendTestCaseManifestFromFacts, } from "../workflows/dag/backend-test-case-coverage-analysis.js";
|
|
21
25
|
import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport } from "../workflows/dag/backend-test-result-contract.js";
|
|
22
26
|
import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
|
|
23
27
|
import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
|
|
@@ -370,6 +374,14 @@ async function readRequiredRunReport(reportsDir, filename) {
|
|
|
370
374
|
throw new Error(`missing required upstream report: reports/${filename}`);
|
|
371
375
|
}
|
|
372
376
|
}
|
|
377
|
+
async function readAdvisoryRunReport(reportsDir, filename, title) {
|
|
378
|
+
try {
|
|
379
|
+
return await readFile(path.join(reportsDir, filename), "utf8");
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
return `# ${title}\n\n## Status\n\nUNAVAILABLE\n\n## Findings\n\n- Missing advisory upstream report: reports/${filename}\n`;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
373
385
|
async function executeBackendTestPipeline(input, meta) {
|
|
374
386
|
const pipeline = input.task.shell?.backendTestPipeline;
|
|
375
387
|
const started = Date.now();
|
|
@@ -421,6 +433,48 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
421
433
|
durationMs: Date.now() - started,
|
|
422
434
|
};
|
|
423
435
|
}
|
|
436
|
+
const sourceBinding = meta.spec.sourceBinding;
|
|
437
|
+
if (!sourceBinding) {
|
|
438
|
+
const coverage = "# Backend Test Case Coverage Analysis\n\n## Status\n\nUNAVAILABLE\n\n## Findings\n\n- Coverage analysis requires spec.sourceBinding; node 4 and node 7 fail closed on the same task/source binding contract.\n";
|
|
439
|
+
const coveragePath = await writeRunReport(meta.runDir, "backend-test-case-coverage-analysis.md", coverage);
|
|
440
|
+
outputs.push(`caseCoverage=${coveragePath}`, coverage);
|
|
441
|
+
return {
|
|
442
|
+
ok: false,
|
|
443
|
+
stdout: outputs.join("\n\n"),
|
|
444
|
+
stderr: "backend-test markdown-cases requires spec.sourceBinding",
|
|
445
|
+
failureCategory: "invalid-output",
|
|
446
|
+
durationMs: Date.now() - started,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
const normalizedBinding = {
|
|
450
|
+
taskId: sourceBinding.taskId,
|
|
451
|
+
requirementPath: sourceBinding.sources.find((s) => s.kind === "requirement")?.path ?? "source/requirement.md",
|
|
452
|
+
requirementSha256: sourceBinding.sources.find((s) => s.kind === "requirement")?.sha256 ?? "0".repeat(64),
|
|
453
|
+
referencePaths: sourceBinding.sources.filter((s) => s.kind === "reference").map((s) => s.path),
|
|
454
|
+
requirementIds: sourceBinding.requirementIds,
|
|
455
|
+
};
|
|
456
|
+
try {
|
|
457
|
+
const coverage = await analyzeBackendTestCaseCoverage({ workspaceRoot: input.cwd, sourceBinding: normalizedBinding });
|
|
458
|
+
const coveragePath = await writeRunReport(meta.runDir, "backend-test-case-coverage-analysis.md", coverage.markdown);
|
|
459
|
+
const contractsDir = path.join(meta.runDir, "contracts");
|
|
460
|
+
await mkdir(contractsDir, { recursive: true });
|
|
461
|
+
const factsPath = path.join(contractsDir, "backend-test-case-coverage-facts.json");
|
|
462
|
+
await writeFile(factsPath, JSON.stringify(coverage.facts, null, 2), "utf8");
|
|
463
|
+
outputs.push(`caseCoverage=${coveragePath}`, `caseCoverageFacts=${factsPath}`, coverage.markdown);
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
467
|
+
const coverage = `# Backend Test Case Coverage Analysis\n\n## Status\n\nUNAVAILABLE\n\n## Findings\n\n- Coverage analysis crashed and is treated as an infrastructure failure: ${message}\n`;
|
|
468
|
+
const coveragePath = await writeRunReport(meta.runDir, "backend-test-case-coverage-analysis.md", coverage);
|
|
469
|
+
outputs.push(`caseCoverage=${coveragePath}`, coverage);
|
|
470
|
+
return {
|
|
471
|
+
ok: false,
|
|
472
|
+
stdout: outputs.join("\n\n"),
|
|
473
|
+
stderr: `backend-test coverage analysis crashed: ${message}`,
|
|
474
|
+
failureCategory: "invalid-output",
|
|
475
|
+
durationMs: Date.now() - started,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
424
478
|
}
|
|
425
479
|
else if (pipeline === "markdown-traceability") {
|
|
426
480
|
let report;
|
|
@@ -432,6 +486,60 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
432
486
|
}
|
|
433
487
|
const reportPath = await writeRunReport(meta.runDir, "backend-test-traceability.md", report);
|
|
434
488
|
outputs.push(`traceability=${reportPath}`, report);
|
|
489
|
+
const sourceBinding = meta.spec.sourceBinding;
|
|
490
|
+
if (!sourceBinding) {
|
|
491
|
+
const correspondence = "# Backend Test Markdown → pytest Correspondence\n\n## Status\n\nUNAVAILABLE\n\n## Findings\n\n- Correspondence analysis requires spec.sourceBinding; nodes 4, 6 and 7 fail closed on the same task/source binding contract.\n";
|
|
492
|
+
const correspondencePath = await writeRunReport(meta.runDir, "backend-test-markdown-pytest-correspondence.md", correspondence);
|
|
493
|
+
outputs.push(`correspondence=${correspondencePath}`, correspondence);
|
|
494
|
+
return {
|
|
495
|
+
ok: false,
|
|
496
|
+
stdout: outputs.join("\n\n"),
|
|
497
|
+
stderr: "backend-test markdown-traceability requires spec.sourceBinding",
|
|
498
|
+
failureCategory: "invalid-output",
|
|
499
|
+
durationMs: Date.now() - started,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
const correspondence = await analyzeBackendTestMarkdownPytestCorrespondence({ workspaceRoot: input.cwd, taskId: sourceBinding.taskId });
|
|
504
|
+
const correspondencePath = await writeRunReport(meta.runDir, "backend-test-markdown-pytest-correspondence.md", correspondence.markdown);
|
|
505
|
+
const contractsDir = path.join(meta.runDir, "contracts");
|
|
506
|
+
await mkdir(contractsDir, { recursive: true });
|
|
507
|
+
const factsPath = path.join(contractsDir, "backend-test-markdown-pytest-correspondence-facts.json");
|
|
508
|
+
await writeFile(factsPath, JSON.stringify(correspondence.facts, null, 2), "utf8");
|
|
509
|
+
outputs.push(`correspondence=${correspondencePath}`, `correspondenceFacts=${factsPath}`, correspondence.markdown);
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
513
|
+
const correspondence = `# Backend Test Markdown → pytest Correspondence\n\n## Status\n\nUNAVAILABLE\n\n## Findings\n\n- Correspondence analysis crashed and is treated as an infrastructure failure: ${message}\n`;
|
|
514
|
+
const correspondencePath = await writeRunReport(meta.runDir, "backend-test-markdown-pytest-correspondence.md", correspondence);
|
|
515
|
+
outputs.push(`correspondence=${correspondencePath}`, correspondence);
|
|
516
|
+
return {
|
|
517
|
+
ok: false,
|
|
518
|
+
stdout: outputs.join("\n\n"),
|
|
519
|
+
stderr: `backend-test correspondence analysis crashed: ${message}`,
|
|
520
|
+
failureCategory: "invalid-output",
|
|
521
|
+
durationMs: Date.now() - started,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
else if (pipeline === "markdown-manifest") {
|
|
526
|
+
const sourceBinding = meta.spec.sourceBinding;
|
|
527
|
+
if (!sourceBinding)
|
|
528
|
+
throw new Error("backend-test markdown-manifest requires spec.sourceBinding");
|
|
529
|
+
const manifest = await materializeBackendTestCaseManifestFromFacts({
|
|
530
|
+
runDir: meta.runDir,
|
|
531
|
+
workspaceRoot: input.cwd,
|
|
532
|
+
sourceBinding: {
|
|
533
|
+
taskId: sourceBinding.taskId,
|
|
534
|
+
requirementPath: sourceBinding.sources.find((s) => s.kind === "requirement")?.path ?? "source/requirement.md",
|
|
535
|
+
requirementSha256: sourceBinding.sources.find((s) => s.kind === "requirement")?.sha256 ?? "0".repeat(64),
|
|
536
|
+
referencePaths: sourceBinding.sources.filter((s) => s.kind === "reference").map((s) => s.path),
|
|
537
|
+
requirementIds: sourceBinding.requirementIds,
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
const manifestPath = path.join(meta.runDir, "contracts", "backend-test-case-manifest.json");
|
|
541
|
+
const summary = manifest.coverageSummary;
|
|
542
|
+
outputs.push(`manifest=${manifestPath}`, `materializationStatus=${manifest.materializationStatus ?? "available"}`, `coverageSummary.explicitAcCount=${summary?.explicitAcCount ?? "unavailable"}`, `coverageSummary.coveredAcCount=${summary?.coveredAcCount ?? "unavailable"}`, `coverageSummary.caseCount=${summary?.caseCount ?? "unavailable"}`, `coverageSummary.generatedCount=${summary?.generatedCount ?? "unavailable"}`, `ruleCoverageSummary.ruleCount=${manifest.ruleCoverageSummary?.ruleCount ?? "unavailable"}`, `correspondenceSummary.exactCorrespondenceCount=${manifest.correspondenceSummary?.exactCorrespondenceCount ?? "unavailable"}`);
|
|
435
543
|
}
|
|
436
544
|
else if (pipeline === "markdown-execute-html") {
|
|
437
545
|
const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
|
|
@@ -599,14 +707,18 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
599
707
|
});
|
|
600
708
|
const cases = await collectBackendTestHumanCaseCatalog(input.cwd);
|
|
601
709
|
const caseValidationSummary = await readRequiredRunReport(reportsDir, "backend-md-case-validation.md");
|
|
710
|
+
const caseCoverageSummary = await readAdvisoryRunReport(reportsDir, "backend-test-case-coverage-analysis.md", "Backend Test Case Coverage Analysis");
|
|
602
711
|
const traceabilitySummary = await readRequiredRunReport(reportsDir, "backend-test-traceability.md");
|
|
712
|
+
const correspondenceSummary = await readAdvisoryRunReport(reportsDir, "backend-test-markdown-pytest-correspondence.md", "Backend Test Markdown → pytest Correspondence");
|
|
603
713
|
const htmlContent = renderBackendTestHtml({
|
|
604
714
|
title: meta.spec.title,
|
|
605
715
|
parsed,
|
|
606
716
|
cases,
|
|
607
717
|
environmentSummary: await readFile(path.join(reportsDir, "backend-test-environment.md"), "utf8"),
|
|
608
718
|
caseValidationSummary,
|
|
719
|
+
caseCoverageSummary,
|
|
609
720
|
traceabilitySummary,
|
|
721
|
+
correspondenceSummary,
|
|
610
722
|
});
|
|
611
723
|
const htmlPath = await writeRunReport(meta.runDir, "backend-test.html", htmlContent);
|
|
612
724
|
const facts = renderBackendTestFacts({
|
|
@@ -616,7 +728,9 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
616
728
|
htmlRelativePath: "reports/backend-test.html",
|
|
617
729
|
htmlContent,
|
|
618
730
|
caseValidationSummary,
|
|
731
|
+
caseCoverageSummary,
|
|
619
732
|
traceabilitySummary,
|
|
733
|
+
correspondenceSummary,
|
|
620
734
|
});
|
|
621
735
|
const markdownPath = await writeRunReport(meta.runDir, "backend-test.md", facts);
|
|
622
736
|
const factsPath = await writeRunReport(meta.runDir, "backend-test-facts.md", facts);
|
|
@@ -679,7 +793,9 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
679
793
|
parsed,
|
|
680
794
|
metrics: l5Metrics,
|
|
681
795
|
caseValidationSummary,
|
|
796
|
+
caseCoverageSummary,
|
|
682
797
|
traceabilitySummary,
|
|
798
|
+
correspondenceSummary,
|
|
683
799
|
failures: failureSummaries.length > 0 ? failureSummaries : undefined,
|
|
684
800
|
});
|
|
685
801
|
const l5Path = await writeRunReport(meta.runDir, "backend-test-l5-dashboard.html", l5Html);
|
|
@@ -1143,6 +1259,40 @@ async function executeFrontendVerificationBundle(input, meta) {
|
|
|
1143
1259
|
};
|
|
1144
1260
|
}
|
|
1145
1261
|
}
|
|
1262
|
+
async function executeFrontendTestCaseChecklist(input, meta) {
|
|
1263
|
+
const started = Date.now();
|
|
1264
|
+
try {
|
|
1265
|
+
const declaredAcIds = (meta.spec.sourceBinding?.requirementIds ?? []).filter((id) => /^AC(?:-[A-Z0-9]+)+$/i.test(id));
|
|
1266
|
+
const result = await validateFrontendCaseChecklist({ workspaceRoot: input.cwd, declaredAcIds });
|
|
1267
|
+
if (result.issues.length) {
|
|
1268
|
+
return { ok: false, stdout: "", stderr: `frontend-test checklist blocked: ${JSON.stringify(result.issues)}`, failureCategory: "invalid-output", durationMs: Date.now() - started };
|
|
1269
|
+
}
|
|
1270
|
+
return { ok: true, stdout: `frontend-test checklist ok cases=${result.caseCount} source=${path.basename(result.manifestPath)}`, stderr: "", failureCategory: "success", durationMs: Date.now() - started };
|
|
1271
|
+
}
|
|
1272
|
+
catch (error) {
|
|
1273
|
+
return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
async function executeFrontendTestL5Report(input, meta) {
|
|
1277
|
+
const started = Date.now();
|
|
1278
|
+
try {
|
|
1279
|
+
const output = await renderFrontendTestL5Report({ workspaceRoot: input.cwd, runDir: meta.runDir });
|
|
1280
|
+
return { ok: true, stdout: `Frontend L-5 report: ${output.htmlPath}\nMarkdown: ${output.markdownPath}\nStatus: ${output.metrics.status}`, stderr: "", failureCategory: "success", durationMs: Date.now() - started };
|
|
1281
|
+
}
|
|
1282
|
+
catch (error) {
|
|
1283
|
+
return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
async function executeFrontendTestHtmlReport(input, meta) {
|
|
1287
|
+
const started = Date.now();
|
|
1288
|
+
try {
|
|
1289
|
+
const output = await renderFrontendTestHtmlReport({ workspaceRoot: input.cwd, runDir: meta.runDir });
|
|
1290
|
+
return { ok: true, stdout: `Frontend test report: ${output.htmlPath}\nMarkdown: ${output.markdownPath}\nOutcome: ${output.outcome}\nCases: ${output.caseCount}`, stderr: "", failureCategory: "success", durationMs: Date.now() - started };
|
|
1291
|
+
}
|
|
1292
|
+
catch (error) {
|
|
1293
|
+
return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1146
1296
|
async function executeFrontendTestEvidenceValidation(input) {
|
|
1147
1297
|
const started = Date.now();
|
|
1148
1298
|
try {
|
|
@@ -1275,9 +1425,18 @@ export async function executeDagShellNode(input, meta) {
|
|
|
1275
1425
|
return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
|
|
1276
1426
|
}
|
|
1277
1427
|
}
|
|
1428
|
+
if (shell?.frontendTestCaseChecklist) {
|
|
1429
|
+
return executeFrontendTestCaseChecklist(input, meta);
|
|
1430
|
+
}
|
|
1278
1431
|
if (shell?.frontendTestEvidenceValidation) {
|
|
1279
1432
|
return executeFrontendTestEvidenceValidation(input);
|
|
1280
1433
|
}
|
|
1434
|
+
if (shell?.frontendTestL5Report) {
|
|
1435
|
+
return executeFrontendTestL5Report(input, meta);
|
|
1436
|
+
}
|
|
1437
|
+
if (shell?.frontendTestHtmlReport) {
|
|
1438
|
+
return executeFrontendTestHtmlReport(input, meta);
|
|
1439
|
+
}
|
|
1281
1440
|
if (shell?.backendTestPipeline) {
|
|
1282
1441
|
return executeBackendTestPipelineWithWriteGuard(input, meta);
|
|
1283
1442
|
}
|
|
@@ -165,8 +165,8 @@ export function validateShellWriteGuard(input) {
|
|
|
165
165
|
return { ok: violations.length === 0, violations };
|
|
166
166
|
}
|
|
167
167
|
export async function readGitStatusPorcelain(cwd, options = {}) {
|
|
168
|
-
const attempts = Math.max(1, options.attempts ??
|
|
169
|
-
const retryDelayMs = Math.max(0, options.retryDelayMs ??
|
|
168
|
+
const attempts = Math.max(1, options.attempts ?? 5);
|
|
169
|
+
const retryDelayMs = Math.max(0, options.retryDelayMs ?? 150);
|
|
170
170
|
let lastError;
|
|
171
171
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
172
172
|
try {
|
|
@@ -179,14 +179,24 @@ export async function readGitStatusPorcelain(cwd, options = {}) {
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
182
|
+
const detail = lastError instanceof Error ? lastError.message : String(lastError);
|
|
183
|
+
const exitCode = lastError instanceof Error &&
|
|
184
|
+
"exitCode" in lastError &&
|
|
185
|
+
typeof lastError.exitCode === "number"
|
|
186
|
+
? String(lastError.exitCode)
|
|
187
|
+
: "unavailable";
|
|
188
|
+
const signal = lastError instanceof Error &&
|
|
189
|
+
"signal" in lastError &&
|
|
190
|
+
typeof lastError.signal === "string"
|
|
191
|
+
? lastError.signal
|
|
192
|
+
: "unavailable";
|
|
193
|
+
throw new Error(`git status failed after ${attempts} attempts (exit code=${exitCode}, signal=${signal}, cwd=${path.resolve(cwd)}): ${detail}`, { cause: lastError });
|
|
185
194
|
}
|
|
186
195
|
function readGitStatusPorcelainOnce(cwd) {
|
|
187
196
|
return new Promise((resolve, reject) => {
|
|
188
197
|
const child = spawn("git", ["status", "--porcelain=v1", "--untracked-files=all"], {
|
|
189
198
|
cwd,
|
|
199
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
|
|
190
200
|
stdio: ["ignore", "pipe", "pipe"],
|
|
191
201
|
});
|
|
192
202
|
let stdout = "";
|
|
@@ -200,12 +210,16 @@ function readGitStatusPorcelainOnce(cwd) {
|
|
|
200
210
|
stderr += chunk;
|
|
201
211
|
});
|
|
202
212
|
child.on("error", reject);
|
|
203
|
-
child.on("close", (code) => {
|
|
213
|
+
child.on("close", (code, signal) => {
|
|
204
214
|
if (code === 0) {
|
|
205
215
|
resolve(stdout);
|
|
206
216
|
return;
|
|
207
217
|
}
|
|
208
|
-
|
|
218
|
+
const detail = stderr.trim() || stdout.trim() || "no stderr/stdout";
|
|
219
|
+
const error = new Error(`git status failed: ${detail}`);
|
|
220
|
+
error.exitCode = code;
|
|
221
|
+
error.signal = signal;
|
|
222
|
+
reject(error);
|
|
209
223
|
});
|
|
210
224
|
});
|
|
211
225
|
}
|
|
@@ -7,7 +7,13 @@ import path from "node:path";
|
|
|
7
7
|
* (except a bare root). No extra case-folding on macOS/Windows.
|
|
8
8
|
*/
|
|
9
9
|
export function normalizeWorktreeRealpath(repoRoot) {
|
|
10
|
-
|
|
10
|
+
// The legacy JS implementation preserves an input 8.3 path such as
|
|
11
|
+
// C:\\Users\\MICROS~1 on Windows. The native implementation expands the
|
|
12
|
+
// short and long aliases to the same physical path before hashing.
|
|
13
|
+
const absolute = path.resolve(repoRoot);
|
|
14
|
+
let resolved = process.platform === "win32"
|
|
15
|
+
? realpathSync.native(absolute)
|
|
16
|
+
: realpathSync(absolute);
|
|
11
17
|
if (/^[a-zA-Z]:/.test(resolved)) {
|
|
12
18
|
resolved = resolved[0].toUpperCase() + resolved.slice(1);
|
|
13
19
|
}
|