taskplane 0.5.11 → 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.
@@ -219,6 +219,9 @@ function mapTaskRunnerYaml(raw: any): Partial<TaskRunnerSection> {
219
219
  if (raw.never_load) result.neverLoad = [...raw.never_load];
220
220
  if (raw.protected_docs) result.protectedDocs = [...raw.protected_docs];
221
221
 
222
+ // Quality gate (structural — all keys are schema-defined)
223
+ if (raw.quality_gate) result.qualityGate = convertStructuralKeys(raw.quality_gate);
224
+
222
225
  return result;
223
226
  }
224
227
 
@@ -255,6 +258,9 @@ function mapOrchestratorYaml(raw: any): Partial<OrchestratorSection> {
255
258
  if (raw.pre_warm.always) result.preWarm.always = [...raw.pre_warm.always];
256
259
  }
257
260
 
261
+ // verification: all keys are structural (TP-032)
262
+ if (raw.verification) result.verification = convertStructuralKeys(raw.verification);
263
+
258
264
  return result;
259
265
  }
260
266
 
@@ -760,6 +766,11 @@ export function toOrchestratorConfig(config: TaskplaneConfig): import("./types.t
760
766
  monitoring: {
761
767
  poll_interval: o.monitoring.pollInterval,
762
768
  },
769
+ verification: {
770
+ enabled: o.verification.enabled,
771
+ mode: o.verification.mode,
772
+ flaky_reruns: o.verification.flakyReruns,
773
+ },
763
774
  };
764
775
  }
765
776
 
@@ -788,9 +799,15 @@ export function toTaskRunnerConfig(config: TaskplaneConfig): import("./types.ts"
788
799
  taskAreas[name] = ta;
789
800
  }
790
801
 
802
+ // Include testing_commands for baseline fingerprinting (TP-032).
803
+ // Only set the field when there are actual commands configured.
804
+ const testingCommands = config.taskRunner.testing?.commands;
805
+ const hasTestingCommands = testingCommands && Object.keys(testingCommands).length > 0;
806
+
791
807
  return {
792
808
  task_areas: taskAreas,
793
809
  reference_docs: { ...config.taskRunner.referenceDocs },
810
+ ...(hasTestingCommands ? { testing_commands: { ...testingCommands } } : {}),
794
811
  };
795
812
  }
796
813
 
@@ -818,6 +835,13 @@ export function toTaskConfig(config: TaskplaneConfig): {
818
835
  no_progress_limit: number;
819
836
  max_worker_minutes?: number;
820
837
  };
838
+ quality_gate: {
839
+ enabled: boolean;
840
+ review_model: string;
841
+ max_review_cycles: number;
842
+ max_fix_cycles: number;
843
+ pass_threshold: "no_critical" | "no_important" | "all_clear";
844
+ };
821
845
  } {
822
846
  const tr = config.taskRunner;
823
847
 
@@ -857,5 +881,12 @@ export function toTaskConfig(config: TaskplaneConfig): {
857
881
  no_progress_limit: tr.context.noProgressLimit,
858
882
  max_worker_minutes: tr.context.maxWorkerMinutes,
859
883
  },
884
+ quality_gate: {
885
+ enabled: tr.qualityGate.enabled,
886
+ review_model: tr.qualityGate.reviewModel,
887
+ max_review_cycles: tr.qualityGate.maxReviewCycles,
888
+ max_fix_cycles: tr.qualityGate.maxFixCycles,
889
+ pass_threshold: tr.qualityGate.passThreshold,
890
+ },
860
891
  };
861
892
  }
@@ -159,6 +159,29 @@ export interface SelfDocTarget {
159
159
  [key: string]: string;
160
160
  }
161
161
 
162
+ /**
163
+ * Severity threshold for quality gate pass decisions.
164
+ *
165
+ * - `no_critical`: PASS if no critical findings (important/suggestion allowed)
166
+ * - `no_important`: PASS if no critical and fewer than 3 important findings
167
+ * - `all_clear`: PASS only if zero findings of any severity
168
+ */
169
+ export type PassThreshold = "no_critical" | "no_important" | "all_clear";
170
+
171
+ /** Quality gate configuration — opt-in post-completion review */
172
+ export interface QualityGateConfig {
173
+ /** Enable quality gate review before .DONE creation (default: false) */
174
+ enabled: boolean;
175
+ /** Model used for quality gate review agent (empty = inherit session model) */
176
+ reviewModel: string;
177
+ /** Max total review cycles before marking task failed (default: 2) */
178
+ maxReviewCycles: number;
179
+ /** Max fix agent cycles per quality gate run (default: 1) */
180
+ maxFixCycles: number;
181
+ /** Severity threshold for PASS decision (default: "no_critical") */
182
+ passThreshold: PassThreshold;
183
+ }
184
+
162
185
 
