pi-famulus 0.1.3 → 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.
@@ -26,9 +26,17 @@ import type {
26
26
  ChildRunRequest,
27
27
  ChildRunner,
28
28
  ChildStatus,
29
+ ChildTokenUsage,
30
+ ChildWallUsage,
29
31
  DisposableChildHandle,
30
32
  } from "./types";
31
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
+
32
40
  // ---------------------------------------------------------------------------
33
41
  // Appendix B public record shape
34
42
  // ---------------------------------------------------------------------------
@@ -40,6 +48,8 @@ export interface RunRecord {
40
48
  childId: string;
41
49
  name: string;
42
50
  agent: string;
51
+ workKind?: string;
52
+ queueMs?: number;
43
53
  model?: string;
44
54
  status: ChildStatus;
45
55
  /** User-authored prompt (after chain interpolation), without injected preamble. */
@@ -51,6 +61,8 @@ export interface RunRecord {
51
61
  endedAt?: number;
52
62
  /** User turns so far: 1 for the launch, +1 per accepted resume. */
53
63
  turn?: number;
64
+ tokenUsage?: ChildTokenUsage;
65
+ wallUsage?: ChildWallUsage;
54
66
  }[];
55
67
  status: "running" | "completed" | "partial" | "failed" | "interrupted";
56
68
  createdAt: number;
@@ -64,7 +76,7 @@ export interface RunRegistry {
64
76
  findChild(runId: string, childIdOrName: string): ChildHandle | undefined;
65
77
  lineage(runIdA: string, runIdB: string): boolean; // v1: same runId
66
78
  onTransition(cb: (run: RunRecord) => void): void; // fleet widget / notifications
67
- disposeRun(runId: string): void;
79
+ disposeRun(runId: string): Promise<void>;
68
80
  }
69
81
 
70
82
  // ---------------------------------------------------------------------------
@@ -94,6 +106,8 @@ export interface ActiveChildInfo {
94
106
  runId: string;
95
107
  name: string;
96
108
  agent: string;
109
+ workKind?: string;
110
+ queueMs?: number;
97
111
  model?: string;
98
112
  status: ChildStatus;
99
113
  startedAt: number;
@@ -105,6 +119,7 @@ interface InternalChild {
105
119
  runId: string;
106
120
  name: string;
107
121
  agent: string;
122
+ workKind?: string;
108
123
  /** Best-effort model id for fleet / ls (set when startChild runs). */
109
124
  model?: string;
110
125
  status: ChildStatus;
@@ -115,7 +130,11 @@ interface InternalChild {
115
130
  endedAt?: number;
116
131
  turn: number;
117
132
  handle?: ChildHandle;
133
+ starting?: Promise<ChildHandle>;
118
134
  shouldStart?: () => boolean;
135
+ /** Initial admission wait while runner.start() has not returned its handle. */
136
+ queueStartedAt?: number;
137
+ queueMs: number;
119
138
  }
120
139
 
121
140
  interface InternalRun {
@@ -185,6 +204,14 @@ class FailedChildHandle implements ChildHandle {
185
204
  conversation() {
186
205
  return [];
187
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
+ }
188
215
  }
189
216
 
190
217
  export class SubagentRegistry implements RunRegistry {
@@ -229,16 +256,17 @@ export class SubagentRegistry implements RunRegistry {
229
256
  };
230
257
  this.runs.set(run.runId, run);
231
258
  this.emit(run);
232
- return snapshot(run);
259
+ return snapshot(run, this.now());
233
260
  }
234
261
 
235
262
  get(runId: string): RunRecord | undefined {
236
263
  const run = this.runs.get(runId);
237
- return run ? snapshot(run) : undefined;
264
+ return run ? snapshot(run, this.now()) : undefined;
238
265
  }
239
266
 
240
267
  list(): RunRecord[] {
241
- return [...this.runs.values()].map(snapshot);
268
+ const now = this.now();
269
+ return [...this.runs.values()].map((run) => snapshot(run, now));
242
270
  }
243
271
 
244
272
  handle(childId: string): ChildHandle | undefined {
@@ -262,7 +290,7 @@ export class SubagentRegistry implements RunRegistry {
262
290
  this.transitionCbs.add(cb);
263
291
  }
264
292
 
265
- disposeRun(runId: string): void {
293
+ async disposeRun(runId: string): Promise<void> {
266
294
  const run = this.runs.get(runId);
267
295
  if (!run) return;
268
296
  const now = this.now();
@@ -271,6 +299,10 @@ export class SubagentRegistry implements RunRegistry {
271
299
  // "running" record (ghost agents in ls).
272
300
  for (const child of run.children) {
273
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
+ }
274
306
  child.status = "interrupted";
275
307
  child.endedAt = now;
276
308
  child.result = {
@@ -284,17 +316,26 @@ export class SubagentRegistry implements RunRegistry {
284
316
  this.recomputeRunStatus(run);
285
317
  this.emit(run);
286
318
  this.runs.delete(runId);
319
+ const disposals: Array<void | Promise<void>> = [];
287
320
  for (const child of run.children) {
288
321
  this.children.delete(child.childId);
289
322
  const handle = child.handle as DisposableChildHandle | undefined;
290
- 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
+ }
291
331
  void handle.interrupt().catch(() => {});
292
332
  try {
293
- handle.dispose?.();
294
- } catch {
295
- // ignore
333
+ disposals.push(handle.dispose?.());
334
+ } catch (error) {
335
+ disposals.push(Promise.reject(error));
296
336
  }
297
337
  }
338
+ await awaitDisposals(disposals);
298
339
  }
299
340
 
300
341
  // -------------------------------------------------------------------------
@@ -302,7 +343,7 @@ export class SubagentRegistry implements RunRegistry {
302
343
  // -------------------------------------------------------------------------
303
344
 
304
345
  /** Allocate a pending child inside a run. Returns the child id. */
305
- addChild(runId: string, info: { name: string; agent: string }): string {
346
+ addChild(runId: string, info: { name: string; agent: string; workKind?: string }): string {
306
347
  const run = this.runs.get(runId);
307
348
  if (!run) throw new Error(`unknown run ${runId}`);
308
349
  const child: InternalChild = {
@@ -310,9 +351,12 @@ export class SubagentRegistry implements RunRegistry {
310
351
  runId,
311
352
  name: info.name,
312
353
  agent: info.agent,
354
+ workKind: info.workKind ?? "other",
313
355
  status: "pending",
314
356
  startedAt: this.now(),
357
+ queueStartedAt: this.now(),
315
358
  turn: 1,
359
+ queueMs: 0,
316
360
  };
317
361
  run.children.push(child);
318
362
  this.children.set(child.childId, child);
@@ -329,6 +373,7 @@ export class SubagentRegistry implements RunRegistry {
329
373
  const child = this.children.get(req.childId);
330
374
  if (!child) throw new Error(`unknown child ${req.childId} (addChild first)`);
331
375
  child.shouldStart = opts?.shouldStart;
376
+ child.workKind = req.workKind ?? child.workKind ?? "other";
332
377
  child.prompt = req.taskPrompt ?? req.prompt;
333
378
  child.preamble = req.agent.systemPrompt || undefined;
334
379
  child.model = req.model ?? req.agent.model;
@@ -343,12 +388,28 @@ export class SubagentRegistry implements RunRegistry {
343
388
  return this.failWithoutSession(child, (err as Error).message);
344
389
  }
345
390
 
346
- 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());
347
405
  child.handle = handle;
348
- // Prefer the actually resolved provider/id over the request-time spec
349
- // (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.
350
408
  const resolved = handle.resolvedModel();
351
- if (resolved) child.model = resolved;
409
+ if (resolved) {
410
+ const thinking = handle.effectiveThinkingLevel?.();
411
+ child.model = `${resolved}${thinking ? `:${thinking}` : ""}`;
412
+ }
352
413
  // Generation-1 result wiring (later generations are wired in admitChild,
353
414
  // where the handle is already visible).
354
415
  handle.result.then((result) => this.settleChild(child.childId, result));
@@ -370,7 +431,10 @@ export class SubagentRegistry implements RunRegistry {
370
431
  * and wires result settlement for resumed generations. Returns the slot
371
432
  * releaser.
372
433
  */
373
- async admitChild(childId: string, ticket?: { current: () => boolean }): Promise<() => void> {
434
+ async reserveChildSlot(
435
+ childId: string,
436
+ ticket?: { current: () => boolean },
437
+ ): Promise<{ admit: () => void; release: () => void }> {
374
438
  await this.acquireSlot();
375
439
  let released = false;
376
440
  const release = () => {
@@ -383,27 +447,47 @@ export class SubagentRegistry implements RunRegistry {
383
447
  release();
384
448
  throw new Error(`unknown child ${childId}`);
385
449
  }
386
- if (child.shouldStart && !child.shouldStart()) {
387
- release();
388
- throw new ChildCancelledError();
389
- }
390
- // The generation that queued this request settled while it waited
391
- // (interrupt/dispose; maybe a newer resume is queued behind other
392
- // children): hand the slot on, without touching the child's status.
393
- // Without a ticket, fall back to the handle's status.
394
- const stale = ticket ? !ticket.current() : (child.handle?.status() ?? "pending") !== "pending";
395
- if (stale) {
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());
396
461
  release();
397
- throw new ChildCancelledError("settled while queued");
462
+ throw error;
398
463
  }
399
- this.transitionChild(child, "running");
400
- const handle = child.handle;
401
- if (handle) {
402
- // Resume generations: the handle (and its fresh result promise) already
403
- // exists, so wire settlement here. Generation 1 is wired by startChild.
404
- handle.result.then((result) => this.settleChild(childId, result));
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;
405
490
  }
406
- return release;
407
491
  }
408
492
 
409
493
  /**
@@ -428,6 +512,8 @@ export class SubagentRegistry implements RunRegistry {
428
512
  result: child.result,
429
513
  startedAt: child.startedAt,
430
514
  endedAt: child.endedAt,
515
+ queueMs: child.queueMs,
516
+ queueStartedAt: child.queueStartedAt,
431
517
  };
432
518
  const accepted = handle.resume(message, opts);
433
519
  // Mark the child queued before awaiting: with a free slot, admitChild's
@@ -436,6 +522,8 @@ export class SubagentRegistry implements RunRegistry {
436
522
  child.result = undefined;
437
523
  child.endedAt = undefined;
438
524
  child.startedAt = this.now();
525
+ child.queueMs = 0;
526
+ child.queueStartedAt = child.startedAt;
439
527
  child.turn += 1;
440
528
  try {
441
529
  await accepted;
@@ -448,6 +536,8 @@ export class SubagentRegistry implements RunRegistry {
448
536
  child.result = previous.result;
449
537
  child.startedAt = previous.startedAt;
450
538
  child.endedAt = previous.endedAt;
539
+ child.queueMs = previous.queueMs;
540
+ child.queueStartedAt = previous.queueStartedAt;
451
541
  }
452
542
  throw err;
453
543
  }
@@ -477,12 +567,17 @@ export class SubagentRegistry implements RunRegistry {
477
567
  finalizeRun(runId: string, error: string): void {
478
568
  const run = this.runs.get(runId);
479
569
  if (!run) return;
570
+ const now = this.now();
480
571
  let changed = false;
481
572
  for (const child of run.children) {
482
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
+ }
483
578
  child.status = "interrupted";
484
579
  child.result = { status: "interrupted", text: "", error, durationMs: 0 };
485
- child.endedAt = this.now();
580
+ child.endedAt = now;
486
581
  changed = true;
487
582
  }
488
583
  }
@@ -502,6 +597,11 @@ export class SubagentRegistry implements RunRegistry {
502
597
  runId: child.runId,
503
598
  name: child.name,
504
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,
505
605
  model: child.model,
506
606
  status: child.status,
507
607
  startedAt: child.startedAt,
@@ -513,10 +613,8 @@ export class SubagentRegistry implements RunRegistry {
513
613
  }
514
614
 
515
615
  /** Dispose every run (session shutdown). */
516
- disposeAll(): void {
517
- for (const runId of [...this.runs.keys()]) {
518
- this.disposeRun(runId);
519
- }
616
+ async disposeAll(): Promise<void> {
617
+ await awaitDisposals([...this.runs.keys()].map((runId) => this.disposeRun(runId)));
520
618
  }
521
619
 
522
620
  // -------------------------------------------------------------------------
@@ -535,9 +633,15 @@ export class SubagentRegistry implements RunRegistry {
535
633
  const child = this.children.get(childId);
536
634
  if (!child) return;
537
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;
538
642
  child.status = result.status;
539
643
  child.result = result;
540
- child.endedAt = this.now();
644
+ child.endedAt = settledAt;
541
645
  child.shouldStart = undefined;
542
646
  const run = this.runs.get(child.runId);
543
647
  if (run) {
@@ -550,8 +654,11 @@ export class SubagentRegistry implements RunRegistry {
550
654
  if (child.status === status) return;
551
655
  child.status = status;
552
656
  if (status === "running") {
657
+ const startedAt = this.now();
658
+ child.queueMs = Math.max(0, startedAt - (child.queueStartedAt ?? child.startedAt));
659
+ child.queueStartedAt = undefined;
553
660
  // (Re-)start: elapsed time and any previous generation's result reset.
554
- child.startedAt = this.now();
661
+ child.startedAt = startedAt;
555
662
  child.result = undefined;
556
663
  child.endedAt = undefined;
557
664
  }
@@ -579,9 +686,15 @@ export class SubagentRegistry implements RunRegistry {
579
686
  else run.status = "partial";
580
687
  }
581
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
+
582
695
  private emit(run: InternalRun): void {
583
696
  if (this.transitionCbs.size === 0) return;
584
- const record = snapshot(run);
697
+ const record = snapshot(run, this.now());
585
698
  for (const cb of this.transitionCbs) {
586
699
  try {
587
700
  cb(record);
@@ -630,7 +743,7 @@ export class SubagentRegistry implements RunRegistry {
630
743
  }
631
744
  }
632
745
 
633
- function snapshot(run: InternalRun): RunRecord {
746
+ function snapshot(run: InternalRun, now: number): RunRecord {
634
747
  return {
635
748
  runId: run.runId,
636
749
  kind: run.kind,
@@ -640,6 +753,8 @@ function snapshot(run: InternalRun): RunRecord {
640
753
  childId: c.childId,
641
754
  name: c.name,
642
755
  agent: c.agent,
756
+ ...(c.workKind !== undefined ? { workKind: c.workKind } : {}),
757
+ ...(c.queueMs !== undefined ? { queueMs: c.queueMs } : {}),
643
758
  ...(c.model !== undefined ? { model: c.model } : {}),
644
759
  status: c.status,
645
760
  ...(c.prompt !== undefined ? { prompt: c.prompt } : {}),
@@ -648,6 +763,14 @@ function snapshot(run: InternalRun): RunRecord {
648
763
  startedAt: c.startedAt,
649
764
  endedAt: c.endedAt,
650
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
+ },
651
774
  })),
652
775
  };
653
776
  }