pi-famulus 0.1.2 → 0.1.4

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.
@@ -10,8 +10,10 @@
10
10
  * - lineage: v1 = same runId.
11
11
  *
12
12
  * Wiring contract: the runner must be constructed with
13
- * `acquire: (req) => registry.admitChild(req.childId)` so every generation
14
- * (initial start and resume) passes through admission. startChild() wires the
13
+ * `acquire: (req, ticket) => registry.admitChild(req.childId, ticket)` so every
14
+ * generation (initial start and resume) passes through admission. Forward the
15
+ * ticket: without it admitChild falls back to the handle's current status and
16
+ * a stale queued request can admit a newer resume. startChild() wires the
15
17
  * first generation's result; admitChild() wires subsequent generations.
16
18
  *
17
19
  * Zero pi dependency.
@@ -24,9 +26,17 @@ import type {
24
26
  ChildRunRequest,
25
27
  ChildRunner,
26
28
  ChildStatus,
29
+ ChildTokenUsage,
30
+ ChildWallUsage,
27
31
  DisposableChildHandle,
28
32
  } from "./types";
29
33
 
34
+ async function awaitDisposals(disposals: Array<void | Promise<void>>): Promise<void> {
35
+ const results = await Promise.allSettled(disposals);
36
+ const errors = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
37
+ if (errors.length > 0) throw new AggregateError(errors, "Child session cleanup failed");
38
+ }
39
+
30
40
  // ---------------------------------------------------------------------------
31
41
  // Appendix B public record shape
32
42
  // ---------------------------------------------------------------------------
