@tea-agent/loop-agent 0.26.1 → 0.26.3
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 +47 -0
- package/dist/application/dag/generate-task-dag.js +33 -0
- package/dist/commands/task-source-prepare.js +6 -0
- package/dist/executors/dag-pi-executor.js +156 -9
- package/dist/executors/shell-executor.js +111 -0
- package/dist/executors/shell-presets.js +12 -4
- package/dist/executors/shell-write-guard.js +145 -12
- package/dist/task/config-types.js +6 -0
- package/dist/task/contract/constants.js +1 -0
- package/dist/task/contract/project.js +8 -0
- package/dist/task/contract/schema.js +1 -0
- package/dist/task/frontend-preflight.js +131 -0
- package/dist/task/runtime.js +2 -4
- package/dist/task/source-prepare/build-draft.js +9 -0
- package/dist/task/source-prepare/completeness.js +1 -1
- package/dist/worker/observability/read-model.js +134 -0
- package/dist/worker/observe/static/state.js +61 -0
- package/dist/worker/observe/static/styles.css +8 -0
- package/dist/worker/observe/static/views/dag-graph.js +107 -31
- package/dist/worker/observe/static/views/dag-inspector.js +374 -157
- package/dist/worker/observe/static/views/dag.js +4 -11
- package/dist/workflows/dag/backend-test-pytest-collection.js +277 -0
- package/dist/workflows/dag/convergence/controller.js +110 -21
- package/dist/workflows/dag/frontend-implementation-contract.js +218 -17
- package/dist/workflows/dag/frontend-repair.js +29 -29
- package/dist/workflows/dag/frontend-review-context.js +7 -1
- package/dist/workflows/dag/frontend-verification-trace.js +26 -5
- package/dist/workflows/dag/frontend-worktree-diff.js +14 -3
- package/dist/workflows/dag/governance-profile.js +1 -1
- package/dist/workflows/dag/init-hybrid.js +115 -39
- package/dist/workflows/dag/output-protocol.js +180 -7
- package/dist/workflows/dag/runner.js +141 -52
- package/dist/workflows/dag/types.js +5 -1
- package/dist/workflows/dag/validate.js +3 -2
- package/docs/templates/backend-test-dag.json +100 -8
- package/package.json +1 -1
- package/skills/loop-agent/references/hybrid-dag.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,53 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.26.3] - 2026-08-02
|
|
6
|
+
|
|
7
|
+
### 重点更新
|
|
8
|
+
|
|
9
|
+
- DAG 无害工具副产物自动恢复
|
|
10
|
+
- 提升前端验证命令的精确度与失败分类
|
|
11
|
+
- 隔离测试固件与运行环境标记
|
|
12
|
+
|
|
13
|
+
### 新增
|
|
14
|
+
|
|
15
|
+
- DAG 无害工具副产物自动恢复:自动清理 Git Bash 误用 `> nul` / `2> nul` 在仓库根目录生成的普通非 symlink untracked `nul` 文件,避免其干扰 Git 状态复查与变更清单生成
|
|
16
|
+
|
|
17
|
+
### 改进
|
|
18
|
+
|
|
19
|
+
- 精确限定前端验证命令的作用域,提升验证执行的准确性
|
|
20
|
+
- 更精准地分类前端验证失败原因,避免模糊报错
|
|
21
|
+
- 将运行器标记与测试固件进行安全隔离,防止测试环境干扰
|
|
22
|
+
|
|
23
|
+
### 修复
|
|
24
|
+
|
|
25
|
+
- 修复配置验证目标错误处理符号引用的问题
|
|
26
|
+
- 修复 DAG 执行中无害的根目录 nul 副产物导致流程异常的问题
|
|
27
|
+
|
|
28
|
+
## [0.26.2] - 2026-08-02
|
|
29
|
+
|
|
30
|
+
### 重点更新
|
|
31
|
+
|
|
32
|
+
- 后端测试在执行前新增有界的 pytest 收集修复机制,安全且精准地自动修复生成测试中的不一致问题
|
|
33
|
+
- 全面加固 DAG 审查协议与前端契约边界,提升执行稳定性
|
|
34
|
+
- 修复 Observe 看板轮询刷新时的交互状态保留问题,优化使用体验
|
|
35
|
+
|
|
36
|
+
### 新增
|
|
37
|
+
|
|
38
|
+
- 后端测试在业务 pytest 执行前新增有界的收集(collection)修复机制:仅对生成的测试目录中可唯一归因的语法、导入、模块或参数化不一致进行最多一次自动修复,且严格禁止修改生产代码或放宽断言
|
|
39
|
+
|
|
40
|
+
### 改进
|
|
41
|
+
|
|
42
|
+
- 加固 DAG 审查协议与追踪门禁,提升执行稳定性
|
|
43
|
+
- 加固前端 DAG 契约边界
|
|
44
|
+
- 稳定 Linux 环境下的测试收集固件(fixture)
|
|
45
|
+
- 对齐受监督的 DAG 收尾拓扑结构
|
|
46
|
+
|
|
47
|
+
### 修复
|
|
48
|
+
|
|
49
|
+
- 修复 Observe 看板在轮询刷新时破坏 DAG 图视口 DOM 的问题,确保拖动与滚动状态不被销毁
|
|
50
|
+
- 修复节点检查器在内容未变时重建内容的问题,现支持异步标签页安全恢复滚动位置
|
|
51
|
+
|
|
5
52
|
## [0.26.1] - 2026-08-02
|
|
6
53
|
|
|
7
54
|
### 新增
|
|
@@ -12,6 +12,9 @@ import { validateDagUseCase } from "./validate-dag.js";
|
|
|
12
12
|
import { runDagUseCase } from "./run-dag.js";
|
|
13
13
|
import { getTaskPaths, loadTaskConfig } from "../../task/runtime.js";
|
|
14
14
|
import { writeTaskConfig } from "../../infrastructure/harness/task-store.js";
|
|
15
|
+
import { runFrontendPreflight } from "../../task/frontend-preflight.js";
|
|
16
|
+
import { observeTaskContract } from "../../task/contract/observe.js";
|
|
17
|
+
import { adoptTaskContract } from "../../task/contract/adopt.js";
|
|
15
18
|
const PLACEHOLDER_WRITESET_MARKER = "REPLACE/WITH";
|
|
16
19
|
function buildValidateInput(repoRoot, dagPath, parsed) {
|
|
17
20
|
return {
|
|
@@ -243,6 +246,36 @@ export async function generateTaskDagUseCase(input) {
|
|
|
243
246
|
// before any expensive DAG generation or execution. Empty/consistent
|
|
244
247
|
// repos stay compatible so the default DAG flow is unblocked.
|
|
245
248
|
await assertExecPlanIndexConsistent(repoRoot);
|
|
249
|
+
const preflightTaskConfig = await loadTaskConfig(repoRoot, parsed.taskId);
|
|
250
|
+
const frontendScope = await runFrontendPreflight({
|
|
251
|
+
repoRoot,
|
|
252
|
+
taskId: parsed.taskId,
|
|
253
|
+
taskConfig: preflightTaskConfig,
|
|
254
|
+
});
|
|
255
|
+
if (frontendScope) {
|
|
256
|
+
await writeTaskConfig(repoRoot, parsed.taskId, {
|
|
257
|
+
...preflightTaskConfig,
|
|
258
|
+
allowedPaths: [
|
|
259
|
+
...new Set([
|
|
260
|
+
frontendScope.entrypoint,
|
|
261
|
+
...frontendScope.implementationPaths,
|
|
262
|
+
...frontendScope.testPaths,
|
|
263
|
+
]),
|
|
264
|
+
],
|
|
265
|
+
});
|
|
266
|
+
const state = await observeTaskContract({ repoRoot, taskId: parsed.taskId });
|
|
267
|
+
if (!state.ref || !state.observedCanonicalHash) {
|
|
268
|
+
throw new Error("frontend preflight could not reconcile the managed Task Contract");
|
|
269
|
+
}
|
|
270
|
+
await adoptTaskContract({
|
|
271
|
+
repoRoot,
|
|
272
|
+
taskId: parsed.taskId,
|
|
273
|
+
expectedRevision: state.ref.revision,
|
|
274
|
+
expectedObservedHash: state.observedCanonicalHash,
|
|
275
|
+
requestId: `frontend-preflight-${parsed.taskId}-${randomUUID()}`,
|
|
276
|
+
requestPayloadSha256: randomUUID().replaceAll("-", "").padEnd(64, "0"),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
246
279
|
const candidateResult = await initHybridDagFromTask(repoRoot, parsed.taskId, {
|
|
247
280
|
outputPath: parsed.outputPath,
|
|
248
281
|
template: "standard-dag",
|
|
@@ -18,6 +18,7 @@ const USAGE = `usage:
|
|
|
18
18
|
[--task-kind <TaskKind>]
|
|
19
19
|
[--feature-id <string>]
|
|
20
20
|
[--allowed-path <glob>]...
|
|
21
|
+
[--allowed-root <glob>]...
|
|
21
22
|
[--forbidden-path <glob>]...
|
|
22
23
|
[--no-default-forbidden-paths]
|
|
23
24
|
[--invariant <text>]...
|
|
@@ -113,6 +114,10 @@ export function parseTaskSourcePrepareArgs(args) {
|
|
|
113
114
|
options.allowedPaths = pushList(options.allowedPaths, next());
|
|
114
115
|
continue;
|
|
115
116
|
}
|
|
117
|
+
if (token === "--allowed-root") {
|
|
118
|
+
options.allowedRoots = pushList(options.allowedRoots, next());
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
116
121
|
if (token === "--forbidden-path") {
|
|
117
122
|
options.forbiddenPaths = pushList(options.forbiddenPaths, next());
|
|
118
123
|
continue;
|
|
@@ -264,6 +269,7 @@ function buildFlags(options) {
|
|
|
264
269
|
taskKind: options.taskKind,
|
|
265
270
|
featureId: options.featureId,
|
|
266
271
|
allowedPaths: options.allowedPaths,
|
|
272
|
+
allowedRoots: options.allowedRoots,
|
|
267
273
|
forbiddenPaths: options.forbiddenPaths,
|
|
268
274
|
noDefaultForbiddenPaths: options.noDefaultForbiddenPaths,
|
|
269
275
|
invariants: options.invariants,
|
|
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import { writeDagNodeJsonArtifact, writeTextArtifactFile, } from "../infrastructure/harness/artifact-store.js";
|
|
4
4
|
import { executePiStep, } from "./pi-executor.js";
|
|
5
5
|
import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
|
|
6
|
-
import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
|
|
6
|
+
import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelain, recoverRootNulArtifact, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
|
|
7
7
|
import { redactSecrets, truncateUtf8Preview } from "../shared/preview.js";
|
|
8
8
|
export const DAG_PI_READONLY_TOOLS = ["read", "grep", "find", "ls"];
|
|
9
9
|
export const DAG_PI_WRITE_TOOLS = [
|
|
@@ -259,6 +259,7 @@ export async function writePiExecutorArtifacts(artifactsDir, input) {
|
|
|
259
259
|
}
|
|
260
260
|
const DEFAULT_DAG_PI_WRITE_GUARD_DEPENDENCIES = {
|
|
261
261
|
readGitStatusPorcelain,
|
|
262
|
+
recoverRootNulArtifact,
|
|
262
263
|
};
|
|
263
264
|
export async function executeDagPiNode(input, meta, piStepFn = executePiStep, writeGuardDependencies = DEFAULT_DAG_PI_WRITE_GUARD_DEPENDENCIES) {
|
|
264
265
|
const started = Date.now();
|
|
@@ -360,10 +361,105 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
360
361
|
let changeManifestAfterStatus;
|
|
361
362
|
let changeManifestChangedFiles;
|
|
362
363
|
if (beforeStatus !== undefined) {
|
|
364
|
+
let recoveryEvidence;
|
|
365
|
+
let removalPendingRecheck;
|
|
363
366
|
try {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
+
let afterStatus = await writeGuardDependencies.readGitStatusPorcelain(input.cwd, { phase: "pi-writer-after" });
|
|
368
|
+
let afterSnapshot = snapshotGitStatusPorcelain(afterStatus);
|
|
369
|
+
let afterPathFingerprints = await snapshotGitStatusPathFingerprints(input.cwd, afterSnapshot);
|
|
370
|
+
// Benign tool artifact recovery: a new untracked bounded regular file
|
|
371
|
+
// literally named `nul` at the repository root is removed with
|
|
372
|
+
// the Node file API, then git status is recaptured so the guard,
|
|
373
|
+
// outcome validation and change manifest only ever see the
|
|
374
|
+
// sanitized diff. Every non-matching candidate stays fail-closed.
|
|
375
|
+
if (afterSnapshot.has("nul")) {
|
|
376
|
+
const recoverRootNulArtifactFn = writeGuardDependencies.recoverRootNulArtifact ??
|
|
377
|
+
recoverRootNulArtifact;
|
|
378
|
+
const recovery = await recoverRootNulArtifactFn({
|
|
379
|
+
rootCwd: input.cwd,
|
|
380
|
+
beforeSnapshot: snapshotGitStatusPorcelain(beforeStatus),
|
|
381
|
+
afterSnapshot,
|
|
382
|
+
afterPathFingerprints,
|
|
383
|
+
candidateAuthorized: validateShellWriteGuardFromDiff({
|
|
384
|
+
changedFiles: ["nul"],
|
|
385
|
+
task: input.task,
|
|
386
|
+
concurrentSiblingWriteSets: meta.concurrentSiblingWriteSets,
|
|
387
|
+
}).ok,
|
|
388
|
+
});
|
|
389
|
+
const beforeStatusSha256 = createHash("sha256")
|
|
390
|
+
.update(beforeStatus)
|
|
391
|
+
.digest("hex");
|
|
392
|
+
const afterStatusSha256 = createHash("sha256")
|
|
393
|
+
.update(afterStatus)
|
|
394
|
+
.digest("hex");
|
|
395
|
+
if (recovery.action === "removed") {
|
|
396
|
+
const evidenceBase = {
|
|
397
|
+
candidatePath: "nul",
|
|
398
|
+
reason: recovery.reason,
|
|
399
|
+
action: "recovered",
|
|
400
|
+
result: "removed",
|
|
401
|
+
observedSizeBytes: recovery.observedSizeBytes,
|
|
402
|
+
removedAt: recovery.removedAt,
|
|
403
|
+
beforeStatusSha256,
|
|
404
|
+
afterStatusSha256,
|
|
405
|
+
};
|
|
406
|
+
removalPendingRecheck = evidenceBase;
|
|
407
|
+
const recheckedStatus = await writeGuardDependencies.readGitStatusPorcelain(input.cwd, {
|
|
408
|
+
phase: "pi-writer-after-recheck",
|
|
409
|
+
});
|
|
410
|
+
const recheckedSnapshot = snapshotGitStatusPorcelain(recheckedStatus);
|
|
411
|
+
afterStatus = recheckedStatus;
|
|
412
|
+
afterSnapshot = recheckedSnapshot;
|
|
413
|
+
afterPathFingerprints = await snapshotGitStatusPathFingerprints(input.cwd, recheckedSnapshot);
|
|
414
|
+
recoveryEvidence = {
|
|
415
|
+
...evidenceBase,
|
|
416
|
+
recheck: {
|
|
417
|
+
statusSha256: createHash("sha256")
|
|
418
|
+
.update(recheckedStatus)
|
|
419
|
+
.digest("hex"),
|
|
420
|
+
nulStillPresent: recheckedSnapshot.has("nul"),
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
removalPendingRecheck = undefined;
|
|
424
|
+
}
|
|
425
|
+
else if (recovery.action === "skipped") {
|
|
426
|
+
recoveryEvidence = {
|
|
427
|
+
candidatePath: "nul",
|
|
428
|
+
reason: recovery.reason,
|
|
429
|
+
action: "skipped",
|
|
430
|
+
result: "kept",
|
|
431
|
+
...(recovery.observedSizeBytes === undefined
|
|
432
|
+
? {}
|
|
433
|
+
: { observedSizeBytes: recovery.observedSizeBytes }),
|
|
434
|
+
beforeStatusSha256,
|
|
435
|
+
afterStatusSha256,
|
|
436
|
+
recheck: {
|
|
437
|
+
statusSha256: afterStatusSha256,
|
|
438
|
+
nulStillPresent: true,
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
recoveryEvidence = {
|
|
444
|
+
candidatePath: "nul",
|
|
445
|
+
reason: recovery.reason,
|
|
446
|
+
action: "failed",
|
|
447
|
+
result: "removal-failed",
|
|
448
|
+
observedSizeBytes: recovery.observedSizeBytes,
|
|
449
|
+
errorDetail: recovery.errorDetail,
|
|
450
|
+
beforeStatusSha256,
|
|
451
|
+
afterStatusSha256,
|
|
452
|
+
recheck: {
|
|
453
|
+
statusSha256: afterStatusSha256,
|
|
454
|
+
nulStillPresent: true,
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
writeGuardOk = false;
|
|
458
|
+
writeGuardViolations = [
|
|
459
|
+
`nul artifact removal failed: ${recovery.errorDetail}`,
|
|
460
|
+
];
|
|
461
|
+
}
|
|
462
|
+
}
|
|
367
463
|
const changedFiles = pathsChangedDuringRun(snapshotGitStatusPorcelain(beforeStatus), afterSnapshot, beforePathFingerprints, afterPathFingerprints);
|
|
368
464
|
changeManifestAfterStatus = afterStatus;
|
|
369
465
|
changeManifestChangedFiles = changedFiles;
|
|
@@ -372,16 +468,35 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
372
468
|
task: input.task,
|
|
373
469
|
concurrentSiblingWriteSets: meta.concurrentSiblingWriteSets,
|
|
374
470
|
});
|
|
375
|
-
writeGuardOk = guard.ok;
|
|
376
|
-
writeGuardViolations = guard.violations;
|
|
471
|
+
writeGuardOk = writeGuardOk && guard.ok;
|
|
472
|
+
writeGuardViolations = [...writeGuardViolations, ...guard.violations];
|
|
377
473
|
}
|
|
378
474
|
catch (error) {
|
|
379
475
|
await persistGitWriteGuardDiagnostics(meta.runDir, input.task.id, error);
|
|
476
|
+
if (removalPendingRecheck) {
|
|
477
|
+
// The artifact was already deleted but the recheck read failed:
|
|
478
|
+
// record the recovery facts with the recheck failure so the
|
|
479
|
+
// evidence trail stays complete while the node remains failed.
|
|
480
|
+
recoveryEvidence = {
|
|
481
|
+
...removalPendingRecheck,
|
|
482
|
+
recheck: {
|
|
483
|
+
failed: true,
|
|
484
|
+
errorDetail: boundedRecoveryErrorDetail(error),
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
}
|
|
380
488
|
writeGuardOk = false;
|
|
381
489
|
writeGuardViolations = [
|
|
382
490
|
`git status unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
383
491
|
];
|
|
384
492
|
}
|
|
493
|
+
if (recoveryEvidence) {
|
|
494
|
+
await persistBenignToolArtifactRecovery({
|
|
495
|
+
runDir: meta.runDir,
|
|
496
|
+
nodeId: input.task.id,
|
|
497
|
+
evidence: recoveryEvidence,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
385
500
|
}
|
|
386
501
|
let writerOutcomeViolation;
|
|
387
502
|
if (mapped.ok && input.task.writerOutcomePolicy) {
|
|
@@ -624,9 +739,7 @@ async function persistWriterGitBaseline(input) {
|
|
|
624
739
|
nodeId: input.nodeId,
|
|
625
740
|
phase: "pi-writer-before",
|
|
626
741
|
capturedAt: new Date().toISOString(),
|
|
627
|
-
statusSha256: createHash("sha256")
|
|
628
|
-
.update(input.beforeStatus)
|
|
629
|
-
.digest("hex"),
|
|
742
|
+
statusSha256: createHash("sha256").update(input.beforeStatus).digest("hex"),
|
|
630
743
|
dirtyPaths: [...input.beforeSnapshot.keys()].sort(),
|
|
631
744
|
pathFingerprintsSha256: createHash("sha256")
|
|
632
745
|
.update(JSON.stringify(fingerprintEntries))
|
|
@@ -639,6 +752,40 @@ async function persistGitWriteGuardDiagnostics(runDir, nodeId, error) {
|
|
|
639
752
|
return;
|
|
640
753
|
await writeDagNodeJsonArtifact(runDir, nodeId, "git-write-guard-diagnostics.json", error.diagnostics);
|
|
641
754
|
}
|
|
755
|
+
function boundedRecoveryErrorDetail(error) {
|
|
756
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
757
|
+
return detail.length <= 1000
|
|
758
|
+
? detail
|
|
759
|
+
: `${detail.slice(0, 1000)}...[truncated]`;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Persist bounded run-owned evidence for a recognized repository-root `nul`
|
|
763
|
+
* candidate: candidate path, reason, action, result and recheck facts plus
|
|
764
|
+
* status hashes. Never records environment variables or secrets.
|
|
765
|
+
*/
|
|
766
|
+
async function persistBenignToolArtifactRecovery(input) {
|
|
767
|
+
const artifact = {
|
|
768
|
+
schemaVersion: 1,
|
|
769
|
+
nodeId: input.nodeId,
|
|
770
|
+
candidatePath: input.evidence.candidatePath,
|
|
771
|
+
reason: input.evidence.reason,
|
|
772
|
+
action: input.evidence.action,
|
|
773
|
+
result: input.evidence.result,
|
|
774
|
+
...(input.evidence.observedSizeBytes === undefined
|
|
775
|
+
? {}
|
|
776
|
+
: { observedSizeBytes: input.evidence.observedSizeBytes }),
|
|
777
|
+
...(input.evidence.errorDetail
|
|
778
|
+
? { errorDetail: input.evidence.errorDetail }
|
|
779
|
+
: {}),
|
|
780
|
+
...(input.evidence.removedAt
|
|
781
|
+
? { removedAt: input.evidence.removedAt }
|
|
782
|
+
: {}),
|
|
783
|
+
beforeStatusSha256: input.evidence.beforeStatusSha256,
|
|
784
|
+
afterStatusSha256: input.evidence.afterStatusSha256,
|
|
785
|
+
recheck: input.evidence.recheck,
|
|
786
|
+
};
|
|
787
|
+
await writeDagNodeJsonArtifact(input.runDir, input.nodeId, "benign-tool-artifact-recovery.json", artifact);
|
|
788
|
+
}
|
|
642
789
|
/**
|
|
643
790
|
* Validate the writer's observed diff against its declared write boundary.
|
|
644
791
|
* Inlined mirror of runPostRunWriteGuard that reuses the already-computed diff
|
|
@@ -24,6 +24,7 @@ import { materializeBackendTestExecutionContract } from "../workflows/dag/backen
|
|
|
24
24
|
import { analyzeBackendTestCaseCoverage, analyzeBackendTestMarkdownPytestCorrespondence, materializeBackendTestCaseManifestFromFacts, } from "../workflows/dag/backend-test-case-coverage-analysis.js";
|
|
25
25
|
import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport } from "../workflows/dag/backend-test-result-contract.js";
|
|
26
26
|
import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
|
|
27
|
+
import { assessBackendPytestCollection, assertBackendPytestCollectionFresh, buildBackendPytestAssetInventory, materializeEffectiveBackendPytestCollection, readBackendPytestCollectionFacts, writeBackendPytestCollectionArtifacts, } from "../workflows/dag/backend-test-pytest-collection.js";
|
|
27
28
|
import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
|
|
28
29
|
import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
|
|
29
30
|
import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
|
|
@@ -476,6 +477,114 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
476
477
|
};
|
|
477
478
|
}
|
|
478
479
|
}
|
|
480
|
+
else if (pipeline === "markdown-collection-assess") {
|
|
481
|
+
const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
|
|
482
|
+
const inventory = await buildBackendPytestAssetInventory(input.cwd, mappedScripts);
|
|
483
|
+
const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
484
|
+
const targets = mappedScripts.map(shellQuote).join(" ");
|
|
485
|
+
const command = `PYTHONDONTWRITEBYTECODE=1 python -m pytest --collect-only -q -p no:cacheprovider ${targets}`;
|
|
486
|
+
const [result] = await executePipelineCommands(input, meta, [command]);
|
|
487
|
+
if (!result)
|
|
488
|
+
throw new Error("backend pytest collection command did not produce a result");
|
|
489
|
+
if (["spawn-error", "timeout", "termination-unconfirmed"].includes(result.failureCategory ?? "")) {
|
|
490
|
+
return {
|
|
491
|
+
ok: false,
|
|
492
|
+
stdout: result.stdout,
|
|
493
|
+
stderr: result.stderr || "backend pytest collection could not start",
|
|
494
|
+
failureCategory: result.failureCategory,
|
|
495
|
+
durationMs: Date.now() - started,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
const facts = assessBackendPytestCollection({
|
|
499
|
+
phase: "initial",
|
|
500
|
+
mappedScripts,
|
|
501
|
+
inventory,
|
|
502
|
+
exitCode: result.exitCode ?? 2,
|
|
503
|
+
stdout: result.stdout,
|
|
504
|
+
stderr: result.stderr,
|
|
505
|
+
});
|
|
506
|
+
const artifacts = await writeBackendPytestCollectionArtifacts({
|
|
507
|
+
runDir: meta.runDir,
|
|
508
|
+
stem: "initial",
|
|
509
|
+
facts,
|
|
510
|
+
});
|
|
511
|
+
outputs.push(`collectionFacts=${artifacts.factsPath}`, `collectionReport=${artifacts.reportPath}`);
|
|
512
|
+
return {
|
|
513
|
+
ok: true,
|
|
514
|
+
stdout: JSON.stringify({
|
|
515
|
+
status: facts.status,
|
|
516
|
+
repairEligible: facts.repairEligible,
|
|
517
|
+
pytestExitCode: facts.pytestExitCode,
|
|
518
|
+
collectedItemCount: facts.collectedItemCount,
|
|
519
|
+
factsPath: "contracts/backend-test-pytest-collection-initial.json",
|
|
520
|
+
}),
|
|
521
|
+
stderr: "",
|
|
522
|
+
durationMs: Date.now() - started,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
else if (pipeline === "markdown-collection-effective") {
|
|
526
|
+
const initial = await readBackendPytestCollectionFacts(path.join(meta.runDir, "contracts", "backend-test-pytest-collection-initial.json"));
|
|
527
|
+
let effective;
|
|
528
|
+
if (initial.status === "PASS") {
|
|
529
|
+
effective = await materializeEffectiveBackendPytestCollection({
|
|
530
|
+
workspaceRoot: input.cwd,
|
|
531
|
+
initial,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
else if (initial.status === "REPAIRABLE") {
|
|
535
|
+
const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
|
|
536
|
+
const inventory = await buildBackendPytestAssetInventory(input.cwd, mappedScripts);
|
|
537
|
+
const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
538
|
+
const targets = mappedScripts.map(shellQuote).join(" ");
|
|
539
|
+
const command = `PYTHONDONTWRITEBYTECODE=1 python -m pytest --collect-only -q -p no:cacheprovider ${targets}`;
|
|
540
|
+
const [result] = await executePipelineCommands(input, meta, [command]);
|
|
541
|
+
if (!result)
|
|
542
|
+
throw new Error("backend pytest final collection command did not produce a result");
|
|
543
|
+
if (["spawn-error", "timeout", "termination-unconfirmed"].includes(result.failureCategory ?? "")) {
|
|
544
|
+
return {
|
|
545
|
+
ok: false,
|
|
546
|
+
stdout: result.stdout,
|
|
547
|
+
stderr: result.stderr || "backend pytest final collection could not start",
|
|
548
|
+
failureCategory: result.failureCategory,
|
|
549
|
+
durationMs: Date.now() - started,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
const finalFacts = assessBackendPytestCollection({
|
|
553
|
+
phase: "final",
|
|
554
|
+
mappedScripts,
|
|
555
|
+
inventory,
|
|
556
|
+
exitCode: result.exitCode ?? 2,
|
|
557
|
+
stdout: result.stdout,
|
|
558
|
+
stderr: result.stderr,
|
|
559
|
+
});
|
|
560
|
+
effective = await materializeEffectiveBackendPytestCollection({
|
|
561
|
+
workspaceRoot: input.cwd,
|
|
562
|
+
initial,
|
|
563
|
+
final: finalFacts,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
throw new Error("backend pytest collection is blocked and does not authorize repair");
|
|
568
|
+
}
|
|
569
|
+
const artifacts = await writeBackendPytestCollectionArtifacts({
|
|
570
|
+
runDir: meta.runDir,
|
|
571
|
+
stem: "effective",
|
|
572
|
+
facts: effective,
|
|
573
|
+
});
|
|
574
|
+
return {
|
|
575
|
+
ok: true,
|
|
576
|
+
stdout: JSON.stringify({
|
|
577
|
+
status: effective.status,
|
|
578
|
+
collectionSource: effective.collectionSource,
|
|
579
|
+
repairAttempt: effective.repairAttempt,
|
|
580
|
+
collectedItemCount: effective.collectedItemCount,
|
|
581
|
+
factsPath: "contracts/backend-test-pytest-collection-effective.json",
|
|
582
|
+
reportPath: artifacts.reportPath,
|
|
583
|
+
}),
|
|
584
|
+
stderr: "",
|
|
585
|
+
durationMs: Date.now() - started,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
479
588
|
else if (pipeline === "markdown-traceability") {
|
|
480
589
|
let report;
|
|
481
590
|
try {
|
|
@@ -542,6 +651,8 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
542
651
|
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"}`, `correspondenceSummary.primarySymbolCount=${manifest.correspondenceSummary?.primarySymbolCount ?? "unavailable"}`, `correspondenceSummary.testPoints=${manifest.correspondenceSummary?.mappedTestPointCount ?? "unavailable"}/${manifest.correspondenceSummary?.testPointCount ?? "unavailable"}`, `correspondenceSummary.variantTestPointCount=${manifest.correspondenceSummary?.variantTestPointCount ?? "unavailable"}`, `correspondenceSummary.assertionTestPointCount=${manifest.correspondenceSummary?.assertionTestPointCount ?? "unavailable"}`, `correspondenceSummary.crossCuttingTestPointCount=${manifest.correspondenceSummary?.crossCuttingTestPointCount ?? "unavailable"}`, `correspondenceSummary.unclassifiedTestPointCount=${manifest.correspondenceSummary?.unclassifiedTestPointCount ?? "unavailable"}`, `correspondenceSummary.duplicateBindingTestPointCount=${manifest.correspondenceSummary?.duplicateBindingTestPointCount ?? "unavailable"}`);
|
|
543
652
|
}
|
|
544
653
|
else if (pipeline === "markdown-execute-html") {
|
|
654
|
+
const effectiveCollection = await readBackendPytestCollectionFacts(path.join(meta.runDir, "contracts", "backend-test-pytest-collection-effective.json"));
|
|
655
|
+
await assertBackendPytestCollectionFresh(input.cwd, effectiveCollection);
|
|
545
656
|
const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
|
|
546
657
|
const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
547
658
|
const pytestTargets = mappedScripts.map(shellQuote).join(" ");
|
|
@@ -15,7 +15,10 @@ function escapeShellSingleQuoted(value) {
|
|
|
15
15
|
/**
|
|
16
16
|
* Build a single shell command that reads `$HARNESS_DAG_RUN_DIR/<fromNodeId>.json`
|
|
17
17
|
* (injected by the shell executor from runtime meta), parses a single candidate
|
|
18
|
-
* verdict
|
|
18
|
+
* verdict from `assistantText ?? stdout`, and accepts only exact verdict values.
|
|
19
|
+
* Sources:
|
|
20
|
+
* - verdict-line: line-based legacy VERDICT protocol
|
|
21
|
+
* - json-review-verdict: structured review JSON with verdict: pass/request-revision
|
|
19
22
|
* Modes:
|
|
20
23
|
* - first-non-empty: first non-empty line
|
|
21
24
|
* - first-verdict-line: first line matching /^VERDICT:/ after trim and optional whole-line Markdown emphasis normalization
|
|
@@ -25,6 +28,7 @@ function escapeShellSingleQuoted(value) {
|
|
|
25
28
|
*/
|
|
26
29
|
export function buildVerdictGateShellCommand(gate) {
|
|
27
30
|
const gateLabel = gate.label ?? `${gate.fromNodeId} verdict`;
|
|
31
|
+
const source = gate.source ?? "verdict-line";
|
|
28
32
|
const lineMode = gate.lineMode ?? "first-non-empty";
|
|
29
33
|
const candidateNodeIds = [
|
|
30
34
|
gate.fromNodeId,
|
|
@@ -32,8 +36,10 @@ export function buildVerdictGateShellCommand(gate) {
|
|
|
32
36
|
];
|
|
33
37
|
const config = {
|
|
34
38
|
label: gateLabel,
|
|
39
|
+
source,
|
|
35
40
|
lineMode,
|
|
36
41
|
accept: gate.accept,
|
|
42
|
+
routingAccept: gate.routingAccept ?? [],
|
|
37
43
|
candidateNodeIds,
|
|
38
44
|
};
|
|
39
45
|
const configLiteral = escapeShellSingleQuoted(JSON.stringify(config));
|
|
@@ -48,10 +54,12 @@ export function buildVerdictGateShellCommand(gate) {
|
|
|
48
54
|
'if(!file){console.error("missing "+cfg.label+" JSON output (tried: "+cfg.candidateNodeIds.join(", ")+")");process.exit(1);}',
|
|
49
55
|
'const raw=JSON.parse(fs.readFileSync(file,"utf8"));',
|
|
50
56
|
'const text=String(raw.assistantText ?? raw.stdout ?? "");',
|
|
57
|
+
'const extractJson=(value)=>{const text=String(value).trim();const fenced=[...text.matchAll(/```(?:json)?\\s*([\\s\\S]*?)\\s*```/gi)];const source=fenced.length===1?String(fenced[0][1]).trim():text;for(let start=0;start<source.length;start+=1){if(source[start]!=="{")continue;let depth=0,inString=false,escaped=false;for(let i=start;i<source.length;i+=1){const ch=source[i];if(inString){if(escaped){escaped=false;}else if(ch==="\\\\"){escaped=true;}else if(ch===String.fromCharCode(34)){inString=false;}continue;}if(ch===String.fromCharCode(34)){inString=true;continue;}if(ch==="{"){depth+=1;}else if(ch===String.fromCharCode(125)){depth-=1;if(depth===0){const candidate=source.slice(start,i+1);try{const parsed=JSON.parse(candidate);if(parsed&&typeof parsed==="object"&&!Array.isArray(parsed))return candidate;}catch{}break;}}}}return "";};',
|
|
58
|
+
'let first="";',
|
|
59
|
+
'if(cfg.source==="json-review-verdict"){const jsonText=extractJson(text);if(!jsonText){console.error(cfg.label+" gate blocked: missing JSON review verdict");process.exit(1);}let parsed;try{parsed=JSON.parse(jsonText);}catch(error){console.error(cfg.label+" gate blocked: invalid JSON review verdict: "+error.message);process.exit(1);}first=String(parsed&&parsed.verdict||"");}',
|
|
51
60
|
'const normalize=(value)=>{const trimmed=String(value).trim();const emphasized=trimmed.match(/^\\*{1,3}\\s*(VERDICT:[^*]+?)\\s*\\*{1,3}$/);return (emphasized?emphasized[1]:trimmed).trim();};',
|
|
52
|
-
'const lines=text.split(/\\r?\\n/).map(normalize);',
|
|
53
|
-
'const
|
|
54
|
-
'if(!cfg.accept.includes(first)){console.error(cfg.label+" gate blocked: "+(first||"missing VERDICT line"));process.exit(1);}',
|
|
61
|
+
'if(cfg.source!=="json-review-verdict"){const lines=text.split(/\\r?\\n/).map(normalize);first=cfg.lineMode==="first-verdict-line"? (lines.find((value)=>/^VERDICT:/.test(value))??"") : (lines.find((value)=>value.length>0)??"");}',
|
|
62
|
+
'const routing=Array.isArray(cfg.routingAccept)?cfg.routingAccept:[];if(!cfg.accept.includes(first)&&!routing.includes(first)){console.error(cfg.label+" gate blocked: "+(first|| (cfg.source==="json-review-verdict"?"missing JSON verdict":"missing VERDICT line")));process.exit(1);}',
|
|
55
63
|
'process.stdout.write(first+"\\n");',
|
|
56
64
|
].join("");
|
|
57
65
|
return `node -e '${escapeShellSingleQuoted(program)}' '${configLiteral}'`;
|