taskplane 0.1.15 → 0.1.16
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 +317 -5
- package/extensions/taskplane/abort.ts +461 -466
- package/extensions/taskplane/config.ts +17 -12
- package/extensions/taskplane/discovery.ts +168 -32
- package/extensions/taskplane/engine.ts +22 -12
- package/extensions/taskplane/execution.ts +108 -46
- package/extensions/taskplane/extension.ts +780 -693
- package/extensions/taskplane/index.ts +23 -22
- package/extensions/taskplane/messages.ts +146 -134
- package/extensions/taskplane/resume.ts +9 -3
- package/extensions/taskplane/types.ts +238 -1
- package/extensions/taskplane/workspace.ts +382 -0
- package/extensions/taskplane/worktree.ts +107 -6
- package/package.json +1 -1
|
@@ -1,693 +1,780 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
|
|
3
|
-
import { execSync } from "child_process";
|
|
4
|
-
import { writeFileSync, unlinkSync, mkdirSync } from "fs";
|
|
5
|
-
import { join } from "path";
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
DEFAULT_ORCHESTRATOR_CONFIG,
|
|
9
|
-
DEFAULT_TASK_RUNNER_CONFIG,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
let
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
case "
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
ctx.ui.notify(
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
ctx.ui.notify(
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
ctx.ui.notify(
|
|
466
|
-
} catch
|
|
467
|
-
ctx.ui.notify(
|
|
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
|
-
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
import { writeFileSync, unlinkSync, mkdirSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_ORCHESTRATOR_CONFIG,
|
|
9
|
+
DEFAULT_TASK_RUNNER_CONFIG,
|
|
10
|
+
FATAL_DISCOVERY_CODES,
|
|
11
|
+
ORCH_MESSAGES,
|
|
12
|
+
WorkspaceConfigError,
|
|
13
|
+
computeWaveAssignments,
|
|
14
|
+
createOrchWidget,
|
|
15
|
+
deleteBatchState,
|
|
16
|
+
detectOrphanSessions,
|
|
17
|
+
executeLane,
|
|
18
|
+
executeOrchBatch,
|
|
19
|
+
formatDependencyGraph,
|
|
20
|
+
formatDiscoveryResults,
|
|
21
|
+
formatOrchSessions,
|
|
22
|
+
formatPreflightResults,
|
|
23
|
+
formatWavePlan,
|
|
24
|
+
freshOrchBatchState,
|
|
25
|
+
listOrchSessions,
|
|
26
|
+
loadBatchState,
|
|
27
|
+
loadOrchestratorConfig,
|
|
28
|
+
loadTaskRunnerConfig,
|
|
29
|
+
parseOrchSessionNames,
|
|
30
|
+
resumeOrchBatch,
|
|
31
|
+
runDiscovery,
|
|
32
|
+
runPreflight,
|
|
33
|
+
} from "./index.ts";
|
|
34
|
+
import { buildExecutionContext } from "./workspace.ts";
|
|
35
|
+
import type {
|
|
36
|
+
AbortMode,
|
|
37
|
+
ExecutionContext,
|
|
38
|
+
MonitorState,
|
|
39
|
+
OrchestratorConfig,
|
|
40
|
+
PersistedBatchState,
|
|
41
|
+
TaskRunnerConfig,
|
|
42
|
+
} from "./index.ts";
|
|
43
|
+
|
|
44
|
+
// ── Extension ────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export default function (pi: ExtensionAPI) {
|
|
47
|
+
let orchBatchState = freshOrchBatchState();
|
|
48
|
+
let orchConfig: OrchestratorConfig = { ...DEFAULT_ORCHESTRATOR_CONFIG };
|
|
49
|
+
let runnerConfig: TaskRunnerConfig = { ...DEFAULT_TASK_RUNNER_CONFIG };
|
|
50
|
+
let orchWidgetCtx: ExtensionContext | undefined;
|
|
51
|
+
let latestMonitorState: MonitorState | null = null;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Execution context loaded at session start. Null if startup failed
|
|
55
|
+
* (e.g., workspace config present but invalid). Commands check this
|
|
56
|
+
* and return early with a user-facing error when null.
|
|
57
|
+
*/
|
|
58
|
+
let execCtx: ExecutionContext | null = null;
|
|
59
|
+
|
|
60
|
+
// ── Widget Rendering ─────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
function updateOrchWidget() {
|
|
63
|
+
if (!orchWidgetCtx) return;
|
|
64
|
+
const ctx = orchWidgetCtx;
|
|
65
|
+
const prefix = orchConfig.orchestrator.tmux_prefix;
|
|
66
|
+
|
|
67
|
+
ctx.ui.setWidget(
|
|
68
|
+
"task-orchestrator",
|
|
69
|
+
createOrchWidget(
|
|
70
|
+
() => orchBatchState,
|
|
71
|
+
() => latestMonitorState,
|
|
72
|
+
prefix,
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Command Guard ────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Guard: returns true if execution context is initialized, false otherwise.
|
|
81
|
+
* Emits a user-facing error notification when the context is missing.
|
|
82
|
+
*/
|
|
83
|
+
function requireExecCtx(ctx: ExtensionContext): boolean {
|
|
84
|
+
if (execCtx) return true;
|
|
85
|
+
ctx.ui.notify(
|
|
86
|
+
"❌ Orchestrator not initialized. Workspace configuration failed at startup.\n" +
|
|
87
|
+
"Fix the workspace config or remove it to use repo mode, then restart.",
|
|
88
|
+
"error",
|
|
89
|
+
);
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Commands ─────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
pi.registerCommand("orch", {
|
|
96
|
+
description: "Start batch execution: /orch <areas|paths|all>",
|
|
97
|
+
handler: async (args, ctx) => {
|
|
98
|
+
if (!args?.trim()) {
|
|
99
|
+
ctx.ui.notify(
|
|
100
|
+
"Usage: /orch <areas|paths|all>\n\n" +
|
|
101
|
+
"Examples:\n" +
|
|
102
|
+
" /orch all Run all pending tasks\n" +
|
|
103
|
+
" /orch time-off performance-management Run specific areas\n" +
|
|
104
|
+
" /orch path/to/tasks Scan directory\n" +
|
|
105
|
+
" /orch path/to/PROMPT.md Single task with isolation",
|
|
106
|
+
"info",
|
|
107
|
+
);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!requireExecCtx(ctx)) return;
|
|
112
|
+
|
|
113
|
+
// Prevent concurrent batch execution (merging is an active state)
|
|
114
|
+
if (orchBatchState.phase !== "idle" && orchBatchState.phase !== "completed" && orchBatchState.phase !== "failed" && orchBatchState.phase !== "stopped") {
|
|
115
|
+
ctx.ui.notify(
|
|
116
|
+
`⚠️ A batch is already ${orchBatchState.phase} (${orchBatchState.batchId}). ` +
|
|
117
|
+
`Use /orch-pause to pause or wait for completion.`,
|
|
118
|
+
"warning",
|
|
119
|
+
);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Root references from execution context.
|
|
124
|
+
// Currently all .pi state, orphan detection, batch state, abort signal,
|
|
125
|
+
// and discovery operations use repoRoot for consistency with engine.ts,
|
|
126
|
+
// resume.ts, and execution.ts which all alias cwd → repoRoot.
|
|
127
|
+
// In repo mode workspaceRoot === repoRoot, so this is safe.
|
|
128
|
+
// TODO(workspace-mode): when workspace mode is fully threaded through
|
|
129
|
+
// engine/resume/execution, split state root from git root.
|
|
130
|
+
const { repoRoot } = execCtx!;
|
|
131
|
+
|
|
132
|
+
// ── Orphan detection (TS-009 Step 3) ─────────────────────
|
|
133
|
+
const orphanResult = detectOrphanSessions(
|
|
134
|
+
orchConfig.orchestrator.tmux_prefix,
|
|
135
|
+
repoRoot,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
switch (orphanResult.recommendedAction) {
|
|
139
|
+
case "resume": {
|
|
140
|
+
// Safety net: if the persisted phase is not actually resumable (e.g. "failed",
|
|
141
|
+
// "stopped") — which can happen when the batch crashed after writing a terminal
|
|
142
|
+
// phase but before /orch-abort cleaned up — auto-delete the state file and
|
|
143
|
+
// fall through to start fresh rather than blocking the user with a catch-22.
|
|
144
|
+
const resumablePhases = ["paused", "executing", "merging"];
|
|
145
|
+
const phase = orphanResult.loadedState?.phase ?? "";
|
|
146
|
+
const hasOrphans = orphanResult.orphanSessions.length > 0;
|
|
147
|
+
if (!hasOrphans && !resumablePhases.includes(phase)) {
|
|
148
|
+
try { deleteBatchState(repoRoot); } catch { /* best effort */ }
|
|
149
|
+
ctx.ui.notify(
|
|
150
|
+
`🧹 Cleared non-resumable stale batch (${orphanResult.loadedState?.batchId}, phase=${phase}). Starting fresh.`,
|
|
151
|
+
"info",
|
|
152
|
+
);
|
|
153
|
+
break; // fall through to start a new batch
|
|
154
|
+
}
|
|
155
|
+
// Genuinely resumable or has live orphan sessions — prompt user
|
|
156
|
+
ctx.ui.notify(orphanResult.userMessage, "warning");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
case "abort-orphans":
|
|
161
|
+
// Orphan sessions without usable state
|
|
162
|
+
ctx.ui.notify(orphanResult.userMessage, "warning");
|
|
163
|
+
return;
|
|
164
|
+
|
|
165
|
+
case "cleanup-stale":
|
|
166
|
+
// No orphans + stale/invalid state file — auto-delete and continue
|
|
167
|
+
try {
|
|
168
|
+
deleteBatchState(repoRoot);
|
|
169
|
+
} catch {
|
|
170
|
+
// Best-effort cleanup — proceed even if delete fails
|
|
171
|
+
}
|
|
172
|
+
if (orphanResult.userMessage) {
|
|
173
|
+
ctx.ui.notify(orphanResult.userMessage, "info");
|
|
174
|
+
}
|
|
175
|
+
break;
|
|
176
|
+
|
|
177
|
+
case "start-fresh":
|
|
178
|
+
// No orphans, no state file — proceed normally
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Reset batch state for new execution
|
|
183
|
+
orchBatchState = freshOrchBatchState();
|
|
184
|
+
latestMonitorState = null;
|
|
185
|
+
updateOrchWidget();
|
|
186
|
+
|
|
187
|
+
await executeOrchBatch(
|
|
188
|
+
args,
|
|
189
|
+
orchConfig,
|
|
190
|
+
runnerConfig,
|
|
191
|
+
repoRoot,
|
|
192
|
+
orchBatchState,
|
|
193
|
+
(message, level) => {
|
|
194
|
+
ctx.ui.notify(message, level);
|
|
195
|
+
updateOrchWidget(); // Refresh widget on every phase message
|
|
196
|
+
},
|
|
197
|
+
(monState: MonitorState) => {
|
|
198
|
+
const changed = !latestMonitorState ||
|
|
199
|
+
latestMonitorState.totalDone !== monState.totalDone ||
|
|
200
|
+
latestMonitorState.totalFailed !== monState.totalFailed ||
|
|
201
|
+
latestMonitorState.lanes.some((l, i) =>
|
|
202
|
+
l.currentTaskId !== monState.lanes[i]?.currentTaskId ||
|
|
203
|
+
l.currentStep !== monState.lanes[i]?.currentStep ||
|
|
204
|
+
l.completedChecks !== monState.lanes[i]?.completedChecks,
|
|
205
|
+
);
|
|
206
|
+
latestMonitorState = monState;
|
|
207
|
+
if (changed) updateOrchWidget(); // Only refresh on actual state change
|
|
208
|
+
},
|
|
209
|
+
execCtx!.workspaceConfig,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
// Final widget update after batch completes
|
|
213
|
+
updateOrchWidget();
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
pi.registerCommand("orch-plan", {
|
|
218
|
+
description: "Preview execution plan: /orch-plan <areas|paths|all> [--refresh]",
|
|
219
|
+
handler: async (args, ctx) => {
|
|
220
|
+
if (!args?.trim()) {
|
|
221
|
+
ctx.ui.notify(
|
|
222
|
+
"Usage: /orch-plan <areas|paths|all> [--refresh]\n\n" +
|
|
223
|
+
"Shows the execution plan (tasks, waves, lane assignments)\n" +
|
|
224
|
+
"without actually executing anything.\n\n" +
|
|
225
|
+
"Options:\n" +
|
|
226
|
+
" --refresh Force re-scan of areas (bypass dependency cache)\n\n" +
|
|
227
|
+
"Examples:\n" +
|
|
228
|
+
" /orch-plan all\n" +
|
|
229
|
+
" /orch-plan time-off notifications\n" +
|
|
230
|
+
" /orch-plan docs/task-management/domains/time-off/tasks\n" +
|
|
231
|
+
" /orch-plan all --refresh",
|
|
232
|
+
"info",
|
|
233
|
+
);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!requireExecCtx(ctx)) return;
|
|
238
|
+
|
|
239
|
+
// Parse --refresh flag
|
|
240
|
+
const hasRefresh = /--refresh/.test(args);
|
|
241
|
+
const cleanArgs = args.replace(/--refresh/g, "").trim();
|
|
242
|
+
if (!cleanArgs) {
|
|
243
|
+
ctx.ui.notify(
|
|
244
|
+
"Usage: /orch-plan <areas|paths|all> [--refresh]\n" +
|
|
245
|
+
"Error: target argument required (e.g., 'all', area name, or path)",
|
|
246
|
+
"error",
|
|
247
|
+
);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (hasRefresh) {
|
|
251
|
+
ctx.ui.notify("🔄 Refresh mode: re-scanning all areas (cache bypassed)", "info");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── Section 1: Preflight ─────────────────────────────────
|
|
255
|
+
const preflight = runPreflight(orchConfig);
|
|
256
|
+
ctx.ui.notify(formatPreflightResults(preflight), preflight.passed ? "info" : "error");
|
|
257
|
+
if (!preflight.passed) return;
|
|
258
|
+
|
|
259
|
+
// ── Section 2: Discovery ─────────────────────────────────
|
|
260
|
+
// Discovery uses repoRoot for consistency with engine.ts and resume.ts
|
|
261
|
+
// which both call runDiscovery with their cwd (= repoRoot).
|
|
262
|
+
const discovery = runDiscovery(cleanArgs, runnerConfig.task_areas, execCtx!.repoRoot, {
|
|
263
|
+
refreshDependencies: hasRefresh,
|
|
264
|
+
dependencySource: orchConfig.dependencies.source,
|
|
265
|
+
useDependencyCache: orchConfig.dependencies.cache,
|
|
266
|
+
workspaceConfig: execCtx!.workspaceConfig,
|
|
267
|
+
});
|
|
268
|
+
ctx.ui.notify(formatDiscoveryResults(discovery), discovery.errors.length > 0 ? "warning" : "info");
|
|
269
|
+
|
|
270
|
+
// Check for fatal errors
|
|
271
|
+
const fatalCodes = new Set<string>(FATAL_DISCOVERY_CODES);
|
|
272
|
+
const fatalErrors = discovery.errors.filter((e) => fatalCodes.has(e.code));
|
|
273
|
+
if (fatalErrors.length > 0) {
|
|
274
|
+
ctx.ui.notify("❌ Cannot compute plan due to discovery errors above.", "error");
|
|
275
|
+
const hasRoutingErrors = fatalErrors.some(
|
|
276
|
+
(e) => e.code === "TASK_REPO_UNRESOLVED" || e.code === "TASK_REPO_UNKNOWN",
|
|
277
|
+
);
|
|
278
|
+
if (hasRoutingErrors) {
|
|
279
|
+
ctx.ui.notify(
|
|
280
|
+
"💡 Check PROMPT Repo: fields, area repo_id config, and routing.default_repo in workspace config.",
|
|
281
|
+
"info",
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (discovery.pending.size === 0) {
|
|
288
|
+
ctx.ui.notify("No pending tasks found. Nothing to plan.", "info");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── Section 3: Dependency Graph ──────────────────────────
|
|
293
|
+
ctx.ui.notify(
|
|
294
|
+
formatDependencyGraph(discovery.pending, discovery.completed),
|
|
295
|
+
"info",
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
// ── Section 4: Waves + Estimate ──────────────────────────
|
|
299
|
+
// Uses computeWaveAssignments pipeline only — NO re-parsing
|
|
300
|
+
const waveResult = computeWaveAssignments(
|
|
301
|
+
discovery.pending,
|
|
302
|
+
discovery.completed,
|
|
303
|
+
orchConfig,
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
ctx.ui.notify(
|
|
307
|
+
formatWavePlan(waveResult, orchConfig.assignment.size_weights),
|
|
308
|
+
waveResult.errors.length > 0 ? "error" : "info",
|
|
309
|
+
);
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
pi.registerCommand("orch-status", {
|
|
314
|
+
description: "Show current batch progress",
|
|
315
|
+
handler: async (_args, ctx) => {
|
|
316
|
+
if (orchBatchState.phase === "idle") {
|
|
317
|
+
ctx.ui.notify("No batch is running. Use /orch <areas|paths|all> to start.", "info");
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const elapsedSec = orchBatchState.endedAt
|
|
322
|
+
? Math.round((orchBatchState.endedAt - orchBatchState.startedAt) / 1000)
|
|
323
|
+
: Math.round((Date.now() - orchBatchState.startedAt) / 1000);
|
|
324
|
+
|
|
325
|
+
const lines: string[] = [
|
|
326
|
+
`📊 Batch ${orchBatchState.batchId} — ${orchBatchState.phase}`,
|
|
327
|
+
` Wave: ${orchBatchState.currentWaveIndex + 1}/${orchBatchState.totalWaves}`,
|
|
328
|
+
` Tasks: ${orchBatchState.succeededTasks} succeeded, ${orchBatchState.failedTasks} failed, ${orchBatchState.skippedTasks} skipped, ${orchBatchState.blockedTasks} blocked / ${orchBatchState.totalTasks} total`,
|
|
329
|
+
` Elapsed: ${elapsedSec}s`,
|
|
330
|
+
];
|
|
331
|
+
|
|
332
|
+
if (orchBatchState.errors.length > 0) {
|
|
333
|
+
lines.push(` Errors: ${orchBatchState.errors.length}`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
pi.registerCommand("orch-pause", {
|
|
341
|
+
description: "Pause batch after current tasks finish",
|
|
342
|
+
handler: async (_args, ctx) => {
|
|
343
|
+
if (orchBatchState.phase === "idle" || orchBatchState.phase === "completed" || orchBatchState.phase === "failed" || orchBatchState.phase === "stopped") {
|
|
344
|
+
ctx.ui.notify(ORCH_MESSAGES.pauseNoBatch(), "warning");
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (orchBatchState.phase === "paused" || orchBatchState.pauseSignal.paused) {
|
|
348
|
+
ctx.ui.notify(ORCH_MESSAGES.pauseAlreadyPaused(orchBatchState.batchId), "warning");
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
// Set pause signal — executeLane() checks this between tasks
|
|
352
|
+
orchBatchState.pauseSignal.paused = true;
|
|
353
|
+
ctx.ui.notify(ORCH_MESSAGES.pauseActivated(orchBatchState.batchId), "info");
|
|
354
|
+
updateOrchWidget();
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
pi.registerCommand("orch-resume", {
|
|
359
|
+
description: "Resume a paused or interrupted batch",
|
|
360
|
+
handler: async (_args, ctx) => {
|
|
361
|
+
if (!requireExecCtx(ctx)) return;
|
|
362
|
+
|
|
363
|
+
// Prevent resume if a batch is actively running
|
|
364
|
+
if (orchBatchState.phase === "executing" || orchBatchState.phase === "merging" || orchBatchState.phase === "planning") {
|
|
365
|
+
ctx.ui.notify(
|
|
366
|
+
`⚠️ A batch is currently ${orchBatchState.phase} (${orchBatchState.batchId}). Cannot resume.`,
|
|
367
|
+
"warning",
|
|
368
|
+
);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Reset batch state for resume
|
|
373
|
+
orchBatchState = freshOrchBatchState();
|
|
374
|
+
latestMonitorState = null;
|
|
375
|
+
updateOrchWidget();
|
|
376
|
+
|
|
377
|
+
await resumeOrchBatch(
|
|
378
|
+
orchConfig,
|
|
379
|
+
runnerConfig,
|
|
380
|
+
execCtx!.repoRoot,
|
|
381
|
+
orchBatchState,
|
|
382
|
+
(message, level) => {
|
|
383
|
+
ctx.ui.notify(message, level);
|
|
384
|
+
updateOrchWidget();
|
|
385
|
+
},
|
|
386
|
+
(monState: MonitorState) => {
|
|
387
|
+
latestMonitorState = monState;
|
|
388
|
+
updateOrchWidget();
|
|
389
|
+
},
|
|
390
|
+
execCtx!.workspaceConfig,
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
// Final widget update
|
|
394
|
+
updateOrchWidget();
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
pi.registerCommand("orch-abort", {
|
|
399
|
+
description: "Abort batch: /orch-abort [--hard]",
|
|
400
|
+
handler: async (args, ctx) => {
|
|
401
|
+
try {
|
|
402
|
+
const hard = args?.trim() === "--hard";
|
|
403
|
+
const mode: AbortMode = hard ? "hard" : "graceful";
|
|
404
|
+
const prefix = orchConfig.orchestrator.tmux_prefix;
|
|
405
|
+
const gracePeriodMs = orchConfig.orchestrator.abort_grace_period * 1000;
|
|
406
|
+
|
|
407
|
+
// Abort must work even if execCtx failed to load (safety-critical).
|
|
408
|
+
// Fall back to ctx.cwd if no execution context is available.
|
|
409
|
+
// Uses repoRoot for consistency with engine/resume/execution
|
|
410
|
+
// which all persist state and poll abort signals from repoRoot.
|
|
411
|
+
const stateRoot = execCtx?.repoRoot ?? ctx.cwd;
|
|
412
|
+
|
|
413
|
+
ctx.ui.notify(`🛑 Abort requested (${mode} mode, prefix: ${prefix})...`, "info");
|
|
414
|
+
|
|
415
|
+
// ── Step 1: Write abort signal file immediately ──────────
|
|
416
|
+
// This is the primary abort mechanism. The orchestrator's polling
|
|
417
|
+
// loop checks for this file on every cycle, so even if this command
|
|
418
|
+
// handler runs concurrently with /orch (or is queued behind it),
|
|
419
|
+
// the signal file will be detected.
|
|
420
|
+
const abortSignalFile = join(stateRoot, ".pi", "orch-abort-signal");
|
|
421
|
+
try {
|
|
422
|
+
mkdirSync(join(stateRoot, ".pi"), { recursive: true });
|
|
423
|
+
writeFileSync(abortSignalFile, `abort requested at ${new Date().toISOString()} (mode: ${mode})`, "utf-8");
|
|
424
|
+
ctx.ui.notify(" ✓ Abort signal file written (.pi/orch-abort-signal)", "info");
|
|
425
|
+
} catch (err) {
|
|
426
|
+
ctx.ui.notify(` ⚠ Failed to write abort signal file: ${err instanceof Error ? err.message : String(err)}`, "warning");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── Step 2: Set pause signal immediately ─────────────────
|
|
430
|
+
// Belt-and-suspenders: if the /orch polling loop can see this
|
|
431
|
+
// shared object, it will stop on the next iteration.
|
|
432
|
+
if (orchBatchState.pauseSignal) {
|
|
433
|
+
orchBatchState.pauseSignal.paused = true;
|
|
434
|
+
ctx.ui.notify(" ✓ Pause signal set on in-memory batch state", "info");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ── Step 3: Check what we're aborting ────────────────────
|
|
438
|
+
const hasActiveBatch = orchBatchState.phase !== "idle" &&
|
|
439
|
+
orchBatchState.phase !== "completed" &&
|
|
440
|
+
orchBatchState.phase !== "failed" &&
|
|
441
|
+
orchBatchState.phase !== "stopped";
|
|
442
|
+
|
|
443
|
+
let persistedState: PersistedBatchState | null = null;
|
|
444
|
+
try {
|
|
445
|
+
persistedState = loadBatchState(stateRoot);
|
|
446
|
+
} catch {
|
|
447
|
+
// Ignore — we may still have in-memory state or orphan sessions
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
ctx.ui.notify(
|
|
451
|
+
` Batch state: in-memory=${hasActiveBatch ? orchBatchState.phase : "none"}, ` +
|
|
452
|
+
`persisted=${persistedState ? persistedState.batchId : "none"}`,
|
|
453
|
+
"info",
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
// ── Step 4: Scan for tmux sessions ──────────────────────
|
|
457
|
+
let allSessionNames: string[] = [];
|
|
458
|
+
try {
|
|
459
|
+
const tmuxOutput = execSync('tmux list-sessions -F "#{session_name}"', {
|
|
460
|
+
encoding: "utf-8",
|
|
461
|
+
timeout: 5000,
|
|
462
|
+
}).trim();
|
|
463
|
+
const all = tmuxOutput ? tmuxOutput.split("\n").map(s => s.trim()).filter(Boolean) : [];
|
|
464
|
+
allSessionNames = all.filter(name => name.startsWith(`${prefix}-`));
|
|
465
|
+
ctx.ui.notify(` Found ${allSessionNames.length} session(s) matching prefix "${prefix}-": ${allSessionNames.join(", ") || "(none)"}`, "info");
|
|
466
|
+
} catch {
|
|
467
|
+
ctx.ui.notify(" ⚠ Could not list tmux sessions (tmux not available?)", "warning");
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// If no batch AND no sessions, nothing to abort
|
|
471
|
+
if (!hasActiveBatch && !persistedState && allSessionNames.length === 0) {
|
|
472
|
+
ctx.ui.notify(ORCH_MESSAGES.abortNoBatch(), "warning");
|
|
473
|
+
// Clean up signal file
|
|
474
|
+
try { unlinkSync(abortSignalFile); } catch {}
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const batchId = orchBatchState.batchId || persistedState?.batchId || "unknown";
|
|
479
|
+
|
|
480
|
+
// ── Step 5: Kill sessions directly (fast path) ──────────
|
|
481
|
+
// For hard mode or when sessions are found, kill them immediately
|
|
482
|
+
// rather than waiting through the full executeAbort flow.
|
|
483
|
+
if (allSessionNames.length > 0) {
|
|
484
|
+
ctx.ui.notify(` Killing ${allSessionNames.length} tmux session(s)...`, "info");
|
|
485
|
+
let killed = 0;
|
|
486
|
+
for (const name of allSessionNames) {
|
|
487
|
+
try {
|
|
488
|
+
// Kill child sessions first (worker, reviewer)
|
|
489
|
+
execSync(`tmux kill-session -t "${name}-worker" 2>/dev/null`, { timeout: 3000 }).toString();
|
|
490
|
+
} catch {}
|
|
491
|
+
try {
|
|
492
|
+
execSync(`tmux kill-session -t "${name}-reviewer" 2>/dev/null`, { timeout: 3000 }).toString();
|
|
493
|
+
} catch {}
|
|
494
|
+
try {
|
|
495
|
+
execSync(`tmux kill-session -t "${name}" 2>/dev/null`, { timeout: 3000 }).toString();
|
|
496
|
+
killed++;
|
|
497
|
+
ctx.ui.notify(` ✓ Killed: ${name}`, "info");
|
|
498
|
+
} catch {
|
|
499
|
+
// Session may have already exited
|
|
500
|
+
ctx.ui.notify(` · ${name} (already exited)`, "info");
|
|
501
|
+
killed++;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
ctx.ui.notify(` ✓ ${killed}/${allSessionNames.length} session(s) terminated`, "info");
|
|
505
|
+
} else {
|
|
506
|
+
ctx.ui.notify(" No tmux sessions to kill", "info");
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── Step 6: Clean up batch state ────────────────────────
|
|
510
|
+
try {
|
|
511
|
+
orchBatchState.phase = "stopped";
|
|
512
|
+
orchBatchState.endedAt = Date.now();
|
|
513
|
+
updateOrchWidget();
|
|
514
|
+
ctx.ui.notify(" ✓ In-memory batch state set to 'stopped'", "info");
|
|
515
|
+
} catch (err) {
|
|
516
|
+
ctx.ui.notify(` ⚠ Failed to update in-memory state: ${err instanceof Error ? err.message : String(err)}`, "warning");
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
deleteBatchState(stateRoot);
|
|
521
|
+
ctx.ui.notify(" ✓ Batch state file deleted (.pi/batch-state.json)", "info");
|
|
522
|
+
} catch (err) {
|
|
523
|
+
ctx.ui.notify(` ⚠ Failed to delete batch state file: ${err instanceof Error ? err.message : String(err)}`, "warning");
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ── Step 7: Clean up abort signal file ───────────────────
|
|
527
|
+
try { unlinkSync(abortSignalFile); } catch {}
|
|
528
|
+
|
|
529
|
+
// ── Done ─────────────────────────────────────────────────
|
|
530
|
+
ctx.ui.notify(
|
|
531
|
+
`✅ Abort complete for batch ${batchId}. Sessions killed, state cleaned up.\n` +
|
|
532
|
+
` Worktrees and branches are preserved for inspection.`,
|
|
533
|
+
"info",
|
|
534
|
+
);
|
|
535
|
+
} catch (err) {
|
|
536
|
+
// Top-level catch: ensure the user ALWAYS sees something
|
|
537
|
+
ctx.ui.notify(
|
|
538
|
+
`❌ Abort failed with error: ${err instanceof Error ? err.message : String(err)}\n` +
|
|
539
|
+
` Stack: ${err instanceof Error ? err.stack : "N/A"}\n\n` +
|
|
540
|
+
` Manual cleanup: tmux kill-server (kills ALL tmux sessions)\n` +
|
|
541
|
+
` Or: tmux kill-session -t <session-name> for each session`,
|
|
542
|
+
"error",
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
pi.registerCommand("orch-deps", {
|
|
549
|
+
description: "Show dependency graph: /orch-deps <areas|paths|all> [--refresh] [--task <id>]",
|
|
550
|
+
handler: async (args, ctx) => {
|
|
551
|
+
if (!args?.trim()) {
|
|
552
|
+
ctx.ui.notify(
|
|
553
|
+
"Usage: /orch-deps <areas|paths|all> [--refresh] [--task <id>]\n\n" +
|
|
554
|
+
"Shows the dependency graph for tasks in the specified areas.\n\n" +
|
|
555
|
+
"Options:\n" +
|
|
556
|
+
" --refresh Force re-scan of areas (bypass dependency cache)\n" +
|
|
557
|
+
" --task <id> Show dependencies for a single task only\n\n" +
|
|
558
|
+
"Examples:\n" +
|
|
559
|
+
" /orch-deps all\n" +
|
|
560
|
+
" /orch-deps all --task TO-014\n" +
|
|
561
|
+
" /orch-deps time-off --refresh\n" +
|
|
562
|
+
" /orch-deps all --task COMP-006 --refresh",
|
|
563
|
+
"info",
|
|
564
|
+
);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (!requireExecCtx(ctx)) return;
|
|
569
|
+
|
|
570
|
+
// Parse --refresh flag
|
|
571
|
+
const hasRefresh = /--refresh/.test(args);
|
|
572
|
+
|
|
573
|
+
// Parse --task <id> flag
|
|
574
|
+
let filterTaskId: string | undefined;
|
|
575
|
+
const taskMatch = args.match(/--task\s+([A-Z]+-\d+)/i);
|
|
576
|
+
if (taskMatch) {
|
|
577
|
+
filterTaskId = taskMatch[1].toUpperCase();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Strip flags to get clean area/path arguments
|
|
581
|
+
let cleanArgs = args
|
|
582
|
+
.replace(/--refresh/g, "")
|
|
583
|
+
.replace(/--task\s+[A-Z]+-\d+/gi, "")
|
|
584
|
+
.trim();
|
|
585
|
+
|
|
586
|
+
if (!cleanArgs) {
|
|
587
|
+
ctx.ui.notify(
|
|
588
|
+
"Usage: /orch-deps <areas|paths|all> [--refresh] [--task <id>]\n" +
|
|
589
|
+
"Error: target argument required (e.g., 'all', area name, or path)",
|
|
590
|
+
"error",
|
|
591
|
+
);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (hasRefresh) {
|
|
596
|
+
ctx.ui.notify("🔄 Refresh mode: re-scanning all areas (dependency cache bypassed)", "info");
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Run discovery (no preflight needed for deps view).
|
|
600
|
+
// Uses repoRoot for consistency with engine.ts and resume.ts.
|
|
601
|
+
const discovery = runDiscovery(cleanArgs, runnerConfig.task_areas, execCtx!.repoRoot, {
|
|
602
|
+
refreshDependencies: hasRefresh,
|
|
603
|
+
dependencySource: orchConfig.dependencies.source,
|
|
604
|
+
useDependencyCache: orchConfig.dependencies.cache,
|
|
605
|
+
workspaceConfig: execCtx!.workspaceConfig,
|
|
606
|
+
});
|
|
607
|
+
ctx.ui.notify(
|
|
608
|
+
formatDiscoveryResults(discovery),
|
|
609
|
+
discovery.errors.length > 0 ? "warning" : "info",
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
// Show dependency graph (full or filtered)
|
|
613
|
+
if (discovery.pending.size > 0) {
|
|
614
|
+
ctx.ui.notify(
|
|
615
|
+
formatDependencyGraph(
|
|
616
|
+
discovery.pending,
|
|
617
|
+
discovery.completed,
|
|
618
|
+
filterTaskId,
|
|
619
|
+
),
|
|
620
|
+
"info",
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
},
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
pi.registerCommand("orch-sessions", {
|
|
627
|
+
description: "List active orchestrator TMUX sessions",
|
|
628
|
+
handler: async (_args, ctx) => {
|
|
629
|
+
const sessions = listOrchSessions(orchConfig.orchestrator.tmux_prefix, orchBatchState);
|
|
630
|
+
ctx.ui.notify(formatOrchSessions(sessions), "info");
|
|
631
|
+
},
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
// ── Session Lifecycle ────────────────────────────────────────────
|
|
635
|
+
|
|
636
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
637
|
+
// Store widget context for dashboard updates (needed even if startup fails)
|
|
638
|
+
orchWidgetCtx = ctx;
|
|
639
|
+
|
|
640
|
+
// ── Build execution context (config + workspace mode detection) ──
|
|
641
|
+
// Reset execCtx before loading to prevent stale state on re-init
|
|
642
|
+
execCtx = null;
|
|
643
|
+
try {
|
|
644
|
+
execCtx = buildExecutionContext(ctx.cwd, loadOrchestratorConfig, loadTaskRunnerConfig);
|
|
645
|
+
} catch (err: unknown) {
|
|
646
|
+
if (err instanceof WorkspaceConfigError) {
|
|
647
|
+
// Workspace config is present but invalid — fatal startup error.
|
|
648
|
+
// Leave execCtx null; command guard will block all commands except abort.
|
|
649
|
+
ctx.ui.notify(
|
|
650
|
+
`❌ Workspace configuration error [${err.code}]\n\n` +
|
|
651
|
+
`${err.message}\n\n` +
|
|
652
|
+
`Fix the workspace config at .pi/taskplane-workspace.yaml or remove it to use repo mode.\n` +
|
|
653
|
+
`Orchestrator commands are disabled until this is resolved.`,
|
|
654
|
+
"error",
|
|
655
|
+
);
|
|
656
|
+
ctx.ui.setStatus(
|
|
657
|
+
"task-orchestrator",
|
|
658
|
+
"🔀 Orchestrator · ❌ startup failed (workspace config error)",
|
|
659
|
+
);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
throw err; // Re-throw unexpected errors
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Populate module-level config refs from the loaded context
|
|
666
|
+
orchConfig = execCtx.orchestratorConfig;
|
|
667
|
+
runnerConfig = execCtx.taskRunnerConfig;
|
|
668
|
+
|
|
669
|
+
// Set status line
|
|
670
|
+
const areaCount = Object.keys(runnerConfig.task_areas).length;
|
|
671
|
+
const modeLabel = execCtx.mode === "workspace" ? "workspace" : "repo";
|
|
672
|
+
ctx.ui.setStatus(
|
|
673
|
+
"task-orchestrator",
|
|
674
|
+
`🔀 Orchestrator · ${modeLabel} mode · ${areaCount} areas · ${orchConfig.orchestrator.max_lanes} lanes`,
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
// Register initial dashboard widget (idle state)
|
|
678
|
+
updateOrchWidget();
|
|
679
|
+
|
|
680
|
+
// Notify user of available commands
|
|
681
|
+
ctx.ui.notify(
|
|
682
|
+
"Task Orchestrator ready\n\n" +
|
|
683
|
+
`Mode: ${modeLabel}\n` +
|
|
684
|
+
`Config: ${orchConfig.orchestrator.max_lanes} lanes, ` +
|
|
685
|
+
`${orchConfig.orchestrator.spawn_mode} mode, ` +
|
|
686
|
+
`${orchConfig.dependencies.source} deps\n` +
|
|
687
|
+
`Areas: ${areaCount} registered\n\n` +
|
|
688
|
+
"/orch <areas|all> Start batch execution\n" +
|
|
689
|
+
"/orch-plan <areas|all> Preview execution plan\n" +
|
|
690
|
+
"/orch-deps <areas|all> Show dependency graph\n" +
|
|
691
|
+
"/orch-sessions List TMUX sessions",
|
|
692
|
+
"info",
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
// Check for taskplane updates (non-blocking)
|
|
696
|
+
checkForUpdate(ctx);
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// ── Update Check ─────────────────────────────────────────────────────
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Check npm registry for a newer version of taskplane.
|
|
704
|
+
*
|
|
705
|
+
* Runs asynchronously and never throws — update check failures are
|
|
706
|
+
* silently ignored so they don't interfere with normal operation.
|
|
707
|
+
*/
|
|
708
|
+
async function checkForUpdate(ctx: ExtensionContext): Promise<void> {
|
|
709
|
+
try {
|
|
710
|
+
// Get installed version from our own package.json
|
|
711
|
+
const { readFileSync: readFS } = await import("fs");
|
|
712
|
+
const { dirname, join: joinPath } = await import("path");
|
|
713
|
+
const { fileURLToPath } = await import("url");
|
|
714
|
+
|
|
715
|
+
// Resolve package.json relative to this extension file.
|
|
716
|
+
// In npm install layout: node_modules/taskplane/extensions/taskplane/extension.ts
|
|
717
|
+
// package.json is at: node_modules/taskplane/package.json
|
|
718
|
+
let pkgJsonPath: string;
|
|
719
|
+
try {
|
|
720
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
721
|
+
pkgJsonPath = joinPath(thisDir, "..", "..", "package.json");
|
|
722
|
+
} catch {
|
|
723
|
+
// Fallback for environments where import.meta.url is unavailable
|
|
724
|
+
pkgJsonPath = joinPath(__dirname, "..", "..", "package.json");
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
let installedVersion: string;
|
|
728
|
+
try {
|
|
729
|
+
const pkg = JSON.parse(readFS(pkgJsonPath, "utf-8"));
|
|
730
|
+
installedVersion = pkg.version;
|
|
731
|
+
} catch {
|
|
732
|
+
return; // Can't determine installed version — skip check
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Fetch latest version from npm registry (5s timeout)
|
|
736
|
+
const controller = new AbortController();
|
|
737
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
738
|
+
|
|
739
|
+
const response = await fetch("https://registry.npmjs.org/taskplane/latest", {
|
|
740
|
+
signal: controller.signal,
|
|
741
|
+
headers: { "Accept": "application/json" },
|
|
742
|
+
});
|
|
743
|
+
clearTimeout(timeout);
|
|
744
|
+
|
|
745
|
+
if (!response.ok) return;
|
|
746
|
+
|
|
747
|
+
const data = await response.json() as { version?: string };
|
|
748
|
+
const latestVersion = data.version;
|
|
749
|
+
if (!latestVersion) return;
|
|
750
|
+
|
|
751
|
+
// Compare versions (simple semver comparison)
|
|
752
|
+
if (latestVersion !== installedVersion && isNewerVersion(latestVersion, installedVersion)) {
|
|
753
|
+
ctx.ui.notify(
|
|
754
|
+
`\n` +
|
|
755
|
+
` Update Available\n` +
|
|
756
|
+
` New version ${latestVersion} is available (installed: ${installedVersion}).\n` +
|
|
757
|
+
` Run: pi update\n`,
|
|
758
|
+
"info",
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
} catch {
|
|
762
|
+
// Silently ignore — network errors, offline, etc.
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Compare two semver version strings. Returns true if `a` is newer than `b`.
|
|
768
|
+
*/
|
|
769
|
+
function isNewerVersion(a: string, b: string): boolean {
|
|
770
|
+
const pa = a.split(".").map(Number);
|
|
771
|
+
const pb = b.split(".").map(Number);
|
|
772
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
773
|
+
const na = pa[i] || 0;
|
|
774
|
+
const nb = pb[i] || 0;
|
|
775
|
+
if (na > nb) return true;
|
|
776
|
+
if (na < nb) return false;
|
|
777
|
+
}
|
|
778
|
+
return false;
|
|
779
|
+
}
|
|
780
|
+
|