@@ -38,6 +48,8 @@ export interface RunRecord {
38
48
  childId: string;
39
49
  name: string;
40
50
  agent: string;
51
+ workKind?: string;
52
+ queueMs?: number;
41
53
  model?: string;
42
54
  status: ChildStatus;
43
55
  /** User-authored prompt (after chain interpolation), without injected preamble. */
@@ -47,6 +59,10 @@ export interface RunRecord {
47
59
  result?: ChildResult;
48
60
  startedAt: number;
49
61
  endedAt?: number;
62
+ /** User turns so far: 1 for the launch, +1 per accepted resume. */
63
+ turn?: number;
64
+ tokenUsage?: ChildTokenUsage;
65
+ wallUsage?: ChildWallUsage;
50
66
  }[];
51
67
  status: "running" | "completed" | "partial" | "failed" | "interrupted";
52
68
  createdAt: number;
@@ -60,7 +76,7 @@ export interface RunRegistry {
60
76
  findChild(runId: string, childIdOrName: string): ChildHandle | undefined;
61
77
  lineage(runIdA: string, runIdB: string): boolean; // v1: same runId
62
78
  onTransition(cb: (run: RunRecord) => void): void; // fleet widget / notifications
63
- disposeRun(runId: string): void;
79
+ disposeRun(runId: string): Promise<void>;
64
80
  }
65
81
 
66
82
  // ---------------------------------------------------------------------------
@@ -90,6 +106,8 @@ export interface ActiveChildInfo {
90
106
  runId: string;
91
107
  name: string;
92
108
  agent: string;
109
+ workKind?: string;
110
+ queueMs?: number;
93
111
  model?: string;
94
112
  status: ChildStatus;
95
113
  startedAt: number;
@@ -101,6 +119,7 @@ interface InternalChild {
101
119
  runId: string;
102
120
  name: string;
103
121
  agent: string;
122
+ workKind?: string;
104
123
  /** Best-effort model id for fleet / ls (set when startChild runs). */
105
124
  model?: string;
106
125
  status: ChildStatus;
@@ -109,8 +128,13 @@ interface InternalChild {
109
128
  result?: ChildResult;
110
129
  startedAt: number;
111
130
  endedAt?: number;
131
+ turn: number;
112
132
  handle?: ChildHandle;
133
+ starting?: Promise<ChildHandle>;
113
134
  shouldStart?: () => boolean;
135
+ /** Initial admission wait while runner.start() has not returned its handle. */
136
+ queueStartedAt?: number;
137
+ queueMs: number;
114
138
  }
115
139
 
116
140
  interface InternalRun {
@@ -157,6 +181,10 @@ class FailedChildHandle implements ChildHandle {
157
181
  return Promise.reject(this.dead());
158
182
  }
159
183
 
184
+ extend(): never {
185
+ throw this.dead();
186
+ }
187
+
160
188
  interrupt(): Promise<void> {
161
189
  return Promise.resolve();
162
190
  }
@@ -176,6 +204,14 @@ class FailedChildHandle implements ChildHandle {
176
204
  conversation() {
177
205
  return [];
178
206
  }
207
+
208
+ tokenUsage() {
209
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
210
+ }
211
+
212
+ wallUsage(): ChildWallUsage {
213
+ return { llmMs: 0, toolMs: 0, queueMs: 0, otherMs: 0, approximate: false };
214
+ }
179
215
  }
180
216
 
181
217
  export class SubagentRegistry implements RunRegistry {
@@ -220,16 +256,17 @@ export class SubagentRegistry implements RunRegistry {
220
256
  };
221
257
  this.runs.set(run.runId, run);
222
258
  this.emit(run);
223
- return snapshot(run);
259
+ return snapshot(run, this.now());
224
260
  }
225
261
 
226
262
  get(runId: string): RunRecord | undefined {
227
263
  const run = this.runs.get(runId);
228
- return run ? snapshot(run) : undefined;
264
+ return run ? snapshot(run, this.now()) : undefined;
229
265
  }
230
266
 
231
267
  list(): RunRecord[] {
232
- return [...this.runs.values()].map(snapshot);
268
+ const now = this.now();
269
+ return [...this.runs.values()].map((run) => snapshot(run, now));
233
270
  }
234
271
 
235
272
  handle(childId: string): ChildHandle | undefined {
@@ -253,7 +290,7 @@ export class SubagentRegistry implements RunRegistry {
253
290
  this.transitionCbs.add(cb);
254
291
  }
255
292
 
256
- disposeRun(runId: string): void {
293
+ async disposeRun(runId: string): Promise<void> {
257
294
  const run = this.runs.get(runId);
258
295
  if (!run) return;
259
296
  const now = this.now();
@@ -262,6 +299,10 @@ export class SubagentRegistry implements RunRegistry {
262
299
  // "running" record (ghost agents in ls).
263
300
  for (const child of run.children) {
264
301
  if (child.status === "pending" || child.status === "running") {
302
+ if (child.status === "pending" && child.queueStartedAt !== undefined) {
303
+ child.queueMs = Math.max(0, now - child.queueStartedAt);
304
+ child.queueStartedAt = undefined;
305
+ }
265
306
  child.status = "interrupted";
266
307
  child.endedAt = now;
267
308
  child.result = {
@@ -275,17 +316,26 @@ export class SubagentRegistry implements RunRegistry {
275
316
  this.recomputeRunStatus(run);
276
317
  this.emit(run);
277
318
  this.runs.delete(runId);
319
+ const disposals: Array<void | Promise<void>> = [];
278
320
  for (const child of run.children) {
279
321
  this.children.delete(child.childId);
280
322
  const handle = child.handle as DisposableChildHandle | undefined;
281
- if (!handle) continue;
323
+ if (!handle) {
324
+ // Custom runners may not expose a handle until startup completes.
325
+ if (child.starting) disposals.push(child.starting.then(async (lateHandle) => {
326
+ await lateHandle.interrupt().catch(() => {});
327
+ await (lateHandle as DisposableChildHandle).dispose?.();
328
+ }, () => {}));
329
+ continue;
330
+ }
282
331
  void handle.interrupt().catch(() => {});
283
332
  try {
284
- handle.dispose?.();
285
- } catch {
286
- // ignore
333
+ disposals.push(handle.dispose?.());
334
+ } catch (error) {
335
+ disposals.push(Promise.reject(error));
287
336
  }
288
337
  }
338
+ await awaitDisposals(disposals);
289
339
  }
290
340
 
291
341
  // -------------------------------------------------------------------------
@@ -293,7 +343,7 @@ export class SubagentRegistry implements RunRegistry {
293
343
  // -------------------------------------------------------------------------
294
344
 
295
345
  /** Allocate a pending child inside a run. Returns the child id. */
296
- addChild(runId: string, info: { name: string; agent: string }): string {
346
+ addChild(runId: string, info: { name: string; agent: string; workKind?: string }): string {
297
347
  const run = this.runs.get(runId);
298
348
  if (!run) throw new Error(`unknown run ${runId}`);
299
349
  const child: InternalChild = {
@@ -301,8 +351,12 @@ export class SubagentRegistry implements RunRegistry {
301
351
  runId,
302
352
  name: info.name,
303
353
  agent: info.agent,
354
+ workKind: info.workKind ?? "other",
304
355
  status: "pending",
305
356
  startedAt: this.now(),
357
+ queueStartedAt: this.now(),
358
+ turn: 1,
359
+ queueMs: 0,
306
360
  };
307
361
  run.children.push(child);
308
362
  this.children.set(child.childId, child);
@@ -319,6 +373,7 @@ export class SubagentRegistry implements RunRegistry {
319
373
  const child = this.children.get(req.childId);
320
374
  if (!child) throw new Error(`unknown child ${req.childId} (addChild first)`);
321
375
  child.shouldStart = opts?.shouldStart;
376
+ child.workKind = req.workKind ?? child.workKind ?? "other";
322
377
  child.prompt = req.taskPrompt ?? req.prompt;
323
378
  child.preamble = req.agent.systemPrompt || undefined;
324
379
  child.model = req.model ?? req.agent.model;
@@ -333,12 +388,28 @@ export class SubagentRegistry implements RunRegistry {
333
388
  return this.failWithoutSession(child, (err as Error).message);
334
389
  }
335
390
 
336
- const handle = await runner.start(req);
391
+ child.queueStartedAt = this.now();
392
+ let handle: ChildHandle;
393
+ try {
394
+ child.starting = runner.start(req, (startingHandle) => { child.handle = startingHandle; });
395
+ handle = await child.starting;
396
+ } catch (error) {
397
+ this.closeInitialQueue(child, this.now());
398
+ throw error;
399
+ } finally {
400
+ child.starting = undefined;
401
+ }
402
+ // Teardown already owns this handle and its asynchronous cleanup.
403
+ if (!this.children.has(child.childId)) return handle;
404
+ this.closeInitialQueue(child, this.now());
337
405
  child.handle = handle;
338
- // Prefer the actually resolved provider/id over the request-time spec
339
- // (inherits parent model when neither param nor agent.model is set).
406
+ // Preserve the session's effective model and thinking setting, not a raw
407
+ // request suffix that may have fallen back to a different parent model.
340
408
  const resolved = handle.resolvedModel();
341
- if (resolved) child.model = resolved;
409
+ if (resolved) {
410
+ const thinking = handle.effectiveThinkingLevel?.();
411
+ child.model = `${resolved}${thinking ? `:${thinking}` : ""}`;
412
+ }
342
413
  // Generation-1 result wiring (later generations are wired in admitChild,
343
414
  // where the handle is already visible).
344
415
  handle.result.then((result) => this.settleChild(child.childId, result));
@@ -360,7 +431,10 @@ export class SubagentRegistry implements RunRegistry {
360
431
  * and wires result settlement for resumed generations. Returns the slot
361
432
  * releaser.
362
433
  */
363
- async admitChild(childId: string): Promise<() => void> {
434
+ async reserveChildSlot(
435
+ childId: string,
436
+ ticket?: { current: () => boolean },
437
+ ): Promise<{ admit: () => void; release: () => void }> {
364
438
  await this.acquireSlot();
365
439
  let released = false;
366
440
  const release = () => {
@@ -373,18 +447,111 @@ export class SubagentRegistry implements RunRegistry {
373
447
  release();
374
448
  throw new Error(`unknown child ${childId}`);
375
449
  }
376
- if (child.shouldStart && !child.shouldStart()) {
450
+ const checkCurrent = () => {
451
+ if (child.shouldStart && !child.shouldStart()) throw new ChildCancelledError();
452
+ // A request settled while waiting (interrupt/dispose or a newer resume
453
+ // queued behind other children) must hand this local reservation on.
454
+ const stale = ticket ? !ticket.current() : (child.handle?.status() ?? "pending") !== "pending";
455
+ if (stale) throw new ChildCancelledError("settled while queued");
456
+ };
457
+ try {
458
+ checkCurrent();
459
+ } catch (error) {
460
+ this.closeInitialQueue(child, this.now());
377
461
  release();
378
- throw new ChildCancelledError();
462
+ throw error;
379
463
  }
380
- this.transitionChild(child, "running");
464
+ let admitted = false;
465
+ return {
466
+ release,
467
+ admit: () => {
468
+ if (admitted) return;
469
+ checkCurrent();
470
+ this.closeInitialQueue(child, this.now());
471
+ admitted = true;
472
+ this.transitionChild(child, "running");
473
+ const handle = child.handle;
474
+ if (handle) {
475
+ // Resume generations: wire settlement only once global admission also succeeds.
476
+ handle.result.then((result) => this.settleChild(childId, result));
477
+ }
478
+ },
479
+ };
480
+ }
481
+
482
+ async admitChild(childId: string, ticket?: { current: () => boolean }): Promise<() => void> {
483
+ const reservation = await this.reserveChildSlot(childId, ticket);
484
+ try {
485
+ reservation.admit();
486
+ return reservation.release;
487
+ } catch (error) {
488
+ reservation.release();
489
+ throw error;
490
+ }
491
+ }
492
+
493
+ /**
494
+ * Resume an ended child: a new user turn on its session. Returns once the
495
+ * request is accepted; admission runs in the background (design.md §4.6
496
+ * "Child lifecycle", resume rows). Until a slot is granted the child is
497
+ * pending. `queuedBehind` is null when a slot was free at request time,
498
+ * otherwise how many children were already waiting for one.
499
+ */
500
+ async resumeChild(
501
+ childId: string,
502
+ message: string,
503
+ opts: { timeoutMs?: number } = {},
504
+ ): Promise<{ queuedBehind: number | null }> {
505
+ const child = this.children.get(childId);
506
+ if (!child) throw new Error(`unknown child ${childId}`);
381
507
  const handle = child.handle;
382
- if (handle) {
383
- // Resume generations: the handle (and its fresh result promise) already
384
- // exists, so wire settlement here. Generation 1 is wired by startChild.
385
- handle.result.then((result) => this.settleChild(childId, result));
508
+ if (!handle) throw new Error(`subagent ${childId} has no live session to resume`);
509
+ const queuedBehind = this.activeSlots >= this.maxChildren ? this.slotWaiters.length : null;
510
+ const previous = {
511
+ status: child.status,
512
+ result: child.result,
513
+ startedAt: child.startedAt,
514
+ endedAt: child.endedAt,
515
+ queueMs: child.queueMs,
516
+ queueStartedAt: child.queueStartedAt,
517
+ };
518
+ const accepted = handle.resume(message, opts);
519
+ // Mark the child queued before awaiting: with a free slot, admitChild's
520
+ // continuation can run first and move it to running, which must win.
521
+ child.status = "pending";
522
+ child.result = undefined;
523
+ child.endedAt = undefined;
524
+ child.startedAt = this.now();
525
+ child.queueMs = 0;
526
+ child.queueStartedAt = child.startedAt;
527
+ child.turn += 1;
528
+ try {
529
+ await accepted;
530
+ } catch (err) {
531
+ // Undo this attempt's +1 even if a concurrent accepted resume has
532
+ // already moved the child to running (the pending guard would skip).
533
+ child.turn -= 1;
534
+ if (child.status === "pending") {
535
+ child.status = previous.status;
536
+ child.result = previous.result;
537
+ child.startedAt = previous.startedAt;
538
+ child.endedAt = previous.endedAt;
539
+ child.queueMs = previous.queueMs;
540
+ child.queueStartedAt = previous.queueStartedAt;
541
+ }
542
+ throw err;
386
543
  }
387
- return release;
544
+ // Wired at request time, not admission: a turn that never gets a slot
545
+ // (interrupt/dispose while queued, abort drain bound) still settles here.
546
+ handle.result.then((result) => this.settleChild(childId, result));
547
+ if (child.status === "pending") {
548
+ const run = this.runs.get(child.runId);
549
+ if (run) {
550
+ this.recomputeRunStatus(run);
551
+ this.emit(run);
552
+ }
553
+ }
554
+ return { queuedBehind };
388
555
  }
389
556
 
390
557
  /** Current generation's result promise for a child (post-resume aware). */
@@ -400,12 +567,17 @@ export class SubagentRegistry implements RunRegistry {
400
567
  finalizeRun(runId: string, error: string): void {
401
568
  const run = this.runs.get(runId);
402
569
  if (!run) return;
570
+ const now = this.now();
403
571
  let changed = false;
404
572
  for (const child of run.children) {
405
573
  if (child.status === "pending" && !child.handle) {
574
+ if (child.queueStartedAt !== undefined) {
575
+ child.queueMs = (child.queueMs ?? 0) + Math.max(0, now - child.queueStartedAt);
576
+ child.queueStartedAt = undefined;
577
+ }
406
578
  child.status = "interrupted";
407
579
  child.result = { status: "interrupted", text: "", error, durationMs: 0 };
408
- child.endedAt = this.now();
580
+ child.endedAt = now;
409
581
  changed = true;
410
582
  }
411
583
  }
@@ -425,6 +597,11 @@ export class SubagentRegistry implements RunRegistry {
425
597
  runId: child.runId,
426
598
  name: child.name,
427
599
  agent: child.agent,
600
+ workKind: child.workKind,
601
+ queueMs:
602
+ child.status === "pending" && child.queueStartedAt !== undefined
603
+ ? (child.queueMs ?? 0) + Math.max(0, this.now() - child.queueStartedAt)
604
+ : child.queueMs,
428
605
  model: child.model,
429
606
  status: child.status,
430
607
  startedAt: child.startedAt,
@@ -436,10 +613,8 @@ export class SubagentRegistry implements RunRegistry {
436
613
  }
437
614
 
438
615
  /** Dispose every run (session shutdown). */
439
- disposeAll(): void {
440
- for (const runId of [...this.runs.keys()]) {
441
- this.disposeRun(runId);
442
- }
616
+ async disposeAll(): Promise<void> {
617
+ await awaitDisposals([...this.runs.keys()].map((runId) => this.disposeRun(runId)));
443
618
  }
444
619
 
445
620
  // -------------------------------------------------------------------------
@@ -458,9 +633,15 @@ export class SubagentRegistry implements RunRegistry {
458
633
  const child = this.children.get(childId);
459
634
  if (!child) return;
460
635
  if (child.status !== "pending" && child.status !== "running") return; // already terminal
636
+ const settledAt = this.now();
637
+ if (child.status === "pending" && child.queueStartedAt !== undefined) {
638
+ child.queueMs = Math.max(0, settledAt - child.queueStartedAt);
639
+ child.queueStartedAt = undefined;
640
+ }
641
+ if (child.queueMs === undefined && result.queueMs !== undefined) child.queueMs = result.queueMs;
461
642
  child.status = result.status;
462
643
  child.result = result;
463
- child.endedAt = this.now();
644
+ child.endedAt = settledAt;
464
645
  child.shouldStart = undefined;
465
646
  const run = this.runs.get(child.runId);
466
647
  if (run) {
@@ -473,8 +654,11 @@ export class SubagentRegistry implements RunRegistry {
473
654
  if (child.status === status) return;
474
655
  child.status = status;
475
656
  if (status === "running") {
657
+ const startedAt = this.now();
658
+ child.queueMs = Math.max(0, startedAt - (child.queueStartedAt ?? child.startedAt));
659
+ child.queueStartedAt = undefined;
476
660
  // (Re-)start: elapsed time and any previous generation's result reset.
477
- child.startedAt = this.now();
661
+ child.startedAt = startedAt;
478
662
  child.result = undefined;
479
663
  child.endedAt = undefined;
480
664
  }
@@ -502,9 +686,15 @@ export class SubagentRegistry implements RunRegistry {
502
686
  else run.status = "partial";
503
687
  }
504
688
 
689
+ private closeInitialQueue(child: InternalChild, at: number): void {
690
+ if (child.queueStartedAt === undefined) return;
691
+ child.queueMs += Math.max(0, at - child.queueStartedAt);
692
+ child.queueStartedAt = undefined;
693
+ }
694
+
505
695
  private emit(run: InternalRun): void {
506
696
  if (this.transitionCbs.size === 0) return;
507
- const record = snapshot(run);
697
+ const record = snapshot(run, this.now());
508
698
  for (const cb of this.transitionCbs) {
509
699
  try {
510
700
  cb(record);
@@ -553,7 +743,7 @@ export class SubagentRegistry implements RunRegistry {
553
743
  }
554
744
  }
555
745
 
556
- function snapshot(run: InternalRun): RunRecord {
746
+ function snapshot(run: InternalRun, now: number): RunRecord {
557
747
  return {
558
748
  runId: run.runId,
559
749
  kind: run.kind,
@@ -563,6 +753,8 @@ function snapshot(run: InternalRun): RunRecord {
563
753
  childId: c.childId,
564
754
  name: c.name,
565
755
  agent: c.agent,
756
+ ...(c.workKind !== undefined ? { workKind: c.workKind } : {}),
757
+ ...(c.queueMs !== undefined ? { queueMs: c.queueMs } : {}),
566
758
  ...(c.model !== undefined ? { model: c.model } : {}),
567
759
  status: c.status,
568
760
  ...(c.prompt !== undefined ? { prompt: c.prompt } : {}),
@@ -570,6 +762,15 @@ function snapshot(run: InternalRun): RunRecord {
570
762
  result: c.result,
571
763
  startedAt: c.startedAt,
572
764
  endedAt: c.endedAt,
765
+ turn: c.turn,
766
+ tokenUsage: c.handle?.tokenUsage() ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
767
+ wallUsage: c.handle?.wallUsage() ?? {
768
+ llmMs: 0,
769
+ toolMs: 0,
770
+ queueMs: c.queueMs + (c.queueStartedAt === undefined ? 0 : Math.max(0, now - c.queueStartedAt)),
771
+ otherMs: c.status === "running" ? Math.max(0, now - c.startedAt) : 0,
772
+ approximate: c.status === "running",
773
+ },
573
774
  })),
574
775
  };
575
776
  }