@tea-agent/loop-agent 0.16.1 → 0.16.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/executors/dag-pi-executor.js +4 -2
  3. package/dist/executors/pi-sdk-executor.js +66 -3
  4. package/dist/executors/shell-executor.js +239 -29
  5. package/dist/executors/shell-presets.js +12 -2
  6. package/dist/executors/shell-write-guard.js +20 -1
  7. package/dist/shared/git-progress.js +9 -2
  8. package/dist/worker/observability/read-model.js +56 -0
  9. package/dist/worker/observe/server.js +6 -3
  10. package/dist/workflows/dag/backend-test-analysis-contract.js +87 -30
  11. package/dist/workflows/dag/backend-test-case-manifest.js +71 -8
  12. package/dist/workflows/dag/backend-test-execution-contract.js +63 -11
  13. package/dist/workflows/dag/backend-test-repair-contract.js +94 -0
  14. package/dist/workflows/dag/backend-test-result-contract.js +6 -4
  15. package/dist/workflows/dag/backend-test-semantic-review-contract.js +36 -0
  16. package/dist/workflows/dag/dynamic-runtime/condition.js +1 -1
  17. package/dist/workflows/dag/dynamic-runtime/shared.js +42 -0
  18. package/dist/workflows/dag/failure-routing.js +1 -1
  19. package/dist/workflows/dag/frontend-implementation-contract.js +32 -16
  20. package/dist/workflows/dag/frontend-worktree-diff.js +127 -0
  21. package/dist/workflows/dag/init-hybrid.js +616 -120
  22. package/dist/workflows/dag/lifecycle.js +33 -2
  23. package/dist/workflows/dag/scheduler.js +87 -17
  24. package/dist/workflows/dag/types.js +31 -0
  25. package/dist/workflows/dag/validate.js +20 -14
  26. package/docs/templates/agent-dag.schema.json +25 -2
  27. package/docs/templates/backend-test-analysis.schema.json +9 -16
  28. package/docs/templates/backend-test-dag.json +493 -197
  29. package/docs/templates/backend-test-dag.review-cases.prompt.md +10 -4
  30. package/docs/templates/backend-test-execution.schema.json +6 -1
  31. package/package.json +1 -1
  32. package/skills/frontend-review/SKILL.md +6 -2
  33. package/skills/loop-agent/references/hybrid-dag.md +4 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
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.3] - 2026-07-19
19
+
20
+ ### 修复
21
+
22
+ - 前端实现 DAG 在 review 前新增确定性 `frontend-worktree-diff-shell`,写出 run-owned `diff_patch` 与清单,避免 review 因找不到 actual diff 而误拦截。
23
+
24
+ ## [0.16.2] - 2026-07-19
25
+
26
+ ### 修复
27
+
28
+ - 前端 `frontend-implementation-contract-shell` 物化时用 DAG 权威 `sourceBinding` 覆盖模型输出,避免模型错绑 requirement 路径/哈希或额外引用路径导致实现链在设计门后 fail-closed。
29
+
3
30
  ## [0.16.1] - 2026-07-19
4
31
 
5
32
  ### 重点更新
