@tea-agent/loop-agent 0.39.0-next.28 → 0.39.0-next.29

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 CHANGED
@@ -4,6 +4,10 @@
4
4
 
5
5
  ### 重点更新
6
6
 
7
+ - frontend-test `passed` 回执链改按 dispatch 序号判序(B-HASH-01):`summarizePlaywrightCliReceipts` 先按 `sequence` 排序再判 open→find→cleanup 连续链;并行 `find`/`screenshot` 按完成时间落盘导致的乱序不再误杀执行已通过的 case(Wave 1 实测 4 条 passed 被改判 `browser-command-evidence-missing`)。真 gap / 重复序号仍 fail-closed。
8
+ - `tool-policy` 失败归一为 `executor`(B-REC-03):前端 preflight 能力拒绝(缺 `@playwright/cli` JS entry、allowlist 拒绝)不再落 `unknown`→`manual-review`+`humanRequired`;`dag report` 给出 `rerun-after-fix`,Console `standaloneTaskRerun` 的 R2 gate 放行 run-failed 同 task 整单重跑,无需人工决策门或 sibling task。
9
+ - Operator Chrome Band1 模式切换入口补显式 `aria-label`(B-FIND-01,方案 A):`操作`/`观测` mode link 原为 inline-flex 裸文本节点,不参与 name-from-content,a11y snapshot 序列化为无名 link,`find` 无法断言;补 `aria-label` 后实测 `find "观测"` 1 命中、click 后 `find "运行总览"` 1 命中,AC-FE-CHROME-003 恢复可见断言路径。
10
+
7
11
  - `next-release.yml` 防重复发布守卫补充幂等语义:`mode=recover`(dist-tags.next 已指向出自本 HEAD 的 tarball)时,手动 `workflow_dispatch` 重入不再撞 duplicate publish 硬失败,改为写 summary 后干净退出(§5.1 规则 4);非 recover 的重复发布仍硬失败。
8
12
 
9
13
  - frozen repair 工具审计改为按 attempt 段扫描(B1):`session-events.jsonl` 跨 attempt 共享追加,attempt>1 的 `auditRepairAttemptToolUse` 现在只审本 attempt 字节段(newline 对齐),planner attempt 1 的 ls/find 不再被误判给 repair 轮;repair prompt 显式声明 read-only 工具契约与 allowlist 边界(B2),并内嵌 openspec-citations 输出格式正例;`LOOP_AGENT_IGNORE_STALE_DIST=1` 环境变量旁路 stale-dist guard(B3),Console spawn 的 sibling CLI 可继承。
@@ -39,6 +43,8 @@
39
43
 
40
44
  ### 改进
41
45
 
46
+ - DAG 节点检查器「执行过程」按执行器分流:Pi 仍看 session-events;有命令的 shell 展示 `shell-command-receipt` 与 stdout/stderr;`frontendBrowserToolPreflight` 等内部处理器展示步骤回执(历史 run 可从 capability / attempt 产物合成)。空态不再把 shell/static 误说成「暂无过程日志」。节点输入补充 named handler 名称。脚本仍属于节点输入,不假装已执行。
47
+ - DAG 节点检查器「节点输出」在整段内容为完整 JSON 对象或数组时改为缩进代码块展示,不再把紧凑 `JSON.stringify` 挤成一段 Markdown 段落;截断或非法 JSON、普通 Markdown 仍走原阅读器。
42
48
  - frontend DAG 的 materialized source references 恢复统一的有界 excerpt 与按需 read 指针,不再把 requirement/acceptance 文档全文复制进每个相关节点,也不再强制 contract/plan 重读全部绑定源文件,降低长需求在多节点中的重复上下文占用。
43
49
  - frontend-test prepare-package 会从 `docs/templates/`、`harness.json` `governanceRoot`/templates 以及 init 投影的 `ai_workspace/loop-agent/templates/` 拷贝标准场景;full init 目标仓不再静默落到仅含 `STD-FE-SMOKE-ENTRY` 的 fallback。
44
50
  - frontend-test 环境探测区分 connection-refused(curl 7)与 HTTP 4xx/5xx,并提示先启动本地前端后再从 probe 节点续跑;不再把所有不可达都写成同一句 `frontend-base-url-unreachable`。
@@ -82,6 +88,7 @@
82
88
 
83
89
  ### 修复
84
90
 
91
+ - backend-test 用例覆盖分析的 payload 形状提取在无 `python` 可执行文件的环境(现代 macOS 只有 `python3`)静默降级为空结果,导致 payload 观测路径全空、`PAYLOAD_SHAPE_MISMATCH` 误报;解释器解析改为 POSIX 优先 `python3`、Windows 优先 `python`(与 scenario-param 语法校验一致),仅在解释器缺失时尝试下一候选,分析输入自身的失败不再重试。
85
92
  - Inspector「执行过程」时间轴按「执行分次」选择过滤:session-events 是跨 attempt 共享追加的单文件,此前所有次执行的事件重合显示、切换顶部分次不生效;现在按 `agent_start` 边界分段,选中第 N 次执行只显示该段并标注「已按第 N 次执行过滤 · 共 M 次执行」,单次执行与 pass 选择保持全量展示,检测不到边界时显式提示并回退全量。
86
93
  - 前端 plan/revision 的 `openspec-citations` fence 提取改为行锚定正则:直接闭合的空 fence(无候选行、紧跟关闭 fence)此前被判定为「不存在 citation 块」而进入格式重试循环,现在计为合法的唯一 citation 块;重复、损坏或非唯一的 fence 仍被拒绝。
87
94
  - `npm test` 前置段失败导致后续段(io-heavy / integration)被跳过时,`scripts/run-tests.mjs` 显式输出警告与补跑命令,不再静默跳过(此前失败汇总会被误读为全量覆盖)。
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "version": "0.38.3",
4
- "gitSha": "8440f2c71f99959bd1ccd91ec4e48b3e37733d6f",
5
- "builtAt": "2026-08-22T06:33:22.850Z"
4
+ "gitSha": "a757f2e56597122ab0be09e7524038ad14a576bb",
5
+ "builtAt": "2026-08-22T10:37:15.456Z"
6
6
  }
@@ -111,6 +111,13 @@ export async function readPlaywrightCliReceipts(runDir, nodeId) {
111
111
  }
112
112
  }
