@tea-agent/loop-agent 0.5.0 → 0.6.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/AGENTS.md +142 -142
- package/CHANGELOG.md +116 -98
- package/README.md +195 -195
- package/bin/agent-worker.js +22 -22
- package/bin/loop-agent.js +21 -21
- package/dist/application/dag/args.js +9 -1
- package/dist/application/dag/run-dag.js +16 -2
- package/dist/cli/command-definitions.js +22 -4
- package/dist/cli/help.js +3 -2
- package/dist/cli/program.js +7 -5
- package/dist/commands/import-prd.js +76 -0
- package/dist/commands/init.js +467 -457
- package/dist/commands/instructions.js +90 -58
- package/dist/commands/loop-benchmark.js +11 -11
- package/dist/commands/pi-reuse-benchmark.js +16 -16
- package/dist/executors/cursor-executor.js +1 -1
- package/dist/executors/dag-pi-executor.js +1 -0
- package/dist/executors/pi-sdk-executor.js +63 -1
- package/dist/shared/preview.js +39 -0
- package/dist/task/runtime.js +27 -27
- package/dist/task/source-references.js +221 -0
- package/dist/worker/cli.js +62 -1
- package/dist/worker/loop-agent/loop-agent-client.js +97 -5
- package/dist/worker/materialize/harness-task-materializer.js +162 -5
- package/dist/worker/observability/event-store.js +82 -0
- package/dist/worker/observability/events.js +79 -0
- package/dist/worker/observability/progress-composite.js +33 -0
- package/dist/worker/observability/read-model.js +1013 -0
- package/dist/worker/observability/snapshot-store.js +43 -0
- package/dist/worker/observability/types.js +1 -0
- package/dist/worker/observe/paths.js +64 -0
- package/dist/worker/observe/routes.js +423 -0
- package/dist/worker/observe/server.js +61 -0
- package/dist/worker/observe/static/app.js +1419 -0
- package/dist/worker/observe/static/index.html +63 -0
- package/dist/worker/observe/static/styles.css +613 -0
- package/dist/worker/pool/failure-routing.js +41 -6
- package/dist/worker/pool/run-store.js +50 -0
- package/dist/worker/progress-reporter.js +0 -18
- package/dist/worker/run-task/run-task.js +327 -92
- package/dist/worker/runner/run-ready.js +112 -4
- package/dist/workflows/dag/canvas-observer.js +275 -275
- package/dist/workflows/dag/event-observer.js +132 -0
- package/dist/workflows/dag/init-hybrid.js +146 -13
- package/dist/workflows/dag/observer-compose.js +52 -0
- package/docs/README.md +74 -72
- package/docs/agent-dag-recovery-playbook.md +184 -184
- package/docs/agent-dag-runner.md +42 -42
- package/docs/architecture/runtime-boundaries.md +162 -147
- package/docs/cursor-executor-usage.md +25 -25
- package/docs/decisions/README.md +3 -3
- package/docs/design/README.md +49 -36
- package/docs/development-principles.md +73 -73
- package/docs/dynamic-workflow-dag-engine-roadmap.md +1749 -1749
- package/docs/exec-plans/README.md +6 -6
- package/docs/exec-plans/active/README.md +12 -7
- package/docs/exec-plans/completed/README.md +31 -19
- package/docs/feature-workflow.md +186 -186
- package/docs/harness-methodology-debugging.md +153 -153
- package/docs/harness-methodology-tdd.md +130 -130
- package/docs/harness-methodology-verification.md +27 -27
- package/docs/init-surface.manifest.json +205 -199
- package/docs/loop-agent-harness.md +55 -42
- package/docs/production-readiness.md +96 -96
- package/docs/progress/README.md +3 -3
- package/docs/reports/README.md +9 -5
- package/docs/skills/README.md +6 -6
- package/docs/skills/vetted-skill-registry.md +26 -26
- package/docs/templates/adr.md +60 -60
- package/docs/templates/agent-dag-authority-surface-audit.prompt.md +94 -94
- package/docs/templates/agent-dag-decision-envelope.schema.json +213 -213
- package/docs/templates/agent-dag-decision-gate-dogfood-report.md +117 -117
- package/docs/templates/agent-dag-decision-gate.prompt.md +246 -246
- package/docs/templates/agent-dag-process-supervisor.prompt.md +98 -98
- package/docs/templates/agent-dag-report.schema.json +454 -454
- package/docs/templates/agent-dag-review-verdict.prompt.md +68 -68
- package/docs/templates/agent-dag.base.json +195 -195
- package/docs/templates/agent-dag.final-verification.json +190 -190
- package/docs/templates/agent-dag.schema.json +316 -316
- package/docs/templates/agent-dag.supervised-implementation.json +500 -500
- package/docs/templates/exec-plan.md +64 -64
- package/docs/templates/feature-spec.md +53 -53
- package/docs/templates/hybrid-dag.json +193 -193
- package/docs/templates/init-evolution-review.md +33 -33
- package/docs/templates/production-readiness-checklist.md +57 -57
- package/docs/templates/progress-log.md +17 -17
- package/docs/templates/project-start-checklist.md +9 -9
- package/docs/templates/qa-report.md +48 -48
- package/docs/templates/sprint-contract.md +29 -29
- package/docs/templates/worker-dogfood-evidence.md +52 -0
- package/docs/templates/worker-dogfood-setup.md +48 -0
- package/docs/verification-matrix.md +41 -41
- package/examples/decision-gate-agent-dag.json +123 -123
- package/examples/example-dag.json +51 -51
- package/examples/hybrid-loop-agent-dag.json +194 -194
- package/harness.json +70 -69
- package/package.json +66 -66
- package/skills/ai-engineering-context/SKILL.md +48 -48
- package/skills/code-review-core/SKILL.md +20 -20
- package/skills/codebase-scout/SKILL.md +19 -19
- package/skills/init-capability-evolution/SKILL.md +69 -69
- package/skills/loop-agent/SKILL.md +149 -147
- package/skills/loop-agent/references/README.md +67 -67
- package/skills/loop-agent/references/command-reference.md +412 -403
- package/skills/loop-agent/references/harness-policy.md +263 -259
- package/skills/loop-agent/references/hybrid-dag.md +216 -216
- package/skills/loop-agent/references/learned/README.md +21 -21
- package/skills/loop-agent/references/long-running-loop.md +59 -59
- package/skills/loop-agent/references/model-routing.md +36 -36
- package/skills/loop-agent/references/multi-worktree.md +54 -54
- package/skills/loop-agent/references/one-shot-runs.md +85 -85
- package/skills/loop-agent/references/orchestrator-and-interventions.md +169 -169
- package/skills/loop-agent/references/pi-prompt.md +23 -23
- package/skills/loop-agent/references/pi-subagent-assisted-mode.md +81 -81
- package/skills/loop-agent/references/post-implementation-and-patterns.md +44 -44
- package/skills/loop-agent/references/task-workflow.md +89 -84
- package/skills/loop-agent/references/verification-and-failure-handling.md +128 -128
- package/skills/requesting-code-review/SKILL.md +101 -101
- package/skills/requesting-code-review/code-reviewer.md +168 -168
- package/skills/systematic-debugging/CREATION-LOG.md +119 -119
- package/skills/systematic-debugging/SKILL.md +296 -296
- package/skills/systematic-debugging/condition-based-waiting-example.ts +158 -158
- package/skills/systematic-debugging/condition-based-waiting.md +115 -115
- package/skills/systematic-debugging/defense-in-depth.md +122 -122
- package/skills/systematic-debugging/find-polluter.sh +63 -63
- package/skills/systematic-debugging/root-cause-tracing.md +169 -169
- package/skills/systematic-debugging/test-academic.md +14 -14
- package/skills/systematic-debugging/test-pressure-1.md +58 -58
- package/skills/systematic-debugging/test-pressure-2.md +68 -68
- package/skills/systematic-debugging/test-pressure-3.md +69 -69
- package/skills/test-driven-development/SKILL.md +20 -20
- package/skills/verification-before-completion/SKILL.md +154 -154
- package/skills/webapp-testing/SKILL.md +19 -19
|
@@ -0,0 +1,1013 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { truncateUtf8Preview } from "../../shared/preview.js";
|
|
5
|
+
import { parseWorkerEventLine } from "./events.js";
|
|
6
|
+
const ACTIVE_TASK_STATUSES = new Set(["running", "pending"]);
|
|
7
|
+
const ACTIVE_BATCH_STATUSES = new Set(["running", "pending"]);
|
|
8
|
+
export async function buildGlobalSnapshot(options) {
|
|
9
|
+
try {
|
|
10
|
+
const repoRoot = path.resolve(options.repoRoot);
|
|
11
|
+
const now = options.now ?? (() => new Date());
|
|
12
|
+
const staleThresholdMs = options.staleThresholdMs ?? 90_000;
|
|
13
|
+
const quietThresholdMs = options.quietThresholdMs ?? 60_000;
|
|
14
|
+
const [events, ledgerRuns, ledgerBatches, states, handoffs, dagRuns] = await Promise.all([
|
|
15
|
+
loadObservabilityEvents(repoRoot),
|
|
16
|
+
loadLedgerRuns(repoRoot),
|
|
17
|
+
loadLedgerBatches(repoRoot),
|
|
18
|
+
loadStateRecords(repoRoot),
|
|
19
|
+
loadFailureHandoffs(repoRoot),
|
|
20
|
+
loadDagRuns(repoRoot),
|
|
21
|
+
]);
|
|
22
|
+
const taskMap = new Map();
|
|
23
|
+
for (const state of states) {
|
|
24
|
+
mergeStateRecord(taskMap, state);
|
|
25
|
+
}
|
|
26
|
+
for (const run of ledgerRuns) {
|
|
27
|
+
mergeLedgerRun(taskMap, run);
|
|
28
|
+
}
|
|
29
|
+
mergeEvents(taskMap, events);
|
|
30
|
+
mergeHandoffs(taskMap, handoffs);
|
|
31
|
+
applyStaleDetection(taskMap, now(), staleThresholdMs, quietThresholdMs);
|
|
32
|
+
const historicalRunMap = buildHistoricalRunMap(ledgerRuns, events);
|
|
33
|
+
mergeHandoffs(historicalRunMap, handoffs);
|
|
34
|
+
applyStaleDetection(historicalRunMap, now(), staleThresholdMs, quietThresholdMs);
|
|
35
|
+
const tasks = [...taskMap.values()]
|
|
36
|
+
.map(toPublicTaskSummary)
|
|
37
|
+
.map((task) => normalizeTaskArtifactRefs(task, repoRoot))
|
|
38
|
+
.sort((a, b) => a.taskId.localeCompare(b.taskId));
|
|
39
|
+
const batches = buildBatches(ledgerBatches, events, historicalRunMap).map((batch) => ({
|
|
40
|
+
...batch,
|
|
41
|
+
tasks: batch.tasks.map((task) => normalizeTaskArtifactRefs(task, repoRoot)),
|
|
42
|
+
}));
|
|
43
|
+
const health = computeHealth(batches, tasks);
|
|
44
|
+
return {
|
|
45
|
+
schemaVersion: 1,
|
|
46
|
+
generatedAt: now().toISOString(),
|
|
47
|
+
repoRoot,
|
|
48
|
+
batches,
|
|
49
|
+
tasks,
|
|
50
|
+
dagRuns,
|
|
51
|
+
health,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
const now = options.now ?? (() => new Date());
|
|
56
|
+
return emptySnapshot(path.resolve(options.repoRoot), now);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function emptySnapshot(repoRoot, now) {
|
|
60
|
+
return {
|
|
61
|
+
schemaVersion: 1,
|
|
62
|
+
generatedAt: now().toISOString(),
|
|
63
|
+
repoRoot,
|
|
64
|
+
batches: [],
|
|
65
|
+
tasks: [],
|
|
66
|
+
dagRuns: [],
|
|
67
|
+
health: {
|
|
68
|
+
activeBatches: 0,
|
|
69
|
+
activeTasks: 0,
|
|
70
|
+
quietCount: 0,
|
|
71
|
+
staleCount: 0,
|
|
72
|
+
timeoutRiskCount: 0,
|
|
73
|
+
failuresCount: 0,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function toPublicTaskSummary(task) {
|
|
78
|
+
const { batchRunId: _batch, timeoutMs: _to, commandStartedAt: _cs, ...rest } = task;
|
|
79
|
+
return rest;
|
|
80
|
+
}
|
|
81
|
+
function mergeStateRecord(taskMap, state) {
|
|
82
|
+
const existing = taskMap.get(state.taskId) ?? { taskId: state.taskId, status: "unknown" };
|
|
83
|
+
taskMap.set(state.taskId, {
|
|
84
|
+
...existing,
|
|
85
|
+
status: existing.status !== "unknown" ? existing.status : mapStateStatus(state.status),
|
|
86
|
+
workerRunId: existing.workerRunId ?? state.workerRunId,
|
|
87
|
+
artifactRefs: mergeArtifactRefs(existing.artifactRefs, {
|
|
88
|
+
runRecordPath: state.lastRunRecordPath,
|
|
89
|
+
}),
|
|
90
|
+
failureCategory: existing.failureCategory ?? state.failureCategory,
|
|
91
|
+
recommendedFollowUp: existing.recommendedFollowUp ?? state.recommendedFollowUp,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function mergeLedgerRun(taskMap, run) {
|
|
95
|
+
const existing = taskMap.get(run.taskId) ?? { taskId: run.taskId, status: "unknown" };
|
|
96
|
+
const sameRun = existing.workerRunId === run.workerRunId;
|
|
97
|
+
const runArtifactRefs = {
|
|
98
|
+
runRecordPath: run.runRecordPath,
|
|
99
|
+
dagPath: run.dagPath,
|
|
100
|
+
...run.failureArtifacts,
|
|
101
|
+
};
|
|
102
|
+
taskMap.set(run.taskId, {
|
|
103
|
+
...(sameRun
|
|
104
|
+
? existing
|
|
105
|
+
: {
|
|
106
|
+
taskId: run.taskId,
|
|
107
|
+
status: "unknown",
|
|
108
|
+
featureId: existing.featureId,
|
|
109
|
+
harnessTaskId: existing.harnessTaskId,
|
|
110
|
+
}),
|
|
111
|
+
batchRunId: run.batchRunId,
|
|
112
|
+
featureId: run.featureId ?? existing.featureId,
|
|
113
|
+
status: mapRunRecordStatus(run.status),
|
|
114
|
+
workerRunId: run.workerRunId,
|
|
115
|
+
harnessTaskId: run.harnessTaskId ?? existing.harnessTaskId,
|
|
116
|
+
finishedAt: run.recordedAt ?? (sameRun ? existing.finishedAt : undefined),
|
|
117
|
+
failureCategory: run.failureCategory ?? (sameRun ? existing.failureCategory : undefined),
|
|
118
|
+
recommendedFollowUp: run.recommendedFollowUp ?? (sameRun ? existing.recommendedFollowUp : undefined),
|
|
119
|
+
artifactRefs: mergeArtifactRefs(sameRun ? existing.artifactRefs : undefined, runArtifactRefs),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
function mergeEvents(taskMap, events) {
|
|
123
|
+
const sorted = [...events].sort((a, b) => a.at.localeCompare(b.at));
|
|
124
|
+
for (const event of sorted) {
|
|
125
|
+
if (event.taskId) {
|
|
126
|
+
applyTaskEvent(taskMap, event);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function applyTaskEvent(taskMap, event) {
|
|
131
|
+
const taskId = event.taskId;
|
|
132
|
+
const existing = taskMap.get(taskId) ?? { taskId, status: "unknown" };
|
|
133
|
+
taskMap.set(taskId, applyEventToSummary(existing, event));
|
|
134
|
+
}
|
|
135
|
+
function applyEventToSummary(existing, event) {
|
|
136
|
+
const updated = {
|
|
137
|
+
...existing,
|
|
138
|
+
batchRunId: event.batchRunId ?? existing.batchRunId,
|
|
139
|
+
featureId: existing.featureId,
|
|
140
|
+
workerRunId: event.workerRunId ?? existing.workerRunId,
|
|
141
|
+
harnessTaskId: event.harnessTaskId ?? existing.harnessTaskId,
|
|
142
|
+
dagRunId: event.dagRunId ?? existing.dagRunId,
|
|
143
|
+
failureCategory: event.failureCategory ?? existing.failureCategory,
|
|
144
|
+
recommendedFollowUp: event.recommendedFollowUp ?? existing.recommendedFollowUp,
|
|
145
|
+
artifactRefs: mergeArtifactRefs(existing.artifactRefs, event.artifactRefs),
|
|
146
|
+
};
|
|
147
|
+
const derivedStatus = deriveEventStatus(event);
|
|
148
|
+
if (derivedStatus) {
|
|
149
|
+
updated.status = derivedStatus;
|
|
150
|
+
}
|
|
151
|
+
if (event.type === "task.started" || event.type === "step.started") {
|
|
152
|
+
if (!updated.startedAt || event.at < updated.startedAt) {
|
|
153
|
+
updated.startedAt = event.at;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (event.type === "task.finished" || event.type === "task.reused") {
|
|
157
|
+
updated.finishedAt = event.at;
|
|
158
|
+
if (event.durationMs !== undefined) {
|
|
159
|
+
updated.durationMs = event.durationMs;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (event.type === "command.heartbeat" || event.type === "step.heartbeat") {
|
|
163
|
+
updated.lastHeartbeatAt = event.at;
|
|
164
|
+
}
|
|
165
|
+
if (event.type === "command.output" ||
|
|
166
|
+
event.type === "step.output" ||
|
|
167
|
+
event.type === "dag.node.output") {
|
|
168
|
+
updated.lastOutputAt = event.at;
|
|
169
|
+
}
|
|
170
|
+
if (event.type === "command.started") {
|
|
171
|
+
if (event.timeoutMs !== undefined)
|
|
172
|
+
updated.timeoutMs = event.timeoutMs;
|
|
173
|
+
if (event.pid !== undefined)
|
|
174
|
+
updated.pid = event.pid;
|
|
175
|
+
updated.commandStartedAt = event.at;
|
|
176
|
+
}
|
|
177
|
+
if (event.type === "command.finished") {
|
|
178
|
+
delete updated.timeoutMs;
|
|
179
|
+
delete updated.pid;
|
|
180
|
+
delete updated.commandStartedAt;
|
|
181
|
+
}
|
|
182
|
+
return updated;
|
|
183
|
+
}
|
|
184
|
+
function buildHistoricalRunMap(ledgerRuns, events) {
|
|
185
|
+
const runMap = new Map();
|
|
186
|
+
for (const run of ledgerRuns) {
|
|
187
|
+
runMap.set(run.workerRunId, {
|
|
188
|
+
taskId: run.taskId,
|
|
189
|
+
batchRunId: run.batchRunId,
|
|
190
|
+
featureId: run.featureId,
|
|
191
|
+
status: mapRunRecordStatus(run.status),
|
|
192
|
+
workerRunId: run.workerRunId,
|
|
193
|
+
harnessTaskId: run.harnessTaskId,
|
|
194
|
+
finishedAt: run.recordedAt,
|
|
195
|
+
failureCategory: run.failureCategory,
|
|
196
|
+
recommendedFollowUp: run.recommendedFollowUp,
|
|
197
|
+
artifactRefs: mergeArtifactRefs(undefined, {
|
|
198
|
+
runRecordPath: run.runRecordPath,
|
|
199
|
+
dagPath: run.dagPath,
|
|
200
|
+
...run.failureArtifacts,
|
|
201
|
+
}),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
for (const event of [...events].sort((a, b) => a.at.localeCompare(b.at))) {
|
|
205
|
+
if (!event.workerRunId || !event.taskId)
|
|
206
|
+
continue;
|
|
207
|
+
const existing = runMap.get(event.workerRunId) ?? {
|
|
208
|
+
taskId: event.taskId,
|
|
209
|
+
workerRunId: event.workerRunId,
|
|
210
|
+
status: "unknown",
|
|
211
|
+
};
|
|
212
|
+
runMap.set(event.workerRunId, applyEventToSummary(existing, event));
|
|
213
|
+
}
|
|
214
|
+
return runMap;
|
|
215
|
+
}
|
|
216
|
+
function deriveEventStatus(event) {
|
|
217
|
+
switch (event.type) {
|
|
218
|
+
case "task.queued":
|
|
219
|
+
return event.status ?? "pending";
|
|
220
|
+
case "task.started":
|
|
221
|
+
return event.status ?? "running";
|
|
222
|
+
case "task.reused":
|
|
223
|
+
return event.status ?? "reused";
|
|
224
|
+
case "task.finished":
|
|
225
|
+
return event.status ?? "succeeded";
|
|
226
|
+
case "state.updated":
|
|
227
|
+
return event.status;
|
|
228
|
+
case "failure.routed":
|
|
229
|
+
return "failed";
|
|
230
|
+
default:
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function mergeHandoffs(taskMap, handoffs) {
|
|
235
|
+
for (const handoff of handoffs) {
|
|
236
|
+
for (const task of taskMap.values()) {
|
|
237
|
+
if (task.workerRunId === handoff.workerRunId) {
|
|
238
|
+
task.artifactRefs = mergeArtifactRefs(task.artifactRefs, {
|
|
239
|
+
closeoutDraft: handoff.closeoutDraftPath,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function applyStaleDetection(taskMap, now, staleThresholdMs, quietThresholdMs) {
|
|
246
|
+
const nowMs = now.getTime();
|
|
247
|
+
for (const task of taskMap.values()) {
|
|
248
|
+
if (task.status !== "running")
|
|
249
|
+
continue;
|
|
250
|
+
try {
|
|
251
|
+
computeLiveness(task, nowMs, staleThresholdMs, quietThresholdMs);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// Fault tolerance: keep task as-is if liveness computation fails.
|
|
255
|
+
task.liveness = "active";
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function computeLiveness(task, nowMs, staleThresholdMs, quietThresholdMs) {
|
|
260
|
+
const startedAt = task.startedAt;
|
|
261
|
+
let elapsedMs;
|
|
262
|
+
if (startedAt) {
|
|
263
|
+
const startedMs = Date.parse(startedAt);
|
|
264
|
+
if (!Number.isNaN(startedMs)) {
|
|
265
|
+
elapsedMs = Math.max(0, nowMs - startedMs);
|
|
266
|
+
task.elapsedMs = elapsedMs;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const timeoutMs = task.timeoutMs;
|
|
270
|
+
let commandElapsedMs;
|
|
271
|
+
const commandStartedMs = task.commandStartedAt
|
|
272
|
+
? Date.parse(task.commandStartedAt)
|
|
273
|
+
: NaN;
|
|
274
|
+
if (!Number.isNaN(commandStartedMs)) {
|
|
275
|
+
commandElapsedMs = Math.max(0, nowMs - commandStartedMs);
|
|
276
|
+
if (timeoutMs !== undefined) {
|
|
277
|
+
task.timeoutAt = new Date(commandStartedMs + timeoutMs).toISOString();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const heartbeatMs = task.lastHeartbeatAt ? Date.parse(task.lastHeartbeatAt) : NaN;
|
|
281
|
+
const outputMs = task.lastOutputAt ? Date.parse(task.lastOutputAt) : NaN;
|
|
282
|
+
// Priority 1: timeout-risk (elapsed > 0.8 * timeoutMs), only if timeoutMs known.
|
|
283
|
+
if (timeoutMs !== undefined &&
|
|
284
|
+
commandElapsedMs !== undefined &&
|
|
285
|
+
commandElapsedMs > 0.8 * timeoutMs) {
|
|
286
|
+
task.liveness = "timeout-risk";
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// Priority 2: stale.
|
|
290
|
+
// (a) Heartbeat present but older than staleThresholdMs.
|
|
291
|
+
// (b) No heartbeat but running longer than staleThresholdMs (startedAt known).
|
|
292
|
+
const heartbeatStale = !Number.isNaN(heartbeatMs) && nowMs - heartbeatMs > staleThresholdMs;
|
|
293
|
+
const noHeartbeatStale = Number.isNaN(heartbeatMs) && elapsedMs !== undefined && elapsedMs > staleThresholdMs;
|
|
294
|
+
if (heartbeatStale || noHeartbeatStale) {
|
|
295
|
+
task.liveness = "stale";
|
|
296
|
+
task.status = "stale";
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
// Priority 3: quiet — alive (recent heartbeat) but output older than quietThresholdMs.
|
|
300
|
+
const quietBaselineMs = !Number.isNaN(outputMs) ? outputMs : commandStartedMs;
|
|
301
|
+
if (!Number.isNaN(heartbeatMs) &&
|
|
302
|
+
!Number.isNaN(quietBaselineMs) &&
|
|
303
|
+
nowMs - quietBaselineMs > quietThresholdMs) {
|
|
304
|
+
task.liveness = "quiet";
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// Default: active.
|
|
308
|
+
task.liveness = "active";
|
|
309
|
+
}
|
|
310
|
+
function buildBatches(ledgerBatches, events, taskMap) {
|
|
311
|
+
const batchMap = new Map();
|
|
312
|
+
for (const batch of ledgerBatches) {
|
|
313
|
+
batchMap.set(batch.batchRunId, { ...batch, tasks: [] });
|
|
314
|
+
}
|
|
315
|
+
for (const event of events) {
|
|
316
|
+
if (!event.batchRunId)
|
|
317
|
+
continue;
|
|
318
|
+
if (!batchMap.has(event.batchRunId)) {
|
|
319
|
+
batchMap.set(event.batchRunId, {
|
|
320
|
+
batchRunId: event.batchRunId,
|
|
321
|
+
status: "unknown",
|
|
322
|
+
tasks: [],
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
const batch = batchMap.get(event.batchRunId);
|
|
326
|
+
if (event.type === "batch.started") {
|
|
327
|
+
batch.startedAt = event.at;
|
|
328
|
+
batch.status = "running";
|
|
329
|
+
}
|
|
330
|
+
if (event.type === "batch.finished") {
|
|
331
|
+
batch.finishedAt = event.at;
|
|
332
|
+
batch.status = event.status ?? "succeeded";
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
for (const task of taskMap.values()) {
|
|
336
|
+
if (!task.batchRunId)
|
|
337
|
+
continue;
|
|
338
|
+
if (!batchMap.has(task.batchRunId)) {
|
|
339
|
+
batchMap.set(task.batchRunId, {
|
|
340
|
+
batchRunId: task.batchRunId,
|
|
341
|
+
status: "unknown",
|
|
342
|
+
tasks: [],
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
const batch = batchMap.get(task.batchRunId);
|
|
346
|
+
batch.featureId = batch.featureId ?? task.featureId;
|
|
347
|
+
batch.tasks.push(toPublicTaskSummary(task));
|
|
348
|
+
}
|
|
349
|
+
for (const batch of batchMap.values()) {
|
|
350
|
+
batch.tasks.sort((a, b) => a.taskId.localeCompare(b.taskId));
|
|
351
|
+
if (batch.summary === undefined && batch.tasks.length > 0) {
|
|
352
|
+
batch.summary = summarizeTasks(batch.tasks);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return [...batchMap.values()].sort((a, b) => a.batchRunId.localeCompare(b.batchRunId));
|
|
356
|
+
}
|
|
357
|
+
function summarizeTasks(tasks) {
|
|
358
|
+
let succeeded = 0;
|
|
359
|
+
let failed = 0;
|
|
360
|
+
let reused = 0;
|
|
361
|
+
for (const task of tasks) {
|
|
362
|
+
if (task.status === "succeeded")
|
|
363
|
+
succeeded++;
|
|
364
|
+
else if (task.status === "failed")
|
|
365
|
+
failed++;
|
|
366
|
+
else if (task.status === "reused")
|
|
367
|
+
reused++;
|
|
368
|
+
}
|
|
369
|
+
return { total: tasks.length, succeeded, failed, reused };
|
|
370
|
+
}
|
|
371
|
+
function computeHealth(batches, tasks) {
|
|
372
|
+
return {
|
|
373
|
+
activeBatches: batches.filter((b) => ACTIVE_BATCH_STATUSES.has(b.status)).length,
|
|
374
|
+
activeTasks: tasks.filter((t) => ACTIVE_TASK_STATUSES.has(t.status)).length,
|
|
375
|
+
quietCount: tasks.filter((t) => t.liveness === "quiet").length,
|
|
376
|
+
staleCount: tasks.filter((t) => t.status === "stale").length,
|
|
377
|
+
timeoutRiskCount: tasks.filter((t) => t.liveness === "timeout-risk").length,
|
|
378
|
+
failuresCount: tasks.filter((t) => t.status === "failed").length,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
function mergeArtifactRefs(existing, incoming) {
|
|
382
|
+
if (!incoming)
|
|
383
|
+
return existing;
|
|
384
|
+
const merged = { ...existing };
|
|
385
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
386
|
+
if (!value)
|
|
387
|
+
continue;
|
|
388
|
+
if (key === "runRecordPath" || key === "dagPath" || key === "reportMarkdown" || key === "doctorMarkdown" || key === "closeoutDraft") {
|
|
389
|
+
merged[key] = value;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return Object.keys(merged).length > 0 ? merged : undefined;
|
|
393
|
+
}
|
|
394
|
+
function normalizeTaskArtifactRefs(task, repoRoot) {
|
|
395
|
+
if (!task.artifactRefs)
|
|
396
|
+
return task;
|
|
397
|
+
const normalized = {};
|
|
398
|
+
const keys = [
|
|
399
|
+
"runRecordPath",
|
|
400
|
+
"dagPath",
|
|
401
|
+
"reportMarkdown",
|
|
402
|
+
"doctorMarkdown",
|
|
403
|
+
"closeoutDraft",
|
|
404
|
+
];
|
|
405
|
+
for (const key of keys) {
|
|
406
|
+
const value = task.artifactRefs[key];
|
|
407
|
+
if (!value)
|
|
408
|
+
continue;
|
|
409
|
+
const normalizedPath = normalizeRepoRelativePath(repoRoot, value);
|
|
410
|
+
if (normalizedPath)
|
|
411
|
+
normalized[key] = normalizedPath;
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
...task,
|
|
415
|
+
artifactRefs: Object.keys(normalized).length > 0 ? normalized : undefined,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
function normalizeRepoRelativePath(repoRoot, value) {
|
|
419
|
+
const resolvedRoot = path.resolve(repoRoot);
|
|
420
|
+
const resolved = path.isAbsolute(value)
|
|
421
|
+
? path.resolve(value)
|
|
422
|
+
: path.resolve(resolvedRoot, value);
|
|
423
|
+
const relative = path.relative(resolvedRoot, resolved);
|
|
424
|
+
if (relative === "" ||
|
|
425
|
+
relative === ".." ||
|
|
426
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
427
|
+
path.isAbsolute(relative)) {
|
|
428
|
+
return undefined;
|
|
429
|
+
}
|
|
430
|
+
return relative.split(path.sep).join("/");
|
|
431
|
+
}
|
|
432
|
+
function mapRunRecordStatus(status) {
|
|
433
|
+
switch (status) {
|
|
434
|
+
case "succeeded":
|
|
435
|
+
return "succeeded";
|
|
436
|
+
case "failed":
|
|
437
|
+
return "failed";
|
|
438
|
+
case "reused":
|
|
439
|
+
return "reused";
|
|
440
|
+
case "run-error":
|
|
441
|
+
case "record-error":
|
|
442
|
+
return "failed";
|
|
443
|
+
default:
|
|
444
|
+
return "unknown";
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
function mapStateStatus(status) {
|
|
448
|
+
switch (status) {
|
|
449
|
+
case "Running":
|
|
450
|
+
return "running";
|
|
451
|
+
case "Queued":
|
|
452
|
+
return "pending";
|
|
453
|
+
case "Done":
|
|
454
|
+
return "succeeded";
|
|
455
|
+
case "Failed":
|
|
456
|
+
return "failed";
|
|
457
|
+
case "Blocked":
|
|
458
|
+
return "blocked";
|
|
459
|
+
default:
|
|
460
|
+
return "unknown";
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
function mapBatchStatus(status) {
|
|
464
|
+
switch (status) {
|
|
465
|
+
case "completed":
|
|
466
|
+
return "succeeded";
|
|
467
|
+
case "failed":
|
|
468
|
+
return "failed";
|
|
469
|
+
case "running":
|
|
470
|
+
return "running";
|
|
471
|
+
default:
|
|
472
|
+
return "unknown";
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
async function loadObservabilityEvents(repoRoot) {
|
|
476
|
+
const events = [];
|
|
477
|
+
const obsRoot = path.join(repoRoot, ".task-pool", "observability");
|
|
478
|
+
await appendJsonlEvents(path.join(obsRoot, "events.jsonl"), events);
|
|
479
|
+
const batchesDir = path.join(obsRoot, "batches");
|
|
480
|
+
await forEachSubdirJsonl(batchesDir, "events.jsonl", events);
|
|
481
|
+
const runsDir = path.join(obsRoot, "runs");
|
|
482
|
+
await forEachSubdirJsonl(runsDir, "events.jsonl", events);
|
|
483
|
+
return [...new Map(events.map((event) => [event.id, event])).values()];
|
|
484
|
+
}
|
|
485
|
+
async function forEachSubdirJsonl(parentDir, filename, events) {
|
|
486
|
+
try {
|
|
487
|
+
const entries = await readdir(parentDir, { withFileTypes: true });
|
|
488
|
+
for (const entry of entries) {
|
|
489
|
+
if (!entry.isDirectory())
|
|
490
|
+
continue;
|
|
491
|
+
await appendJsonlEvents(path.join(parentDir, entry.name, filename), events);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
// skip missing dir
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
async function appendJsonlEvents(filePath, events) {
|
|
499
|
+
const raw = await safeReadText(filePath);
|
|
500
|
+
if (!raw)
|
|
501
|
+
return;
|
|
502
|
+
for (const line of raw.split("\n")) {
|
|
503
|
+
const trimmed = line.trim();
|
|
504
|
+
if (!trimmed)
|
|
505
|
+
continue;
|
|
506
|
+
const event = parseWorkerEventLine(trimmed);
|
|
507
|
+
if (event)
|
|
508
|
+
events.push(event);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
async function loadLedgerRuns(repoRoot) {
|
|
512
|
+
const runs = [];
|
|
513
|
+
const raw = await safeReadText(path.join(repoRoot, ".task-pool", "runs", "runs.jsonl"));
|
|
514
|
+
if (!raw)
|
|
515
|
+
return runs;
|
|
516
|
+
for (const line of raw.split("\n")) {
|
|
517
|
+
const trimmed = line.trim();
|
|
518
|
+
if (!trimmed)
|
|
519
|
+
continue;
|
|
520
|
+
const parsed = safeParseJson(trimmed);
|
|
521
|
+
if (!parsed)
|
|
522
|
+
continue;
|
|
523
|
+
const taskId = readString(parsed, "taskId");
|
|
524
|
+
const batchRunId = readString(parsed, "batchRunId");
|
|
525
|
+
const workerRunId = readString(parsed, "workerRunId");
|
|
526
|
+
const status = readString(parsed, "status");
|
|
527
|
+
if (!taskId || !batchRunId || !workerRunId || !status)
|
|
528
|
+
continue;
|
|
529
|
+
const failure = readObject(parsed, "failure");
|
|
530
|
+
const failureArtifacts = readObject(parsed, "failureArtifacts");
|
|
531
|
+
runs.push({
|
|
532
|
+
batchRunId,
|
|
533
|
+
workerRunId,
|
|
534
|
+
taskId,
|
|
535
|
+
featureId: readString(parsed, "featureId"),
|
|
536
|
+
status,
|
|
537
|
+
harnessTaskId: readString(parsed, "harnessTaskId"),
|
|
538
|
+
runRecordPath: readString(parsed, "runRecordPath"),
|
|
539
|
+
dagPath: readString(parsed, "dagPath"),
|
|
540
|
+
recordedAt: readString(parsed, "recordedAt"),
|
|
541
|
+
failureCategory: failure ? readString(failure, "category") : undefined,
|
|
542
|
+
recommendedFollowUp: failure ? readString(failure, "recommendedFollowUpKind") : undefined,
|
|
543
|
+
failureArtifacts: failureArtifacts
|
|
544
|
+
? {
|
|
545
|
+
reportMarkdown: readString(failureArtifacts, "reportMarkdownArtifactPath"),
|
|
546
|
+
doctorMarkdown: readString(failureArtifacts, "doctorMarkdownArtifactPath"),
|
|
547
|
+
closeoutDraft: readString(failureArtifacts, "closeoutDraftPath"),
|
|
548
|
+
}
|
|
549
|
+
: undefined,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
return runs;
|
|
553
|
+
}
|
|
554
|
+
async function loadLedgerBatches(repoRoot) {
|
|
555
|
+
const batches = [];
|
|
556
|
+
const runsDir = path.join(repoRoot, ".task-pool", "runs");
|
|
557
|
+
try {
|
|
558
|
+
const entries = await readdir(runsDir, { withFileTypes: true });
|
|
559
|
+
for (const entry of entries) {
|
|
560
|
+
if (!entry.isFile() || !entry.name.endsWith(".json") || entry.name === "runs.jsonl")
|
|
561
|
+
continue;
|
|
562
|
+
const batchRunId = entry.name.replace(/\.json$/, "");
|
|
563
|
+
const parsed = await safeReadJson(path.join(runsDir, entry.name));
|
|
564
|
+
if (!parsed)
|
|
565
|
+
continue;
|
|
566
|
+
const summaryObj = readObject(parsed, "summary");
|
|
567
|
+
batches.push({
|
|
568
|
+
batchRunId: readString(parsed, "batchRunId") ?? batchRunId,
|
|
569
|
+
featureId: readString(parsed, "featureId"),
|
|
570
|
+
startedAt: readString(parsed, "startedAt"),
|
|
571
|
+
finishedAt: readString(parsed, "finishedAt"),
|
|
572
|
+
status: mapBatchStatus(readString(parsed, "status") ?? "unknown"),
|
|
573
|
+
summary: summaryObj
|
|
574
|
+
? {
|
|
575
|
+
total: readNumber(summaryObj, "total") ?? 0,
|
|
576
|
+
succeeded: readNumber(summaryObj, "succeeded") ?? 0,
|
|
577
|
+
failed: readNumber(summaryObj, "failed") ?? 0,
|
|
578
|
+
reused: readNumber(summaryObj, "reused") ?? 0,
|
|
579
|
+
}
|
|
580
|
+
: undefined,
|
|
581
|
+
tasks: [],
|
|
582
|
+
batchRunPath: path.join(runsDir, entry.name),
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
// skip
|
|
588
|
+
}
|
|
589
|
+
return batches;
|
|
590
|
+
}
|
|
591
|
+
async function loadStateRecords(repoRoot) {
|
|
592
|
+
const states = [];
|
|
593
|
+
const stateDir = path.join(repoRoot, ".task-pool", "state");
|
|
594
|
+
try {
|
|
595
|
+
const entries = await readdir(stateDir, { withFileTypes: true });
|
|
596
|
+
for (const entry of entries) {
|
|
597
|
+
if (!entry.isFile() || !entry.name.endsWith(".json"))
|
|
598
|
+
continue;
|
|
599
|
+
const parsed = await safeReadJson(path.join(stateDir, entry.name));
|
|
600
|
+
if (!parsed)
|
|
601
|
+
continue;
|
|
602
|
+
const taskId = readString(parsed, "taskId") ?? entry.name.replace(/\.json$/, "");
|
|
603
|
+
const status = readString(parsed, "status");
|
|
604
|
+
if (!status)
|
|
605
|
+
continue;
|
|
606
|
+
const failure = readObject(parsed, "failure");
|
|
607
|
+
states.push({
|
|
608
|
+
taskId,
|
|
609
|
+
status,
|
|
610
|
+
workerRunId: readString(parsed, "workerRunId"),
|
|
611
|
+
lastRunRecordPath: readString(parsed, "lastRunRecordPath"),
|
|
612
|
+
failureCategory: failure ? readString(failure, "category") : undefined,
|
|
613
|
+
recommendedFollowUp: failure ? readString(failure, "recommendedFollowUpKind") : undefined,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
// skip
|
|
619
|
+
}
|
|
620
|
+
return states;
|
|
621
|
+
}
|
|
622
|
+
async function loadFailureHandoffs(repoRoot) {
|
|
623
|
+
const handoffs = [];
|
|
624
|
+
const handoffDir = path.join(repoRoot, ".task-pool", "failure-handoffs");
|
|
625
|
+
try {
|
|
626
|
+
const entries = await readdir(handoffDir, { withFileTypes: true });
|
|
627
|
+
for (const entry of entries) {
|
|
628
|
+
if (!entry.isFile())
|
|
629
|
+
continue;
|
|
630
|
+
const match = /^(.+)-failure-closeout-draft\.md$/.exec(entry.name);
|
|
631
|
+
if (!match)
|
|
632
|
+
continue;
|
|
633
|
+
handoffs.push({
|
|
634
|
+
workerRunId: match[1],
|
|
635
|
+
closeoutDraftPath: path.join(handoffDir, entry.name),
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
// skip
|
|
641
|
+
}
|
|
642
|
+
return handoffs;
|
|
643
|
+
}
|
|
644
|
+
async function loadDagRuns(repoRoot) {
|
|
645
|
+
const byId = new Map();
|
|
646
|
+
const statePaths = await findStateJsonFiles(path.join(repoRoot, ".harness", "dag-runs"));
|
|
647
|
+
for (const statePath of statePaths) {
|
|
648
|
+
const summary = parseDagStateFile(statePath);
|
|
649
|
+
if (summary)
|
|
650
|
+
byId.set(summary.dagRunId, mergeDagRun(byId.get(summary.dagRunId), summary));
|
|
651
|
+
}
|
|
652
|
+
const dagEventRuns = await loadDagEventFiles(repoRoot);
|
|
653
|
+
for (const eventRun of dagEventRuns) {
|
|
654
|
+
const existing = byId.get(eventRun.dagRunId);
|
|
655
|
+
byId.set(eventRun.dagRunId, mergeDagRun(existing, eventRun));
|
|
656
|
+
}
|
|
657
|
+
return [...byId.values()].sort((a, b) => a.dagRunId.localeCompare(b.dagRunId));
|
|
658
|
+
}
|
|
659
|
+
function mergeDagRun(existing, incoming) {
|
|
660
|
+
if (!existing)
|
|
661
|
+
return incoming;
|
|
662
|
+
const startedAt = incoming.startedAt ?? existing.startedAt;
|
|
663
|
+
const finishedAt = incoming.finishedAt ?? existing.finishedAt;
|
|
664
|
+
const ranks = incoming.ranks && incoming.ranks.length > 0 ? incoming.ranks : existing.ranks;
|
|
665
|
+
const durationMs = incoming.durationMs ??
|
|
666
|
+
existing.durationMs ??
|
|
667
|
+
computeDurationMs(startedAt, finishedAt);
|
|
668
|
+
return {
|
|
669
|
+
dagRunId: incoming.dagRunId,
|
|
670
|
+
status: incoming.status ?? existing.status,
|
|
671
|
+
title: incoming.title ?? existing.title,
|
|
672
|
+
startedAt,
|
|
673
|
+
finishedAt,
|
|
674
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
675
|
+
...(ranks && ranks.length > 0 ? { ranks } : {}),
|
|
676
|
+
nodes: mergeDagNodes(existing.nodes, incoming.nodes),
|
|
677
|
+
...(incoming.dagPath ?? existing.dagPath ? { dagPath: incoming.dagPath ?? existing.dagPath } : {}),
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
function mergeDagNodes(existing, incoming) {
|
|
681
|
+
const byId = new Map();
|
|
682
|
+
for (const node of existing) {
|
|
683
|
+
byId.set(node.nodeId, node);
|
|
684
|
+
}
|
|
685
|
+
for (const node of incoming) {
|
|
686
|
+
const prev = byId.get(node.nodeId);
|
|
687
|
+
byId.set(node.nodeId, mergeDagNode(prev, node));
|
|
688
|
+
}
|
|
689
|
+
return [...byId.values()].sort((a, b) => a.nodeId.localeCompare(b.nodeId));
|
|
690
|
+
}
|
|
691
|
+
function mergeDagNode(existing, incoming) {
|
|
692
|
+
if (!existing)
|
|
693
|
+
return incoming;
|
|
694
|
+
return {
|
|
695
|
+
nodeId: incoming.nodeId,
|
|
696
|
+
rank: incoming.rank ?? existing.rank,
|
|
697
|
+
executor: incoming.executor ?? existing.executor,
|
|
698
|
+
model: incoming.model ?? existing.model,
|
|
699
|
+
status: incoming.status ?? existing.status,
|
|
700
|
+
label: incoming.label ?? existing.label,
|
|
701
|
+
durationMs: incoming.durationMs ?? existing.durationMs,
|
|
702
|
+
startedAt: incoming.startedAt ?? existing.startedAt,
|
|
703
|
+
finishedAt: incoming.finishedAt ?? existing.finishedAt,
|
|
704
|
+
outputPreview: incoming.outputPreview ?? existing.outputPreview,
|
|
705
|
+
errorPreview: incoming.errorPreview ?? existing.errorPreview,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
function computeDurationMs(startedAt, finishedAt) {
|
|
709
|
+
if (!startedAt || !finishedAt)
|
|
710
|
+
return undefined;
|
|
711
|
+
const startMs = Date.parse(startedAt);
|
|
712
|
+
const endMs = Date.parse(finishedAt);
|
|
713
|
+
if (Number.isNaN(startMs) || Number.isNaN(endMs))
|
|
714
|
+
return undefined;
|
|
715
|
+
return Math.max(0, endMs - startMs);
|
|
716
|
+
}
|
|
717
|
+
function isFailedNodeStatus(status) {
|
|
718
|
+
if (!status)
|
|
719
|
+
return false;
|
|
720
|
+
const normalized = status.toLowerCase();
|
|
721
|
+
return normalized === "error" || normalized === "failed" || normalized === "partial_failed";
|
|
722
|
+
}
|
|
723
|
+
function nodeOutputPreview(node) {
|
|
724
|
+
const stdout = readString(node, "stdout") ?? readString(node, "assistantText");
|
|
725
|
+
if (!stdout)
|
|
726
|
+
return undefined;
|
|
727
|
+
return truncateUtf8Preview(stdout);
|
|
728
|
+
}
|
|
729
|
+
function nodeErrorPreview(node, status) {
|
|
730
|
+
if (!isFailedNodeStatus(status))
|
|
731
|
+
return undefined;
|
|
732
|
+
const stderr = readString(node, "stderr");
|
|
733
|
+
if (!stderr)
|
|
734
|
+
return undefined;
|
|
735
|
+
return truncateUtf8Preview(stderr);
|
|
736
|
+
}
|
|
737
|
+
async function loadDagEventFiles(repoRoot) {
|
|
738
|
+
const runsDir = path.join(repoRoot, ".task-pool", "observability", "runs");
|
|
739
|
+
let entries;
|
|
740
|
+
try {
|
|
741
|
+
entries = await readdir(runsDir, { withFileTypes: true });
|
|
742
|
+
}
|
|
743
|
+
catch {
|
|
744
|
+
return [];
|
|
745
|
+
}
|
|
746
|
+
const byId = new Map();
|
|
747
|
+
const runStatus = new Map();
|
|
748
|
+
const runTitle = new Map();
|
|
749
|
+
const runStartedAt = new Map();
|
|
750
|
+
const runFinishedAt = new Map();
|
|
751
|
+
for (const entry of entries) {
|
|
752
|
+
if (!entry.isDirectory())
|
|
753
|
+
continue;
|
|
754
|
+
const eventsPath = path.join(runsDir, entry.name, "dag-events.jsonl");
|
|
755
|
+
const raw = await safeReadText(eventsPath);
|
|
756
|
+
if (!raw)
|
|
757
|
+
continue;
|
|
758
|
+
for (const line of raw.split("\n")) {
|
|
759
|
+
const trimmed = line.trim();
|
|
760
|
+
if (!trimmed)
|
|
761
|
+
continue;
|
|
762
|
+
const parsed = safeParseJson(trimmed);
|
|
763
|
+
if (!parsed)
|
|
764
|
+
continue;
|
|
765
|
+
const type = readString(parsed, "type");
|
|
766
|
+
if (!type || !type.startsWith("dag."))
|
|
767
|
+
continue;
|
|
768
|
+
const dagRunId = readString(parsed, "dagRunId") ?? entry.name;
|
|
769
|
+
const nodeId = readString(parsed, "nodeId");
|
|
770
|
+
if (type === "dag.run.started") {
|
|
771
|
+
runStatus.set(dagRunId, "running");
|
|
772
|
+
const label = readString(parsed, "label");
|
|
773
|
+
if (label)
|
|
774
|
+
runTitle.set(dagRunId, label);
|
|
775
|
+
const at = readString(parsed, "at");
|
|
776
|
+
if (at)
|
|
777
|
+
runStartedAt.set(dagRunId, at);
|
|
778
|
+
}
|
|
779
|
+
else if (type === "dag.run.finished") {
|
|
780
|
+
runStatus.set(dagRunId, readString(parsed, "status") ?? "completed");
|
|
781
|
+
const at = readString(parsed, "at");
|
|
782
|
+
if (at)
|
|
783
|
+
runFinishedAt.set(dagRunId, at);
|
|
784
|
+
}
|
|
785
|
+
else if (type.startsWith("dag.node.") && nodeId) {
|
|
786
|
+
const nodeMap = byId.get(dagRunId) ?? new Map();
|
|
787
|
+
const prev = nodeMap.get(nodeId) ?? { nodeId };
|
|
788
|
+
const rank = readString(parsed, "rank");
|
|
789
|
+
const executor = readString(parsed, "executor");
|
|
790
|
+
const model = readString(parsed, "model");
|
|
791
|
+
const label = readString(parsed, "label");
|
|
792
|
+
const status = readString(parsed, "status");
|
|
793
|
+
const durationMs = readNumber(parsed, "durationMs");
|
|
794
|
+
const outputPreview = readString(parsed, "outputPreview");
|
|
795
|
+
nodeMap.set(nodeId, {
|
|
796
|
+
nodeId,
|
|
797
|
+
...(rank ?? prev.rank ? { rank: rank ?? prev.rank } : {}),
|
|
798
|
+
...(executor ?? prev.executor ? { executor: executor ?? prev.executor } : {}),
|
|
799
|
+
...(model ?? prev.model ? { model: model ?? prev.model } : {}),
|
|
800
|
+
...(label ?? prev.label ? { label: label ?? prev.label } : {}),
|
|
801
|
+
...(status ?? prev.status ? { status: status ?? prev.status } : {}),
|
|
802
|
+
...(durationMs ?? prev.durationMs ? { durationMs: durationMs ?? prev.durationMs } : {}),
|
|
803
|
+
...(outputPreview ?? prev.outputPreview ? { outputPreview: outputPreview ?? prev.outputPreview } : {}),
|
|
804
|
+
});
|
|
805
|
+
byId.set(dagRunId, nodeMap);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
const result = [];
|
|
810
|
+
for (const [dagRunId, nodeMap] of byId) {
|
|
811
|
+
const startedAt = runStartedAt.get(dagRunId);
|
|
812
|
+
const finishedAt = runFinishedAt.get(dagRunId);
|
|
813
|
+
const durationMs = computeDurationMs(startedAt, finishedAt);
|
|
814
|
+
result.push({
|
|
815
|
+
dagRunId,
|
|
816
|
+
...(runStatus.get(dagRunId) ? { status: runStatus.get(dagRunId) } : {}),
|
|
817
|
+
...(runTitle.get(dagRunId) ? { title: runTitle.get(dagRunId) } : {}),
|
|
818
|
+
...(startedAt ? { startedAt } : {}),
|
|
819
|
+
...(finishedAt ? { finishedAt } : {}),
|
|
820
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
821
|
+
nodes: [...nodeMap.values()].sort((a, b) => a.nodeId.localeCompare(b.nodeId)),
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
return result;
|
|
825
|
+
}
|
|
826
|
+
async function findStateJsonFiles(root) {
|
|
827
|
+
const results = [];
|
|
828
|
+
await walkForStateJson(root, results);
|
|
829
|
+
return results;
|
|
830
|
+
}
|
|
831
|
+
async function walkForStateJson(dir, results) {
|
|
832
|
+
try {
|
|
833
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
834
|
+
for (const entry of entries) {
|
|
835
|
+
const fullPath = path.join(dir, entry.name);
|
|
836
|
+
if (entry.isDirectory()) {
|
|
837
|
+
await walkForStateJson(fullPath, results);
|
|
838
|
+
}
|
|
839
|
+
else if (entry.isFile() && entry.name === "state.json") {
|
|
840
|
+
results.push(fullPath);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
catch {
|
|
845
|
+
// skip
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
function parseDagStateFile(statePath) {
|
|
849
|
+
try {
|
|
850
|
+
if (!existsSync(statePath))
|
|
851
|
+
return undefined;
|
|
852
|
+
const raw = readFileSync(statePath, "utf-8");
|
|
853
|
+
const parsed = safeParseJson(raw);
|
|
854
|
+
if (!parsed)
|
|
855
|
+
return undefined;
|
|
856
|
+
const runDir = path.dirname(statePath);
|
|
857
|
+
const dagRunId = readString(parsed, "runId") ?? path.basename(runDir);
|
|
858
|
+
const status = readString(parsed, "status");
|
|
859
|
+
const title = readString(parsed, "title");
|
|
860
|
+
const startedAt = readString(parsed, "startedAt");
|
|
861
|
+
const finishedAt = readString(parsed, "finishedAt");
|
|
862
|
+
const durationMs = computeDurationMs(startedAt, finishedAt);
|
|
863
|
+
const ranks = readStringMatrix(parsed, "ranks");
|
|
864
|
+
const rankByNode = buildRankIndex(ranks);
|
|
865
|
+
const nodes = parseDagNodes(parsed, rankByNode);
|
|
866
|
+
return {
|
|
867
|
+
dagRunId,
|
|
868
|
+
status,
|
|
869
|
+
...(title ? { title } : {}),
|
|
870
|
+
...(startedAt ? { startedAt } : {}),
|
|
871
|
+
...(finishedAt ? { finishedAt } : {}),
|
|
872
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
873
|
+
...(ranks.length > 0 ? { ranks } : {}),
|
|
874
|
+
nodes,
|
|
875
|
+
dagPath: runDir,
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
catch {
|
|
879
|
+
return undefined;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
function buildRankIndex(ranks) {
|
|
883
|
+
const index = new Map();
|
|
884
|
+
for (let i = 0; i < ranks.length; i++) {
|
|
885
|
+
const rank = ranks[i];
|
|
886
|
+
if (!Array.isArray(rank))
|
|
887
|
+
continue;
|
|
888
|
+
const rankLabel = String(i);
|
|
889
|
+
for (const nodeId of rank) {
|
|
890
|
+
if (typeof nodeId === "string") {
|
|
891
|
+
index.set(nodeId, rankLabel);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return index;
|
|
896
|
+
}
|
|
897
|
+
function parseDagNodes(parsed, rankByNode) {
|
|
898
|
+
const nodes = [];
|
|
899
|
+
const nodesValue = parsed.nodes;
|
|
900
|
+
if (nodesValue && typeof nodesValue === "object" && !Array.isArray(nodesValue)) {
|
|
901
|
+
for (const [nodeId, nodeVal] of Object.entries(nodesValue)) {
|
|
902
|
+
if (!nodeVal || typeof nodeVal !== "object" || Array.isArray(nodeVal))
|
|
903
|
+
continue;
|
|
904
|
+
const node = nodeVal;
|
|
905
|
+
const resolvedId = readString(node, "id") ?? nodeId;
|
|
906
|
+
const nodeStatus = readString(node, "status");
|
|
907
|
+
const outputPreview = nodeOutputPreview(node);
|
|
908
|
+
const errorPreview = nodeErrorPreview(node, nodeStatus);
|
|
909
|
+
nodes.push({
|
|
910
|
+
nodeId: resolvedId,
|
|
911
|
+
rank: rankByNode.get(nodeId) ?? rankByNode.get(resolvedId),
|
|
912
|
+
executor: readString(node, "executor"),
|
|
913
|
+
model: readString(node, "model"),
|
|
914
|
+
status: nodeStatus,
|
|
915
|
+
label: readString(node, "label") ?? readString(node, "title") ?? nodeId,
|
|
916
|
+
durationMs: readNumber(node, "durationMs"),
|
|
917
|
+
startedAt: readString(node, "startedAt"),
|
|
918
|
+
finishedAt: readString(node, "finishedAt"),
|
|
919
|
+
...(outputPreview ? { outputPreview } : {}),
|
|
920
|
+
...(errorPreview ? { errorPreview } : {}),
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
return nodes.sort((a, b) => a.nodeId.localeCompare(b.nodeId));
|
|
924
|
+
}
|
|
925
|
+
const nodeArray = readObjectArray(parsed, "nodes");
|
|
926
|
+
for (const node of nodeArray) {
|
|
927
|
+
const nodeId = readString(node, "id") ?? readString(node, "nodeId") ?? "unknown";
|
|
928
|
+
const nodeStatus = readString(node, "status");
|
|
929
|
+
const outputPreview = nodeOutputPreview(node);
|
|
930
|
+
const errorPreview = nodeErrorPreview(node, nodeStatus);
|
|
931
|
+
nodes.push({
|
|
932
|
+
nodeId,
|
|
933
|
+
rank: rankByNode.get(nodeId),
|
|
934
|
+
executor: readString(node, "executor"),
|
|
935
|
+
model: readString(node, "model"),
|
|
936
|
+
status: nodeStatus,
|
|
937
|
+
label: readString(node, "label") ?? readString(node, "title") ?? nodeId,
|
|
938
|
+
durationMs: readNumber(node, "durationMs"),
|
|
939
|
+
startedAt: readString(node, "startedAt"),
|
|
940
|
+
finishedAt: readString(node, "finishedAt"),
|
|
941
|
+
...(outputPreview ? { outputPreview } : {}),
|
|
942
|
+
...(errorPreview ? { errorPreview } : {}),
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
return nodes.sort((a, b) => a.nodeId.localeCompare(b.nodeId));
|
|
946
|
+
}
|
|
947
|
+
async function safeReadText(filePath) {
|
|
948
|
+
try {
|
|
949
|
+
return await readFile(filePath, "utf-8");
|
|
950
|
+
}
|
|
951
|
+
catch {
|
|
952
|
+
return undefined;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
async function safeReadJson(filePath) {
|
|
956
|
+
const raw = await safeReadText(filePath);
|
|
957
|
+
if (!raw)
|
|
958
|
+
return undefined;
|
|
959
|
+
const parsed = safeParseJson(raw);
|
|
960
|
+
if (!parsed || Array.isArray(parsed))
|
|
961
|
+
return undefined;
|
|
962
|
+
return parsed;
|
|
963
|
+
}
|
|
964
|
+
function safeParseJson(raw) {
|
|
965
|
+
try {
|
|
966
|
+
const parsed = JSON.parse(raw);
|
|
967
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
968
|
+
return undefined;
|
|
969
|
+
}
|
|
970
|
+
return parsed;
|
|
971
|
+
}
|
|
972
|
+
catch {
|
|
973
|
+
return undefined;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
function readObject(value, key) {
|
|
977
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
978
|
+
return undefined;
|
|
979
|
+
const child = value[key];
|
|
980
|
+
if (!child || typeof child !== "object" || Array.isArray(child))
|
|
981
|
+
return undefined;
|
|
982
|
+
return child;
|
|
983
|
+
}
|
|
984
|
+
function readObjectArray(value, key) {
|
|
985
|
+
if (!value || typeof value !== "object")
|
|
986
|
+
return [];
|
|
987
|
+
const child = value[key];
|
|
988
|
+
if (!Array.isArray(child))
|
|
989
|
+
return [];
|
|
990
|
+
return child.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item));
|
|
991
|
+
}
|
|
992
|
+
function readString(value, key) {
|
|
993
|
+
if (!value || typeof value !== "object")
|
|
994
|
+
return undefined;
|
|
995
|
+
const child = value[key];
|
|
996
|
+
return typeof child === "string" ? child : undefined;
|
|
997
|
+
}
|
|
998
|
+
function readNumber(value, key) {
|
|
999
|
+
if (!value || typeof value !== "object")
|
|
1000
|
+
return undefined;
|
|
1001
|
+
const child = value[key];
|
|
1002
|
+
return typeof child === "number" && Number.isFinite(child) ? child : undefined;
|
|
1003
|
+
}
|
|
1004
|
+
function readStringMatrix(value, key) {
|
|
1005
|
+
if (!value || typeof value !== "object")
|
|
1006
|
+
return [];
|
|
1007
|
+
const child = value[key];
|
|
1008
|
+
if (!Array.isArray(child))
|
|
1009
|
+
return [];
|
|
1010
|
+
return child
|
|
1011
|
+
.filter((rank) => Array.isArray(rank))
|
|
1012
|
+
.map((rank) => rank.filter((id) => typeof id === "string"));
|
|
1013
|
+
}
|