taskplane 0.5.12 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/rpc-wrapper.mjs +777 -0
- package/dashboard/public/app.js +45 -6
- package/dashboard/public/style.css +31 -0
- package/dashboard/server.cjs +326 -1
- package/extensions/task-runner.ts +1111 -30
- package/extensions/taskplane/config-loader.ts +31 -0
- package/extensions/taskplane/config-schema.ts +88 -0
- package/extensions/taskplane/diagnostic-reports.ts +463 -0
- package/extensions/taskplane/diagnostics.ts +323 -0
- package/extensions/taskplane/engine.ts +407 -62
- package/extensions/taskplane/extension.ts +259 -8
- package/extensions/taskplane/index.ts +1 -0
- package/extensions/taskplane/merge.ts +786 -66
- package/extensions/taskplane/messages.ts +594 -2
- package/extensions/taskplane/persistence.ts +342 -19
- package/extensions/taskplane/quality-gate.ts +1033 -0
- package/extensions/taskplane/resume.ts +505 -36
- package/extensions/taskplane/supervisor-primer.md +664 -0
- package/extensions/taskplane/types.ts +534 -6
- package/extensions/taskplane/verification.ts +537 -0
- package/extensions/taskplane/worktree.ts +329 -1
- package/package.json +1 -1
- package/skills/create-taskplane-task/references/prompt-template.md +0 -2
- package/templates/agents/task-reviewer.md +54 -2
- package/templates/agents/task-worker.md +8 -4
|
@@ -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
|
+
}
|