@@ -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 = ["write guard failed: git status unavailable"];
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, extractTokenUsageFromPiJson, } from './pi-executor.js';
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 && shouldPersistSessionEvent(event)) {
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 = extractTokenUsageFromPiJson(stdout);
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";
@@ -7,11 +8,14 @@ import { buildRequirementCoverageGateShellCommand, expandShellPreset, buildVerdi
7
8
  import { materializeBackendTestAnalysisContract } from "../workflows/dag/backend-test-analysis-contract.js";
8
9
  import { materializeFrontendImplementationContract } from "../workflows/dag/frontend-implementation-contract.js";
9
10
  import { formatFrontendVerificationTraceStdout, runFrontendVerificationTraceGate, } from "../workflows/dag/frontend-verification-trace.js";
11
+ import { formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "../workflows/dag/frontend-worktree-diff.js";
10
12
  import { formatFrontendFailureAssessStdout, formatFrontendRepairContractStdout, runFrontendFailureAssessGate, runFrontendRepairContractGate, } from "../workflows/dag/frontend-repair.js";
11
13
  import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBackendTestTraceabilityGate, } from "../workflows/dag/backend-test-case-manifest.js";
12
14
  import { materializeBackendTestExecutionContract } from "../workflows/dag/backend-test-execution-contract.js";
13
15
  import { materializeBackendTestResultFromRunDir } from "../workflows/dag/backend-test-result-contract.js";
14
- import { pathsChangedDuringRun, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
16
+ import { buildBackendTestEffectiveResultSelectorShellSnippet, buildBackendTestRepairEligibilityShellSnippet, buildBackendTestRepairSafetyShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-repair-contract.js";
17
+ import { materializeBackendTestSemanticReview } from "../workflows/dag/backend-test-semantic-review-contract.js";
18
+ import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
15
19
  import { buildShellProcessEnv } from "./shell-verification.js";
16
20
  const DEFAULT_SHELL_TIMEOUT_MS = 300_000;
17
21
  const SUMMARY_STDOUT_MAX = 4_000;
@@ -63,32 +67,6 @@ export function resolveShellCommands(shell) {
63
67
  ...fromRequirementCoverageGate,
64
68
  ];
65
69
  }
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
70
  export async function executeShellCommand(input) {
93
71
  return new Promise((resolve) => {
94
72
  const startedAt = Date.now();
@@ -297,8 +275,194 @@ async function runShellWriteGuard(input) {
297
275
  forbiddenPaths: input.task.forbiddenPaths,
298
276
  });
299
277
  }
278
+ async function executeBackendTestPipeline(input, meta) {
279
+ const pipeline = input.task.shell?.backendTestPipeline;
280
+ const started = Date.now();
281
+ try {
282
+ if (!pipeline)
283
+ throw new Error("missing backend-test pipeline id");
284
+ const outputs = [];
285
+ if (pipeline === "contracts") {
286
+ const wrapperPath = path.join(meta.runDir, "analyze-and-discover-backend-test-pi.json");
287
+ const wrapper = JSON.parse(await readFile(wrapperPath, "utf8"));
288
+ const raw = wrapper.assistantText?.trim() || wrapper.stdout?.trim() || "";
289
+ const fenced = raw.match(/^```json\s*([\s\S]*?)\s*```$/i);
290
+ const envelope = JSON.parse(fenced ? fenced[1] : raw);
291
+ if (!envelope.analysis || !envelope.execution)
292
+ throw new Error("backend-test contract envelope requires analysis and execution");
293
+ await writeFile(path.join(meta.runDir, "backend-test-analysis-envelope.json"), JSON.stringify({ assistantText: JSON.stringify(envelope.analysis) }));
294
+ await writeFile(path.join(meta.runDir, "backend-test-execution-envelope.json"), JSON.stringify({ assistantText: JSON.stringify(envelope.execution) }));
295
+ const analysis = await materializeBackendTestAnalysisContract({
296
+ runDir: meta.runDir,
297
+ fromNodeId: "backend-test-analysis-envelope",
298
+ artifactName: "backend-test-analysis.json",
299
+ outputDir: "contracts",
300
+ sourceBinding: meta.spec.sourceBinding,
301
+ });
302
+ const execution = await materializeBackendTestExecutionContract({
303
+ runDir: meta.runDir,
304
+ fromNodeId: "backend-test-execution-envelope",
305
+ artifactName: "backend-test-execution.json",
306
+ outputDir: "contracts",
307
+ });
308
+ outputs.push(`analysis=${analysis.path}`, `execution=${execution.path}`);
309
+ }
310
+ else if (pipeline === "semantic-initial" || pipeline === "semantic-final") {
311
+ const fromNodeId = pipeline === "semantic-final"
312
+ ? "review-generated-backend-pytest-final-pi"
313
+ : "review-generated-backend-pytest-pi";
314
+ const artifactName = pipeline === "semantic-final"
315
+ ? "backend-test-semantic-review-final.json"
316
+ : "backend-test-semantic-review.json";
317
+ const review = await materializeBackendTestSemanticReview({
318
+ runDir: meta.runDir,
319
+ fromNodeId,
320
+ artifactName,
321
+ outputDir: "contracts",
322
+ });
323
+ const trace = await runBackendTestTraceabilityGate({
324
+ runDir: meta.runDir,
325
+ workspaceRoot: input.cwd,
326
+ });
327
+ outputs.push(`semantic=${review.path}`, formatTraceabilityGateStdout(trace));
328
+ const parsed = JSON.parse(await readFile(review.path, "utf8"));
329
+ if (pipeline === "semantic-initial") {
330
+ return { ok: true, stdout: JSON.stringify(parsed), stderr: "", failureCategory: "success", durationMs: Date.now() - started };
331
+ }
332
+ if (pipeline === "semantic-final") {
333
+ if (parsed.verdict !== "pass")
334
+ throw new Error("backend pytest semantic review did not pass");
335
+ }
336
+ }
337
+ else if (pipeline === "execute-parse-initial") {
338
+ const results = await executePipelineCommands(input, meta);
339
+ if (!results.every((result) => result.ok)) {
340
+ const failure = results.find((result) => !result.ok);
341
+ return { ok: false, stdout: results.map((result) => result.stdout).join("\n"), stderr: failure.stderr, failureCategory: failure.failureCategory, durationMs: Date.now() - started };
342
+ }
343
+ const artifact = await materializeBackendTestResultFromRunDir({
344
+ runDir: meta.runDir,
345
+ fromNodeId: input.task.id,
346
+ artifactName: "backend-test-result-initial.json",
347
+ outputDir: "contracts",
348
+ junitRelativePath: "reports/backend-test-initial-junit.xml",
349
+ });
350
+ outputs.push(...results.map((result) => result.stdout), `result=${artifact.path}`);
351
+ }
352
+ else if (pipeline === "classification-eligibility") {
353
+ const classification = await materializeBackendTestClassification({
354
+ runDir: meta.runDir,
355
+ fromNodeId: "classify-backend-test-result-pi",
356
+ artifactName: "backend-test-classification.json",
357
+ outputDir: "contracts",
358
+ });
359
+ const results = await executePipelineCommands(input, meta, [buildBackendTestRepairEligibilityShellSnippet()]);
360
+ if (!results.every((result) => result.ok))
361
+ throw new Error(results.find((result) => !result.ok)?.stderr || "repair eligibility failed");
362
+ const eligibility = JSON.parse(await readFile(path.join(meta.runDir, "contracts", "backend-test-repair-eligibility.json"), "utf8"));
363
+ return { ok: true, stdout: JSON.stringify(eligibility), stderr: "", failureCategory: "success", durationMs: Date.now() - started };
364
+ }
365
+ else if (pipeline === "repair-safety-traceability") {
366
+ const results = await executePipelineCommands(input, meta, [buildBackendTestRepairSafetyShellSnippet()]);
367
+ if (!results.every((result) => result.ok))
368
+ throw new Error(results.find((result) => !result.ok)?.stderr || "repair safety failed");
369
+ const trace = await runBackendTestTraceabilityGate({ runDir: meta.runDir, workspaceRoot: input.cwd });
370
+ outputs.push(...results.map((result) => result.stdout), formatTraceabilityGateStdout(trace));
371
+ }
372
+ else if (pipeline === "finalize-effective-result") {
373
+ const eligibilityPath = path.join(meta.runDir, "contracts", "backend-test-repair-eligibility.json");
374
+ const eligibility = existsSync(eligibilityPath)
375
+ ? JSON.parse(await readFile(eligibilityPath, "utf8"))
376
+ : { eligible: false };
377
+ const finalNeeded = eligibility.eligible === true;
378
+ if (finalNeeded) {
379
+ const results = await executePipelineCommands(input, meta);
380
+ if (!results.every((result) => result.ok))
381
+ 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 };
382
+ 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" });
383
+ outputs.push(...results.map((result) => result.stdout), `final=${artifact.path}`);
384
+ }
385
+ const selected = await executePipelineCommands(input, meta, [buildBackendTestEffectiveResultSelectorShellSnippet()]);
386
+ if (!selected.every((result) => result.ok))
387
+ throw new Error(selected.find((result) => !result.ok)?.stderr || "effective result selection failed");
388
+ outputs.push(...selected.map((result) => result.stdout));
389
+ }
390
+ else {
391
+ throw new Error(`unsupported backend-test pipeline: ${pipeline}`);
392
+ }
393
+ return { ok: true, stdout: outputs.filter(Boolean).join("\n"), stderr: "", failureCategory: "success", durationMs: Date.now() - started };
394
+ }
395
+ catch (error) {
396
+ return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
397
+ }
398
+ }
399
+ async function executeBackendTestPipelineWithWriteGuard(input, meta) {
400
+ let beforeStatus;
401
+ try {
402
+ beforeStatus = await readGitStatusPorcelain(input.cwd);
403
+ }
404
+ catch {
405
+ // Non-git cwd: keep the same documented limitation as normal shell nodes.
406
+ beforeStatus = undefined;
407
+ }
408
+ const result = await executeBackendTestPipeline(input, meta);
409
+ if (beforeStatus === undefined)
410
+ return result;
411
+ try {
412
+ const guard = await runShellWriteGuard({
413
+ rootCwd: input.cwd,
414
+ task: input.task,
415
+ beforeStatus,
416
+ });
417
+ if (guard.ok)
418
+ return result;
419
+ const detail = `write guard failed: ${guard.violations.join(", ")}`;
420
+ return {
421
+ ...result,
422
+ ok: false,
423
+ stderr: [result.stderr, detail].filter(Boolean).join("\n\n"),
424
+ failureCategory: "write-guard",
425
+ };
426
+ }
427
+ catch (error) {
428
+ const detail = `write guard failed: git status unavailable: ${error instanceof Error ? error.message : String(error)}`;
429
+ return {
430
+ ...result,
431
+ ok: false,
432
+ stderr: [result.stderr, detail].filter(Boolean).join("\n\n"),
433
+ failureCategory: "write-guard",
434
+ };
435
+ }
436
+ }
437
+ async function executePipelineCommands(input, meta, overrideCommands) {
438
+ const shell = input.task.shell;
439
+ const cwd = resolveShellCwd(input.cwd, shell.cwd);
440
+ const commands = overrideCommands ?? shell.commands;
441
+ const results = [];
442
+ for (const command of commands) {
443
+ const commandNumber = results.length + 1;
444
+ const result = await executeShellCommand({
445
+ command,
446
+ cwd,
447
+ timeoutMs: shell.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS,
448
+ envAllowlist: shell.envAllowlist,
449
+ dagRunMeta: { runDir: meta.runDir, runId: meta.runId },
450
+ outputArtifacts: {
451
+ stdoutPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stdout.txt`),
452
+ stderrPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stderr.txt`),
453
+ },
454
+ });
455
+ results.push(result);
456
+ if (!result.ok)
457
+ break;
458
+ }
459
+ return results;
460
+ }
300
461
  export async function executeDagShellNode(input, meta) {
301
462
  const shell = input.task.shell;
463
+ if (shell?.backendTestPipeline) {
464
+ return executeBackendTestPipelineWithWriteGuard(input, meta);
465
+ }
302
466
  if (shell?.jsonArtifactGate) {
303
467
  const started = Date.now();
304
468
  try {
@@ -306,6 +470,7 @@ export async function executeDagShellNode(input, meta) {
306
470
  let artifact;
307
471
  switch (gate.schemaId) {
308
472
  case "backend-test-analysis-v1":
473
+ case "backend-test-analysis-v2":
309
474
  artifact = await materializeBackendTestAnalysisContract({
310
475
  runDir: meta.runDir,
311
476
  fromNodeId: gate.fromNodeId,
@@ -328,6 +493,23 @@ export async function executeDagShellNode(input, meta) {
328
493
  fromNodeId: gate.fromNodeId,
329
494
  artifactName: gate.artifactName,
330
495
  outputDir: gate.outputDir,
496
+ junitRelativePath: gate.junitRelativePath,
497
+ });
498
+ break;
499
+ case "backend-test-classification-v1":
500
+ artifact = await materializeBackendTestClassification({
501
+ runDir: meta.runDir,
502
+ fromNodeId: gate.fromNodeId,
503
+ artifactName: gate.artifactName,
504
+ outputDir: gate.outputDir,
505
+ });
506
+ break;
507
+ case "backend-test-semantic-review-v1":
508
+ artifact = await materializeBackendTestSemanticReview({
509
+ runDir: meta.runDir,
510
+ fromNodeId: gate.fromNodeId,
511
+ artifactName: gate.artifactName,
512
+ outputDir: gate.outputDir,
331
513
  });
332
514
  break;
333
515
  case "backend-test-case-manifest-v1":
@@ -425,6 +607,32 @@ export async function executeDagShellNode(input, meta) {
425
607
  };
426
608
  }
427
609
  }
610
+ if (shell?.commands?.length === 1 &&
611
+ shell.commands[0] === "frontend-worktree-diff-gate") {
612
+ const started = Date.now();
613
+ try {
614
+ const result = await runFrontendWorktreeDiffGate({
615
+ runDir: meta.runDir,
616
+ workspaceRoot: input.cwd,
617
+ });
618
+ return {
619
+ ok: true,
620
+ stdout: formatFrontendWorktreeDiffStdout(result),
621
+ stderr: "",
622
+ failureCategory: "success",
623
+ durationMs: Date.now() - started,
624
+ };
625
+ }
626
+ catch (error) {
627
+ return {
628
+ ok: false,
629
+ stdout: "",
630
+ stderr: error instanceof Error ? error.message : String(error),
631
+ failureCategory: "invalid-output",
632
+ durationMs: Date.now() - started,
633
+ };
634
+ }
635
+ }
428
636
  if (shell?.commands?.length === 1 &&
429
637
  shell.commands[0] === "frontend-failure-assess-gate") {
430
638
  const started = Date.now();
@@ -520,9 +728,11 @@ export async function executeDagShellNode(input, meta) {
520
728
  writeGuardOk = guard.ok;
521
729
  writeGuardViolations = guard.violations;
522
730
  }
523
- catch {
731
+ catch (error) {
524
732
  writeGuardOk = false;
525
- writeGuardViolations = ["write guard failed: git status unavailable"];
733
+ writeGuardViolations = [
734
+ `git status unavailable: ${error instanceof Error ? error.message : String(error)}`,
735
+ ];
526
736
  }
527
737
  }
528
738
  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 preamble = `test -n "\${HARNESS_DAG_RUN_DIR:-}" || { echo "missing HARNESS_DAG_RUN_DIR for ${gateLabel} gate" >&2; exit 1; }; FILE="\${HARNESS_DAG_RUN_DIR}/${jsonFile}"; test -f "\${FILE}" || { echo "missing ${gateLabel} JSON output: \${FILE}" >&2; exit 1; }; FIRST=$(${parser})`;
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
- regex += '.*';
118
- i += 1;
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`);