@tea-agent/loop-agent 0.30.0 → 0.31.0

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 (29) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/executors/dag-pi-executor.js +3 -2
  3. package/dist/worker/observability/dag-execution-trajectory.js +591 -0
  4. package/dist/worker/observability/read-model.js +258 -31
  5. package/dist/worker/observe/dag-node-execution-output.js +180 -0
  6. package/dist/worker/observe/routes.js +53 -6
  7. package/dist/worker/observe/static/dag-edge-routing.js +368 -0
  8. package/dist/worker/observe/static/dag-history-labels.js +95 -0
  9. package/dist/worker/observe/static/dag-layout.d.ts +12 -7
  10. package/dist/worker/observe/static/dag-layout.js +101 -21
  11. package/dist/worker/observe/static/favicon.svg +37 -0
  12. package/dist/worker/observe/static/format.js +31 -1
  13. package/dist/worker/observe/static/index.html +1 -1
  14. package/dist/worker/observe/static/state.js +102 -0
  15. package/dist/worker/observe/static/styles.css +267 -7
  16. package/dist/worker/observe/static/views/dag-graph.js +414 -154
  17. package/dist/worker/observe/static/views/dag-inspector.js +478 -27
  18. package/dist/worker/observe/static/views/dag-trajectory.js +313 -0
  19. package/dist/worker/observe/static/views/dag.js +20 -3
  20. package/dist/workflows/dag/init-hybrid.js +2 -2
  21. package/docs/architecture/README.md +4 -0
  22. package/docs/architecture/dag-execution.md +1 -1
  23. package/docs/architecture/worker-and-feature.md +1 -1
  24. package/docs/governance/README.md +3 -0
  25. package/docs/operations/README.md +1 -0
  26. package/docs/templates/frontend-test-dag.json +1 -1
  27. package/harness.json +3 -3
  28. package/package.json +3 -2
  29. package/scripts/kb-bootstrap-init-skeleton.sh +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.31.0] - 2026-08-08
6
+
7
+ ### 重点更新
8
+
9
+ - Observe 看板新增 DAG 执行轨迹视图,支持按初始执行、修复轮次和最终收敛分车道展开真实的调度执行过程
10
+ - DAG 节点新增轻量执行历史摘要,在逻辑图卡片、节点表和 Inspector 中直观展示多次尝试与恢复记录
11
+ - 统一执行轨迹与 Inspector 的业务文案为自然中文映射,主界面不再显示难以理解的裸英文状态标识
12
+ - 优化 DAG 拓扑依赖边的路由算法,实现结构化避障正交路由,并恢复平滑的视觉线条样式
13
+
14
+ ### 新增
15
+
16
+ - Observe DAG 详情新增执行轨迹视图,默认展示逻辑拓扑,支持键盘切换,点击轨迹卡片可联动 Inspector
17
+ - 为 DAG 节点投影并展示轻量执行历史摘要,包含执行次数叙事及 Inspector 分次选择器
18
+ - 新增纯函数模块统一执行轨迹与 Inspector 的业务文案中文映射,规范展示失败原因与状态转换
19
+ - Console 与 Observe 共享统一的品牌 favicon,并支持通过同步脚本保持资源一致
20
+
21
+ ### 改进
22
+
23
+ - DAG 拓扑依赖边采用结构化避障正交路由,支持长边避障与控制回环独立展示,提升复杂拓扑的可读性
24
+ - 恢复相邻层级普通依赖边的平滑视觉样式,控制回环标签视觉更轻量,避免掩盖主链路
25
+ - DAG 详情响应内嵌有界的执行轨迹投影,在历史证据缺失时安全降级并保持逻辑拓扑始终可用
26
+ - 主 UI 业务术语优化,图卡与工具栏摘要改用自然的中文轮次描述,原始内部编号仅保留在诊断提示中
27
+
28
+ ### 修复
29
+
30
+ - 修复 Console 与 Observe 未共享 favicon 的问题,现统一使用同一品牌资源并兼容嵌入模式
31
+ - 修复 Observe 逻辑拓扑中同一行 DAG 边未能保持水平的问题,确保同逻辑行混合高度边正确展示
32
+ - 修复执行轨迹车道归类错误,修正收敛开启时节点错误归入最终收敛道的问题,确保各节点按真实位置归类
33
+ - 恢复 DAG Pi writer 节点的 bash 工具权限,同时保持只读节点无 bash 且 edit/write 仍受安全策略约束
34
+
5
35
  ## [0.30.0] - 2026-08-08