163
186
  // ── Task Runner Combined Section ─────────────────────────────────────
164
187
 
@@ -195,6 +218,8 @@ export interface TaskRunnerSection {
195
218
  selfDocTargets: Record<string, string>;
196
219
  /** Paths requiring explicit user approval before modification */
197
220
  protectedDocs: string[];
221
+ /** Quality gate configuration — opt-in post-completion review */
222
+ qualityGate: QualityGateConfig;
198
223
  }
199
224
 
200
225
 
@@ -280,6 +305,55 @@ export interface MonitoringConfig {
280
305
  pollInterval: number;
281
306
  }
282
307
 
308
+ /**
309
+ * Verification baseline fingerprinting settings.
310
+ *
311
+ * Controls orchestrator-side baseline capture and post-merge comparison.
312
+ * When enabled, test commands from `taskRunner.testing.commands` are run
313
+ * before and after each lane merge to detect genuinely new failures.
314
+ *
315
+ * This is separate from `merge.verify` (agent-side verification) which
316
+ * handles revert-on-failure logic within the merge agent.
317
+ */
318
+ export interface VerificationConfig {
319
+ /**
320
+ * Enable verification baseline fingerprinting.
321
+ *
322
+ * When false (default), no baseline capture or comparison is performed,
323
+ * regardless of whether `taskRunner.testing.commands` are configured.
324
+ *
325
+ * When true, requires `taskRunner.testing.commands` to have at least
326
+ * one command configured. If enabled but no commands are configured:
327
+ * - strict mode: treats as baseline-unavailable (triggers merge failure)
328
+ * - permissive mode: logs a warning and continues without verification
329
+ */
330
+ enabled: boolean;
331
+ /**
332
+ * Verification mode controlling behavior when baseline is unavailable.
333
+ *
334
+ * - "strict": Baseline capture failure or missing commands triggers a
335
+ * merge failure. The `failure.onMergeFailure` policy then determines
336
+ * whether the batch pauses or aborts.
337
+ * - "permissive": Baseline capture failure or missing commands logs a
338
+ * warning and continues without orchestrator-side verification.
339
+ * Merge-agent verification (`merge.verify`) still applies independently.
340
+ *
341
+ * Default: "permissive"
342
+ */
343
+ mode: "strict" | "permissive";
344
+ /**
345
+ * Number of flaky re-runs when new failures are detected.
346
+ *
347
+ * When new failures are found after a lane merge, only the commands that
348
+ * produced failures are re-run this many times. If failures disappear on
349
+ * any re-run, the lane is classified as "flaky_suspected" (warning only).
350
+ *
351
+ * Set to 0 to disable flaky re-runs (any new failure immediately blocks).
352
+ * Default: 1
353
+ */
354
+ flakyReruns: number;
355
+ }
356
+
283
357
 
284
358
  // ── Orchestrator Combined Section ────────────────────────────────────
285
359
 
@@ -301,6 +375,8 @@ export interface OrchestratorSection {
301
375
  failure: FailureConfig;
302
376
  /** Monitoring */
303
377
  monitoring: MonitoringConfig;
378
+ /** Verification baseline fingerprinting (TP-032) */
379
+ verification: VerificationConfig;
304
380
  }
305
381
 
306
382
 
@@ -417,6 +493,13 @@ export const DEFAULT_TASK_RUNNER_SECTION: TaskRunnerSection = {
417
493
  neverLoad: [],
418
494
  selfDocTargets: {},
419
495
  protectedDocs: [],
496
+ qualityGate: {
497
+ enabled: false,
498
+ reviewModel: "",
499
+ maxReviewCycles: 2,
500
+ maxFixCycles: 1,
501
+ passThreshold: "no_critical",
502
+ },
420
503
  };
421
504
 
422
505
  /** Default orchestrator section values */
@@ -461,6 +544,11 @@ export const DEFAULT_ORCHESTRATOR_SECTION: OrchestratorSection = {
461
544
  monitoring: {
462
545
  pollInterval: 5,
463
546
  },
547
+ verification: {
548
+ enabled: false,
549
+ mode: "permissive",
550
+ flakyReruns: 1,
551
+ },
464
552
  };
465
553
 
466
554
  /** Default unified config */
@@ -0,0 +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?.tmuxSessionName || "",
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
+ }