@ferris1225/pi-subagents 4.2.4 → 4.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +352 -346
- package/agents/executor.md +53 -53
- package/package.json +53 -55
- package/src/dispatch.ts +552 -541
- package/src/durable.ts +517 -438
- package/src/monitor.ts +1 -1
- package/src/prompt.ts +69 -69
- package/src/rpc-run.ts +9 -15
- package/src/runtime.ts +315 -312
- package/src/thread-lifecycle.ts +1341 -1320
- package/src/tools.ts +384 -384
package/src/thread-lifecycle.ts
CHANGED
|
@@ -1,1320 +1,1341 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Stable logical-thread generation lifecycle for background sub-agents.
|
|
3
|
-
*
|
|
4
|
-
* Dispatch owns tool policy and role briefs; this module owns one
|
|
5
|
-
* stable parent generation end to end: managed-repository lane use,
|
|
6
|
-
* worktree setup/finalization, queue/process ownership,
|
|
7
|
-
* retained-session resume, and guarded one-time terminal publication.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import { existsSync } from "node:fs";
|
|
12
|
-
import { rm } from "node:fs/promises";
|
|
13
|
-
import { realpath } from "node:fs/promises";
|
|
14
|
-
import { join, resolve, dirname } from "node:path";
|
|
15
|
-
import {
|
|
16
|
-
discoverAgents,
|
|
17
|
-
isWriteCapableAgent,
|
|
18
|
-
resolveAgentTools,
|
|
19
|
-
type AgentConfig,
|
|
20
|
-
} from "./agents.ts";
|
|
21
|
-
import { type CompletionMessageItem } from "./completion.ts";
|
|
22
|
-
import {
|
|
23
|
-
DEFAULT_THINKING_LEVEL,
|
|
24
|
-
loadConfig,
|
|
25
|
-
type SubagentsConfig,
|
|
26
|
-
type ThinkingLevel,
|
|
27
|
-
} from "./config.ts";
|
|
28
|
-
import {
|
|
29
|
-
isCurrentBoot,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
import {
|
|
57
|
-
import
|
|
58
|
-
import {
|
|
59
|
-
import {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
type
|
|
71
|
-
type
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
type
|
|
87
|
-
type
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
const
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
runtime.
|
|
208
|
-
|
|
209
|
-
thread.
|
|
210
|
-
thread.
|
|
211
|
-
thread.
|
|
212
|
-
thread.
|
|
213
|
-
thread.
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const
|
|
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
|
-
const
|
|
395
|
-
|
|
396
|
-
const
|
|
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
|
-
const
|
|
431
|
-
const
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
thread.
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
thread.
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
thread.
|
|
496
|
-
thread.
|
|
497
|
-
|
|
498
|
-
thread =
|
|
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
|
-
const
|
|
653
|
-
thread.
|
|
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
|
-
runtime.
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
thread.
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
//
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
}
|
|
959
|
-
};
|
|
960
|
-
|
|
961
|
-
const
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
if (
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
thread.
|
|
1032
|
-
thread.
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
}
|
|
1120
|
-
return
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
thread.
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
worktree
|
|
1160
|
-
|
|
1161
|
-
)
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
if (
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
//
|
|
1235
|
-
//
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
const
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
:
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Stable logical-thread generation lifecycle for background sub-agents.
|
|
3
|
+
*
|
|
4
|
+
* Dispatch owns tool policy and role briefs; this module owns one
|
|
5
|
+
* stable parent generation end to end: managed-repository lane use,
|
|
6
|
+
* worktree setup/finalization, queue/process ownership,
|
|
7
|
+
* retained-session resume, and guarded one-time terminal publication.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { rm } from "node:fs/promises";
|
|
13
|
+
import { realpath } from "node:fs/promises";
|
|
14
|
+
import { join, resolve, dirname } from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
discoverAgents,
|
|
17
|
+
isWriteCapableAgent,
|
|
18
|
+
resolveAgentTools,
|
|
19
|
+
type AgentConfig,
|
|
20
|
+
} from "./agents.ts";
|
|
21
|
+
import { type CompletionMessageItem } from "./completion.ts";
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_THINKING_LEVEL,
|
|
24
|
+
loadConfig,
|
|
25
|
+
type SubagentsConfig,
|
|
26
|
+
type ThinkingLevel,
|
|
27
|
+
} from "./config.ts";
|
|
28
|
+
import {
|
|
29
|
+
isCurrentBoot,
|
|
30
|
+
migrateLegacyThreadsManifest,
|
|
31
|
+
readThreadRecords,
|
|
32
|
+
referencedDurablePaths,
|
|
33
|
+
removeThreadRecord,
|
|
34
|
+
pruneStaleProjectRoots,
|
|
35
|
+
pruneThreadRecords,
|
|
36
|
+
restoredResultFromSummary,
|
|
37
|
+
threadRecordFromThread,
|
|
38
|
+
ThreadRecord,
|
|
39
|
+
upsertThreadRecord,
|
|
40
|
+
} from "./durable.ts";
|
|
41
|
+
import {
|
|
42
|
+
dispatchFailedResult,
|
|
43
|
+
failedStartResult,
|
|
44
|
+
formatCompletionBlock,
|
|
45
|
+
modelLevelTakeoverNote,
|
|
46
|
+
queuedResult,
|
|
47
|
+
} from "./format.ts";
|
|
48
|
+
import {
|
|
49
|
+
availableModelsInScope,
|
|
50
|
+
currentModelRef,
|
|
51
|
+
findModelByRef,
|
|
52
|
+
modelRef,
|
|
53
|
+
resolveAgentModelRoute,
|
|
54
|
+
resolveThinkingLevel,
|
|
55
|
+
} from "./models.ts";
|
|
56
|
+
import { monitor } from "./monitor.ts";
|
|
57
|
+
import { persistRecoveryRecords, recoveryRecordFromFinalization } from "./recovery.ts";
|
|
58
|
+
import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts";
|
|
59
|
+
import { forkRetainedSession } from "./session-fork.ts";
|
|
60
|
+
import {
|
|
61
|
+
buildResumePrompt,
|
|
62
|
+
getProjectRoot,
|
|
63
|
+
PROJECT_ROOTS_DIR_NAME,
|
|
64
|
+
RpcRunControl,
|
|
65
|
+
isFailedResult,
|
|
66
|
+
isModelLevelFailure,
|
|
67
|
+
runSingleAgentWithMainFallback,
|
|
68
|
+
sessionExists,
|
|
69
|
+
sweepProjectResultArtifacts,
|
|
70
|
+
type SingleResult,
|
|
71
|
+
type SubagentDetails,
|
|
72
|
+
type SubagentLiveEvent,
|
|
73
|
+
} from "./spawn.ts";
|
|
74
|
+
import {
|
|
75
|
+
isProcessAlive,
|
|
76
|
+
killProcessTree,
|
|
77
|
+
sweepProjectDurableDirs,
|
|
78
|
+
sweepProjectTempDirs,
|
|
79
|
+
} from "./temp-hygiene.ts";
|
|
80
|
+
import {
|
|
81
|
+
createWorktreeIsolation,
|
|
82
|
+
isPathInside,
|
|
83
|
+
resolveRepositoryRoot,
|
|
84
|
+
restoreWorktreeIsolation,
|
|
85
|
+
worktreeGroupId,
|
|
86
|
+
type IsolationMode,
|
|
87
|
+
type WorktreeFinalization,
|
|
88
|
+
type WorktreeIsolation,
|
|
89
|
+
} from "./worktree.ts";
|
|
90
|
+
|
|
91
|
+
/** Control operations must never wait forever on a settling generation: the
|
|
92
|
+
* queue task can legitimately spend minutes in worktree finalization (bounded
|
|
93
|
+
* per-Git-command timeouts) or wait behind the managed repository lane. After
|
|
94
|
+
* this deadline the control path owns the lifecycle synchronously and proceeds
|
|
95
|
+
* while the stuck tail settles silently in the background. */
|
|
96
|
+
export const CONTROL_QUIESCE_TIMEOUT_MS = 20_000;
|
|
97
|
+
|
|
98
|
+
/** Resolve true when the promise settles, or false after the bounded deadline. */
|
|
99
|
+
export function quiesced(promise: Promise<unknown>, timeoutMs: number = CONTROL_QUIESCE_TIMEOUT_MS): Promise<boolean> {
|
|
100
|
+
return Promise.race([
|
|
101
|
+
promise.then(() => true, () => true),
|
|
102
|
+
new Promise<boolean>((resolve) => {
|
|
103
|
+
const timer = setTimeout(() => resolve(false), timeoutMs);
|
|
104
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
105
|
+
}),
|
|
106
|
+
]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const managedRepositoryRootTails = new Map<string, Promise<void>>();
|
|
110
|
+
|
|
111
|
+
async function canonicalManagedRepositoryRoot(cwd: string): Promise<string> {
|
|
112
|
+
try {
|
|
113
|
+
// Repository identity does not depend on HEAD: empty repositories must
|
|
114
|
+
// serialize root and nested cwd requests under the same lane too.
|
|
115
|
+
return await resolveRepositoryRoot(cwd);
|
|
116
|
+
} catch {
|
|
117
|
+
try {
|
|
118
|
+
return await realpath(resolve(cwd));
|
|
119
|
+
} catch {
|
|
120
|
+
return resolve(cwd);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Run one operation under the canonical original-repository lane.
|
|
126
|
+
*
|
|
127
|
+
* Shared write-capable generations use the abortable overload for their whole
|
|
128
|
+
* run. Isolated generations use the non-abortable overload only for their
|
|
129
|
+
* final worktree apply, so model work remains parallel while the original
|
|
130
|
+
* checkout mutation cannot race a shared writer.
|
|
131
|
+
*/
|
|
132
|
+
export async function runInManagedRepositoryLane<T>(
|
|
133
|
+
cwd: string,
|
|
134
|
+
task: () => Promise<T>,
|
|
135
|
+
): Promise<T>;
|
|
136
|
+
export async function runInManagedRepositoryLane<T>(
|
|
137
|
+
cwd: string,
|
|
138
|
+
task: () => Promise<T>,
|
|
139
|
+
signal: AbortSignal,
|
|
140
|
+
): Promise<T | undefined>;
|
|
141
|
+
export async function runInManagedRepositoryLane<T>(
|
|
142
|
+
cwd: string,
|
|
143
|
+
task: () => Promise<T>,
|
|
144
|
+
signal?: AbortSignal,
|
|
145
|
+
): Promise<T | undefined> {
|
|
146
|
+
if (signal?.aborted) return undefined;
|
|
147
|
+
const root = await canonicalManagedRepositoryRoot(cwd);
|
|
148
|
+
const key = process.platform === "win32" ? root.toLowerCase() : root;
|
|
149
|
+
const previous = managedRepositoryRootTails.get(key) ?? Promise.resolve();
|
|
150
|
+
let release!: () => void;
|
|
151
|
+
const gate = new Promise<void>((resolveGate) => {
|
|
152
|
+
release = resolveGate;
|
|
153
|
+
});
|
|
154
|
+
const tail = previous.catch(() => undefined).then(() => gate);
|
|
155
|
+
managedRepositoryRootTails.set(key, tail);
|
|
156
|
+
let onAbort: (() => void) | undefined;
|
|
157
|
+
try {
|
|
158
|
+
if (signal) {
|
|
159
|
+
await Promise.race([
|
|
160
|
+
previous.catch(() => undefined),
|
|
161
|
+
new Promise<void>((resolveAborted) => {
|
|
162
|
+
if (signal.aborted) resolveAborted();
|
|
163
|
+
else {
|
|
164
|
+
onAbort = resolveAborted;
|
|
165
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
166
|
+
}
|
|
167
|
+
}),
|
|
168
|
+
]);
|
|
169
|
+
} else {
|
|
170
|
+
await previous.catch(() => undefined);
|
|
171
|
+
}
|
|
172
|
+
if (signal?.aborted) return undefined;
|
|
173
|
+
return await task();
|
|
174
|
+
} finally {
|
|
175
|
+
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
176
|
+
release();
|
|
177
|
+
// An aborted waiter may finish before the prior owner. Keep its chained
|
|
178
|
+
// tail installed until that owner also settles, otherwise a newcomer could
|
|
179
|
+
// observe an empty map and race the still-running workflow.
|
|
180
|
+
void tail.then(() => {
|
|
181
|
+
if (managedRepositoryRootTails.get(key) === tail) managedRepositoryRootTails.delete(key);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Track resume setup that has claimed a thread but has not yet enqueued
|
|
187
|
+
* its next generation. Shutdown invalidates these claims and waits for cleanup. */
|
|
188
|
+
export function beginRuntimePreflight(runtime: SubagentRuntime): () => void {
|
|
189
|
+
let resolvePreflight!: () => void;
|
|
190
|
+
const preflight = new Promise<void>((resolve) => {
|
|
191
|
+
resolvePreflight = resolve;
|
|
192
|
+
});
|
|
193
|
+
runtime.preflightOperations.add(preflight);
|
|
194
|
+
return () => {
|
|
195
|
+
runtime.preflightOperations.delete(preflight);
|
|
196
|
+
resolvePreflight();
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Synchronous CAS used by lifecycle controls across their async preflight. */
|
|
201
|
+
export function ownsResumeReservation(
|
|
202
|
+
runtime: SubagentRuntime,
|
|
203
|
+
thread: SubagentThread,
|
|
204
|
+
reservation: { version: number; generation: number; sessionId?: string; sessionDir?: string },
|
|
205
|
+
): boolean {
|
|
206
|
+
return (
|
|
207
|
+
runtime.sessionActive &&
|
|
208
|
+
runtime.threads.get(thread.id) === thread &&
|
|
209
|
+
!thread.retired &&
|
|
210
|
+
thread.lifecycleOperation === "resume" &&
|
|
211
|
+
thread.lifecycleVersion === reservation.version &&
|
|
212
|
+
thread.generation === reservation.generation &&
|
|
213
|
+
thread.sessionId === reservation.sessionId &&
|
|
214
|
+
thread.sessionDir === reservation.sessionDir
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Fire-and-forget durable checkpoint. Parked threads stay resumable across
|
|
219
|
+
* reloads; a settled thread drops its record so the manifest only exists
|
|
220
|
+
* while unfinished work needs it. The live session keeps working when the
|
|
221
|
+
* manifest is unwritable; only cross-reload resume is degraded. */
|
|
222
|
+
export function persistThreadCheckpoint(
|
|
223
|
+
runtime: SubagentRuntime,
|
|
224
|
+
thread: SubagentThread,
|
|
225
|
+
state: "parked" | "completed" | "failed",
|
|
226
|
+
): void {
|
|
227
|
+
const write = state === "parked"
|
|
228
|
+
? upsertThreadRecord(runtime.configPath, threadRecordFromThread(thread, state))
|
|
229
|
+
: removeThreadRecord(runtime.configPath, thread.id, thread.cwd);
|
|
230
|
+
void write.catch(() => undefined);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const WORKTREE_ISOLATION_INSTRUCTIONS =
|
|
234
|
+
"You are running in a temporary detached Git worktree. Work only in the current cwd; do not create another worktree or manually copy/apply changes to the original checkout. The parent dispatcher will integrate your tracked, deleted, and untracked changes when this thread finally settles.";
|
|
235
|
+
|
|
236
|
+
export function withWorktreeSystemPrompt(agent: AgentConfig): AgentConfig {
|
|
237
|
+
return {
|
|
238
|
+
...agent,
|
|
239
|
+
systemPrompt: `${agent.systemPrompt.trimEnd()}\n\n${WORKTREE_ISOLATION_INSTRUCTIONS}`.trim(),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function isWorktreeCapableAgent(agent: AgentConfig): boolean {
|
|
244
|
+
return isWriteCapableAgent(agent);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface DispatchEnvironment {
|
|
248
|
+
ctx: ExtensionContext;
|
|
249
|
+
config: SubagentsConfig;
|
|
250
|
+
agents: AgentConfig[];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export interface SessionSeed {
|
|
254
|
+
sessionId?: string;
|
|
255
|
+
sessionDir?: string;
|
|
256
|
+
prompt?: string;
|
|
257
|
+
worktree?: WorktreeIsolation;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface ResumeReservation {
|
|
261
|
+
version: number;
|
|
262
|
+
generation: number;
|
|
263
|
+
sessionId?: string;
|
|
264
|
+
sessionDir?: string;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The dispatcher's full internal entry point; the public tool surface only
|
|
268
|
+
* uses the first four parameters plus the per-call `thinking` request. */
|
|
269
|
+
export interface StartBackgroundOptions {
|
|
270
|
+
/** Reasoning strength this dispatch asked for; the user's manual
|
|
271
|
+
* `/subagents-setup` choice still outranks it (see resolveDispatchModelRoute). */
|
|
272
|
+
thinking?: ThinkingLevel;
|
|
273
|
+
/** Resume path only: the thread whose retained context continues. */
|
|
274
|
+
existingThread?: SubagentThread;
|
|
275
|
+
appendedObjectiveOnResume?: boolean;
|
|
276
|
+
environment?: DispatchEnvironment;
|
|
277
|
+
seed?: SessionSeed;
|
|
278
|
+
resumeReservation?: ResumeReservation;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export type StartBackgroundInternal = (
|
|
282
|
+
agentName: string,
|
|
283
|
+
task: string,
|
|
284
|
+
cwd: string | undefined,
|
|
285
|
+
isolation?: IsolationMode,
|
|
286
|
+
options?: StartBackgroundOptions,
|
|
287
|
+
) => Promise<SingleResult>;
|
|
288
|
+
|
|
289
|
+
export interface ThreadLifecycleDeps {
|
|
290
|
+
runtime: SubagentRuntime;
|
|
291
|
+
/** Fallback context when a control caller supplies none; restored threads
|
|
292
|
+
* install without one and rely on the per-call context. */
|
|
293
|
+
runCtx?: ExtensionContext;
|
|
294
|
+
/** Fresh dispatch passes the live dispatcher; restored threads resolve it
|
|
295
|
+
* from the runtime at call time so they never pin a stale closure. */
|
|
296
|
+
startBackground: StartBackgroundInternal;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
interface DispatchModelRoute {
|
|
300
|
+
agent: AgentConfig;
|
|
301
|
+
mainFallbackRef?: string;
|
|
302
|
+
thinkingLevel: ThinkingLevel;
|
|
303
|
+
thinkingLevelForModel: (ref?: string) => ThinkingLevel;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function resolveDispatchModelRoute(
|
|
307
|
+
agent: AgentConfig,
|
|
308
|
+
config: SubagentsConfig,
|
|
309
|
+
ctx: ExtensionContext,
|
|
310
|
+
requestedThinking?: ThinkingLevel,
|
|
311
|
+
): DispatchModelRoute {
|
|
312
|
+
const availableModels = availableModelsInScope(ctx);
|
|
313
|
+
const mainRef = currentModelRef(ctx);
|
|
314
|
+
const route = resolveAgentModelRoute({
|
|
315
|
+
selectedRef: config.agentModels[agent.name],
|
|
316
|
+
mainRef,
|
|
317
|
+
availableRefs: availableModels.map(modelRef),
|
|
318
|
+
});
|
|
319
|
+
// agentThinkingLevels only holds a level the user picked by hand in
|
|
320
|
+
// /subagents-setup (missing = Auto), so that deliberate setting outranks the
|
|
321
|
+
// dispatching model's per-call guess, which in turn outranks frontmatter.
|
|
322
|
+
const preferred =
|
|
323
|
+
config.agentThinkingLevels[agent.name] ?? requestedThinking ?? agent.thinking ?? DEFAULT_THINKING_LEVEL;
|
|
324
|
+
const thinkingLevelForModel = (ref?: string): ThinkingLevel => {
|
|
325
|
+
const model = ref === mainRef && ctx.model
|
|
326
|
+
? ctx.model
|
|
327
|
+
: findModelByRef(availableModels, ref);
|
|
328
|
+
return resolveThinkingLevel(model, preferred);
|
|
329
|
+
};
|
|
330
|
+
return {
|
|
331
|
+
agent: { ...agent, model: route.primaryRef },
|
|
332
|
+
mainFallbackRef: route.mainFallbackRef,
|
|
333
|
+
thinkingLevel: thinkingLevelForModel(route.primaryRef),
|
|
334
|
+
thinkingLevelForModel,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
interface BackgroundDispatcherOptions {
|
|
339
|
+
runtime: SubagentRuntime;
|
|
340
|
+
/** Live dispatch environment; resolved lazily so control operations work
|
|
341
|
+
* before the first dispatch of a process (restored threads). */
|
|
342
|
+
getEnvironment: () => DispatchEnvironment;
|
|
343
|
+
finishRun: (
|
|
344
|
+
runId: number,
|
|
345
|
+
status: "done" | "failed",
|
|
346
|
+
opts?: { silent?: boolean },
|
|
347
|
+
) => void;
|
|
348
|
+
makeLiveHandler: (
|
|
349
|
+
runId: number,
|
|
350
|
+
generation?: number,
|
|
351
|
+
) => (event: SubagentLiveEvent) => void;
|
|
352
|
+
makeDetails: (
|
|
353
|
+
mode: "single" | "parallel",
|
|
354
|
+
background?: boolean,
|
|
355
|
+
) => (results: SingleResult[]) => SubagentDetails;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function createBackgroundDispatcher(options: BackgroundDispatcherOptions): StartBackgroundInternal {
|
|
359
|
+
const {
|
|
360
|
+
runtime,
|
|
361
|
+
getEnvironment,
|
|
362
|
+
finishRun,
|
|
363
|
+
makeLiveHandler,
|
|
364
|
+
makeDetails,
|
|
365
|
+
} = options;
|
|
366
|
+
|
|
367
|
+
const startBackground: StartBackgroundInternal = async (
|
|
368
|
+
agentName: string,
|
|
369
|
+
task: string,
|
|
370
|
+
cwd: string | undefined,
|
|
371
|
+
isolation: IsolationMode = "shared",
|
|
372
|
+
startOptions: StartBackgroundOptions = {},
|
|
373
|
+
): Promise<SingleResult> => {
|
|
374
|
+
const {
|
|
375
|
+
thinking,
|
|
376
|
+
existingThread,
|
|
377
|
+
appendedObjectiveOnResume = false,
|
|
378
|
+
environment,
|
|
379
|
+
seed,
|
|
380
|
+
resumeReservation,
|
|
381
|
+
} = startOptions;
|
|
382
|
+
if (!runtime.sessionActive) {
|
|
383
|
+
return failedStartResult(agentName, task, "Parent session shut down before this subagent generation could start.");
|
|
384
|
+
}
|
|
385
|
+
if (existingThread && (!resumeReservation || !ownsResumeReservation(runtime, existingThread, resumeReservation))) {
|
|
386
|
+
return failedStartResult(agentName, task, `Run #${existingThread.id} changed while resume was preparing; no new generation was started.`);
|
|
387
|
+
}
|
|
388
|
+
const baseEnvironment = environment ?? getEnvironment();
|
|
389
|
+
const runCtx = baseEnvironment.ctx;
|
|
390
|
+
const runConfig = baseEnvironment.config;
|
|
391
|
+
const runAgents = baseEnvironment.agents;
|
|
392
|
+
const discoveredAgent = runAgents.find((candidate) => candidate.name === agentName);
|
|
393
|
+
if (!discoveredAgent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
|
|
394
|
+
const resolveLiveAgentTools = (candidate: AgentConfig): AgentConfig =>
|
|
395
|
+
resolveAgentTools({ ...candidate, tools: discoveredAgent.tools }, runtime.getActiveTools());
|
|
396
|
+
const agent = resolveLiveAgentTools(discoveredAgent);
|
|
397
|
+
if (isolation === "worktree" && !isWorktreeCapableAgent(agent)) {
|
|
398
|
+
return {
|
|
399
|
+
...failedStartResult(agentName, task, `Agent "${agentName}" is read-only; worktree isolation is available only to write-capable agents such as executor.`),
|
|
400
|
+
isolation,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const originalCwd = resolve(cwd ?? runCtx.cwd);
|
|
405
|
+
const projectRoot = getProjectRoot(runtime.configPath, originalCwd);
|
|
406
|
+
const sessionsRoot = join(projectRoot, "sessions");
|
|
407
|
+
const worktreesRoot = join(projectRoot, "worktrees");
|
|
408
|
+
const scratchRoot = join(projectRoot, "tmp");
|
|
409
|
+
const previousWorktree = existingThread?.worktree;
|
|
410
|
+
let worktree = seed?.worktree ?? previousWorktree;
|
|
411
|
+
if (isolation === "worktree") {
|
|
412
|
+
if (worktree && worktree.state !== "active") {
|
|
413
|
+
return {
|
|
414
|
+
...failedStartResult(agentName, task, `Run #${existingThread?.id ?? "?"} has no active continuation worktree.`),
|
|
415
|
+
isolation,
|
|
416
|
+
integrationStatus: worktree.state === "finalizing" ? "pending" : worktree.state,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
if (!worktree) {
|
|
420
|
+
try {
|
|
421
|
+
worktree = await createWorktreeIsolation(originalCwd, { tempBaseDir: worktreesRoot });
|
|
422
|
+
} catch (error) {
|
|
423
|
+
return {
|
|
424
|
+
...failedStartResult(agentName, task, error instanceof Error ? error.message : String(error)),
|
|
425
|
+
isolation,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const executionCwd = worktree?.cwd ?? originalCwd;
|
|
431
|
+
const worktreeGroup = worktree ? worktreeGroupId(worktree) : undefined;
|
|
432
|
+
// A resume re-runs at the strength its dispatch asked for, so the retained
|
|
433
|
+
// request survives generations (and, via the durable record, restarts).
|
|
434
|
+
const requestedThinking = thinking ?? existingThread?.requestedThinkingLevel;
|
|
435
|
+
const resolvedRoute = resolveDispatchModelRoute(agent, runConfig, runCtx, requestedThinking);
|
|
436
|
+
// Isolation is a persistent system-level invariant, not a one-shot task
|
|
437
|
+
// prefix: resumes and main-model
|
|
438
|
+
// handoffs all keep the same worktree boundary.
|
|
439
|
+
const route = isolation === "worktree"
|
|
440
|
+
? { ...resolvedRoute, agent: withWorktreeSystemPrompt(resolvedRoute.agent) }
|
|
441
|
+
: resolvedRoute;
|
|
442
|
+
const thinkingLevel = route.thinkingLevel;
|
|
443
|
+
const priorTask = existingThread?.task;
|
|
444
|
+
const priorSessionId = seed?.sessionId ?? existingThread?.sessionId;
|
|
445
|
+
const priorSessionDir = seed?.sessionDir ?? existingThread?.sessionDir;
|
|
446
|
+
const runId = existingThread?.id ?? monitor.addRun(agent.name, task, route.agent.model, thinkingLevel, {
|
|
447
|
+
isolation,
|
|
448
|
+
...(worktreeGroup ? { worktreeId: worktreeGroup } : {}),
|
|
449
|
+
});
|
|
450
|
+
const generation = (existingThread?.generation ?? 0) + 1;
|
|
451
|
+
const pending: SingleResult = {
|
|
452
|
+
...queuedResult(route.agent, task, thinkingLevel),
|
|
453
|
+
runId,
|
|
454
|
+
projectCwd: originalCwd,
|
|
455
|
+
isolation,
|
|
456
|
+
...(isolation === "worktree" ? { integrationStatus: "pending" as const } : {}),
|
|
457
|
+
...(seed?.sessionId && seed.sessionDir
|
|
458
|
+
? { sessionId: seed.sessionId, sessionDir: seed.sessionDir }
|
|
459
|
+
: {}),
|
|
460
|
+
};
|
|
461
|
+
if (existingThread) {
|
|
462
|
+
monitor.restartRun(runId, agent.name, task, route.agent.model, thinkingLevel, isolation, {
|
|
463
|
+
elapsedMs: existingThread.elapsedMs,
|
|
464
|
+
continuationKind: appendedObjectiveOnResume ? "resume-appended" : "resume-retained",
|
|
465
|
+
...(worktreeGroup ? { worktreeId: worktreeGroup } : {}),
|
|
466
|
+
});
|
|
467
|
+
runtime.settledRuns.delete(runId);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
let thread!: SubagentThread;
|
|
471
|
+
const control = new RpcRunControl(task, generation, (phase) => {
|
|
472
|
+
if (runtime.threads.get(runId)?.generation !== generation || phase === "settled") return;
|
|
473
|
+
const state: ThreadState =
|
|
474
|
+
phase === "queued" || phase === "starting"
|
|
475
|
+
? "queued"
|
|
476
|
+
: phase === "interrupting"
|
|
477
|
+
? "interrupting"
|
|
478
|
+
: phase === "stopped"
|
|
479
|
+
? "stopped"
|
|
480
|
+
: "running";
|
|
481
|
+
thread.state = state;
|
|
482
|
+
if (state === "queued") monitor.setStatus(runId, "queued");
|
|
483
|
+
else if (state === "interrupting") monitor.setStatus(runId, "interrupting");
|
|
484
|
+
else if (state === "running") monitor.setStatus(runId, "running");
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
if (existingThread) {
|
|
489
|
+
thread = existingThread;
|
|
490
|
+
thread.generation = generation;
|
|
491
|
+
thread.agentName = agent.name;
|
|
492
|
+
thread.task = task;
|
|
493
|
+
thread.cwd = originalCwd;
|
|
494
|
+
thread.executionCwd = executionCwd;
|
|
495
|
+
thread.thinkingLevel = thinkingLevel;
|
|
496
|
+
thread.requestedThinkingLevel = requestedThinking;
|
|
497
|
+
thread.isolation = isolation;
|
|
498
|
+
thread.worktree = worktree;
|
|
499
|
+
thread.state = "queued";
|
|
500
|
+
thread.control = control;
|
|
501
|
+
// A newly admitted generation owns no output yet. Keeping the prior
|
|
502
|
+
// generation here would make a queued stop publish stale task,
|
|
503
|
+
// session metadata as this generation's partial.
|
|
504
|
+
thread.lastResult = undefined;
|
|
505
|
+
if (seed?.sessionId && seed.sessionDir) {
|
|
506
|
+
thread.sessionId = seed.sessionId;
|
|
507
|
+
thread.sessionDir = seed.sessionDir;
|
|
508
|
+
}
|
|
509
|
+
thread.retireOnSettle = false;
|
|
510
|
+
thread.isolationFailureNotified = false;
|
|
511
|
+
} else {
|
|
512
|
+
thread = {
|
|
513
|
+
id: runId,
|
|
514
|
+
generation,
|
|
515
|
+
agentName: agent.name,
|
|
516
|
+
task,
|
|
517
|
+
cwd: originalCwd,
|
|
518
|
+
executionCwd,
|
|
519
|
+
thinkingLevel,
|
|
520
|
+
requestedThinkingLevel: requestedThinking,
|
|
521
|
+
isolation,
|
|
522
|
+
worktree,
|
|
523
|
+
state: "queued",
|
|
524
|
+
control,
|
|
525
|
+
generationCompletion: Promise.resolve(),
|
|
526
|
+
lifecycleVersion: 0,
|
|
527
|
+
elapsedMs: 0,
|
|
528
|
+
sessionId: seed?.sessionId,
|
|
529
|
+
sessionDir: seed?.sessionDir,
|
|
530
|
+
resume: async () => failedStartResult(agent.name, task, "Thread resume was not initialized."),
|
|
531
|
+
finalizeIsolation: async () => undefined,
|
|
532
|
+
};
|
|
533
|
+
runtime.threads.set(runId, thread);
|
|
534
|
+
}
|
|
535
|
+
installThreadLifecycle(thread, {
|
|
536
|
+
runtime,
|
|
537
|
+
runCtx,
|
|
538
|
+
startBackground: (...args) => startBackground(...args),
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
const onLive = makeLiveHandler(runId, generation);
|
|
542
|
+
// Shared write-capable runs serialize on the repository lane so their
|
|
543
|
+
// edits cannot race; the lane wait releases the process slot because it
|
|
544
|
+
// is write serialization, not pool pacing.
|
|
545
|
+
const reserveManagedLane = isolation === "shared" && isWriteCapableAgent(agent);
|
|
546
|
+
const runGeneration = async (backgroundSignal: AbortSignal, controller: AbortController): Promise<void> => {
|
|
547
|
+
if (runtime.threads.get(runId)?.generation !== generation) return;
|
|
548
|
+
// The generation body owns a process slot from here (a lane wait, if
|
|
549
|
+
// any, was granted above). Recording the transition synchronously keeps
|
|
550
|
+
// every "queued" surface truthful: only runs still pending in the pool
|
|
551
|
+
// or behind the lane report a wait.
|
|
552
|
+
monitor.setWaitReason(runId, "starting");
|
|
553
|
+
// Model/thinking config may have changed while this generation sat
|
|
554
|
+
// queued behind the concurrency limit. Re-resolve the route at actual
|
|
555
|
+
// start so /subagents-setup edits apply to not-yet-started runs.
|
|
556
|
+
let activeRoute = route;
|
|
557
|
+
let activeIdleTimeoutMs = runConfig.idleTimeoutSec * 1000;
|
|
558
|
+
try {
|
|
559
|
+
const startConfig = await loadConfig(runtime.configPath);
|
|
560
|
+
const resolvedStart = resolveDispatchModelRoute(agent, startConfig, runCtx, requestedThinking);
|
|
561
|
+
activeRoute = isolation === "worktree"
|
|
562
|
+
? { ...resolvedStart, agent: withWorktreeSystemPrompt(resolvedStart.agent) }
|
|
563
|
+
: resolvedStart;
|
|
564
|
+
activeIdleTimeoutMs = startConfig.idleTimeoutSec * 1000;
|
|
565
|
+
monitor.setModel(runId, activeRoute.agent.model);
|
|
566
|
+
monitor.setThinking(runId, activeRoute.thinkingLevel);
|
|
567
|
+
} catch {
|
|
568
|
+
/* keep the dispatch-time route when fresh config is unavailable */
|
|
569
|
+
}
|
|
570
|
+
let result: SingleResult;
|
|
571
|
+
try {
|
|
572
|
+
result = await runSingleAgentWithMainFallback(
|
|
573
|
+
{
|
|
574
|
+
defaultCwd: executionCwd,
|
|
575
|
+
agent: activeRoute.agent,
|
|
576
|
+
resolveAgentForAttempt: resolveLiveAgentTools,
|
|
577
|
+
agentName,
|
|
578
|
+
task,
|
|
579
|
+
cwd: executionCwd,
|
|
580
|
+
thinkingLevel: activeRoute.thinkingLevel,
|
|
581
|
+
thinkingLevelForModel: activeRoute.thinkingLevelForModel,
|
|
582
|
+
signal: backgroundSignal,
|
|
583
|
+
onLive,
|
|
584
|
+
control,
|
|
585
|
+
makeDetails: makeDetails("single", true),
|
|
586
|
+
idleTimeoutMs: activeIdleTimeoutMs,
|
|
587
|
+
sessionRoot: sessionsRoot,
|
|
588
|
+
scratchRoot,
|
|
589
|
+
...(priorSessionId && priorSessionDir
|
|
590
|
+
? {
|
|
591
|
+
sessionId: priorSessionId,
|
|
592
|
+
sessionDir: priorSessionDir,
|
|
593
|
+
stdinText: seed?.prompt ?? (appendedObjectiveOnResume
|
|
594
|
+
? task
|
|
595
|
+
: buildResumePrompt(priorTask ?? task, "the retained thread was resumed")),
|
|
596
|
+
}
|
|
597
|
+
: {}),
|
|
598
|
+
},
|
|
599
|
+
activeRoute.mainFallbackRef,
|
|
600
|
+
);
|
|
601
|
+
} catch (error) {
|
|
602
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
603
|
+
result = {
|
|
604
|
+
...pending,
|
|
605
|
+
task: control.getObjective(),
|
|
606
|
+
exitCode: 1,
|
|
607
|
+
stderr: errorMessage,
|
|
608
|
+
stopReason: backgroundSignal.aborted ? "aborted" : "error",
|
|
609
|
+
errorMessage,
|
|
610
|
+
dispatchFailed: true,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// A stale process/generation may finish after a superseded resume. It owns
|
|
615
|
+
// no monitor mutation, result registration, or completion delivery.
|
|
616
|
+
if (runtime.threads.get(runId)?.generation !== generation) return;
|
|
617
|
+
result.runId = runId;
|
|
618
|
+
result.projectCwd = originalCwd;
|
|
619
|
+
result.isolation = isolation;
|
|
620
|
+
thread.task = result.task;
|
|
621
|
+
thread.sessionId = result.sessionId;
|
|
622
|
+
thread.sessionDir = result.sessionDir;
|
|
623
|
+
thread.lastResult = result;
|
|
624
|
+
runtime.retainSession(result);
|
|
625
|
+
monitor.setModel(runId, result.model, result.modelFallbackFrom);
|
|
626
|
+
monitor.setThinking(runId, result.thinking);
|
|
627
|
+
// Checkpoint the session location durably: a crash or reload before
|
|
628
|
+
// settlement still restores this thread with its retained context.
|
|
629
|
+
if (result.sessionId && result.sessionDir) {
|
|
630
|
+
persistThreadCheckpoint(runtime, thread, "parked");
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const lifecycleInterrupted = (): boolean =>
|
|
634
|
+
thread.lifecycleOperation === "stop" ||
|
|
635
|
+
thread.state === "stopped";
|
|
636
|
+
// Destructive stop owns publication once it has synchronously claimed
|
|
637
|
+
// the lifecycle. Leave the partial result/session on the thread; the
|
|
638
|
+
// stop path waits for this queue task, finalizes isolation, and emits
|
|
639
|
+
// exactly one aborted result.
|
|
640
|
+
if (thread.lifecycleOperation === "stop") return;
|
|
641
|
+
|
|
642
|
+
// A shutdown can win in the microtask gap after the child RPC
|
|
643
|
+
// settles. Never replace the stable top-level session with an
|
|
644
|
+
// aborted partial.
|
|
645
|
+
if (backgroundSignal.aborted || lifecycleInterrupted() || !runtime.sessionActive) return;
|
|
646
|
+
|
|
647
|
+
if (thread.retireOnSettle) runtime.retireThreadSession(thread);
|
|
648
|
+
// Claim terminal settlement synchronously before the first slow await.
|
|
649
|
+
// Park therefore either wins while RPC is still active, or is rejected
|
|
650
|
+
// once settlement owns the generation. Destructive stop may supersede
|
|
651
|
+
// this reservation; publication is revalidated after Git finalization.
|
|
652
|
+
const settlementVersion = ++thread.lifecycleVersion;
|
|
653
|
+
thread.lifecycleOperation = "settle";
|
|
654
|
+
const ownsSettlement = (): boolean =>
|
|
655
|
+
runtime.threads.get(runId) === thread &&
|
|
656
|
+
thread.generation === generation &&
|
|
657
|
+
thread.lifecycleVersion === settlementVersion &&
|
|
658
|
+
thread.lifecycleOperation === "settle" &&
|
|
659
|
+
!thread.retired;
|
|
660
|
+
try {
|
|
661
|
+
// For isolated writers the apply runs only after the child settled,
|
|
662
|
+
// so this lifecycle owner integrates the complete settled state
|
|
663
|
+
// exactly once under the repository lane.
|
|
664
|
+
await thread.finalizeIsolation(generation, result);
|
|
665
|
+
if (!ownsSettlement()) return;
|
|
666
|
+
|
|
667
|
+
const failed = isFailedResult(result);
|
|
668
|
+
thread.state = failed ? "failed" : "completed";
|
|
669
|
+
// Stamp the terminal monitor state before projecting it. This gives every
|
|
670
|
+
// path a fixed endedAt even when the row is removed immediately.
|
|
671
|
+
monitor.setStatus(runId, failed ? "failed" : "done");
|
|
672
|
+
thread.elapsedMs = monitor.getElapsedMs(runId) ?? thread.elapsedMs;
|
|
673
|
+
// Persist before the sessionActive check: a shutdown that won the
|
|
674
|
+
// lifecycle race must still leave the settled record on disk.
|
|
675
|
+
persistThreadCheckpoint(runtime, thread, failed ? "failed" : "completed");
|
|
676
|
+
if (!runtime.sessionActive || !ownsSettlement()) return;
|
|
677
|
+
|
|
678
|
+
const modelLevel = failed && isModelLevelFailure(result);
|
|
679
|
+
const dispatchFailed = result.dispatchFailed === true;
|
|
680
|
+
const ownedController = thread.queueController;
|
|
681
|
+
if (runtime.runControllers.get(runId) === ownedController) runtime.runControllers.delete(runId);
|
|
682
|
+
thread.queueController = undefined;
|
|
683
|
+
finishRun(
|
|
684
|
+
runId,
|
|
685
|
+
failed ? "failed" : "done",
|
|
686
|
+
modelLevel || dispatchFailed ? { silent: true } : undefined,
|
|
687
|
+
);
|
|
688
|
+
runtime.registerRunResult(runId, result);
|
|
689
|
+
|
|
690
|
+
const completion: CompletionMessageItem = {
|
|
691
|
+
agent: result.agent,
|
|
692
|
+
block: modelLevel
|
|
693
|
+
? `${formatCompletionBlock(result, runConfig.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, result.projectCwd ?? originalCwd) })}\n\n${modelLevelTakeoverNote(result, { runId })}`
|
|
694
|
+
: formatCompletionBlock(result, runConfig.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, result.projectCwd ?? originalCwd) }),
|
|
695
|
+
triggerTurn: true,
|
|
696
|
+
usage: result.usage,
|
|
697
|
+
};
|
|
698
|
+
if (modelLevel) {
|
|
699
|
+
const detail = result.errorMessage?.trim() || "model unavailable or broken";
|
|
700
|
+
runCtx.ui.notify(`✗ ${result.agent} dispatch failed: ${detail} — task handed to the main window`, "error");
|
|
701
|
+
} else if (dispatchFailed) {
|
|
702
|
+
runCtx.ui.notify(`✗ ${result.agent} dispatch failed: ${result.errorMessage ?? "dispatch crashed"}`, "error");
|
|
703
|
+
}
|
|
704
|
+
if (failed) {
|
|
705
|
+
runtime.sendCompletionGroup([completion]);
|
|
706
|
+
runtime.completionBatcher.flush();
|
|
707
|
+
} else {
|
|
708
|
+
runtime.completionBatcher.push(completion);
|
|
709
|
+
}
|
|
710
|
+
} finally {
|
|
711
|
+
if (ownsSettlement()) thread.lifecycleOperation = undefined;
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
const queuedGeneration = reserveManagedLane
|
|
715
|
+
? async (backgroundSignal: AbortSignal, controller: AbortController): Promise<void> => {
|
|
716
|
+
// This task may spend its whole life waiting for the repository
|
|
717
|
+
// lane (every shared write-capable generation serializes on it).
|
|
718
|
+
// Holding a process slot while only waiting let a batch of shared
|
|
719
|
+
// writers park the entire pool and starve independent read-only
|
|
720
|
+
// dispatches that could have started immediately, so the slot is
|
|
721
|
+
// released up front; the lane itself still serializes same-repo
|
|
722
|
+
// writers, and abort/quiesce guarantees are unchanged.
|
|
723
|
+
runtime.backgroundQueue.suspend(controller);
|
|
724
|
+
// A lane wait is write serialization, not slot pacing: the model
|
|
725
|
+
// must never read it as an exhausted pool.
|
|
726
|
+
monitor.setWaitReason(runId, "repository-lane");
|
|
727
|
+
await runInManagedRepositoryLane(
|
|
728
|
+
originalCwd,
|
|
729
|
+
() => runGeneration(backgroundSignal, controller),
|
|
730
|
+
backgroundSignal,
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
: runGeneration;
|
|
734
|
+
const queueController = runtime.backgroundQueue.enqueue(
|
|
735
|
+
queuedGeneration,
|
|
736
|
+
() => {
|
|
737
|
+
if (runtime.threads.get(runId)?.generation !== generation) return;
|
|
738
|
+
// A destructive stop owns publication and may still be finalizing an
|
|
739
|
+
// isolated worktree. Do not expose a terminal monitor state before
|
|
740
|
+
// that owner records the aborted result.
|
|
741
|
+
if (thread.lifecycleOperation === "stop") return;
|
|
742
|
+
runtime.runControllers.delete(runId);
|
|
743
|
+
thread.queueController = undefined;
|
|
744
|
+
thread.state = "stopped";
|
|
745
|
+
monitor.setStatus(runId, "failed");
|
|
746
|
+
if (!runtime.sessionActive) {
|
|
747
|
+
monitor.removeRun(runId);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
finishRun(runId, "failed");
|
|
751
|
+
},
|
|
752
|
+
async (error) => {
|
|
753
|
+
if (runtime.threads.get(runId)?.generation !== generation) return;
|
|
754
|
+
// Queue-level crashes use the same settlement reservation as ordinary
|
|
755
|
+
// results. A concurrent destructive stop may supersede it while slow
|
|
756
|
+
// worktree finalization is running, in which case stop publishes once.
|
|
757
|
+
if (thread.lifecycleOperation === "stop") return;
|
|
758
|
+
const settlementVersion = ++thread.lifecycleVersion;
|
|
759
|
+
thread.lifecycleOperation = "settle";
|
|
760
|
+
const ownsSettlement = (): boolean =>
|
|
761
|
+
runtime.threads.get(runId) === thread &&
|
|
762
|
+
thread.generation === generation &&
|
|
763
|
+
thread.lifecycleVersion === settlementVersion &&
|
|
764
|
+
thread.lifecycleOperation === "settle" &&
|
|
765
|
+
!thread.retired;
|
|
766
|
+
try {
|
|
767
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
768
|
+
const latest = thread.lastResult;
|
|
769
|
+
const crashed: SingleResult = latest
|
|
770
|
+
? {
|
|
771
|
+
...latest,
|
|
772
|
+
runId,
|
|
773
|
+
projectCwd: originalCwd,
|
|
774
|
+
isolation,
|
|
775
|
+
exitCode: 1,
|
|
776
|
+
stopReason: "error",
|
|
777
|
+
errorMessage: `Subagent dispatch failed: ${errorMessage}`,
|
|
778
|
+
dispatchFailed: true,
|
|
779
|
+
}
|
|
780
|
+
: {
|
|
781
|
+
...dispatchFailedResult(route.agent, control.getObjective(), error, thinkingLevel),
|
|
782
|
+
runId,
|
|
783
|
+
projectCwd: originalCwd,
|
|
784
|
+
isolation,
|
|
785
|
+
};
|
|
786
|
+
thread.lastResult = crashed;
|
|
787
|
+
runtime.retainSession(crashed);
|
|
788
|
+
await thread.finalizeIsolation(generation, crashed);
|
|
789
|
+
if (!ownsSettlement()) return;
|
|
790
|
+
thread.state = "failed";
|
|
791
|
+
monitor.setStatus(runId, "failed");
|
|
792
|
+
thread.elapsedMs = monitor.getElapsedMs(runId) ?? thread.elapsedMs;
|
|
793
|
+
persistThreadCheckpoint(runtime, thread, "failed");
|
|
794
|
+
finishRun(runId, "failed", { silent: true });
|
|
795
|
+
runtime.registerRunResult(runId, crashed);
|
|
796
|
+
runtime.runControllers.delete(runId);
|
|
797
|
+
thread.queueController = undefined;
|
|
798
|
+
if (!runtime.sessionActive || !ownsSettlement()) return;
|
|
799
|
+
try {
|
|
800
|
+
runCtx.ui.notify(`✗ ${crashed.agent} dispatch failed: ${crashed.errorMessage}`, "error");
|
|
801
|
+
runtime.sendCompletionGroup([
|
|
802
|
+
{
|
|
803
|
+
agent: crashed.agent,
|
|
804
|
+
block: formatCompletionBlock(crashed, runConfig.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, crashed.projectCwd ?? originalCwd) }),
|
|
805
|
+
triggerTurn: true,
|
|
806
|
+
usage: crashed.usage,
|
|
807
|
+
},
|
|
808
|
+
]);
|
|
809
|
+
runtime.completionBatcher.flush();
|
|
810
|
+
} catch {
|
|
811
|
+
/* a second delivery failure must not throw through the queue */
|
|
812
|
+
}
|
|
813
|
+
} finally {
|
|
814
|
+
if (ownsSettlement()) thread.lifecycleOperation = undefined;
|
|
815
|
+
}
|
|
816
|
+
},
|
|
817
|
+
);
|
|
818
|
+
thread.queueController = queueController;
|
|
819
|
+
thread.generationCompletion = runtime.backgroundQueue.waitForTask(queueController);
|
|
820
|
+
runtime.runControllers.set(runId, queueController);
|
|
821
|
+
return pending;
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
return startBackground;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** Install resume/finalize control surfaces on a thread. Called for
|
|
828
|
+
* every fresh generation (closures refresh with the current dispatch context)
|
|
829
|
+
* and for threads restored from the durable manifest, whose startBackground
|
|
830
|
+
* resolves the live dispatcher at call time. */
|
|
831
|
+
export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifecycleDeps): void {
|
|
832
|
+
const { runtime, startBackground } = deps;
|
|
833
|
+
const runId = thread.id;
|
|
834
|
+
const projectRoot = getProjectRoot(runtime.configPath, thread.cwd);
|
|
835
|
+
const sessionsRoot = join(projectRoot, "sessions");
|
|
836
|
+
const worktreesRoot = join(projectRoot, "worktrees");
|
|
837
|
+
|
|
838
|
+
thread.notifyIsolationFailure = (finalization) => {
|
|
839
|
+
const paths = [finalization.worktreePath, finalization.patchPath].filter(Boolean).join(" · ");
|
|
840
|
+
deps.runCtx?.ui.notify(
|
|
841
|
+
`✗ ${thread.agentName} worktree ${finalization.integrated ? "cleanup" : "integration"} failed${paths ? ` · retained ${paths}` : ""}: ${finalization.error ?? "unknown Git integration error"}`,
|
|
842
|
+
"error",
|
|
843
|
+
);
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
const generationWorktree = thread.worktree;
|
|
847
|
+
let generationFinalization: Promise<WorktreeFinalization> | undefined;
|
|
848
|
+
thread.finalizeIsolation = async (
|
|
849
|
+
expectedGeneration: number,
|
|
850
|
+
result?: SingleResult,
|
|
851
|
+
): Promise<WorktreeFinalization | undefined> => {
|
|
852
|
+
if (thread.isolation !== "worktree" || !generationWorktree) return undefined;
|
|
853
|
+
if (thread.generation !== expectedGeneration || thread.worktree !== generationWorktree) return undefined;
|
|
854
|
+
// All normal, destructive-stop, and shutdown owners converge here. Cache
|
|
855
|
+
// the lane-protected apply itself so superseding lifecycle paths can project
|
|
856
|
+
// the same finalization onto their own result without acquiring twice.
|
|
857
|
+
if (!generationFinalization) {
|
|
858
|
+
monitor.setIsolation(
|
|
859
|
+
runId,
|
|
860
|
+
"worktree",
|
|
861
|
+
"finalizing",
|
|
862
|
+
worktreeGroupId(generationWorktree),
|
|
863
|
+
);
|
|
864
|
+
generationFinalization = runInManagedRepositoryLane(
|
|
865
|
+
generationWorktree.originalRoot,
|
|
866
|
+
() => generationWorktree.finalize(),
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
const finalization = await generationFinalization;
|
|
870
|
+
monitor.setIsolation(runId, "worktree", finalization.status, worktreeGroupId(generationWorktree));
|
|
871
|
+
if (result) {
|
|
872
|
+
result.runId = runId;
|
|
873
|
+
result.isolation = "worktree";
|
|
874
|
+
result.integrationStatus = finalization.status;
|
|
875
|
+
result.integrationApplied = finalization.integrated;
|
|
876
|
+
result.integrationError = finalization.error;
|
|
877
|
+
result.integrationWorktreePath = finalization.worktreePath;
|
|
878
|
+
result.integrationPatchPath = finalization.patchPath;
|
|
879
|
+
if (finalization.status === "retained") {
|
|
880
|
+
const retained = [
|
|
881
|
+
finalization.worktreePath ? `worktree ${finalization.worktreePath}` : undefined,
|
|
882
|
+
finalization.patchPath ? `patch ${finalization.patchPath}` : undefined,
|
|
883
|
+
].filter(Boolean).join(", ");
|
|
884
|
+
const integrationMessage = finalization.integrated
|
|
885
|
+
? `Worktree changes were applied, but cleanup failed${retained ? `; retained ${retained}` : ""}: ${finalization.error ?? "unknown Git cleanup error"}`
|
|
886
|
+
: `Worktree integration failed${retained ? `; retained ${retained}` : ""}: ${finalization.error ?? "unknown Git integration error"}`;
|
|
887
|
+
result.exitCode = 1;
|
|
888
|
+
result.stopReason = "error";
|
|
889
|
+
result.errorMessage = result.errorMessage
|
|
890
|
+
? `${result.errorMessage}\n${integrationMessage}`
|
|
891
|
+
: integrationMessage;
|
|
892
|
+
result.stderr = result.stderr ? `${result.stderr.trimEnd()}\n${integrationMessage}` : integrationMessage;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (finalization.status === "retained") {
|
|
896
|
+
if (!thread.isolationFailureNotified) {
|
|
897
|
+
thread.isolationFailureNotified = true;
|
|
898
|
+
try {
|
|
899
|
+
thread.notifyIsolationFailure?.(finalization);
|
|
900
|
+
} catch {
|
|
901
|
+
/* notification failures do not hide retained artifacts */
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
// Every retained finalization gets a durable recovery record, so the
|
|
905
|
+
// artifacts stay findable even when the owning process dies next.
|
|
906
|
+
void persistRecoveryRecords(runtime.configPath, [
|
|
907
|
+
recoveryRecordFromFinalization(runId, finalization),
|
|
908
|
+
]).catch(() => undefined);
|
|
909
|
+
}
|
|
910
|
+
return finalization;
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
const cleanupTrackedSessionDir = async (sessionDir: string, action: string): Promise<void> => {
|
|
914
|
+
try {
|
|
915
|
+
await rm(sessionDir, { recursive: true, force: true });
|
|
916
|
+
runtime.sessionDirs.delete(sessionDir);
|
|
917
|
+
} catch (error) {
|
|
918
|
+
// Keep ownership so shutdown can retry; losing the path here leaks a
|
|
919
|
+
// cloned session containing retained model context on Windows locks.
|
|
920
|
+
try {
|
|
921
|
+
deps.runCtx?.ui.notify(
|
|
922
|
+
`✗ ${action}; retained ${sessionDir} for shutdown cleanup: ${error instanceof Error ? error.message : String(error)}`,
|
|
923
|
+
"error",
|
|
924
|
+
);
|
|
925
|
+
} catch {
|
|
926
|
+
/* cleanup ownership remains tracked even if the UI is unavailable */
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
const discardUnusedWorktree = async (candidate: WorktreeIsolation | undefined): Promise<void> => {
|
|
932
|
+
if (!candidate) return;
|
|
933
|
+
try {
|
|
934
|
+
await candidate.discard();
|
|
935
|
+
} catch (error) {
|
|
936
|
+
const retainedPath = existsSync(candidate.worktreePath)
|
|
937
|
+
? candidate.worktreePath
|
|
938
|
+
: existsSync(candidate.tempDir)
|
|
939
|
+
? candidate.tempDir
|
|
940
|
+
: undefined;
|
|
941
|
+
const finalization: WorktreeFinalization = {
|
|
942
|
+
status: "retained",
|
|
943
|
+
integrated: false,
|
|
944
|
+
hadChanges: false,
|
|
945
|
+
originalRoot: candidate.originalRoot,
|
|
946
|
+
...(retainedPath ? { worktreePath: retainedPath } : {}),
|
|
947
|
+
...(existsSync(candidate.patchPath) ? { patchPath: candidate.patchPath } : {}),
|
|
948
|
+
error: `Discarding unused continuation failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
949
|
+
};
|
|
950
|
+
await persistRecoveryRecords(runtime.configPath, [
|
|
951
|
+
recoveryRecordFromFinalization(runId, finalization),
|
|
952
|
+
]).catch(() => undefined);
|
|
953
|
+
try {
|
|
954
|
+
thread.notifyIsolationFailure?.(finalization);
|
|
955
|
+
} catch {
|
|
956
|
+
/* parent UI may already be shutting down */
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
const createContinuationWorktree = async (
|
|
962
|
+
source: WorktreeIsolation,
|
|
963
|
+
seedIsIntegrated: boolean,
|
|
964
|
+
): Promise<WorktreeIsolation> => {
|
|
965
|
+
if (source.state === "finalizing") {
|
|
966
|
+
throw new Error(`Run #${runId}'s worktree is still finalizing.`);
|
|
967
|
+
}
|
|
968
|
+
const seedCheckpoint = await source.snapshotCheckpoint();
|
|
969
|
+
return createWorktreeIsolation(thread.cwd, {
|
|
970
|
+
seedCheckpoint,
|
|
971
|
+
seedIsIntegrated,
|
|
972
|
+
tempBaseDir: worktreesRoot,
|
|
973
|
+
});
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
const persistElapsedTime = (): void => {
|
|
977
|
+
thread.elapsedMs = monitor.getElapsedMs(runId) ?? thread.elapsedMs;
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
thread.resume = async (objective?: string, resumeCtx?: ExtensionContext): Promise<SingleResult> => {
|
|
981
|
+
const requestedObjective = objective?.trim();
|
|
982
|
+
if (!runtime.sessionActive || runtime.threads.get(runId) !== thread) {
|
|
983
|
+
return failedStartResult(thread.agentName, thread.task, `Run #${runId} belongs to a parent session that has shut down.`);
|
|
984
|
+
}
|
|
985
|
+
if (objective !== undefined && !requestedObjective) {
|
|
986
|
+
return failedStartResult(thread.agentName, thread.task, "resume objective must be non-blank when provided.");
|
|
987
|
+
}
|
|
988
|
+
if (thread.retired) return failedStartResult(thread.agentName, thread.task, `Run #${runId} was retired by subagent_stop.`);
|
|
989
|
+
if (thread.lifecycleOperation) {
|
|
990
|
+
return failedStartResult(thread.agentName, thread.task, `Run #${runId} is already resuming.`);
|
|
991
|
+
}
|
|
992
|
+
if (!["parked", "completed", "failed"].includes(thread.state)) {
|
|
993
|
+
return failedStartResult(thread.agentName, thread.task, `Run #${runId} is ${thread.state}; it must be parked or settled before resume.`);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Lifecycle CAS: claim synchronously before the first await, then cancel
|
|
997
|
+
// and fully quiesce any superseded queue/process before cloning or
|
|
998
|
+
// reusing its session. A second resume sees this claim immediately.
|
|
999
|
+
const previousState = thread.state;
|
|
1000
|
+
const previousSessionId = thread.sessionId;
|
|
1001
|
+
const previousSessionDir = thread.sessionDir;
|
|
1002
|
+
const previousExecutionCwd = thread.executionCwd;
|
|
1003
|
+
const reservation: ResumeReservation = {
|
|
1004
|
+
version: ++thread.lifecycleVersion,
|
|
1005
|
+
generation: thread.generation,
|
|
1006
|
+
sessionId: previousSessionId,
|
|
1007
|
+
sessionDir: previousSessionDir,
|
|
1008
|
+
};
|
|
1009
|
+
thread.lifecycleOperation = "resume";
|
|
1010
|
+
thread.state = "resuming";
|
|
1011
|
+
const finishPreflight = beginRuntimePreflight(runtime);
|
|
1012
|
+
const supersededController = thread.queueController;
|
|
1013
|
+
runtime.backgroundQueue.cancel(supersededController);
|
|
1014
|
+
runtime.runControllers.delete(runId);
|
|
1015
|
+
|
|
1016
|
+
let continuationWorktree: WorktreeIsolation | undefined;
|
|
1017
|
+
let clonedSession: Awaited<ReturnType<typeof forkRetainedSession>> | undefined;
|
|
1018
|
+
try {
|
|
1019
|
+
// Never wait forever on a previous generation that is still settling
|
|
1020
|
+
// (e.g. blocked behind the managed repository lane in finalization).
|
|
1021
|
+
if (!(await quiesced(thread.generationCompletion))) {
|
|
1022
|
+
return failedStartResult(
|
|
1023
|
+
thread.agentName,
|
|
1024
|
+
thread.task,
|
|
1025
|
+
`Run #${runId}'s previous generation is still settling; retry the resume shortly.`,
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
if (!ownsResumeReservation(runtime, thread, reservation)) {
|
|
1029
|
+
return failedStartResult(
|
|
1030
|
+
thread.agentName,
|
|
1031
|
+
thread.task,
|
|
1032
|
+
thread.retired
|
|
1033
|
+
? `Run #${runId} was retired by subagent_stop; no new generation was started.`
|
|
1034
|
+
: `Run #${runId} changed while resume was preparing; no new generation was started.`,
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
thread.state = "resuming";
|
|
1038
|
+
const currentCtx = resumeCtx ?? deps.runCtx;
|
|
1039
|
+
if (!currentCtx) {
|
|
1040
|
+
throw new Error(`Run #${runId} has no dispatch context for resume.`);
|
|
1041
|
+
}
|
|
1042
|
+
let seed: SessionSeed | undefined;
|
|
1043
|
+
if (thread.isolation === "worktree" && thread.worktree?.state !== "active") {
|
|
1044
|
+
if (!thread.worktree) throw new Error(`Run #${runId} has no isolated worktree checkpoint.`);
|
|
1045
|
+
const seedAlreadyIntegrated =
|
|
1046
|
+
thread.worktree.state === "integrated" ||
|
|
1047
|
+
thread.worktree.state === "no_changes" ||
|
|
1048
|
+
thread.lastResult?.integrationApplied === true;
|
|
1049
|
+
continuationWorktree = await createContinuationWorktree(thread.worktree, seedAlreadyIntegrated);
|
|
1050
|
+
if (!ownsResumeReservation(runtime, thread, reservation)) {
|
|
1051
|
+
throw new Error(`Run #${runId} changed while its continuation worktree was being created.`);
|
|
1052
|
+
}
|
|
1053
|
+
seed = { worktree: continuationWorktree };
|
|
1054
|
+
if (previousSessionId && previousSessionDir) {
|
|
1055
|
+
clonedSession = await forkRetainedSession({
|
|
1056
|
+
cwd: previousExecutionCwd,
|
|
1057
|
+
targetCwd: continuationWorktree.cwd,
|
|
1058
|
+
sessionDir: previousSessionDir,
|
|
1059
|
+
sessionId: previousSessionId,
|
|
1060
|
+
targetRoot: sessionsRoot,
|
|
1061
|
+
});
|
|
1062
|
+
runtime.sessionDirs.add(clonedSession.sessionDir);
|
|
1063
|
+
if (!ownsResumeReservation(runtime, thread, reservation)) {
|
|
1064
|
+
throw new Error(`Run #${runId} changed while its retained session was being cloned.`);
|
|
1065
|
+
}
|
|
1066
|
+
seed.sessionId = clonedSession.sessionId;
|
|
1067
|
+
seed.sessionDir = clonedSession.sessionDir;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const currentConfig = await loadConfig(runtime.configPath);
|
|
1072
|
+
if (!ownsResumeReservation(runtime, thread, reservation)) {
|
|
1073
|
+
throw new Error(`Run #${runId} changed while resume configuration was loading.`);
|
|
1074
|
+
}
|
|
1075
|
+
const currentAgents = discoverAgents(currentCtx.cwd, {
|
|
1076
|
+
scope: currentConfig.agentScope,
|
|
1077
|
+
enabledNames: currentConfig.enabledAgents,
|
|
1078
|
+
projectTrusted: currentCtx.isProjectTrusted?.() === true,
|
|
1079
|
+
}).agents;
|
|
1080
|
+
const nextTask = requestedObjective ?? thread.task;
|
|
1081
|
+
const pending = await startBackground(
|
|
1082
|
+
thread.agentName,
|
|
1083
|
+
nextTask,
|
|
1084
|
+
thread.cwd,
|
|
1085
|
+
thread.isolation,
|
|
1086
|
+
{
|
|
1087
|
+
existingThread: thread,
|
|
1088
|
+
appendedObjectiveOnResume: objective !== undefined,
|
|
1089
|
+
environment: {
|
|
1090
|
+
ctx: currentCtx,
|
|
1091
|
+
config: currentConfig,
|
|
1092
|
+
agents: currentAgents,
|
|
1093
|
+
},
|
|
1094
|
+
seed,
|
|
1095
|
+
resumeReservation: reservation,
|
|
1096
|
+
},
|
|
1097
|
+
);
|
|
1098
|
+
if (pending.exitCode !== -1) {
|
|
1099
|
+
if (clonedSession) {
|
|
1100
|
+
await cleanupTrackedSessionDir(
|
|
1101
|
+
clonedSession.sessionDir,
|
|
1102
|
+
`Could not discard failed resume session clone for run #${runId}`,
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
await discardUnusedWorktree(continuationWorktree);
|
|
1106
|
+
if (ownsResumeReservation(runtime, thread, reservation)) thread.state = previousState;
|
|
1107
|
+
return pending;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// The cloned branch replaces the removed-worktree session for this
|
|
1111
|
+
// logical id. Keep an undeletable old dir in runtime cleanup if needed.
|
|
1112
|
+
if (clonedSession && previousSessionDir && previousSessionDir !== clonedSession.sessionDir) {
|
|
1113
|
+
try {
|
|
1114
|
+
await rm(previousSessionDir, { recursive: true, force: true });
|
|
1115
|
+
runtime.sessionDirs.delete(previousSessionDir);
|
|
1116
|
+
} catch {
|
|
1117
|
+
/* shutdown retries cleanup of the old retained branch */
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
return pending;
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
if (clonedSession) {
|
|
1123
|
+
await cleanupTrackedSessionDir(
|
|
1124
|
+
clonedSession.sessionDir,
|
|
1125
|
+
`Could not discard interrupted resume session clone for run #${runId}`,
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
await discardUnusedWorktree(continuationWorktree);
|
|
1129
|
+
if (ownsResumeReservation(runtime, thread, reservation)) {
|
|
1130
|
+
thread.state = previousState;
|
|
1131
|
+
thread.sessionId = previousSessionId;
|
|
1132
|
+
thread.sessionDir = previousSessionDir;
|
|
1133
|
+
thread.executionCwd = previousExecutionCwd;
|
|
1134
|
+
}
|
|
1135
|
+
return failedStartResult(
|
|
1136
|
+
thread.agentName,
|
|
1137
|
+
requestedObjective ?? thread.task,
|
|
1138
|
+
`Could not resume run #${runId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1139
|
+
);
|
|
1140
|
+
} finally {
|
|
1141
|
+
finishPreflight();
|
|
1142
|
+
if (
|
|
1143
|
+
thread.lifecycleOperation === "resume" &&
|
|
1144
|
+
thread.lifecycleVersion === reservation.version
|
|
1145
|
+
) {
|
|
1146
|
+
thread.lifecycleOperation = undefined;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/** Dropped restored record: no session means no context to resume, so its
|
|
1153
|
+
* artifacts go away with the record. */
|
|
1154
|
+
async function discardRestoredRecord(runtime: SubagentRuntime, record: ThreadRecord): Promise<void> {
|
|
1155
|
+
if (record.sessionDir) {
|
|
1156
|
+
await rm(record.sessionDir, { recursive: true, force: true }).catch(() => undefined);
|
|
1157
|
+
runtime.sessionDirs.delete(record.sessionDir);
|
|
1158
|
+
}
|
|
1159
|
+
if (record.worktree && (record.worktree.state === "active" || record.worktree.state === "retained")) {
|
|
1160
|
+
const worktree = await restoreWorktreeIsolation(record.worktree).catch(() => undefined);
|
|
1161
|
+
await worktree?.discard().catch(() => undefined);
|
|
1162
|
+
}
|
|
1163
|
+
await removeThreadRecord(runtime.configPath, record.runId, record.cwd).catch(() => undefined);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/** Project-scoped <projectRoot>/results for a completion's artifacts. */
|
|
1167
|
+
export function projectResultsRoot(configPath: string, cwd: string | undefined): string {
|
|
1168
|
+
return join(getProjectRoot(configPath, cwd), "results");
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function createRestoredThread(
|
|
1172
|
+
runtime: SubagentRuntime,
|
|
1173
|
+
record: ThreadRecord,
|
|
1174
|
+
worktree: WorktreeIsolation | undefined,
|
|
1175
|
+
state: ThreadState,
|
|
1176
|
+
): SubagentThread {
|
|
1177
|
+
const thread: SubagentThread = {
|
|
1178
|
+
id: record.runId,
|
|
1179
|
+
generation: record.generation,
|
|
1180
|
+
agentName: record.agentName,
|
|
1181
|
+
task: record.task,
|
|
1182
|
+
cwd: record.cwd,
|
|
1183
|
+
executionCwd: record.executionCwd,
|
|
1184
|
+
...(record.thinkingLevel ? { thinkingLevel: record.thinkingLevel as ThinkingLevel } : {}),
|
|
1185
|
+
...(record.requestedThinkingLevel
|
|
1186
|
+
? { requestedThinkingLevel: record.requestedThinkingLevel as ThinkingLevel }
|
|
1187
|
+
: {}),
|
|
1188
|
+
isolation: record.isolation,
|
|
1189
|
+
worktree,
|
|
1190
|
+
state,
|
|
1191
|
+
control: new RpcRunControl(record.task, record.generation),
|
|
1192
|
+
generationCompletion: Promise.resolve(),
|
|
1193
|
+
lifecycleVersion: 0,
|
|
1194
|
+
elapsedMs: record.elapsedMs,
|
|
1195
|
+
sessionId: record.sessionId,
|
|
1196
|
+
sessionDir: record.sessionDir,
|
|
1197
|
+
lastResult: restoredResultFromSummary(record),
|
|
1198
|
+
resume: async () => failedStartResult(record.agentName, record.task, "Thread resume was not initialized."),
|
|
1199
|
+
finalizeIsolation: async () => undefined,
|
|
1200
|
+
};
|
|
1201
|
+
installThreadLifecycle(thread, {
|
|
1202
|
+
runtime,
|
|
1203
|
+
startBackground: (...args) => {
|
|
1204
|
+
const dispatcher = runtime.dispatcher;
|
|
1205
|
+
if (!dispatcher) {
|
|
1206
|
+
return Promise.resolve(failedStartResult(
|
|
1207
|
+
record.agentName,
|
|
1208
|
+
record.task,
|
|
1209
|
+
`Run #${record.runId} cannot continue: no dispatch context is available yet. Dispatch any subagent once, then retry.`,
|
|
1210
|
+
));
|
|
1211
|
+
}
|
|
1212
|
+
return dispatcher(...args);
|
|
1213
|
+
},
|
|
1214
|
+
});
|
|
1215
|
+
return thread;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/** Rebuild interrupted (parked) threads from the durable manifest after a
|
|
1219
|
+
* reload or restart. Orphaned children recorded by the previous process are
|
|
1220
|
+
* killed first; records whose retained session vanished — and settled records
|
|
1221
|
+
* left by older versions, which hold no work worth resuming — drop out with
|
|
1222
|
+
* their artifacts. Returns the restored run ids. */
|
|
1223
|
+
export async function restoreDurableThreads(runtime: SubagentRuntime): Promise<number[]> {
|
|
1224
|
+
const records = await readThreadRecords(runtime.configPath);
|
|
1225
|
+
const restoredIds: number[] = [];
|
|
1226
|
+
for (const record of records) {
|
|
1227
|
+
if (runtime.threads.has(record.runId) || monitor.findRun(record.runId)) continue;
|
|
1228
|
+
if (record.state !== "parked") {
|
|
1229
|
+
await discardRestoredRecord(runtime, record);
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
// A child orphaned by reload/crash may still hold the retained session.
|
|
1233
|
+
// The on-disk session checkpoint is what survives; kill the writer — but
|
|
1234
|
+
// only while the recorded pids are still ours. Across a reboot the same
|
|
1235
|
+
// numbers belong to unrelated processes.
|
|
1236
|
+
if (isCurrentBoot(record)) {
|
|
1237
|
+
for (const pid of record.childPids) {
|
|
1238
|
+
if (isProcessAlive(pid)) killProcessTree(pid);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
const sessionValid =
|
|
1242
|
+
record.sessionId !== undefined &&
|
|
1243
|
+
record.sessionDir !== undefined &&
|
|
1244
|
+
sessionExists(record.sessionDir, record.sessionId);
|
|
1245
|
+
if (!sessionValid) {
|
|
1246
|
+
await discardRestoredRecord(runtime, record);
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
const worktree = record.worktree
|
|
1250
|
+
? await restoreWorktreeIsolation(record.worktree).catch(() => undefined)
|
|
1251
|
+
: undefined;
|
|
1252
|
+
// A worktree thread whose isolated filesystem is gone cannot continue its
|
|
1253
|
+
// isolation invariant; surface it as failed instead of pretending.
|
|
1254
|
+
const state: ThreadState = worktree
|
|
1255
|
+
? "parked"
|
|
1256
|
+
: record.isolation === "worktree" && record.worktree
|
|
1257
|
+
? "failed"
|
|
1258
|
+
: "parked";
|
|
1259
|
+
const thread = createRestoredThread(runtime, record, worktree, state);
|
|
1260
|
+
runtime.threads.set(record.runId, thread);
|
|
1261
|
+
runtime.sessionDirs.add(record.sessionDir!);
|
|
1262
|
+
monitor.restoreRun({
|
|
1263
|
+
id: record.runId,
|
|
1264
|
+
agent: record.agentName,
|
|
1265
|
+
task: record.task,
|
|
1266
|
+
status: "parked",
|
|
1267
|
+
elapsedMs: record.elapsedMs,
|
|
1268
|
+
isolation: record.isolation,
|
|
1269
|
+
...(record.worktree
|
|
1270
|
+
? {
|
|
1271
|
+
integrationStatus: record.worktree.state === "active"
|
|
1272
|
+
? ("pending" as const)
|
|
1273
|
+
: record.worktree.state,
|
|
1274
|
+
...(worktree ? { worktreeId: worktreeGroupId(worktree) } : {}),
|
|
1275
|
+
}
|
|
1276
|
+
: {}),
|
|
1277
|
+
});
|
|
1278
|
+
restoredIds.push(record.runId);
|
|
1279
|
+
}
|
|
1280
|
+
return restoredIds;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/** Load-time durable bootstrap: restore threads, age out expired records, and
|
|
1284
|
+
* sweep leaked temp/state directories. Every stage is best-effort so a broken
|
|
1285
|
+
* manifest never blocks extension registration.
|
|
1286
|
+
*
|
|
1287
|
+
* Restore is published on the runtime as `durableRestore` before this returns,
|
|
1288
|
+
* so callers that must see restored threads await that pass alone and never the
|
|
1289
|
+
* hygiene sweeps behind it. Hygiene still runs after restore: pruning decides
|
|
1290
|
+
* what to delete from the records restore has already claimed. */
|
|
1291
|
+
export function bootstrapDurableState(runtime: SubagentRuntime): Promise<void> {
|
|
1292
|
+
const restore = (async () => {
|
|
1293
|
+
try {
|
|
1294
|
+
// Records from a pre-per-project manifest must land in their project
|
|
1295
|
+
// roots before restore reads anything.
|
|
1296
|
+
await migrateLegacyThreadsManifest(runtime.configPath);
|
|
1297
|
+
runtime.restoredRunIds = await restoreDurableThreads(runtime);
|
|
1298
|
+
} catch {
|
|
1299
|
+
/* restore is best-effort */
|
|
1300
|
+
}
|
|
1301
|
+
})();
|
|
1302
|
+
runtime.durableRestore = restore;
|
|
1303
|
+
return (async () => {
|
|
1304
|
+
await restore;
|
|
1305
|
+
try {
|
|
1306
|
+
await pruneThreadRecords(runtime.configPath);
|
|
1307
|
+
} catch {
|
|
1308
|
+
/* retention is best-effort */
|
|
1309
|
+
}
|
|
1310
|
+
const projectRoots = join(dirname(runtime.configPath), PROJECT_ROOTS_DIR_NAME);
|
|
1311
|
+
try {
|
|
1312
|
+
sweepProjectTempDirs(projectRoots);
|
|
1313
|
+
} catch {
|
|
1314
|
+
/* temp hygiene is best-effort */
|
|
1315
|
+
}
|
|
1316
|
+
try {
|
|
1317
|
+
// Sessions and worktrees outlive their process on purpose, so only
|
|
1318
|
+
// ownership separates state a live pi still resumes from state a crash
|
|
1319
|
+
// abandoned. Parked work is claimed by the manifest and always kept.
|
|
1320
|
+
const records = await readThreadRecords(runtime.configPath);
|
|
1321
|
+
const referenced = [...referencedDurablePaths(records)];
|
|
1322
|
+
sweepProjectDurableDirs(projectRoots, {
|
|
1323
|
+
keep: (path) => referenced.some((claimed) => isPathInside(path, claimed)),
|
|
1324
|
+
});
|
|
1325
|
+
} catch {
|
|
1326
|
+
/* durable-state hygiene is best-effort */
|
|
1327
|
+
}
|
|
1328
|
+
try {
|
|
1329
|
+
// Result excerpts are bounded on write, which never reaches a project
|
|
1330
|
+
// that has stopped producing them.
|
|
1331
|
+
sweepProjectResultArtifacts(projectRoots);
|
|
1332
|
+
} catch {
|
|
1333
|
+
/* result retention is best-effort */
|
|
1334
|
+
}
|
|
1335
|
+
try {
|
|
1336
|
+
await pruneStaleProjectRoots(runtime.configPath);
|
|
1337
|
+
} catch {
|
|
1338
|
+
/* project-root hygiene is best-effort */
|
|
1339
|
+
}
|
|
1340
|
+
})();
|
|
1341
|
+
}
|