@cr1ms0n/pi-subagent 0.8.9 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -1
- package/README.md +218 -115
- package/docs/ARCHITECTURE.md +56 -13
- package/docs/COST-ACCOUNTING.md +116 -66
- package/docs/RELEASING.md +32 -32
- package/docs/SECURITY.md +42 -5
- package/docs/UX.md +158 -141
- package/package.json +2 -2
- package/skills/subagent/SKILL.md +78 -49
- package/src/backends/pi.ts +164 -94
- package/src/child-preflight.ts +166 -0
- package/src/config.ts +254 -252
- package/src/dispatch-preflight.ts +87 -0
- package/src/dispatch-routing.ts +56 -0
- package/src/extension.ts +366 -158
- package/src/format.ts +436 -365
- package/src/jev-router.ts +1036 -0
- package/src/orchestrator.ts +75 -19
- package/src/persistence.ts +643 -335
- package/src/policy.ts +120 -89
- package/src/process-lock.ts +730 -687
- package/src/protocol.ts +320 -290
- package/src/registry.ts +730 -632
- package/src/routing-policy.ts +268 -0
- package/src/routing-types.ts +217 -0
- package/src/runner.ts +1299 -850
- package/src/schema.ts +10 -10
- package/src/startup-check.ts +481 -0
- package/src/types.ts +208 -198
- package/src/usage.ts +316 -274
- package/src/model-policy.ts +0 -169
package/src/runner.ts
CHANGED
|
@@ -1,850 +1,1299 @@
|
|
|
1
|
-
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
-
import * as fs from "node:fs/promises";
|
|
3
|
-
import * as os from "node:os";
|
|
4
|
-
import * as path from "node:path";
|
|
5
|
-
import type {
|
|
6
|
-
ChildProcessIdentity,
|
|
7
|
-
TaskResult,
|
|
8
|
-
TaskSpec,
|
|
9
|
-
TimeoutPhase,
|
|
10
|
-
UsageStats,
|
|
11
|
-
} from "./types.js";
|
|
12
|
-
import { emptyUsage } from "./types.js";
|
|
13
|
-
import { ProtocolParser, type ProtocolUpdate } from "./protocol.js";
|
|
14
|
-
import { Semaphore } from "./semaphore.js";
|
|
15
|
-
import { defaultConfig } from "./config.js";
|
|
16
|
-
import { DEPTH_ENV_VAR, SPAWNS_ENV_VAR, parseDepth } from "./policy.js";
|
|
17
|
-
import {
|
|
18
|
-
processStartTime,
|
|
19
|
-
type ProcessLockManager,
|
|
20
|
-
type SlotToken,
|
|
21
|
-
} from "./process-lock.js";
|
|
22
|
-
import { createGetPiCommand } from "./launch.js";
|
|
23
|
-
import {
|
|
24
|
-
checkAgainstSchema,
|
|
25
|
-
extractStructuredResult,
|
|
26
|
-
repairMessage,
|
|
27
|
-
} from "./structured.js";
|
|
28
|
-
import type { BackendAdapter, BackendParser } from "./backend.js";
|
|
29
|
-
import { resolveBackend } from "./backends/index.js";
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
);
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
};
|
|
334
|
-
|
|
335
|
-
const
|
|
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
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
if (
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
if (
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
)
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
)
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
)
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
}
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import type {
|
|
6
|
+
ChildProcessIdentity,
|
|
7
|
+
TaskResult,
|
|
8
|
+
TaskSpec,
|
|
9
|
+
TimeoutPhase,
|
|
10
|
+
UsageStats,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
import { emptyUsage } from "./types.js";
|
|
13
|
+
import { ProtocolParser, type ProtocolUpdate } from "./protocol.js";
|
|
14
|
+
import { Semaphore } from "./semaphore.js";
|
|
15
|
+
import { defaultConfig } from "./config.js";
|
|
16
|
+
import { DEPTH_ENV_VAR, SPAWNS_ENV_VAR, parseDepth } from "./policy.js";
|
|
17
|
+
import {
|
|
18
|
+
processStartTime,
|
|
19
|
+
type ProcessLockManager,
|
|
20
|
+
type SlotToken,
|
|
21
|
+
} from "./process-lock.js";
|
|
22
|
+
import { createGetPiCommand } from "./launch.js";
|
|
23
|
+
import {
|
|
24
|
+
checkAgainstSchema,
|
|
25
|
+
extractStructuredResult,
|
|
26
|
+
repairMessage,
|
|
27
|
+
} from "./structured.js";
|
|
28
|
+
import type { BackendAdapter, BackendParser } from "./backend.js";
|
|
29
|
+
import { resolveBackend } from "./backends/index.js";
|
|
30
|
+
import {
|
|
31
|
+
PREFLIGHT_FAILURE_STOP_REASON,
|
|
32
|
+
PREFLIGHT_MANIFEST_ENV,
|
|
33
|
+
STARTUP_FAILURE_RESULT_PREFIX,
|
|
34
|
+
ownExtensionEntryCandidates,
|
|
35
|
+
ownPreflightExtensionPath,
|
|
36
|
+
parsePreflightAckContent,
|
|
37
|
+
parsePreflightManifest,
|
|
38
|
+
preflightCommandBase,
|
|
39
|
+
readStartupFailure,
|
|
40
|
+
resolvePreflightCommand,
|
|
41
|
+
startupFailure,
|
|
42
|
+
startupTimeoutDetail,
|
|
43
|
+
summarizeCommandResolution,
|
|
44
|
+
summarizePreflightProblems,
|
|
45
|
+
verifyPreflightAck,
|
|
46
|
+
type PreflightExpectation,
|
|
47
|
+
} from "./startup-check.js";
|
|
48
|
+
|
|
49
|
+
export type GetPiCommand = (args: string[]) => {
|
|
50
|
+
command: string;
|
|
51
|
+
args: string[];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export interface RunnerOptions {
|
|
55
|
+
semaphore?: Semaphore;
|
|
56
|
+
getPiCommand?: GetPiCommand;
|
|
57
|
+
sessionDir?: string;
|
|
58
|
+
onCheckpoint?: (result: Partial<TaskResult>) => void;
|
|
59
|
+
killGraceMs?: number;
|
|
60
|
+
/**
|
|
61
|
+
* Optional durable coordinator for global slots + run process records.
|
|
62
|
+
*
|
|
63
|
+
* **Library consumers:** without `locks` + `runId`, no durable run record is
|
|
64
|
+
* written and the child is invisible to orphan reclaim on parent restart.
|
|
65
|
+
* There is intentionally **no** implicit default lock manager (opt in when
|
|
66
|
+
* you need durability; magic global state is worse).
|
|
67
|
+
*/
|
|
68
|
+
locks?: ProcessLockManager;
|
|
69
|
+
/** Run id for durable identity (orphan reconcile). Required with `locks` for reclaim. */
|
|
70
|
+
runId?: string;
|
|
71
|
+
/** Parent session key for durable identity. */
|
|
72
|
+
parentSessionKey?: string;
|
|
73
|
+
/** Max task stdin bytes (Guard against runaway prompt buffering). */
|
|
74
|
+
maxTaskBytes?: number;
|
|
75
|
+
/** Wrap-up grace turns after a budget breach (spec.graceTurns overrides). */
|
|
76
|
+
graceTurns?: number;
|
|
77
|
+
/** Protocol-silence window before flagging a running child as stalled. 0 disables. */
|
|
78
|
+
stallAfterMs?: number;
|
|
79
|
+
/** Additional silence after the stall flag before the child is killed. 0 disables kill. */
|
|
80
|
+
stallKillAfterMs?: number;
|
|
81
|
+
/**
|
|
82
|
+
* Bounded startup-verification budget for routed tasks (model/tool handshake before
|
|
83
|
+
* the real prompt). Defaults to 30s and is always clamped by the remaining task time.
|
|
84
|
+
*/
|
|
85
|
+
startupTimeoutMs?: number;
|
|
86
|
+
/** Backend adapter override (defaults to the spec's backend, then pi). */
|
|
87
|
+
backend?: BackendAdapter;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type StopReason =
|
|
91
|
+
"cancelled" | "timeout" | "max_turns" | "max_cost" | "fatal" | "stalled";
|
|
92
|
+
|
|
93
|
+
/** Budget stops preserve completed work: they end as "partial", not "failed". */
|
|
94
|
+
const BUDGET_STOPS = new Set<StopReason>(["max_turns", "max_cost"]);
|
|
95
|
+
|
|
96
|
+
const DEFAULT_MAX_TASK_BYTES = 512 * 1024;
|
|
97
|
+
|
|
98
|
+
/** Startup handshake budget; a routed child that cannot confirm model+tools fails fast. */
|
|
99
|
+
const DEFAULT_STARTUP_TIMEOUT_MS = 30_000;
|
|
100
|
+
|
|
101
|
+
/** Poll interval while waiting for the private preflight command to load. */
|
|
102
|
+
const STARTUP_COMMAND_POLL_MS = 200;
|
|
103
|
+
|
|
104
|
+
/** Bound on get_commands polls so an unhealthy child cannot flood its stdin. */
|
|
105
|
+
const MAX_STARTUP_COMMAND_POLLS = 250;
|
|
106
|
+
|
|
107
|
+
const WRAP_UP_MESSAGE =
|
|
108
|
+
"You have reached your budget for this task. Stop all tool use and provide your final answer NOW, " +
|
|
109
|
+
"summarizing what you completed, what remains, and any key findings. This is your last chance to respond.";
|
|
110
|
+
|
|
111
|
+
/** Bounded timer that never keeps the parent process alive. */
|
|
112
|
+
function sleep(ms: number): Promise<void> {
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
const timer = setTimeout(resolve, Math.max(0, ms));
|
|
115
|
+
timer.unref?.();
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Order-insensitive equality for the finalized/expected tool name sets. */
|
|
120
|
+
function sameNameSet(left: readonly string[], right: readonly string[]): boolean {
|
|
121
|
+
if (left.length !== right.length) return false;
|
|
122
|
+
const observed = new Set(left);
|
|
123
|
+
if (observed.size !== left.length) return false;
|
|
124
|
+
return right.every((name) => observed.has(name));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Convert a run result into a non-transient startup-capability failure.
|
|
129
|
+
*
|
|
130
|
+
* Uses `PREFLIGHT_FAILURE_STOP_REASON` (never in the transient-retry classification) so a
|
|
131
|
+
* routed child that could not be verified is refused rather than retried into an
|
|
132
|
+
* unverified launch.
|
|
133
|
+
*/
|
|
134
|
+
function markStartupFailure(result: TaskResult, code: string, detail: string): TaskResult {
|
|
135
|
+
result.state = "failed";
|
|
136
|
+
result.stopReason = PREFLIGHT_FAILURE_STOP_REASON;
|
|
137
|
+
result.errorMessage = `${STARTUP_FAILURE_RESULT_PREFIX} (${code}): ${detail}`;
|
|
138
|
+
result.exitCode ??= 1;
|
|
139
|
+
result.endedAt = Date.now();
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Owns exactly one child Pi process and its process tree. */
|
|
144
|
+
export class ChildRunner {
|
|
145
|
+
/** Live stdin command channel; set while the child process is running. */
|
|
146
|
+
private sendCommand?: (command: unknown) => boolean;
|
|
147
|
+
private readonly graceTurns: number;
|
|
148
|
+
private readonly stallAfterMs: number;
|
|
149
|
+
private readonly stallKillAfterMs: number;
|
|
150
|
+
private readonly startupTimeoutMs: number;
|
|
151
|
+
private readonly backendOverride?: BackendAdapter;
|
|
152
|
+
/** Backend for the in-flight run; set at spawn so steer() uses the right dialect. */
|
|
153
|
+
private backend: BackendAdapter = resolveBackend("pi");
|
|
154
|
+
|
|
155
|
+
constructor(
|
|
156
|
+
private readonly semaphore = new Semaphore(
|
|
157
|
+
defaultConfig.maxActiveProcesses,
|
|
158
|
+
defaultConfig.maxQueuedTasks,
|
|
159
|
+
),
|
|
160
|
+
private readonly getPiCommand: GetPiCommand = createGetPiCommand(),
|
|
161
|
+
private readonly sessionDir = defaultConfig.sessionDir,
|
|
162
|
+
private readonly onCheckpoint?: (result: Partial<TaskResult>) => void,
|
|
163
|
+
private readonly killGraceMs = defaultConfig.killGraceMs,
|
|
164
|
+
private readonly locks?: ProcessLockManager,
|
|
165
|
+
private readonly runId?: string,
|
|
166
|
+
private readonly parentSessionKey?: string,
|
|
167
|
+
private readonly maxTaskBytes = DEFAULT_MAX_TASK_BYTES,
|
|
168
|
+
options: Pick<
|
|
169
|
+
RunnerOptions,
|
|
170
|
+
"graceTurns" | "stallAfterMs" | "stallKillAfterMs" | "startupTimeoutMs" | "backend"
|
|
171
|
+
> = {},
|
|
172
|
+
) {
|
|
173
|
+
this.backendOverride = options.backend;
|
|
174
|
+
this.graceTurns = options.graceTurns ?? defaultConfig.graceTurns;
|
|
175
|
+
this.stallAfterMs = options.stallAfterMs ?? defaultConfig.stallAfterMs;
|
|
176
|
+
this.stallKillAfterMs =
|
|
177
|
+
options.stallKillAfterMs ?? defaultConfig.stallKillAfterMs;
|
|
178
|
+
this.startupTimeoutMs = Math.max(
|
|
179
|
+
0,
|
|
180
|
+
options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Queue a steering message into the running child (delivered after the
|
|
186
|
+
* current assistant turn, before the next LLM call). Returns false when the
|
|
187
|
+
* child is not running or its stdin is closed.
|
|
188
|
+
*/
|
|
189
|
+
steer(message: string): boolean {
|
|
190
|
+
const command = this.backend.steerCommand?.(message);
|
|
191
|
+
if (command === undefined) return false;
|
|
192
|
+
return this.sendCommand?.(command) === true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async run(spec: TaskSpec, abortSignal?: AbortSignal): Promise<TaskResult> {
|
|
196
|
+
const startedAt = Date.now();
|
|
197
|
+
const result: TaskResult = {
|
|
198
|
+
label: spec.label ?? "subagent",
|
|
199
|
+
task: spec.task,
|
|
200
|
+
model: spec.model,
|
|
201
|
+
routing: spec.routing,
|
|
202
|
+
state: "queued",
|
|
203
|
+
exitCode: null,
|
|
204
|
+
messages: [],
|
|
205
|
+
stderr: "",
|
|
206
|
+
usage: emptyUsage(),
|
|
207
|
+
outputFile: spec.output,
|
|
208
|
+
outputMode: spec.outputMode,
|
|
209
|
+
thinking: spec.thinking,
|
|
210
|
+
profile: spec.profile,
|
|
211
|
+
backend: spec.backend ?? "pi",
|
|
212
|
+
canWrite: spec.canWrite,
|
|
213
|
+
startedAt,
|
|
214
|
+
protocol: {
|
|
215
|
+
headerSeen: false,
|
|
216
|
+
assistantEndSeen: false,
|
|
217
|
+
agentEndSeen: false,
|
|
218
|
+
agentSettledSeen: false,
|
|
219
|
+
validEvents: 0,
|
|
220
|
+
parseErrors: 0,
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
let processHandle: ChildProcess | undefined;
|
|
225
|
+
let slotHeld = false;
|
|
226
|
+
let globalSlot: SlotToken | undefined;
|
|
227
|
+
let forceKillTimer: NodeJS.Timeout | undefined;
|
|
228
|
+
const tempDirs: string[] = [];
|
|
229
|
+
let requestedStop: StopReason | undefined;
|
|
230
|
+
let fatalError: string | undefined;
|
|
231
|
+
let timeoutPhase: TimeoutPhase | undefined;
|
|
232
|
+
let abortHandler: (() => void) | undefined;
|
|
233
|
+
let stderr = "";
|
|
234
|
+
// Backend resolution happens before anything else so parser dialect,
|
|
235
|
+
// capability checks and stdin command shapes all agree.
|
|
236
|
+
const backend =
|
|
237
|
+
this.backendOverride ?? resolveBackend(spec.backend ?? "pi");
|
|
238
|
+
this.backend = backend;
|
|
239
|
+
const parser: BackendParser = backend.createParser();
|
|
240
|
+
let spawned = false;
|
|
241
|
+
let acquiredAt: number | undefined;
|
|
242
|
+
let childStartTime = 0;
|
|
243
|
+
// Graceful budget stop state: after a breach the child is steered to wrap
|
|
244
|
+
// up and allowed `graceTurns` more turns before SIGTERM.
|
|
245
|
+
let pendingBudgetStop:
|
|
246
|
+
{ reason: "max_turns" | "max_cost"; deadlineTurns: number } | undefined;
|
|
247
|
+
let wrappedUp = false;
|
|
248
|
+
// Structured-output repair state: one steer-based retry after failed validation.
|
|
249
|
+
let schemaRepairAttempted = false;
|
|
250
|
+
// Stall watchdog state.
|
|
251
|
+
let lastEventAt = Date.now();
|
|
252
|
+
let stallTimer: NodeJS.Timeout | undefined;
|
|
253
|
+
let stalledAt: number | undefined;
|
|
254
|
+
|
|
255
|
+
// ---- Routed-task startup verification state --------------------------------
|
|
256
|
+
// `spec.routing` is added by the extension only for Jev-routed dispatches; the
|
|
257
|
+
// trusted low-level SDK never sets it, so unrouted runs keep the old lifecycle.
|
|
258
|
+
const routed = spec.routing !== undefined;
|
|
259
|
+
let taskPromptSent = false;
|
|
260
|
+
const absoluteDeadline =
|
|
261
|
+
typeof spec.deadline === "number" && Number.isFinite(spec.deadline) ? spec.deadline : undefined;
|
|
262
|
+
const deadlineRemainingMs =
|
|
263
|
+
absoluteDeadline === undefined
|
|
264
|
+
? undefined
|
|
265
|
+
: Math.max(0, absoluteDeadline - Date.now());
|
|
266
|
+
// The absolute task deadline is honored from the first line of the run and is never
|
|
267
|
+
// reset by a retry, restart or a later phase.
|
|
268
|
+
const effectiveTimeoutMs =
|
|
269
|
+
deadlineRemainingMs === undefined
|
|
270
|
+
? spec.timeoutMs
|
|
271
|
+
: Math.min(spec.timeoutMs, deadlineRemainingMs);
|
|
272
|
+
type StartupWaiter = {
|
|
273
|
+
test: (update: ProtocolUpdate) => boolean;
|
|
274
|
+
resolve: (update: ProtocolUpdate | null) => void;
|
|
275
|
+
};
|
|
276
|
+
const startupWaiters = new Set<StartupWaiter>();
|
|
277
|
+
const settleStartupWaiters = () => {
|
|
278
|
+
for (const waiter of [...startupWaiters]) {
|
|
279
|
+
startupWaiters.delete(waiter);
|
|
280
|
+
waiter.resolve(null);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
const waitForUpdate = (
|
|
284
|
+
test: StartupWaiter["test"],
|
|
285
|
+
): Promise<ProtocolUpdate | null> =>
|
|
286
|
+
new Promise((resolve) => {
|
|
287
|
+
startupWaiters.add({ test, resolve });
|
|
288
|
+
});
|
|
289
|
+
let startupTimer: NodeJS.Timeout | undefined;
|
|
290
|
+
let startupTimedOut = false;
|
|
291
|
+
let startupBudgetMs = 0;
|
|
292
|
+
let childExited:
|
|
293
|
+
{ code: number | null; signal: NodeJS.Signals | null; error?: Error } | undefined;
|
|
294
|
+
|
|
295
|
+
// Internal signal combines the caller's abort with the run timeout so both
|
|
296
|
+
// interrupt semaphore queue waits. Queue time counts against timeoutMs.
|
|
297
|
+
const internal = new AbortController();
|
|
298
|
+
const onExternalAbort = () => internal.abort();
|
|
299
|
+
const onInternalAbort = () => settleStartupWaiters();
|
|
300
|
+
internal.signal.addEventListener("abort", onInternalAbort, { once: true });
|
|
301
|
+
if (abortSignal?.aborted) internal.abort();
|
|
302
|
+
else
|
|
303
|
+
abortSignal?.addEventListener("abort", onExternalAbort, { once: true });
|
|
304
|
+
const timeout = setTimeout(() => {
|
|
305
|
+
// Record which phase timed out before nightfall.
|
|
306
|
+
timeoutPhase = !slotHeld ? "queued" : !spawned || (routed && !taskPromptSent) ? "starting" : "running";
|
|
307
|
+
requestStop("timeout");
|
|
308
|
+
internal.abort();
|
|
309
|
+
}, effectiveTimeoutMs);
|
|
310
|
+
timeout.unref?.();
|
|
311
|
+
|
|
312
|
+
const release = () => {
|
|
313
|
+
if (slotHeld) {
|
|
314
|
+
slotHeld = false;
|
|
315
|
+
this.semaphore.release();
|
|
316
|
+
}
|
|
317
|
+
if (globalSlot) {
|
|
318
|
+
this.locks?.releaseGlobalSlot(globalSlot);
|
|
319
|
+
globalSlot = undefined;
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Group-kill only when the PID still belongs to our child (start-time
|
|
325
|
+
* identity check guards against PID reuse racing a delayed kill). When
|
|
326
|
+
* identity is unverifiable, fall back to the direct child handle, which
|
|
327
|
+
* Node ties to the real process regardless of PID recycling.
|
|
328
|
+
*/
|
|
329
|
+
const pidStillOurs = (pid: number): boolean => {
|
|
330
|
+
if (childStartTime <= 0) return false;
|
|
331
|
+
const live = processStartTime(pid);
|
|
332
|
+
return live > 0 && live === childStartTime;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const forceKillTree = () => {
|
|
336
|
+
const pid = processHandle?.pid;
|
|
337
|
+
if (!pid) return;
|
|
338
|
+
try {
|
|
339
|
+
if (process.platform === "win32") {
|
|
340
|
+
const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
|
|
341
|
+
shell: false,
|
|
342
|
+
stdio: "ignore",
|
|
343
|
+
});
|
|
344
|
+
killer.unref();
|
|
345
|
+
} else if (
|
|
346
|
+
processHandle &&
|
|
347
|
+
processHandle.exitCode === null &&
|
|
348
|
+
processHandle.signalCode === null
|
|
349
|
+
) {
|
|
350
|
+
// Child object still live: group id is safe to use.
|
|
351
|
+
process.kill(-pid, "SIGKILL");
|
|
352
|
+
} else if (pidStillOurs(pid)) {
|
|
353
|
+
process.kill(-pid, "SIGKILL");
|
|
354
|
+
}
|
|
355
|
+
// Child exited and identity is unverifiable: skip the group kill (a
|
|
356
|
+
// recycled PID must never be killed); descendants are covered by the
|
|
357
|
+
// exit-path reap that runs while the handle is still authoritative.
|
|
358
|
+
} catch (error: any) {
|
|
359
|
+
if (error?.code !== "ESRCH") {
|
|
360
|
+
try {
|
|
361
|
+
processHandle?.kill("SIGKILL");
|
|
362
|
+
} catch {
|
|
363
|
+
/* best effort */
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const requestStop = (reason: StopReason) => {
|
|
370
|
+
if (!requestedStop) requestedStop = reason;
|
|
371
|
+
// Cancellation may arrive before spawn. In that case remember the reason,
|
|
372
|
+
// then a second call immediately after spawn performs the actual signal.
|
|
373
|
+
if (forceKillTimer) return;
|
|
374
|
+
const pid = processHandle?.pid;
|
|
375
|
+
if (!pid) return;
|
|
376
|
+
try {
|
|
377
|
+
if (process.platform === "win32") {
|
|
378
|
+
const killer = spawn("taskkill", ["/pid", String(pid), "/T"], {
|
|
379
|
+
shell: false,
|
|
380
|
+
stdio: "ignore",
|
|
381
|
+
});
|
|
382
|
+
killer.unref();
|
|
383
|
+
} else {
|
|
384
|
+
process.kill(-pid, "SIGTERM");
|
|
385
|
+
}
|
|
386
|
+
} catch (error: any) {
|
|
387
|
+
if (error?.code !== "ESRCH") {
|
|
388
|
+
try {
|
|
389
|
+
processHandle?.kill("SIGTERM");
|
|
390
|
+
} catch {
|
|
391
|
+
/* best effort */
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
forceKillTimer = setTimeout(forceKillTree, this.killGraceMs);
|
|
396
|
+
forceKillTimer.unref?.();
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const stopStallWatchdog = () => {
|
|
400
|
+
if (stallTimer) clearInterval(stallTimer);
|
|
401
|
+
stallTimer = undefined;
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Activity-based stall detection: protocol silence for `stallAfterMs`
|
|
406
|
+
* flags the task as stalled (visible in checkpoints/status); continued
|
|
407
|
+
* silence for `stallKillAfterMs` more kills the child so retry can take
|
|
408
|
+
* over. Any protocol event clears the flag.
|
|
409
|
+
*/
|
|
410
|
+
const startStallWatchdog = () => {
|
|
411
|
+
if (this.stallAfterMs <= 0 || stallTimer) return;
|
|
412
|
+
const tick = Math.max(
|
|
413
|
+
1_000,
|
|
414
|
+
Math.min(10_000, Math.floor(this.stallAfterMs / 3)),
|
|
415
|
+
);
|
|
416
|
+
stallTimer = setInterval(() => {
|
|
417
|
+
if (requestedStop) return stopStallWatchdog();
|
|
418
|
+
const silence = Date.now() - lastEventAt;
|
|
419
|
+
if (silence < this.stallAfterMs) {
|
|
420
|
+
if (stalledAt !== undefined) {
|
|
421
|
+
stalledAt = undefined;
|
|
422
|
+
result.stalledSince = undefined;
|
|
423
|
+
progress({ stalledSince: undefined });
|
|
424
|
+
}
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (stalledAt === undefined) {
|
|
428
|
+
stalledAt = lastEventAt + this.stallAfterMs;
|
|
429
|
+
result.stalledSince = stalledAt;
|
|
430
|
+
progress({ stalledSince: stalledAt });
|
|
431
|
+
// Cheap liveness probe: a healthy-but-quiet child answers get_state,
|
|
432
|
+
// which itself counts as protocol activity and clears the flag.
|
|
433
|
+
this.sendCommand?.({ type: "get_state" });
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (
|
|
437
|
+
this.stallKillAfterMs > 0 &&
|
|
438
|
+
silence >= this.stallAfterMs + this.stallKillAfterMs
|
|
439
|
+
) {
|
|
440
|
+
stopStallWatchdog();
|
|
441
|
+
requestStop("stalled");
|
|
442
|
+
}
|
|
443
|
+
}, tick);
|
|
444
|
+
stallTimer.unref?.();
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const cleanup = async () => {
|
|
448
|
+
this.sendCommand = undefined;
|
|
449
|
+
clearTimeout(timeout);
|
|
450
|
+
stopStallWatchdog();
|
|
451
|
+
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
452
|
+
if (startupTimer) clearTimeout(startupTimer);
|
|
453
|
+
startupTimer = undefined;
|
|
454
|
+
// Pending startup waiters must never keep the run (or a stale session) alive.
|
|
455
|
+
settleStartupWaiters();
|
|
456
|
+
internal.signal.removeEventListener("abort", onInternalAbort);
|
|
457
|
+
abortSignal?.removeEventListener("abort", onExternalAbort);
|
|
458
|
+
if (abortSignal && abortHandler)
|
|
459
|
+
abortSignal.removeEventListener("abort", abortHandler);
|
|
460
|
+
processHandle?.stdout?.removeAllListeners();
|
|
461
|
+
processHandle?.stderr?.removeAllListeners();
|
|
462
|
+
processHandle?.removeAllListeners();
|
|
463
|
+
for (const dir of tempDirs)
|
|
464
|
+
await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
465
|
+
release();
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
// Transcript joins are O(transcript) — only attach them on structural
|
|
469
|
+
// updates (message boundaries), not per-chunk live-text ticks.
|
|
470
|
+
const progress = (partial: Partial<TaskResult>, withTranscript = false) => {
|
|
471
|
+
Object.assign(result, partial);
|
|
472
|
+
const checkpoint: Partial<TaskResult> = {
|
|
473
|
+
...result,
|
|
474
|
+
liveText: parser.getLiveText(),
|
|
475
|
+
};
|
|
476
|
+
if (withTranscript) checkpoint.transcript = parser.getTranscript();
|
|
477
|
+
else delete checkpoint.transcript;
|
|
478
|
+
this.onCheckpoint?.(checkpoint);
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Budget breach → graceful wrap-up: steer the child to answer NOW and
|
|
483
|
+
* allow `graceTurns` more turns. SIGTERM fires only when grace is
|
|
484
|
+
* exhausted (or configured to 0, or steering is impossible).
|
|
485
|
+
*/
|
|
486
|
+
const handleBudgetBreach = (
|
|
487
|
+
reason: "max_turns" | "max_cost",
|
|
488
|
+
turns: number,
|
|
489
|
+
) => {
|
|
490
|
+
if (requestedStop || pendingBudgetStop) {
|
|
491
|
+
if (pendingBudgetStop && turns >= pendingBudgetStop.deadlineTurns)
|
|
492
|
+
requestStop(pendingBudgetStop.reason);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const grace = spec.graceTurns ?? this.graceTurns;
|
|
496
|
+
if (
|
|
497
|
+
grace <= 0 ||
|
|
498
|
+
!this.sendCommand?.({ type: "steer", message: WRAP_UP_MESSAGE })
|
|
499
|
+
) {
|
|
500
|
+
requestStop(reason);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
pendingBudgetStop = { reason, deadlineTurns: turns + grace };
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
const handleUpdates = (updates: ProtocolUpdate[]) => {
|
|
507
|
+
if (updates.length) {
|
|
508
|
+
lastEventAt = Date.now();
|
|
509
|
+
if (stalledAt !== undefined) {
|
|
510
|
+
stalledAt = undefined;
|
|
511
|
+
result.stalledSince = undefined;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
for (const update of updates) {
|
|
515
|
+
// Startup verification waiters are registered before the command is written,
|
|
516
|
+
// so a fast acknowledgement cannot race past its waiter.
|
|
517
|
+
if (startupWaiters.size) {
|
|
518
|
+
for (const waiter of [...startupWaiters]) {
|
|
519
|
+
if (!waiter.test(update)) continue;
|
|
520
|
+
startupWaiters.delete(waiter);
|
|
521
|
+
waiter.resolve(update);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (update.type === "session")
|
|
525
|
+
progress({ sessionId: update.sessionId });
|
|
526
|
+
if (update.type === "live-text")
|
|
527
|
+
progress({ liveText: update.liveText });
|
|
528
|
+
if (update.type === "message") {
|
|
529
|
+
result.messages = parser.getMessages();
|
|
530
|
+
result.usage = update.usage;
|
|
531
|
+
progress(
|
|
532
|
+
{
|
|
533
|
+
messages: result.messages,
|
|
534
|
+
usage: result.usage,
|
|
535
|
+
liveText: parser.getLiveText(),
|
|
536
|
+
},
|
|
537
|
+
true,
|
|
538
|
+
);
|
|
539
|
+
if (pendingBudgetStop) {
|
|
540
|
+
if (update.usage.turns >= pendingBudgetStop.deadlineTurns)
|
|
541
|
+
requestStop(pendingBudgetStop.reason);
|
|
542
|
+
} else {
|
|
543
|
+
const budget = this.checkBudgets(spec, result.usage);
|
|
544
|
+
if (budget) handleBudgetBreach(budget, update.usage.turns);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
// Headless children cannot answer extension UI dialogs; cancel so the child never hangs.
|
|
548
|
+
if (update.type === "ui-request")
|
|
549
|
+
this.sendCommand?.({
|
|
550
|
+
type: "extension_ui_response",
|
|
551
|
+
id: update.id,
|
|
552
|
+
cancelled: true,
|
|
553
|
+
});
|
|
554
|
+
if (update.type === "fatal") {
|
|
555
|
+
fatalError = update.error;
|
|
556
|
+
requestStop("fatal");
|
|
557
|
+
}
|
|
558
|
+
// RPC children stay alive until stdin closes; end it once the run settles.
|
|
559
|
+
if (update.type === "agent-settled") {
|
|
560
|
+
// A settle during the wrap-up window means the child finished its
|
|
561
|
+
// final answer in time.
|
|
562
|
+
if (pendingBudgetStop && !requestedStop) wrappedUp = true;
|
|
563
|
+
// Structured-output gate: validate before letting the child exit.
|
|
564
|
+
// Invalid → one steer-based repair round (a fresh prompt keeps the
|
|
565
|
+
// RPC child alive and produces a new settle when it finishes).
|
|
566
|
+
if (spec.outputSchema && !requestedStop && !pendingBudgetStop) {
|
|
567
|
+
const extracted = extractStructuredResult(parser.getLiveText());
|
|
568
|
+
const check =
|
|
569
|
+
extracted.value !== undefined
|
|
570
|
+
? checkAgainstSchema(extracted.value, spec.outputSchema)
|
|
571
|
+
: {
|
|
572
|
+
ok: false,
|
|
573
|
+
errors: [
|
|
574
|
+
extracted.raw
|
|
575
|
+
? "json:result block did not parse as JSON"
|
|
576
|
+
: "no json:result block found in the final message",
|
|
577
|
+
],
|
|
578
|
+
};
|
|
579
|
+
if (!check.ok && !schemaRepairAttempted) {
|
|
580
|
+
schemaRepairAttempted = true;
|
|
581
|
+
if (
|
|
582
|
+
this.sendCommand?.({
|
|
583
|
+
type: "prompt",
|
|
584
|
+
message: repairMessage(check.errors),
|
|
585
|
+
})
|
|
586
|
+
) {
|
|
587
|
+
lastEventAt = Date.now();
|
|
588
|
+
continue; // repair round in flight: do not close stdin yet
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
try {
|
|
593
|
+
processHandle?.stdin?.end();
|
|
594
|
+
} catch {
|
|
595
|
+
/* already closed */
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const applyTimeoutSemantics = (base: TaskResult): TaskResult => {
|
|
602
|
+
if (requestedStop !== "timeout") return base;
|
|
603
|
+
// Queue timeouts never start work: model them as a clean timeout with phase,
|
|
604
|
+
// not a mysterious execution "failed".
|
|
605
|
+
const phase = timeoutPhase ?? "running";
|
|
606
|
+
return {
|
|
607
|
+
...base,
|
|
608
|
+
state: "timeout",
|
|
609
|
+
stopReason: "timeout",
|
|
610
|
+
timeoutPhase: phase,
|
|
611
|
+
errorMessage:
|
|
612
|
+
phase === "queued"
|
|
613
|
+
? "Timed out waiting for a process slot (never started)"
|
|
614
|
+
: phase === "starting"
|
|
615
|
+
? "Timed out while starting the child process"
|
|
616
|
+
: base.errorMessage || "Timed out while the child was running",
|
|
617
|
+
};
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
try {
|
|
621
|
+
if (internal.signal.aborted) {
|
|
622
|
+
result.state = requestedStop === "timeout" ? "timeout" : "cancelled";
|
|
623
|
+
result.stopReason = requestedStop ?? "cancelled";
|
|
624
|
+
result.timeoutPhase =
|
|
625
|
+
requestedStop === "timeout" ? (timeoutPhase ?? "queued") : undefined;
|
|
626
|
+
result.exitCode = 1;
|
|
627
|
+
result.endedAt = Date.now();
|
|
628
|
+
if (result.state === "timeout" && !result.errorMessage) {
|
|
629
|
+
result.errorMessage =
|
|
630
|
+
"Timed out waiting for a process slot (never started)";
|
|
631
|
+
}
|
|
632
|
+
return result;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// A routed spec must carry the finalized model + explicit tool ceiling before
|
|
636
|
+
// anything is spawned; otherwise the child's active set could not be verified.
|
|
637
|
+
if (routed) {
|
|
638
|
+
if (!spec.model?.trim()) {
|
|
639
|
+
return markStartupFailure(
|
|
640
|
+
result,
|
|
641
|
+
"model_missing",
|
|
642
|
+
"A routed subagent task must carry the Jev-selected execution model.",
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
if (!Array.isArray(spec.tools)) {
|
|
646
|
+
return markStartupFailure(
|
|
647
|
+
result,
|
|
648
|
+
"tools_missing",
|
|
649
|
+
"A routed subagent task must carry the finalized tool allowlist so the child's active set can be verified.",
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
// The absolute task deadline is shared across attempts and is honored here,
|
|
653
|
+
// before a slot is taken or a child is spawned. It is never reset later.
|
|
654
|
+
if (deadlineRemainingMs !== undefined && deadlineRemainingMs <= 0) {
|
|
655
|
+
timeoutPhase = "queued";
|
|
656
|
+
requestStop("timeout");
|
|
657
|
+
internal.abort();
|
|
658
|
+
throw new Error("The task deadline expired before the child could start.");
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Global cap (if configured) is checked before the per-session semaphore so
|
|
663
|
+
// a saturated machine rejects early with a clear message. Depth is the
|
|
664
|
+
// parent process nest level (PI_SUBAGENT_DEPTH via parseDepth): shallow tiers
|
|
665
|
+
// reserve capacity so nested spawns cannot deadlock on a full pool.
|
|
666
|
+
if (this.locks) {
|
|
667
|
+
try {
|
|
668
|
+
globalSlot = this.locks.tryAcquireGlobalSlot(
|
|
669
|
+
this.runId ?? "anonymous",
|
|
670
|
+
parseDepth(),
|
|
671
|
+
);
|
|
672
|
+
} catch (error: any) {
|
|
673
|
+
result.state = "failed";
|
|
674
|
+
result.stopReason = "global_limit";
|
|
675
|
+
result.errorMessage = error?.message ?? String(error);
|
|
676
|
+
result.exitCode = 1;
|
|
677
|
+
result.endedAt = Date.now();
|
|
678
|
+
return result;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
await this.semaphore.acquire(internal.signal);
|
|
683
|
+
slotHeld = true;
|
|
684
|
+
acquiredAt = Date.now();
|
|
685
|
+
result.acquiredAt = acquiredAt;
|
|
686
|
+
if (internal.signal.aborted)
|
|
687
|
+
throw new Error("Subagent cancelled before spawn");
|
|
688
|
+
if (abortSignal) {
|
|
689
|
+
abortHandler = () => requestStop("cancelled");
|
|
690
|
+
abortSignal.addEventListener("abort", abortHandler, { once: true });
|
|
691
|
+
}
|
|
692
|
+
result.state = "running";
|
|
693
|
+
progress({ state: "running", acquiredAt });
|
|
694
|
+
|
|
695
|
+
await fs.mkdir(this.sessionDir, { recursive: true });
|
|
696
|
+
if (internal.signal.aborted)
|
|
697
|
+
throw new Error("Subagent cancelled before spawn");
|
|
698
|
+
|
|
699
|
+
const taskBytes = Buffer.byteLength(spec.task, "utf8");
|
|
700
|
+
if (taskBytes > this.maxTaskBytes) {
|
|
701
|
+
throw new Error(
|
|
702
|
+
`Task exceeds maxTaskBytes (${taskBytes} > ${this.maxTaskBytes}). Pass a shorter objective or raise the limit.`,
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const invocation = await backend.buildInvocation(spec, {
|
|
707
|
+
sessionDir: this.sessionDir,
|
|
708
|
+
getPiCommand: this.getPiCommand,
|
|
709
|
+
});
|
|
710
|
+
if (invocation.cleanupDirs?.length)
|
|
711
|
+
tempDirs.push(...invocation.cleanupDirs);
|
|
712
|
+
// Invocation construction performs filesystem awaits. Cancellation/deadline must be
|
|
713
|
+
// rechecked before spawning, otherwise an earlier stop had no process to terminate.
|
|
714
|
+
if (internal.signal.aborted) throw new Error("Subagent cancelled before spawn");
|
|
715
|
+
if (absoluteDeadline !== undefined && Date.now() >= absoluteDeadline) {
|
|
716
|
+
timeoutPhase = "starting";
|
|
717
|
+
requestStop("timeout");
|
|
718
|
+
internal.abort();
|
|
719
|
+
throw new Error("Subagent deadline expired before spawn");
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const depth = Number.parseInt(process.env[DEPTH_ENV_VAR] ?? "0", 10) || 0;
|
|
723
|
+
// Pin the launch identity + depth in env. Children re-register only when
|
|
724
|
+
// depth leaves remaining headroom (enforced in extension + policy too).
|
|
725
|
+
// Encode the child's own spawn allowlist so grandchildren validate against it.
|
|
726
|
+
const spawnEnv =
|
|
727
|
+
spec.spawns === false
|
|
728
|
+
? ""
|
|
729
|
+
: Array.isArray(spec.spawns)
|
|
730
|
+
? spec.spawns.join(",")
|
|
731
|
+
: spec.spawns === "*"
|
|
732
|
+
? "*"
|
|
733
|
+
: undefined;
|
|
734
|
+
// Trusted backend env (e.g. the temporary preflight manifest path) is merged
|
|
735
|
+
// over the inherited environment, but the depth/spawn controls stay authoritative:
|
|
736
|
+
// they are stripped from the backend env and re-applied last.
|
|
737
|
+
const trustedEnv: Record<string, string> = { ...(invocation.env ?? {}) };
|
|
738
|
+
delete trustedEnv[DEPTH_ENV_VAR];
|
|
739
|
+
delete trustedEnv[SPAWNS_ENV_VAR];
|
|
740
|
+
const childEnv: NodeJS.ProcessEnv = {
|
|
741
|
+
...process.env,
|
|
742
|
+
...trustedEnv,
|
|
743
|
+
[DEPTH_ENV_VAR]: String(depth + 1),
|
|
744
|
+
...(spawnEnv !== undefined ? { [SPAWNS_ENV_VAR]: spawnEnv } : {}),
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
processHandle = spawn(invocation.command, invocation.args, {
|
|
748
|
+
cwd: spec.cwd || process.cwd(),
|
|
749
|
+
shell: false,
|
|
750
|
+
detached: process.platform !== "win32",
|
|
751
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
752
|
+
env: childEnv,
|
|
753
|
+
});
|
|
754
|
+
spawned = true;
|
|
755
|
+
|
|
756
|
+
const pid = processHandle.pid;
|
|
757
|
+
if (pid) {
|
|
758
|
+
childStartTime = processStartTime(pid);
|
|
759
|
+
const identity: ChildProcessIdentity = {
|
|
760
|
+
pid,
|
|
761
|
+
startTime: childStartTime,
|
|
762
|
+
// On POSIX the child is a new process group leader (detached).
|
|
763
|
+
pgid: process.platform === "win32" ? undefined : pid,
|
|
764
|
+
hostname: os.hostname(),
|
|
765
|
+
};
|
|
766
|
+
result.process = identity;
|
|
767
|
+
progress({ process: identity });
|
|
768
|
+
if (this.locks && this.runId) {
|
|
769
|
+
this.locks.writeRunRecord({
|
|
770
|
+
runId: this.runId,
|
|
771
|
+
parentSessionKey: this.parentSessionKey ?? "",
|
|
772
|
+
childSessionId: result.sessionId,
|
|
773
|
+
// Worktree-isolated runs record their checkout so concurrent Pi
|
|
774
|
+
// processes' machine-wide GC sweeps can shield it while we live.
|
|
775
|
+
worktreeCwd: spec.isolation === "worktree" ? spec.cwd : undefined,
|
|
776
|
+
process: {
|
|
777
|
+
pid: identity.pid,
|
|
778
|
+
startTime: identity.startTime,
|
|
779
|
+
pgid: identity.pgid,
|
|
780
|
+
hostname: identity.hostname ?? os.hostname(),
|
|
781
|
+
},
|
|
782
|
+
startedAt: Date.now(),
|
|
783
|
+
state: "running",
|
|
784
|
+
updatedAt: Date.now(),
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (requestedStop) requestStop(requestedStop);
|
|
790
|
+
else if (internal.signal.aborted) requestStop("cancelled");
|
|
791
|
+
|
|
792
|
+
// Attach readers BEFORE writing stdin so a chatty child cannot fill the
|
|
793
|
+
// OS pipe buffer and deadlock waiting for us to drain.
|
|
794
|
+
processHandle.stdout?.on("data", (chunk: Buffer) =>
|
|
795
|
+
handleUpdates(parser.feed(chunk)),
|
|
796
|
+
);
|
|
797
|
+
processHandle.stderr?.on("data", (chunk: Buffer) => {
|
|
798
|
+
stderr = (stderr + chunk.toString()).slice(-50 * 1024);
|
|
799
|
+
result.stderr = stderr;
|
|
800
|
+
});
|
|
801
|
+
processHandle.stdin?.on("error", (error: NodeJS.ErrnoException) => {
|
|
802
|
+
// EPIPE is expected when a child fails before consuming stdin.
|
|
803
|
+
if (error.code !== "EPIPE")
|
|
804
|
+
result.errorMessage = `stdin error: ${error.message}`;
|
|
805
|
+
});
|
|
806
|
+
const send = (command: unknown): boolean => {
|
|
807
|
+
const stdin = processHandle?.stdin;
|
|
808
|
+
if (!stdin || !stdin.writable || stdin.destroyed) return false;
|
|
809
|
+
try {
|
|
810
|
+
stdin.write(JSON.stringify(command) + "\n"); // JSONL: LF-delimited, JSON escapes embedded newlines
|
|
811
|
+
return true;
|
|
812
|
+
} catch {
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
if (!routed) this.sendCommand = send;
|
|
817
|
+
|
|
818
|
+
// The close promise is created before any startup traffic so the handshake, the
|
|
819
|
+
// real task and the final await all observe exactly one exit event.
|
|
820
|
+
const closedPromise = new Promise<{
|
|
821
|
+
code: number | null;
|
|
822
|
+
signal: NodeJS.Signals | null;
|
|
823
|
+
error?: Error;
|
|
824
|
+
}>((resolve) => {
|
|
825
|
+
let settled = false;
|
|
826
|
+
const finish = (value: {
|
|
827
|
+
code: number | null;
|
|
828
|
+
signal: NodeJS.Signals | null;
|
|
829
|
+
error?: Error;
|
|
830
|
+
}) => {
|
|
831
|
+
if (settled) return;
|
|
832
|
+
settled = true;
|
|
833
|
+
resolve(value);
|
|
834
|
+
};
|
|
835
|
+
processHandle!.once("close", (code, signal) =>
|
|
836
|
+
finish({ code, signal }),
|
|
837
|
+
);
|
|
838
|
+
processHandle!.once("error", (error) =>
|
|
839
|
+
finish({ code: 1, signal: null, error }),
|
|
840
|
+
);
|
|
841
|
+
});
|
|
842
|
+
closedPromise.then((value) => {
|
|
843
|
+
childExited = value;
|
|
844
|
+
// A dead child will never answer; unblock startup waiters immediately.
|
|
845
|
+
settleStartupWaiters();
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
type StartupOutcome =
|
|
849
|
+
| { kind: "ok" }
|
|
850
|
+
| { kind: "cancelled" }
|
|
851
|
+
| { kind: "failed"; code: string; detail: string };
|
|
852
|
+
|
|
853
|
+
const describeChildExit = (): string => {
|
|
854
|
+
if (!childExited) return "exit status not observed";
|
|
855
|
+
if (childExited.signal) return `signal ${childExited.signal}`;
|
|
856
|
+
return `exit code ${childExited.code ?? "unknown"}`;
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
/** Stop a child that failed startup verification, bounded by the kill grace. */
|
|
860
|
+
const stopChildForStartupFailure = async () => {
|
|
861
|
+
if (!processHandle) return;
|
|
862
|
+
requestStop("fatal");
|
|
863
|
+
await Promise.race([
|
|
864
|
+
closedPromise,
|
|
865
|
+
new Promise<void>((resolve) => {
|
|
866
|
+
const timer = setTimeout(resolve, Math.max(0, this.killGraceMs));
|
|
867
|
+
timer.unref?.();
|
|
868
|
+
}),
|
|
869
|
+
]);
|
|
870
|
+
forceKillTree();
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Provider-free startup handshake. Returns `ok` only after the child's active
|
|
875
|
+
* model and tool set were both proven to match the finalized route.
|
|
876
|
+
* Never submits an unverified slash command: an unknown command would be treated
|
|
877
|
+
* as an ordinary model prompt.
|
|
878
|
+
*/
|
|
879
|
+
const runStartupPreflight = async (
|
|
880
|
+
manifestPath: string | undefined,
|
|
881
|
+
): Promise<StartupOutcome> => {
|
|
882
|
+
const interruption = (): StartupOutcome | undefined => {
|
|
883
|
+
if (abortSignal?.aborted || requestedStop === "cancelled") return { kind: "cancelled" };
|
|
884
|
+
if (startupTimedOut || requestedStop === "timeout")
|
|
885
|
+
return { kind: "failed", code: "startup_timeout", detail: startupTimeoutDetail(startupBudgetMs) };
|
|
886
|
+
if (childExited)
|
|
887
|
+
return {
|
|
888
|
+
kind: "failed",
|
|
889
|
+
code: "child_exit",
|
|
890
|
+
detail: `The child process exited during startup verification (${describeChildExit()}).`,
|
|
891
|
+
};
|
|
892
|
+
return undefined;
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
// 1. Read the expectation the backend handed to the child and cross-check it
|
|
896
|
+
// against the spec we are about to enforce (defence in depth).
|
|
897
|
+
let expectation: PreflightExpectation;
|
|
898
|
+
try {
|
|
899
|
+
if (!manifestPath) {
|
|
900
|
+
throw startupFailure(
|
|
901
|
+
"preflight_manifest_missing",
|
|
902
|
+
"The Pi backend did not provide a startup expectation manifest for this routed task.",
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
const raw = await fs.readFile(manifestPath, "utf8");
|
|
906
|
+
const parsed = parsePreflightManifest(raw);
|
|
907
|
+
if (!parsed.ok) throw startupFailure(parsed.code, parsed.message);
|
|
908
|
+
if (parsed.manifest.model !== spec.model) {
|
|
909
|
+
throw startupFailure(
|
|
910
|
+
"preflight_manifest_mismatch",
|
|
911
|
+
"The child's startup manifest model did not match the finalized route model.",
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
if (!sameNameSet(parsed.manifest.tools, spec.tools ?? [])) {
|
|
915
|
+
throw startupFailure(
|
|
916
|
+
"preflight_manifest_mismatch",
|
|
917
|
+
"The child's startup manifest tool allowlist did not match the finalized route tools.",
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
expectation = {
|
|
921
|
+
nonce: parsed.manifest.nonce,
|
|
922
|
+
model: parsed.manifest.model,
|
|
923
|
+
tools: parsed.manifest.tools,
|
|
924
|
+
nestedTools: parsed.manifest.nestedTools,
|
|
925
|
+
ownEntryPaths: ownExtensionEntryCandidates(),
|
|
926
|
+
preflightCommandPath: ownPreflightExtensionPath(),
|
|
927
|
+
};
|
|
928
|
+
} catch (error) {
|
|
929
|
+
const failure = readStartupFailure(error);
|
|
930
|
+
if (failure) return { kind: "failed", ...failure };
|
|
931
|
+
if (error instanceof Error && /cancel|abort/i.test(error.message)) return { kind: "cancelled" };
|
|
932
|
+
return {
|
|
933
|
+
kind: "failed",
|
|
934
|
+
code: "preflight_manifest_unreadable",
|
|
935
|
+
detail: "The startup expectation manifest could not be read.",
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// 2. Correlated get_commands polling: extensions may still be loading, so an
|
|
940
|
+
// early empty answer is not yet a failure.
|
|
941
|
+
const baseName = preflightCommandBase(expectation.nonce);
|
|
942
|
+
const pollDeadline = Date.now() + Math.max(0, startupBudgetMs);
|
|
943
|
+
let verified: { invocableName: string } | undefined;
|
|
944
|
+
let lastResolution: string | undefined;
|
|
945
|
+
for (let attempt = 1; attempt <= MAX_STARTUP_COMMAND_POLLS; attempt += 1) {
|
|
946
|
+
// Cancellation and child death end the loop immediately; an expired budget
|
|
947
|
+
// falls through to the post-loop diagnosis so the remedy stays specific.
|
|
948
|
+
const stop = interruption();
|
|
949
|
+
if (stop && stop.kind === "cancelled") return stop;
|
|
950
|
+
if (stop && stop.code === "child_exit") return stop;
|
|
951
|
+
if (stop) break;
|
|
952
|
+
const requestId = `pi-subagent-preflight-cmd-${attempt}`;
|
|
953
|
+
const responseWait = waitForUpdate(
|
|
954
|
+
(update) => update.type === "rpc-response" && update.id === requestId,
|
|
955
|
+
);
|
|
956
|
+
if (!send({ type: "get_commands", id: requestId })) {
|
|
957
|
+
return {
|
|
958
|
+
kind: "failed",
|
|
959
|
+
code: "child_stdin_closed",
|
|
960
|
+
detail: "The child's command channel closed before startup verification could run.",
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
const update = await responseWait;
|
|
964
|
+
const stopAfterResponse = interruption();
|
|
965
|
+
if (stopAfterResponse && stopAfterResponse.kind === "cancelled") return stopAfterResponse;
|
|
966
|
+
if (stopAfterResponse && stopAfterResponse.code === "child_exit") return stopAfterResponse;
|
|
967
|
+
if (stopAfterResponse) break;
|
|
968
|
+
if (update && update.type === "rpc-response") {
|
|
969
|
+
if (!update.success) {
|
|
970
|
+
return {
|
|
971
|
+
kind: "failed",
|
|
972
|
+
code: "get_commands_rejected",
|
|
973
|
+
detail: "The child host rejected the capability probe required for startup verification.",
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
const commands = (update.data as { commands?: unknown } | undefined)?.commands;
|
|
977
|
+
const expectedCommandPaths = expectation.preflightCommandPath
|
|
978
|
+
? [expectation.preflightCommandPath]
|
|
979
|
+
: [];
|
|
980
|
+
const resolution = resolvePreflightCommand(commands, baseName, expectedCommandPaths);
|
|
981
|
+
if (resolution.ok) {
|
|
982
|
+
verified = { invocableName: resolution.invocableName };
|
|
983
|
+
break;
|
|
984
|
+
}
|
|
985
|
+
lastResolution = summarizeCommandResolution(resolution);
|
|
986
|
+
}
|
|
987
|
+
if (Date.now() >= pollDeadline) break;
|
|
988
|
+
await sleep(STARTUP_COMMAND_POLL_MS);
|
|
989
|
+
}
|
|
990
|
+
if (!verified) {
|
|
991
|
+
const stop = interruption();
|
|
992
|
+
// A definite observation (we saw get_commands answers) gives a better remedy
|
|
993
|
+
// than the generic budget message, but never mask a cancellation or a death.
|
|
994
|
+
if (stop && stop.kind === "cancelled") return stop;
|
|
995
|
+
if (stop && stop.code === "child_exit") return stop;
|
|
996
|
+
if (lastResolution !== undefined) {
|
|
997
|
+
return {
|
|
998
|
+
kind: "failed",
|
|
999
|
+
code: "preflight_command_unavailable",
|
|
1000
|
+
detail: `The private startup command was not available from the expected package source (${lastResolution}).`,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
if (stop) return stop;
|
|
1004
|
+
return {
|
|
1005
|
+
kind: "failed",
|
|
1006
|
+
code: "preflight_command_unavailable",
|
|
1007
|
+
detail: "The private startup command was not available from the expected package source.",
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// 3. Invoke only the verified command and require BOTH a successful correlated
|
|
1012
|
+
// response and a nonce-matching typed acknowledgement.
|
|
1013
|
+
const promptId = "pi-subagent-preflight-prompt";
|
|
1014
|
+
// Any typed acknowledgement is accepted here and validated below, so a wrong-nonce
|
|
1015
|
+
// or malformed answer fails fast with a precise reason instead of a generic budget
|
|
1016
|
+
// timeout. There is exactly one child, so no stale acknowledgement can arrive.
|
|
1017
|
+
const ackWait = waitForUpdate((update) => update.type === "preflight-ack");
|
|
1018
|
+
const promptResponseWait = waitForUpdate(
|
|
1019
|
+
(update) => update.type === "rpc-response" && update.id === promptId,
|
|
1020
|
+
);
|
|
1021
|
+
if (!send({ type: "prompt", message: `/${verified.invocableName}`, id: promptId })) {
|
|
1022
|
+
return {
|
|
1023
|
+
kind: "failed",
|
|
1024
|
+
code: "child_stdin_closed",
|
|
1025
|
+
detail: "The child's command channel closed before the startup command could be invoked.",
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
const promptResponse = await promptResponseWait;
|
|
1029
|
+
const stopAfterPrompt = interruption();
|
|
1030
|
+
if (stopAfterPrompt) return stopAfterPrompt;
|
|
1031
|
+
if (!promptResponse || promptResponse.type !== "rpc-response" || !promptResponse.success) {
|
|
1032
|
+
return {
|
|
1033
|
+
kind: "failed",
|
|
1034
|
+
code: "preflight_prompt_rejected",
|
|
1035
|
+
detail: "The child rejected its verified startup command.",
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
const ackUpdate = await ackWait;
|
|
1039
|
+
const stopAfterAck = interruption();
|
|
1040
|
+
if (!ackUpdate || ackUpdate.type !== "preflight-ack") {
|
|
1041
|
+
if (stopAfterAck && stopAfterAck.kind === "cancelled") return stopAfterAck;
|
|
1042
|
+
if (stopAfterAck && stopAfterAck.code === "child_exit") return stopAfterAck;
|
|
1043
|
+
return {
|
|
1044
|
+
kind: "failed",
|
|
1045
|
+
code: "preflight_ack_missing",
|
|
1046
|
+
detail: startupTimeoutDetail(startupBudgetMs),
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
if (stopAfterAck) return stopAfterAck;
|
|
1050
|
+
const ack = parsePreflightAckContent(ackUpdate.content);
|
|
1051
|
+
if (ack === null) {
|
|
1052
|
+
return {
|
|
1053
|
+
kind: "failed",
|
|
1054
|
+
code: "preflight_ack_malformed",
|
|
1055
|
+
detail: "The child's startup acknowledgement was not bounded, valid JSON.",
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
if (ack.nonce !== expectation.nonce) {
|
|
1059
|
+
return {
|
|
1060
|
+
kind: "failed",
|
|
1061
|
+
code: "preflight_ack_nonce_mismatch",
|
|
1062
|
+
detail: "The child's startup acknowledgement did not carry this invocation's correlation nonce.",
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
const problems = verifyPreflightAck(ack, expectation);
|
|
1066
|
+
if (problems.length > 0) {
|
|
1067
|
+
return { kind: "failed", code: "preflight_ack_rejected", detail: summarizePreflightProblems(problems) };
|
|
1068
|
+
}
|
|
1069
|
+
return { kind: "ok" };
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
let startupOutcome: StartupOutcome = { kind: "ok" };
|
|
1073
|
+
if (routed) {
|
|
1074
|
+
// Bounded by both the local startup budget and the remaining absolute task time.
|
|
1075
|
+
startupBudgetMs = Math.min(
|
|
1076
|
+
this.startupTimeoutMs,
|
|
1077
|
+
Math.max(0, effectiveTimeoutMs - (Date.now() - startedAt)),
|
|
1078
|
+
);
|
|
1079
|
+
startupTimer = setTimeout(() => {
|
|
1080
|
+
startupTimedOut = true;
|
|
1081
|
+
settleStartupWaiters();
|
|
1082
|
+
}, startupBudgetMs);
|
|
1083
|
+
startupTimer.unref?.();
|
|
1084
|
+
startupOutcome = await runStartupPreflight(invocation.env?.[PREFLIGHT_MANIFEST_ENV]);
|
|
1085
|
+
if (startupTimer) {
|
|
1086
|
+
clearTimeout(startupTimer);
|
|
1087
|
+
startupTimer = undefined;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// Keep public steering unavailable until verification, and recheck cancellation
|
|
1092
|
+
// even when the final acknowledgement was delivered in the same microtask turn.
|
|
1093
|
+
if (startupOutcome.kind === "ok" && (internal.signal.aborted || abortSignal?.aborted)) {
|
|
1094
|
+
startupOutcome = { kind: "cancelled" };
|
|
1095
|
+
}
|
|
1096
|
+
if (startupOutcome.kind === "failed") {
|
|
1097
|
+
// Capability mismatch is not transient: never compensate by broadening tools,
|
|
1098
|
+
// choosing another model or retrying into an unverified launch.
|
|
1099
|
+
await stopChildForStartupFailure();
|
|
1100
|
+
throw startupFailure(startupOutcome.code, startupOutcome.detail);
|
|
1101
|
+
}
|
|
1102
|
+
if (startupOutcome.kind === "cancelled") {
|
|
1103
|
+
// Cancelled/timed out during startup: never send the real task prompt.
|
|
1104
|
+
if (!requestedStop) requestStop("cancelled");
|
|
1105
|
+
} else {
|
|
1106
|
+
this.sendCommand = send;
|
|
1107
|
+
taskPromptSent = send({ type: "prompt", message: spec.task });
|
|
1108
|
+
// RPC mode has no session header line; get_state supplies the session id.
|
|
1109
|
+
send({ type: "get_state" });
|
|
1110
|
+
}
|
|
1111
|
+
lastEventAt = Date.now();
|
|
1112
|
+
startStallWatchdog();
|
|
1113
|
+
|
|
1114
|
+
const closed = await closedPromise;
|
|
1115
|
+
|
|
1116
|
+
handleUpdates(parser.flush());
|
|
1117
|
+
// The child owns a dedicated process group. Reap descendants even when the
|
|
1118
|
+
// direct Pi process exits normally after a tool backgrounds work — unless
|
|
1119
|
+
// the task explicitly opted into keeping backgrounded processes alive.
|
|
1120
|
+
if (!spec.keepBackground || requestedStop) forceKillTree();
|
|
1121
|
+
const finalized = parser.finalize(
|
|
1122
|
+
closed.code,
|
|
1123
|
+
closed.signal ?? undefined,
|
|
1124
|
+
stderr,
|
|
1125
|
+
);
|
|
1126
|
+
Object.assign(result, finalized, {
|
|
1127
|
+
label: result.label,
|
|
1128
|
+
task: spec.task,
|
|
1129
|
+
outputFile: spec.output,
|
|
1130
|
+
outputMode: spec.outputMode,
|
|
1131
|
+
thinking: spec.thinking,
|
|
1132
|
+
profile: spec.profile,
|
|
1133
|
+
backend: spec.backend ?? "pi",
|
|
1134
|
+
model: finalized.model ?? result.model ?? spec.model,
|
|
1135
|
+
canWrite: spec.canWrite,
|
|
1136
|
+
process: result.process,
|
|
1137
|
+
startedAt,
|
|
1138
|
+
acquiredAt,
|
|
1139
|
+
endedAt: Date.now(),
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
if (closed.error) {
|
|
1143
|
+
result.state = "failed";
|
|
1144
|
+
result.stopReason = "spawn_error";
|
|
1145
|
+
result.errorMessage = closed.error.message;
|
|
1146
|
+
} else if (requestedStop) {
|
|
1147
|
+
if (requestedStop === "timeout") {
|
|
1148
|
+
Object.assign(result, applyTimeoutSemantics(result));
|
|
1149
|
+
} else if (requestedStop === "cancelled") {
|
|
1150
|
+
result.state = "cancelled";
|
|
1151
|
+
result.stopReason = "cancelled";
|
|
1152
|
+
result.exitCode = closed.code;
|
|
1153
|
+
} else if (requestedStop === "stalled") {
|
|
1154
|
+
// Stall kill is a transient infrastructure failure (retryable), but
|
|
1155
|
+
// completed turns still carry useful output.
|
|
1156
|
+
result.state = result.usage.turns > 0 ? "partial" : "failed";
|
|
1157
|
+
result.stopReason = "stalled";
|
|
1158
|
+
result.exitCode = closed.code ?? 1;
|
|
1159
|
+
result.stalledSince = stalledAt;
|
|
1160
|
+
result.errorMessage = `Child produced no protocol activity for ${Math.round((this.stallAfterMs + this.stallKillAfterMs) / 1000)}s and was stopped`;
|
|
1161
|
+
} else if (BUDGET_STOPS.has(requestedStop) && result.usage.turns > 0) {
|
|
1162
|
+
result.state = "partial";
|
|
1163
|
+
result.stopReason = requestedStop;
|
|
1164
|
+
result.exitCode = closed.code;
|
|
1165
|
+
result.errorMessage = `Stopped by ${requestedStop.replace("_", " ")} budget after the wrap-up grace period; partial output preserved`;
|
|
1166
|
+
} else {
|
|
1167
|
+
result.state = "failed";
|
|
1168
|
+
result.stopReason =
|
|
1169
|
+
requestedStop === "fatal" ? "error" : requestedStop;
|
|
1170
|
+
result.exitCode = closed.code ?? 1;
|
|
1171
|
+
if (fatalError) result.errorMessage = fatalError;
|
|
1172
|
+
}
|
|
1173
|
+
} else if (
|
|
1174
|
+
pendingBudgetStop &&
|
|
1175
|
+
(result.state as TaskResult["state"]) === "completed"
|
|
1176
|
+
) {
|
|
1177
|
+
// Budget breached, but the child wrapped up its final answer within the
|
|
1178
|
+
// grace turns: a concluded (if budget-limited) result, not a truncation.
|
|
1179
|
+
result.state = "partial";
|
|
1180
|
+
result.stopReason = pendingBudgetStop.reason;
|
|
1181
|
+
result.wrappedUp = true;
|
|
1182
|
+
result.errorMessage = `Reached ${pendingBudgetStop.reason.replace("_", " ")} budget and wrapped up gracefully`;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// Structured-output verdict: validate the final text once, after any
|
|
1186
|
+
// repair round. Failure downgrades completed → partial (paid work is
|
|
1187
|
+
// still delivered; the parent sees why it is not machine-readable).
|
|
1188
|
+
if (spec.outputSchema) {
|
|
1189
|
+
const extracted = extractStructuredResult(result.liveText);
|
|
1190
|
+
const check =
|
|
1191
|
+
extracted.value !== undefined
|
|
1192
|
+
? checkAgainstSchema(extracted.value, spec.outputSchema)
|
|
1193
|
+
: {
|
|
1194
|
+
ok: false,
|
|
1195
|
+
errors: [
|
|
1196
|
+
extracted.raw
|
|
1197
|
+
? "json:result block did not parse as JSON"
|
|
1198
|
+
: "no json:result block found in the final message",
|
|
1199
|
+
],
|
|
1200
|
+
};
|
|
1201
|
+
if (check.ok) {
|
|
1202
|
+
result.structuredOutput = extracted.value;
|
|
1203
|
+
} else {
|
|
1204
|
+
result.structuredError = check.errors.slice(0, 10).join("; ");
|
|
1205
|
+
if ((result.state as TaskResult["state"]) === "completed") {
|
|
1206
|
+
result.state = "partial";
|
|
1207
|
+
result.stopReason = "schema_mismatch";
|
|
1208
|
+
result.errorMessage = `Structured output failed validation${schemaRepairAttempted ? " (after one repair round)" : ""}: ${result.structuredError}`;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
if (this.locks && this.runId) {
|
|
1214
|
+
this.locks.markRunTerminal(this.runId, result.state);
|
|
1215
|
+
}
|
|
1216
|
+
return result;
|
|
1217
|
+
} catch (error: any) {
|
|
1218
|
+
// A routed child that could not be verified is a non-transient capability refusal:
|
|
1219
|
+
// plain Error + owned code, never a custom subclass or a transient retry.
|
|
1220
|
+
const startupFailureInfo = readStartupFailure(error);
|
|
1221
|
+
if (startupFailureInfo && requestedStop !== "timeout" && !abortSignal?.aborted) {
|
|
1222
|
+
markStartupFailure(result, startupFailureInfo.code, startupFailureInfo.detail);
|
|
1223
|
+
if (this.locks && this.runId)
|
|
1224
|
+
this.locks.markRunTerminal(this.runId, result.state);
|
|
1225
|
+
return result;
|
|
1226
|
+
}
|
|
1227
|
+
const cancelled =
|
|
1228
|
+
(abortSignal?.aborted && requestedStop !== "timeout") ||
|
|
1229
|
+
/cancel/i.test(String(error?.message));
|
|
1230
|
+
if (requestedStop === "timeout") {
|
|
1231
|
+
result.state = "timeout";
|
|
1232
|
+
result.stopReason = "timeout";
|
|
1233
|
+
result.timeoutPhase =
|
|
1234
|
+
timeoutPhase ?? (!slotHeld ? "queued" : "running");
|
|
1235
|
+
result.errorMessage =
|
|
1236
|
+
result.timeoutPhase === "queued"
|
|
1237
|
+
? "Timed out waiting for a process slot (never started)"
|
|
1238
|
+
: (error?.message ?? "Timed out");
|
|
1239
|
+
} else {
|
|
1240
|
+
result.state = cancelled ? "cancelled" : "failed";
|
|
1241
|
+
result.stopReason =
|
|
1242
|
+
requestedStop ??
|
|
1243
|
+
(result.state === "cancelled" ? "cancelled" : "error");
|
|
1244
|
+
result.errorMessage = error?.message ?? String(error);
|
|
1245
|
+
}
|
|
1246
|
+
result.exitCode ??= 1;
|
|
1247
|
+
result.endedAt = Date.now();
|
|
1248
|
+
if (this.locks && this.runId)
|
|
1249
|
+
this.locks.markRunTerminal(this.runId, result.state);
|
|
1250
|
+
return result;
|
|
1251
|
+
} finally {
|
|
1252
|
+
await cleanup();
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
private checkBudgets(
|
|
1257
|
+
spec: TaskSpec,
|
|
1258
|
+
usage: UsageStats,
|
|
1259
|
+
): "max_turns" | "max_cost" | undefined {
|
|
1260
|
+
// Stop only after a completed turn has pushed usage beyond the configured ceiling.
|
|
1261
|
+
if (spec.maxTurns !== undefined && usage.turns > spec.maxTurns)
|
|
1262
|
+
return "max_turns";
|
|
1263
|
+
if (spec.maxCost !== undefined && usage.cost > spec.maxCost)
|
|
1264
|
+
return "max_cost";
|
|
1265
|
+
return undefined;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* Run a single subagent child process.
|
|
1271
|
+
*
|
|
1272
|
+
* **Orphan reclaim:** without `options.locks` **and** `options.runId`, no durable
|
|
1273
|
+
* run record is written under the lock root, so children are invisible to
|
|
1274
|
+
* startup orphan reclaim. Pass both when embedding the runner as a library if you
|
|
1275
|
+
* need crash recovery. No implicit default lock manager is created (opt-in only).
|
|
1276
|
+
*/
|
|
1277
|
+
export function runSubagent(
|
|
1278
|
+
spec: TaskSpec,
|
|
1279
|
+
options: RunnerOptions & { signal?: AbortSignal } = {},
|
|
1280
|
+
): Promise<TaskResult> {
|
|
1281
|
+
return new ChildRunner(
|
|
1282
|
+
options.semaphore,
|
|
1283
|
+
options.getPiCommand,
|
|
1284
|
+
options.sessionDir,
|
|
1285
|
+
options.onCheckpoint,
|
|
1286
|
+
options.killGraceMs,
|
|
1287
|
+
options.locks,
|
|
1288
|
+
options.runId,
|
|
1289
|
+
options.parentSessionKey,
|
|
1290
|
+
options.maxTaskBytes,
|
|
1291
|
+
{
|
|
1292
|
+
graceTurns: options.graceTurns,
|
|
1293
|
+
stallAfterMs: options.stallAfterMs,
|
|
1294
|
+
stallKillAfterMs: options.stallKillAfterMs,
|
|
1295
|
+
startupTimeoutMs: options.startupTimeoutMs,
|
|
1296
|
+
backend: options.backend,
|
|
1297
|
+
},
|
|
1298
|
+
).run(spec, options.signal);
|
|
1299
|
+
}
|