@tea-agent/loop-agent 0.29.3 → 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.
- package/CHANGELOG.md +60 -0
- package/dist/executors/dag-pi-executor.js +18 -6
- package/dist/worker/observability/dag-execution-trajectory.js +591 -0
- package/dist/worker/observability/read-model.js +258 -31
- package/dist/worker/observe/dag-node-execution-output.js +180 -0
- package/dist/worker/observe/routes.js +53 -6
- package/dist/worker/observe/static/dag-edge-routing.js +368 -0
- package/dist/worker/observe/static/dag-history-labels.js +95 -0
- package/dist/worker/observe/static/dag-layout.d.ts +12 -7
- package/dist/worker/observe/static/dag-layout.js +101 -21
- package/dist/worker/observe/static/favicon.svg +37 -0
- package/dist/worker/observe/static/format.js +31 -1
- package/dist/worker/observe/static/index.html +1 -1
- package/dist/worker/observe/static/state.js +102 -0
- package/dist/worker/observe/static/styles.css +267 -7
- package/dist/worker/observe/static/views/dag-graph.js +414 -154
- package/dist/worker/observe/static/views/dag-inspector.js +478 -27
- package/dist/worker/observe/static/views/dag-trajectory.js +313 -0
- package/dist/worker/observe/static/views/dag.js +20 -3
- package/dist/workflows/dag/backend-test-writer-completeness.js +398 -26
- package/dist/workflows/dag/dynamic-runtime/map.js +10 -4
- package/dist/workflows/dag/init-hybrid.js +228 -229
- package/dist/workflows/dag/node-execution.js +13 -2
- package/dist/workflows/dag/types.js +21 -7
- package/docs/architecture/README.md +4 -0
- package/docs/architecture/dag-execution.md +1 -1
- package/docs/architecture/worker-and-feature.md +1 -1
- package/docs/governance/README.md +3 -0
- package/docs/operations/README.md +1 -0
- package/docs/templates/backend-test-dag.json +664 -460
- package/docs/templates/frontend-test-dag.json +1 -1
- package/harness.json +2 -2
- package/package.json +3 -2
- package/scripts/kb-bootstrap-init-skeleton.sh +1 -1
|
@@ -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
|
+
}
|