6
36
 
7
37
  ### 重点更新
@@ -12,7 +12,7 @@ import { isWriterEmptyDiffRetryCandidate, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, W
12
12
  import { assessBackendTestMdPlanCompleteness, assessBackendTestMdWriterCompleteness, assessBackendTestPytestPlanCompleteness, assessBackendTestPytestWriterCompleteness, assessBackendTestShardChildCompleteness, backendTestWriterProgressRoleForTask, classifyBackendTestWriterCompletenessFailure, isBackendTestCompletenessRetryCandidate, isBackendTestMdPlanTask, isBackendTestPytestPlanTask, isBackendTestShardChildTask, writeBackendTestWriterProgressArtifacts, } from "../workflows/dag/backend-test-writer-completeness.js";
13
13
  import { redactSecrets, truncateUtf8Preview } from "../shared/preview.js";
14
14
  export const DAG_PI_READONLY_TOOLS = ["read", "grep", "find", "ls"];
15
- /** Bounded writer tools: no unrestricted bash; edit/write are policy-wrapped via SDK customTools. */
15
+ /** Bounded writer tools include bash; edit/write remain policy-wrapped via SDK customTools. */
16
16
  export const DAG_PI_WRITE_TOOLS = [
17
17
  "read",
18
18
  "edit",
@@ -20,6 +20,7 @@ export const DAG_PI_WRITE_TOOLS = [
20
20
  "grep",
21
21
  "find",
22
22
  "ls",
23
+ "bash",
23
24
  ];
24
25
  export const DEFAULT_DAG_PI_PROVIDER = "wizard-local";
25
26
  const WRITER_OUTCOME_PROTOCOL_LINE = "IMPLEMENTATION_OUTCOME:";
@@ -132,7 +133,7 @@ export function resolveDagPiToolNames(task) {
132
133
  return [...DAG_PI_READONLY_TOOLS];
133
134
  const tools = [...DAG_PI_WRITE_TOOLS];
134
135
  // Pi SDK activates custom tools only when they are in this explicit list.
135
- // Do not add a command capability to ordinary writers or read-only nodes.
136
+ // Command capabilities (playwright_cli) stay capability-gated; read-only nodes never get command tools.
136
137
  if (dagCommandPolicyAllows(task.commandPolicy, "playwright-cli")) {
137
138
  tools.push("playwright_cli");
138
139
  }
@@ -0,0 +1,591 @@
1
+ /**
2
+ * Bounded execution-trajectory projection (Observe P1, AC-004/AC-006).
3
+ *
4
+ * Read-only: derives a compact storyboard of real scheduler executions
5
+ * (initial execution + convergence passes + final converged state) from
6
+ * run-owned state facts. The projection never embeds full stdout/stderr/
7
+ * assistant text or session messages — the detail API stays compact.
8
+ *
9
+ * Fact rules:
10
+ * - Archived `convergence.passHistory[].artifactRefs` project one occurrence
11
+ * per executed node (pass 1 = 初始执行, later passes = 修复轮次 N).
12
+ * - `convergence.supersededFailures` mark the matching pass occurrence as
13
+ * recovered/superseded while preserving the original ERROR status fact.
14
+ * - The convergence segment [segmentMinRank, segmentMaxRank] is derived from
15
+ * archived convergence facts: chain node ids observed in passHistory refs /
16
+ * supersededFailures, projected through state ranks. Live `state.nodes` are
17
+ * classified by rank vs the segment: rank < segmentMin → base (@base),
18
+ * rank > segmentMax → final (@pass-<effectiveCurrentPass>), in-segment →
19
+ * projected by effectiveCurrentPass (pass=1 → base, pass>=2 →
20
+ * convergence-pass). Live occurrences dedupe against the archived
21
+ * occurrence for the same node+projected pass: only lightweight attempt
22
+ * metadata (attempts/attemptCount, attemptRecovered, and missing
23
+ * failureCategory/durationMs) is merged; the archived
24
+ * status/semanticVerdict/recovery facts stay authoritative.
25
+ * - When the segment boundary is not derivable (no passHistory /
26
+ * supersededFailures chain node ranks) or an individual live node lacks a
27
+ * rank fact, the projection degrades to base classification with a
28
+ * deterministic Chinese warning — live nodes are never faked as final.
29
+ * - `availability=partial` when an archived artifactRef lacks preserved path
30
+ * facts (no disk existence probing per snapshot — evidence gap AC-006).
31
+ */
32
+ import { readFile, stat } from "node:fs/promises";
33
+ import { resolveDagRunArtifact } from "../observe/dag-run-artifacts.js";
34
+ /** Hard cap on state.json size for a single trajectory read (bounded API). */
35
+ const TRAJECTORY_STATE_MAX_BYTES = 32 * 1024 * 1024;
36
+ const TRAJECTORY_MAX_OCCURRENCES = 300;
37
+ const TRAJECTORY_MAX_WARNINGS = 20;
38
+ /** Statuses that count as a real scheduler execution (D3: no PENDING/skip). */
39
+ const EXECUTED_STATUSES = new Set([
40
+ "running",
41
+ "started",
42
+ "paused",
43
+ "finished",
44
+ "completed",
45
+ "succeeded",
46
+ "done",
47
+ "error",
48
+ "failed",
49
+ "partial_failed",
50
+ "partial-failed",
51
+ "superseded",
52
+ ]);
53
+ const FAILURE_STATUSES = new Set([
54
+ "error",
55
+ "failed",
56
+ "partial_failed",
57
+ "partial-failed",
58
+ "interrupted",
59
+ "superseded",
60
+ ]);
61
+ const REVIEW_NODE_IDS = new Set([
62
+ "initial-review-pi",
63
+ "initial-review-gate-shell",
64
+ "initial-review-pass-shell",
65
+ "review-pi",
66
+ "review-gate-shell",
67
+ "review-verdict-recovery-pi",
68
+ ]);
69
+ function readString(value, key) {
70
+ if (!value || typeof value !== "object" || Array.isArray(value))
71
+ return undefined;
72
+ const raw = value[key];
73
+ return typeof raw === "string" && raw ? raw : undefined;
74
+ }
75
+ function readNumber(value, key) {
76
+ if (!value || typeof value !== "object" || Array.isArray(value))
77
+ return undefined;
78
+ const raw = value[key];
79
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined;
80
+ }
81
+ function readBoolean(value, key) {
82
+ if (!value || typeof value !== "object" || Array.isArray(value))
83
+ return undefined;
84
+ const raw = value[key];
85
+ return typeof raw === "boolean" ? raw : undefined;
86
+ }
87
+ function readArray(value, key) {
88
+ if (!value || typeof value !== "object" || Array.isArray(value))
89
+ return [];
90
+ const raw = value[key];
91
+ return Array.isArray(raw) ? raw : [];
92
+ }
93
+ function parsePassRecord(raw) {
94
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
95
+ return null;
96
+ const record = raw;
97
+ const pass = readNumber(record, "pass");
98
+ if (pass === undefined || !Number.isFinite(pass) || pass < 1)
99
+ return null;
100
+ const refs = [];
101
+ for (const refRaw of readArray(record, "artifactRefs")) {
102
+ if (!refRaw || typeof refRaw !== "object" || Array.isArray(refRaw))
103
+ continue;
104
+ const ref = refRaw;
105
+ const nodeId = readString(ref, "nodeId");
106
+ if (!nodeId)
107
+ continue;
108
+ refs.push({
109
+ nodeId,
110
+ status: readString(ref, "status"),
111
+ failureCategory: readString(ref, "failureCategory"),
112
+ preservedNodeRecordPath: readString(ref, "preservedNodeRecordPath"),
113
+ preservedNodeDir: readString(ref, "preservedNodeDir"),
114
+ });
115
+ }
116
+ return {
117
+ pass,
118
+ reason: readString(record, "reason"),
119
+ reviewVerdict: readString(record, "reviewVerdict"),
120
+ hardVerifyStatus: readString(record, "hardVerifyStatus"),
121
+ artifactRefs: refs,
122
+ };
123
+ }
124
+ function parseSupersededFailure(raw) {
125
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
126
+ return {};
127
+ const record = raw;
128
+ const supersededRaw = readArray(record, "supersededBy");
129
+ return {
130
+ nodeId: readString(record, "nodeId"),
131
+ pass: readNumber(record, "pass"),
132
+ supersededBy: supersededRaw
133
+ .filter((entry) => typeof entry === "string")
134
+ .slice(0, TRAJECTORY_MAX_WARNINGS),
135
+ reason: readString(record, "reason"),
136
+ };
137
+ }
138
+ function refSemanticVerdict(nodeId, refStatus, passRecord) {
139
+ const normalized = (refStatus ?? "").toLowerCase();
140
+ if (FAILURE_STATUSES.has(normalized))
141
+ return "失败";
142
+ if (REVIEW_NODE_IDS.has(nodeId)) {
143
+ if (passRecord.reviewVerdict === "request-revision")
144
+ return "request-revision";
145
+ if (passRecord.reviewVerdict === "pass")
146
+ return "pass";
147
+ }
148
+ // hard-verify 失败语义判定只投影到 hard-verify-shell:仅当该 ref 自身技术
149
+ // status 非失败(FINISHED)而 pass 级 hardVerifyStatus 命中失败状态时附加;
150
+ // process-supervisor-pi / process-gate-shell 即使技术 FINISHED 也不再携带
151
+ // 该 verdict(只展示自身技术状态,AC-001/AC-002)。
152
+ if (nodeId === "hard-verify-shell" &&
153
+ passRecord.hardVerifyStatus &&
154
+ FAILURE_STATUSES.has(passRecord.hardVerifyStatus.toLowerCase())) {
155
+ return "hard-verify 失败";
156
+ }
157
+ return undefined;
158
+ }
159
+ function isExecutedStatus(status) {
160
+ return Boolean(status && EXECUTED_STATUSES.has(status.toLowerCase()));
161
+ }
162
+ /**
163
+ * Project the bounded execution trajectory from run state facts. Pure and
164
+ * deterministic; never throws on legacy or malformed shapes.
165
+ */
166
+ export function projectDagExecutionTrajectory(state) {
167
+ const dagRunId = state.runId ?? "";
168
+ const occurrences = [];
169
+ const warnings = [];
170
+ const pushWarning = (message) => {
171
+ if (warnings.length < TRAJECTORY_MAX_WARNINGS)
172
+ warnings.push(message);
173
+ };
174
+ const convergence = state.convergence;
175
+ const convergenceEnabled = Boolean(readBoolean(convergence, "enabled"));
176
+ const currentPassRaw = readNumber(convergence, "currentPass");
177
+ const currentPass = currentPassRaw !== undefined && currentPassRaw >= 1
178
+ ? Math.floor(currentPassRaw)
179
+ : undefined;
180
+ const passRecords = [];
181
+ for (const raw of readArray(convergence, "passHistory")) {
182
+ const record = parsePassRecord(raw);
183
+ if (record)
184
+ passRecords.push(record);
185
+ }
186
+ passRecords.sort((a, b) => a.pass - b.pass);
187
+ const maxArchivedPass = passRecords.length > 0 ? passRecords[passRecords.length - 1].pass : 0;
188
+ const effectiveCurrentPass = currentPass ?? (convergenceEnabled ? Math.max(1, maxArchivedPass) : 0);
189
+ // Rank index for deterministic within-lane ordering of live nodes.
190
+ const rankIndex = new Map();
191
+ (state.ranks ?? []).forEach((rank, index) => {
192
+ if (!Array.isArray(rank))
193
+ return;
194
+ for (const nodeId of rank) {
195
+ if (typeof nodeId === "string" && !rankIndex.has(nodeId)) {
196
+ rankIndex.set(nodeId, index);
197
+ }
198
+ }
199
+ });
200
+ // Convergence segment boundary, derived from archived convergence facts:
201
+ // chain node ids observed in passHistory refs / supersededFailures whose
202
+ // rank is known. Live nodes below segmentMin → base, above segmentMax →
203
+ // final, inside → projected by effectiveCurrentPass.
204
+ const chainNodeIds = new Set();
205
+ for (const passRecord of passRecords) {
206
+ for (const ref of passRecord.artifactRefs) {
207
+ if (ref.nodeId)
208
+ chainNodeIds.add(ref.nodeId);
209
+ }
210
+ }
211
+ for (const raw of readArray(convergence, "supersededFailures")) {
212
+ const sup = parseSupersededFailure(raw);
213
+ if (sup.nodeId)
214
+ chainNodeIds.add(sup.nodeId);
215
+ }
216
+ let segmentMinRank;
217
+ let segmentMaxRank;
218
+ for (const nodeId of chainNodeIds) {
219
+ const rank = rankIndex.get(nodeId);
220
+ if (rank === undefined)
221
+ continue;
222
+ segmentMinRank =
223
+ segmentMinRank === undefined
224
+ ? rank
225
+ : Math.min(segmentMinRank, rank);
226
+ segmentMaxRank =
227
+ segmentMaxRank === undefined
228
+ ? rank
229
+ : Math.max(segmentMaxRank, rank);
230
+ }
231
+ const segmentDerivable = convergenceEnabled &&
232
+ chainNodeIds.size > 0 &&
233
+ segmentMinRank !== undefined &&
234
+ segmentMaxRank !== undefined;
235
+ if (convergenceEnabled && !segmentDerivable) {
236
+ pushWarning("convergence 段边界不可推导(缺少 passHistory/supersededFailures 链节点 rank),live 节点按初始执行归类");
237
+ }
238
+ // 1) Archived convergence pass occurrences.
239
+ for (const passRecord of passRecords) {
240
+ const pass = passRecord.pass;
241
+ const scope = pass === 1 ? "base" : "convergence-pass";
242
+ passRecord.artifactRefs.forEach((ref, index) => {
243
+ if (!isExecutedStatus(ref.status))
244
+ return;
245
+ const preserved = Boolean(ref.preservedNodeRecordPath || ref.preservedNodeDir);
246
+ const occurrence = {
247
+ executionId: `${ref.nodeId}@pass-${pass}`,
248
+ nodeId: ref.nodeId,
249
+ pass,
250
+ scope,
251
+ sequence: index,
252
+ status: ref.status ?? "UNKNOWN",
253
+ availability: preserved ? "complete" : "partial",
254
+ ...(ref.failureCategory
255
+ ? { failureCategory: ref.failureCategory }
256
+ : {}),
257
+ ...(() => {
258
+ const verdict = refSemanticVerdict(ref.nodeId, ref.status, passRecord);
259
+ return verdict ? { semanticVerdict: verdict } : {};
260
+ })(),
261
+ ...(passRecord.reason ? { nextReason: passRecord.reason } : {}),
262
+ };
263
+ if (!preserved) {
264
+ pushWarning(`pass ${pass} 节点 ${ref.nodeId} 历史证据不完整(缺少 preserved 路径事实)`);
265
+ }
266
+ occurrences.push(occurrence);
267
+ if (occurrences.length >= TRAJECTORY_MAX_OCCURRENCES) {
268
+ pushWarning(`执行轨迹已截断至 ${TRAJECTORY_MAX_OCCURRENCES} 条 occurrence(容量上限)`);
269
+ return;
270
+ }
271
+ });
272
+ if (occurrences.length >= TRAJECTORY_MAX_OCCURRENCES) {
273
+ pushWarning(`执行轨迹已截断至 ${TRAJECTORY_MAX_OCCURRENCES} 条 occurrence(容量上限)`);
274
+ break;
275
+ }
276
+ }
277
+ // 2) Superseded historical failures: mark matching occurrences; preserve
278
+ // the ERROR fact even when the archived artifactRef is missing.
279
+ for (const raw of readArray(convergence, "supersededFailures")) {
280
+ const sup = parseSupersededFailure(raw);
281
+ if (!sup.nodeId || sup.pass === undefined || sup.pass < 1)
282
+ continue;
283
+ const match = occurrences.find((o) => o.nodeId === sup.nodeId && o.pass === sup.pass);
284
+ if (match) {
285
+ match.recovered = true;
286
+ match.supersededBy = sup.supersededBy;
287
+ continue;
288
+ }
289
+ occurrences.push({
290
+ executionId: `${sup.nodeId}@pass-${sup.pass}`,
291
+ nodeId: sup.nodeId,
292
+ pass: sup.pass,
293
+ scope: sup.pass === 1 ? "base" : "convergence-pass",
294
+ sequence: 0,
295
+ status: "ERROR",
296
+ recovered: true,
297
+ supersededBy: sup.supersededBy,
298
+ availability: "partial",
299
+ ...(sup.reason ? { semanticVerdict: sup.reason } : {}),
300
+ });
301
+ pushWarning(`节点 ${sup.nodeId} pass ${sup.pass} 历史失败已覆盖,但归档 evidence 不完整`);
302
+ if (occurrences.length >= TRAJECTORY_MAX_OCCURRENCES) {
303
+ pushWarning(`执行轨迹已截断至 ${TRAJECTORY_MAX_OCCURRENCES} 条 occurrence(容量上限)`);
304
+ break;
305
+ }
306
+ }
307
+ // 3) Live node records → lane by rank vs the convergence segment:
308
+ // rank < segmentMin → base (@base), in-segment → projected by
309
+ // effectiveCurrentPass (pass=1 → base, pass>=2 → convergence-pass),
310
+ // rank > segmentMax → final (@pass-<effectiveCurrentPass>). Degradation:
311
+ // convergence enabled without a derivable segment boundary or without a
312
+ // node rank fact degrades to base classification (never faked final).
313
+ // Attempt-recovered nodes keep their recovered flag.
314
+ const nodes = state.nodes ?? {};
315
+ const liveEntries = Object.entries(nodes).filter(([, raw]) => raw && typeof raw === "object" && !Array.isArray(raw));
316
+ liveEntries.sort((a, b) => {
317
+ const ra = rankIndex.get(a[0]) ?? Number.MAX_SAFE_INTEGER;
318
+ const rb = rankIndex.get(b[0]) ?? Number.MAX_SAFE_INTEGER;
319
+ if (ra !== rb)
320
+ return ra - rb;
321
+ return a[0].localeCompare(b[0]);
322
+ });
323
+ const projectedPass = convergenceEnabled
324
+ ? Math.max(1, effectiveCurrentPass ?? 1)
325
+ : 1;
326
+ for (const [nodeId, raw] of liveEntries) {
327
+ const record = raw;
328
+ const status = readString(record, "status");
329
+ if (!isExecutedStatus(status))
330
+ continue;
331
+ const rank = rankIndex.get(nodeId);
332
+ let scope;
333
+ let pass;
334
+ let executionId;
335
+ if (!convergenceEnabled) {
336
+ scope = "base";
337
+ pass = null;
338
+ executionId = `${nodeId}@base`;
339
+ }
340
+ else if (!segmentDerivable) {
341
+ scope = "base";
342
+ pass = null;
343
+ executionId = `${nodeId}@base`;
344
+ }
345
+ else if (rank === undefined) {
346
+ scope = "base";
347
+ pass = null;
348
+ executionId = `${nodeId}@base`;
349
+ pushWarning(`节点 ${nodeId} 缺少 rank 事实,按初始执行归类(不伪装最终收敛)`);
350
+ }
351
+ else if (rank < segmentMinRank) {
352
+ scope = "base";
353
+ pass = null;
354
+ executionId = `${nodeId}@base`;
355
+ }
356
+ else if (rank > segmentMaxRank) {
357
+ scope = "final";
358
+ pass = projectedPass;
359
+ executionId = `${nodeId}@pass-${projectedPass}`;
360
+ }
361
+ else if (projectedPass <= 1) {
362
+ scope = "base";
363
+ pass = 1;
364
+ executionId = `${nodeId}@pass-1`;
365
+ }
366
+ else {
367
+ scope = "convergence-pass";
368
+ pass = projectedPass;
369
+ executionId = `${nodeId}@pass-${projectedPass}`;
370
+ }
371
+ // Lightweight live attempt metadata — merged into the archived
372
+ // occurrence on a dedupe hit (AC-001), attached to the live
373
+ // occurrence otherwise.
374
+ const attemptsRaw = readArray(record, "attempts");
375
+ const attempts = [];
376
+ for (const attemptRaw of attemptsRaw) {
377
+ if (!attemptRaw ||
378
+ typeof attemptRaw !== "object" ||
379
+ Array.isArray(attemptRaw)) {
380
+ continue;
381
+ }
382
+ const attemptRecord = attemptRaw;
383
+ const attempt = readNumber(attemptRecord, "attempt");
384
+ if (attempt === undefined || attempt < 1)
385
+ continue;
386
+ const ok = readBoolean(attemptRecord, "ok");
387
+ if (ok === undefined)
388
+ continue;
389
+ attempts.push({
390
+ attempt: Math.floor(attempt),
391
+ status: ok ? "success" : "failure",
392
+ ...(readString(attemptRecord, "failureCategory")
393
+ ? { failureCategory: readString(attemptRecord, "failureCategory") }
394
+ : {}),
395
+ ...(readNumber(attemptRecord, "durationMs") !== undefined
396
+ ? { durationMs: readNumber(attemptRecord, "durationMs") }
397
+ : {}),
398
+ });
399
+ }
400
+ // 合并后的 attempts 按 attempt 升序(确定性输出,AC-001)。
401
+ attempts.sort((a, b) => a.attempt - b.attempt);
402
+ const attemptRecovered = attempts.length > 1 &&
403
+ attempts[0].status === "failure" &&
404
+ attempts[attempts.length - 1].status === "success";
405
+ const recovered = attemptRecovered || (status ?? "").toLowerCase() === "superseded";
406
+ if (pass !== null) {
407
+ const archived = occurrences.find((o) => o.nodeId === nodeId && o.pass === pass);
408
+ if (archived) {
409
+ // 去重合并(dedupe key: nodeId + projected pass):归档 pass record
410
+ // 权威,只合并轻量 attempts 元数据;归档 status/semanticVerdict/
411
+ // recovered/supersededBy 原样保留(AC-001)。
412
+ if (attempts.length > 0) {
413
+ archived.attemptCount = attempts.length;
414
+ archived.attempts = attempts;
415
+ }
416
+ // live 首败末成派生的 attemptRecovered 仅当归档 occurrence 无
417
+ // recovery 事实(recovered 非 true 且 supersededBy 为空)时附加
418
+ // recovered: true;绝不写入 supersededBy(AC-002)。
419
+ if (attemptRecovered &&
420
+ archived.recovered !== true &&
421
+ !(archived.supersededBy && archived.supersededBy.length > 0)) {
422
+ archived.recovered = true;
423
+ }
424
+ // live failureCategory/durationMs 仅在归档缺失时补充(AC-001)。
425
+ if (archived.failureCategory === undefined) {
426
+ const liveFailureCategory = readString(record, "failureCategory");
427
+ if (liveFailureCategory) {
428
+ archived.failureCategory = liveFailureCategory;
429
+ }
430
+ }
431
+ if (archived.durationMs === undefined) {
432
+ const liveDurationMs = readNumber(record, "durationMs");
433
+ if (liveDurationMs !== undefined) {
434
+ archived.durationMs = liveDurationMs;
435
+ }
436
+ }
437
+ continue;
438
+ }
439
+ }
440
+ const decisionEnvelope = record
441
+ .decisionEnvelope;
442
+ const decisionVerdict = decisionEnvelope &&
443
+ typeof decisionEnvelope === "object" &&
444
+ !Array.isArray(decisionEnvelope)
445
+ ? readString(decisionEnvelope, "decision")
446
+ : undefined;
447
+ occurrences.push({
448
+ executionId,
449
+ nodeId,
450
+ pass,
451
+ scope,
452
+ sequence: occurrences.length,
453
+ status: status,
454
+ availability: "complete",
455
+ ...(recovered ? { recovered: true } : {}),
456
+ ...(attempts.length > 0
457
+ ? {
458
+ attemptCount: attempts.length,
459
+ attempts,
460
+ }
461
+ : {}),
462
+ ...(decisionVerdict ? { semanticVerdict: decisionVerdict } : {}),
463
+ ...(readString(record, "failureCategory")
464
+ ? { failureCategory: readString(record, "failureCategory") }
465
+ : {}),
466
+ ...(readNumber(record, "durationMs") !== undefined
467
+ ? { durationMs: readNumber(record, "durationMs") }
468
+ : {}),
469
+ });
470
+ if (occurrences.length >= TRAJECTORY_MAX_OCCURRENCES) {
471
+ pushWarning(`执行轨迹已截断至 ${TRAJECTORY_MAX_OCCURRENCES} 条 occurrence(容量上限)`);
472
+ break;
473
+ }
474
+ }
475
+ // Deterministic lane order: base(0) → convergence pass N(N) → final(last);
476
+ // within a lane rankIndex ascending (unknown rank at lane end), then
477
+ // stable nodeId tiebreak, then executionId tiebreak for same-node
478
+ // occurrences (archived pass-N refs and live @base occurrences can share
479
+ // a nodeId inside the base lane).
480
+ const laneOrder = (occurrence) => {
481
+ if (occurrence.scope === "final")
482
+ return Number.MAX_SAFE_INTEGER;
483
+ if (occurrence.scope === "base")
484
+ return 0;
485
+ return occurrence.pass ?? 0;
486
+ };
487
+ occurrences.sort((a, b) => {
488
+ const la = laneOrder(a);
489
+ const lb = laneOrder(b);
490
+ if (la !== lb)
491
+ return la - lb;
492
+ const ra = rankIndex.get(a.nodeId) ?? Number.MAX_SAFE_INTEGER;
493
+ const rb = rankIndex.get(b.nodeId) ?? Number.MAX_SAFE_INTEGER;
494
+ if (ra !== rb)
495
+ return ra - rb;
496
+ const nodeCompare = a.nodeId.localeCompare(b.nodeId);
497
+ if (nodeCompare !== 0)
498
+ return nodeCompare;
499
+ return a.executionId.localeCompare(b.executionId);
500
+ });
501
+ // Renumber within-lane sequence after sorting.
502
+ const laneSeq = new Map();
503
+ for (const occurrence of occurrences) {
504
+ const laneKey = occurrence.scope === "final"
505
+ ? "final"
506
+ : occurrence.scope === "base"
507
+ ? "base"
508
+ : `pass-${occurrence.pass}`;
509
+ const next = (laneSeq.get(laneKey) ?? 0) + 1;
510
+ laneSeq.set(laneKey, next);
511
+ occurrence.sequence = next - 1;
512
+ }
513
+ return {
514
+ dagRunId,
515
+ occurrences,
516
+ warnings,
517
+ };
518
+ }
519
+ /**
520
+ * Load + project the execution trajectory for one DAG run. Returns null when
521
+ * no run-owned state.json exists (legacy/event-only runs → UI empty state).
522
+ * Never throws: projection failures degrade to a projectionError trajectory
523
+ * so the logical topology keeps rendering (AC-006).
524
+ */
525
+ export async function loadDagRunExecutionTrajectory(repoRoot, dagRunId) {
526
+ try {
527
+ const resolved = resolveDagRunArtifact(repoRoot, dagRunId, ["state.json"]);
528
+ if (!resolved.ok)
529
+ return null;
530
+ const stateInfo = await stat(resolved.result.absolutePath);
531
+ if (stateInfo.size > TRAJECTORY_STATE_MAX_BYTES) {
532
+ return {
533
+ dagRunId,
534
+ occurrences: [],
535
+ warnings: [
536
+ `state.json 超出 ${TRAJECTORY_STATE_MAX_BYTES} 字节读取上限,执行轨迹暂不可用`,
537
+ ],
538
+ projectionError: true,
539
+ };
540
+ }
541
+ const handle = await readFile(resolved.result.absolutePath);
542
+ let parsed;
543
+ try {
544
+ parsed = JSON.parse(handle.toString("utf-8"));
545
+ }
546
+ catch {
547
+ return {
548
+ dagRunId,
549
+ occurrences: [],
550
+ warnings: ["state.json 解析失败,执行历史暂不可用"],
551
+ projectionError: true,
552
+ };
553
+ }
554
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
555
+ return {
556
+ dagRunId,
557
+ occurrences: [],
558
+ warnings: ["state.json 结构异常,执行历史暂不可用"],
559
+ projectionError: true,
560
+ };
561
+ }
562
+ const state = parsed;
563
+ const convergenceRaw = state.convergence;
564
+ const input = {
565
+ runId: dagRunId,
566
+ status: readString(state, "status"),
567
+ ranks: Array.isArray(state.ranks)
568
+ ? state.ranks
569
+ : undefined,
570
+ nodes: state.nodes &&
571
+ typeof state.nodes === "object" &&
572
+ !Array.isArray(state.nodes)
573
+ ? state.nodes
574
+ : undefined,
575
+ convergence: convergenceRaw &&
576
+ typeof convergenceRaw === "object" &&
577
+ !Array.isArray(convergenceRaw)
578
+ ? convergenceRaw
579
+ : undefined,
580
+ };
581
+ return projectDagExecutionTrajectory(input);
582
+ }
583
+ catch {
584
+ return {
585
+ dagRunId,
586
+ occurrences: [],
587
+ warnings: ["执行历史投影失败"],
588
+ projectionError: true,
589
+ };
590
+ }
591
+ }