taskplane 0.1.13 → 0.1.15
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/bin/taskplane.mjs +35 -37
- package/extensions/taskplane/engine.ts +771 -758
- package/extensions/taskplane/execution.ts +4 -2
- package/extensions/taskplane/git.ts +25 -7
- package/extensions/taskplane/merge.ts +18 -16
- package/extensions/taskplane/persistence.ts +1136 -1121
- package/extensions/taskplane/resume.ts +1096 -1092
- package/extensions/taskplane/types.ts +5 -2
- package/extensions/taskplane/waves.ts +894 -900
- package/extensions/taskplane/worktree.ts +6 -5
- package/package.json +1 -1
- package/templates/config/task-orchestrator.yaml +86 -89
- package/templates/config/task-runner.yaml +95 -99
|
@@ -1,758 +1,771 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Main batch execution engine
|
|
3
|
-
* @module orch/engine
|
|
4
|
-
*/
|
|
5
|
-
import { readFileSync, readdirSync, unlinkSync } from "fs";
|
|
6
|
-
import { join } from "path";
|
|
7
|
-
|
|
8
|
-
import { formatDiscoveryResults, runDiscovery } from "./discovery.ts";
|
|
9
|
-
import { execLog, executeWave, tmuxKillSession } from "./execution.ts";
|
|
10
|
-
import type { MonitorUpdateCallback } from "./execution.ts";
|
|
11
|
-
import { runGit } from "./git.ts";
|
|
12
|
-
import { mergeWave } from "./merge.ts";
|
|
13
|
-
import { ORCH_MESSAGES } from "./messages.ts";
|
|
14
|
-
import { deleteBatchState, loadBatchHistory, persistRuntimeState, saveBatchHistory, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
|
|
15
|
-
import { listOrchSessions } from "./sessions.ts";
|
|
16
|
-
import { generateBatchId } from "./types.ts";
|
|
17
|
-
import type { AllocatedLane, BatchHistorySummary, BatchTaskSummary, BatchWaveSummary, DiscoveryResult, LaneExecutionResult, LaneTaskOutcome, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, TaskRunnerConfig, TokenCounts } from "./types.ts";
|
|
18
|
-
import { buildDependencyGraph, computeWaves, validateGraph } from "./waves.ts";
|
|
19
|
-
import { deleteBranchBestEffort, formatPreflightResults, listWorktrees, removeAllWorktrees, removeWorktree, runPreflight, safeResetWorktree, sleepSync } from "./worktree.ts";
|
|
20
|
-
|
|
21
|
-
// ── /orch Execution Engine ───────────────────────────────────────────
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Execute the full /orch batch: discover → plan → execute waves → cleanup.
|
|
25
|
-
*
|
|
26
|
-
* This is the core orchestration loop that ties together all prior steps.
|
|
27
|
-
*
|
|
28
|
-
* @param args - User arguments (areas/paths/all)
|
|
29
|
-
* @param orchConfig - Orchestrator configuration
|
|
30
|
-
* @param runnerConfig - Task runner configuration
|
|
31
|
-
* @param cwd - Current working directory (repo root)
|
|
32
|
-
* @param batchState - Mutable batch state (updated throughout execution)
|
|
33
|
-
* @param onNotify - Callback for user-facing messages
|
|
34
|
-
* @param onMonitorUpdate - Optional callback for dashboard updates
|
|
35
|
-
*/
|
|
36
|
-
export async function executeOrchBatch(
|
|
37
|
-
args: string,
|
|
38
|
-
orchConfig: OrchestratorConfig,
|
|
39
|
-
runnerConfig: TaskRunnerConfig,
|
|
40
|
-
cwd: string,
|
|
41
|
-
batchState: OrchBatchRuntimeState,
|
|
42
|
-
onNotify: (message: string, level: "info" | "warning" | "error") => void,
|
|
43
|
-
onMonitorUpdate?: MonitorUpdateCallback,
|
|
44
|
-
): Promise<void> {
|
|
45
|
-
const repoRoot = cwd;
|
|
46
|
-
|
|
47
|
-
// ── Phase 1: Planning ────────────────────────────────────────
|
|
48
|
-
batchState.phase = "planning";
|
|
49
|
-
batchState.batchId = generateBatchId();
|
|
50
|
-
batchState.startedAt = Date.now();
|
|
51
|
-
batchState.pauseSignal = { paused: false };
|
|
52
|
-
batchState.mergeResults = [];
|
|
53
|
-
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
//
|
|
130
|
-
const
|
|
131
|
-
if (
|
|
132
|
-
batchState.phase = "failed";
|
|
133
|
-
batchState.endedAt = Date.now();
|
|
134
|
-
const errMsgs =
|
|
135
|
-
batchState.errors.push(`
|
|
136
|
-
onNotify(`❌
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
batchState.
|
|
241
|
-
batchState.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
for (const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
);
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
batchState
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
.
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
batchState
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
//
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
//
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
if
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
// ──
|
|
758
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Main batch execution engine
|
|
3
|
+
* @module orch/engine
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync, readdirSync, unlinkSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
|
|
8
|
+
import { formatDiscoveryResults, runDiscovery } from "./discovery.ts";
|
|
9
|
+
import { execLog, executeWave, tmuxKillSession } from "./execution.ts";
|
|
10
|
+
import type { MonitorUpdateCallback } from "./execution.ts";
|
|
11
|
+
import { getCurrentBranch, runGit } from "./git.ts";
|
|
12
|
+
import { mergeWave } from "./merge.ts";
|
|
13
|
+
import { ORCH_MESSAGES } from "./messages.ts";
|
|
14
|
+
import { deleteBatchState, loadBatchHistory, persistRuntimeState, saveBatchHistory, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
|
|
15
|
+
import { listOrchSessions } from "./sessions.ts";
|
|
16
|
+
import { generateBatchId } from "./types.ts";
|
|
17
|
+
import type { AllocatedLane, BatchHistorySummary, BatchTaskSummary, BatchWaveSummary, DiscoveryResult, LaneExecutionResult, LaneTaskOutcome, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, TaskRunnerConfig, TokenCounts } from "./types.ts";
|
|
18
|
+
import { buildDependencyGraph, computeWaves, validateGraph } from "./waves.ts";
|
|
19
|
+
import { deleteBranchBestEffort, formatPreflightResults, listWorktrees, removeAllWorktrees, removeWorktree, runPreflight, safeResetWorktree, sleepSync } from "./worktree.ts";
|
|
20
|
+
|
|
21
|
+
// ── /orch Execution Engine ───────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Execute the full /orch batch: discover → plan → execute waves → cleanup.
|
|
25
|
+
*
|
|
26
|
+
* This is the core orchestration loop that ties together all prior steps.
|
|
27
|
+
*
|
|
28
|
+
* @param args - User arguments (areas/paths/all)
|
|
29
|
+
* @param orchConfig - Orchestrator configuration
|
|
30
|
+
* @param runnerConfig - Task runner configuration
|
|
31
|
+
* @param cwd - Current working directory (repo root)
|
|
32
|
+
* @param batchState - Mutable batch state (updated throughout execution)
|
|
33
|
+
* @param onNotify - Callback for user-facing messages
|
|
34
|
+
* @param onMonitorUpdate - Optional callback for dashboard updates
|
|
35
|
+
*/
|
|
36
|
+
export async function executeOrchBatch(
|
|
37
|
+
args: string,
|
|
38
|
+
orchConfig: OrchestratorConfig,
|
|
39
|
+
runnerConfig: TaskRunnerConfig,
|
|
40
|
+
cwd: string,
|
|
41
|
+
batchState: OrchBatchRuntimeState,
|
|
42
|
+
onNotify: (message: string, level: "info" | "warning" | "error") => void,
|
|
43
|
+
onMonitorUpdate?: MonitorUpdateCallback,
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
const repoRoot = cwd;
|
|
46
|
+
|
|
47
|
+
// ── Phase 1: Planning ────────────────────────────────────────
|
|
48
|
+
batchState.phase = "planning";
|
|
49
|
+
batchState.batchId = generateBatchId();
|
|
50
|
+
batchState.startedAt = Date.now();
|
|
51
|
+
batchState.pauseSignal = { paused: false };
|
|
52
|
+
batchState.mergeResults = [];
|
|
53
|
+
|
|
54
|
+
// Capture the current branch as the base for worktrees and merge target
|
|
55
|
+
const detectedBranch = getCurrentBranch(repoRoot);
|
|
56
|
+
if (!detectedBranch) {
|
|
57
|
+
batchState.phase = "failed";
|
|
58
|
+
batchState.endedAt = Date.now();
|
|
59
|
+
batchState.errors.push("Cannot determine current branch (detached HEAD or not a git repo)");
|
|
60
|
+
onNotify("❌ Cannot determine current branch. Ensure HEAD is on a branch (not detached).", "error");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
batchState.baseBranch = detectedBranch;
|
|
64
|
+
|
|
65
|
+
// When true, final cleanup is skipped so failed merge state is preserved
|
|
66
|
+
// for manual intervention and TS-009 resume flow.
|
|
67
|
+
let preserveWorktreesForResume = false;
|
|
68
|
+
|
|
69
|
+
// ── State persistence tracking (TS-009 Step 2) ───────────────
|
|
70
|
+
// Accumulated task outcomes across all waves for state serialization.
|
|
71
|
+
let allTaskOutcomes: LaneTaskOutcome[] = [];
|
|
72
|
+
// Merge results accumulated across waves (for branch cleanup after worktree removal).
|
|
73
|
+
const allMergeResults: MergeWaveResult[] = [];
|
|
74
|
+
// Latest allocated lanes (updated each wave for serialization).
|
|
75
|
+
let latestAllocatedLanes: AllocatedLane[] = [];
|
|
76
|
+
// Wave plan as array of task ID arrays (set after wave computation).
|
|
77
|
+
let wavePlan: string[][] = [];
|
|
78
|
+
// Reference to discovery result for enriching taskFolder paths.
|
|
79
|
+
let discoveryRef: DiscoveryResult | null = null;
|
|
80
|
+
|
|
81
|
+
execLog("batch", batchState.batchId, "starting batch planning");
|
|
82
|
+
|
|
83
|
+
// Preflight
|
|
84
|
+
const preflight = runPreflight(orchConfig);
|
|
85
|
+
onNotify(formatPreflightResults(preflight), preflight.passed ? "info" : "error");
|
|
86
|
+
if (!preflight.passed) {
|
|
87
|
+
batchState.phase = "failed";
|
|
88
|
+
batchState.endedAt = Date.now();
|
|
89
|
+
batchState.errors.push("Preflight check failed");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Discovery
|
|
94
|
+
const discovery = runDiscovery(args, runnerConfig.task_areas, cwd, {
|
|
95
|
+
refreshDependencies: false,
|
|
96
|
+
dependencySource: orchConfig.dependencies.source,
|
|
97
|
+
useDependencyCache: orchConfig.dependencies.cache,
|
|
98
|
+
});
|
|
99
|
+
onNotify(formatDiscoveryResults(discovery), discovery.errors.length > 0 ? "warning" : "info");
|
|
100
|
+
|
|
101
|
+
// Check for fatal errors
|
|
102
|
+
const fatalErrors = discovery.errors.filter(
|
|
103
|
+
(e) =>
|
|
104
|
+
e.code === "DUPLICATE_ID" ||
|
|
105
|
+
e.code === "DEP_UNRESOLVED" ||
|
|
106
|
+
e.code === "DEP_PENDING" ||
|
|
107
|
+
e.code === "DEP_AMBIGUOUS" ||
|
|
108
|
+
e.code === "PARSE_MISSING_ID",
|
|
109
|
+
);
|
|
110
|
+
if (fatalErrors.length > 0) {
|
|
111
|
+
batchState.phase = "failed";
|
|
112
|
+
batchState.endedAt = Date.now();
|
|
113
|
+
batchState.errors.push("Discovery had fatal errors — cannot proceed");
|
|
114
|
+
onNotify("❌ Cannot execute due to discovery errors above.", "error");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (discovery.pending.size === 0) {
|
|
119
|
+
batchState.phase = "completed";
|
|
120
|
+
batchState.endedAt = Date.now();
|
|
121
|
+
onNotify("No pending tasks found. Nothing to execute.", "info");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Build dependency graph
|
|
126
|
+
const depGraph = buildDependencyGraph(discovery.pending, discovery.completed);
|
|
127
|
+
batchState.dependencyGraph = depGraph;
|
|
128
|
+
|
|
129
|
+
// Validate graph
|
|
130
|
+
const validation = validateGraph(depGraph, discovery.pending, discovery.completed);
|
|
131
|
+
if (!validation.valid) {
|
|
132
|
+
batchState.phase = "failed";
|
|
133
|
+
batchState.endedAt = Date.now();
|
|
134
|
+
const errMsgs = validation.errors.map(e => `[${e.code}] ${e.message}`).join("\n");
|
|
135
|
+
batchState.errors.push(`Graph validation failed:\n${errMsgs}`);
|
|
136
|
+
onNotify(`❌ Dependency graph errors:\n${errMsgs}`, "error");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Compute waves
|
|
141
|
+
const { waves: rawWaves, errors: waveErrors } = computeWaves(depGraph, discovery.completed, discovery.pending);
|
|
142
|
+
if (waveErrors.length > 0) {
|
|
143
|
+
batchState.phase = "failed";
|
|
144
|
+
batchState.endedAt = Date.now();
|
|
145
|
+
const errMsgs = waveErrors.map(e => `[${e.code}] ${e.message}`).join("\n");
|
|
146
|
+
batchState.errors.push(`Wave computation failed:\n${errMsgs}`);
|
|
147
|
+
onNotify(`❌ Wave computation errors:\n${errMsgs}`, "error");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
batchState.totalWaves = rawWaves.length;
|
|
152
|
+
batchState.totalTasks = rawWaves.reduce((sum, w) => sum + w.length, 0);
|
|
153
|
+
|
|
154
|
+
// Store wave plan and discovery for state persistence
|
|
155
|
+
wavePlan = rawWaves;
|
|
156
|
+
discoveryRef = discovery;
|
|
157
|
+
|
|
158
|
+
onNotify(
|
|
159
|
+
ORCH_MESSAGES.orchStarting(batchState.batchId, rawWaves.length, batchState.totalTasks),
|
|
160
|
+
"info",
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// ── Phase 2: Wave Execution Loop ─────────────────────────────
|
|
164
|
+
batchState.phase = "executing";
|
|
165
|
+
|
|
166
|
+
// ── TS-009: Persist state on batch start (after wave computation) ──
|
|
167
|
+
persistRuntimeState("batch-start", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
|
|
168
|
+
|
|
169
|
+
for (let waveIdx = 0; waveIdx < rawWaves.length; waveIdx++) {
|
|
170
|
+
// Check pause signal before starting each wave
|
|
171
|
+
if (batchState.pauseSignal.paused) {
|
|
172
|
+
batchState.phase = "paused";
|
|
173
|
+
execLog("batch", batchState.batchId, `batch paused before wave ${waveIdx + 1}`);
|
|
174
|
+
onNotify(`⏸️ Batch paused before wave ${waveIdx + 1}. Resume not yet implemented (TS-009).`, "warning");
|
|
175
|
+
// ── TS-009: Persist state on pause ──
|
|
176
|
+
persistRuntimeState("pause-before-wave", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
batchState.currentWaveIndex = waveIdx;
|
|
181
|
+
|
|
182
|
+
// ── TS-009: Persist state on wave index change ──
|
|
183
|
+
persistRuntimeState("wave-index-change", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
|
|
184
|
+
|
|
185
|
+
// Filter wave tasks against blockedTaskIds
|
|
186
|
+
let waveTasks = rawWaves[waveIdx].filter(
|
|
187
|
+
taskId => !batchState.blockedTaskIds.has(taskId),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// Log blocked tasks if any were filtered
|
|
191
|
+
const blockedInWave = rawWaves[waveIdx].filter(
|
|
192
|
+
taskId => batchState.blockedTaskIds.has(taskId),
|
|
193
|
+
);
|
|
194
|
+
if (blockedInWave.length > 0) {
|
|
195
|
+
execLog("batch", batchState.batchId, `wave ${waveIdx + 1}: skipping ${blockedInWave.length} blocked task(s)`, {
|
|
196
|
+
blocked: blockedInWave.join(","),
|
|
197
|
+
});
|
|
198
|
+
batchState.blockedTasks += blockedInWave.length;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (waveTasks.length === 0) {
|
|
202
|
+
execLog("batch", batchState.batchId, `wave ${waveIdx + 1}: no tasks to execute (all blocked)`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
onNotify(
|
|
207
|
+
ORCH_MESSAGES.orchWaveStart(waveIdx + 1, rawWaves.length, waveTasks.length, Math.min(waveTasks.length, orchConfig.orchestrator.max_lanes)),
|
|
208
|
+
"info",
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const handleWaveMonitorUpdate: MonitorUpdateCallback = (monitorState) => {
|
|
212
|
+
const changed = syncTaskOutcomesFromMonitor(monitorState, allTaskOutcomes);
|
|
213
|
+
if (changed) {
|
|
214
|
+
persistRuntimeState("task-transition", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
|
|
215
|
+
}
|
|
216
|
+
onMonitorUpdate?.(monitorState);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// Execute the wave
|
|
220
|
+
const waveResult = await executeWave(
|
|
221
|
+
waveTasks,
|
|
222
|
+
waveIdx + 1,
|
|
223
|
+
discovery.pending,
|
|
224
|
+
orchConfig,
|
|
225
|
+
repoRoot,
|
|
226
|
+
batchState.batchId,
|
|
227
|
+
batchState.pauseSignal,
|
|
228
|
+
depGraph,
|
|
229
|
+
batchState.baseBranch,
|
|
230
|
+
handleWaveMonitorUpdate,
|
|
231
|
+
(lanes) => {
|
|
232
|
+
latestAllocatedLanes = lanes;
|
|
233
|
+
batchState.currentLanes = lanes;
|
|
234
|
+
if (seedPendingOutcomesForAllocatedLanes(lanes, allTaskOutcomes)) {
|
|
235
|
+
persistRuntimeState("wave-lanes-allocated", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
batchState.waveResults.push(waveResult);
|
|
241
|
+
batchState.currentLanes = []; // Clear current lanes after wave completes
|
|
242
|
+
|
|
243
|
+
// ── TS-009: Accumulate task outcomes from this wave ──
|
|
244
|
+
latestAllocatedLanes = waveResult.allocatedLanes;
|
|
245
|
+
for (const lr of waveResult.laneResults) {
|
|
246
|
+
for (const taskOutcome of lr.tasks) {
|
|
247
|
+
upsertTaskOutcome(allTaskOutcomes, taskOutcome);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Accumulate results
|
|
252
|
+
batchState.succeededTasks += waveResult.succeededTaskIds.length;
|
|
253
|
+
batchState.failedTasks += waveResult.failedTaskIds.length;
|
|
254
|
+
batchState.skippedTasks += waveResult.skippedTaskIds.length;
|
|
255
|
+
|
|
256
|
+
// Add newly blocked tasks
|
|
257
|
+
for (const blocked of waveResult.blockedTaskIds) {
|
|
258
|
+
batchState.blockedTaskIds.add(blocked);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── TS-009: Persist state after wave execution ──
|
|
262
|
+
persistRuntimeState("wave-execution-complete", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
|
|
263
|
+
|
|
264
|
+
const elapsedSec = Math.round((waveResult.endedAt - waveResult.startedAt) / 1000);
|
|
265
|
+
onNotify(
|
|
266
|
+
ORCH_MESSAGES.orchWaveComplete(
|
|
267
|
+
waveIdx + 1,
|
|
268
|
+
waveResult.succeededTaskIds.length,
|
|
269
|
+
waveResult.failedTaskIds.length,
|
|
270
|
+
waveResult.skippedTaskIds.length,
|
|
271
|
+
elapsedSec,
|
|
272
|
+
),
|
|
273
|
+
waveResult.failedTaskIds.length > 0 ? "warning" : "info",
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
// Check if we should stop based on task failure policy
|
|
277
|
+
if (waveResult.stoppedEarly) {
|
|
278
|
+
if (waveResult.policyApplied === "stop-all") {
|
|
279
|
+
batchState.phase = "stopped";
|
|
280
|
+
// ── TS-009: Persist state on stop-all ──
|
|
281
|
+
persistRuntimeState("stop-all", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
|
|
282
|
+
onNotify(ORCH_MESSAGES.orchBatchStopped(batchState.batchId, "stop-all"), "error");
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
if (waveResult.policyApplied === "stop-wave") {
|
|
286
|
+
batchState.phase = "stopped";
|
|
287
|
+
// ── TS-009: Persist state on stop-wave ──
|
|
288
|
+
persistRuntimeState("stop-wave", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
|
|
289
|
+
onNotify(ORCH_MESSAGES.orchBatchStopped(batchState.batchId, "stop-wave"), "error");
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Wave Merge ───────────────────────────────────────────
|
|
295
|
+
// Only merge if there are succeeded tasks in this wave
|
|
296
|
+
let mergeResult: MergeWaveResult | null = null;
|
|
297
|
+
|
|
298
|
+
// Build lane outcome lookup and detect mixed-outcome lanes
|
|
299
|
+
// (succeeded work + failed/stalled task in same lane).
|
|
300
|
+
const laneOutcomeByNumber = new Map<number, LaneExecutionResult>();
|
|
301
|
+
for (const lr of waveResult.laneResults) {
|
|
302
|
+
laneOutcomeByNumber.set(lr.laneNumber, lr);
|
|
303
|
+
}
|
|
304
|
+
const mixedOutcomeLanes = waveResult.laneResults.filter(lr => {
|
|
305
|
+
const hasSucceeded = lr.tasks.some(t => t.status === "succeeded");
|
|
306
|
+
const hasHardFailure = lr.tasks.some(
|
|
307
|
+
t => t.status === "failed" || t.status === "stalled",
|
|
308
|
+
);
|
|
309
|
+
return hasSucceeded && hasHardFailure;
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
if (waveResult.succeededTaskIds.length > 0) {
|
|
313
|
+
const mergeableLaneCount = waveResult.allocatedLanes.filter(lane => {
|
|
314
|
+
const outcome = laneOutcomeByNumber.get(lane.laneNumber);
|
|
315
|
+
if (!outcome) return false;
|
|
316
|
+
const hasSucceeded = outcome.tasks.some(t => t.status === "succeeded");
|
|
317
|
+
const hasHardFailure = outcome.tasks.some(
|
|
318
|
+
t => t.status === "failed" || t.status === "stalled",
|
|
319
|
+
);
|
|
320
|
+
return hasSucceeded && !hasHardFailure;
|
|
321
|
+
}).length;
|
|
322
|
+
|
|
323
|
+
if (mergeableLaneCount > 0) {
|
|
324
|
+
batchState.phase = "merging";
|
|
325
|
+
// ── TS-009: Persist state on executing→merging transition ──
|
|
326
|
+
persistRuntimeState("merge-start", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
|
|
327
|
+
onNotify(ORCH_MESSAGES.orchMergeStart(waveIdx + 1, mergeableLaneCount), "info");
|
|
328
|
+
|
|
329
|
+
mergeResult = mergeWave(
|
|
330
|
+
waveResult.allocatedLanes,
|
|
331
|
+
waveResult,
|
|
332
|
+
waveIdx + 1,
|
|
333
|
+
orchConfig,
|
|
334
|
+
repoRoot,
|
|
335
|
+
batchState.batchId,
|
|
336
|
+
batchState.baseBranch,
|
|
337
|
+
);
|
|
338
|
+
allMergeResults.push(mergeResult);
|
|
339
|
+
batchState.mergeResults.push(mergeResult);
|
|
340
|
+
|
|
341
|
+
// Persist state after merge so dashboard shows wave merge results
|
|
342
|
+
persistRuntimeState("merge-complete", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
|
|
343
|
+
|
|
344
|
+
// Emit per-lane merge notifications
|
|
345
|
+
for (const lr of mergeResult.laneResults) {
|
|
346
|
+
const durationSec = Math.round(lr.durationMs / 1000);
|
|
347
|
+
if (lr.result?.status === "SUCCESS") {
|
|
348
|
+
onNotify(ORCH_MESSAGES.orchMergeLaneSuccess(lr.laneNumber, lr.result.merge_commit, durationSec), "info");
|
|
349
|
+
} else if (lr.result?.status === "CONFLICT_RESOLVED") {
|
|
350
|
+
onNotify(ORCH_MESSAGES.orchMergeLaneConflictResolved(lr.laneNumber, lr.result.conflicts.length, durationSec), "info");
|
|
351
|
+
} else if (lr.result?.status === "CONFLICT_UNRESOLVED" || lr.result?.status === "BUILD_FAILURE") {
|
|
352
|
+
onNotify(ORCH_MESSAGES.orchMergeLaneFailed(lr.laneNumber, lr.error || lr.result.status), "error");
|
|
353
|
+
} else if (lr.error) {
|
|
354
|
+
onNotify(ORCH_MESSAGES.orchMergeLaneFailed(lr.laneNumber, lr.error), "error");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// If any lane has mixed outcomes, do not silently discard succeeded work.
|
|
359
|
+
// Force merge failure handling so state is preserved for manual resolution.
|
|
360
|
+
if (mixedOutcomeLanes.length > 0) {
|
|
361
|
+
const mixedIds = mixedOutcomeLanes.map(l => `lane-${l.laneNumber}`).join(", ");
|
|
362
|
+
const failureReason =
|
|
363
|
+
`Lane(s) ${mixedIds} contain both succeeded and failed tasks. ` +
|
|
364
|
+
`Automatic partial-branch merge is disabled to avoid dropping succeeded commits.`;
|
|
365
|
+
execLog("merge", `W${waveIdx + 1}`, "mixed-outcome lanes detected — escalating to merge failure handling", {
|
|
366
|
+
mixedLaneIds: mixedIds,
|
|
367
|
+
});
|
|
368
|
+
mergeResult = {
|
|
369
|
+
...mergeResult,
|
|
370
|
+
status: "partial",
|
|
371
|
+
failedLane: mixedOutcomeLanes[0].laneNumber,
|
|
372
|
+
failureReason,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Emit overall merge result notification
|
|
377
|
+
const mergedCount = mergeResult.laneResults.filter(
|
|
378
|
+
r => r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED",
|
|
379
|
+
).length;
|
|
380
|
+
const mergeTotalSec = Math.round(mergeResult.totalDurationMs / 1000);
|
|
381
|
+
|
|
382
|
+
if (mergeResult.status === "succeeded") {
|
|
383
|
+
onNotify(ORCH_MESSAGES.orchMergeComplete(waveIdx + 1, mergedCount, mergeTotalSec), "info");
|
|
384
|
+
} else {
|
|
385
|
+
onNotify(
|
|
386
|
+
ORCH_MESSAGES.orchMergeFailed(waveIdx + 1, mergeResult.failedLane ?? 0, mergeResult.failureReason || "unknown"),
|
|
387
|
+
"error",
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Restore phase to executing (may be overridden below by failure handling)
|
|
392
|
+
batchState.phase = "executing";
|
|
393
|
+
// ── TS-009: Persist state after merge (merging→executing) ──
|
|
394
|
+
persistRuntimeState("merge-complete", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
|
|
395
|
+
} else if (mixedOutcomeLanes.length > 0) {
|
|
396
|
+
const mixedIds = mixedOutcomeLanes.map(l => `lane-${l.laneNumber}`).join(", ");
|
|
397
|
+
mergeResult = {
|
|
398
|
+
waveIndex: waveIdx + 1,
|
|
399
|
+
status: "partial",
|
|
400
|
+
laneResults: [],
|
|
401
|
+
failedLane: mixedOutcomeLanes[0].laneNumber,
|
|
402
|
+
failureReason:
|
|
403
|
+
`Lane(s) ${mixedIds} contain both succeeded and failed tasks. ` +
|
|
404
|
+
`Automatic partial-branch merge is disabled to avoid dropping succeeded commits.`,
|
|
405
|
+
totalDurationMs: 0,
|
|
406
|
+
};
|
|
407
|
+
onNotify(
|
|
408
|
+
ORCH_MESSAGES.orchMergeFailed(waveIdx + 1, mergeResult.failedLane, mergeResult.failureReason || "unknown"),
|
|
409
|
+
"error",
|
|
410
|
+
);
|
|
411
|
+
} else {
|
|
412
|
+
// No mergeable lanes and no mixed outcomes (e.g., only skipped tasks)
|
|
413
|
+
onNotify(ORCH_MESSAGES.orchMergeSkipped(waveIdx + 1), "info");
|
|
414
|
+
}
|
|
415
|
+
} else {
|
|
416
|
+
// No succeeded tasks — skip merge entirely
|
|
417
|
+
onNotify(ORCH_MESSAGES.orchMergeSkipped(waveIdx + 1), "info");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ── Handle merge failure ─────────────────────────────────
|
|
421
|
+
// Apply config.failure.on_merge_failure policy
|
|
422
|
+
if (mergeResult && (mergeResult.status === "failed" || mergeResult.status === "partial")) {
|
|
423
|
+
const mergeFailurePolicy = orchConfig.failure.on_merge_failure;
|
|
424
|
+
let failedLaneIds = mergeResult.laneResults
|
|
425
|
+
.filter(r => r.result?.status === "CONFLICT_UNRESOLVED" || r.result?.status === "BUILD_FAILURE" || r.error)
|
|
426
|
+
.map(r => `lane-${r.laneNumber}`)
|
|
427
|
+
.join(", ");
|
|
428
|
+
if (!failedLaneIds && mergeResult.failedLane !== null) {
|
|
429
|
+
failedLaneIds = `lane-${mergeResult.failedLane}`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
execLog("batch", batchState.batchId, `merge failure — applying ${mergeFailurePolicy} policy`, {
|
|
433
|
+
failedLane: mergeResult.failedLane ?? 0,
|
|
434
|
+
failedLaneIds,
|
|
435
|
+
reason: mergeResult.failureReason?.slice(0, 200) || "unknown",
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
if (mergeFailurePolicy === "pause") {
|
|
439
|
+
batchState.phase = "paused";
|
|
440
|
+
batchState.errors.push(
|
|
441
|
+
`Merge failed at wave ${waveIdx + 1}: ${mergeResult.failureReason || "unknown"}. ` +
|
|
442
|
+
`Batch paused. Resolve conflicts and use /orch-resume to continue.`,
|
|
443
|
+
);
|
|
444
|
+
// ── TS-009: Persist BEFORE cleanup decision (pause) ──
|
|
445
|
+
persistRuntimeState("merge-failure-pause", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
|
|
446
|
+
onNotify(
|
|
447
|
+
`⏸️ Batch paused due to merge failure at wave ${waveIdx + 1} (${failedLaneIds}). ` +
|
|
448
|
+
`Reason: ${mergeResult.failureReason?.slice(0, 200) || "unknown"}. ` +
|
|
449
|
+
`Resolve conflicts and resume (TS-009).`,
|
|
450
|
+
"error",
|
|
451
|
+
);
|
|
452
|
+
// DO NOT cleanup/reset worktrees — preserve state for debugging/resume
|
|
453
|
+
preserveWorktreesForResume = true;
|
|
454
|
+
break;
|
|
455
|
+
} else {
|
|
456
|
+
// abort policy
|
|
457
|
+
batchState.phase = "stopped";
|
|
458
|
+
batchState.errors.push(
|
|
459
|
+
`Merge failed at wave ${waveIdx + 1}: ${mergeResult.failureReason || "unknown"}. ` +
|
|
460
|
+
`Batch aborted by on_merge_failure policy.`,
|
|
461
|
+
);
|
|
462
|
+
// ── TS-009: Persist BEFORE cleanup decision (abort) ──
|
|
463
|
+
persistRuntimeState("merge-failure-abort", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
|
|
464
|
+
onNotify(
|
|
465
|
+
`⛔ Batch aborted due to merge failure at wave ${waveIdx + 1} (${failedLaneIds}). ` +
|
|
466
|
+
`Reason: ${mergeResult.failureReason?.slice(0, 200) || "unknown"}.`,
|
|
467
|
+
"error",
|
|
468
|
+
);
|
|
469
|
+
// DO NOT cleanup/reset worktrees — preserve state for debugging
|
|
470
|
+
preserveWorktreesForResume = true;
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// NOTE: Merged branch cleanup is deferred to Phase 3, AFTER worktree
|
|
476
|
+
// removal. git branch -D fails if a worktree has the branch checked out.
|
|
477
|
+
|
|
478
|
+
// ── Post-merge: Reset worktrees for next wave ────────────
|
|
479
|
+
// Only reset if merge succeeded AND there are more waves
|
|
480
|
+
if (waveIdx < rawWaves.length - 1 && !batchState.pauseSignal.paused) {
|
|
481
|
+
const prefix = orchConfig.orchestrator.worktree_prefix;
|
|
482
|
+
const existingWorktrees = listWorktrees(prefix, repoRoot);
|
|
483
|
+
|
|
484
|
+
if (existingWorktrees.length > 0) {
|
|
485
|
+
onNotify(
|
|
486
|
+
ORCH_MESSAGES.orchWorktreeReset(waveIdx + 1, existingWorktrees.length),
|
|
487
|
+
"info",
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
const targetBranch = batchState.baseBranch;
|
|
491
|
+
for (const wt of existingWorktrees) {
|
|
492
|
+
const resetResult = safeResetWorktree(wt, targetBranch, repoRoot);
|
|
493
|
+
if (!resetResult.success) {
|
|
494
|
+
execLog("batch", batchState.batchId, `worktree reset failed for lane ${wt.laneNumber}`, {
|
|
495
|
+
error: resetResult.error || "unknown",
|
|
496
|
+
path: wt.path,
|
|
497
|
+
});
|
|
498
|
+
// If reset fails, remove this worktree so the next wave can recreate it cleanly.
|
|
499
|
+
try {
|
|
500
|
+
removeWorktree(wt, repoRoot);
|
|
501
|
+
execLog("batch", batchState.batchId, `removed unrecoverable worktree for lane ${wt.laneNumber}`);
|
|
502
|
+
} catch (removeErr: unknown) {
|
|
503
|
+
execLog("batch", batchState.batchId, `failed to remove unrecoverable worktree for lane ${wt.laneNumber}`, {
|
|
504
|
+
error: removeErr instanceof Error ? removeErr.message : String(removeErr),
|
|
505
|
+
path: wt.path,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
} else {
|
|
509
|
+
execLog("batch", batchState.batchId, `worktree reset OK for lane ${wt.laneNumber}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ── Save batch history (before cleanup deletes sidecar files) ────
|
|
517
|
+
try {
|
|
518
|
+
// Read token data from sidecar files while they still exist
|
|
519
|
+
const piDir = join(repoRoot, ".pi");
|
|
520
|
+
const laneTokens = new Map<string, TokenCounts>();
|
|
521
|
+
try {
|
|
522
|
+
const files = readdirSync(piDir).filter(f => f.startsWith("lane-state-") && f.endsWith(".json"));
|
|
523
|
+
for (const f of files) {
|
|
524
|
+
try {
|
|
525
|
+
const raw = readFileSync(join(piDir, f), "utf-8").trim();
|
|
526
|
+
if (!raw) continue;
|
|
527
|
+
const data = JSON.parse(raw);
|
|
528
|
+
if (data.prefix) {
|
|
529
|
+
laneTokens.set(data.prefix, {
|
|
530
|
+
input: data.workerInputTokens || 0,
|
|
531
|
+
output: data.workerOutputTokens || 0,
|
|
532
|
+
cacheRead: data.workerCacheReadTokens || 0,
|
|
533
|
+
cacheWrite: data.workerCacheWriteTokens || 0,
|
|
534
|
+
costUsd: data.workerCostUsd || 0,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
} catch { /* skip invalid files */ }
|
|
538
|
+
}
|
|
539
|
+
} catch { /* .pi dir may not exist */ }
|
|
540
|
+
|
|
541
|
+
// Build per-task summaries from allTaskOutcomes + wave plan
|
|
542
|
+
const taskSummaries: BatchTaskSummary[] = allTaskOutcomes.map((to) => {
|
|
543
|
+
// Find which wave and lane this task ran in
|
|
544
|
+
let wave = 0, lane = 0;
|
|
545
|
+
for (let wi = 0; wi < wavePlan.length; wi++) {
|
|
546
|
+
if (wavePlan[wi].includes(to.taskId)) { wave = wi + 1; break; }
|
|
547
|
+
}
|
|
548
|
+
// Match lane via tmux session name
|
|
549
|
+
const laneMatch = to.sessionName?.match(/lane-(\d+)/);
|
|
550
|
+
if (laneMatch) lane = parseInt(laneMatch[1]);
|
|
551
|
+
|
|
552
|
+
// Compute duration from start/end times
|
|
553
|
+
const durationMs = (to.startTime && to.endTime) ? (to.endTime - to.startTime) : 0;
|
|
554
|
+
|
|
555
|
+
// Get tokens for this lane (cumulative — shared across tasks in same lane)
|
|
556
|
+
const tokens = laneTokens.get(to.sessionName) || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 };
|
|
557
|
+
|
|
558
|
+
return {
|
|
559
|
+
taskId: to.taskId,
|
|
560
|
+
taskName: to.taskId,
|
|
561
|
+
status: to.status as BatchTaskSummary["status"],
|
|
562
|
+
wave,
|
|
563
|
+
lane,
|
|
564
|
+
durationMs,
|
|
565
|
+
tokens,
|
|
566
|
+
exitReason: to.exitReason || null,
|
|
567
|
+
};
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// Build per-wave summaries
|
|
571
|
+
const waveSummaries: BatchWaveSummary[] = wavePlan.map((taskIds, wi) => {
|
|
572
|
+
const waveTasks = taskSummaries.filter(t => t.wave === wi + 1);
|
|
573
|
+
const mergeResult = batchState.mergeResults.find(mr => mr.waveIndex === wi + 1);
|
|
574
|
+
const waveTokens: TokenCounts = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 };
|
|
575
|
+
for (const t of waveTasks) {
|
|
576
|
+
waveTokens.input += t.tokens.input;
|
|
577
|
+
waveTokens.output += t.tokens.output;
|
|
578
|
+
waveTokens.cacheRead += t.tokens.cacheRead;
|
|
579
|
+
waveTokens.cacheWrite += t.tokens.cacheWrite;
|
|
580
|
+
waveTokens.costUsd += t.tokens.costUsd;
|
|
581
|
+
}
|
|
582
|
+
const waveDuration = waveTasks.reduce((sum, t) => Math.max(sum, t.durationMs), 0);
|
|
583
|
+
return {
|
|
584
|
+
wave: wi + 1,
|
|
585
|
+
tasks: taskIds,
|
|
586
|
+
mergeStatus: mergeResult?.status || "skipped",
|
|
587
|
+
durationMs: waveDuration,
|
|
588
|
+
tokens: waveTokens,
|
|
589
|
+
};
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
// Aggregate batch tokens
|
|
593
|
+
const batchTokens: TokenCounts = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 };
|
|
594
|
+
for (const ws of waveSummaries) {
|
|
595
|
+
batchTokens.input += ws.tokens.input;
|
|
596
|
+
batchTokens.output += ws.tokens.output;
|
|
597
|
+
batchTokens.cacheRead += ws.tokens.cacheRead;
|
|
598
|
+
batchTokens.cacheWrite += ws.tokens.cacheWrite;
|
|
599
|
+
batchTokens.costUsd += ws.tokens.costUsd;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Determine history status from actual outcomes, not batchState.phase
|
|
603
|
+
// (phase hasn't been set to "completed" yet at this point in the flow).
|
|
604
|
+
const historyStatus: "completed" | "partial" | "failed" | "aborted" =
|
|
605
|
+
batchState.failedTasks > 0
|
|
606
|
+
? (batchState.succeededTasks > 0 ? "partial" : "failed")
|
|
607
|
+
: batchState.succeededTasks > 0
|
|
608
|
+
? "completed"
|
|
609
|
+
: "aborted";
|
|
610
|
+
|
|
611
|
+
const summary: BatchHistorySummary = {
|
|
612
|
+
batchId: batchState.batchId,
|
|
613
|
+
status: historyStatus,
|
|
614
|
+
startedAt: batchState.startedAt,
|
|
615
|
+
endedAt: Date.now(),
|
|
616
|
+
durationMs: Date.now() - batchState.startedAt,
|
|
617
|
+
totalWaves: wavePlan.length,
|
|
618
|
+
totalTasks: batchState.totalTasks,
|
|
619
|
+
succeededTasks: batchState.succeededTasks,
|
|
620
|
+
failedTasks: batchState.failedTasks,
|
|
621
|
+
skippedTasks: batchState.skippedTasks,
|
|
622
|
+
blockedTasks: batchState.blockedTasks,
|
|
623
|
+
tokens: batchTokens,
|
|
624
|
+
tasks: taskSummaries,
|
|
625
|
+
waves: waveSummaries,
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
saveBatchHistory(repoRoot, summary);
|
|
629
|
+
} catch (err) {
|
|
630
|
+
execLog("batch", batchState.batchId, `failed to save batch history: ${err}`);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ── Phase 3: Cleanup ─────────────────────────────────────────
|
|
634
|
+
const prefix = orchConfig.orchestrator.worktree_prefix;
|
|
635
|
+
|
|
636
|
+
if (preserveWorktreesForResume) {
|
|
637
|
+
execLog("batch", batchState.batchId, "skipping final cleanup to preserve worktrees/branches for resume");
|
|
638
|
+
} else {
|
|
639
|
+
// Kill any lingering lane tmux sessions BEFORE removing worktrees.
|
|
640
|
+
// On Windows, tmux sessions with cwd inside the worktree lock the
|
|
641
|
+
// directory, causing git worktree remove to fail.
|
|
642
|
+
const orchPrefix = orchConfig.orchestrator.tmux_prefix;
|
|
643
|
+
const lingering = listOrchSessions(orchPrefix, batchState);
|
|
644
|
+
if (lingering.length > 0) {
|
|
645
|
+
execLog("batch", batchState.batchId, `killing ${lingering.length} lingering tmux session(s) before cleanup`);
|
|
646
|
+
for (const sess of lingering) {
|
|
647
|
+
tmuxKillSession(sess.sessionName);
|
|
648
|
+
}
|
|
649
|
+
sleepSync(1000); // Give OS time to release file locks
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Clean up sidecar files (lane state, worker conversation, merge artifacts)
|
|
653
|
+
const piDir = join(repoRoot, ".pi");
|
|
654
|
+
try {
|
|
655
|
+
const sidecarFiles = readdirSync(piDir).filter(
|
|
656
|
+
f => f.startsWith("lane-state-") ||
|
|
657
|
+
f.startsWith("worker-conversation-") ||
|
|
658
|
+
f.startsWith("merge-result-") ||
|
|
659
|
+
f.startsWith("merge-request-"),
|
|
660
|
+
);
|
|
661
|
+
for (const f of sidecarFiles) {
|
|
662
|
+
try { unlinkSync(join(piDir, f)); } catch { /* best effort */ }
|
|
663
|
+
}
|
|
664
|
+
if (sidecarFiles.length > 0) {
|
|
665
|
+
execLog("batch", batchState.batchId, `cleaned up ${sidecarFiles.length} sidecar file(s)`);
|
|
666
|
+
}
|
|
667
|
+
} catch { /* .pi dir may not exist */ }
|
|
668
|
+
|
|
669
|
+
// Clean up worktrees — pass base branch to protect unmerged work
|
|
670
|
+
const targetBranch = batchState.baseBranch;
|
|
671
|
+
execLog("batch", batchState.batchId, "cleaning up worktrees");
|
|
672
|
+
const removeResult = removeAllWorktrees(prefix, repoRoot, targetBranch);
|
|
673
|
+
|
|
674
|
+
// Log preserved branches
|
|
675
|
+
for (const p of removeResult.preserved) {
|
|
676
|
+
execLog("batch", batchState.batchId, `preserving unmerged branch as saved ref`, {
|
|
677
|
+
branch: p.branch,
|
|
678
|
+
savedBranch: p.savedBranch,
|
|
679
|
+
lane: p.laneNumber,
|
|
680
|
+
target: targetBranch,
|
|
681
|
+
commitCount: p.unmergedCount ?? 0,
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (removeResult.failed.length > 0) {
|
|
686
|
+
const failedPaths = removeResult.failed.map(f => f.worktree.path).join(", ");
|
|
687
|
+
execLog("batch", batchState.batchId, `worktree cleanup: ${removeResult.removed.length} removed, ${removeResult.failed.length} failed, ${removeResult.preserved.length} preserved`, {
|
|
688
|
+
failedPaths,
|
|
689
|
+
});
|
|
690
|
+
} else if (removeResult.totalAttempted > 0) {
|
|
691
|
+
execLog("batch", batchState.batchId, `worktree cleanup: ${removeResult.removed.length} removed, ${removeResult.preserved.length} preserved`);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ── Post-worktree-removal: Clean up merged branches ──────
|
|
695
|
+
// This MUST run after worktree removal because git branch -D
|
|
696
|
+
// fails if any worktree still has the branch checked out.
|
|
697
|
+
for (const mergeResult of allMergeResults) {
|
|
698
|
+
if (mergeResult.status === "succeeded" || mergeResult.status === "partial") {
|
|
699
|
+
for (const lr of mergeResult.laneResults) {
|
|
700
|
+
if (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED") {
|
|
701
|
+
const ancestorCheck = runGit(
|
|
702
|
+
["merge-base", "--is-ancestor", lr.sourceBranch, targetBranch],
|
|
703
|
+
repoRoot,
|
|
704
|
+
);
|
|
705
|
+
if (ancestorCheck.ok) {
|
|
706
|
+
const deleted = deleteBranchBestEffort(lr.sourceBranch, repoRoot);
|
|
707
|
+
if (deleted) {
|
|
708
|
+
execLog("batch", batchState.batchId, `deleted merged branch ${lr.sourceBranch}`);
|
|
709
|
+
} else {
|
|
710
|
+
execLog("batch", batchState.batchId, `warning: failed to delete merged branch ${lr.sourceBranch} — retained for manual cleanup`);
|
|
711
|
+
}
|
|
712
|
+
} else {
|
|
713
|
+
execLog("batch", batchState.batchId, `warning: branch ${lr.sourceBranch} not fully merged into ${targetBranch} — retained`);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Set final state
|
|
722
|
+
batchState.endedAt = Date.now();
|
|
723
|
+
const totalElapsedSec = Math.round((batchState.endedAt - batchState.startedAt) / 1000);
|
|
724
|
+
|
|
725
|
+
// Determine final batch state. Cast to OrchBatchPhase to bypass control-flow
|
|
726
|
+
// narrowing — mergeWave() is synchronous but could leave phase as "merging"
|
|
727
|
+
// if an unexpected throw occurs between setting "merging" and restoring "executing".
|
|
728
|
+
if ((batchState.phase as OrchBatchPhase) === "executing" || (batchState.phase as OrchBatchPhase) === "merging") {
|
|
729
|
+
// Normal completion (not stopped, paused, or aborted)
|
|
730
|
+
if (batchState.failedTasks > 0) {
|
|
731
|
+
batchState.phase = "failed";
|
|
732
|
+
} else {
|
|
733
|
+
batchState.phase = "completed";
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ── TS-009: Persist terminal state ──
|
|
738
|
+
persistRuntimeState("batch-terminal", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
|
|
739
|
+
|
|
740
|
+
if (batchState.phase === "paused" || batchState.phase === "stopped") {
|
|
741
|
+
execLog("batch", batchState.batchId, "batch ended in non-terminal execution state; completion banner suppressed", {
|
|
742
|
+
phase: batchState.phase,
|
|
743
|
+
});
|
|
744
|
+
} else {
|
|
745
|
+
onNotify(
|
|
746
|
+
ORCH_MESSAGES.orchBatchComplete(
|
|
747
|
+
batchState.batchId,
|
|
748
|
+
batchState.succeededTasks,
|
|
749
|
+
batchState.failedTasks,
|
|
750
|
+
batchState.skippedTasks,
|
|
751
|
+
batchState.blockedTasks,
|
|
752
|
+
totalElapsedSec,
|
|
753
|
+
),
|
|
754
|
+
batchState.failedTasks > 0 ? "warning" : "info",
|
|
755
|
+
);
|
|
756
|
+
|
|
757
|
+
// ── TS-009: Delete state file on clean completion (no failures) ──
|
|
758
|
+
if (batchState.phase === "completed") {
|
|
759
|
+
try {
|
|
760
|
+
deleteBatchState(repoRoot);
|
|
761
|
+
execLog("state", batchState.batchId, "state file deleted on clean completion");
|
|
762
|
+
} catch (err: unknown) {
|
|
763
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
764
|
+
execLog("state", batchState.batchId, `failed to delete state file: ${msg}`);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ── Dashboard Widget (Step 6) ────────────────────────────────────────
|
|
771
|
+
|