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.
Files changed (71) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +215 -215
  3. package/bin/gitignore-patterns.mjs +79 -79
  4. package/bin/rpc-wrapper.mjs +1086 -1086
  5. package/bin/taskplane.mjs +3254 -3254
  6. package/dashboard/public/app.js +2573 -2573
  7. package/dashboard/public/index.html +139 -139
  8. package/dashboard/public/style.css +1882 -1882
  9. package/dashboard/public/taskplane-word-color.svg +18 -18
  10. package/dashboard/public/taskplane-word-white.svg +18 -18
  11. package/dashboard/server.cjs +1666 -1666
  12. package/extensions/reviewer-extension.ts +119 -119
  13. package/extensions/task-orchestrator.ts +28 -28
  14. package/extensions/taskplane/abort.ts +502 -502
  15. package/extensions/taskplane/agent-bridge-extension.ts +838 -765
  16. package/extensions/taskplane/agent-host.ts +833 -745
  17. package/extensions/taskplane/cleanup.ts +747 -747
  18. package/extensions/taskplane/config-loader.ts +1328 -1322
  19. package/extensions/taskplane/config-schema.ts +692 -682
  20. package/extensions/taskplane/config.ts +73 -73
  21. package/extensions/taskplane/context-window.ts +66 -66
  22. package/extensions/taskplane/diagnostic-reports.ts +463 -463
  23. package/extensions/taskplane/diagnostics.ts +385 -385
  24. package/extensions/taskplane/engine-worker-entry.mjs +34 -34
  25. package/extensions/taskplane/engine-worker.ts +381 -381
  26. package/extensions/taskplane/engine.ts +4539 -4527
  27. package/extensions/taskplane/execution.ts +2733 -2708
  28. package/extensions/taskplane/extension.ts +30 -9
  29. package/extensions/taskplane/formatting.ts +773 -773
  30. package/extensions/taskplane/git.ts +90 -90
  31. package/extensions/taskplane/index.ts +28 -28
  32. package/extensions/taskplane/lane-runner.ts +1383 -1360
  33. package/extensions/taskplane/mailbox.ts +689 -689
  34. package/extensions/taskplane/merge.ts +3135 -3135
  35. package/extensions/taskplane/messages.ts +985 -985
  36. package/extensions/taskplane/migrations.ts +278 -278
  37. package/extensions/taskplane/naming.ts +117 -117
  38. package/extensions/taskplane/path-resolver.ts +237 -237
  39. package/extensions/taskplane/persistence.ts +2087 -2087
  40. package/extensions/taskplane/process-registry.ts +416 -416
  41. package/extensions/taskplane/quality-gate.ts +1033 -1033
  42. package/extensions/taskplane/resume.ts +2879 -2878
  43. package/extensions/taskplane/sessions.ts +57 -57
  44. package/extensions/taskplane/settings-loader.ts +136 -136
  45. package/extensions/taskplane/settings-tui.ts +1867 -1867
  46. package/extensions/taskplane/sidecar-telemetry.ts +252 -252
  47. package/extensions/taskplane/supervisor-primer.md +1694 -1694
  48. package/extensions/taskplane/supervisor.ts +4341 -4341
  49. package/extensions/taskplane/task-executor-core.ts +550 -550
  50. package/extensions/taskplane/tmux-compat.ts +37 -37
  51. package/extensions/taskplane/types.ts +4297 -4278
  52. package/extensions/taskplane/verification.ts +542 -542
  53. package/extensions/taskplane/waves.ts +1548 -1548
  54. package/extensions/taskplane/workspace.ts +705 -705
  55. package/extensions/taskplane/worktree.ts +2604 -2505
  56. package/package.json +57 -57
  57. package/skills/create-taskplane-task/SKILL.md +465 -465
  58. package/skills/create-taskplane-task/references/prompt-template.md +285 -285
  59. package/templates/agents/local/supervisor.md +33 -33
  60. package/templates/agents/local/task-merger.md +27 -27
  61. package/templates/agents/local/task-reviewer.md +30 -30
  62. package/templates/agents/local/task-worker.md +34 -34
  63. package/templates/agents/supervisor-routing.md +92 -92
  64. package/templates/agents/supervisor.md +168 -168
  65. package/templates/agents/task-merger.md +214 -214
  66. package/templates/agents/task-reviewer.md +192 -192
  67. package/templates/agents/task-worker.md +505 -429
  68. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -98
  69. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -73
  70. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -97
  71. 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
+ }