@cr1ms0n/pi-subagent 0.8.8 → 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 +14 -6
- package/README.md +218 -128
- package/docs/ARCHITECTURE.md +168 -132
- package/docs/COST-ACCOUNTING.md +116 -66
- package/docs/RELEASING.md +32 -32
- package/docs/SECURITY.md +125 -97
- package/docs/UX.md +158 -141
- package/package.json +2 -2
- package/skills/subagent/SKILL.md +142 -121
- package/src/agents.ts +282 -288
- 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 +368 -187
- package/src/format.ts +436 -365
- package/src/jev-router.ts +1036 -0
- package/src/orchestrator.ts +303 -312
- package/src/persistence.ts +643 -335
- package/src/policy.ts +562 -561
- 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 +189 -189
- package/src/startup-check.ts +481 -0
- package/src/types.ts +208 -198
- package/src/usage.ts +316 -274
- package/src/context-policy.ts +0 -169
- package/src/model-policy.ts +0 -169
package/src/registry.ts
CHANGED
|
@@ -1,632 +1,730 @@
|
|
|
1
|
-
import { Buffer } from "node:buffer";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
import type { Message } from "@earendil-works/pi-ai";
|
|
4
|
-
import type { SubagentConfig } from "./config.js";
|
|
5
|
-
import type { PersistenceAdapter, PersistedResult } from "./persistence.js";
|
|
6
|
-
import { PersistenceLayer } from "./persistence.js";
|
|
7
|
-
import type { ProcessLockManager } from "./process-lock.js";
|
|
8
|
-
import type { RunMode, RunSnapshot, RunState, TaskResult, TaskSpec } from "./types.js";
|
|
9
|
-
import { emptyUsage } from "./types.js";
|
|
10
|
-
|
|
11
|
-
export interface LiveRun {
|
|
12
|
-
id: string;
|
|
13
|
-
sessionKey: string;
|
|
14
|
-
mode: RunMode;
|
|
15
|
-
state: RunState;
|
|
16
|
-
startedAt: number;
|
|
17
|
-
endedAt?: number;
|
|
18
|
-
taskPreviews: string[];
|
|
19
|
-
taskSpecs: TaskSpec[];
|
|
20
|
-
results: TaskResult[];
|
|
21
|
-
summary?: string;
|
|
22
|
-
delivered: boolean;
|
|
23
|
-
promise: Promise<unknown>;
|
|
24
|
-
controller: AbortController;
|
|
25
|
-
childSessionIds: Set<string>;
|
|
26
|
-
lastProgressCheckpoint: number;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface RunLookupResult {
|
|
30
|
-
status: "found" | "not-found" | "ambiguous";
|
|
31
|
-
run?: LiveRun | RunSnapshot;
|
|
32
|
-
matches?: string[];
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface SessionRuntime {
|
|
36
|
-
sessionKey: string;
|
|
37
|
-
runs: Map<string, LiveRun>;
|
|
38
|
-
snapshots: Map<string, RunSnapshot>;
|
|
39
|
-
activeResumes: Map<string, string>;
|
|
40
|
-
shuttingDown: boolean;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export type RegistryEvent =
|
|
44
|
-
| { type: "changed"; sessionKey: string; runId: string }
|
|
45
|
-
| { type: "terminal"; sessionKey: string; runId: string; state: RunState };
|
|
46
|
-
|
|
47
|
-
const terminalStates = new Set<RunState>(["completed", "partial", "failed", "cancelled", "lost", "timeout"]);
|
|
48
|
-
|
|
49
|
-
/** Trailing coalesce window for high-frequency "changed" events. */
|
|
50
|
-
const EMIT_COALESCE_MS = 100;
|
|
51
|
-
|
|
52
|
-
function finalText(messages: Message[], fallback?: string): string | undefined {
|
|
53
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
54
|
-
const message = messages[i];
|
|
55
|
-
if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
|
|
56
|
-
const text = message.content
|
|
57
|
-
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
|
|
58
|
-
.map((part: any) => part.text)
|
|
59
|
-
.join("");
|
|
60
|
-
if (text) return text;
|
|
61
|
-
}
|
|
62
|
-
return fallback;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function utf8Prefix(value: string | undefined, maxBytes: number): string | undefined {
|
|
66
|
-
if (!value) return undefined;
|
|
67
|
-
const buffer = Buffer.from(value, "utf8");
|
|
68
|
-
if (buffer.length <= maxBytes) return value;
|
|
69
|
-
let end = maxBytes;
|
|
70
|
-
while (end > 0 && (buffer[end] & 0xc0) === 0x80) end--;
|
|
71
|
-
return buffer.subarray(0, end).toString("utf8");
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Full result projection: capped transcripts included. Used for terminal
|
|
76
|
-
* persistence and UI snapshots — the single converter for both paths.
|
|
77
|
-
*/
|
|
78
|
-
export function toPersistedResult(result: TaskResult): PersistedResult {
|
|
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
|
-
result.
|
|
122
|
-
result.
|
|
123
|
-
result.
|
|
124
|
-
result.
|
|
125
|
-
result.
|
|
126
|
-
result.
|
|
127
|
-
result.
|
|
128
|
-
result.
|
|
129
|
-
result.
|
|
130
|
-
result.
|
|
131
|
-
result.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
projectionCache.
|
|
141
|
-
return projected;
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
this.
|
|
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
|
-
const
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
})
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
const
|
|
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
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
id,
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
const
|
|
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
|
-
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
4
|
+
import type { SubagentConfig } from "./config.js";
|
|
5
|
+
import type { PersistenceAdapter, PersistedResult } from "./persistence.js";
|
|
6
|
+
import { normalizeTaskRouting, PersistenceLayer } from "./persistence.js";
|
|
7
|
+
import type { ProcessLockManager } from "./process-lock.js";
|
|
8
|
+
import type { RunMode, RunSnapshot, RunState, TaskResult, TaskSpec } from "./types.js";
|
|
9
|
+
import { emptyUsage } from "./types.js";
|
|
10
|
+
|
|
11
|
+
export interface LiveRun {
|
|
12
|
+
id: string;
|
|
13
|
+
sessionKey: string;
|
|
14
|
+
mode: RunMode;
|
|
15
|
+
state: RunState;
|
|
16
|
+
startedAt: number;
|
|
17
|
+
endedAt?: number;
|
|
18
|
+
taskPreviews: string[];
|
|
19
|
+
taskSpecs: TaskSpec[];
|
|
20
|
+
results: TaskResult[];
|
|
21
|
+
summary?: string;
|
|
22
|
+
delivered: boolean;
|
|
23
|
+
promise: Promise<unknown>;
|
|
24
|
+
controller: AbortController;
|
|
25
|
+
childSessionIds: Set<string>;
|
|
26
|
+
lastProgressCheckpoint: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RunLookupResult {
|
|
30
|
+
status: "found" | "not-found" | "ambiguous";
|
|
31
|
+
run?: LiveRun | RunSnapshot;
|
|
32
|
+
matches?: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SessionRuntime {
|
|
36
|
+
sessionKey: string;
|
|
37
|
+
runs: Map<string, LiveRun>;
|
|
38
|
+
snapshots: Map<string, RunSnapshot>;
|
|
39
|
+
activeResumes: Map<string, string>;
|
|
40
|
+
shuttingDown: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type RegistryEvent =
|
|
44
|
+
| { type: "changed"; sessionKey: string; runId: string }
|
|
45
|
+
| { type: "terminal"; sessionKey: string; runId: string; state: RunState };
|
|
46
|
+
|
|
47
|
+
const terminalStates = new Set<RunState>(["completed", "partial", "failed", "cancelled", "lost", "timeout"]);
|
|
48
|
+
|
|
49
|
+
/** Trailing coalesce window for high-frequency "changed" events. */
|
|
50
|
+
const EMIT_COALESCE_MS = 100;
|
|
51
|
+
|
|
52
|
+
function finalText(messages: Message[], fallback?: string): string | undefined {
|
|
53
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
54
|
+
const message = messages[i];
|
|
55
|
+
if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
|
|
56
|
+
const text = message.content
|
|
57
|
+
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
|
|
58
|
+
.map((part: any) => part.text)
|
|
59
|
+
.join("");
|
|
60
|
+
if (text) return text;
|
|
61
|
+
}
|
|
62
|
+
return fallback;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function utf8Prefix(value: string | undefined, maxBytes: number): string | undefined {
|
|
66
|
+
if (!value) return undefined;
|
|
67
|
+
const buffer = Buffer.from(value, "utf8");
|
|
68
|
+
if (buffer.length <= maxBytes) return value;
|
|
69
|
+
let end = maxBytes;
|
|
70
|
+
while (end > 0 && (buffer[end] & 0xc0) === 0x80) end--;
|
|
71
|
+
return buffer.subarray(0, end).toString("utf8");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Full result projection: capped transcripts included. Used for terminal
|
|
76
|
+
* persistence and UI snapshots — the single converter for both paths.
|
|
77
|
+
*/
|
|
78
|
+
export function toPersistedResult(result: TaskResult): PersistedResult {
|
|
79
|
+
const routing = normalizeTaskRouting((result as { routing?: unknown }).routing);
|
|
80
|
+
return {
|
|
81
|
+
label: result.label,
|
|
82
|
+
task: result.task.slice(0, 1_000),
|
|
83
|
+
state: result.state,
|
|
84
|
+
exitCode: result.exitCode,
|
|
85
|
+
stopReason: result.stopReason,
|
|
86
|
+
timeoutPhase: result.timeoutPhase,
|
|
87
|
+
errorMessage: result.errorMessage,
|
|
88
|
+
usage: result.usage,
|
|
89
|
+
model: result.model,
|
|
90
|
+
thinking: result.thinking,
|
|
91
|
+
profile: result.profile,
|
|
92
|
+
backend: result.backend,
|
|
93
|
+
canWrite: result.canWrite,
|
|
94
|
+
outputFile: result.outputFile,
|
|
95
|
+
outputMode: result.outputMode,
|
|
96
|
+
sessionId: result.sessionId,
|
|
97
|
+
process: result.process,
|
|
98
|
+
...(routing === undefined ? {} : { routing }),
|
|
99
|
+
finalOutput: utf8Prefix(finalText(result.messages, result.liveText), 16_384),
|
|
100
|
+
transcript: utf8Prefix(result.transcript, 32_768),
|
|
101
|
+
worktree: result.worktree,
|
|
102
|
+
wrappedUp: result.wrappedUp,
|
|
103
|
+
stalledSince: result.stalledSince,
|
|
104
|
+
attempts: result.attempts,
|
|
105
|
+
attemptedModels: result.attemptedModels,
|
|
106
|
+
structuredOutput: result.structuredOutput,
|
|
107
|
+
structuredError: result.structuredError,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Memoized per-result projection for live snapshots. High-frequency emitters
|
|
113
|
+
* (footer refresh, streamed tool updates) re-snapshot the whole run on every
|
|
114
|
+
* event; only the task that actually changed should pay the projection cost
|
|
115
|
+
* (message scan + capped-string allocation).
|
|
116
|
+
*/
|
|
117
|
+
const projectionCache = new WeakMap<TaskResult, { fingerprint: string; projected: PersistedResult }>();
|
|
118
|
+
|
|
119
|
+
function resultFingerprint(result: TaskResult): string {
|
|
120
|
+
return [
|
|
121
|
+
result.state,
|
|
122
|
+
result.usage.turns,
|
|
123
|
+
result.usage.cost,
|
|
124
|
+
result.sessionId ?? "",
|
|
125
|
+
result.messages.length,
|
|
126
|
+
result.liveText?.length ?? 0,
|
|
127
|
+
result.transcript?.length ?? 0,
|
|
128
|
+
result.errorMessage?.length ?? 0,
|
|
129
|
+
result.stalledSince ?? 0,
|
|
130
|
+
result.attempts ?? 0,
|
|
131
|
+
result.worktree ? 1 : 0,
|
|
132
|
+
result.structuredOutput !== undefined ? 1 : 0,
|
|
133
|
+
result.structuredError?.length ?? 0,
|
|
134
|
+
(result as { routing?: { decisionId?: unknown } }).routing?.decisionId ?? "",
|
|
135
|
+
].join("|");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function toPersistedResultCached(result: TaskResult): PersistedResult {
|
|
139
|
+
const fingerprint = resultFingerprint(result);
|
|
140
|
+
const cached = projectionCache.get(result);
|
|
141
|
+
if (cached && cached.fingerprint === fingerprint) return cached.projected;
|
|
142
|
+
const projected = toPersistedResult(result);
|
|
143
|
+
projectionCache.set(result, { fingerprint, projected });
|
|
144
|
+
return projected;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Lightweight result projection for checkpoint events: state + usage +
|
|
149
|
+
* pointers only. Keeps checkpoint entries small so the parent session file
|
|
150
|
+
* does not bloat during long runs. Transcripts are persisted once, at terminal.
|
|
151
|
+
*/
|
|
152
|
+
export function toCheckpointResult(result: TaskResult): PersistedResult {
|
|
153
|
+
const routing = normalizeTaskRouting((result as { routing?: unknown }).routing);
|
|
154
|
+
return {
|
|
155
|
+
label: result.label,
|
|
156
|
+
task: result.task.slice(0, 200),
|
|
157
|
+
state: result.state,
|
|
158
|
+
exitCode: result.exitCode,
|
|
159
|
+
stopReason: result.stopReason,
|
|
160
|
+
timeoutPhase: result.timeoutPhase,
|
|
161
|
+
errorMessage: utf8Prefix(result.errorMessage, 1_000),
|
|
162
|
+
usage: result.usage,
|
|
163
|
+
model: result.model,
|
|
164
|
+
thinking: result.thinking,
|
|
165
|
+
profile: result.profile,
|
|
166
|
+
backend: result.backend,
|
|
167
|
+
canWrite: result.canWrite,
|
|
168
|
+
outputFile: result.outputFile,
|
|
169
|
+
outputMode: result.outputMode,
|
|
170
|
+
sessionId: result.sessionId,
|
|
171
|
+
process: result.process,
|
|
172
|
+
worktree: result.worktree,
|
|
173
|
+
wrappedUp: result.wrappedUp,
|
|
174
|
+
stalledSince: result.stalledSince,
|
|
175
|
+
attempts: result.attempts,
|
|
176
|
+
attemptedModels: result.attemptedModels,
|
|
177
|
+
...(routing === undefined ? {} : { routing }),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* One shared LiveRun → RunSnapshot projection with capped transcripts.
|
|
183
|
+
* Unchanged task results reuse their cached projection (see toPersistedResultCached).
|
|
184
|
+
*/
|
|
185
|
+
export function snapshotFromLiveRun(run: LiveRun): RunSnapshot {
|
|
186
|
+
return {
|
|
187
|
+
schemaVersion: 1,
|
|
188
|
+
id: run.id,
|
|
189
|
+
sessionKey: run.sessionKey,
|
|
190
|
+
mode: run.mode,
|
|
191
|
+
state: run.state,
|
|
192
|
+
startedAt: run.startedAt,
|
|
193
|
+
endedAt: run.endedAt,
|
|
194
|
+
taskPreviews: run.taskPreviews,
|
|
195
|
+
summary: run.summary,
|
|
196
|
+
delivered: run.delivered,
|
|
197
|
+
results: run.results.map(toPersistedResultCached),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface ResumeAvailabilityConflict {
|
|
202
|
+
sessionId: string;
|
|
203
|
+
runId: string;
|
|
204
|
+
/** Which read-only source rejected: in-process holder, persisted block, or durable lock. */
|
|
205
|
+
reason: "active" | "blocked" | "durable";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Result of the side-effect-free direct-resume preflight (mirrors acquireResumeLocks). */
|
|
209
|
+
export interface ResumeAvailabilityResult {
|
|
210
|
+
ok: boolean;
|
|
211
|
+
conflict?: ResumeAvailabilityConflict;
|
|
212
|
+
/** Session ids whose durable lock is provably stale (reclaimable) but left untouched. */
|
|
213
|
+
reclaimable?: string[];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Minimal task shape the read-only resume check reads; never acquires anything. */
|
|
217
|
+
export interface DirectResumeTask {
|
|
218
|
+
resume?: string;
|
|
219
|
+
forkResume?: boolean;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Session-owned live state plus immutable, bounded terminal snapshots. */
|
|
223
|
+
export class SessionScopedRunRegistry {
|
|
224
|
+
private readonly runtimes = new Map<string, SessionRuntime>();
|
|
225
|
+
private readonly persistence: PersistenceLayer;
|
|
226
|
+
private readonly listeners = new Set<(event: RegistryEvent) => void>();
|
|
227
|
+
private readonly pendingEmits = new Map<string, NodeJS.Timeout>();
|
|
228
|
+
private readonly locks?: ProcessLockManager;
|
|
229
|
+
|
|
230
|
+
constructor(
|
|
231
|
+
private readonly config: SubagentConfig,
|
|
232
|
+
persistenceAdapter: PersistenceAdapter,
|
|
233
|
+
locks?: ProcessLockManager,
|
|
234
|
+
) {
|
|
235
|
+
this.persistence = new PersistenceLayer(persistenceAdapter, config);
|
|
236
|
+
this.locks = locks;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
allocateRunId(): string {
|
|
240
|
+
return randomUUID();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
subscribe(listener: (event: RegistryEvent) => void): () => void {
|
|
244
|
+
this.listeners.add(listener);
|
|
245
|
+
return () => this.listeners.delete(listener);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private emit(event: RegistryEvent): void {
|
|
249
|
+
for (const listener of this.listeners) listener(event);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Coalesce per-run "changed" bursts (live-text ticks can arrive per stdout
|
|
254
|
+
* chunk) into at most one listener notification per window. Terminal and
|
|
255
|
+
* structural events always flush immediately.
|
|
256
|
+
*/
|
|
257
|
+
private emitChanged(sessionKey: string, runId: string, immediate = false): void {
|
|
258
|
+
const key = `${sessionKey}\u0000${runId}`;
|
|
259
|
+
if (immediate) {
|
|
260
|
+
const pending = this.pendingEmits.get(key);
|
|
261
|
+
if (pending) {
|
|
262
|
+
clearTimeout(pending);
|
|
263
|
+
this.pendingEmits.delete(key);
|
|
264
|
+
}
|
|
265
|
+
this.emit({ type: "changed", sessionKey, runId });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (this.pendingEmits.has(key)) return;
|
|
269
|
+
const timer = setTimeout(() => {
|
|
270
|
+
this.pendingEmits.delete(key);
|
|
271
|
+
this.emit({ type: "changed", sessionKey, runId });
|
|
272
|
+
}, EMIT_COALESCE_MS);
|
|
273
|
+
timer.unref?.();
|
|
274
|
+
this.pendingEmits.set(key, timer);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private clearPendingEmits(sessionKey?: string): void {
|
|
278
|
+
for (const [key, timer] of this.pendingEmits) {
|
|
279
|
+
if (sessionKey && !key.startsWith(`${sessionKey}\u0000`)) continue;
|
|
280
|
+
clearTimeout(timer);
|
|
281
|
+
this.pendingEmits.delete(key);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private getOrCreateRuntime(sessionKey: string): SessionRuntime {
|
|
286
|
+
let runtime = this.runtimes.get(sessionKey);
|
|
287
|
+
if (!runtime) {
|
|
288
|
+
runtime = {
|
|
289
|
+
sessionKey,
|
|
290
|
+
runs: new Map(),
|
|
291
|
+
snapshots: this.persistence.rebuild(sessionKey),
|
|
292
|
+
activeResumes: new Map(),
|
|
293
|
+
shuttingDown: false,
|
|
294
|
+
};
|
|
295
|
+
this.runtimes.set(sessionKey, runtime);
|
|
296
|
+
}
|
|
297
|
+
return runtime;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
getSessionRuntime(sessionKey: string): SessionRuntime | undefined {
|
|
301
|
+
return this.runtimes.get(sessionKey);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
getLiveRuns(sessionKey: string): LiveRun[] {
|
|
305
|
+
return [...this.getOrCreateRuntime(sessionKey).runs.values()];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
getSnapshots(sessionKey: string): RunSnapshot[] {
|
|
309
|
+
return [...this.getOrCreateRuntime(sessionKey).snapshots.values()];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Live cwds of worktree-isolated tasks; used to protect them from sweeps. */
|
|
313
|
+
getLiveWorktreeCwds(sessionKey: string): Set<string> {
|
|
314
|
+
const cwds = new Set<string>();
|
|
315
|
+
for (const run of this.getOrCreateRuntime(sessionKey).runs.values()) {
|
|
316
|
+
for (const result of run.results) if (result.worktree?.cwd) cwds.add(result.worktree.cwd);
|
|
317
|
+
for (const spec of run.taskSpecs) if (spec.isolation === "worktree" && spec.cwd) cwds.add(spec.cwd);
|
|
318
|
+
}
|
|
319
|
+
return cwds;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Rebuild terminal history after active-branch navigation. Live runs stay session-owned. */
|
|
323
|
+
refreshSnapshots(sessionKey: string): void {
|
|
324
|
+
const runtime = this.getOrCreateRuntime(sessionKey);
|
|
325
|
+
runtime.snapshots = this.persistence.rebuild(sessionKey);
|
|
326
|
+
this.capSnapshots(runtime);
|
|
327
|
+
this.emitChanged(sessionKey, "branch", true);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
lookup(idOrPrefix: string, sessionKey: string): RunLookupResult {
|
|
331
|
+
if (!idOrPrefix) return { status: "not-found" };
|
|
332
|
+
const runtime = this.getOrCreateRuntime(sessionKey);
|
|
333
|
+
const exact = runtime.runs.get(idOrPrefix) ?? runtime.snapshots.get(idOrPrefix);
|
|
334
|
+
if (exact) return { status: "found", run: exact };
|
|
335
|
+
|
|
336
|
+
const matches = new Map<string, LiveRun | RunSnapshot>();
|
|
337
|
+
for (const [id, run] of runtime.runs) if (id.startsWith(idOrPrefix)) matches.set(id, run);
|
|
338
|
+
for (const [id, run] of runtime.snapshots) if (id.startsWith(idOrPrefix)) matches.set(id, run);
|
|
339
|
+
if (matches.size === 0) return { status: "not-found" };
|
|
340
|
+
if (matches.size > 1) return { status: "ambiguous", matches: [...matches.keys()].sort() };
|
|
341
|
+
return { status: "found", run: [...matches.values()][0] };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
start(
|
|
345
|
+
sessionKey: string,
|
|
346
|
+
mode: RunMode,
|
|
347
|
+
specs: TaskSpec[],
|
|
348
|
+
controller: AbortController,
|
|
349
|
+
promise: Promise<unknown>,
|
|
350
|
+
labels: string[] = [],
|
|
351
|
+
id = this.allocateRunId(),
|
|
352
|
+
): string {
|
|
353
|
+
const runtime = this.getOrCreateRuntime(sessionKey);
|
|
354
|
+
if (runtime.shuttingDown) throw new Error("Cannot start a subagent while the parent session is shutting down");
|
|
355
|
+
if (runtime.runs.has(id) || runtime.snapshots.has(id)) throw new Error(`Duplicate run id ${id}`);
|
|
356
|
+
|
|
357
|
+
const startedAt = Date.now();
|
|
358
|
+
const taskPreviews = specs.map((spec, i) => `${labels[i] || `task-${i + 1}`}: ${spec.task.slice(0, 120)}`);
|
|
359
|
+
const results: TaskResult[] = specs.map((spec, i) => ({
|
|
360
|
+
label: labels[i] || `task-${i + 1}`,
|
|
361
|
+
task: spec.task,
|
|
362
|
+
state: "queued",
|
|
363
|
+
exitCode: null,
|
|
364
|
+
messages: [],
|
|
365
|
+
stderr: "",
|
|
366
|
+
usage: emptyUsage(),
|
|
367
|
+
outputFile: spec.output,
|
|
368
|
+
outputMode: spec.outputMode,
|
|
369
|
+
model: spec.model,
|
|
370
|
+
routing: spec.routing,
|
|
371
|
+
thinking: spec.thinking,
|
|
372
|
+
profile: spec.profile,
|
|
373
|
+
backend: spec.backend,
|
|
374
|
+
canWrite: spec.canWrite,
|
|
375
|
+
protocol: {
|
|
376
|
+
headerSeen: false,
|
|
377
|
+
assistantEndSeen: false,
|
|
378
|
+
agentEndSeen: false,
|
|
379
|
+
agentSettledSeen: false,
|
|
380
|
+
validEvents: 0,
|
|
381
|
+
parseErrors: 0,
|
|
382
|
+
},
|
|
383
|
+
}));
|
|
384
|
+
|
|
385
|
+
runtime.runs.set(id, {
|
|
386
|
+
id,
|
|
387
|
+
sessionKey,
|
|
388
|
+
mode,
|
|
389
|
+
state: "queued",
|
|
390
|
+
startedAt,
|
|
391
|
+
taskPreviews,
|
|
392
|
+
taskSpecs: [...specs],
|
|
393
|
+
results,
|
|
394
|
+
delivered: false,
|
|
395
|
+
promise,
|
|
396
|
+
controller,
|
|
397
|
+
childSessionIds: new Set(),
|
|
398
|
+
lastProgressCheckpoint: 0,
|
|
399
|
+
});
|
|
400
|
+
this.persistence.persist(id, sessionKey, "start", {
|
|
401
|
+
mode,
|
|
402
|
+
state: "queued",
|
|
403
|
+
startedAt,
|
|
404
|
+
taskPreviews,
|
|
405
|
+
results: results.map(toCheckpointResult),
|
|
406
|
+
});
|
|
407
|
+
this.emitChanged(sessionKey, id, true);
|
|
408
|
+
return id;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
checkpoint(
|
|
412
|
+
id: string,
|
|
413
|
+
sessionKey: string,
|
|
414
|
+
updates: {
|
|
415
|
+
childSessionId?: string;
|
|
416
|
+
progress?: string;
|
|
417
|
+
turn?: number;
|
|
418
|
+
resultIndex?: number;
|
|
419
|
+
resultUpdate?: Partial<TaskResult>;
|
|
420
|
+
state?: RunState;
|
|
421
|
+
},
|
|
422
|
+
): boolean {
|
|
423
|
+
const runtime = this.runtimes.get(sessionKey);
|
|
424
|
+
const run = runtime?.runs.get(id);
|
|
425
|
+
if (!runtime || !run || run.sessionKey !== sessionKey || runtime.shuttingDown) return false;
|
|
426
|
+
|
|
427
|
+
const index = updates.resultIndex ?? 0;
|
|
428
|
+
const result = run.results[index];
|
|
429
|
+
const previousTurns = result?.usage.turns ?? 0;
|
|
430
|
+
const previousCost = result?.usage.cost ?? 0;
|
|
431
|
+
const previousRunState = run.state;
|
|
432
|
+
if (result && updates.resultUpdate) Object.assign(result, updates.resultUpdate);
|
|
433
|
+
const usageAdvanced = !!result && (result.usage.turns > previousTurns || result.usage.cost > previousCost);
|
|
434
|
+
if (updates.state) run.state = updates.state;
|
|
435
|
+
else if (run.state === "queued") run.state = "running";
|
|
436
|
+
const stateChanged = run.state !== previousRunState;
|
|
437
|
+
|
|
438
|
+
const childSessionId = updates.childSessionId ?? updates.resultUpdate?.sessionId;
|
|
439
|
+
let newChildSession = false;
|
|
440
|
+
if (childSessionId) {
|
|
441
|
+
newChildSession = !run.childSessionIds.has(childSessionId);
|
|
442
|
+
run.childSessionIds.add(childSessionId);
|
|
443
|
+
if (result) result.sessionId = childSessionId;
|
|
444
|
+
// Crash recovery requirement: session ids are never throttled.
|
|
445
|
+
if (newChildSession) {
|
|
446
|
+
this.persistence.persist(id, sessionKey, "checkpoint", {
|
|
447
|
+
state: run.state,
|
|
448
|
+
resultIndex: index,
|
|
449
|
+
childSessionId,
|
|
450
|
+
results: run.results.map(toCheckpointResult),
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Persist lightweight checkpoints (state + usage + pointers, never
|
|
456
|
+
// transcripts) only when billed usage advanced, or on a throttled progress
|
|
457
|
+
// beat. Full transcripts are written exactly once, in the terminal event.
|
|
458
|
+
const now = Date.now();
|
|
459
|
+
if (usageAdvanced || ((updates.progress || updates.turn !== undefined) && now - run.lastProgressCheckpoint >= 500)) {
|
|
460
|
+
run.lastProgressCheckpoint = now;
|
|
461
|
+
this.persistence.persist(id, sessionKey, "checkpoint", {
|
|
462
|
+
state: run.state,
|
|
463
|
+
resultIndex: index,
|
|
464
|
+
progress: utf8Prefix(updates.progress, 200),
|
|
465
|
+
turn: updates.turn,
|
|
466
|
+
results: run.results.map(toCheckpointResult),
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
// Structural changes flush immediately; live-text ticks coalesce.
|
|
470
|
+
this.emitChanged(sessionKey, id, usageAdvanced || stateChanged || newChildSession);
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
complete(
|
|
475
|
+
id: string,
|
|
476
|
+
sessionKey: string,
|
|
477
|
+
finalState: RunState,
|
|
478
|
+
summary?: string,
|
|
479
|
+
finalResults?: TaskResult[],
|
|
480
|
+
): boolean {
|
|
481
|
+
const runtime = this.runtimes.get(sessionKey);
|
|
482
|
+
const run = runtime?.runs.get(id);
|
|
483
|
+
if (!runtime || !run || run.sessionKey !== sessionKey) return false;
|
|
484
|
+
|
|
485
|
+
const endedAt = Date.now();
|
|
486
|
+
const results = finalResults ?? run.results;
|
|
487
|
+
const snapshot: RunSnapshot = {
|
|
488
|
+
schemaVersion: 1,
|
|
489
|
+
id,
|
|
490
|
+
sessionKey,
|
|
491
|
+
mode: run.mode,
|
|
492
|
+
state: terminalStates.has(finalState) ? finalState : "failed",
|
|
493
|
+
startedAt: run.startedAt,
|
|
494
|
+
endedAt,
|
|
495
|
+
taskPreviews: run.taskPreviews,
|
|
496
|
+
summary,
|
|
497
|
+
delivered: run.delivered,
|
|
498
|
+
results: results.map(toPersistedResult),
|
|
499
|
+
};
|
|
500
|
+
runtime.snapshots.set(id, snapshot);
|
|
501
|
+
runtime.runs.delete(id);
|
|
502
|
+
this.releaseLocksForRun(runtime, id);
|
|
503
|
+
this.persistence.persist(id, sessionKey, "terminal", {
|
|
504
|
+
mode: snapshot.mode,
|
|
505
|
+
state: snapshot.state,
|
|
506
|
+
startedAt: snapshot.startedAt,
|
|
507
|
+
endedAt,
|
|
508
|
+
taskPreviews: snapshot.taskPreviews,
|
|
509
|
+
summary,
|
|
510
|
+
delivered: snapshot.delivered,
|
|
511
|
+
results: snapshot.results,
|
|
512
|
+
});
|
|
513
|
+
this.capSnapshots(runtime);
|
|
514
|
+
this.clearPendingEmitsForRun(sessionKey, id);
|
|
515
|
+
this.emit({ type: "terminal", sessionKey, runId: id, state: snapshot.state });
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private clearPendingEmitsForRun(sessionKey: string, runId: string): void {
|
|
520
|
+
const key = `${sessionKey}\u0000${runId}`;
|
|
521
|
+
const pending = this.pendingEmits.get(key);
|
|
522
|
+
if (pending) {
|
|
523
|
+
clearTimeout(pending);
|
|
524
|
+
this.pendingEmits.delete(key);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Returns false when this result was already delivered. */
|
|
529
|
+
markDelivered(id: string, sessionKey: string): boolean {
|
|
530
|
+
const runtime = this.getOrCreateRuntime(sessionKey);
|
|
531
|
+
const run = runtime.runs.get(id) ?? runtime.snapshots.get(id);
|
|
532
|
+
if (!run || run.delivered) return false;
|
|
533
|
+
// A failed append must leave the result claimable by a later wait.
|
|
534
|
+
this.persistence.markDelivered(id, sessionKey);
|
|
535
|
+
run.delivered = true;
|
|
536
|
+
this.emitChanged(sessionKey, id, true);
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
markDismissed(id: string, sessionKey: string): boolean {
|
|
541
|
+
return this.markDelivered(id, sessionKey);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** UI history eviction must never evict unresolved ownership safety state. */
|
|
545
|
+
private resumeSafetySnapshots(sessionKey: string): Map<string, RunSnapshot> {
|
|
546
|
+
const snapshots = this.persistence.rebuild(sessionKey);
|
|
547
|
+
const runtime = this.runtimes.get(sessionKey);
|
|
548
|
+
if (runtime) {
|
|
549
|
+
// Persisted checkpoints of runs we still own are not lost/orphaned runs.
|
|
550
|
+
for (const id of runtime.runs.keys()) snapshots.delete(id);
|
|
551
|
+
for (const [id, snapshot] of runtime.snapshots) snapshots.set(id, snapshot);
|
|
552
|
+
}
|
|
553
|
+
return snapshots;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
acquireResumeLocks(childSessionIds: string[], runId: string, sessionKey: string): {
|
|
557
|
+
ok: boolean;
|
|
558
|
+
conflict?: { sessionId: string; runId: string };
|
|
559
|
+
} {
|
|
560
|
+
const runtime = this.getOrCreateRuntime(sessionKey);
|
|
561
|
+
const unique = [...new Set(childSessionIds.filter(Boolean))];
|
|
562
|
+
// Block resume of runs whose ownership is not yet proven dead.
|
|
563
|
+
for (const snapshot of this.resumeSafetySnapshots(sessionKey).values()) {
|
|
564
|
+
if (!snapshot.resumeBlocked) continue;
|
|
565
|
+
for (const result of snapshot.results) {
|
|
566
|
+
if (result.sessionId && unique.includes(result.sessionId)) {
|
|
567
|
+
return {
|
|
568
|
+
ok: false,
|
|
569
|
+
conflict: {
|
|
570
|
+
sessionId: result.sessionId,
|
|
571
|
+
runId: snapshot.id,
|
|
572
|
+
},
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
// In-memory lock first (fast path within one process).
|
|
578
|
+
for (const sessionId of unique) {
|
|
579
|
+
const holder = runtime.activeResumes.get(sessionId);
|
|
580
|
+
if (holder && holder !== runId) return { ok: false, conflict: { sessionId, runId: holder } };
|
|
581
|
+
}
|
|
582
|
+
// Durable, machine-wide lock — survival across parent crashes and cross-process contention.
|
|
583
|
+
const durableHeld: string[] = [];
|
|
584
|
+
if (this.locks) {
|
|
585
|
+
try {
|
|
586
|
+
for (const sessionId of unique) {
|
|
587
|
+
const acquired = this.locks.acquireSessionLock(sessionId, {
|
|
588
|
+
ownerId: `${sessionKey}:${runId}`,
|
|
589
|
+
runId,
|
|
590
|
+
parentSessionKey: sessionKey,
|
|
591
|
+
});
|
|
592
|
+
if (!acquired.ok) {
|
|
593
|
+
for (const held of durableHeld) this.locks.releaseSessionLock(held, runId);
|
|
594
|
+
return {
|
|
595
|
+
ok: false,
|
|
596
|
+
conflict: { sessionId: acquired.conflict.childSessionId, runId: acquired.conflict.runId },
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
durableHeld.push(sessionId);
|
|
600
|
+
}
|
|
601
|
+
} catch (error) {
|
|
602
|
+
for (const held of durableHeld) this.locks.releaseSessionLock(held, runId);
|
|
603
|
+
throw error;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
for (const sessionId of unique) runtime.activeResumes.set(sessionId, runId);
|
|
607
|
+
return { ok: true };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
acquireResumeLock(childSessionId: string, runId: string, sessionKey: string, isFork = false): boolean {
|
|
611
|
+
if (isFork) return true;
|
|
612
|
+
return this.acquireResumeLocks([childSessionId], runId, sessionKey).ok;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Side-effect-free counterpart to `acquireResumeLocks` for the pre-inference preflight.
|
|
617
|
+
*
|
|
618
|
+
* Checks, in the same order as the authoritative acquire path, in-memory active resume
|
|
619
|
+
* holders, persisted `resumeBlocked` snapshots and durable machine-wide locks. It never
|
|
620
|
+
* creates/renews/reaps a lock, never registers a runtime entry and never mutates state,
|
|
621
|
+
* so a successful check is an observation, not a reservation. `forkResume:true` tasks are
|
|
622
|
+
* skipped (they never take the exclusive direct-resume lock). Provably stale durable locks
|
|
623
|
+
* are surfaced as `reclaimable` and left in place for the atomic acquire at launch.
|
|
624
|
+
*/
|
|
625
|
+
checkResumeAvailability(tasks: readonly DirectResumeTask[], sessionKey: string): ResumeAvailabilityResult {
|
|
626
|
+
const runtime = this.runtimes.get(sessionKey);
|
|
627
|
+
const unique = [...new Set(tasks
|
|
628
|
+
.filter((task) => !!task && typeof task.resume === "string" && task.resume.length > 0 && task.forkResume !== true)
|
|
629
|
+
.map((task) => task.resume as string))];
|
|
630
|
+
if (unique.length === 0) return { ok: true };
|
|
631
|
+
|
|
632
|
+
// Rebuild safety state independently of the bounded UI history.
|
|
633
|
+
const snapshots = this.resumeSafetySnapshots(sessionKey);
|
|
634
|
+
// Block resume of runs whose ownership is not yet proven dead.
|
|
635
|
+
for (const snapshot of snapshots.values()) {
|
|
636
|
+
if (!snapshot.resumeBlocked) continue;
|
|
637
|
+
for (const result of snapshot.results) {
|
|
638
|
+
if (result.sessionId && unique.includes(result.sessionId)) {
|
|
639
|
+
return { ok: false, conflict: { sessionId: result.sessionId, runId: snapshot.id, reason: "blocked" } };
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
// In-memory lock holders in this process (only known once the runtime exists).
|
|
644
|
+
if (runtime) {
|
|
645
|
+
for (const sessionId of unique) {
|
|
646
|
+
const holder = runtime.activeResumes.get(sessionId);
|
|
647
|
+
if (holder) return { ok: false, conflict: { sessionId, runId: holder, reason: "active" } };
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const reclaimable: string[] = [];
|
|
652
|
+
if (this.locks) {
|
|
653
|
+
for (const sessionId of unique) {
|
|
654
|
+
const availability = this.locks.checkResumeAvailability(sessionId);
|
|
655
|
+
if (availability.status === "held") {
|
|
656
|
+
return {
|
|
657
|
+
ok: false,
|
|
658
|
+
conflict: { sessionId, runId: availability.owner?.runId ?? "unknown", reason: "durable" },
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
if (availability.status === "reclaimable") reclaimable.push(sessionId);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return reclaimable.length ? { ok: true, reclaimable } : { ok: true };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
releaseResumeLock(childSessionId: string, sessionKey: string, runId?: string): void {
|
|
668
|
+
const runtime = this.runtimes.get(sessionKey);
|
|
669
|
+
if (!runtime) return;
|
|
670
|
+
if (!runId || runtime.activeResumes.get(childSessionId) === runId) runtime.activeResumes.delete(childSessionId);
|
|
671
|
+
if (this.locks) this.locks.releaseSessionLock(childSessionId, runId);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
private releaseLocksForRun(runtime: SessionRuntime, runId: string): void {
|
|
675
|
+
for (const [sessionId, holder] of [...runtime.activeResumes]) {
|
|
676
|
+
if (holder === runId) {
|
|
677
|
+
runtime.activeResumes.delete(sessionId);
|
|
678
|
+
this.locks?.releaseSessionLock(sessionId, runId);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** Clear the resume-blocked flag after orphan reconcile has proven the child dead. */
|
|
684
|
+
clearResumeBlock(id: string, sessionKey: string): void {
|
|
685
|
+
const runtime = this.getOrCreateRuntime(sessionKey);
|
|
686
|
+
const snapshot = runtime.snapshots.get(id) ?? this.resumeSafetySnapshots(sessionKey).get(id);
|
|
687
|
+
if (!snapshot || !snapshot.resumeBlocked) return;
|
|
688
|
+
snapshot.resumeBlocked = false;
|
|
689
|
+
this.persistence.persist(id, sessionKey, "checkpoint", { resumeBlocked: false, state: snapshot.state });
|
|
690
|
+
this.emitChanged(sessionKey, id, true);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async shutdown(sessionKey: string, graceMs = 8_000): Promise<void> {
|
|
694
|
+
const runtime = this.runtimes.get(sessionKey);
|
|
695
|
+
if (!runtime) return;
|
|
696
|
+
runtime.shuttingDown = true;
|
|
697
|
+
const live = [...runtime.runs.values()];
|
|
698
|
+
for (const run of live) run.controller.abort();
|
|
699
|
+
|
|
700
|
+
let timer: NodeJS.Timeout | undefined;
|
|
701
|
+
await Promise.race([
|
|
702
|
+
Promise.allSettled(live.map((run) => run.promise)),
|
|
703
|
+
new Promise<void>((resolve) => {
|
|
704
|
+
timer = setTimeout(resolve, graceMs);
|
|
705
|
+
timer.unref?.();
|
|
706
|
+
}),
|
|
707
|
+
]);
|
|
708
|
+
if (timer) clearTimeout(timer);
|
|
709
|
+
|
|
710
|
+
// Any orchestration promise that did not call complete is snapshotted as cancelled.
|
|
711
|
+
for (const run of [...runtime.runs.values()]) {
|
|
712
|
+
this.complete(run.id, sessionKey, "cancelled", "Cancelled when the parent session shut down", run.results);
|
|
713
|
+
}
|
|
714
|
+
runtime.activeResumes.clear();
|
|
715
|
+
runtime.shuttingDown = false;
|
|
716
|
+
this.clearPendingEmits(sessionKey);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
planSessionRetention(referencedSessionIds = new Set<string>()): { keep: string[]; candidates: string[] } {
|
|
720
|
+
return this.persistence.planRetention(referencedSessionIds);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
private capSnapshots(runtime: SessionRuntime): void {
|
|
724
|
+
while (runtime.snapshots.size > this.config.maxCompletedInMemory) {
|
|
725
|
+
const oldest = [...runtime.snapshots.values()].sort((a, b) => a.startedAt - b.startedAt)[0];
|
|
726
|
+
if (!oldest) break;
|
|
727
|
+
runtime.snapshots.delete(oldest.id);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|