taskplane 0.28.4 → 0.28.6
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/LICENSE +21 -21
- package/README.md +215 -215
- package/bin/gitignore-patterns.mjs +79 -79
- package/bin/rpc-wrapper.mjs +1086 -1086
- package/bin/taskplane.mjs +3254 -3254
- package/dashboard/public/app.js +2573 -2573
- package/dashboard/public/index.html +139 -139
- package/dashboard/public/style.css +1882 -1882
- package/dashboard/public/taskplane-word-color.svg +18 -18
- package/dashboard/public/taskplane-word-white.svg +18 -18
- package/dashboard/server.cjs +1666 -1666
- package/extensions/reviewer-extension.ts +119 -119
- package/extensions/task-orchestrator.ts +28 -28
- package/extensions/taskplane/abort.ts +502 -502
- package/extensions/taskplane/agent-bridge-extension.ts +838 -765
- package/extensions/taskplane/agent-host.ts +833 -745
- package/extensions/taskplane/cleanup.ts +747 -747
- package/extensions/taskplane/config-loader.ts +1328 -1322
- package/extensions/taskplane/config-schema.ts +692 -682
- package/extensions/taskplane/config.ts +73 -73
- package/extensions/taskplane/context-window.ts +66 -66
- package/extensions/taskplane/diagnostic-reports.ts +463 -463
- package/extensions/taskplane/diagnostics.ts +385 -385
- package/extensions/taskplane/engine-worker-entry.mjs +34 -34
- package/extensions/taskplane/engine-worker.ts +381 -381
- package/extensions/taskplane/engine.ts +4539 -4527
- package/extensions/taskplane/execution.ts +2733 -2708
- package/extensions/taskplane/extension.ts +30 -9
- package/extensions/taskplane/formatting.ts +773 -773
- package/extensions/taskplane/git.ts +90 -90
- package/extensions/taskplane/index.ts +28 -28
- package/extensions/taskplane/lane-runner.ts +1383 -1360
- package/extensions/taskplane/mailbox.ts +689 -689
- package/extensions/taskplane/merge.ts +3135 -3135
- package/extensions/taskplane/messages.ts +985 -985
- package/extensions/taskplane/migrations.ts +278 -278
- package/extensions/taskplane/naming.ts +117 -117
- package/extensions/taskplane/path-resolver.ts +237 -237
- package/extensions/taskplane/persistence.ts +2087 -2087
- package/extensions/taskplane/process-registry.ts +416 -416
- package/extensions/taskplane/quality-gate.ts +1033 -1033
- package/extensions/taskplane/resume.ts +2879 -2878
- package/extensions/taskplane/sessions.ts +57 -57
- package/extensions/taskplane/settings-loader.ts +136 -136
- package/extensions/taskplane/settings-tui.ts +1867 -1867
- package/extensions/taskplane/sidecar-telemetry.ts +252 -252
- package/extensions/taskplane/supervisor-primer.md +1694 -1694
- package/extensions/taskplane/supervisor.ts +4341 -4341
- package/extensions/taskplane/task-executor-core.ts +550 -550
- package/extensions/taskplane/tmux-compat.ts +37 -37
- package/extensions/taskplane/types.ts +4297 -4278
- package/extensions/taskplane/verification.ts +542 -542
- package/extensions/taskplane/waves.ts +1548 -1548
- package/extensions/taskplane/workspace.ts +705 -705
- package/extensions/taskplane/worktree.ts +2604 -2505
- package/package.json +57 -57
- package/skills/create-taskplane-task/SKILL.md +465 -465
- package/skills/create-taskplane-task/references/prompt-template.md +285 -285
- package/templates/agents/local/supervisor.md +33 -33
- package/templates/agents/local/task-merger.md +27 -27
- package/templates/agents/local/task-reviewer.md +30 -30
- package/templates/agents/local/task-worker.md +34 -34
- package/templates/agents/supervisor-routing.md +92 -92
- package/templates/agents/supervisor.md +168 -168
- package/templates/agents/task-merger.md +214 -214
- package/templates/agents/task-reviewer.md +192 -192
- package/templates/agents/task-worker.md +505 -429
- package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -98
- package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -73
- package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -97
- package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -73
|
@@ -1,463 +1,463 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Diagnostic report generation for batch completion/failure.
|
|
3
|
-
*
|
|
4
|
-
* Emits two artifacts at batch-terminal time:
|
|
5
|
-
* 1. JSONL event log: `.pi/diagnostics/{opId}-{batchId}-events.jsonl`
|
|
6
|
-
* 2. Human-readable summary: `.pi/diagnostics/{opId}-{batchId}-report.md`
|
|
7
|
-
*
|
|
8
|
-
* Write failures are non-fatal — errors are logged but never crash
|
|
9
|
-
* the batch finalization flow.
|
|
10
|
-
*
|
|
11
|
-
* @module orch/diagnostic-reports
|
|
12
|
-
*/
|
|
13
|
-
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
14
|
-
import { join } from "path";
|
|
15
|
-
|
|
16
|
-
import { execLog } from "./execution.ts";
|
|
17
|
-
import { resolveOperatorId } from "./naming.ts";
|
|
18
|
-
import type { AllocatedLane, LaneTaskOutcome, OrchBatchRuntimeState, OrchestratorConfig, PersistedTaskRecord, BatchDiagnostics, PersistedTaskExitSummary } from "./types.ts";
|
|
19
|
-
import { defaultBatchDiagnostics } from "./types.ts";
|
|
20
|
-
|
|
21
|
-
// ── Types ────────────────────────────────────────────────────────────
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* A single JSONL event representing one task's diagnostic record.
|
|
25
|
-
* Deterministically ordered by taskId for reproducible output.
|
|
26
|
-
*/
|
|
27
|
-
export interface DiagnosticEvent {
|
|
28
|
-
/** Batch identifier */
|
|
29
|
-
batchId: string;
|
|
30
|
-
/** Final batch phase at emission time */
|
|
31
|
-
phase: string;
|
|
32
|
-
/** Execution mode: "repo" or "workspace" */
|
|
33
|
-
mode: string;
|
|
34
|
-
/** Task identifier */
|
|
35
|
-
taskId: string;
|
|
36
|
-
/** Task execution status */
|
|
37
|
-
status: string;
|
|
38
|
-
/** Exit classification (from diagnostics.taskExits or exitDiagnostic, fallback: "unknown") */
|
|
39
|
-
classification: string;
|
|
40
|
-
/** Estimated cost in USD (0 if unavailable) */
|
|
41
|
-
cost: number;
|
|
42
|
-
/** Wall-clock duration in seconds (0 if unavailable) */
|
|
43
|
-
durationSec: number;
|
|
44
|
-
/** Number of retry attempts (0 if never retried) */
|
|
45
|
-
retries: number;
|
|
46
|
-
/** Repo ID for workspace mode (null in repo mode or if unresolved) */
|
|
47
|
-
repoId: string | null;
|
|
48
|
-
/** Human-readable exit reason */
|
|
49
|
-
exitReason: string;
|
|
50
|
-
/** Epoch ms when task started (null if never started) */
|
|
51
|
-
startedAt: number | null;
|
|
52
|
-
/** Epoch ms when task ended (null if still running or never started) */
|
|
53
|
-
endedAt: number | null;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Input data for diagnostic report generation.
|
|
58
|
-
*
|
|
59
|
-
* Assembled by the caller (engine.ts / resume.ts) from available
|
|
60
|
-
* runtime state at the batch-terminal checkpoint.
|
|
61
|
-
*/
|
|
62
|
-
export interface DiagnosticReportInput {
|
|
63
|
-
/** Orchestrator config (for opId resolution) */
|
|
64
|
-
orchConfig: OrchestratorConfig;
|
|
65
|
-
/** Batch ID */
|
|
66
|
-
batchId: string;
|
|
67
|
-
/** Final batch phase */
|
|
68
|
-
phase: string;
|
|
69
|
-
/** Execution mode */
|
|
70
|
-
mode: string;
|
|
71
|
-
/** Epoch ms when batch started */
|
|
72
|
-
startedAt: number;
|
|
73
|
-
/** Epoch ms when batch ended (null if still running) */
|
|
74
|
-
endedAt: number | null;
|
|
75
|
-
/** Per-task records from serialized state */
|
|
76
|
-
tasks: PersistedTaskRecord[];
|
|
77
|
-
/** Batch-level diagnostics (may have empty taskExits) */
|
|
78
|
-
diagnostics: BatchDiagnostics;
|
|
79
|
-
/** Summary counters */
|
|
80
|
-
succeededTasks: number;
|
|
81
|
-
failedTasks: number;
|
|
82
|
-
skippedTasks: number;
|
|
83
|
-
blockedTasks: number;
|
|
84
|
-
totalTasks: number;
|
|
85
|
-
/** State root path where `.pi/` lives */
|
|
86
|
-
stateRoot: string;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// ── Diagnostics Directory ────────────────────────────────────────────
|
|
90
|
-
|
|
91
|
-
/** Resolve the diagnostics directory path. */
|
|
92
|
-
export function diagnosticsDir(stateRoot: string): string {
|
|
93
|
-
return join(stateRoot, ".pi", "diagnostics");
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Ensure `.pi/diagnostics/` exists, creating it if needed. */
|
|
97
|
-
function ensureDiagnosticsDir(stateRoot: string): string {
|
|
98
|
-
const dir = diagnosticsDir(stateRoot);
|
|
99
|
-
if (!existsSync(dir)) {
|
|
100
|
-
mkdirSync(dir, { recursive: true });
|
|
101
|
-
}
|
|
102
|
-
return dir;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// ── Event Generation ─────────────────────────────────────────────────
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Build diagnostic events from task records and diagnostics data.
|
|
109
|
-
*
|
|
110
|
-
* Data source precedence for each task:
|
|
111
|
-
* 1. `diagnostics.taskExits[taskId]` — canonical v3 exit summary (classification, cost, duration, retries)
|
|
112
|
-
* 2. `task.exitDiagnostic.classification` — per-task exit diagnostic on the task record
|
|
113
|
-
* 3. Fallback defaults: classification="unknown", cost=0, durationSec computed from startedAt/endedAt, retries=0
|
|
114
|
-
*
|
|
115
|
-
* Tasks are sorted by taskId for deterministic output.
|
|
116
|
-
*/
|
|
117
|
-
export function buildDiagnosticEvents(input: DiagnosticReportInput): DiagnosticEvent[] {
|
|
118
|
-
const { batchId, phase, mode, tasks, diagnostics } = input;
|
|
119
|
-
const taskExits = diagnostics.taskExits ?? {};
|
|
120
|
-
|
|
121
|
-
// Sort tasks by taskId for deterministic ordering
|
|
122
|
-
const sortedTasks = [...tasks].sort((a, b) => a.taskId.localeCompare(b.taskId));
|
|
123
|
-
|
|
124
|
-
return sortedTasks.map((task): DiagnosticEvent => {
|
|
125
|
-
const exitSummary: PersistedTaskExitSummary | undefined = taskExits[task.taskId];
|
|
126
|
-
|
|
127
|
-
// Classification: prefer taskExits, then exitDiagnostic, then "unknown"
|
|
128
|
-
let classification = "unknown";
|
|
129
|
-
if (exitSummary) {
|
|
130
|
-
classification = exitSummary.classification;
|
|
131
|
-
} else if (task.exitDiagnostic?.classification) {
|
|
132
|
-
classification = task.exitDiagnostic.classification;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Cost: from taskExits, else 0
|
|
136
|
-
const cost = exitSummary?.cost ?? 0;
|
|
137
|
-
|
|
138
|
-
// Duration: from taskExits, else compute from timestamps, else 0
|
|
139
|
-
let durationSec = 0;
|
|
140
|
-
if (exitSummary) {
|
|
141
|
-
durationSec = exitSummary.durationSec;
|
|
142
|
-
} else if (task.startedAt !== null && task.endedAt !== null) {
|
|
143
|
-
durationSec = Math.round((task.endedAt - task.startedAt) / 1000);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Retries: from taskExits, else 0
|
|
147
|
-
const retries = exitSummary?.retries ?? 0;
|
|
148
|
-
|
|
149
|
-
// Repo ID: prefer resolvedRepoId, then repoId (workspace mode), else null
|
|
150
|
-
const repoId = task.resolvedRepoId ?? task.repoId ?? null;
|
|
151
|
-
|
|
152
|
-
return {
|
|
153
|
-
batchId,
|
|
154
|
-
phase,
|
|
155
|
-
mode,
|
|
156
|
-
taskId: task.taskId,
|
|
157
|
-
status: task.status,
|
|
158
|
-
classification,
|
|
159
|
-
cost,
|
|
160
|
-
durationSec,
|
|
161
|
-
retries,
|
|
162
|
-
repoId,
|
|
163
|
-
exitReason: task.exitReason,
|
|
164
|
-
startedAt: task.startedAt,
|
|
165
|
-
endedAt: task.endedAt,
|
|
166
|
-
};
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// ── JSONL Generation ─────────────────────────────────────────────────
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Serialize diagnostic events to JSONL format (one JSON object per line).
|
|
174
|
-
*/
|
|
175
|
-
export function eventsToJsonl(events: DiagnosticEvent[]): string {
|
|
176
|
-
return events.map(e => JSON.stringify(e)).join("\n") + "\n";
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// ── Human-Readable Summary ───────────────────────────────────────────
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Format a duration in seconds to a human-readable string.
|
|
183
|
-
* e.g., 3661 → "1h 1m 1s", 42 → "42s"
|
|
184
|
-
*/
|
|
185
|
-
function formatDuration(seconds: number): string {
|
|
186
|
-
if (seconds <= 0) return "0s";
|
|
187
|
-
const h = Math.floor(seconds / 3600);
|
|
188
|
-
const m = Math.floor((seconds % 3600) / 60);
|
|
189
|
-
const s = seconds % 60;
|
|
190
|
-
const parts: string[] = [];
|
|
191
|
-
if (h > 0) parts.push(`${h}h`);
|
|
192
|
-
if (m > 0) parts.push(`${m}m`);
|
|
193
|
-
if (s > 0 || parts.length === 0) parts.push(`${s}s`);
|
|
194
|
-
return parts.join(" ");
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Format a cost value to a display string.
|
|
199
|
-
* Shows "$0.00" for zero, otherwise up to 4 decimal places.
|
|
200
|
-
*/
|
|
201
|
-
function formatCost(cost: number): string {
|
|
202
|
-
if (cost === 0) return "$0.00";
|
|
203
|
-
return `$${cost.toFixed(4)}`;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Generate a human-readable markdown summary report.
|
|
208
|
-
*/
|
|
209
|
-
export function buildMarkdownReport(input: DiagnosticReportInput, events: DiagnosticEvent[]): string {
|
|
210
|
-
const { batchId, phase, mode, startedAt, endedAt, diagnostics } = input;
|
|
211
|
-
const { succeededTasks, failedTasks, skippedTasks, blockedTasks, totalTasks } = input;
|
|
212
|
-
|
|
213
|
-
const batchDurationSec = endedAt ? Math.round((endedAt - startedAt) / 1000) : 0;
|
|
214
|
-
const batchCost = diagnostics.batchCost ?? 0;
|
|
215
|
-
|
|
216
|
-
const lines: string[] = [];
|
|
217
|
-
|
|
218
|
-
// ── Header ──
|
|
219
|
-
lines.push(`# Batch Diagnostic Report`);
|
|
220
|
-
lines.push(``);
|
|
221
|
-
|
|
222
|
-
// ── Batch Overview ──
|
|
223
|
-
lines.push(`## Batch Overview`);
|
|
224
|
-
lines.push(``);
|
|
225
|
-
lines.push(`| Field | Value |`);
|
|
226
|
-
lines.push(`|-------|-------|`);
|
|
227
|
-
lines.push(`| Batch ID | \`${batchId}\` |`);
|
|
228
|
-
lines.push(`| Final Phase | ${phase} |`);
|
|
229
|
-
lines.push(`| Mode | ${mode} |`);
|
|
230
|
-
lines.push(`| Duration | ${formatDuration(batchDurationSec)} |`);
|
|
231
|
-
lines.push(`| Total Cost | ${formatCost(batchCost)} |`);
|
|
232
|
-
lines.push(`| Total Tasks | ${totalTasks} |`);
|
|
233
|
-
lines.push(`| Succeeded | ${succeededTasks} |`);
|
|
234
|
-
lines.push(`| Failed | ${failedTasks} |`);
|
|
235
|
-
lines.push(`| Skipped | ${skippedTasks} |`);
|
|
236
|
-
lines.push(`| Blocked | ${blockedTasks} |`);
|
|
237
|
-
lines.push(``);
|
|
238
|
-
|
|
239
|
-
// ── Per-Task Table ──
|
|
240
|
-
lines.push(`## Per-Task Results`);
|
|
241
|
-
lines.push(``);
|
|
242
|
-
|
|
243
|
-
if (events.length === 0) {
|
|
244
|
-
lines.push(`_No task records available._`);
|
|
245
|
-
lines.push(``);
|
|
246
|
-
} else {
|
|
247
|
-
lines.push(`| Task | Status | Classification | Cost | Duration | Retries |`);
|
|
248
|
-
lines.push(`|------|--------|---------------|------|----------|---------|`);
|
|
249
|
-
for (const evt of events) {
|
|
250
|
-
lines.push(
|
|
251
|
-
`| ${evt.taskId} | ${evt.status} | ${evt.classification} | ${formatCost(evt.cost)} | ${formatDuration(evt.durationSec)} | ${evt.retries} |`
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
lines.push(``);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// ── Per-Repo Breakdown (workspace mode only) ──
|
|
258
|
-
if (mode === "workspace") {
|
|
259
|
-
lines.push(`## Per-Repo Breakdown`);
|
|
260
|
-
lines.push(``);
|
|
261
|
-
|
|
262
|
-
// Group events by repoId
|
|
263
|
-
const byRepo = new Map<string, DiagnosticEvent[]>();
|
|
264
|
-
for (const evt of events) {
|
|
265
|
-
const key = evt.repoId ?? "(unresolved)";
|
|
266
|
-
if (!byRepo.has(key)) byRepo.set(key, []);
|
|
267
|
-
byRepo.get(key)!.push(evt);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Sort repo keys for deterministic output
|
|
271
|
-
const repoKeys = [...byRepo.keys()].sort();
|
|
272
|
-
|
|
273
|
-
if (repoKeys.length === 0) {
|
|
274
|
-
lines.push(`_No per-repo data available._`);
|
|
275
|
-
lines.push(``);
|
|
276
|
-
} else {
|
|
277
|
-
for (const repoKey of repoKeys) {
|
|
278
|
-
const repoEvents = byRepo.get(repoKey)!;
|
|
279
|
-
const repoSucceeded = repoEvents.filter(e => e.status === "succeeded").length;
|
|
280
|
-
const repoFailed = repoEvents.filter(e => e.status === "failed").length;
|
|
281
|
-
const repoCost = repoEvents.reduce((sum, e) => sum + e.cost, 0);
|
|
282
|
-
|
|
283
|
-
lines.push(`### ${repoKey}`);
|
|
284
|
-
lines.push(``);
|
|
285
|
-
lines.push(`- Tasks: ${repoEvents.length} (${repoSucceeded} succeeded, ${repoFailed} failed)`);
|
|
286
|
-
lines.push(`- Cost: ${formatCost(repoCost)}`);
|
|
287
|
-
lines.push(``);
|
|
288
|
-
|
|
289
|
-
lines.push(`| Task | Status | Classification | Cost | Duration |`);
|
|
290
|
-
lines.push(`|------|--------|---------------|------|----------|`);
|
|
291
|
-
for (const evt of repoEvents) {
|
|
292
|
-
lines.push(
|
|
293
|
-
`| ${evt.taskId} | ${evt.status} | ${evt.classification} | ${formatCost(evt.cost)} | ${formatDuration(evt.durationSec)} |`
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
lines.push(``);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// ── Footer ──
|
|
302
|
-
lines.push(`---`);
|
|
303
|
-
lines.push(`_Generated at ${new Date().toISOString()}_`);
|
|
304
|
-
lines.push(``);
|
|
305
|
-
|
|
306
|
-
return lines.join("\n");
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// ── Report Emission ──────────────────────────────────────────────────
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* Emit diagnostic reports (JSONL event log + markdown summary) at batch terminal.
|
|
313
|
-
*
|
|
314
|
-
* This function is called exactly once per batch run, immediately after
|
|
315
|
-
* the `persistRuntimeState("batch-terminal", ...)` call in both engine.ts
|
|
316
|
-
* and resume.ts.
|
|
317
|
-
*
|
|
318
|
-
* **Non-fatal:** All errors during report generation or writing are caught
|
|
319
|
-
* and logged via `execLog()`. They never propagate to the caller or crash
|
|
320
|
-
* the batch finalization flow.
|
|
321
|
-
*
|
|
322
|
-
* @param input - Diagnostic report input assembled from runtime state
|
|
323
|
-
*/
|
|
324
|
-
export function emitDiagnosticReports(input: DiagnosticReportInput): void {
|
|
325
|
-
try {
|
|
326
|
-
const opId = resolveOperatorId(input.orchConfig);
|
|
327
|
-
const dir = ensureDiagnosticsDir(input.stateRoot);
|
|
328
|
-
|
|
329
|
-
const events = buildDiagnosticEvents(input);
|
|
330
|
-
|
|
331
|
-
// ── JSONL event log ──
|
|
332
|
-
const jsonlPath = join(dir, `${opId}-${input.batchId}-events.jsonl`);
|
|
333
|
-
const jsonlContent = eventsToJsonl(events);
|
|
334
|
-
writeFileSync(jsonlPath, jsonlContent, "utf-8");
|
|
335
|
-
|
|
336
|
-
// ── Markdown summary ──
|
|
337
|
-
const reportPath = join(dir, `${opId}-${input.batchId}-report.md`);
|
|
338
|
-
const reportContent = buildMarkdownReport(input, events);
|
|
339
|
-
writeFileSync(reportPath, reportContent, "utf-8");
|
|
340
|
-
|
|
341
|
-
execLog("diagnostics", input.batchId, `emitted diagnostic reports`, {
|
|
342
|
-
jsonl: jsonlPath,
|
|
343
|
-
report: reportPath,
|
|
344
|
-
taskCount: events.length,
|
|
345
|
-
});
|
|
346
|
-
} catch (err: unknown) {
|
|
347
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
348
|
-
execLog("diagnostics", input.batchId, `failed to emit diagnostic reports: ${msg}`);
|
|
349
|
-
// Non-fatal: do not rethrow. The batch finalization continues.
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* Assemble diagnostic report input from batch runtime state.
|
|
355
|
-
*
|
|
356
|
-
* Convenience helper for engine.ts and resume.ts to call at the
|
|
357
|
-
* batch-terminal checkpoint. Builds the full task registry from the
|
|
358
|
-
* wave plan + allocated lanes + task outcomes — matching the canonical
|
|
359
|
-
* model used by `serializeBatchState()`. This ensures diagnostics cover
|
|
360
|
-
* all tasks (including pending/blocked tasks that were never allocated)
|
|
361
|
-
* and preserve repo attribution fields for workspace per-repo breakdown.
|
|
362
|
-
*
|
|
363
|
-
* @param orchConfig - Orchestrator configuration
|
|
364
|
-
* @param batchState - Current runtime batch state (at batch-terminal)
|
|
365
|
-
* @param wavePlan - Wave plan (array of waves, each an array of taskIds)
|
|
366
|
-
* @param lanes - Allocated lanes with task/repo metadata
|
|
367
|
-
* @param allTaskOutcomes - All task outcomes accumulated during execution
|
|
368
|
-
* @param stateRoot - State root path where `.pi/` lives
|
|
369
|
-
*/
|
|
370
|
-
export function assembleDiagnosticInput(
|
|
371
|
-
orchConfig: OrchestratorConfig,
|
|
372
|
-
batchState: OrchBatchRuntimeState,
|
|
373
|
-
wavePlan: string[][],
|
|
374
|
-
lanes: AllocatedLane[],
|
|
375
|
-
allTaskOutcomes: LaneTaskOutcome[],
|
|
376
|
-
stateRoot: string,
|
|
377
|
-
): DiagnosticReportInput {
|
|
378
|
-
// Build lookup maps for fast per-task enrichment (mirrors serializeBatchState logic).
|
|
379
|
-
const laneByTaskId = new Map<string, AllocatedLane>();
|
|
380
|
-
const allocatedTaskByTaskId = new Map<string, { allocatedTask: import("./types.ts").AllocatedTask; lane: AllocatedLane }>();
|
|
381
|
-
for (const lane of lanes) {
|
|
382
|
-
for (const allocTask of lane.tasks) {
|
|
383
|
-
laneByTaskId.set(allocTask.taskId, lane);
|
|
384
|
-
allocatedTaskByTaskId.set(allocTask.taskId, { allocatedTask: allocTask, lane });
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// Latest outcome wins (allTaskOutcomes is append/replace ordered by time).
|
|
389
|
-
const outcomeByTaskId = new Map<string, LaneTaskOutcome>();
|
|
390
|
-
for (const outcome of allTaskOutcomes) {
|
|
391
|
-
outcomeByTaskId.set(outcome.taskId, outcome);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// Build full task ID set from wave plan + outcomes (covers pending/blocked tasks).
|
|
395
|
-
const taskIdSet = new Set<string>();
|
|
396
|
-
for (const wave of wavePlan) {
|
|
397
|
-
for (const taskId of wave) taskIdSet.add(taskId);
|
|
398
|
-
}
|
|
399
|
-
for (const outcome of allTaskOutcomes) {
|
|
400
|
-
taskIdSet.add(outcome.taskId);
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// Build task records sorted by taskId for deterministic output.
|
|
404
|
-
const tasks: PersistedTaskRecord[] = [...taskIdSet]
|
|
405
|
-
.sort()
|
|
406
|
-
.map((taskId): PersistedTaskRecord => {
|
|
407
|
-
const lane = laneByTaskId.get(taskId);
|
|
408
|
-
const outcome = outcomeByTaskId.get(taskId);
|
|
409
|
-
const allocated = allocatedTaskByTaskId.get(taskId);
|
|
410
|
-
|
|
411
|
-
const record: PersistedTaskRecord = {
|
|
412
|
-
taskId,
|
|
413
|
-
laneNumber: lane?.laneNumber ?? 0,
|
|
414
|
-
sessionName: outcome?.sessionName || lane?.laneSessionId || "",
|
|
415
|
-
status: outcome?.status ?? "pending",
|
|
416
|
-
taskFolder: "",
|
|
417
|
-
startedAt: outcome?.startTime ?? null,
|
|
418
|
-
endedAt: outcome?.endTime ?? null,
|
|
419
|
-
doneFileFound: outcome?.doneFileFound ?? false,
|
|
420
|
-
exitReason: outcome?.exitReason ?? "",
|
|
421
|
-
};
|
|
422
|
-
|
|
423
|
-
// Repo attribution from allocated task metadata (workspace mode).
|
|
424
|
-
if (allocated?.allocatedTask.task?.promptRepoId !== undefined) {
|
|
425
|
-
record.repoId = allocated.allocatedTask.task.promptRepoId;
|
|
426
|
-
}
|
|
427
|
-
if (allocated?.allocatedTask.task?.resolvedRepoId !== undefined) {
|
|
428
|
-
record.resolvedRepoId = allocated.allocatedTask.task.resolvedRepoId;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
// Partial progress fields from outcome.
|
|
432
|
-
if (outcome?.partialProgressCommits !== undefined) {
|
|
433
|
-
record.partialProgressCommits = outcome.partialProgressCommits;
|
|
434
|
-
}
|
|
435
|
-
if (outcome?.partialProgressBranch !== undefined) {
|
|
436
|
-
record.partialProgressBranch = outcome.partialProgressBranch;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
// v3: Exit diagnostic from outcome.
|
|
440
|
-
if (outcome?.exitDiagnostic !== undefined) {
|
|
441
|
-
record.exitDiagnostic = outcome.exitDiagnostic;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
return record;
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
return {
|
|
448
|
-
orchConfig,
|
|
449
|
-
batchId: batchState.batchId,
|
|
450
|
-
phase: batchState.phase,
|
|
451
|
-
mode: batchState.mode ?? "repo",
|
|
452
|
-
startedAt: batchState.startedAt,
|
|
453
|
-
endedAt: batchState.endedAt,
|
|
454
|
-
tasks,
|
|
455
|
-
diagnostics: batchState.diagnostics ?? defaultBatchDiagnostics(),
|
|
456
|
-
succeededTasks: batchState.succeededTasks,
|
|
457
|
-
failedTasks: batchState.failedTasks,
|
|
458
|
-
skippedTasks: batchState.skippedTasks,
|
|
459
|
-
blockedTasks: batchState.blockedTasks,
|
|
460
|
-
totalTasks: batchState.totalTasks,
|
|
461
|
-
stateRoot,
|
|
462
|
-
};
|
|
463
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Diagnostic report generation for batch completion/failure.
|
|
3
|
+
*
|
|
4
|
+
* Emits two artifacts at batch-terminal time:
|
|
5
|
+
* 1. JSONL event log: `.pi/diagnostics/{opId}-{batchId}-events.jsonl`
|
|
6
|
+
* 2. Human-readable summary: `.pi/diagnostics/{opId}-{batchId}-report.md`
|
|
7
|
+
*
|
|
8
|
+
* Write failures are non-fatal — errors are logged but never crash
|
|
9
|
+
* the batch finalization flow.
|
|
10
|
+
*
|
|
11
|
+
* @module orch/diagnostic-reports
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
|
|
16
|
+
import { execLog } from "./execution.ts";
|
|
17
|
+
import { resolveOperatorId } from "./naming.ts";
|
|
18
|
+
import type { AllocatedLane, LaneTaskOutcome, OrchBatchRuntimeState, OrchestratorConfig, PersistedTaskRecord, BatchDiagnostics, PersistedTaskExitSummary } from "./types.ts";
|
|
19
|
+
import { defaultBatchDiagnostics } from "./types.ts";
|
|
20
|
+
|
|
21
|
+
// ── Types ────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A single JSONL event representing one task's diagnostic record.
|
|
25
|
+
* Deterministically ordered by taskId for reproducible output.
|
|
26
|
+
*/
|
|
27
|
+
export interface DiagnosticEvent {
|
|
28
|
+
/** Batch identifier */
|
|
29
|
+
batchId: string;
|
|
30
|
+
/** Final batch phase at emission time */
|
|
31
|
+
phase: string;
|
|
32
|
+
/** Execution mode: "repo" or "workspace" */
|
|
33
|
+
mode: string;
|
|
34
|
+
/** Task identifier */
|
|
35
|
+
taskId: string;
|
|
36
|
+
/** Task execution status */
|
|
37
|
+
status: string;
|
|
38
|
+
/** Exit classification (from diagnostics.taskExits or exitDiagnostic, fallback: "unknown") */
|
|
39
|
+
classification: string;
|
|
40
|
+
/** Estimated cost in USD (0 if unavailable) */
|
|
41
|
+
cost: number;
|
|
42
|
+
/** Wall-clock duration in seconds (0 if unavailable) */
|
|
43
|
+
durationSec: number;
|
|
44
|
+
/** Number of retry attempts (0 if never retried) */
|
|
45
|
+
retries: number;
|
|
46
|
+
/** Repo ID for workspace mode (null in repo mode or if unresolved) */
|
|
47
|
+
repoId: string | null;
|
|
48
|
+
/** Human-readable exit reason */
|
|
49
|
+
exitReason: string;
|
|
50
|
+
/** Epoch ms when task started (null if never started) */
|
|
51
|
+
startedAt: number | null;
|
|
52
|
+
/** Epoch ms when task ended (null if still running or never started) */
|
|
53
|
+
endedAt: number | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Input data for diagnostic report generation.
|
|
58
|
+
*
|
|
59
|
+
* Assembled by the caller (engine.ts / resume.ts) from available
|
|
60
|
+
* runtime state at the batch-terminal checkpoint.
|
|
61
|
+
*/
|
|
62
|
+
export interface DiagnosticReportInput {
|
|
63
|
+
/** Orchestrator config (for opId resolution) */
|
|
64
|
+
orchConfig: OrchestratorConfig;
|
|
65
|
+
/** Batch ID */
|
|
66
|
+
batchId: string;
|
|
67
|
+
/** Final batch phase */
|
|
68
|
+
phase: string;
|
|
69
|
+
/** Execution mode */
|
|
70
|
+
mode: string;
|
|
71
|
+
/** Epoch ms when batch started */
|
|
72
|
+
startedAt: number;
|
|
73
|
+
/** Epoch ms when batch ended (null if still running) */
|
|
74
|
+
endedAt: number | null;
|
|
75
|
+
/** Per-task records from serialized state */
|
|
76
|
+
tasks: PersistedTaskRecord[];
|
|
77
|
+
/** Batch-level diagnostics (may have empty taskExits) */
|
|
78
|
+
diagnostics: BatchDiagnostics;
|
|
79
|
+
/** Summary counters */
|
|
80
|
+
succeededTasks: number;
|
|
81
|
+
failedTasks: number;
|
|
82
|
+
skippedTasks: number;
|
|
83
|
+
blockedTasks: number;
|
|
84
|
+
totalTasks: number;
|
|
85
|
+
/** State root path where `.pi/` lives */
|
|
86
|
+
stateRoot: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Diagnostics Directory ────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/** Resolve the diagnostics directory path. */
|
|
92
|
+
export function diagnosticsDir(stateRoot: string): string {
|
|
93
|
+
return join(stateRoot, ".pi", "diagnostics");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Ensure `.pi/diagnostics/` exists, creating it if needed. */
|
|
97
|
+
function ensureDiagnosticsDir(stateRoot: string): string {
|
|
98
|
+
const dir = diagnosticsDir(stateRoot);
|
|
99
|
+
if (!existsSync(dir)) {
|
|
100
|
+
mkdirSync(dir, { recursive: true });
|
|
101
|
+
}
|
|
102
|
+
return dir;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Event Generation ─────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Build diagnostic events from task records and diagnostics data.
|
|
109
|
+
*
|
|
110
|
+
* Data source precedence for each task:
|
|
111
|
+
* 1. `diagnostics.taskExits[taskId]` — canonical v3 exit summary (classification, cost, duration, retries)
|
|
112
|
+
* 2. `task.exitDiagnostic.classification` — per-task exit diagnostic on the task record
|
|
113
|
+
* 3. Fallback defaults: classification="unknown", cost=0, durationSec computed from startedAt/endedAt, retries=0
|
|
114
|
+
*
|
|
115
|
+
* Tasks are sorted by taskId for deterministic output.
|
|
116
|
+
*/
|
|
117
|
+
export function buildDiagnosticEvents(input: DiagnosticReportInput): DiagnosticEvent[] {
|
|
118
|
+
const { batchId, phase, mode, tasks, diagnostics } = input;
|
|
119
|
+
const taskExits = diagnostics.taskExits ?? {};
|
|
120
|
+
|
|
121
|
+
// Sort tasks by taskId for deterministic ordering
|
|
122
|
+
const sortedTasks = [...tasks].sort((a, b) => a.taskId.localeCompare(b.taskId));
|
|
123
|
+
|
|
124
|
+
return sortedTasks.map((task): DiagnosticEvent => {
|
|
125
|
+
const exitSummary: PersistedTaskExitSummary | undefined = taskExits[task.taskId];
|
|
126
|
+
|
|
127
|
+
// Classification: prefer taskExits, then exitDiagnostic, then "unknown"
|
|
128
|
+
let classification = "unknown";
|
|
129
|
+
if (exitSummary) {
|
|
130
|
+
classification = exitSummary.classification;
|
|
131
|
+
} else if (task.exitDiagnostic?.classification) {
|
|
132
|
+
classification = task.exitDiagnostic.classification;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Cost: from taskExits, else 0
|
|
136
|
+
const cost = exitSummary?.cost ?? 0;
|
|
137
|
+
|
|
138
|
+
// Duration: from taskExits, else compute from timestamps, else 0
|
|
139
|
+
let durationSec = 0;
|
|
140
|
+
if (exitSummary) {
|
|
141
|
+
durationSec = exitSummary.durationSec;
|
|
142
|
+
} else if (task.startedAt !== null && task.endedAt !== null) {
|
|
143
|
+
durationSec = Math.round((task.endedAt - task.startedAt) / 1000);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Retries: from taskExits, else 0
|
|
147
|
+
const retries = exitSummary?.retries ?? 0;
|
|
148
|
+
|
|
149
|
+
// Repo ID: prefer resolvedRepoId, then repoId (workspace mode), else null
|
|
150
|
+
const repoId = task.resolvedRepoId ?? task.repoId ?? null;
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
batchId,
|
|
154
|
+
phase,
|
|
155
|
+
mode,
|
|
156
|
+
taskId: task.taskId,
|
|
157
|
+
status: task.status,
|
|
158
|
+
classification,
|
|
159
|
+
cost,
|
|
160
|
+
durationSec,
|
|
161
|
+
retries,
|
|
162
|
+
repoId,
|
|
163
|
+
exitReason: task.exitReason,
|
|
164
|
+
startedAt: task.startedAt,
|
|
165
|
+
endedAt: task.endedAt,
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── JSONL Generation ─────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Serialize diagnostic events to JSONL format (one JSON object per line).
|
|
174
|
+
*/
|
|
175
|
+
export function eventsToJsonl(events: DiagnosticEvent[]): string {
|
|
176
|
+
return events.map(e => JSON.stringify(e)).join("\n") + "\n";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Human-Readable Summary ───────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Format a duration in seconds to a human-readable string.
|
|
183
|
+
* e.g., 3661 → "1h 1m 1s", 42 → "42s"
|
|
184
|
+
*/
|
|
185
|
+
function formatDuration(seconds: number): string {
|
|
186
|
+
if (seconds <= 0) return "0s";
|
|
187
|
+
const h = Math.floor(seconds / 3600);
|
|
188
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
189
|
+
const s = seconds % 60;
|
|
190
|
+
const parts: string[] = [];
|
|
191
|
+
if (h > 0) parts.push(`${h}h`);
|
|
192
|
+
if (m > 0) parts.push(`${m}m`);
|
|
193
|
+
if (s > 0 || parts.length === 0) parts.push(`${s}s`);
|
|
194
|
+
return parts.join(" ");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Format a cost value to a display string.
|
|
199
|
+
* Shows "$0.00" for zero, otherwise up to 4 decimal places.
|
|
200
|
+
*/
|
|
201
|
+
function formatCost(cost: number): string {
|
|
202
|
+
if (cost === 0) return "$0.00";
|
|
203
|
+
return `$${cost.toFixed(4)}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Generate a human-readable markdown summary report.
|
|
208
|
+
*/
|
|
209
|
+
export function buildMarkdownReport(input: DiagnosticReportInput, events: DiagnosticEvent[]): string {
|
|
210
|
+
const { batchId, phase, mode, startedAt, endedAt, diagnostics } = input;
|
|
211
|
+
const { succeededTasks, failedTasks, skippedTasks, blockedTasks, totalTasks } = input;
|
|
212
|
+
|
|
213
|
+
const batchDurationSec = endedAt ? Math.round((endedAt - startedAt) / 1000) : 0;
|
|
214
|
+
const batchCost = diagnostics.batchCost ?? 0;
|
|
215
|
+
|
|
216
|
+
const lines: string[] = [];
|
|
217
|
+
|
|
218
|
+
// ── Header ──
|
|
219
|
+
lines.push(`# Batch Diagnostic Report`);
|
|
220
|
+
lines.push(``);
|
|
221
|
+
|
|
222
|
+
// ── Batch Overview ──
|
|
223
|
+
lines.push(`## Batch Overview`);
|
|
224
|
+
lines.push(``);
|
|
225
|
+
lines.push(`| Field | Value |`);
|
|
226
|
+
lines.push(`|-------|-------|`);
|
|
227
|
+
lines.push(`| Batch ID | \`${batchId}\` |`);
|
|
228
|
+
lines.push(`| Final Phase | ${phase} |`);
|
|
229
|
+
lines.push(`| Mode | ${mode} |`);
|
|
230
|
+
lines.push(`| Duration | ${formatDuration(batchDurationSec)} |`);
|
|
231
|
+
lines.push(`| Total Cost | ${formatCost(batchCost)} |`);
|
|
232
|
+
lines.push(`| Total Tasks | ${totalTasks} |`);
|
|
233
|
+
lines.push(`| Succeeded | ${succeededTasks} |`);
|
|
234
|
+
lines.push(`| Failed | ${failedTasks} |`);
|
|
235
|
+
lines.push(`| Skipped | ${skippedTasks} |`);
|
|
236
|
+
lines.push(`| Blocked | ${blockedTasks} |`);
|
|
237
|
+
lines.push(``);
|
|
238
|
+
|
|
239
|
+
// ── Per-Task Table ──
|
|
240
|
+
lines.push(`## Per-Task Results`);
|
|
241
|
+
lines.push(``);
|
|
242
|
+
|
|
243
|
+
if (events.length === 0) {
|
|
244
|
+
lines.push(`_No task records available._`);
|
|
245
|
+
lines.push(``);
|
|
246
|
+
} else {
|
|
247
|
+
lines.push(`| Task | Status | Classification | Cost | Duration | Retries |`);
|
|
248
|
+
lines.push(`|------|--------|---------------|------|----------|---------|`);
|
|
249
|
+
for (const evt of events) {
|
|
250
|
+
lines.push(
|
|
251
|
+
`| ${evt.taskId} | ${evt.status} | ${evt.classification} | ${formatCost(evt.cost)} | ${formatDuration(evt.durationSec)} | ${evt.retries} |`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
lines.push(``);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── Per-Repo Breakdown (workspace mode only) ──
|
|
258
|
+
if (mode === "workspace") {
|
|
259
|
+
lines.push(`## Per-Repo Breakdown`);
|
|
260
|
+
lines.push(``);
|
|
261
|
+
|
|
262
|
+
// Group events by repoId
|
|
263
|
+
const byRepo = new Map<string, DiagnosticEvent[]>();
|
|
264
|
+
for (const evt of events) {
|
|
265
|
+
const key = evt.repoId ?? "(unresolved)";
|
|
266
|
+
if (!byRepo.has(key)) byRepo.set(key, []);
|
|
267
|
+
byRepo.get(key)!.push(evt);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Sort repo keys for deterministic output
|
|
271
|
+
const repoKeys = [...byRepo.keys()].sort();
|
|
272
|
+
|
|
273
|
+
if (repoKeys.length === 0) {
|
|
274
|
+
lines.push(`_No per-repo data available._`);
|
|
275
|
+
lines.push(``);
|
|
276
|
+
} else {
|
|
277
|
+
for (const repoKey of repoKeys) {
|
|
278
|
+
const repoEvents = byRepo.get(repoKey)!;
|
|
279
|
+
const repoSucceeded = repoEvents.filter(e => e.status === "succeeded").length;
|
|
280
|
+
const repoFailed = repoEvents.filter(e => e.status === "failed").length;
|
|
281
|
+
const repoCost = repoEvents.reduce((sum, e) => sum + e.cost, 0);
|
|
282
|
+
|
|
283
|
+
lines.push(`### ${repoKey}`);
|
|
284
|
+
lines.push(``);
|
|
285
|
+
lines.push(`- Tasks: ${repoEvents.length} (${repoSucceeded} succeeded, ${repoFailed} failed)`);
|
|
286
|
+
lines.push(`- Cost: ${formatCost(repoCost)}`);
|
|
287
|
+
lines.push(``);
|
|
288
|
+
|
|
289
|
+
lines.push(`| Task | Status | Classification | Cost | Duration |`);
|
|
290
|
+
lines.push(`|------|--------|---------------|------|----------|`);
|
|
291
|
+
for (const evt of repoEvents) {
|
|
292
|
+
lines.push(
|
|
293
|
+
`| ${evt.taskId} | ${evt.status} | ${evt.classification} | ${formatCost(evt.cost)} | ${formatDuration(evt.durationSec)} |`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
lines.push(``);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── Footer ──
|
|
302
|
+
lines.push(`---`);
|
|
303
|
+
lines.push(`_Generated at ${new Date().toISOString()}_`);
|
|
304
|
+
lines.push(``);
|
|
305
|
+
|
|
306
|
+
return lines.join("\n");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Report Emission ──────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Emit diagnostic reports (JSONL event log + markdown summary) at batch terminal.
|
|
313
|
+
*
|
|
314
|
+
* This function is called exactly once per batch run, immediately after
|
|
315
|
+
* the `persistRuntimeState("batch-terminal", ...)` call in both engine.ts
|
|
316
|
+
* and resume.ts.
|
|
317
|
+
*
|
|
318
|
+
* **Non-fatal:** All errors during report generation or writing are caught
|
|
319
|
+
* and logged via `execLog()`. They never propagate to the caller or crash
|
|
320
|
+
* the batch finalization flow.
|
|
321
|
+
*
|
|
322
|
+
* @param input - Diagnostic report input assembled from runtime state
|
|
323
|
+
*/
|
|
324
|
+
export function emitDiagnosticReports(input: DiagnosticReportInput): void {
|
|
325
|
+
try {
|
|
326
|
+
const opId = resolveOperatorId(input.orchConfig);
|
|
327
|
+
const dir = ensureDiagnosticsDir(input.stateRoot);
|
|
328
|
+
|
|
329
|
+
const events = buildDiagnosticEvents(input);
|
|
330
|
+
|
|
331
|
+
// ── JSONL event log ──
|
|
332
|
+
const jsonlPath = join(dir, `${opId}-${input.batchId}-events.jsonl`);
|
|
333
|
+
const jsonlContent = eventsToJsonl(events);
|
|
334
|
+
writeFileSync(jsonlPath, jsonlContent, "utf-8");
|
|
335
|
+
|
|
336
|
+
// ── Markdown summary ──
|
|
337
|
+
const reportPath = join(dir, `${opId}-${input.batchId}-report.md`);
|
|
338
|
+
const reportContent = buildMarkdownReport(input, events);
|
|
339
|
+
writeFileSync(reportPath, reportContent, "utf-8");
|
|
340
|
+
|
|
341
|
+
execLog("diagnostics", input.batchId, `emitted diagnostic reports`, {
|
|
342
|
+
jsonl: jsonlPath,
|
|
343
|
+
report: reportPath,
|
|
344
|
+
taskCount: events.length,
|
|
345
|
+
});
|
|
346
|
+
} catch (err: unknown) {
|
|
347
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
348
|
+
execLog("diagnostics", input.batchId, `failed to emit diagnostic reports: ${msg}`);
|
|
349
|
+
// Non-fatal: do not rethrow. The batch finalization continues.
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Assemble diagnostic report input from batch runtime state.
|
|
355
|
+
*
|
|
356
|
+
* Convenience helper for engine.ts and resume.ts to call at the
|
|
357
|
+
* batch-terminal checkpoint. Builds the full task registry from the
|
|
358
|
+
* wave plan + allocated lanes + task outcomes — matching the canonical
|
|
359
|
+
* model used by `serializeBatchState()`. This ensures diagnostics cover
|
|
360
|
+
* all tasks (including pending/blocked tasks that were never allocated)
|
|
361
|
+
* and preserve repo attribution fields for workspace per-repo breakdown.
|
|
362
|
+
*
|
|
363
|
+
* @param orchConfig - Orchestrator configuration
|
|
364
|
+
* @param batchState - Current runtime batch state (at batch-terminal)
|
|
365
|
+
* @param wavePlan - Wave plan (array of waves, each an array of taskIds)
|
|
366
|
+
* @param lanes - Allocated lanes with task/repo metadata
|
|
367
|
+
* @param allTaskOutcomes - All task outcomes accumulated during execution
|
|
368
|
+
* @param stateRoot - State root path where `.pi/` lives
|
|
369
|
+
*/
|
|
370
|
+
export function assembleDiagnosticInput(
|
|
371
|
+
orchConfig: OrchestratorConfig,
|
|
372
|
+
batchState: OrchBatchRuntimeState,
|
|
373
|
+
wavePlan: string[][],
|
|
374
|
+
lanes: AllocatedLane[],
|
|
375
|
+
allTaskOutcomes: LaneTaskOutcome[],
|
|
376
|
+
stateRoot: string,
|
|
377
|
+
): DiagnosticReportInput {
|
|
378
|
+
// Build lookup maps for fast per-task enrichment (mirrors serializeBatchState logic).
|
|
379
|
+
const laneByTaskId = new Map<string, AllocatedLane>();
|
|
380
|
+
const allocatedTaskByTaskId = new Map<string, { allocatedTask: import("./types.ts").AllocatedTask; lane: AllocatedLane }>();
|
|
381
|
+
for (const lane of lanes) {
|
|
382
|
+
for (const allocTask of lane.tasks) {
|
|
383
|
+
laneByTaskId.set(allocTask.taskId, lane);
|
|
384
|
+
allocatedTaskByTaskId.set(allocTask.taskId, { allocatedTask: allocTask, lane });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Latest outcome wins (allTaskOutcomes is append/replace ordered by time).
|
|
389
|
+
const outcomeByTaskId = new Map<string, LaneTaskOutcome>();
|
|
390
|
+
for (const outcome of allTaskOutcomes) {
|
|
391
|
+
outcomeByTaskId.set(outcome.taskId, outcome);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Build full task ID set from wave plan + outcomes (covers pending/blocked tasks).
|
|
395
|
+
const taskIdSet = new Set<string>();
|
|
396
|
+
for (const wave of wavePlan) {
|
|
397
|
+
for (const taskId of wave) taskIdSet.add(taskId);
|
|
398
|
+
}
|
|
399
|
+
for (const outcome of allTaskOutcomes) {
|
|
400
|
+
taskIdSet.add(outcome.taskId);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Build task records sorted by taskId for deterministic output.
|
|
404
|
+
const tasks: PersistedTaskRecord[] = [...taskIdSet]
|
|
405
|
+
.sort()
|
|
406
|
+
.map((taskId): PersistedTaskRecord => {
|
|
407
|
+
const lane = laneByTaskId.get(taskId);
|
|
408
|
+
const outcome = outcomeByTaskId.get(taskId);
|
|
409
|
+
const allocated = allocatedTaskByTaskId.get(taskId);
|
|
410
|
+
|
|
411
|
+
const record: PersistedTaskRecord = {
|
|
412
|
+
taskId,
|
|
413
|
+
laneNumber: lane?.laneNumber ?? 0,
|
|
414
|
+
sessionName: outcome?.sessionName || lane?.laneSessionId || "",
|
|
415
|
+
status: outcome?.status ?? "pending",
|
|
416
|
+
taskFolder: "",
|
|
417
|
+
startedAt: outcome?.startTime ?? null,
|
|
418
|
+
endedAt: outcome?.endTime ?? null,
|
|
419
|
+
doneFileFound: outcome?.doneFileFound ?? false,
|
|
420
|
+
exitReason: outcome?.exitReason ?? "",
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// Repo attribution from allocated task metadata (workspace mode).
|
|
424
|
+
if (allocated?.allocatedTask.task?.promptRepoId !== undefined) {
|
|
425
|
+
record.repoId = allocated.allocatedTask.task.promptRepoId;
|
|
426
|
+
}
|
|
427
|
+
if (allocated?.allocatedTask.task?.resolvedRepoId !== undefined) {
|
|
428
|
+
record.resolvedRepoId = allocated.allocatedTask.task.resolvedRepoId;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Partial progress fields from outcome.
|
|
432
|
+
if (outcome?.partialProgressCommits !== undefined) {
|
|
433
|
+
record.partialProgressCommits = outcome.partialProgressCommits;
|
|
434
|
+
}
|
|
435
|
+
if (outcome?.partialProgressBranch !== undefined) {
|
|
436
|
+
record.partialProgressBranch = outcome.partialProgressBranch;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// v3: Exit diagnostic from outcome.
|
|
440
|
+
if (outcome?.exitDiagnostic !== undefined) {
|
|
441
|
+
record.exitDiagnostic = outcome.exitDiagnostic;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return record;
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
return {
|
|
448
|
+
orchConfig,
|
|
449
|
+
batchId: batchState.batchId,
|
|
450
|
+
phase: batchState.phase,
|
|
451
|
+
mode: batchState.mode ?? "repo",
|
|
452
|
+
startedAt: batchState.startedAt,
|
|
453
|
+
endedAt: batchState.endedAt,
|
|
454
|
+
tasks,
|
|
455
|
+
diagnostics: batchState.diagnostics ?? defaultBatchDiagnostics(),
|
|
456
|
+
succeededTasks: batchState.succeededTasks,
|
|
457
|
+
failedTasks: batchState.failedTasks,
|
|
458
|
+
skippedTasks: batchState.skippedTasks,
|
|
459
|
+
blockedTasks: batchState.blockedTasks,
|
|
460
|
+
totalTasks: batchState.totalTasks,
|
|
461
|
+
stateRoot,
|
|
462
|
+
};
|
|
463
|
+
}
|