@tea-agent/loop-agent 0.16.1 → 0.16.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/dist/executors/dag-pi-executor.js +4 -2
- package/dist/executors/pi-sdk-executor.js +66 -3
- package/dist/executors/shell-executor.js +212 -29
- package/dist/executors/shell-presets.js +12 -2
- package/dist/executors/shell-write-guard.js +20 -1
- package/dist/shared/git-progress.js +9 -2
- package/dist/worker/observability/read-model.js +56 -0
- package/dist/worker/observe/server.js +6 -3
- package/dist/workflows/dag/backend-test-analysis-contract.js +87 -30
- package/dist/workflows/dag/backend-test-case-manifest.js +71 -8
- package/dist/workflows/dag/backend-test-execution-contract.js +63 -11
- package/dist/workflows/dag/backend-test-repair-contract.js +94 -0
- package/dist/workflows/dag/backend-test-result-contract.js +6 -4
- package/dist/workflows/dag/backend-test-semantic-review-contract.js +36 -0
- package/dist/workflows/dag/dynamic-runtime/condition.js +1 -1
- package/dist/workflows/dag/dynamic-runtime/shared.js +42 -0
- package/dist/workflows/dag/failure-routing.js +1 -1
- package/dist/workflows/dag/frontend-implementation-contract.js +32 -16
- package/dist/workflows/dag/init-hybrid.js +591 -119
- package/dist/workflows/dag/lifecycle.js +33 -2
- package/dist/workflows/dag/scheduler.js +87 -17
- package/dist/workflows/dag/types.js +31 -0
- package/dist/workflows/dag/validate.js +20 -14
- package/docs/templates/agent-dag.schema.json +25 -2
- package/docs/templates/backend-test-analysis.schema.json +9 -16
- package/docs/templates/backend-test-dag.json +493 -197
- package/docs/templates/backend-test-dag.review-cases.prompt.md +10 -4
- package/docs/templates/backend-test-execution.schema.json +6 -1
- package/package.json +1 -1
- package/skills/loop-agent/references/hybrid-dag.md +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
### 改进
|
|
6
|
+
|
|
7
|
+
- 后端测试 DAG 从 38 个收敛为 24 个真实顶层节点;使用 fail-closed `runIf` 和复合 Shell capability 减少调度,同时保留双合同、Manifest、语义评审、JUnit、Result、分类、修复安全、追踪和最终 outcome 证据。三条可选修订/修复分支仍各最多执行一次。
|
|
8
|
+
- Backend Test Analysis、Case Manifest、Semantic Review 与 Classification 的严格 JSON 契约进一步对齐,生成和修订节点明确字段白名单、`sourceBinding` 与 evidence gap 约束,避免模型自定义字段导致确定性门禁失败。
|
|
9
|
+
|
|
10
|
+
### 修复
|
|
11
|
+
|
|
12
|
+
- 后端测试复合 Shell pipeline 现在与普通 Shell 节点共享 Git write guard;即使命令退出成功,只要越过 `read-only`、`allowedPaths` 或 `forbiddenPaths` 边界,节点仍会 fail-closed。
|
|
13
|
+
- Observe 现使用实际的 repair safety 节点,并只在修复节点真正开始执行后计入一次 attempt;条件跳过不再误报已修复,安全门禁失败会显示为 `rejected`。
|
|
14
|
+
- Pi SDK 对缺少响应 ID 的累计 Token 生命周期事件改为取本次执行最大快照,避免同一响应的匿名 usage 被重复累加。
|
|
15
|
+
- Pi SDK 执行长推理或大段结构化输出时不再把高频流式增量事件无界累积到内存;同一响应在多个生命周期事件中重复出现的 Token 用量只统计一次,避免 `Invalid string length` 和成本数据虚高。
|
|
16
|
+
- 后端测试复合执行节点继续保持 clean environment、失败分类和 fail-closed outcome,并为 initial/final Result、repair eligibility、traceability 与 Observe 投影保留结构化运行证据。
|
|
17
|
+
|
|
18
|
+
## [0.16.2] - 2026-07-19
|
|
19
|
+
|
|
20
|
+
### 修复
|
|
21
|
+
|
|
22
|
+
- 前端 `frontend-implementation-contract-shell` 物化时用 DAG 权威 `sourceBinding` 覆盖模型输出,避免模型错绑 requirement 路径/哈希或额外引用路径导致实现链在设计门后 fail-closed。
|
|
23
|
+
|
|
3
24
|
## [0.16.1] - 2026-07-19
|
|
4
25
|
|
|
5
26
|
### 重点更新
|
|
@@ -292,9 +292,11 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
|
|
|
292
292
|
writeGuardOk = guard.ok;
|
|
293
293
|
writeGuardViolations = guard.violations;
|
|
294
294
|
}
|
|
295
|
-
catch {
|
|
295
|
+
catch (error) {
|
|
296
296
|
writeGuardOk = false;
|
|
297
|
-
writeGuardViolations = [
|
|
297
|
+
writeGuardViolations = [
|
|
298
|
+
`git status unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
299
|
+
];
|
|
298
300
|
}
|
|
299
301
|
}
|
|
300
302
|
if (writeGuardOk) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { appendFile, mkdir } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { classifyPiFailure, DEFAULT_TIMEOUT_MS, extractAssistantTextFromPiJson,
|
|
3
|
+
import { classifyPiFailure, DEFAULT_TIMEOUT_MS, extractAssistantTextFromPiJson, } from './pi-executor.js';
|
|
4
4
|
import { serializeSessionEvent } from './pi-event-serializer.js';
|
|
5
5
|
let sdkSessionFactoryOverride;
|
|
6
6
|
let sdkImportOverrideForTests;
|
|
@@ -202,6 +202,63 @@ async function resolveSdkSessionFactory(reuseScope) {
|
|
|
202
202
|
return createSdkSession(sdk, input, shared);
|
|
203
203
|
};
|
|
204
204
|
}
|
|
205
|
+
function isRecord(value) {
|
|
206
|
+
return typeof value === 'object' && value !== null;
|
|
207
|
+
}
|
|
208
|
+
function readUsageNumber(record, keys) {
|
|
209
|
+
for (const key of keys) {
|
|
210
|
+
const value = record[key];
|
|
211
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
|
|
212
|
+
return Math.trunc(value);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
function extractSdkUsageSample(event) {
|
|
218
|
+
const message = isRecord(event.message) ? event.message : undefined;
|
|
219
|
+
const usageCandidates = [event.usage, message?.usage, event.tokenUsage];
|
|
220
|
+
let tokens;
|
|
221
|
+
for (const candidate of usageCandidates) {
|
|
222
|
+
if (!isRecord(candidate))
|
|
223
|
+
continue;
|
|
224
|
+
const input = readUsageNumber(candidate, ['input_tokens', 'inputTokens', 'prompt_tokens', 'promptTokens']);
|
|
225
|
+
const output = readUsageNumber(candidate, ['output_tokens', 'outputTokens', 'completion_tokens', 'completionTokens']);
|
|
226
|
+
const total = readUsageNumber(candidate, ['total_tokens', 'totalTokens']);
|
|
227
|
+
if (input !== undefined && output !== undefined) {
|
|
228
|
+
tokens = input + output;
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
if (total !== undefined) {
|
|
232
|
+
tokens = total;
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (tokens === undefined)
|
|
237
|
+
return undefined;
|
|
238
|
+
const responseKeyCandidates = [
|
|
239
|
+
event.responseId,
|
|
240
|
+
event.messageId,
|
|
241
|
+
message?.responseId,
|
|
242
|
+
message?.id,
|
|
243
|
+
];
|
|
244
|
+
const responseKey = responseKeyCandidates.find((value) => typeof value === 'string' && value.length > 0);
|
|
245
|
+
return responseKey ? { responseKey, tokens } : { tokens };
|
|
246
|
+
}
|
|
247
|
+
function aggregateSdkTokenUsage(samples) {
|
|
248
|
+
const identified = new Map();
|
|
249
|
+
let anonymousMaximum = 0;
|
|
250
|
+
for (const sample of samples) {
|
|
251
|
+
if (sample.responseKey) {
|
|
252
|
+
identified.set(sample.responseKey, Math.max(identified.get(sample.responseKey) ?? 0, sample.tokens));
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
// SDK lifecycle events may repeat cumulative usage without a response id.
|
|
256
|
+
// Treat anonymous samples as snapshots for this attempt rather than increments.
|
|
257
|
+
anonymousMaximum = Math.max(anonymousMaximum, sample.tokens);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return anonymousMaximum + Array.from(identified.values()).reduce((sum, tokens) => sum + tokens, 0);
|
|
261
|
+
}
|
|
205
262
|
/**
|
|
206
263
|
* Execute a single Pi step via the SDK.
|
|
207
264
|
* When reuseScope is active, only shared auth/model resources are reused; each attempt still
|
|
@@ -227,6 +284,7 @@ export async function executeSingleSdkAttempt(options) {
|
|
|
227
284
|
let timedOut = false;
|
|
228
285
|
let stderr = '';
|
|
229
286
|
const stdoutLines = [];
|
|
287
|
+
const usageSamples = [];
|
|
230
288
|
let session;
|
|
231
289
|
let timeoutHandle;
|
|
232
290
|
const sessionEventAppender = options.sessionEventsPath
|
|
@@ -243,9 +301,14 @@ export async function executeSingleSdkAttempt(options) {
|
|
|
243
301
|
thinking: modelConfig.thinking,
|
|
244
302
|
});
|
|
245
303
|
const unsubscribe = session.subscribe((event) => {
|
|
304
|
+
const usageSample = extractSdkUsageSample(event);
|
|
305
|
+
if (usageSample)
|
|
306
|
+
usageSamples.push(usageSample);
|
|
307
|
+
if (!shouldPersistSessionEvent(event))
|
|
308
|
+
return;
|
|
246
309
|
const line = serializeSessionEvent(event);
|
|
247
310
|
stdoutLines.push(line);
|
|
248
|
-
if (sessionEventAppender
|
|
311
|
+
if (sessionEventAppender) {
|
|
249
312
|
sessionEventAppender.append(line, event);
|
|
250
313
|
}
|
|
251
314
|
});
|
|
@@ -305,7 +368,7 @@ export async function executeSingleSdkAttempt(options) {
|
|
|
305
368
|
const stdout = stdoutLines.join('\n');
|
|
306
369
|
const durationMs = Date.now() - startedAt;
|
|
307
370
|
const { assistantText, parsedEvents } = extractAssistantTextFromPiJson(stdout);
|
|
308
|
-
const tokensUsed =
|
|
371
|
+
const tokensUsed = aggregateSdkTokenUsage(usageSamples);
|
|
309
372
|
const failureCategory = classifyPiFailure({
|
|
310
373
|
assistantText,
|
|
311
374
|
exitCode: timedOut ? 1 : stderr ? 1 : 0,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { writeDagNodeTextArtifact } from "../infrastructure/harness/artifact-store.js";
|
|
5
6
|
import { truncateOutput } from "../shared/output-truncation.js";
|
|
@@ -11,7 +12,9 @@ import { formatFrontendFailureAssessStdout, formatFrontendRepairContractStdout,
|
|
|
11
12
|
import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBackendTestTraceabilityGate, } from "../workflows/dag/backend-test-case-manifest.js";
|
|
12
13
|
import { materializeBackendTestExecutionContract } from "../workflows/dag/backend-test-execution-contract.js";
|
|
13
14
|
import { materializeBackendTestResultFromRunDir } from "../workflows/dag/backend-test-result-contract.js";
|
|
14
|
-
import {
|
|
15
|
+
import { buildBackendTestEffectiveResultSelectorShellSnippet, buildBackendTestRepairEligibilityShellSnippet, buildBackendTestRepairSafetyShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-repair-contract.js";
|
|
16
|
+
import { materializeBackendTestSemanticReview } from "../workflows/dag/backend-test-semantic-review-contract.js";
|
|
17
|
+
import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
|
|
15
18
|
import { buildShellProcessEnv } from "./shell-verification.js";
|
|
16
19
|
const DEFAULT_SHELL_TIMEOUT_MS = 300_000;
|
|
17
20
|
const SUMMARY_STDOUT_MAX = 4_000;
|
|
@@ -63,32 +66,6 @@ export function resolveShellCommands(shell) {
|
|
|
63
66
|
...fromRequirementCoverageGate,
|
|
64
67
|
];
|
|
65
68
|
}
|
|
66
|
-
async function readGitStatusPorcelain(cwd) {
|
|
67
|
-
return new Promise((resolve, reject) => {
|
|
68
|
-
const child = spawn("git", ["status", "--porcelain=v1", "--untracked-files=all"], {
|
|
69
|
-
cwd,
|
|
70
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
71
|
-
});
|
|
72
|
-
let stdout = "";
|
|
73
|
-
let stderr = "";
|
|
74
|
-
child.stdout.setEncoding("utf-8");
|
|
75
|
-
child.stderr.setEncoding("utf-8");
|
|
76
|
-
child.stdout.on("data", (chunk) => {
|
|
77
|
-
stdout += chunk;
|
|
78
|
-
});
|
|
79
|
-
child.stderr.on("data", (chunk) => {
|
|
80
|
-
stderr += chunk;
|
|
81
|
-
});
|
|
82
|
-
child.on("error", reject);
|
|
83
|
-
child.on("close", (code) => {
|
|
84
|
-
if (code === 0) {
|
|
85
|
-
resolve(stdout);
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
reject(new Error(`git status failed: ${stderr.trim() || stdout.trim()}`));
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
69
|
export async function executeShellCommand(input) {
|
|
93
70
|
return new Promise((resolve) => {
|
|
94
71
|
const startedAt = Date.now();
|
|
@@ -297,8 +274,194 @@ async function runShellWriteGuard(input) {
|
|
|
297
274
|
forbiddenPaths: input.task.forbiddenPaths,
|
|
298
275
|
});
|
|
299
276
|
}
|
|
277
|
+
async function executeBackendTestPipeline(input, meta) {
|
|
278
|
+
const pipeline = input.task.shell?.backendTestPipeline;
|
|
279
|
+
const started = Date.now();
|
|
280
|
+
try {
|
|
281
|
+
if (!pipeline)
|
|
282
|
+
throw new Error("missing backend-test pipeline id");
|
|
283
|
+
const outputs = [];
|
|
284
|
+
if (pipeline === "contracts") {
|
|
285
|
+
const wrapperPath = path.join(meta.runDir, "analyze-and-discover-backend-test-pi.json");
|
|
286
|
+
const wrapper = JSON.parse(await readFile(wrapperPath, "utf8"));
|
|
287
|
+
const raw = wrapper.assistantText?.trim() || wrapper.stdout?.trim() || "";
|
|
288
|
+
const fenced = raw.match(/^```json\s*([\s\S]*?)\s*```$/i);
|
|
289
|
+
const envelope = JSON.parse(fenced ? fenced[1] : raw);
|
|
290
|
+
if (!envelope.analysis || !envelope.execution)
|
|
291
|
+
throw new Error("backend-test contract envelope requires analysis and execution");
|
|
292
|
+
await writeFile(path.join(meta.runDir, "backend-test-analysis-envelope.json"), JSON.stringify({ assistantText: JSON.stringify(envelope.analysis) }));
|
|
293
|
+
await writeFile(path.join(meta.runDir, "backend-test-execution-envelope.json"), JSON.stringify({ assistantText: JSON.stringify(envelope.execution) }));
|
|
294
|
+
const analysis = await materializeBackendTestAnalysisContract({
|
|
295
|
+
runDir: meta.runDir,
|
|
296
|
+
fromNodeId: "backend-test-analysis-envelope",
|
|
297
|
+
artifactName: "backend-test-analysis.json",
|
|
298
|
+
outputDir: "contracts",
|
|
299
|
+
sourceBinding: meta.spec.sourceBinding,
|
|
300
|
+
});
|
|
301
|
+
const execution = await materializeBackendTestExecutionContract({
|
|
302
|
+
runDir: meta.runDir,
|
|
303
|
+
fromNodeId: "backend-test-execution-envelope",
|
|
304
|
+
artifactName: "backend-test-execution.json",
|
|
305
|
+
outputDir: "contracts",
|
|
306
|
+
});
|
|
307
|
+
outputs.push(`analysis=${analysis.path}`, `execution=${execution.path}`);
|
|
308
|
+
}
|
|
309
|
+
else if (pipeline === "semantic-initial" || pipeline === "semantic-final") {
|
|
310
|
+
const fromNodeId = pipeline === "semantic-final"
|
|
311
|
+
? "review-generated-backend-pytest-final-pi"
|
|
312
|
+
: "review-generated-backend-pytest-pi";
|
|
313
|
+
const artifactName = pipeline === "semantic-final"
|
|
314
|
+
? "backend-test-semantic-review-final.json"
|
|
315
|
+
: "backend-test-semantic-review.json";
|
|
316
|
+
const review = await materializeBackendTestSemanticReview({
|
|
317
|
+
runDir: meta.runDir,
|
|
318
|
+
fromNodeId,
|
|
319
|
+
artifactName,
|
|
320
|
+
outputDir: "contracts",
|
|
321
|
+
});
|
|
322
|
+
const trace = await runBackendTestTraceabilityGate({
|
|
323
|
+
runDir: meta.runDir,
|
|
324
|
+
workspaceRoot: input.cwd,
|
|
325
|
+
});
|
|
326
|
+
outputs.push(`semantic=${review.path}`, formatTraceabilityGateStdout(trace));
|
|
327
|
+
const parsed = JSON.parse(await readFile(review.path, "utf8"));
|
|
328
|
+
if (pipeline === "semantic-initial") {
|
|
329
|
+
return { ok: true, stdout: JSON.stringify(parsed), stderr: "", failureCategory: "success", durationMs: Date.now() - started };
|
|
330
|
+
}
|
|
331
|
+
if (pipeline === "semantic-final") {
|
|
332
|
+
if (parsed.verdict !== "pass")
|
|
333
|
+
throw new Error("backend pytest semantic review did not pass");
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
else if (pipeline === "execute-parse-initial") {
|
|
337
|
+
const results = await executePipelineCommands(input, meta);
|
|
338
|
+
if (!results.every((result) => result.ok)) {
|
|
339
|
+
const failure = results.find((result) => !result.ok);
|
|
340
|
+
return { ok: false, stdout: results.map((result) => result.stdout).join("\n"), stderr: failure.stderr, failureCategory: failure.failureCategory, durationMs: Date.now() - started };
|
|
341
|
+
}
|
|
342
|
+
const artifact = await materializeBackendTestResultFromRunDir({
|
|
343
|
+
runDir: meta.runDir,
|
|
344
|
+
fromNodeId: input.task.id,
|
|
345
|
+
artifactName: "backend-test-result-initial.json",
|
|
346
|
+
outputDir: "contracts",
|
|
347
|
+
junitRelativePath: "reports/backend-test-initial-junit.xml",
|
|
348
|
+
});
|
|
349
|
+
outputs.push(...results.map((result) => result.stdout), `result=${artifact.path}`);
|
|
350
|
+
}
|
|
351
|
+
else if (pipeline === "classification-eligibility") {
|
|
352
|
+
const classification = await materializeBackendTestClassification({
|
|
353
|
+
runDir: meta.runDir,
|
|
354
|
+
fromNodeId: "classify-backend-test-result-pi",
|
|
355
|
+
artifactName: "backend-test-classification.json",
|
|
356
|
+
outputDir: "contracts",
|
|
357
|
+
});
|
|
358
|
+
const results = await executePipelineCommands(input, meta, [buildBackendTestRepairEligibilityShellSnippet()]);
|
|
359
|
+
if (!results.every((result) => result.ok))
|
|
360
|
+
throw new Error(results.find((result) => !result.ok)?.stderr || "repair eligibility failed");
|
|
361
|
+
const eligibility = JSON.parse(await readFile(path.join(meta.runDir, "contracts", "backend-test-repair-eligibility.json"), "utf8"));
|
|
362
|
+
return { ok: true, stdout: JSON.stringify(eligibility), stderr: "", failureCategory: "success", durationMs: Date.now() - started };
|
|
363
|
+
}
|
|
364
|
+
else if (pipeline === "repair-safety-traceability") {
|
|
365
|
+
const results = await executePipelineCommands(input, meta, [buildBackendTestRepairSafetyShellSnippet()]);
|
|
366
|
+
if (!results.every((result) => result.ok))
|
|
367
|
+
throw new Error(results.find((result) => !result.ok)?.stderr || "repair safety failed");
|
|
368
|
+
const trace = await runBackendTestTraceabilityGate({ runDir: meta.runDir, workspaceRoot: input.cwd });
|
|
369
|
+
outputs.push(...results.map((result) => result.stdout), formatTraceabilityGateStdout(trace));
|
|
370
|
+
}
|
|
371
|
+
else if (pipeline === "finalize-effective-result") {
|
|
372
|
+
const eligibilityPath = path.join(meta.runDir, "contracts", "backend-test-repair-eligibility.json");
|
|
373
|
+
const eligibility = existsSync(eligibilityPath)
|
|
374
|
+
? JSON.parse(await readFile(eligibilityPath, "utf8"))
|
|
375
|
+
: { eligible: false };
|
|
376
|
+
const finalNeeded = eligibility.eligible === true;
|
|
377
|
+
if (finalNeeded) {
|
|
378
|
+
const results = await executePipelineCommands(input, meta);
|
|
379
|
+
if (!results.every((result) => result.ok))
|
|
380
|
+
return { ok: false, stdout: results.map((result) => result.stdout).join("\n"), stderr: results.find((result) => !result.ok)?.stderr ?? "final pytest failed", failureCategory: results.find((result) => !result.ok)?.failureCategory ?? "nonzero-exit", durationMs: Date.now() - started };
|
|
381
|
+
const artifact = await materializeBackendTestResultFromRunDir({ runDir: meta.runDir, fromNodeId: input.task.id, artifactName: "backend-test-result-final.json", outputDir: "contracts", junitRelativePath: "reports/backend-test-final-junit.xml" });
|
|
382
|
+
outputs.push(...results.map((result) => result.stdout), `final=${artifact.path}`);
|
|
383
|
+
}
|
|
384
|
+
const selected = await executePipelineCommands(input, meta, [buildBackendTestEffectiveResultSelectorShellSnippet()]);
|
|
385
|
+
if (!selected.every((result) => result.ok))
|
|
386
|
+
throw new Error(selected.find((result) => !result.ok)?.stderr || "effective result selection failed");
|
|
387
|
+
outputs.push(...selected.map((result) => result.stdout));
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
throw new Error(`unsupported backend-test pipeline: ${pipeline}`);
|
|
391
|
+
}
|
|
392
|
+
return { ok: true, stdout: outputs.filter(Boolean).join("\n"), stderr: "", failureCategory: "success", durationMs: Date.now() - started };
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
async function executeBackendTestPipelineWithWriteGuard(input, meta) {
|
|
399
|
+
let beforeStatus;
|
|
400
|
+
try {
|
|
401
|
+
beforeStatus = await readGitStatusPorcelain(input.cwd);
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
// Non-git cwd: keep the same documented limitation as normal shell nodes.
|
|
405
|
+
beforeStatus = undefined;
|
|
406
|
+
}
|
|
407
|
+
const result = await executeBackendTestPipeline(input, meta);
|
|
408
|
+
if (beforeStatus === undefined)
|
|
409
|
+
return result;
|
|
410
|
+
try {
|
|
411
|
+
const guard = await runShellWriteGuard({
|
|
412
|
+
rootCwd: input.cwd,
|
|
413
|
+
task: input.task,
|
|
414
|
+
beforeStatus,
|
|
415
|
+
});
|
|
416
|
+
if (guard.ok)
|
|
417
|
+
return result;
|
|
418
|
+
const detail = `write guard failed: ${guard.violations.join(", ")}`;
|
|
419
|
+
return {
|
|
420
|
+
...result,
|
|
421
|
+
ok: false,
|
|
422
|
+
stderr: [result.stderr, detail].filter(Boolean).join("\n\n"),
|
|
423
|
+
failureCategory: "write-guard",
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
const detail = `write guard failed: git status unavailable: ${error instanceof Error ? error.message : String(error)}`;
|
|
428
|
+
return {
|
|
429
|
+
...result,
|
|
430
|
+
ok: false,
|
|
431
|
+
stderr: [result.stderr, detail].filter(Boolean).join("\n\n"),
|
|
432
|
+
failureCategory: "write-guard",
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
async function executePipelineCommands(input, meta, overrideCommands) {
|
|
437
|
+
const shell = input.task.shell;
|
|
438
|
+
const cwd = resolveShellCwd(input.cwd, shell.cwd);
|
|
439
|
+
const commands = overrideCommands ?? shell.commands;
|
|
440
|
+
const results = [];
|
|
441
|
+
for (const command of commands) {
|
|
442
|
+
const commandNumber = results.length + 1;
|
|
443
|
+
const result = await executeShellCommand({
|
|
444
|
+
command,
|
|
445
|
+
cwd,
|
|
446
|
+
timeoutMs: shell.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS,
|
|
447
|
+
envAllowlist: shell.envAllowlist,
|
|
448
|
+
dagRunMeta: { runDir: meta.runDir, runId: meta.runId },
|
|
449
|
+
outputArtifacts: {
|
|
450
|
+
stdoutPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stdout.txt`),
|
|
451
|
+
stderrPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stderr.txt`),
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
results.push(result);
|
|
455
|
+
if (!result.ok)
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
return results;
|
|
459
|
+
}
|
|
300
460
|
export async function executeDagShellNode(input, meta) {
|
|
301
461
|
const shell = input.task.shell;
|
|
462
|
+
if (shell?.backendTestPipeline) {
|
|
463
|
+
return executeBackendTestPipelineWithWriteGuard(input, meta);
|
|
464
|
+
}
|
|
302
465
|
if (shell?.jsonArtifactGate) {
|
|
303
466
|
const started = Date.now();
|
|
304
467
|
try {
|
|
@@ -306,6 +469,7 @@ export async function executeDagShellNode(input, meta) {
|
|
|
306
469
|
let artifact;
|
|
307
470
|
switch (gate.schemaId) {
|
|
308
471
|
case "backend-test-analysis-v1":
|
|
472
|
+
case "backend-test-analysis-v2":
|
|
309
473
|
artifact = await materializeBackendTestAnalysisContract({
|
|
310
474
|
runDir: meta.runDir,
|
|
311
475
|
fromNodeId: gate.fromNodeId,
|
|
@@ -328,6 +492,23 @@ export async function executeDagShellNode(input, meta) {
|
|
|
328
492
|
fromNodeId: gate.fromNodeId,
|
|
329
493
|
artifactName: gate.artifactName,
|
|
330
494
|
outputDir: gate.outputDir,
|
|
495
|
+
junitRelativePath: gate.junitRelativePath,
|
|
496
|
+
});
|
|
497
|
+
break;
|
|
498
|
+
case "backend-test-classification-v1":
|
|
499
|
+
artifact = await materializeBackendTestClassification({
|
|
500
|
+
runDir: meta.runDir,
|
|
501
|
+
fromNodeId: gate.fromNodeId,
|
|
502
|
+
artifactName: gate.artifactName,
|
|
503
|
+
outputDir: gate.outputDir,
|
|
504
|
+
});
|
|
505
|
+
break;
|
|
506
|
+
case "backend-test-semantic-review-v1":
|
|
507
|
+
artifact = await materializeBackendTestSemanticReview({
|
|
508
|
+
runDir: meta.runDir,
|
|
509
|
+
fromNodeId: gate.fromNodeId,
|
|
510
|
+
artifactName: gate.artifactName,
|
|
511
|
+
outputDir: gate.outputDir,
|
|
331
512
|
});
|
|
332
513
|
break;
|
|
333
514
|
case "backend-test-case-manifest-v1":
|
|
@@ -520,9 +701,11 @@ export async function executeDagShellNode(input, meta) {
|
|
|
520
701
|
writeGuardOk = guard.ok;
|
|
521
702
|
writeGuardViolations = guard.violations;
|
|
522
703
|
}
|
|
523
|
-
catch {
|
|
704
|
+
catch (error) {
|
|
524
705
|
writeGuardOk = false;
|
|
525
|
-
writeGuardViolations = [
|
|
706
|
+
writeGuardViolations = [
|
|
707
|
+
`git status unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
708
|
+
];
|
|
526
709
|
}
|
|
527
710
|
}
|
|
528
711
|
const commandsOk = results.every((result) => result.ok);
|
|
@@ -31,10 +31,20 @@ function verdictGateNodeParser(lineMode) {
|
|
|
31
31
|
*/
|
|
32
32
|
export function buildVerdictGateShellCommand(gate) {
|
|
33
33
|
const gateLabel = gate.label ?? `${gate.fromNodeId} verdict`;
|
|
34
|
-
const jsonFile = `${gate.fromNodeId}.json`;
|
|
35
34
|
const lineMode = gate.lineMode ?? "first-non-empty";
|
|
36
35
|
const parser = verdictGateNodeParser(lineMode);
|
|
37
|
-
const
|
|
36
|
+
const candidateNodeIds = [
|
|
37
|
+
gate.fromNodeId,
|
|
38
|
+
...(gate.fallbackFromNodeIds ?? []).filter((id) => id !== gate.fromNodeId),
|
|
39
|
+
];
|
|
40
|
+
const resolveFile = candidateNodeIds.length === 1
|
|
41
|
+
? `FILE="\${HARNESS_DAG_RUN_DIR}/${candidateNodeIds[0]}.json"; test -f "\${FILE}" || { echo "missing ${gateLabel} JSON output: \${FILE}" >&2; exit 1; }`
|
|
42
|
+
: [
|
|
43
|
+
"FILE=\"\"",
|
|
44
|
+
...candidateNodeIds.map((id) => `if test -z "\${FILE}" && test -f "\${HARNESS_DAG_RUN_DIR}/${id}.json"; then FILE="\${HARNESS_DAG_RUN_DIR}/${id}.json"; fi`),
|
|
45
|
+
`test -n "\${FILE}" || { echo "missing ${gateLabel} JSON output (tried: ${candidateNodeIds.join(", ")})" >&2; exit 1; }`,
|
|
46
|
+
].join("; ");
|
|
47
|
+
const preamble = `test -n "\${HARNESS_DAG_RUN_DIR:-}" || { echo "missing HARNESS_DAG_RUN_DIR for ${gateLabel} gate" >&2; exit 1; }; ${resolveFile}; FIRST=$(${parser})`;
|
|
38
48
|
const blockedMessage = `${gateLabel} gate blocked: \${FIRST:-missing VERDICT line}`;
|
|
39
49
|
if (gate.accept.length === 1) {
|
|
40
50
|
const expected = escapeShellSingleQuoted(gate.accept[0]);
|
|
@@ -67,7 +67,26 @@ export function validateShellWriteGuard(input) {
|
|
|
67
67
|
}
|
|
68
68
|
return { ok: violations.length === 0, violations };
|
|
69
69
|
}
|
|
70
|
-
export async function readGitStatusPorcelain(cwd) {
|
|
70
|
+
export async function readGitStatusPorcelain(cwd, options = {}) {
|
|
71
|
+
const attempts = Math.max(1, options.attempts ?? 3);
|
|
72
|
+
const retryDelayMs = Math.max(0, options.retryDelayMs ?? 100);
|
|
73
|
+
let lastError;
|
|
74
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
75
|
+
try {
|
|
76
|
+
return await readGitStatusPorcelainOnce(cwd);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
lastError = error;
|
|
80
|
+
if (attempt < attempts && retryDelayMs > 0) {
|
|
81
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs * attempt));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
throw lastError instanceof Error
|
|
86
|
+
? lastError
|
|
87
|
+
: new Error(`git status failed after ${attempts} attempts`);
|
|
88
|
+
}
|
|
89
|
+
function readGitStatusPorcelainOnce(cwd) {
|
|
71
90
|
return new Promise((resolve, reject) => {
|
|
72
91
|
const child = spawn("git", ["status", "--porcelain=v1", "--untracked-files=all"], {
|
|
73
92
|
cwd,
|
|
@@ -114,8 +114,15 @@ function globToRegExp(pattern) {
|
|
|
114
114
|
const char = normalized[i];
|
|
115
115
|
const next = normalized[i + 1];
|
|
116
116
|
if (char === '*' && next === '*') {
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
const afterGlobstar = normalized[i + 2];
|
|
118
|
+
if (afterGlobstar === '/') {
|
|
119
|
+
regex += '(?:.*/)?';
|
|
120
|
+
i += 2;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
regex += '.*';
|
|
124
|
+
i += 1;
|
|
125
|
+
}
|
|
119
126
|
continue;
|
|
120
127
|
}
|
|
121
128
|
if (char === '*') {
|
|
@@ -1283,6 +1283,9 @@ function mergeDagRun(existing, incoming) {
|
|
|
1283
1283
|
...((incoming.dagPath ?? existing.dagPath)
|
|
1284
1284
|
? { dagPath: incoming.dagPath ?? existing.dagPath }
|
|
1285
1285
|
: {}),
|
|
1286
|
+
...((incoming.backendTest ?? existing.backendTest)
|
|
1287
|
+
? { backendTest: incoming.backendTest ?? existing.backendTest }
|
|
1288
|
+
: {}),
|
|
1286
1289
|
};
|
|
1287
1290
|
}
|
|
1288
1291
|
function mergeDagNodes(existing, incoming) {
|
|
@@ -1462,6 +1465,57 @@ async function walkForStateJson(dir, results) {
|
|
|
1462
1465
|
// skip
|
|
1463
1466
|
}
|
|
1464
1467
|
}
|
|
1468
|
+
async function loadBackendTestProjection(runDir, nodes) {
|
|
1469
|
+
const contracts = path.join(runDir, "contracts");
|
|
1470
|
+
const readContract = async (name) => {
|
|
1471
|
+
const value = await safeReadJson(path.join(contracts, name));
|
|
1472
|
+
return value ?? undefined;
|
|
1473
|
+
};
|
|
1474
|
+
const [initial, classification, eligibility, final, effective, manifest] = await Promise.all([
|
|
1475
|
+
readContract("backend-test-result-initial.json"),
|
|
1476
|
+
readContract("backend-test-classification.json"),
|
|
1477
|
+
readContract("backend-test-repair-eligibility.json"),
|
|
1478
|
+
readContract("backend-test-result-final.json"),
|
|
1479
|
+
readContract("backend-test-result.json"),
|
|
1480
|
+
readContract("backend-test-case-manifest.json"),
|
|
1481
|
+
]);
|
|
1482
|
+
if (!initial && !classification && !eligibility && !final && !effective && !manifest)
|
|
1483
|
+
return undefined;
|
|
1484
|
+
const result = (value) => value ? {
|
|
1485
|
+
outcome: readString(value, "outcome"),
|
|
1486
|
+
passed: readNumber(value, "passed"),
|
|
1487
|
+
failed: readNumber(value, "failed"),
|
|
1488
|
+
error: readNumber(value, "error"),
|
|
1489
|
+
} : undefined;
|
|
1490
|
+
const repairNode = nodes.find((node) => node.nodeId === "repair-backend-pytest-pi");
|
|
1491
|
+
const safetyNode = nodes.find((node) => node.nodeId === "validate-repair-safety-and-traceability-shell");
|
|
1492
|
+
const eligible = eligibility ? eligibility.eligible === true : undefined;
|
|
1493
|
+
const repairNodeStatus = repairNode?.status?.toLowerCase();
|
|
1494
|
+
const repairAttempted = repairNodeStatus === "running" || repairNodeStatus === "finished" || repairNodeStatus === "error";
|
|
1495
|
+
let repairStatus = "not-needed";
|
|
1496
|
+
if (repairNodeStatus === "running")
|
|
1497
|
+
repairStatus = "repairing";
|
|
1498
|
+
else if (safetyNode && isFailedNodeStatus(safetyNode.status))
|
|
1499
|
+
repairStatus = "rejected";
|
|
1500
|
+
else if (final)
|
|
1501
|
+
repairStatus = "completed";
|
|
1502
|
+
else if (eligible)
|
|
1503
|
+
repairStatus = "eligible";
|
|
1504
|
+
const effectiveSource = final ? "final" : "initial";
|
|
1505
|
+
return {
|
|
1506
|
+
...(initial ? { initial: result(initial) } : {}),
|
|
1507
|
+
...(classification ? { classification: { category: readString(classification, "category"), confidence: readNumber(classification, "confidence") } } : {}),
|
|
1508
|
+
repair: {
|
|
1509
|
+
eligible,
|
|
1510
|
+
reason: eligibility ? readString(eligibility, "reason") : undefined,
|
|
1511
|
+
attempt: final || repairAttempted ? 1 : 0,
|
|
1512
|
+
status: repairStatus,
|
|
1513
|
+
},
|
|
1514
|
+
...(final ? { final: result(final) } : {}),
|
|
1515
|
+
...(effective ? { effective: { source: effectiveSource, outcome: readString(effective, "outcome") } } : {}),
|
|
1516
|
+
...(manifest && manifest.coverageSummary ? { coverage: manifest.coverageSummary } : {}),
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1465
1519
|
async function parseDagStateFile(statePath, now) {
|
|
1466
1520
|
try {
|
|
1467
1521
|
if (!existsSync(statePath))
|
|
@@ -1516,6 +1570,7 @@ async function parseDagStateFile(statePath, now) {
|
|
|
1516
1570
|
liveness: liveness.status,
|
|
1517
1571
|
})
|
|
1518
1572
|
: undefined;
|
|
1573
|
+
const backendTest = await loadBackendTestProjection(runDir, nodes);
|
|
1519
1574
|
return {
|
|
1520
1575
|
dagRunId,
|
|
1521
1576
|
status,
|
|
@@ -1532,6 +1587,7 @@ async function parseDagStateFile(statePath, now) {
|
|
|
1532
1587
|
nodes,
|
|
1533
1588
|
edges,
|
|
1534
1589
|
dagPath: runDir,
|
|
1590
|
+
...(backendTest ? { backendTest } : {}),
|
|
1535
1591
|
};
|
|
1536
1592
|
}
|
|
1537
1593
|
catch {
|
|
@@ -24,9 +24,6 @@ export function createObserveServer(options) {
|
|
|
24
24
|
return new Promise((resolve, reject) => {
|
|
25
25
|
const server = createServer((req, res) => {
|
|
26
26
|
const startedAt = Date.now();
|
|
27
|
-
req.socket?.on("error", () => {
|
|
28
|
-
// Ignore client resets; avoid unhandled socket error noise.
|
|
29
|
-
});
|
|
30
27
|
if (debug) {
|
|
31
28
|
let logged = false;
|
|
32
29
|
const onceLog = (note) => {
|
|
@@ -59,6 +56,12 @@ export function createObserveServer(options) {
|
|
|
59
56
|
server.headersTimeout = keepAliveTimeoutMs + 5_000;
|
|
60
57
|
// Allow long-lived SSE; per-request work still finishes promptly.
|
|
61
58
|
server.requestTimeout = 0;
|
|
59
|
+
server.on("connection", (socket) => {
|
|
60
|
+
socket.on("error", () => {
|
|
61
|
+
// Ignore client resets; install once per connection so keep-alive
|
|
62
|
+
// requests cannot accumulate listeners on the same socket.
|
|
63
|
+
});
|
|
64
|
+
});
|
|
62
65
|
server.on("clientError", (err, socket) => {
|
|
63
66
|
if (debug) {
|
|
64
67
|
process.stderr.write(`[observe] ${new Date().toISOString()} clientError ${err.message}\n`);
|