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,773 +1,773 @@
1
- /**
2
- * Output formatting, dashboard widget, wave plan display
3
- * @module orch/formatting
4
- */
5
- import { join } from "path";
6
- import { truncateToWidth } from "@mariozechner/pi-tui";
7
-
8
- import { parseDependencyReference } from "./discovery.ts";
9
- import type { LaneAssignment, MonitorState, OrchBatchRuntimeState, OrchDashboardViewModel, OrchLaneCardData, OrchSummaryCounts, ParsedTask, WaveComputationResult } from "./types.ts";
10
- import { getTaskDurationMinutes, SIZE_DURATION_MINUTES } from "./types.ts";
11
-
12
- // ── Wave Output Formatting ───────────────────────────────────────────
13
-
14
- // ── Dependency Graph Formatting ──────────────────────────────────────
15
-
16
- /**
17
- * Format a dependency graph for display.
18
- *
19
- * Shows both upstream (what each task depends on) and downstream
20
- * (what depends on each task) views. Output is deterministic:
21
- * tasks sorted by ID, edges sorted by target ID.
22
- *
23
- * If `filterTaskId` is provided, only shows edges involving that task.
24
- */
25
- export function formatDependencyGraph(
26
- pending: Map<string, ParsedTask>,
27
- completed: Set<string>,
28
- filterTaskId?: string,
29
- ): string {
30
- const lines: string[] = [];
31
-
32
- // Sort tasks deterministically by ID
33
- const sortedTasks = [...pending.values()].sort((a, b) =>
34
- a.taskId.localeCompare(b.taskId),
35
- );
36
-
37
- // Build downstream index: taskID → tasks that depend on it
38
- const downstream = new Map<string, string[]>();
39
- for (const task of sortedTasks) {
40
- for (const depRaw of task.dependencies) {
41
- const depId = parseDependencyReference(depRaw).taskId;
42
- const existing = downstream.get(depId) || [];
43
- existing.push(task.taskId);
44
- downstream.set(depId, existing);
45
- }
46
- }
47
-
48
- // If filtering to a single task
49
- if (filterTaskId) {
50
- const task = pending.get(filterTaskId);
51
- if (!task) {
52
- lines.push(`❌ Task "${filterTaskId}" not found in pending tasks.`);
53
- return lines.join("\n");
54
- }
55
-
56
- lines.push(`🔗 Dependencies for ${filterTaskId} (${task.taskName}):`);
57
- lines.push("");
58
-
59
- // Upstream: what this task depends on
60
- lines.push(" ⬆ Upstream (depends on):");
61
- if (task.dependencies.length === 0) {
62
- lines.push(" (none — no dependencies)");
63
- } else {
64
- const sortedDeps = [...task.dependencies].sort();
65
- for (const depRaw of sortedDeps) {
66
- const depId = parseDependencyReference(depRaw).taskId;
67
- const status = completed.has(depId)
68
- ? "✅ complete"
69
- : pending.has(depId)
70
- ? "⏳ pending"
71
- : "❓ unknown";
72
- lines.push(` ${filterTaskId} → ${depRaw} (${status})`);
73
- }
74
- }
75
-
76
- // Downstream: what depends on this task
77
- lines.push("");
78
- lines.push(" ⬇ Downstream (depended on by):");
79
- const downstreamTasks = (downstream.get(filterTaskId) || []).sort();
80
- if (downstreamTasks.length === 0) {
81
- lines.push(" (none — no tasks depend on this)");
82
- } else {
83
- for (const dep of downstreamTasks) {
84
- lines.push(` ${dep} → ${filterTaskId}`);
85
- }
86
- }
87
-
88
- return lines.join("\n");
89
- }
90
-
91
- // Full graph view
92
- lines.push("🔗 Dependency Graph:");
93
- lines.push("");
94
-
95
- let hasDeps = false;
96
-
97
- // Section 1: Upstream view (what each task depends on)
98
- lines.push(" ⬆ Upstream (task → depends on):");
99
- for (const task of sortedTasks) {
100
- if (task.dependencies.length > 0) {
101
- hasDeps = true;
102
- const sortedDeps = [...task.dependencies].sort();
103
- for (const depRaw of sortedDeps) {
104
- const depId = parseDependencyReference(depRaw).taskId;
105
- const status = completed.has(depId)
106
- ? "✅ complete"
107
- : pending.has(depId)
108
- ? "⏳ pending"
109
- : "❓ unknown";
110
- lines.push(` ${task.taskId} → ${depRaw} (${status})`);
111
- }
112
- }
113
- }
114
- if (!hasDeps) {
115
- lines.push(" (none — all tasks are independent)");
116
- }
117
-
118
- // Section 2: Downstream view (what depends on each task)
119
- lines.push("");
120
- lines.push(" ⬇ Downstream (task ← depended on by):");
121
- let hasDownstream = false;
122
- const allTargets = new Set<string>();
123
- for (const task of sortedTasks) {
124
- for (const depRaw of task.dependencies) {
125
- allTargets.add(parseDependencyReference(depRaw).taskId);
126
- }
127
- }
128
- const sortedTargets = [...allTargets].sort();
129
- for (const target of sortedTargets) {
130
- const dependents = (downstream.get(target) || []).sort();
131
- if (dependents.length > 0) {
132
- hasDownstream = true;
133
- const status = completed.has(target)
134
- ? "✅"
135
- : pending.has(target)
136
- ? "⏳"
137
- : "❓";
138
- lines.push(
139
- ` ${target} ${status} ← ${dependents.join(", ")}`,
140
- );
141
- }
142
- }
143
- if (!hasDownstream) {
144
- lines.push(" (none — no downstream dependencies)");
145
- }
146
-
147
- // Section 3: Independent tasks (no deps, nothing depends on them)
148
- const independentTasks = sortedTasks.filter(
149
- (t) =>
150
- t.dependencies.length === 0 &&
151
- !(downstream.get(t.taskId)?.length),
152
- );
153
- if (independentTasks.length > 0) {
154
- lines.push("");
155
- lines.push(" ○ Independent (no dependencies, nothing depends on them):");
156
- for (const task of independentTasks) {
157
- lines.push(` ${task.taskId} [${task.size}] ${task.taskName}`);
158
- }
159
- }
160
-
161
- return lines.join("\n");
162
- }
163
-
164
- /**
165
- * Format wave computation results as a readable execution plan.
166
- *
167
- * Output sections (fixed order):
168
- * 1. Wave overview header
169
- * 2. Per-wave: task count, lane count, parallel/serial indicator
170
- * 3. Per-lane within wave: tasks with sizes, serial notes, lane weight
171
- * 4. Per-wave: estimated duration (critical path = max lane duration)
172
- * 5. Summary: total estimated duration, size-to-duration table
173
- *
174
- * Duration calculation:
175
- * - Per lane: sum of task durations for tasks in that lane
176
- * - Per wave: max lane duration (parallel bottleneck / critical path)
177
- * - Total: sum of wave durations (waves run sequentially)
178
- */
179
- export function formatWavePlan(
180
- result: WaveComputationResult,
181
- sizeWeights: Record<string, number>,
182
- ): string {
183
- const lines: string[] = [];
184
-
185
- if (result.errors.length > 0) {
186
- lines.push("❌ Wave Computation Errors:");
187
- for (const err of result.errors) {
188
- lines.push(` [${err.code}] ${err.message}`);
189
- }
190
- return lines.join("\n");
191
- }
192
-
193
- if (result.waves.length === 0) {
194
- lines.push("No waves to schedule.");
195
- return lines.join("\n");
196
- }
197
-
198
- // Count total tasks
199
- const totalTasks = result.waves.reduce((sum, w) => sum + w.tasks.length, 0);
200
- const maxLanesUsed = Math.max(
201
- ...result.waves.map((w) => {
202
- const lanes = new Set(w.tasks.map((t) => t.lane));
203
- return lanes.size;
204
- }),
205
- );
206
-
207
- lines.push(
208
- `🌊 Execution Plan: ${result.waves.length} wave(s), ` +
209
- `${totalTasks} task(s), up to ${maxLanesUsed} lane(s)`,
210
- );
211
- lines.push("");
212
-
213
- let totalEstimate = 0;
214
- for (const wave of result.waves) {
215
- // Group tasks by lane (deterministic: Map preserves insertion order)
216
- const laneGroups = new Map<number, LaneAssignment[]>();
217
- for (const assignment of wave.tasks) {
218
- const existing = laneGroups.get(assignment.lane) || [];
219
- existing.push(assignment);
220
- laneGroups.set(assignment.lane, existing);
221
- }
222
-
223
- const laneCount = laneGroups.size;
224
- const taskCount = wave.tasks.length;
225
- const parallel = laneCount > 1 ? "parallel" : "serial";
226
-
227
- lines.push(
228
- ` Wave ${wave.waveNumber}: ${taskCount} task(s) across ` +
229
- `${laneCount} lane(s) [${parallel}]`,
230
- );
231
-
232
- // Calculate wave duration: critical path = max lane duration
233
- let maxLaneDuration = 0;
234
-
235
- // Sort lanes deterministically by lane number
236
- const sortedLanes = [...laneGroups.entries()].sort(
237
- (a, b) => a[0] - b[0],
238
- );
239
-
240
- for (const [lane, assignments] of sortedLanes) {
241
- // Sort tasks within lane by task ID for deterministic output
242
- const sortedAssignments = [...assignments].sort((a, b) =>
243
- a.taskId.localeCompare(b.taskId),
244
- );
245
- const taskList = sortedAssignments
246
- .map((a) => `${a.taskId} [${a.task.size}]`)
247
- .join(", ");
248
- const laneDuration = sortedAssignments.reduce(
249
- (sum, a) =>
250
- sum + getTaskDurationMinutes(a.task.size, sizeWeights),
251
- 0,
252
- );
253
- if (laneDuration > maxLaneDuration) maxLaneDuration = laneDuration;
254
- const serialNote =
255
- sortedAssignments.length > 1 ? " (serial)" : "";
256
- lines.push(
257
- ` Lane ${lane}: ${taskList}${serialNote} ` +
258
- `[est. ${laneDuration} min]`,
259
- );
260
- }
261
-
262
- // Critical path for this wave
263
- totalEstimate += maxLaneDuration;
264
- lines.push(
265
- ` ⏱ Wave duration: ${maxLaneDuration} min ` +
266
- `(critical path: longest lane)`,
267
- );
268
- lines.push("");
269
- }
270
-
271
- // Summary with size-to-duration table
272
- const totalHours = (totalEstimate / 60).toFixed(1);
273
- lines.push(`📊 Total estimated duration: ${totalEstimate} min (~${totalHours} hours)`);
274
- lines.push(
275
- ` Duration model: S=${SIZE_DURATION_MINUTES["S"]}m, ` +
276
- `M=${SIZE_DURATION_MINUTES["M"]}m, L=${SIZE_DURATION_MINUTES["L"]}m`,
277
- );
278
- lines.push(
279
- " Critical path: sum of per-wave bottleneck lanes " +
280
- "(waves sequential, lanes parallel)",
281
- );
282
-
283
- return lines.join("\n");
284
- }
285
-
286
-
287
- // ── Summary Helpers ──────────────────────────────────────────────────
288
-
289
- /**
290
- * Compute summary counts from batch state + optional monitor state.
291
- *
292
- * Pure function — no side effects, deterministic output.
293
- */
294
- export function computeOrchSummaryCounts(
295
- batchState: OrchBatchRuntimeState,
296
- monitorState?: MonitorState | null,
297
- ): OrchSummaryCounts {
298
- let running = 0;
299
- let stalled = 0;
300
-
301
- // If we have live monitor data, count running/stalled from it
302
- if (monitorState) {
303
- for (const lane of monitorState.lanes) {
304
- if (lane.currentTaskSnapshot) {
305
- if (lane.currentTaskSnapshot.status === "stalled") {
306
- stalled++;
307
- } else if (lane.currentTaskSnapshot.status === "running") {
308
- running++;
309
- }
310
- }
311
- }
312
- }
313
-
314
- const completed = batchState.succeededTasks;
315
- const failed = batchState.failedTasks;
316
- const blocked = batchState.blockedTasks;
317
- const total = batchState.totalTasks;
318
- const queued = Math.max(0, total - completed - failed - blocked - stalled - running - batchState.skippedTasks);
319
-
320
- return { completed, running, queued, failed, blocked, stalled, total };
321
- }
322
-
323
- /**
324
- * Format elapsed time from start/end timestamps.
325
- *
326
- * @param startMs - Start epoch ms
327
- * @param endMs - End epoch ms (null = use current time)
328
- * @returns Human-readable string, e.g., "2m 14s" or "1h 5m 30s"
329
- */
330
- export function formatElapsedTime(startMs: number, endMs?: number | null): string {
331
- if (startMs <= 0) return "0s";
332
- const elapsed = (endMs ?? Date.now()) - startMs;
333
- if (elapsed < 0) return "0s";
334
-
335
- const totalSec = Math.floor(elapsed / 1000);
336
- const hours = Math.floor(totalSec / 3600);
337
- const minutes = Math.floor((totalSec % 3600) / 60);
338
- const seconds = totalSec % 60;
339
-
340
- if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
341
- if (minutes > 0) return `${minutes}m ${seconds}s`;
342
- return `${seconds}s`;
343
- }
344
-
345
- /**
346
- * Build the dashboard view-model from runtime state.
347
- *
348
- * Pure function — deterministic mapping from OrchBatchRuntimeState +
349
- * optional MonitorState to render-ready OrchDashboardViewModel.
350
- *
351
- * Fallback behavior:
352
- * - No batch → idle view with zeroed counts
353
- * - No monitor data → empty lane cards, counts from batch state only
354
- * - Missing STATUS.md → "no data" in lane card
355
- */
356
- export function buildDashboardViewModel(
357
- batchState: OrchBatchRuntimeState,
358
- monitorState?: MonitorState | null,
359
- ): OrchDashboardViewModel {
360
- const summary = computeOrchSummaryCounts(batchState, monitorState);
361
- const elapsed = formatElapsedTime(batchState.startedAt, batchState.endedAt);
362
-
363
- const waveProgress = batchState.totalWaves > 0
364
- ? `${Math.max(0, batchState.currentWaveIndex + 1)}/${batchState.totalWaves}`
365
- : "0/0";
366
-
367
- // Build lane cards from monitor state (if available) or current lanes
368
- const laneCards: OrchLaneCardData[] = [];
369
-
370
- // TP-170: Detect stale monitor data from prior waves.
371
- // When wave N+1 starts, batchState.currentLanes is updated to wave N+1's
372
- // lanes, but monitorState may still hold wave N's data until the first
373
- // poll of wave N+1's monitor. Detect this mismatch by checking whether
374
- // the monitor's lane numbers match the current allocation.
375
- const monitorIsFresh = monitorState && monitorState.lanes.length > 0 && (
376
- // If no current allocation, monitor data is the best we have
377
- // (covers terminal phases like completed/failed/stopped)
378
- batchState.currentLanes.length === 0 ||
379
- // If allocated lanes exist, verify monitor lanes match them
380
- monitorState.lanes.some(ml =>
381
- batchState.currentLanes.some(cl => cl.laneNumber === ml.laneNumber),
382
- )
383
- );
384
-
385
- // TP-170: Build a laneNumber → AllocatedLane index for identity reconciliation.
386
- // In workspace mode, the monitor’s sessionName (e.g., "orch-henry-api-lane-1")
387
- // may differ from the V2 registry agentId ("orch-henry-lane-3-worker").
388
- // Cross-referencing with the current allocation ensures the displayed session
389
- // name matches the authoritative laneSessionId for the current wave.
390
- const allocatedByLaneNumber = new Map<number, { laneSessionId: string; laneId: string }>();
391
- for (const cl of batchState.currentLanes) {
392
- allocatedByLaneNumber.set(cl.laneNumber, { laneSessionId: cl.laneSessionId, laneId: cl.laneId });
393
- }
394
-
395
- if (monitorIsFresh && monitorState) {
396
- // Sort lanes by laneNumber (deterministic)
397
- const sortedLanes = [...monitorState.lanes].sort((a, b) => a.laneNumber - b.laneNumber);
398
-
399
- for (const lane of sortedLanes) {
400
- const snap = lane.currentTaskSnapshot;
401
- const alloc = allocatedByLaneNumber.get(lane.laneNumber);
402
-
403
- // TP-170: Reconcile task-level vs lane-level sessionAlive.
404
- // resolveTaskMonitorState may derive sessionAlive from the lane
405
- // snapshot file (snap.status === "running") while the lane-level
406
- // sessionAlive comes from isV2AgentAlive (PID check). When the
407
- // task snapshot says "running" but the lane session is confirmed
408
- // dead, the task is effectively failed — not still running.
409
- let status: OrchLaneCardData["status"] = "idle";
410
- if (lane.failedTasks.length > 0) {
411
- status = "failed";
412
- } else if (snap?.status === "stalled") {
413
- status = "stalled";
414
- } else if (snap?.status === "running") {
415
- // TP-170: TOCTOU guard — if lane session is dead but task snapshot
416
- // still says "running", treat as failed instead of showing
417
- // "session dead" in the card. This prevents the false positive
418
- // where the lane snapshot file lags behind the PID liveness check.
419
- status = lane.sessionAlive ? "running" : "failed";
420
- } else if (
421
- lane.completedTasks.length > 0 &&
422
- lane.remainingTasks.length === 0 &&
423
- !lane.currentTaskId
424
- ) {
425
- status = "succeeded";
426
- }
427
-
428
- laneCards.push({
429
- laneNumber: lane.laneNumber,
430
- laneId: alloc?.laneId || lane.laneId,
431
- // TP-170: Prefer the allocation’s laneSessionId (current-wave authority)
432
- // over the monitor’s sessionName which may use a stale or workspace-local
433
- // name that doesn’t match the V2 registry.
434
- sessionName: alloc?.laneSessionId || lane.sessionName,
435
- sessionAlive: lane.sessionAlive,
436
- currentTaskId: lane.currentTaskId,
437
- currentStepName: snap?.currentStepName || null,
438
- totalChecked: snap?.totalChecked || 0,
439
- totalItems: snap?.totalItems || 0,
440
- completedTasks: lane.completedTasks.length,
441
- totalLaneTasks: lane.completedTasks.length + lane.failedTasks.length + lane.remainingTasks.length + (lane.currentTaskId ? 1 : 0),
442
- status,
443
- stallReason: snap?.stallReason || null,
444
- });
445
- }
446
- } else if (batchState.currentLanes.length > 0) {
447
- // No fresh monitor data — show lanes from allocation.
448
- // This covers both initial startup (monitor hasn't polled yet)
449
- // and wave transitions (monitor data is stale from prior wave).
450
- const sortedLanes = [...batchState.currentLanes].sort((a, b) => a.laneNumber - b.laneNumber);
451
- for (const lane of sortedLanes) {
452
- laneCards.push({
453
- laneNumber: lane.laneNumber,
454
- laneId: lane.laneId,
455
- sessionName: lane.laneSessionId,
456
- sessionAlive: true, // assumed alive during allocation
457
- currentTaskId: lane.tasks.length > 0 ? lane.tasks[0].taskId : null,
458
- currentStepName: null,
459
- totalChecked: 0,
460
- totalItems: 0,
461
- completedTasks: 0,
462
- totalLaneTasks: lane.tasks.length,
463
- status: "running",
464
- stallReason: null,
465
- });
466
- }
467
- }
468
-
469
- // Determine attach hint
470
- let attachHint = "";
471
- const aliveLane = laneCards.find(l => l.sessionAlive && l.status === "running");
472
- if (aliveLane) {
473
- attachHint = `Use /orch-sessions to inspect active lane sessions (${aliveLane.sessionName})`;
474
- } else if (laneCards.length > 0) {
475
- attachHint = "Use /orch-sessions for active lane session list";
476
- }
477
-
478
- // Determine failure policy if batch was stopped
479
- let failurePolicy: string | null = null;
480
- if (batchState.phase === "stopped" && batchState.waveResults.length > 0) {
481
- const lastWave = batchState.waveResults[batchState.waveResults.length - 1];
482
- if (lastWave.stoppedEarly && lastWave.policyApplied) {
483
- failurePolicy = lastWave.policyApplied;
484
- }
485
- }
486
-
487
- return {
488
- phase: batchState.phase,
489
- batchId: batchState.batchId,
490
- orchBranch: batchState.orchBranch || batchState.baseBranch || "",
491
- waveProgress,
492
- elapsed,
493
- summary,
494
- laneCards,
495
- attachHint,
496
- errors: batchState.errors,
497
- failurePolicy,
498
- };
499
- }
500
-
501
- // ── Lane Card Rendering ──────────────────────────────────────────────
502
-
503
- /**
504
- * Render a single lane card for the dashboard.
505
- *
506
- * Follows the task-runner `renderStepCard` pattern:
507
- * bordered box with lane info, status icon, task progress.
508
- *
509
- * @param card - Lane card data from view-model
510
- * @param colWidth - Available width for the card (including borders)
511
- * @param theme - Pi theme object for color styling
512
- * @returns Array of styled string lines (one per card row)
513
- */
514
- export function renderLaneCard(card: OrchLaneCardData, colWidth: number, theme: any): string[] {
515
- const w = colWidth - 2; // inner width (excluding │ borders)
516
- const trunc = (s: string, max: number) => s.length > max ? s.slice(0, max - 3) + "..." : s;
517
-
518
- // Status icon and color
519
- const statusIcon = card.status === "succeeded" ? "✓"
520
- : card.status === "running" ? "●"
521
- : card.status === "failed" ? "✗"
522
- : card.status === "stalled" ? "⚠"
523
- : "○";
524
- const statusColor = card.status === "succeeded" ? "success"
525
- : card.status === "running" ? "accent"
526
- : card.status === "failed" ? "error"
527
- : card.status === "stalled" ? "warning"
528
- : "dim";
529
-
530
- // Line 1: Session name (e.g., "⎡orch-lane-1⎤")
531
- const sessionLabel = `⎡${card.sessionName}⎤`;
532
- const sessionStr = theme.fg("accent", theme.bold(trunc(sessionLabel, w)));
533
- const sessionVis = Math.min(sessionLabel.length, w);
534
-
535
- // Line 2: Status + current task
536
- const taskInfo = card.currentTaskId
537
- ? `${statusIcon} ${card.currentTaskId}`
538
- : card.status === "succeeded" ? `${statusIcon} done`
539
- : card.status === "failed" ? `${statusIcon} failed`
540
- : `${statusIcon} idle`;
541
- const taskStr = theme.fg(statusColor, trunc(taskInfo, w));
542
- const taskVis = Math.min(taskInfo.length, w);
543
-
544
- // Line 3: Step progress
545
- let stepInfo = "";
546
- if (card.currentStepName) {
547
- stepInfo = trunc(card.currentStepName, w - 2);
548
- } else if (card.currentTaskId && card.totalItems === 0) {
549
- // TP-170: Distinguish startup-grace (no STATUS.md yet) from
550
- // genuine stale data. During startup, the lane is alive but
551
- // hasn’t written STATUS.md yet — show "starting..." instead of
552
- // the misleading "waiting for data..." which implies a problem.
553
- stepInfo = card.sessionAlive ? "starting..." : "no status data";
554
- } else if (!card.currentTaskId && card.status !== "idle") {
555
- stepInfo = `${card.completedTasks}/${card.totalLaneTasks} tasks`;
556
- }
557
- const stepStr = theme.fg("muted", trunc(stepInfo, w));
558
- const stepVis = Math.min(stepInfo.length, w);
559
-
560
- // Line 4: Checkbox progress or stall reason
561
- let extraInfo = "";
562
- let extraColor = "dim";
563
- if (card.stallReason) {
564
- extraInfo = `⚠ ${trunc(card.stallReason, w - 4)}`;
565
- extraColor = "warning";
566
- } else if (card.totalItems > 0) {
567
- extraInfo = `${card.totalChecked}/${card.totalItems} ✓`;
568
- extraColor = card.totalChecked === card.totalItems ? "success" : "muted";
569
- } else if (!card.sessionAlive && card.status === "running") {
570
- // TP-170: With the TOCTOU guard in buildDashboardViewModel, a lane
571
- // with a dead session and task snapshot "running" now gets status
572
- // "failed" instead. This branch guards any remaining edge cases
573
- // (e.g., allocation-fallback lane assumed alive but actually dead).
574
- extraInfo = "session ended";
575
- extraColor = "warning";
576
- }
577
- const extraStr = theme.fg(extraColor, trunc(extraInfo, w));
578
- const extraVis = Math.min(extraInfo.length, w);
579
-
580
- // Build bordered card
581
- const top = "┌" + "─".repeat(w) + "┐";
582
- const bot = "└" + "─".repeat(w) + "┘";
583
- const border = (content: string, vis: number) =>
584
- theme.fg("dim", "│") + content + " ".repeat(Math.max(0, w - vis)) + theme.fg("dim", "│");
585
-
586
- return [
587
- theme.fg("dim", top),
588
- border(" " + sessionStr, 1 + sessionVis),
589
- border(" " + taskStr, 1 + taskVis),
590
- border(" " + stepStr, 1 + stepVis),
591
- border(extraInfo ? " " + extraStr : "", extraVis ? 1 + extraVis : 0),
592
- theme.fg("dim", bot),
593
- ];
594
- }
595
-
596
- // ── Core Widget ──────────────────────────────────────────────────────
597
-
598
- /**
599
- * Create the widget registration callback for the orchestrator dashboard.
600
- *
601
- * This is the main entry point for the dashboard widget. It captures
602
- * batchState and monitorState references and returns a widget that
603
- * re-renders on each paint cycle using the latest state.
604
- *
605
- * @param getBatchState - Getter for current batch state
606
- * @param getMonitorState - Getter for current monitor state (may be null)
607
- * @param sessionPrefix - Session prefix for lane identification
608
- */
609
- export function createOrchWidget(
610
- getBatchState: () => OrchBatchRuntimeState,
611
- getMonitorState: () => MonitorState | null,
612
- sessionPrefix: string,
613
- ): (_tui: any, theme: any) => { render(width: number): string[]; invalidate(): void } {
614
- return (_tui: any, theme: any) => {
615
- return {
616
- render(width: number): string[] {
617
- const batchState = getBatchState();
618
- const monitorState = getMonitorState();
619
- const vm = buildDashboardViewModel(batchState, monitorState);
620
-
621
- // ── Idle state ─────────────────────────────────
622
- if (vm.phase === "idle") {
623
- return [];
624
- }
625
-
626
- const lines: string[] = [""];
627
-
628
- // ── Phase-specific rendering ──────────────────
629
- const phaseIcon =
630
- vm.phase === "launching" ? "◌"
631
- : vm.phase === "planning" ? "◌"
632
- : vm.phase === "executing" ? "●"
633
- : vm.phase === "merging" ? "🔀"
634
- : vm.phase === "paused" ? "⏸"
635
- : vm.phase === "stopped" ? "⛔"
636
- : vm.phase === "completed" ? "✓"
637
- : vm.phase === "failed" ? "✗"
638
- : "○";
639
- const phaseColor =
640
- vm.phase === "executing" ? "accent"
641
- : vm.phase === "merging" ? "accent"
642
- : vm.phase === "completed" ? "success"
643
- : vm.phase === "failed" || vm.phase === "stopped" ? "error"
644
- : vm.phase === "paused" ? "warning"
645
- : "dim";
646
-
647
- // Header: phase icon + batch ID + wave + elapsed
648
- const header =
649
- theme.fg(phaseColor, ` ${phaseIcon} `) +
650
- theme.fg("accent", theme.bold(vm.batchId || "—")) +
651
- theme.fg("dim", " ") +
652
- theme.fg("warning", `W${vm.waveProgress}`) +
653
- theme.fg("dim", " · ") +
654
- theme.fg("muted", vm.elapsed);
655
- lines.push(truncateToWidth(header, width));
656
-
657
- // ── Planning state ────────────────────────────
658
- if (vm.phase === "planning") {
659
- lines.push(truncateToWidth(
660
- theme.fg("dim", " ◌ Planning batch..."),
661
- width,
662
- ));
663
- return lines;
664
- }
665
-
666
- // ── Progress bar ──────────────────────────────
667
- const { completed, failed, total } = vm.summary;
668
- const done = completed + failed;
669
- const pct = total > 0 ? Math.round((done / total) * 100) : 0;
670
- const barWidth = Math.min(30, width - 20);
671
- const filled = Math.round((pct / 100) * barWidth);
672
- const progressBar =
673
- theme.fg("dim", " ") +
674
- theme.fg("warning", "[") +
675
- theme.fg("success", "█".repeat(filled)) +
676
- theme.fg("dim", "░".repeat(Math.max(0, barWidth - filled))) +
677
- theme.fg("warning", "]") +
678
- theme.fg("dim", " ") +
679
- theme.fg("accent", `${done}/${total}`) +
680
- theme.fg("dim", ` (${pct}%)`);
681
- lines.push(truncateToWidth(progressBar, width));
682
-
683
- // ── Summary counts line ───────────────────────
684
- const countParts: string[] = [];
685
- if (vm.summary.completed > 0) countParts.push(theme.fg("success", `${vm.summary.completed} ✓`));
686
- if (vm.summary.running > 0) countParts.push(theme.fg("accent", `${vm.summary.running} running`));
687
- if (vm.summary.queued > 0) countParts.push(theme.fg("dim", `${vm.summary.queued} queued`));
688
- if (vm.summary.failed > 0) countParts.push(theme.fg("error", `${vm.summary.failed} ✗`));
689
- if (vm.summary.blocked > 0) countParts.push(theme.fg("warning", `${vm.summary.blocked} blocked`));
690
- if (vm.summary.stalled > 0) countParts.push(theme.fg("warning", `${vm.summary.stalled} stalled`));
691
- if (countParts.length > 0) {
692
- lines.push(truncateToWidth(" " + countParts.join(theme.fg("dim", " · ")), width));
693
- }
694
- lines.push("");
695
-
696
- // ── Lane cards ─────────────────────────────────
697
- if (vm.laneCards.length > 0 && (vm.phase === "executing" || vm.phase === "merging" || vm.phase === "paused")) {
698
- const arrowWidth = 3;
699
- const minCardWidth = 18;
700
- const maxCols = Math.max(1, Math.floor((width + arrowWidth) / (minCardWidth + arrowWidth)));
701
- const cols = Math.min(vm.laneCards.length, maxCols);
702
- const colWidth = Math.max(minCardWidth, Math.floor((width - arrowWidth * (cols - 1)) / cols));
703
-
704
- for (let rowStart = 0; rowStart < vm.laneCards.length; rowStart += cols) {
705
- const rowCards = vm.laneCards.slice(rowStart, rowStart + cols);
706
- const rendered = rowCards.map(c => renderLaneCard(c, colWidth, theme));
707
-
708
- if (rendered.length > 0) {
709
- const cardHeight = rendered[0].length;
710
- for (let line = 0; line < cardHeight; line++) {
711
- let row = rendered[0][line];
712
- for (let c = 1; c < rendered.length; c++) {
713
- row += " "; // spacer between cards
714
- row += rendered[c][line];
715
- }
716
- lines.push(truncateToWidth(row, width));
717
- }
718
- }
719
- }
720
- }
721
-
722
- // ── Terminal states (completed/failed/stopped) ──
723
- if (vm.phase === "completed") {
724
- lines.push(truncateToWidth(
725
- theme.fg("success", " ✅ Batch complete"),
726
- width,
727
- ));
728
- } else if (vm.phase === "failed") {
729
- lines.push(truncateToWidth(
730
- theme.fg("error", " ❌ Batch failed"),
731
- width,
732
- ));
733
- for (const err of vm.errors.slice(0, 3)) {
734
- lines.push(truncateToWidth(
735
- theme.fg("error", ` ${err.slice(0, 80)}`),
736
- width,
737
- ));
738
- }
739
- } else if (vm.phase === "stopped") {
740
- lines.push(truncateToWidth(
741
- theme.fg("error", ` ⛔ Stopped by ${vm.failurePolicy || "policy"}`),
742
- width,
743
- ));
744
- } else if (vm.phase === "merging") {
745
- lines.push("");
746
- lines.push(truncateToWidth(
747
- theme.fg("accent", ` 🔀 Merging lane branches into ${vm.orchBranch || "orch branch"}...`),
748
- width,
749
- ));
750
- } else if (vm.phase === "paused") {
751
- lines.push("");
752
- lines.push(truncateToWidth(
753
- theme.fg("warning", " ⏸ Batch paused — lanes will stop after current tasks"),
754
- width,
755
- ));
756
- }
757
-
758
- // ── Footer: attach hint ───────────────────────
759
- if (vm.attachHint && (vm.phase === "executing" || vm.phase === "merging" || vm.phase === "paused")) {
760
- lines.push("");
761
- lines.push(truncateToWidth(
762
- theme.fg("dim", ` 💡 ${vm.attachHint}`),
763
- width,
764
- ));
765
- }
766
-
767
- return lines;
768
- },
769
- invalidate() {},
770
- };
771
- };
772
- }
773
-
1
+ /**
2
+ * Output formatting, dashboard widget, wave plan display
3
+ * @module orch/formatting
4
+ */
5
+ import { join } from "path";
6
+ import { truncateToWidth } from "@mariozechner/pi-tui";
7
+
8
+ import { parseDependencyReference } from "./discovery.ts";
9
+ import type { LaneAssignment, MonitorState, OrchBatchRuntimeState, OrchDashboardViewModel, OrchLaneCardData, OrchSummaryCounts, ParsedTask, WaveComputationResult } from "./types.ts";
10
+ import { getTaskDurationMinutes, SIZE_DURATION_MINUTES } from "./types.ts";
11
+
12
+ // ── Wave Output Formatting ───────────────────────────────────────────
13
+
14
+ // ── Dependency Graph Formatting ──────────────────────────────────────
15
+
16
+ /**
17
+ * Format a dependency graph for display.
18
+ *
19
+ * Shows both upstream (what each task depends on) and downstream
20
+ * (what depends on each task) views. Output is deterministic:
21
+ * tasks sorted by ID, edges sorted by target ID.
22
+ *
23
+ * If `filterTaskId` is provided, only shows edges involving that task.
24
+ */
25
+ export function formatDependencyGraph(
26
+ pending: Map<string, ParsedTask>,
27
+ completed: Set<string>,
28
+ filterTaskId?: string,
29
+ ): string {
30
+ const lines: string[] = [];
31
+
32
+ // Sort tasks deterministically by ID
33
+ const sortedTasks = [...pending.values()].sort((a, b) =>
34
+ a.taskId.localeCompare(b.taskId),
35
+ );
36
+
37
+ // Build downstream index: taskID → tasks that depend on it
38
+ const downstream = new Map<string, string[]>();
39
+ for (const task of sortedTasks) {
40
+ for (const depRaw of task.dependencies) {
41
+ const depId = parseDependencyReference(depRaw).taskId;
42
+ const existing = downstream.get(depId) || [];
43
+ existing.push(task.taskId);
44
+ downstream.set(depId, existing);
45
+ }
46
+ }
47
+
48
+ // If filtering to a single task
49
+ if (filterTaskId) {
50
+ const task = pending.get(filterTaskId);
51
+ if (!task) {
52
+ lines.push(`❌ Task "${filterTaskId}" not found in pending tasks.`);
53
+ return lines.join("\n");
54
+ }
55
+
56
+ lines.push(`🔗 Dependencies for ${filterTaskId} (${task.taskName}):`);
57
+ lines.push("");
58
+
59
+ // Upstream: what this task depends on
60
+ lines.push(" ⬆ Upstream (depends on):");
61
+ if (task.dependencies.length === 0) {
62
+ lines.push(" (none — no dependencies)");
63
+ } else {
64
+ const sortedDeps = [...task.dependencies].sort();
65
+ for (const depRaw of sortedDeps) {
66
+ const depId = parseDependencyReference(depRaw).taskId;
67
+ const status = completed.has(depId)
68
+ ? "✅ complete"
69
+ : pending.has(depId)
70
+ ? "⏳ pending"
71
+ : "❓ unknown";
72
+ lines.push(` ${filterTaskId} → ${depRaw} (${status})`);
73
+ }
74
+ }
75
+
76
+ // Downstream: what depends on this task
77
+ lines.push("");
78
+ lines.push(" ⬇ Downstream (depended on by):");
79
+ const downstreamTasks = (downstream.get(filterTaskId) || []).sort();
80
+ if (downstreamTasks.length === 0) {
81
+ lines.push(" (none — no tasks depend on this)");
82
+ } else {
83
+ for (const dep of downstreamTasks) {
84
+ lines.push(` ${dep} → ${filterTaskId}`);
85
+ }
86
+ }
87
+
88
+ return lines.join("\n");
89
+ }
90
+
91
+ // Full graph view
92
+ lines.push("🔗 Dependency Graph:");
93
+ lines.push("");
94
+
95
+ let hasDeps = false;
96
+
97
+ // Section 1: Upstream view (what each task depends on)
98
+ lines.push(" ⬆ Upstream (task → depends on):");
99
+ for (const task of sortedTasks) {
100
+ if (task.dependencies.length > 0) {
101
+ hasDeps = true;
102
+ const sortedDeps = [...task.dependencies].sort();
103
+ for (const depRaw of sortedDeps) {
104
+ const depId = parseDependencyReference(depRaw).taskId;
105
+ const status = completed.has(depId)
106
+ ? "✅ complete"
107
+ : pending.has(depId)
108
+ ? "⏳ pending"
109
+ : "❓ unknown";
110
+ lines.push(` ${task.taskId} → ${depRaw} (${status})`);
111
+ }
112
+ }
113
+ }
114
+ if (!hasDeps) {
115
+ lines.push(" (none — all tasks are independent)");
116
+ }
117
+
118
+ // Section 2: Downstream view (what depends on each task)
119
+ lines.push("");
120
+ lines.push(" ⬇ Downstream (task ← depended on by):");
121
+ let hasDownstream = false;
122
+ const allTargets = new Set<string>();
123
+ for (const task of sortedTasks) {
124
+ for (const depRaw of task.dependencies) {
125
+ allTargets.add(parseDependencyReference(depRaw).taskId);
126
+ }
127
+ }
128
+ const sortedTargets = [...allTargets].sort();
129
+ for (const target of sortedTargets) {
130
+ const dependents = (downstream.get(target) || []).sort();
131
+ if (dependents.length > 0) {
132
+ hasDownstream = true;
133
+ const status = completed.has(target)
134
+ ? "✅"
135
+ : pending.has(target)
136
+ ? "⏳"
137
+ : "❓";
138
+ lines.push(
139
+ ` ${target} ${status} ← ${dependents.join(", ")}`,
140
+ );
141
+ }
142
+ }
143
+ if (!hasDownstream) {
144
+ lines.push(" (none — no downstream dependencies)");
145
+ }
146
+
147
+ // Section 3: Independent tasks (no deps, nothing depends on them)
148
+ const independentTasks = sortedTasks.filter(
149
+ (t) =>
150
+ t.dependencies.length === 0 &&
151
+ !(downstream.get(t.taskId)?.length),
152
+ );
153
+ if (independentTasks.length > 0) {
154
+ lines.push("");
155
+ lines.push(" ○ Independent (no dependencies, nothing depends on them):");
156
+ for (const task of independentTasks) {
157
+ lines.push(` ${task.taskId} [${task.size}] ${task.taskName}`);
158
+ }
159
+ }
160
+
161
+ return lines.join("\n");
162
+ }
163
+
164
+ /**
165
+ * Format wave computation results as a readable execution plan.
166
+ *
167
+ * Output sections (fixed order):
168
+ * 1. Wave overview header
169
+ * 2. Per-wave: task count, lane count, parallel/serial indicator
170
+ * 3. Per-lane within wave: tasks with sizes, serial notes, lane weight
171
+ * 4. Per-wave: estimated duration (critical path = max lane duration)
172
+ * 5. Summary: total estimated duration, size-to-duration table
173
+ *
174
+ * Duration calculation:
175
+ * - Per lane: sum of task durations for tasks in that lane
176
+ * - Per wave: max lane duration (parallel bottleneck / critical path)
177
+ * - Total: sum of wave durations (waves run sequentially)
178
+ */
179
+ export function formatWavePlan(
180
+ result: WaveComputationResult,
181
+ sizeWeights: Record<string, number>,
182
+ ): string {
183
+ const lines: string[] = [];
184
+
185
+ if (result.errors.length > 0) {
186
+ lines.push("❌ Wave Computation Errors:");
187
+ for (const err of result.errors) {
188
+ lines.push(` [${err.code}] ${err.message}`);
189
+ }
190
+ return lines.join("\n");
191
+ }
192
+
193
+ if (result.waves.length === 0) {
194
+ lines.push("No waves to schedule.");
195
+ return lines.join("\n");
196
+ }
197
+
198
+ // Count total tasks
199
+ const totalTasks = result.waves.reduce((sum, w) => sum + w.tasks.length, 0);
200
+ const maxLanesUsed = Math.max(
201
+ ...result.waves.map((w) => {
202
+ const lanes = new Set(w.tasks.map((t) => t.lane));
203
+ return lanes.size;
204
+ }),
205
+ );
206
+
207
+ lines.push(
208
+ `🌊 Execution Plan: ${result.waves.length} wave(s), ` +
209
+ `${totalTasks} task(s), up to ${maxLanesUsed} lane(s)`,
210
+ );
211
+ lines.push("");
212
+
213
+ let totalEstimate = 0;
214
+ for (const wave of result.waves) {
215
+ // Group tasks by lane (deterministic: Map preserves insertion order)
216
+ const laneGroups = new Map<number, LaneAssignment[]>();
217
+ for (const assignment of wave.tasks) {
218
+ const existing = laneGroups.get(assignment.lane) || [];
219
+ existing.push(assignment);
220
+ laneGroups.set(assignment.lane, existing);
221
+ }
222
+
223
+ const laneCount = laneGroups.size;
224
+ const taskCount = wave.tasks.length;
225
+ const parallel = laneCount > 1 ? "parallel" : "serial";
226
+
227
+ lines.push(
228
+ ` Wave ${wave.waveNumber}: ${taskCount} task(s) across ` +
229
+ `${laneCount} lane(s) [${parallel}]`,
230
+ );
231
+
232
+ // Calculate wave duration: critical path = max lane duration
233
+ let maxLaneDuration = 0;
234
+
235
+ // Sort lanes deterministically by lane number
236
+ const sortedLanes = [...laneGroups.entries()].sort(
237
+ (a, b) => a[0] - b[0],
238
+ );
239
+
240
+ for (const [lane, assignments] of sortedLanes) {
241
+ // Sort tasks within lane by task ID for deterministic output
242
+ const sortedAssignments = [...assignments].sort((a, b) =>
243
+ a.taskId.localeCompare(b.taskId),
244
+ );
245
+ const taskList = sortedAssignments
246
+ .map((a) => `${a.taskId} [${a.task.size}]`)
247
+ .join(", ");
248
+ const laneDuration = sortedAssignments.reduce(
249
+ (sum, a) =>
250
+ sum + getTaskDurationMinutes(a.task.size, sizeWeights),
251
+ 0,
252
+ );
253
+ if (laneDuration > maxLaneDuration) maxLaneDuration = laneDuration;
254
+ const serialNote =
255
+ sortedAssignments.length > 1 ? " (serial)" : "";
256
+ lines.push(
257
+ ` Lane ${lane}: ${taskList}${serialNote} ` +
258
+ `[est. ${laneDuration} min]`,
259
+ );
260
+ }
261
+
262
+ // Critical path for this wave
263
+ totalEstimate += maxLaneDuration;
264
+ lines.push(
265
+ ` ⏱ Wave duration: ${maxLaneDuration} min ` +
266
+ `(critical path: longest lane)`,
267
+ );
268
+ lines.push("");
269
+ }
270
+
271
+ // Summary with size-to-duration table
272
+ const totalHours = (totalEstimate / 60).toFixed(1);
273
+ lines.push(`📊 Total estimated duration: ${totalEstimate} min (~${totalHours} hours)`);
274
+ lines.push(
275
+ ` Duration model: S=${SIZE_DURATION_MINUTES["S"]}m, ` +
276
+ `M=${SIZE_DURATION_MINUTES["M"]}m, L=${SIZE_DURATION_MINUTES["L"]}m`,
277
+ );
278
+ lines.push(
279
+ " Critical path: sum of per-wave bottleneck lanes " +
280
+ "(waves sequential, lanes parallel)",
281
+ );
282
+
283
+ return lines.join("\n");
284
+ }
285
+
286
+
287
+ // ── Summary Helpers ──────────────────────────────────────────────────
288
+
289
+ /**
290
+ * Compute summary counts from batch state + optional monitor state.
291
+ *
292
+ * Pure function — no side effects, deterministic output.
293
+ */
294
+ export function computeOrchSummaryCounts(
295
+ batchState: OrchBatchRuntimeState,
296
+ monitorState?: MonitorState | null,
297
+ ): OrchSummaryCounts {
298
+ let running = 0;
299
+ let stalled = 0;
300
+
301
+ // If we have live monitor data, count running/stalled from it
302
+ if (monitorState) {
303
+ for (const lane of monitorState.lanes) {
304
+ if (lane.currentTaskSnapshot) {
305
+ if (lane.currentTaskSnapshot.status === "stalled") {
306
+ stalled++;
307
+ } else if (lane.currentTaskSnapshot.status === "running") {
308
+ running++;
309
+ }
310
+ }
311
+ }
312
+ }
313
+
314
+ const completed = batchState.succeededTasks;
315
+ const failed = batchState.failedTasks;
316
+ const blocked = batchState.blockedTasks;
317
+ const total = batchState.totalTasks;
318
+ const queued = Math.max(0, total - completed - failed - blocked - stalled - running - batchState.skippedTasks);
319
+
320
+ return { completed, running, queued, failed, blocked, stalled, total };
321
+ }
322
+
323
+ /**
324
+ * Format elapsed time from start/end timestamps.
325
+ *
326
+ * @param startMs - Start epoch ms
327
+ * @param endMs - End epoch ms (null = use current time)
328
+ * @returns Human-readable string, e.g., "2m 14s" or "1h 5m 30s"
329
+ */
330
+ export function formatElapsedTime(startMs: number, endMs?: number | null): string {
331
+ if (startMs <= 0) return "0s";
332
+ const elapsed = (endMs ?? Date.now()) - startMs;
333
+ if (elapsed < 0) return "0s";
334
+
335
+ const totalSec = Math.floor(elapsed / 1000);
336
+ const hours = Math.floor(totalSec / 3600);
337
+ const minutes = Math.floor((totalSec % 3600) / 60);
338
+ const seconds = totalSec % 60;
339
+
340
+ if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
341
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
342
+ return `${seconds}s`;
343
+ }
344
+
345
+ /**
346
+ * Build the dashboard view-model from runtime state.
347
+ *
348
+ * Pure function — deterministic mapping from OrchBatchRuntimeState +
349
+ * optional MonitorState to render-ready OrchDashboardViewModel.
350
+ *
351
+ * Fallback behavior:
352
+ * - No batch → idle view with zeroed counts
353
+ * - No monitor data → empty lane cards, counts from batch state only
354
+ * - Missing STATUS.md → "no data" in lane card
355
+ */
356
+ export function buildDashboardViewModel(
357
+ batchState: OrchBatchRuntimeState,
358
+ monitorState?: MonitorState | null,
359
+ ): OrchDashboardViewModel {
360
+ const summary = computeOrchSummaryCounts(batchState, monitorState);
361
+ const elapsed = formatElapsedTime(batchState.startedAt, batchState.endedAt);
362
+
363
+ const waveProgress = batchState.totalWaves > 0
364
+ ? `${Math.max(0, batchState.currentWaveIndex + 1)}/${batchState.totalWaves}`
365
+ : "0/0";
366
+
367
+ // Build lane cards from monitor state (if available) or current lanes
368
+ const laneCards: OrchLaneCardData[] = [];
369
+
370
+ // TP-170: Detect stale monitor data from prior waves.
371
+ // When wave N+1 starts, batchState.currentLanes is updated to wave N+1's
372
+ // lanes, but monitorState may still hold wave N's data until the first
373
+ // poll of wave N+1's monitor. Detect this mismatch by checking whether
374
+ // the monitor's lane numbers match the current allocation.
375
+ const monitorIsFresh = monitorState && monitorState.lanes.length > 0 && (
376
+ // If no current allocation, monitor data is the best we have
377
+ // (covers terminal phases like completed/failed/stopped)
378
+ batchState.currentLanes.length === 0 ||
379
+ // If allocated lanes exist, verify monitor lanes match them
380
+ monitorState.lanes.some(ml =>
381
+ batchState.currentLanes.some(cl => cl.laneNumber === ml.laneNumber),
382
+ )
383
+ );
384
+
385
+ // TP-170: Build a laneNumber → AllocatedLane index for identity reconciliation.
386
+ // In workspace mode, the monitor’s sessionName (e.g., "orch-henry-api-lane-1")
387
+ // may differ from the V2 registry agentId ("orch-henry-lane-3-worker").
388
+ // Cross-referencing with the current allocation ensures the displayed session
389
+ // name matches the authoritative laneSessionId for the current wave.
390
+ const allocatedByLaneNumber = new Map<number, { laneSessionId: string; laneId: string }>();
391
+ for (const cl of batchState.currentLanes) {
392
+ allocatedByLaneNumber.set(cl.laneNumber, { laneSessionId: cl.laneSessionId, laneId: cl.laneId });
393
+ }
394
+
395
+ if (monitorIsFresh && monitorState) {
396
+ // Sort lanes by laneNumber (deterministic)
397
+ const sortedLanes = [...monitorState.lanes].sort((a, b) => a.laneNumber - b.laneNumber);
398
+
399
+ for (const lane of sortedLanes) {
400
+ const snap = lane.currentTaskSnapshot;
401
+ const alloc = allocatedByLaneNumber.get(lane.laneNumber);
402
+
403
+ // TP-170: Reconcile task-level vs lane-level sessionAlive.
404
+ // resolveTaskMonitorState may derive sessionAlive from the lane
405
+ // snapshot file (snap.status === "running") while the lane-level
406
+ // sessionAlive comes from isV2AgentAlive (PID check). When the
407
+ // task snapshot says "running" but the lane session is confirmed
408
+ // dead, the task is effectively failed — not still running.
409
+ let status: OrchLaneCardData["status"] = "idle";
410
+ if (lane.failedTasks.length > 0) {
411
+ status = "failed";
412
+ } else if (snap?.status === "stalled") {
413
+ status = "stalled";
414
+ } else if (snap?.status === "running") {
415
+ // TP-170: TOCTOU guard — if lane session is dead but task snapshot
416
+ // still says "running", treat as failed instead of showing
417
+ // "session dead" in the card. This prevents the false positive
418
+ // where the lane snapshot file lags behind the PID liveness check.
419
+ status = lane.sessionAlive ? "running" : "failed";
420
+ } else if (
421
+ lane.completedTasks.length > 0 &&
422
+ lane.remainingTasks.length === 0 &&
423
+ !lane.currentTaskId
424
+ ) {
425
+ status = "succeeded";
426
+ }
427
+
428
+ laneCards.push({
429
+ laneNumber: lane.laneNumber,
430
+ laneId: alloc?.laneId || lane.laneId,
431
+ // TP-170: Prefer the allocation’s laneSessionId (current-wave authority)
432
+ // over the monitor’s sessionName which may use a stale or workspace-local
433
+ // name that doesn’t match the V2 registry.
434
+ sessionName: alloc?.laneSessionId || lane.sessionName,
435
+ sessionAlive: lane.sessionAlive,
436
+ currentTaskId: lane.currentTaskId,
437
+ currentStepName: snap?.currentStepName || null,
438
+ totalChecked: snap?.totalChecked || 0,
439
+ totalItems: snap?.totalItems || 0,
440
+ completedTasks: lane.completedTasks.length,
441
+ totalLaneTasks: lane.completedTasks.length + lane.failedTasks.length + lane.remainingTasks.length + (lane.currentTaskId ? 1 : 0),
442
+ status,
443
+ stallReason: snap?.stallReason || null,
444
+ });
445
+ }
446
+ } else if (batchState.currentLanes.length > 0) {
447
+ // No fresh monitor data — show lanes from allocation.
448
+ // This covers both initial startup (monitor hasn't polled yet)
449
+ // and wave transitions (monitor data is stale from prior wave).
450
+ const sortedLanes = [...batchState.currentLanes].sort((a, b) => a.laneNumber - b.laneNumber);
451
+ for (const lane of sortedLanes) {
452
+ laneCards.push({
453
+ laneNumber: lane.laneNumber,
454
+ laneId: lane.laneId,
455
+ sessionName: lane.laneSessionId,
456
+ sessionAlive: true, // assumed alive during allocation
457
+ currentTaskId: lane.tasks.length > 0 ? lane.tasks[0].taskId : null,
458
+ currentStepName: null,
459
+ totalChecked: 0,
460
+ totalItems: 0,
461
+ completedTasks: 0,
462
+ totalLaneTasks: lane.tasks.length,
463
+ status: "running",
464
+ stallReason: null,
465
+ });
466
+ }
467
+ }
468
+
469
+ // Determine attach hint
470
+ let attachHint = "";
471
+ const aliveLane = laneCards.find(l => l.sessionAlive && l.status === "running");
472
+ if (aliveLane) {
473
+ attachHint = `Use /orch-sessions to inspect active lane sessions (${aliveLane.sessionName})`;
474
+ } else if (laneCards.length > 0) {
475
+ attachHint = "Use /orch-sessions for active lane session list";
476
+ }
477
+
478
+ // Determine failure policy if batch was stopped
479
+ let failurePolicy: string | null = null;
480
+ if (batchState.phase === "stopped" && batchState.waveResults.length > 0) {
481
+ const lastWave = batchState.waveResults[batchState.waveResults.length - 1];
482
+ if (lastWave.stoppedEarly && lastWave.policyApplied) {
483
+ failurePolicy = lastWave.policyApplied;
484
+ }
485
+ }
486
+
487
+ return {
488
+ phase: batchState.phase,
489
+ batchId: batchState.batchId,
490
+ orchBranch: batchState.orchBranch || batchState.baseBranch || "",
491
+ waveProgress,
492
+ elapsed,
493
+ summary,
494
+ laneCards,
495
+ attachHint,
496
+ errors: batchState.errors,
497
+ failurePolicy,
498
+ };
499
+ }
500
+
501
+ // ── Lane Card Rendering ──────────────────────────────────────────────
502
+
503
+ /**
504
+ * Render a single lane card for the dashboard.
505
+ *
506
+ * Follows the task-runner `renderStepCard` pattern:
507
+ * bordered box with lane info, status icon, task progress.
508
+ *
509
+ * @param card - Lane card data from view-model
510
+ * @param colWidth - Available width for the card (including borders)
511
+ * @param theme - Pi theme object for color styling
512
+ * @returns Array of styled string lines (one per card row)
513
+ */
514
+ export function renderLaneCard(card: OrchLaneCardData, colWidth: number, theme: any): string[] {
515
+ const w = colWidth - 2; // inner width (excluding │ borders)
516
+ const trunc = (s: string, max: number) => s.length > max ? s.slice(0, max - 3) + "..." : s;
517
+
518
+ // Status icon and color
519
+ const statusIcon = card.status === "succeeded" ? "✓"
520
+ : card.status === "running" ? "●"
521
+ : card.status === "failed" ? "✗"
522
+ : card.status === "stalled" ? "⚠"
523
+ : "○";
524
+ const statusColor = card.status === "succeeded" ? "success"
525
+ : card.status === "running" ? "accent"
526
+ : card.status === "failed" ? "error"
527
+ : card.status === "stalled" ? "warning"
528
+ : "dim";
529
+
530
+ // Line 1: Session name (e.g., "⎡orch-lane-1⎤")
531
+ const sessionLabel = `⎡${card.sessionName}⎤`;
532
+ const sessionStr = theme.fg("accent", theme.bold(trunc(sessionLabel, w)));
533
+ const sessionVis = Math.min(sessionLabel.length, w);
534
+
535
+ // Line 2: Status + current task
536
+ const taskInfo = card.currentTaskId
537
+ ? `${statusIcon} ${card.currentTaskId}`
538
+ : card.status === "succeeded" ? `${statusIcon} done`
539
+ : card.status === "failed" ? `${statusIcon} failed`
540
+ : `${statusIcon} idle`;
541
+ const taskStr = theme.fg(statusColor, trunc(taskInfo, w));
542
+ const taskVis = Math.min(taskInfo.length, w);
543
+
544
+ // Line 3: Step progress
545
+ let stepInfo = "";
546
+ if (card.currentStepName) {
547
+ stepInfo = trunc(card.currentStepName, w - 2);
548
+ } else if (card.currentTaskId && card.totalItems === 0) {
549
+ // TP-170: Distinguish startup-grace (no STATUS.md yet) from
550
+ // genuine stale data. During startup, the lane is alive but
551
+ // hasn’t written STATUS.md yet — show "starting..." instead of
552
+ // the misleading "waiting for data..." which implies a problem.
553
+ stepInfo = card.sessionAlive ? "starting..." : "no status data";
554
+ } else if (!card.currentTaskId && card.status !== "idle") {
555
+ stepInfo = `${card.completedTasks}/${card.totalLaneTasks} tasks`;
556
+ }
557
+ const stepStr = theme.fg("muted", trunc(stepInfo, w));
558
+ const stepVis = Math.min(stepInfo.length, w);
559
+
560
+ // Line 4: Checkbox progress or stall reason
561
+ let extraInfo = "";
562
+ let extraColor = "dim";
563
+ if (card.stallReason) {
564
+ extraInfo = `⚠ ${trunc(card.stallReason, w - 4)}`;
565
+ extraColor = "warning";
566
+ } else if (card.totalItems > 0) {
567
+ extraInfo = `${card.totalChecked}/${card.totalItems} ✓`;
568
+ extraColor = card.totalChecked === card.totalItems ? "success" : "muted";
569
+ } else if (!card.sessionAlive && card.status === "running") {
570
+ // TP-170: With the TOCTOU guard in buildDashboardViewModel, a lane
571
+ // with a dead session and task snapshot "running" now gets status
572
+ // "failed" instead. This branch guards any remaining edge cases
573
+ // (e.g., allocation-fallback lane assumed alive but actually dead).
574
+ extraInfo = "session ended";
575
+ extraColor = "warning";
576
+ }
577
+ const extraStr = theme.fg(extraColor, trunc(extraInfo, w));
578
+ const extraVis = Math.min(extraInfo.length, w);
579
+
580
+ // Build bordered card
581
+ const top = "┌" + "─".repeat(w) + "┐";
582
+ const bot = "└" + "─".repeat(w) + "┘";
583
+ const border = (content: string, vis: number) =>
584
+ theme.fg("dim", "│") + content + " ".repeat(Math.max(0, w - vis)) + theme.fg("dim", "│");
585
+
586
+ return [
587
+ theme.fg("dim", top),
588
+ border(" " + sessionStr, 1 + sessionVis),
589
+ border(" " + taskStr, 1 + taskVis),
590
+ border(" " + stepStr, 1 + stepVis),
591
+ border(extraInfo ? " " + extraStr : "", extraVis ? 1 + extraVis : 0),
592
+ theme.fg("dim", bot),
593
+ ];
594
+ }
595
+
596
+ // ── Core Widget ──────────────────────────────────────────────────────
597
+
598
+ /**
599
+ * Create the widget registration callback for the orchestrator dashboard.
600
+ *
601
+ * This is the main entry point for the dashboard widget. It captures
602
+ * batchState and monitorState references and returns a widget that
603
+ * re-renders on each paint cycle using the latest state.
604
+ *
605
+ * @param getBatchState - Getter for current batch state
606
+ * @param getMonitorState - Getter for current monitor state (may be null)
607
+ * @param sessionPrefix - Session prefix for lane identification
608
+ */
609
+ export function createOrchWidget(
610
+ getBatchState: () => OrchBatchRuntimeState,
611
+ getMonitorState: () => MonitorState | null,
612
+ sessionPrefix: string,
613
+ ): (_tui: any, theme: any) => { render(width: number): string[]; invalidate(): void } {
614
+ return (_tui: any, theme: any) => {
615
+ return {
616
+ render(width: number): string[] {
617
+ const batchState = getBatchState();
618
+ const monitorState = getMonitorState();
619
+ const vm = buildDashboardViewModel(batchState, monitorState);
620
+
621
+ // ── Idle state ─────────────────────────────────
622
+ if (vm.phase === "idle") {
623
+ return [];
624
+ }
625
+
626
+ const lines: string[] = [""];
627
+
628
+ // ── Phase-specific rendering ──────────────────
629
+ const phaseIcon =
630
+ vm.phase === "launching" ? "◌"
631
+ : vm.phase === "planning" ? "◌"
632
+ : vm.phase === "executing" ? "●"
633
+ : vm.phase === "merging" ? "🔀"
634
+ : vm.phase === "paused" ? "⏸"
635
+ : vm.phase === "stopped" ? "⛔"
636
+ : vm.phase === "completed" ? "✓"
637
+ : vm.phase === "failed" ? "✗"
638
+ : "○";
639
+ const phaseColor =
640
+ vm.phase === "executing" ? "accent"
641
+ : vm.phase === "merging" ? "accent"
642
+ : vm.phase === "completed" ? "success"
643
+ : vm.phase === "failed" || vm.phase === "stopped" ? "error"
644
+ : vm.phase === "paused" ? "warning"
645
+ : "dim";
646
+
647
+ // Header: phase icon + batch ID + wave + elapsed
648
+ const header =
649
+ theme.fg(phaseColor, ` ${phaseIcon} `) +
650
+ theme.fg("accent", theme.bold(vm.batchId || "—")) +
651
+ theme.fg("dim", " ") +
652
+ theme.fg("warning", `W${vm.waveProgress}`) +
653
+ theme.fg("dim", " · ") +
654
+ theme.fg("muted", vm.elapsed);
655
+ lines.push(truncateToWidth(header, width));
656
+
657
+ // ── Planning state ────────────────────────────
658
+ if (vm.phase === "planning") {
659
+ lines.push(truncateToWidth(
660
+ theme.fg("dim", " ◌ Planning batch..."),
661
+ width,
662
+ ));
663
+ return lines;
664
+ }
665
+
666
+ // ── Progress bar ──────────────────────────────
667
+ const { completed, failed, total } = vm.summary;
668
+ const done = completed + failed;
669
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0;
670
+ const barWidth = Math.min(30, width - 20);
671
+ const filled = Math.round((pct / 100) * barWidth);
672
+ const progressBar =
673
+ theme.fg("dim", " ") +
674
+ theme.fg("warning", "[") +
675
+ theme.fg("success", "█".repeat(filled)) +
676
+ theme.fg("dim", "░".repeat(Math.max(0, barWidth - filled))) +
677
+ theme.fg("warning", "]") +
678
+ theme.fg("dim", " ") +
679
+ theme.fg("accent", `${done}/${total}`) +
680
+ theme.fg("dim", ` (${pct}%)`);
681
+ lines.push(truncateToWidth(progressBar, width));
682
+
683
+ // ── Summary counts line ───────────────────────
684
+ const countParts: string[] = [];
685
+ if (vm.summary.completed > 0) countParts.push(theme.fg("success", `${vm.summary.completed} ✓`));
686
+ if (vm.summary.running > 0) countParts.push(theme.fg("accent", `${vm.summary.running} running`));
687
+ if (vm.summary.queued > 0) countParts.push(theme.fg("dim", `${vm.summary.queued} queued`));
688
+ if (vm.summary.failed > 0) countParts.push(theme.fg("error", `${vm.summary.failed} ✗`));
689
+ if (vm.summary.blocked > 0) countParts.push(theme.fg("warning", `${vm.summary.blocked} blocked`));
690
+ if (vm.summary.stalled > 0) countParts.push(theme.fg("warning", `${vm.summary.stalled} stalled`));
691
+ if (countParts.length > 0) {
692
+ lines.push(truncateToWidth(" " + countParts.join(theme.fg("dim", " · ")), width));
693
+ }
694
+ lines.push("");
695
+
696
+ // ── Lane cards ─────────────────────────────────
697
+ if (vm.laneCards.length > 0 && (vm.phase === "executing" || vm.phase === "merging" || vm.phase === "paused")) {
698
+ const arrowWidth = 3;
699
+ const minCardWidth = 18;
700
+ const maxCols = Math.max(1, Math.floor((width + arrowWidth) / (minCardWidth + arrowWidth)));
701
+ const cols = Math.min(vm.laneCards.length, maxCols);
702
+ const colWidth = Math.max(minCardWidth, Math.floor((width - arrowWidth * (cols - 1)) / cols));
703
+
704
+ for (let rowStart = 0; rowStart < vm.laneCards.length; rowStart += cols) {
705
+ const rowCards = vm.laneCards.slice(rowStart, rowStart + cols);
706
+ const rendered = rowCards.map(c => renderLaneCard(c, colWidth, theme));
707
+
708
+ if (rendered.length > 0) {
709
+ const cardHeight = rendered[0].length;
710
+ for (let line = 0; line < cardHeight; line++) {
711
+ let row = rendered[0][line];
712
+ for (let c = 1; c < rendered.length; c++) {
713
+ row += " "; // spacer between cards
714
+ row += rendered[c][line];
715
+ }
716
+ lines.push(truncateToWidth(row, width));
717
+ }
718
+ }
719
+ }
720
+ }
721
+
722
+ // ── Terminal states (completed/failed/stopped) ──
723
+ if (vm.phase === "completed") {
724
+ lines.push(truncateToWidth(
725
+ theme.fg("success", " ✅ Batch complete"),
726
+ width,
727
+ ));
728
+ } else if (vm.phase === "failed") {
729
+ lines.push(truncateToWidth(
730
+ theme.fg("error", " ❌ Batch failed"),
731
+ width,
732
+ ));
733
+ for (const err of vm.errors.slice(0, 3)) {
734
+ lines.push(truncateToWidth(
735
+ theme.fg("error", ` ${err.slice(0, 80)}`),
736
+ width,
737
+ ));
738
+ }
739
+ } else if (vm.phase === "stopped") {
740
+ lines.push(truncateToWidth(
741
+ theme.fg("error", ` ⛔ Stopped by ${vm.failurePolicy || "policy"}`),
742
+ width,
743
+ ));
744
+ } else if (vm.phase === "merging") {
745
+ lines.push("");
746
+ lines.push(truncateToWidth(
747
+ theme.fg("accent", ` 🔀 Merging lane branches into ${vm.orchBranch || "orch branch"}...`),
748
+ width,
749
+ ));
750
+ } else if (vm.phase === "paused") {
751
+ lines.push("");
752
+ lines.push(truncateToWidth(
753
+ theme.fg("warning", " ⏸ Batch paused — lanes will stop after current tasks"),
754
+ width,
755
+ ));
756
+ }
757
+
758
+ // ── Footer: attach hint ───────────────────────
759
+ if (vm.attachHint && (vm.phase === "executing" || vm.phase === "merging" || vm.phase === "paused")) {
760
+ lines.push("");
761
+ lines.push(truncateToWidth(
762
+ theme.fg("dim", ` 💡 ${vm.attachHint}`),
763
+ width,
764
+ ));
765
+ }
766
+
767
+ return lines;
768
+ },
769
+ invalidate() {},
770
+ };
771
+ };
772
+ }
773
+