113
113
  export function summarizePlaywrightCliReceipts(receipts) {
114
+ // Sequence numbers are assigned at dispatch time, which is the controller's
115
+ // causal order. Receipt lines are appended when each command COMPLETES, so
116
+ // concurrent commands (e.g. parallel find calls issued in one turn) persist
117
+ // out of sequence order. Judge chain contiguity on the dispatch order by
118
+ // sorting on `sequence` first; a genuinely missing receipt (gap) or a
119
+ // duplicated sequence still fails the contiguity check below.
120
+ const orderedReceipts = [...receipts].sort((a, b) => a.sequence - b.sequence);
114
121
  let hasSuccessfulOpen = false;
115
122
  let hasInteractionOrAssertion = false;
116
123
  let hasMeaningfulAssertion = false;
@@ -122,7 +129,7 @@ export function summarizePlaywrightCliReceipts(receipts) {
122
129
  let hasContiguousControllerSequence = true;
123
130
  let previousSequence = 0;
124
131
  const caseIds = new Set();
125
- for (const receipt of receipts) {
132
+ for (const receipt of orderedReceipts) {
126
133
  caseIds.add(receipt.caseId);
127
134
  const succeeded = receipt.exitCode === 0 && !receipt.timedOut;
128
135
  const ordered = receipt.sequence === previousSequence + 1;
@@ -2386,7 +2386,76 @@ async function executeFrontendLintBaseline(input, meta) {
2386
2386
  };
2387
2387
  }
2388
2388
  }
2389
- export async function executeDagShellNode(input, meta) {
2389
+ async function writeShellPresetProcessReceipt(input) {
2390
+ await writeDagNodeJsonArtifact(input.runDir, input.nodeId, "process-receipt.json", {
2391
+ schemaVersion: 1,
2392
+ kind: "shell-preset",
2393
+ preset: input.preset,
2394
+ ok: input.ok,
2395
+ durationMs: input.durationMs,
2396
+ steps: input.steps,
2397
+ });
2398
+ }
2399
+ async function ensureShellPresetProcessReceipt(input) {
2400
+ const receiptPath = path.join(input.runDir, input.nodeId, "process-receipt.json");
2401
+ if (existsSync(receiptPath))
2402
+ return;
2403
+ if (existsSync(path.join(input.runDir, input.nodeId, "shell-command-receipt.json"))) {
2404
+ return;
2405
+ }
2406
+ const detail = input.result.ok ? input.result.stdout : input.result.stderr;
2407
+ await writeShellPresetProcessReceipt({
2408
+ runDir: input.runDir,
2409
+ nodeId: input.nodeId,
2410
+ preset: input.preset,
2411
+ ok: input.result.ok,
2412
+ durationMs: input.result.durationMs,
2413
+ steps: [
2414
+ {
2415
+ id: "run",
2416
+ label: input.preset,
2417
+ ok: input.result.ok,
2418
+ detail: detail?.slice(0, 8 * 1024),
2419
+ durationMs: input.result.durationMs,
2420
+ },
2421
+ ],
2422
+ });
2423
+ }
2424
+ function namedShellHandler(shell) {
2425
+ if (!shell)
2426
+ return null;
2427
+ const record = shell;
2428
+ for (const key of [
2429
+ "finalWriteSetApprovalGate",
2430
+ "frontendTestStandardScenarios",
2431
+ "frontendTestEnvironmentProbe",
2432
+ "frontendBrowserToolPreflight",
2433
+ "frontendPrewriteGate",
2434
+ "frontendLintBaseline",
2435
+ "frontendVerificationBundle",
2436
+ "frontendReviewContext",
2437
+ "frontendTestCaseChecklist",
2438
+ "frontendTestCaseManifest",
2439
+ "frontendTestEvidenceValidation",
2440
+ "frontendTestResultFinalize",
2441
+ "frontendTestL5Report",
2442
+ "frontendTestHtmlReport",
2443
+ "frontendTestReports",
2444
+ "backendTestPipeline",
2445
+ "jsonArtifactGate",
2446
+ "projectGovernanceGate",
2447
+ "requirementCoverageGate",
2448
+ "verdictGate",
2449
+ "verifyEvidence",
2450
+ "repairArtifactGate",
2451
+ "jacocoCoverage",
2452
+ ]) {
2453
+ if (record[key] != null)
2454
+ return key;
2455
+ }
2456
+ return null;
2457
+ }
2458
+ async function executeDagShellNodeInner(input, meta) {
2390
2459
  const shell = input.task.shell;
2391
2460
  if (shell?.finalWriteSetApprovalGate) {
2392
2461
  const started = Date.now();
@@ -2433,28 +2502,81 @@ export async function executeDagShellNode(input, meta) {
2433
2502
  }
2434
2503
  if (shell?.frontendBrowserToolPreflight) {
2435
2504
  const started = Date.now();
2505
+ const steps = [];
2506
+ const finish = async (result) => {
2507
+ await writeShellPresetProcessReceipt({
2508
+ runDir: meta.runDir,
2509
+ nodeId: input.task.id,
2510
+ preset: "frontendBrowserToolPreflight",
2511
+ ok: result.ok,
2512
+ durationMs: result.durationMs,
2513
+ steps: steps.length > 0
2514
+ ? steps
2515
+ : [
2516
+ {
2517
+ id: "run",
2518
+ label: "浏览器工具能力探测",
2519
+ ok: result.ok,
2520
+ detail: result.ok ? result.stdout : result.stderr,
2521
+ durationMs: result.durationMs,
2522
+ },
2523
+ ],
2524
+ });
2525
+ return result;
2526
+ };
2436
2527
  try {
2437
2528
  if (resolvePiBackend() === "cli-only") {
2529
+ steps.push({
2530
+ id: "pi-backend",
2531
+ label: "Pi SDK 后端可用",
2532
+ ok: false,
2533
+ detail: "CODE_AGENT_PI_BACKEND=cli-only",
2534
+ });
2438
2535
  throw new Error("browser-command-capability-unavailable: CODE_AGENT_PI_BACKEND=cli-only rejects SDK-only playwright_cli");
2439
2536
  }
2537
+ steps.push({
2538
+ id: "pi-backend",
2539
+ label: "Pi SDK 后端可用",
2540
+ ok: true,
2541
+ });
2440
2542
  const sdkCapability = await checkPiSdkCustomToolCapability(input.cwd);
2441
2543
  if (!sdkCapability.ok) {
2544
+ steps.push({
2545
+ id: "sdk-custom-tool",
2546
+ label: "Pi SDK custom-tool 能力",
2547
+ ok: false,
2548
+ detail: sdkCapability.detail,
2549
+ });
2442
2550
  throw new Error(`browser-command-capability-unavailable: ${sdkCapability.detail}`);
2443
2551
  }
2552
+ steps.push({
2553
+ id: "sdk-custom-tool",
2554
+ label: "Pi SDK custom-tool 能力",
2555
+ ok: true,
2556
+ });
2444
2557
  const toolProbe = await createPlaywrightCliTool({
2445
2558
  repoRoot: input.cwd,
2446
2559
  runDir: meta.runDir,
2447
2560
  nodeId: input.task.id,
2448
2561
  caseId: "frontend-browser-preflight",
2449
2562
  evidenceDir: "testcase/frontend/evidence/frontend-browser-preflight",
2450
- // Capability-only registration probe. The tested URL is selected later by
2451
- // retrieve-frontend-test-context-pi and validated by the environment shell.
2452
2563
  baseUrl: "http://localhost:5173/",
2453
2564
  inputRoot: "testcase/frontend/fixtures",
2454
2565
  });
2455
2566
  if (toolProbe.name !== "playwright_cli") {
2567
+ steps.push({
2568
+ id: "structured-tool",
2569
+ label: "注册 playwright_cli",
2570
+ ok: false,
2571
+ detail: toolProbe.name,
2572
+ });
2456
2573
  throw new Error("browser-command-capability-unavailable: structured playwright_cli tool registration failed");
2457
2574
  }
2575
+ steps.push({
2576
+ id: "structured-tool",
2577
+ label: "注册 playwright_cli",
2578
+ ok: true,
2579
+ });
2458
2580
  const launcher = resolvePlaywrightCliLauncher(input.cwd);
2459
2581
  const result = await defaultPlaywrightCliCommandRunner({
2460
2582
  executable: launcher.executable,
@@ -2467,8 +2589,19 @@ export async function executeDagShellNode(input, meta) {
2467
2589
  if (result.exitCode !== 0 ||
2468
2590
  result.timedOut ||
2469
2591
  !["open", "close", "find", "snapshot", "click"].every((command) => new RegExp(`\\b${command}\\b`, "i").test(help))) {
2592
+ steps.push({
2593
+ id: "playwright-cli-help",
2594
+ label: "playwright-cli --help 合同",
2595
+ ok: false,
2596
+ detail: help.slice(0, 8 * 1024),
2597
+ });
2470
2598
  throw new Error("playwright-cli-contract-incompatible");
2471
2599
  }
2600
+ steps.push({
2601
+ id: "playwright-cli-help",
2602
+ label: "playwright-cli --help 合同",
2603
+ ok: true,
2604
+ });
2472
2605
  const capability = {
2473
2606
  schemaVersion: 1,
2474
2607
  status: "ready",
@@ -2479,22 +2612,28 @@ export async function executeDagShellNode(input, meta) {
2479
2612
  targetUrlAuthority: "retrieve-context-and-environment-probe",
2480
2613
  };
2481
2614
  await writeDagNodeJsonArtifact(meta.runDir, input.task.id, "frontend-browser-capability.json", capability);
2482
- return {
2615
+ steps.push({
2616
+ id: "capability-artifact",
2617
+ label: "写出 frontend-browser-capability.json",
2618
+ ok: true,
2619
+ detail: JSON.stringify(capability, null, 2),
2620
+ });
2621
+ return await finish({
2483
2622
  ok: true,
2484
2623
  stdout: JSON.stringify(capability),
2485
2624
  stderr: "",
2486
2625
  failureCategory: "success",
2487
2626
  durationMs: Date.now() - started,
2488
- };
2627
+ });
2489
2628
  }
2490
2629
  catch (error) {
2491
- return {
2630
+ return await finish({
2492
2631
  ok: false,
2493
2632
  stdout: "",
2494
2633
  stderr: `playwright-cli-unavailable: ${error instanceof Error ? error.message : String(error)}`,
2495
2634
  failureCategory: "tool-policy",
2496
2635
  durationMs: Date.now() - started,
2497
- };
2636
+ });
2498
2637
  }
2499
2638
  }
2500
2639
  if (shell?.frontendPrewriteGate) {
@@ -3151,3 +3290,16 @@ export async function executeDagShellNode(input, meta) {
3151
3290
  ...{ commandResults },
3152
3291
  };
3153
3292
  }
3293
+ export async function executeDagShellNode(input, meta) {
3294
+ const handler = namedShellHandler(input.task.shell);
3295
+ const result = await executeDagShellNodeInner(input, meta);
3296
+ if (handler) {
3297
+ await ensureShellPresetProcessReceipt({
3298
+ runDir: meta.runDir,
3299
+ nodeId: input.task.id,
3300
+ preset: handler,
3301
+ result,
3302
+ });
3303
+ }
3304
+ return result;
3305
+ }
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { redactSecrets, truncateUtf8Preview } from "../../shared/preview.js";
4
4
  import { resolveDagNodeSkills } from "../../workflows/dag/skills.js";
5
5
  import { resolveDagNodePromptRedacted, resolveDagRunArtifact, resolveDagRunJson, } from "./dag-run-artifacts.js";
6
+ import { namedShellHandlers } from "./shell-handler-keys.js";
6
7
  export const NODE_INPUT_SCHEMA_VERSION = 1;
7
8
  export const NODE_INPUT_PREVIEW_MAX_BYTES = 64 * 1024;
8
9
  const SHELL_GATE_KEYS = [
@@ -217,6 +218,7 @@ function projectTask(dagRunId, nodeId, lifecycle, spec, task, fingerprint, piExt
217
218
  : null,
218
219
  failFast: typeof shellRaw.failFast === "boolean" ? shellRaw.failFast : null,
219
220
  gates: shellGateNames(shellRaw),
221
+ handlers: namedShellHandlers(shellRaw),
220
222
  }
221
223
  : null;
222
224
  const staticPreview = staticRaw && typeof staticRaw.resultMarkdown === "string"
@@ -0,0 +1,377 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { redactSecrets, truncateUtf8Preview } from "../../shared/preview.js";
3
+ import { isSafeObservabilityIdentifier } from "../observability/event-store.js";
4
+ import { parseExecutionOutputKey, } from "./dag-node-execution-output.js";
5
+ import { resolveDagRunArtifact, resolveDagRunJson, } from "./dag-run-artifacts.js";
6
+ import { namedShellHandlers } from "./shell-handler-keys.js";
7
+ export const NODE_PROCESS_SCHEMA_VERSION = 1;
8
+ export const NODE_PROCESS_FIELD_MAX_BYTES = 16 * 1024;
9
+ function boundField(value) {
10
+ if (value == null || value === "")
11
+ return { text: null, truncated: false };
12
+ const redacted = redactSecrets(value);
13
+ const truncated = truncateUtf8Preview(redacted, NODE_PROCESS_FIELD_MAX_BYTES);
14
+ return {
15
+ text: truncated,
16
+ truncated: truncated !== redacted,
17
+ };
18
+ }
19
+ function asRecord(value) {
20
+ if (!value || typeof value !== "object" || Array.isArray(value))
21
+ return null;
22
+ return value;
23
+ }
24
+ function asString(value) {
25
+ return typeof value === "string" && value.trim() ? value : null;
26
+ }
27
+ function asNumber(value) {
28
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
29
+ }
30
+ function asBoolean(value) {
31
+ return value === true;
32
+ }
33
+ async function readJsonArtifact(repoRoot, dagRunId, segments) {
34
+ const resolved = resolveDagRunArtifact(repoRoot, dagRunId, segments);
35
+ if (!resolved.ok)
36
+ return null;
37
+ try {
38
+ const raw = await readFile(resolved.result.absolutePath, "utf8");
39
+ const parsed = JSON.parse(raw);
40
+ return asRecord(parsed);
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ async function readTextArtifact(repoRoot, dagRunId, segments) {
47
+ const resolved = resolveDagRunArtifact(repoRoot, dagRunId, segments);
48
+ if (!resolved.ok)
49
+ return { text: null, truncated: false };
50
+ try {
51
+ const raw = await readFile(resolved.result.absolutePath, "utf8");
52
+ return boundField(raw);
53
+ }
54
+ catch {
55
+ return { text: null, truncated: false };
56
+ }
57
+ }
58
+ function emptyBody(dagRunId, nodeId, kind, emptyReason, extras = {}) {
59
+ return {
60
+ schemaVersion: NODE_PROCESS_SCHEMA_VERSION,
61
+ dagRunId,
62
+ nodeId,
63
+ kind,
64
+ executor: extras.executor ?? null,
65
+ handler: extras.handler ?? null,
66
+ emptyReason,
67
+ key: extras.key ?? null,
68
+ commands: extras.commands ?? [],
69
+ steps: extras.steps ?? [],
70
+ };
71
+ }
72
+ function classifyKind(task) {
73
+ const executor = asString(task.executor);
74
+ const shell = asRecord(task.shell);
75
+ const handlers = namedShellHandlers(shell);
76
+ const handler = handlers[0] ?? null;
77
+ const commands = shell?.commands;
78
+ const commandsDeclared = Array.isArray(commands) &&
79
+ commands.some((item) => typeof item === "string" && item.trim());
80
+ if (executor === "static" || task.static) {
81
+ return { kind: "static", executor: executor ?? "static", handler, commandsDeclared };
82
+ }
83
+ if (executor === "shell" || shell) {
84
+ if (commandsDeclared) {
85
+ return { kind: "shell-commands", executor: executor ?? "shell", handler, commandsDeclared };
86
+ }
87
+ if (handler) {
88
+ return { kind: "shell-preset", executor: executor ?? "shell", handler, commandsDeclared };
89
+ }
90
+ return { kind: "shell-commands", executor: executor ?? "shell", handler, commandsDeclared };
91
+ }
92
+ return {
93
+ kind: "pi-session",
94
+ executor: executor ?? "pi",
95
+ handler,
96
+ commandsDeclared,
97
+ };
98
+ }
99
+ function receiptAttemptSegments(nodeId, parsed) {
100
+ if (parsed?.kind === "attempt") {
101
+ return [nodeId, `attempt-${parsed.index}`, "shell-command-receipt.json"];
102
+ }
103
+ return [nodeId, "shell-command-receipt.json"];
104
+ }
105
+ async function loadShellCommands(repoRoot, dagRunId, nodeId, parsed) {
106
+ const preferred = await readJsonArtifact(repoRoot, dagRunId, receiptAttemptSegments(nodeId, parsed));
107
+ const receipt = preferred ??
108
+ (parsed
109
+ ? await readJsonArtifact(repoRoot, dagRunId, [
110
+ nodeId,
111
+ "shell-command-receipt.json",
112
+ ])
113
+ : null);
114
+ if (!receipt)
115
+ return null;
116
+ const rawCommands = Array.isArray(receipt.commands) ? receipt.commands : [];
117
+ const commands = [];
118
+ for (let i = 0; i < rawCommands.length; i += 1) {
119
+ const entry = asRecord(rawCommands[i]) ?? {};
120
+ const stdout = await readTextArtifact(repoRoot, dagRunId, [
121
+ nodeId,
122
+ "commands",
123
+ `${i + 1}.stdout.txt`,
124
+ ]);
125
+ const stderr = await readTextArtifact(repoRoot, dagRunId, [
126
+ nodeId,
127
+ "commands",
128
+ `${i + 1}.stderr.txt`,
129
+ ]);
130
+ const commandBound = boundField(asString(entry.command) ?? "");
131
+ commands.push({
132
+ index: i + 1,
133
+ command: commandBound.text ?? "(command)",
134
+ ok: asBoolean(entry.ok),
135
+ exitCode: asNumber(entry.exitCode),
136
+ failureCategory: asString(entry.failureCategory),
137
+ startedAt: asString(entry.startedAt),
138
+ finishedAt: asString(entry.finishedAt),
139
+ durationMs: asNumber(entry.wallDurationMs) ??
140
+ asNumber(entry.executionDurationMs) ??
141
+ null,
142
+ reused: asBoolean(entry.reused),
143
+ stdout: stdout.text,
144
+ stderr: stderr.text,
145
+ truncated: commandBound.truncated || stdout.truncated || stderr.truncated,
146
+ });
147
+ }
148
+ return commands;
149
+ }
150
+ function handlerLabel(handler) {
151
+ if (handler === "frontendBrowserToolPreflight")
152
+ return "浏览器工具能力探测";
153
+ if (handler === "frontendTestStandardScenarios")
154
+ return "标准场景物化";
155
+ if (handler === "frontendTestEnvironmentProbe")
156
+ return "前端环境探测";
157
+ if (handler === "frontendTestCaseChecklist")
158
+ return "用例清单校验";
159
+ if (handler === "frontendTestCaseManifest")
160
+ return "用例清单物化";
161
+ if (handler === "frontendTestEvidenceValidation")
162
+ return "证据校验";
163
+ if (handler === "frontendTestResultFinalize")
164
+ return "结果定稿";
165
+ if (handler === "frontendTestReports" || handler === "frontendTestL5Report" || handler === "frontendTestHtmlReport") {
166
+ return "测试报告";
167
+ }
168
+ if (handler === "frontendPrewriteGate")
169
+ return "前端预写门禁";
170
+ if (handler === "finalWriteSetApprovalGate")
171
+ return "writeSet 终态批准";
172
+ return handler;
173
+ }
174
+ async function loadPresetSteps(repoRoot, dagRunId, nodeId, handler, parsed) {
175
+ const written = await readJsonArtifact(repoRoot, dagRunId, [
176
+ nodeId,
177
+ "process-receipt.json",
178
+ ]);
179
+ if (written && Array.isArray(written.steps)) {
180
+ return written.steps.flatMap((item, index) => {
181
+ const step = asRecord(item);
182
+ if (!step)
183
+ return [];
184
+ const detail = boundField(asString(step.detail));
185
+ return [
186
+ {
187
+ id: asString(step.id) ?? `step-${index + 1}`,
188
+ label: asString(step.label) ?? handlerLabel(handler ?? "preset"),
189
+ ok: asBoolean(step.ok),
190
+ detail: detail.text,
191
+ durationMs: asNumber(step.durationMs),
192
+ truncated: detail.truncated,
193
+ },
194
+ ];
195
+ });
196
+ }
197
+ const capability = await readJsonArtifact(repoRoot, dagRunId, [
198
+ nodeId,
199
+ "frontend-browser-capability.json",
200
+ ]);
201
+ if (capability && handler === "frontendBrowserToolPreflight") {
202
+ const detail = boundField(JSON.stringify(capability, null, 2));
203
+ return [
204
+ {
205
+ id: "capability",
206
+ label: handlerLabel(handler),
207
+ ok: asString(capability.status) !== "failed",
208
+ detail: detail.text,
209
+ durationMs: null,
210
+ truncated: detail.truncated,
211
+ },
212
+ ];
213
+ }
214
+ const attemptIndex = parsed?.kind === "attempt" ? parsed.index : 1;
215
+ const attempt = await readJsonArtifact(repoRoot, dagRunId, [
216
+ nodeId,
217
+ `attempt-${attemptIndex}.json`,
218
+ ]);
219
+ if (attempt) {
220
+ const ok = asBoolean(attempt.ok);
221
+ const detail = boundField(ok
222
+ ? (asString(attempt.stdout) ?? asString(attempt.assistantText))
223
+ : (asString(attempt.stderr) ?? asString(attempt.stdout)));
224
+ return [
225
+ {
226
+ id: "run",
227
+ label: handlerLabel(handler ?? "preset"),
228
+ ok,
229
+ detail: detail.text,
230
+ durationMs: asNumber(attempt.durationMs),
231
+ truncated: detail.truncated,
232
+ },
233
+ ];
234
+ }
235
+ return [];
236
+ }
237
+ export async function loadDagNodeProcess(repoRoot, dagRunId, nodeId, rawKey) {
238
+ if (!isSafeObservabilityIdentifier(dagRunId) ||
239
+ !isSafeObservabilityIdentifier(nodeId)) {
240
+ return {
241
+ status: 400,
242
+ body: { error: "Invalid dag run or node identifier", reason: "invalid-id" },
243
+ };
244
+ }
245
+ let parsedKey = null;
246
+ if (rawKey != null && String(rawKey).trim() !== "") {
247
+ const parsed = parseExecutionOutputKey(rawKey);
248
+ if (!parsed.ok) {
249
+ return {
250
+ status: 400,
251
+ body: {
252
+ error: "Invalid execution key (expected attempt-N or pass-N)",
253
+ reason: "invalid-key",
254
+ },
255
+ };
256
+ }
257
+ parsedKey = parsed;
258
+ }
259
+ const runResolved = resolveDagRunJson(repoRoot, dagRunId);
260
+ if (!runResolved.ok) {
261
+ if (runResolved.reason === "invalid-id") {
262
+ return {
263
+ status: 400,
264
+ body: { error: "Invalid dag run or node identifier", reason: "invalid-id" },
265
+ };
266
+ }
267
+ if (runResolved.reason === "ambiguous") {
268
+ return {
269
+ status: 409,
270
+ body: { error: "Ambiguous dag run lifecycle", reason: "ambiguous" },
271
+ };
272
+ }
273
+ return {
274
+ status: 404,
275
+ body: { error: "DAG run not found", reason: "not-found" },
276
+ };
277
+ }
278
+ let spec = {};
279
+ try {
280
+ const raw = JSON.parse(await readFile(runResolved.result.absolutePath, "utf8"));
281
+ spec = asRecord(raw) ?? {};
282
+ }
283
+ catch {
284
+ return {
285
+ status: 404,
286
+ body: { error: "run.json is unreadable", reason: "not-found" },
287
+ };
288
+ }
289
+ const tasks = Array.isArray(spec.tasks) ? spec.tasks : [];
290
+ const task = tasks
291
+ .map((item) => asRecord(item))
292
+ .find((item) => item && asString(item.id) === nodeId);
293
+ if (!task) {
294
+ return {
295
+ status: 200,
296
+ body: emptyBody(dagRunId, nodeId, "none", "not-executed"),
297
+ };
298
+ }
299
+ const classified = classifyKind(task);
300
+ const key = parsedKey?.key ?? null;
301
+ if (classified.kind === "pi-session") {
302
+ return {
303
+ status: 200,
304
+ body: emptyBody(dagRunId, nodeId, "pi-session", "pi-session", {
305
+ executor: classified.executor,
306
+ key,
307
+ }),
308
+ };
309
+ }
310
+ if (classified.kind === "static") {
311
+ return {
312
+ status: 200,
313
+ body: emptyBody(dagRunId, nodeId, "static", "static-no-process", {
314
+ executor: classified.executor,
315
+ key,
316
+ }),
317
+ };
318
+ }
319
+ if (classified.kind === "shell-commands" || classified.kind === "shell-preset") {
320
+ const commands = await loadShellCommands(repoRoot, dagRunId, nodeId, parsedKey);
321
+ if (commands && commands.length > 0) {
322
+ return {
323
+ status: 200,
324
+ body: {
325
+ schemaVersion: NODE_PROCESS_SCHEMA_VERSION,
326
+ dagRunId,
327
+ nodeId,
328
+ kind: "shell-commands",
329
+ executor: classified.executor,
330
+ handler: classified.handler,
331
+ emptyReason: null,
332
+ key,
333
+ commands,
334
+ steps: [],
335
+ },
336
+ };
337
+ }
338
+ if (classified.handler || classified.kind === "shell-preset") {
339
+ const steps = await loadPresetSteps(repoRoot, dagRunId, nodeId, classified.handler, parsedKey);
340
+ if (steps.length > 0) {
341
+ return {
342
+ status: 200,
343
+ body: {
344
+ schemaVersion: NODE_PROCESS_SCHEMA_VERSION,
345
+ dagRunId,
346
+ nodeId,
347
+ kind: "shell-preset",
348
+ executor: classified.executor,
349
+ handler: classified.handler,
350
+ emptyReason: null,
351
+ key,
352
+ commands: [],
353
+ steps,
354
+ },
355
+ };
356
+ }
357
+ }
358
+ return {
359
+ status: 200,
360
+ body: emptyBody(dagRunId, nodeId, classified.kind, classified.kind === "shell-preset"
361
+ ? "shell-preset-pending"
362
+ : "shell-receipt-missing", {
363
+ executor: classified.executor,
364
+ handler: classified.handler,
365
+ key,
366
+ }),
367
+ };
368
+ }
369
+ return {
370
+ status: 200,
371
+ body: emptyBody(dagRunId, nodeId, "none", "not-executed", {
372
+ executor: classified.executor,
373
+ handler: classified.handler,
374
+ key,
375
+ }),
376
+ };
377
+ }
@@ -16,6 +16,7 @@ import { isAllowedArtifactTextPath, resolveArtifactPath, resolveRepoFilePreview,
16
16
  import { extractSpecEvidence, extractSpecReadContent, } from "./spec-evidence.js";
17
17
  import { buildDagNodeInput } from "./node-input.js";
18
18
  import { loadDagNodeExecutionOutput } from "./dag-node-execution-output.js";
19
+ import { loadDagNodeProcess } from "./node-process.js";
19
20
  import { buildObserveHealthV1 } from "./health.js";
20
21
  import { buildNightJobDetail, buildNightJobsDoctor, buildNightJobsList, buildNightJobsMorning, } from "./night-jobs.js";
21
22
  const ARTIFACT_PREVIEW_MAX_BYTES = 64 * 1024;
@@ -100,6 +101,11 @@ export const ROUTES = [
100
101
  pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/execution-output$/,
101
102
  handler: handleDagNodeExecutionOutput,
102
103
  },
104
+ {
105
+ method: "GET",
106
+ pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/process$/,
107
+ handler: handleDagNodeProcess,
108
+ },
103
109
  {
104
110
  method: "GET",
105
111
  pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/spec-evidence$/,
@@ -570,6 +576,24 @@ async function handleDagNodeExecutionOutput(_req, res, match, ctx) {
570
576
  const result = await loadDagNodeExecutionOutput(ctx.repoRoot, dagRunId, nodeId, key);
571
577
  sendJson(res, result.status, result.body);
572
578
  }
579
+ async function handleDagNodeProcess(_req, res, match, ctx) {
580
+ const dagRunId = match.params.id;
581
+ const nodeId = match.params.sub;
582
+ if (!isSafeObservabilityIdentifier(dagRunId) ||
583
+ !isSafeObservabilityIdentifier(nodeId)) {
584
+ sendJson(res, 400, { error: "Invalid dag run or node identifier" });
585
+ return;
586
+ }
587
+ if (match.query.has("path") || match.query.has("artifactPath")) {
588
+ sendJson(res, 400, {
589
+ error: "Artifact path is not accepted; use optional key=attempt-N|pass-N",
590
+ reason: "path-not-allowed",
591
+ });
592
+ return;
593
+ }
594
+ const result = await loadDagNodeProcess(ctx.repoRoot, dagRunId, nodeId, match.query.get("key"));
595
+ sendJson(res, result.status, result.body);
596
+ }
573
597
  async function handleDagNodeSessionEvents(_req, res, match, ctx) {
574
598
  const dagRunId = match.params.id;
575
599
  const nodeId = match.params.sub;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Allowlist of in-process shell handlers (preset/gate flags on task.shell).
3
+ * Observe projects these names; the executor writes process receipts for them.
4
+ */
5
+ export const SHELL_NAMED_HANDLER_KEYS = [
6
+ "verdictGate",
7
+ "projectGovernanceGate",
8
+ "requirementCoverageGate",
9
+ "jsonArtifactGate",
10
+ "frontendPrewriteGate",
11
+ "frontendLintBaseline",
12
+ "frontendVerificationBundle",
13
+ "frontendReviewContext",
14
+ "frontendTestCaseChecklist",
15
+ "frontendBrowserToolPreflight",
16
+ "frontendTestStandardScenarios",
17
+ "frontendTestEnvironmentProbe",
18
+ "frontendTestEvidenceValidation",
19
+ "finalWriteSetApprovalGate",
20
+ "frontendTestL5Report",
21
+ "frontendTestHtmlReport",
22
+ "frontendTestCaseManifest",
23
+ "frontendTestResultFinalize",
24
+ "frontendTestReports",
25
+ "backendTestPipeline",
26
+ "jacocoCoverage",
27
+ "verifyEvidence",
28
+ "repairArtifactGate",
29
+ ];
30
+ export function namedShellHandlers(shell) {
31
+ if (!shell)
32
+ return [];
33
+ return SHELL_NAMED_HANDLER_KEYS.filter((key) => shell[key] != null);
34
+ }
@@ -77,6 +77,14 @@ export async function fetchDagNodeInput(runId, nodeId) {
77
77
  return fetchJsonResult(url);
78
78
  }
79
79
 
80
+ export async function fetchDagNodeProcess(runId, nodeId, key) {
81
+ const params = new URLSearchParams();
82
+ if (key) params.set("key", String(key));
83
+ const qs = params.toString();
84
+ const url = `/api/dag-runs/${encodeURIComponent(runId)}/nodes/${encodeURIComponent(nodeId)}/process${qs ? `?${qs}` : ""}`;
85
+ return fetchJsonResult(url);
86
+ }
87
+
80
88
  /**
81
89
  * Fetch a spec-evidence file preview (binding source or successful read).
82
90
  * Resolves to the parsed JSON body, or throws an Error carrying the HTTP
@@ -53,7 +53,14 @@ export const UI_TEXT = {
53
53
  inputError: "加载节点输入失败。",
54
54
  inputFrozenLabel: "冻结 DAG 节点定义",
55
55
  inputFingerprintNote: "仅保存 assembled prompt 指纹,全文未持久化",
56
- noSessionEvents: "该节点暂无过程日志",
56
+ noSessionEvents: "该 Pi 节点暂无过程日志(可能尚未执行、走 CLI 路径,或为历史 run)",
57
+ noShellProcess: "该 shell 节点尚未留下命令回执。",
58
+ noPresetProcess: "该内部处理器尚未留下过程回执。",
59
+ staticNoProcess: "静态节点没有逐步执行过程,请查看节点输出。",
60
+ processLoading: "正在加载执行过程…",
61
+ processError: "加载执行过程失败。",
62
+ shellCommandsHeading: "Shell 命令",
63
+ shellPresetHeading: "内部处理器",
57
64
  noArtifacts: "暂无产物记录。",
58
65
  noActiveCommand: "当前没有正在执行的命令。",
59
66
  noFailures: "当前没有异常 Task。",
@@ -345,3 +345,26 @@ export function formatRepoPathDisplay(repoRoot) {
345
345
  .replace(/^\/Users\/[^/]+/, "~")
346
346
  .replace(/^\/home\/[^/]+/, "~");
347
347
  }
348
+
349
+ /**
350
+ * If `text` is a complete JSON object or array, return pretty-printed JSON.
351
+ * Primitives, JSONL, truncated/invalid JSON, and markdown return null so
352
+ * callers can fall back to Markdown rendering.
353
+ */
354
+ export function prettyJsonBlock(text) {
355
+ if (typeof text !== "string") return null;
356
+ const trimmed = text.trim();
357
+ if (trimmed.length < 2) return null;
358
+ const start = trimmed[0];
359
+ const end = trimmed[trimmed.length - 1];
360
+ if (!((start === "{" && end === "}") || (start === "[" && end === "]"))) {
361
+ return null;
362
+ }
363
+ try {
364
+ const value = JSON.parse(trimmed);
365
+ if (value === null || typeof value !== "object") return null;
366
+ return JSON.stringify(value, null, 2);
367
+ } catch {
368
+ return null;
369
+ }
370
+ }
@@ -1,6 +1,10 @@
1
1
  /** Shared safe Markdown → DOM rendering for Observe UI. */
2
2
  import { el } from "./dom.js";
3
- import { parseMarkdownBlocks, markdownLinkHref } from "./format.js";
3
+ import {
4
+ parseMarkdownBlocks,
5
+ markdownLinkHref,
6
+ prettyJsonBlock,
7
+ } from "./format.js";
4
8
 
5
9
  export function appendInlineMarkdown(target, text) {
6
10
  const tokenPattern = /(`[^`]*`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\))/g;
@@ -49,6 +53,21 @@ export function appendParagraphMarkdown(target, text) {
49
53
  }
50
54
  }
51
55
 
56
+ export function renderInspectorDocument(content) {
57
+ const pretty = prettyJsonBlock(content);
58
+ if (pretty != null) {
59
+ const article = el("article", "markdown-output json-output");
60
+ const pre = document.createElement("pre");
61
+ const code = document.createElement("code");
62
+ code.dataset.language = "json";
63
+ code.textContent = pretty;
64
+ pre.appendChild(code);
65
+ article.appendChild(pre);
66
+ return article;
67
+ }
68
+ return renderMarkdown(content);
69
+ }
70
+
52
71
  export function renderMarkdown(content) {
53
72
  const article = el("article", "markdown-output");
54
73
  for (const block of parseMarkdownBlocks(content)) {
@@ -436,6 +436,10 @@ export function mountOperatorChrome(root, config = {}) {
436
436
  const a = el("a", `och-mode-btn${btn.active ? " och-on" : ""}`);
437
437
  a.href = btn.href;
438
438
  a.dataset.mode = btn.id;
439
+ // 2026-08-22: inline-flex 布局下裸文本节点不参与 name-from-content,
440
+ // a11y snapshot 序列化为无名 link;显式 aria-label 保证「操作/观测」
441
+ // 对 find/读屏稳定可断言(frontend-test passed 权威依赖可见 find)。
442
+ a.setAttribute("aria-label", btn.label);
439
443
  if (btn.active) a.setAttribute("aria-current", "page");
440
444
  a.appendChild(makeIcon(btn.id === "operate" ? "zap" : "eye"));
441
445
  a.appendChild(document.createTextNode(btn.label));
@@ -61,6 +61,12 @@ export const uiState = {
61
61
  dagNodeInputData: null,
62
62
  dagNodeInputError: null,
63
63
  dagNodeInputLoading: false,
64
+ /** Node-process request identity ("<dagRunId>:<nodeId>:<key>#<seq>"). */
65
+ dagNodeProcessIdentity: null,
66
+ dagNodeProcessRequestSeq: 0,
67
+ dagNodeProcessData: null,
68
+ dagNodeProcessError: null,
69
+ dagNodeProcessLoading: false,
64
70
  /**
65
71
  * Execution-output request identity ("<dagRunId>:<nodeId>:<key>#<seq>").
66
72
  * Stale async responses are rejected when this no longer matches.
@@ -222,6 +228,11 @@ export function makeDagNodeInputIdentity(dagRunId, nodeId) {
222
228
  return `${dagRunId}:${nodeId}#${uiState.dagNodeInputRequestSeq}`;
223
229
  }
224
230
 
231
+ export function makeDagNodeProcessIdentity(dagRunId, nodeId, key) {
232
+ uiState.dagNodeProcessRequestSeq += 1;
233
+ return `${dagRunId}:${nodeId}:${key ?? "latest"}#${uiState.dagNodeProcessRequestSeq}`;
234
+ }
235
+
225
236
  /** Build a fresh execution-output identity token (includes key). */
226
237
  export function makeDagNodeExecutionOutputIdentity(dagRunId, nodeId, key) {
227
238
  uiState.dagNodeExecutionOutputRequestSeq += 1;
@@ -2111,6 +2111,9 @@ body.is-resizing-dag-graph {
2111
2111
  background: transparent;
2112
2112
  color: inherit;
2113
2113
  }
2114
+ .markdown-output.json-output pre {
2115
+ white-space: pre;
2116
+ }
2114
2117
  .markdown-output a {
2115
2118
  color: var(--orange-active);
2116
2119
  text-decoration-color: var(--orange);
@@ -11,14 +11,15 @@ import {
11
11
  } from "../constants.js";
12
12
  import { clearNode, el } from "../dom.js";
13
13
  import { attemptFailureLabel } from "../dag-history-labels.js";
14
- import { fetchJson, fetchSpecEvidenceFile, fetchDagNodeInput } from "../api.js";
15
- import { badge, formatMs } from "../format.js";
16
- import { renderMarkdown } from "../markdown-render.js";
14
+ import { fetchJson, fetchSpecEvidenceFile, fetchDagNodeInput, fetchDagNodeProcess } from "../api.js";
15
+ import { badge, formatMs, prettyJsonBlock } from "../format.js";
16
+ import { renderMarkdown, renderInspectorDocument } from "../markdown-render.js";
17
17
  import {
18
18
  uiState,
19
19
  makeSessionEventIdentity,
20
20
  makeDagNodeInputIdentity,
21
21
  makeDagNodeExecutionOutputIdentity,
22
+ makeDagNodeProcessIdentity,
22
23
  getDagNodeExecutionOutputCache,
23
24
  setDagNodeExecutionOutputCache,
24
25
  clearDagNodeExecutionOutputCache,
@@ -68,6 +69,10 @@ function resetInspectorNodeSelection(dagRunId, nodeId) {
68
69
  uiState.dagNodeInputData = null;
69
70
  uiState.dagNodeInputError = null;
70
71
  uiState.dagNodeInputLoading = false;
72
+ uiState.dagNodeProcessData = null;
73
+ uiState.dagNodeProcessError = null;
74
+ uiState.dagNodeProcessLoading = false;
75
+ uiState.dagNodeProcessIdentity = null;
71
76
  // Drop execution-output cache/identity so node A's bodies never leak into B.
72
77
  clearDagNodeExecutionOutputCache();
73
78
  // Bump the session-event identity token so any in-flight response for the
@@ -748,6 +753,12 @@ function renderDagNodeInputDetail(content, data) {
748
753
  (shell.gates ?? []).join(", ") || "(none)",
749
754
  null,
750
755
  );
756
+ appendMetaRow(
757
+ shellDl,
758
+ "handlers",
759
+ (shell.handlers ?? []).join(", ") || "(none)",
760
+ null,
761
+ );
751
762
  section.appendChild(shellDl);
752
763
  if ((shell.commands ?? []).length > 0) {
753
764
  const list = el("ol", "node-input-command-list");
@@ -1305,7 +1316,7 @@ function appendOutputBody(content, outputText, errorText, options = null) {
1305
1316
  content.appendChild(el("p", "empty", UI_TEXT.noOutput));
1306
1317
  return;
1307
1318
  }
1308
- if (outputText) content.appendChild(renderMarkdown(outputText));
1319
+ if (outputText) content.appendChild(renderInspectorDocument(outputText));
1309
1320
  // Truncation note only for full/cached execution-output bodies (AC-003);
1310
1321
  // preview path never invents truncated/maxBytes.
1311
1322
  if (options?.truncated === true) {
@@ -1321,7 +1332,7 @@ function appendOutputBody(content, outputText, errorText, options = null) {
1321
1332
  if (errorText) {
1322
1333
  const error = el("section", "markdown-error");
1323
1334
  error.appendChild(el("p", "markdown-error-title", "错误输出"));
1324
- error.appendChild(renderMarkdown(errorText));
1335
+ error.appendChild(renderInspectorDocument(errorText));
1325
1336
  content.appendChild(error);
1326
1337
  }
1327
1338
  }
@@ -1455,6 +1466,228 @@ function repaintOutputTabBody(content, dagRunId, node) {
1455
1466
  restoreInspectorContentScroll(content, dagRunId, node.nodeId, "output");
1456
1467
  }
1457
1468
 
1469
+ function processEmptyMessage(body) {
1470
+ const reason = body?.emptyReason;
1471
+ if (reason === "static-no-process") return UI_TEXT.staticNoProcess;
1472
+ if (reason === "shell-receipt-missing") return UI_TEXT.noShellProcess;
1473
+ if (reason === "shell-preset-pending") return UI_TEXT.noPresetProcess;
1474
+ if (reason === "pi-session") return UI_TEXT.noSessionEvents;
1475
+ return UI_TEXT.noSessionEvents;
1476
+ }
1477
+
1478
+ function formatProcessBlock(text) {
1479
+ if (!text) return "";
1480
+ return prettyJsonBlock(text) ?? text;
1481
+ }
1482
+
1483
+ function appendProcessPre(parent, text) {
1484
+ if (!text) return;
1485
+ const pre = el("pre", "process-timeline-code");
1486
+ pre.textContent = formatProcessBlock(text);
1487
+ parent.appendChild(pre);
1488
+ }
1489
+
1490
+ function renderProcessCommandCard(command) {
1491
+ const failed = command.ok === false;
1492
+ const card = el(
1493
+ "div",
1494
+ `process-timeline-card process-timeline-tool-card${failed ? " is-error" : ""}`,
1495
+ );
1496
+ const details = document.createElement("details");
1497
+ details.className = "process-timeline-details";
1498
+ details.open = failed;
1499
+ const summary = el("summary", "process-timeline-summary");
1500
+ summary.appendChild(
1501
+ el("span", "process-timeline-summary-title", command.command || "command"),
1502
+ );
1503
+ summary.appendChild(
1504
+ el(
1505
+ "span",
1506
+ `process-timeline-summary-status status-${failed ? "failed" : "succeeded"}`,
1507
+ failed ? "失败" : command.reused ? "复用" : "成功",
1508
+ ),
1509
+ );
1510
+ details.appendChild(summary);
1511
+ const body = el("div", "process-timeline-card-body");
1512
+ const meta = [
1513
+ command.exitCode != null ? `exit ${command.exitCode}` : null,
1514
+ command.durationMs != null ? `耗时 ${formatMs(command.durationMs)}` : null,
1515
+ command.failureCategory ? command.failureCategory : null,
1516
+ ].filter(Boolean);
1517
+ if (meta.length > 0) {
1518
+ body.appendChild(el("div", "process-timeline-section-label", meta.join(" · ")));
1519
+ }
1520
+ if (command.stdout) {
1521
+ body.appendChild(el("div", "process-timeline-section-label", "stdout"));
1522
+ appendProcessPre(body, command.stdout);
1523
+ }
1524
+ if (command.stderr) {
1525
+ body.appendChild(el("div", "process-timeline-section-label", "stderr"));
1526
+ appendProcessPre(body, command.stderr);
1527
+ }
1528
+ if (command.truncated) {
1529
+ body.appendChild(el("p", "muted", "内容过长,已截断。"));
1530
+ }
1531
+ details.appendChild(body);
1532
+ card.appendChild(details);
1533
+ return card;
1534
+ }
1535
+
1536
+ function renderProcessStepCard(step) {
1537
+ const failed = step.ok === false;
1538
+ const card = el(
1539
+ "div",
1540
+ `process-timeline-card process-timeline-tool-card${failed ? " is-error" : ""}`,
1541
+ );
1542
+ const details = document.createElement("details");
1543
+ details.className = "process-timeline-details";
1544
+ details.open = true;
1545
+ const summary = el("summary", "process-timeline-summary");
1546
+ summary.appendChild(
1547
+ el("span", "process-timeline-summary-title", step.label || step.id || "step"),
1548
+ );
1549
+ summary.appendChild(
1550
+ el(
1551
+ "span",
1552
+ `process-timeline-summary-status status-${failed ? "failed" : "succeeded"}`,
1553
+ failed ? "失败" : "成功",
1554
+ ),
1555
+ );
1556
+ details.appendChild(summary);
1557
+ const body = el("div", "process-timeline-card-body");
1558
+ if (step.durationMs != null) {
1559
+ body.appendChild(
1560
+ el("div", "process-timeline-section-label", `耗时 ${formatMs(step.durationMs)}`),
1561
+ );
1562
+ }
1563
+ if (step.detail) appendProcessPre(body, step.detail);
1564
+ if (step.truncated) {
1565
+ body.appendChild(el("p", "muted", "内容过长,已截断。"));
1566
+ }
1567
+ details.appendChild(body);
1568
+ card.appendChild(details);
1569
+ return card;
1570
+ }
1571
+
1572
+ function renderNonPiProcess(content, body) {
1573
+ content.classList.add("process-timeline-scroll");
1574
+ if (!body || body.emptyReason || ((body.commands ?? []).length === 0 && (body.steps ?? []).length === 0)) {
1575
+ content.appendChild(el("p", "empty", processEmptyMessage(body)));
1576
+ return;
1577
+ }
1578
+ const heading =
1579
+ body.kind === "shell-preset"
1580
+ ? UI_TEXT.shellPresetHeading
1581
+ : UI_TEXT.shellCommandsHeading;
1582
+ if (body.handler) {
1583
+ content.appendChild(
1584
+ el("p", "process-timeline-note", `${heading} · ${body.handler}`),
1585
+ );
1586
+ }
1587
+ const ul = el("ul", "process-timeline process-timeline-rich");
1588
+ const items = (body.commands ?? []).length > 0 ? body.commands : body.steps ?? [];
1589
+ for (let index = 0; index < items.length; index += 1) {
1590
+ const item = items[index];
1591
+ const failed = item.ok === false;
1592
+ const li = el(
1593
+ "li",
1594
+ `process-timeline-item process-timeline-tool-end${failed ? " is-error" : ""}${index === 0 ? " is-first" : ""}${index === items.length - 1 ? " is-last" : ""}`,
1595
+ );
1596
+ const rail = el("span", "process-timeline-rail");
1597
+ rail.appendChild(el("span", "process-timeline-dot"));
1598
+ li.appendChild(rail);
1599
+ const cardBody = el("div", "process-timeline-body");
1600
+ cardBody.appendChild(
1601
+ body.kind === "shell-preset"
1602
+ ? renderProcessStepCard(item)
1603
+ : renderProcessCommandCard(item),
1604
+ );
1605
+ li.appendChild(cardBody);
1606
+ ul.appendChild(li);
1607
+ }
1608
+ content.appendChild(ul);
1609
+ }
1610
+
1611
+ export async function mergeNodeProcess(dagRunId, node) {
1612
+ if (!dagRunId || !node?.nodeId) return;
1613
+ const nodeId = node.nodeId;
1614
+ const key = effectiveExecutionKey(dagRunId, node);
1615
+ const identity = makeDagNodeProcessIdentity(dagRunId, nodeId, key);
1616
+ uiState.dagNodeProcessIdentity = identity;
1617
+ uiState.dagNodeProcessLoading = true;
1618
+ const result = await fetchDagNodeProcess(dagRunId, nodeId, key);
1619
+ if (uiState.dagNodeProcessIdentity !== identity) return;
1620
+ if (!result.ok || !result.body) {
1621
+ uiState.dagNodeProcessError =
1622
+ result.body?.error || UI_TEXT.processError;
1623
+ uiState.dagNodeProcessData = {
1624
+ schemaVersion: 1,
1625
+ dagRunId,
1626
+ nodeId,
1627
+ kind: "none",
1628
+ executor: node.executor ?? null,
1629
+ handler: null,
1630
+ emptyReason: "not-executed",
1631
+ key: key ?? null,
1632
+ commands: [],
1633
+ steps: [],
1634
+ };
1635
+ uiState.dagNodeProcessLoading = false;
1636
+ return;
1637
+ }
1638
+ uiState.dagNodeProcessError = null;
1639
+ uiState.dagNodeProcessData = result.body;
1640
+ uiState.dagNodeProcessLoading = false;
1641
+ }
1642
+
1643
+ function renderExecutorAwareTimeline(content, dagRunId, node) {
1644
+ content.dataset.dagRunId = String(dagRunId ?? "");
1645
+ content.dataset.nodeId = String(node.nodeId ?? "");
1646
+ const key = effectiveExecutionKey(dagRunId, node);
1647
+ const cached = uiState.dagNodeProcessData;
1648
+ const sameNode =
1649
+ cached &&
1650
+ cached.dagRunId === dagRunId &&
1651
+ cached.nodeId === node.nodeId &&
1652
+ (cached.key ?? null) === (key ?? null);
1653
+ if (sameNode && uiState.dagNodeProcessError) {
1654
+ content.appendChild(el("p", "empty", uiState.dagNodeProcessError));
1655
+ return;
1656
+ }
1657
+ if (sameNode && cached.kind === "pi-session") {
1658
+ renderSessionTimeline(content, node.nodeId, {
1659
+ executionKey: effectiveExecutionKey(dagRunId, node),
1660
+ });
1661
+ return;
1662
+ }
1663
+ if (sameNode && cached.kind !== "pi-session") {
1664
+ renderNonPiProcess(content, cached);
1665
+ return;
1666
+ }
1667
+ if ((node.executor ?? "pi") === "pi") {
1668
+ renderSessionTimeline(content, node.nodeId, {
1669
+ executionKey: effectiveExecutionKey(dagRunId, node),
1670
+ });
1671
+ return;
1672
+ }
1673
+ if (uiState.dagNodeProcessError && sameNode) {
1674
+ content.appendChild(el("p", "empty", uiState.dagNodeProcessError));
1675
+ return;
1676
+ }
1677
+ const loading = el("p", "muted", UI_TEXT.processLoading);
1678
+ loading.setAttribute("role", "status");
1679
+ content.appendChild(loading);
1680
+ void mergeNodeProcess(dagRunId, node).then(() => {
1681
+ if (
1682
+ uiState.dagInspectorTab === "timeline" &&
1683
+ uiState.selectedDagNodeId === node.nodeId &&
1684
+ uiState.currentDagRunId === dagRunId
1685
+ ) {
1686
+ renderDagInspectorForCurrent();
1687
+ }
1688
+ });
1689
+ }
1690
+
1458
1691
  function fillDagInspectorContent(content, dagRunId, node) {
1459
1692
  const previousRenderedNode = content.dataset.dagNodeId ?? "";
1460
1693
  const previousRenderedRun = content.dataset.dagRunId ?? "";
@@ -1504,9 +1737,7 @@ function fillDagInspectorContent(content, dagRunId, node) {
1504
1737
  } else if (activeTab === "timeline") {
1505
1738
  const attemptBuilt = buildAttemptBar(dagRunId, node);
1506
1739
  if (attemptBuilt?.bar) content.appendChild(attemptBuilt.bar);
1507
- renderSessionTimeline(content, node.nodeId, {
1508
- executionKey: effectiveExecutionKey(dagRunId, node),
1509
- });
1740
+ renderExecutorAwareTimeline(content, dagRunId, node);
1510
1741
  } else if (activeTab === "input") {
1511
1742
  void renderDagNodeInput(content, dagRunId, node);
1512
1743
  } else {
@@ -1659,7 +1890,14 @@ function dagInspectorSignature(dagRunId, node) {
1659
1890
  uiState.dagNodeExecutionOutputLoading ? "loading" : "idle",
1660
1891
  ]
1661
1892
  : tab === "timeline"
1662
- ? [uiState.sessionEventOffset]
1893
+ ? [
1894
+ uiState.sessionEventOffset,
1895
+ uiState.dagNodeProcessData?.kind ?? "",
1896
+ uiState.dagNodeProcessData?.emptyReason ?? "",
1897
+ (uiState.dagNodeProcessData?.commands ?? []).length,
1898
+ (uiState.dagNodeProcessData?.steps ?? []).length,
1899
+ uiState.dagNodeProcessLoading ? "loading" : "idle",
1900
+ ]
1663
1901
  : [node.status ?? ""];
1664
1902
  // Attempt dimension: selection identity + history shape. Poll refreshes keep
1665
1903
  // the DOM while selection/history are unchanged; switching attempt or a
@@ -90,6 +90,7 @@ import {
90
90
  closeDagInspector,
91
91
  selectDagNode,
92
92
  renderDagInspector,
93
+ mergeNodeProcess,
93
94
  } from "./dag-inspector.js";
94
95
  import { mergeSessionEvents } from "./session-timeline.js";
95
96
 
@@ -451,13 +452,19 @@ export async function renderDagDetail(dagRunId, initial = true) {
451
452
  nodesEl.scrollLeft = nodesScrollLeft;
452
453
 
453
454
  if (uiState.dagInspectorOpen && uiState.selectedDagNodeId) {
455
+ const selectedNode = nodes.find(
456
+ (node) => node.nodeId === uiState.selectedDagNodeId,
457
+ );
454
458
  if (!uiState.sessionEventIdentity) {
455
459
  uiState.sessionEventIdentity = makeSessionEventIdentity(
456
460
  dagRunId,
457
461
  uiState.selectedDagNodeId,
458
462
  );
459
463
  }
460
- await mergeSessionEvents(dagRunId, uiState.selectedDagNodeId);
464
+ await Promise.all([
465
+ mergeSessionEvents(dagRunId, uiState.selectedDagNodeId),
466
+ selectedNode ? mergeNodeProcess(dagRunId, selectedNode) : Promise.resolve(),
467
+ ]);
461
468
  }
462
469
  renderDagInspector(
463
470
  dagRunId,
@@ -1500,8 +1500,26 @@ print(json.dumps(out,ensure_ascii=True))
1500
1500
  // Windows Python may expose stdin as GBK; decoding generated Chinese/emoji
1501
1501
  // source through that locale can create surrogateescape code points and make
1502
1502
  // ast.parse fail with "surrogates not allowed".
1503
- const result = spawnSync("python", ["-c", program], { input: Buffer.from(source, "utf8"), encoding: "utf8", maxBuffer: 4 * 1024 * 1024, windowsHide: true });
1504
- if (result.status !== 0 || !result.stdout.trim())
1503
+ // POSIX images usually expose only `python3` (modern macOS has no `python`);
1504
+ // Windows often has a real `python` plus a Microsoft Store `python3` stub.
1505
+ // A missing interpreter must not silently degrade to empty payload shapes.
1506
+ const interpreterCandidates = process.platform === "win32" ? ["python", "python3"] : ["python3", "python"];
1507
+ let result;
1508
+ for (const interpreter of interpreterCandidates) {
1509
+ const attempt = spawnSync(interpreter, ["-c", program], {
1510
+ input: Buffer.from(source, "utf8"),
1511
+ encoding: "utf8",
1512
+ maxBuffer: 4 * 1024 * 1024,
1513
+ windowsHide: true,
1514
+ });
1515
+ // Interpreter missing/unspawnable → try the next candidate. A spawned
1516
+ // interpreter that exits nonzero analyzed real input; do not retry then.
1517
+ if (attempt.error)
1518
+ continue;
1519
+ result = attempt;
1520
+ break;
1521
+ }
1522
+ if (!result || result.status !== 0 || !result.stdout.trim())
1505
1523
  return new Map();
1506
1524
  try {
1507
1525
  const parsed = JSON.parse(result.stdout);
@@ -45,6 +45,13 @@ const RAW_TO_NORMALIZED = {
45
45
  quota: "executor",
46
46
  "rate-limit": "executor",
47
47
  unavailable: "executor",
48
+ // Deterministic capability/policy refusal: missing verified launcher JS
49
+ // entry, capability allowlist rejection, or missing bounded write tool
50
+ // profile. These are environment/config defects the operator repairs
51
+ // before rerunning; classify as executor so recovery recommends
52
+ // rerun-after-fix WITHOUT forcing a human-decision gate on
53
+ // standaloneTaskRerun (run-failed same-task rerun).
54
+ "tool-policy": "executor",
48
55
  unknown: "unknown",
49
56
  };
50
57
  function isFinishedStatus(status) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.39.0-next.28",
3
+ "version": "0.39.0-next.29",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",