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.
@@ -41,11 +41,13 @@
41
41
  import { realClock, TimerScope, type Clock, type ClockTimer } from "../clock";
42
42
  import type { OverrunTick } from "./overrun";
43
43
  import type {
44
+ ChildHandle,
44
45
  ChildResult,
45
46
  ChildRunRequest,
46
47
  ChildRunner,
47
48
  ChildSessionAdapter,
48
49
  ChildStatus,
50
+ ChildWallUsage,
49
51
  CreateSessionFn,
50
52
  DisposableChildHandle,
51
53
  } from "./types";
@@ -71,6 +73,8 @@ export function stallRetryPrompt(stallMs: number): string {
71
73
  export interface AdmissionTicket {
72
74
  /** False once the requesting generation settled or was superseded. */
73
75
  current: () => boolean;
76
+ /** Aborted when this generation settles while its admission is queued. */
77
+ signal: AbortSignal;
74
78
  }
75
79
 
76
80
  export interface InProcessRunnerOptions {
@@ -96,10 +100,11 @@ export interface InProcessRunnerOptions {
96
100
  clock?: Clock;
97
101
  /**
98
102
  * Per-generation admission hook. Awaited before each (re)start; the
99
- * resolved releaser is called when the generation settles. Rejecting
100
- * cancels the generation as {status:"interrupted", error}.
103
+ * releaser gets terminal=false for a resumable interruption so its local
104
+ * slot can be returned while the child-owned machine permit is retained.
105
+ * Rejecting cancels the generation as {status:"interrupted", error}.
101
106
  */
102
- acquire?: (req: ChildRunRequest, ticket: AdmissionTicket) => Promise<() => void>;
107
+ acquire?: (req: ChildRunRequest, ticket: AdmissionTicket) => Promise<(terminal?: boolean) => void>;
103
108
  /**
104
109
  * Called after the child's conversation may have changed (a message or
105
110
  * tool finished, or the generation settled). Used to persist transcripts.
@@ -118,8 +123,9 @@ export class InProcessRunner implements ChildRunner {
118
123
  this.opts = opts;
119
124
  }
120
125
 
121
- async start(req: ChildRunRequest): Promise<DisposableChildHandle> {
126
+ async start(req: ChildRunRequest, onHandle?: (handle: ChildHandle) => void): Promise<DisposableChildHandle> {
122
127
  const handle = new InProcessChildHandle(req, this.opts);
128
+ onHandle?.(handle);
123
129
  await handle.launch();
124
130
  return handle;
125
131
  }
@@ -136,18 +142,21 @@ class InProcessChildHandle implements DisposableChildHandle {
136
142
  private readonly hardTimeoutMs: number;
137
143
  private readonly onOverrun?: (tick: OverrunTick) => void;
138
144
  private readonly clock: Clock;
139
- private readonly acquire?: (req: ChildRunRequest, ticket: AdmissionTicket) => Promise<() => void>;
145
+ private readonly acquire?: (req: ChildRunRequest, ticket: AdmissionTicket) => Promise<(terminal?: boolean) => void>;
140
146
  private readonly onActivity?: (childId: string) => void;
141
147
 
142
148
  private session: ChildSessionAdapter | null = null;
149
+ private sessionCreation: Promise<ChildSessionAdapter> | null = null;
143
150
  private resolvedModel_: string | undefined;
144
151
  private unsubscribe: (() => void) | null = null;
145
152
  private status_: ChildStatus = "pending";
146
153
  private lastEvent: number;
147
- /** Generation start (reset per generation; durationMs is relative to runStartedAt). */
154
+ /** Session start, set once and preserved across user resumes. */
148
155
  private startedAt: number;
149
156
  /** User-turn start: reset on launch and user resume(), not on stall retries. */
150
157
  private runStartedAt: number;
158
+ /** Timestamp when this generation acquired admission; durationMs is measured from here. */
159
+ private admittedAt: number | null;
151
160
  /**
152
161
  * When the turn's deadlines start counting. Set after admission and session
153
162
  * creation (queue wait must not eat the budget), and NOT reset by stall
@@ -185,10 +194,29 @@ class InProcessChildHandle implements DisposableChildHandle {
185
194
  private stallAttempts = 0;
186
195
  /** Generation retired by a stall detection awaiting its retry. */
187
196
  private retiredGen: number | null = null;
188
- private releaseSlot: (() => void) | null = null;
197
+ private releaseSlot: ((terminal?: boolean) => void) | null = null;
198
+ private admissionAbort: AbortController | null = null;
199
+ /** Whether the current generation's settled result is terminal for this child. */
200
+ private terminalSettle = false;
189
201
  private disposed = false;
190
- /** Nested tool_execution_start/end. Stall stays paused while > 0. */
202
+ private disposalPromise: Promise<void> | undefined;
203
+ /** Nested current-generation tool calls. Stall stays paused while > 0. */
191
204
  private toolDepth = 0;
205
+ /** Start generation by Pi toolCallId so late end events cannot close a new interval. */
206
+ private readonly toolCallGenerations = new Map<string, number>();
207
+ /** FIFO fallback for adapters that omit Pi's toolCallId (marked approximate). */
208
+ private readonly anonymousToolGenerations: number[] = [];
209
+ private wall: ChildWallUsage = { llmMs: 0, toolMs: 0, queueMs: 0, otherMs: 0, approximate: false };
210
+ private generationAt: number | null = null;
211
+ private generationLlmMs = 0;
212
+ private generationToolMs = 0;
213
+ private generationQueueMs = 0;
214
+ private queueStartedAt: number | null = null;
215
+ private queueGeneration: number | null = null;
216
+ /** Pending-abort drain before resume is unclassified wall time. */
217
+ private otherStartedAt: number | null = null;
218
+ private llmStartedAt: number | null = null;
219
+ private toolStartedAt: number | null = null;
192
220
  /** contact_supervisor need_decision. Stall stays paused while true. */
193
221
  private decisionPaused = false;
194
222
 
@@ -219,6 +247,7 @@ class InProcessChildHandle implements DisposableChildHandle {
219
247
  this.onActivity = opts.onActivity;
220
248
  this.startedAt = this.clock.now();
221
249
  this.runStartedAt = this.startedAt;
250
+ this.admittedAt = null;
222
251
  this.turnBudgetStart = this.startedAt;
223
252
  this.lastEvent = this.startedAt;
224
253
  this.resultPromise = new Promise((resolve) => {
@@ -247,6 +276,10 @@ class InProcessChildHandle implements DisposableChildHandle {
247
276
  return this.resolvedModel_ ?? this.session?.resolvedModel;
248
277
  }
249
278
 
279
+ effectiveThinkingLevel(): string | undefined {
280
+ return this.session?.effectiveThinkingLevel;
281
+ }
282
+
250
283
  /**
251
284
  * need_decision pending: pause the stall watchdog (nested with tool
252
285
  * execution) and hold overrun reminders. The soft budget keeps counting.
@@ -269,6 +302,30 @@ class InProcessChildHandle implements DisposableChildHandle {
269
302
  return this.session?.getConversation() ?? [];
270
303
  }
271
304
 
305
+ tokenUsage() {
306
+ return this.session?.tokenUsage() ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
307
+ }
308
+
309
+ wallUsage(): ChildWallUsage {
310
+ const now = this.clock.now();
311
+ const queueMs = this.queueStartedAt === null ? 0 : Math.max(0, now - this.queueStartedAt);
312
+ const llmMs = this.llmStartedAt === null ? 0 : Math.max(0, now - this.llmStartedAt);
313
+ const toolMs = this.toolStartedAt === null ? 0 : Math.max(0, now - this.toolStartedAt);
314
+ const elapsed = this.generationAt === null ? 0 : Math.max(0, now - this.generationAt);
315
+ const accounted = this.generationQueueMs + queueMs + this.generationLlmMs + llmMs + this.generationToolMs + toolMs;
316
+ const otherMs =
317
+ this.wall.otherMs +
318
+ Math.max(0, elapsed - accounted) +
319
+ (this.otherStartedAt === null ? 0 : Math.max(0, now - this.otherStartedAt));
320
+ return {
321
+ llmMs: this.wall.llmMs + llmMs,
322
+ toolMs: this.wall.toolMs + toolMs,
323
+ queueMs: this.wall.queueMs + queueMs,
324
+ otherMs,
325
+ approximate: this.wall.approximate || otherMs > this.wall.otherMs,
326
+ };
327
+ }
328
+
272
329
  /** Launch generation 1. Resolves once the prompt is issued (not completed). */
273
330
  async launch(): Promise<void> {
274
331
  await this.beginGeneration(this.req.prompt, true);
@@ -363,6 +420,7 @@ class InProcessChildHandle implements DisposableChildHandle {
363
420
  opts.timeoutMs !== undefined && opts.timeoutMs > 0 ? opts.timeoutMs : this.req.timeoutMs;
364
421
  this.reminders = 0;
365
422
  this.runStartedAt = this.clock.now();
423
+ this.admittedAt = null;
366
424
  // The turn's generation exists from here: interrupt()/dispose() while it
367
425
  // is queued settle it, and its result promise is observable through
368
426
  // registry.getResult() before admission.
@@ -370,7 +428,6 @@ class InProcessChildHandle implements DisposableChildHandle {
370
428
  this.settledFlag = false;
371
429
  this.retiredGen = null;
372
430
  this.status_ = "pending";
373
- this.startedAt = this.runStartedAt;
374
431
  this.lastEvent = this.runStartedAt;
375
432
  this.resultPromise = new Promise((resolve) => {
376
433
  this.resolveResult = resolve;
@@ -387,14 +444,19 @@ class InProcessChildHandle implements DisposableChildHandle {
387
444
  * dead, and the turn settles failed.
388
445
  */
389
446
  private async startResumedTurn(gen: number, message: string, pendingAbort: Promise<void> | null): Promise<void> {
390
- if (pendingAbort && !(await this.awaitAbortBounded(pendingAbort))) {
391
- this.settle(gen, {
392
- status: "failed",
393
- text: "",
394
- error: `subagent ${this.req.childId} session did not go idle after the abort; cannot resume`,
395
- durationMs: this.now() - this.runStartedAt,
396
- });
397
- return;
447
+ if (pendingAbort) {
448
+ this.otherStartedAt = this.now();
449
+ const idle = await this.awaitAbortBounded(pendingAbort);
450
+ this.closeOther(this.now());
451
+ if (!idle) {
452
+ this.settle(gen, {
453
+ status: "failed",
454
+ text: "",
455
+ error: `subagent ${this.req.childId} session did not go idle after the abort; cannot resume`,
456
+ durationMs: this.now() - this.runStartedAt,
457
+ });
458
+ return;
459
+ }
398
460
  }
399
461
  if (this.disposed || this.isSettled(gen)) return;
400
462
  await this.beginGeneration(message, false, false, gen);
@@ -416,7 +478,8 @@ class InProcessChildHandle implements DisposableChildHandle {
416
478
  }
417
479
  }
418
480
 
419
- dispose(): void {
481
+ dispose(): void | Promise<void> {
482
+ if (this.disposed) return this.disposalPromise;
420
483
  this.disposed = true;
421
484
  this.clearTimers();
422
485
  if (this.unsubscribe) {
@@ -429,15 +492,18 @@ class InProcessChildHandle implements DisposableChildHandle {
429
492
  }
430
493
  const session = this.session;
431
494
  this.session = null;
432
- if (session) {
433
- try {
434
- session.dispose();
435
- } catch {
436
- // ignore
437
- }
495
+ try {
496
+ this.disposalPromise = session
497
+ ? session.dispose() || undefined
498
+ : this.sessionCreation?.then((created) => created.dispose(), () => {});
499
+ return this.disposalPromise;
500
+ } finally {
501
+ // Never leave result waiters hanging, even when cleanup throws or awaits.
502
+ this.settle(this.generation, { status: "interrupted", text: "", error: "disposed", durationMs: 0 });
503
+ // settle() can be a no-op if the child was already interrupted; disposal
504
+ // is terminal and must still return its retained machine permit.
505
+ this.release(true);
438
506
  }
439
- // Never leave result waiters hanging.
440
- this.settle(this.generation, { status: "interrupted", text: "", error: "disposed", durationMs: 0 });
441
507
  }
442
508
 
443
509
  // -------------------------------------------------------------------------
@@ -455,6 +521,9 @@ class InProcessChildHandle implements DisposableChildHandle {
455
521
  reuseSlot = false,
456
522
  resumedGen?: number,
457
523
  ): Promise<void> {
524
+ if (this.disposed) return;
525
+ // Stall retries replace a generation without settling the user turn.
526
+ if (reuseSlot) this.pruneToolCalls(this.generation);
458
527
  // A resumed turn allocated its generation when it was requested.
459
528
  const gen = resumedGen ?? ++this.generation;
460
529
  this.retiredGen = null;
@@ -462,24 +531,46 @@ class InProcessChildHandle implements DisposableChildHandle {
462
531
  this.timerScope?.dispose();
463
532
  this.timerScope = new TimerScope(this.clock);
464
533
  this.settledFlag = false;
534
+ this.terminalSettle = false;
465
535
  this.status_ = "pending";
466
536
  this.startedAt = this.clock.now();
467
537
  this.lastEvent = this.startedAt;
538
+ if (!reuseSlot) {
539
+ this.generationAt = this.startedAt;
540
+ this.generationLlmMs = 0;
541
+ this.generationToolMs = 0;
542
+ this.generationQueueMs = 0;
543
+ this.queueStartedAt = this.acquire ? this.startedAt : null;
544
+ this.queueGeneration = this.queueStartedAt === null ? null : gen;
545
+ this.llmStartedAt = null;
546
+ this.toolStartedAt = null;
547
+ }
468
548
  // A tool_execution_end from the previous generation may have been dropped
469
549
  // after settle. Don't carry that depth (or a pending decision) into this one.
470
550
  this.toolDepth = 0;
471
551
  this.decisionPaused = false;
472
552
 
553
+ if (!reuseSlot) this.generationQueueMs = 0;
473
554
  if (this.acquire && !reuseSlot) {
555
+ const admissionAbort = new AbortController();
556
+ this.admissionAbort = admissionAbort;
474
557
  try {
475
558
  // The ticket ties the slot request to THIS generation: a request
476
559
  // whose generation settled while queued (interrupt, then a new
477
560
  // resume) must not admit the child's next generation.
478
- this.releaseSlot = await this.acquire(this.req, {
561
+ const release = await this.acquire(this.req, {
479
562
  current: () => !this.disposed && !this.isSettled(gen),
563
+ signal: admissionAbort.signal,
480
564
  });
565
+ if (this.disposed || this.isSettled(gen)) {
566
+ // A queued interruption is resumable; only disposal is terminal.
567
+ release(this.disposed);
568
+ return;
569
+ }
570
+ this.releaseSlot = release;
481
571
  } catch (err) {
482
572
  // Admission denied (e.g. fail_fast cancellation while queued).
573
+ this.closeQueue(this.now(), gen);
483
574
  this.settle(gen, {
484
575
  status: "interrupted",
485
576
  text: "",
@@ -487,18 +578,30 @@ class InProcessChildHandle implements DisposableChildHandle {
487
578
  durationMs: this.now() - this.startedAt,
488
579
  });
489
580
  return;
581
+ } finally {
582
+ if (gen === this.generation) {
583
+ // closeQueue()/settle() accounts for this interval exactly once;
584
+ // the finally block only clears state owned by the current generation.
585
+ if (this.admissionAbort === admissionAbort) this.admissionAbort = null;
586
+ }
490
587
  }
588
+ this.closeQueue(this.now(), gen);
491
589
  }
492
590
  if (this.disposed || this.isSettled(gen)) {
493
- this.release();
591
+ this.release(this.terminalSettle);
494
592
  return;
495
593
  }
594
+ if (!reuseSlot) this.admittedAt = this.now();
496
595
  this.status_ = "running";
497
596
 
498
597
  if (first) {
499
598
  try {
500
- this.session = await this.createSession(this.req);
501
- this.resolvedModel_ = this.session.resolvedModel;
599
+ this.sessionCreation = this.createSession(this.req);
600
+ const created = await this.sessionCreation;
601
+ // dispose() owns cleanup of an adapter that resolves after teardown.
602
+ if (this.disposed) return;
603
+ this.session = created;
604
+ this.resolvedModel_ = created.resolvedModel;
502
605
  } catch (err) {
503
606
  this.settle(gen, {
504
607
  status: "failed",
@@ -507,34 +610,14 @@ class InProcessChildHandle implements DisposableChildHandle {
507
610
  durationMs: this.now() - this.startedAt,
508
611
  });
509
612
  return;
613
+ } finally {
614
+ this.sessionCreation = null;
510
615
  }
511
616
  if (this.disposed || this.isSettled(gen)) {
512
617
  this.release();
513
618
  return;
514
619
  }
515
- this.unsubscribe = this.session.subscribe((event) => {
516
- if (event.type === "message_end" || event.type === "tool_execution_end" || event.type === "agent_end") {
517
- this.notifyActivity();
518
- }
519
- // Track depth even after settle. A late tool_execution_end must not
520
- // leak into the next resume, and must not rearm a stale generation.
521
- if (event.type === "tool_execution_start") {
522
- this.toolDepth++;
523
- if (this.status_ === "running") this.clearStall();
524
- return;
525
- }
526
- if (event.type === "tool_execution_end") {
527
- this.toolDepth = Math.max(0, this.toolDepth - 1);
528
- this.lastEvent = this.now();
529
- if (this.status_ === "running" && this.toolDepth === 0 && !this.decisionPaused) {
530
- this.armStall(this.generation);
531
- }
532
- return;
533
- }
534
- if (this.status_ !== "running") return;
535
- this.lastEvent = this.now();
536
- if (this.toolDepth === 0 && !this.decisionPaused) this.armStall(this.generation);
537
- });
620
+ this.unsubscribe = this.session.subscribe((event) => this.handleSessionEvent(event));
538
621
  }
539
622
 
540
623
  const session = this.session;
@@ -635,7 +718,19 @@ class InProcessChildHandle implements DisposableChildHandle {
635
718
 
636
719
  private settle(gen: number, result: ChildResult): void {
637
720
  if (gen !== this.generation || this.settledFlag) return;
721
+ const at = this.now();
722
+ this.pruneToolCalls(gen);
723
+ const queueMs = this.generationQueueMs + (
724
+ this.queueStartedAt !== null && this.queueGeneration === gen
725
+ ? Math.max(0, at - this.queueStartedAt)
726
+ : 0
727
+ );
728
+ if (result.queueMs === undefined && queueMs > 0) result.queueMs = queueMs;
729
+ this.closeOther(at);
730
+ this.finishWallGeneration(at, gen);
638
731
  this.settledFlag = true;
732
+ this.admissionAbort?.abort();
733
+ this.admissionAbort = null;
639
734
  this.clearTimers();
640
735
  // Generations run: 1 + resumes + stall retries. Forensics for the
641
736
  // incident class this exists for (a stalled child that needed retries).
@@ -643,9 +738,14 @@ class InProcessChildHandle implements DisposableChildHandle {
643
738
  if (result.attempts === undefined && this.generation > 1) {
644
739
  result.attempts = this.generation;
645
740
  }
646
- // durationMs covers the whole user turn (launch/resume -> settle),
647
- // including time lost to stalls and retry delays. dispose keeps its 0.
648
- if (result.error !== "disposed") result.durationMs = this.now() - this.runStartedAt;
741
+ // durationMs covers admitted execution (including stalls and retry delays),
742
+ // but excludes time waiting for an admission slot. dispose keeps its 0.
743
+ if (result.error !== "disposed") {
744
+ result.durationMs = this.admittedAt === null ? 0 : Math.max(0, this.now() - this.admittedAt);
745
+ }
746
+ result.queueMs = this.admittedAt === null
747
+ ? Math.max(0, this.now() - this.runStartedAt)
748
+ : Math.max(0, this.admittedAt - this.runStartedAt);
649
749
  if (result.stalls === undefined && this.stallAttempts > 0) {
650
750
  result.stalls = this.stallAttempts;
651
751
  }
@@ -656,7 +756,8 @@ class InProcessChildHandle implements DisposableChildHandle {
656
756
  result.warning = this.session.warning;
657
757
  }
658
758
  this.status_ = result.status;
659
- this.release();
759
+ this.terminalSettle = result.status !== "interrupted" || result.error === "disposed";
760
+ this.release(this.terminalSettle);
660
761
  this.notifyActivity();
661
762
  this.resolveResult(result);
662
763
  }
@@ -669,12 +770,163 @@ class InProcessChildHandle implements DisposableChildHandle {
669
770
  }
670
771
  }
671
772
 
672
- private release(): void {
773
+ private pruneToolCalls(gen: number): void {
774
+ for (const [toolCallId, generation] of this.toolCallGenerations) {
775
+ if (generation === gen) this.toolCallGenerations.delete(toolCallId);
776
+ }
777
+ for (let i = this.anonymousToolGenerations.length - 1; i >= 0; i--) {
778
+ if (this.anonymousToolGenerations[i] === gen) this.anonymousToolGenerations.splice(i, 1);
779
+ }
780
+ }
781
+
782
+ private closeQueue(at: number, gen: number): void {
783
+ if (this.queueStartedAt === null || this.queueGeneration !== gen) return;
784
+ const elapsed = Math.max(0, at - this.queueStartedAt);
785
+ this.wall.queueMs += elapsed;
786
+ this.generationQueueMs += elapsed;
787
+ this.queueStartedAt = null;
788
+ this.queueGeneration = null;
789
+ }
790
+
791
+ private closeLlm(at: number): void {
792
+ if (this.llmStartedAt === null) return;
793
+ const elapsed = Math.max(0, at - this.llmStartedAt);
794
+ this.wall.llmMs += elapsed;
795
+ this.generationLlmMs += elapsed;
796
+ this.llmStartedAt = null;
797
+ }
798
+
799
+ private closeTool(at: number): void {
800
+ if (this.toolStartedAt === null) return;
801
+ const elapsed = Math.max(0, at - this.toolStartedAt);
802
+ this.wall.toolMs += elapsed;
803
+ this.generationToolMs += elapsed;
804
+ this.toolStartedAt = null;
805
+ }
806
+
807
+ private closeOther(at: number): void {
808
+ if (this.otherStartedAt === null) return;
809
+ const elapsed = Math.max(0, at - this.otherStartedAt);
810
+ this.wall.otherMs += elapsed;
811
+ if (elapsed > 0) this.wall.approximate = true;
812
+ this.otherStartedAt = null;
813
+ }
814
+
815
+ private accountWallEvent(event: { type: string; role?: string; usage?: { input: number; output: number } }, at: number): void {
816
+ const assistant = event.role === "assistant" || (event.role === undefined && event.usage !== undefined);
817
+ if (event.type === "message_start" && assistant) {
818
+ if (this.toolDepth === 0 && this.llmStartedAt === null) this.llmStartedAt = at;
819
+ else this.wall.approximate = true;
820
+ } else if (event.type === "message_end" && assistant) {
821
+ if (this.llmStartedAt === null) this.wall.approximate = true;
822
+ else this.closeLlm(at);
823
+ } else if (event.type === "tool_execution_start") {
824
+ if (this.toolDepth === 0) {
825
+ if (this.llmStartedAt !== null) {
826
+ this.closeLlm(at);
827
+ this.wall.approximate = true;
828
+ }
829
+ this.toolStartedAt = at;
830
+ }
831
+ } else if (event.type === "tool_execution_end") {
832
+ if (this.toolDepth === 0) this.wall.approximate = true;
833
+ else if (this.toolDepth === 1) this.closeTool(at);
834
+ }
835
+ }
836
+
837
+ private finishWallGeneration(at: number, gen: number): void {
838
+ this.closeQueue(at, gen);
839
+ if (this.llmStartedAt !== null) {
840
+ this.closeLlm(at);
841
+ this.wall.approximate = true;
842
+ }
843
+ if (this.toolStartedAt !== null) {
844
+ this.closeTool(at);
845
+ this.wall.approximate = true;
846
+ }
847
+ if (this.generationAt !== null) {
848
+ const elapsed = Math.max(0, at - this.generationAt);
849
+ const measured = this.generationQueueMs + this.generationLlmMs + this.generationToolMs;
850
+ const other = Math.max(0, elapsed - measured);
851
+ this.wall.otherMs += other;
852
+ if (other > 0) this.wall.approximate = true;
853
+ }
854
+ this.generationAt = null;
855
+ this.generationLlmMs = 0;
856
+ this.generationToolMs = 0;
857
+ this.generationQueueMs = 0;
858
+ this.queueStartedAt = null;
859
+ this.queueGeneration = null;
860
+ this.llmStartedAt = null;
861
+ this.toolStartedAt = null;
862
+ }
863
+
864
+ private handleSessionEvent(event: { type: string; role?: string; toolCallId?: string; usage?: { input: number; output: number } }): void {
865
+ const now = this.now();
866
+ const active = this.status_ === "running";
867
+ let currentToolEvent = false;
868
+ if (event.type === "tool_execution_start" && active) {
869
+ if (event.toolCallId) {
870
+ if (this.toolCallGenerations.has(event.toolCallId)) {
871
+ this.wall.approximate = true;
872
+ } else {
873
+ this.toolCallGenerations.set(event.toolCallId, this.generation);
874
+ currentToolEvent = true;
875
+ }
876
+ } else {
877
+ // Pi emits toolCallId; retain generation ownership for older adapters
878
+ // too, but mark their FIFO pairing as approximate.
879
+ this.anonymousToolGenerations.push(this.generation);
880
+ this.wall.approximate = true;
881
+ currentToolEvent = true;
882
+ }
883
+ } else if (event.type === "tool_execution_end") {
884
+ if (event.toolCallId) {
885
+ const generation = this.toolCallGenerations.get(event.toolCallId);
886
+ this.toolCallGenerations.delete(event.toolCallId);
887
+ currentToolEvent = active && generation === this.generation;
888
+ if (active && generation === undefined) this.wall.approximate = true;
889
+ } else {
890
+ const generation = this.anonymousToolGenerations.shift();
891
+ currentToolEvent = active && generation === this.generation;
892
+ if (active && !currentToolEvent) this.wall.approximate = true;
893
+ }
894
+ }
895
+ if (active && (!event.type.startsWith("tool_execution_") || currentToolEvent)) {
896
+ this.accountWallEvent(event, now);
897
+ }
898
+ if (
899
+ ["message_end", "tool_execution_end", "agent_end"].includes(event.type) ||
900
+ (active && ["message_start", "tool_execution_start"].includes(event.type))
901
+ ) {
902
+ this.notifyActivity();
903
+ }
904
+ if (event.type === "tool_execution_start") {
905
+ if (currentToolEvent) {
906
+ this.toolDepth++;
907
+ this.clearStall();
908
+ }
909
+ return;
910
+ }
911
+ if (event.type === "tool_execution_end") {
912
+ if (currentToolEvent) {
913
+ this.toolDepth = Math.max(0, this.toolDepth - 1);
914
+ this.lastEvent = now;
915
+ if (this.toolDepth === 0 && !this.decisionPaused) this.armStall(this.generation);
916
+ }
917
+ return;
918
+ }
919
+ if (!active) return;
920
+ this.lastEvent = now;
921
+ if (this.toolDepth === 0 && !this.decisionPaused) this.armStall(this.generation);
922
+ }
923
+
924
+ private release(terminal = true): void {
673
925
  const release = this.releaseSlot;
674
- this.releaseSlot = null;
926
+ if (terminal) this.releaseSlot = null;
675
927
  if (release) {
676
928
  try {
677
- release();
929
+ release(terminal);
678
930
  } catch {
679
931
  // ignore
680
932
  }
@@ -705,6 +957,25 @@ class InProcessChildHandle implements DisposableChildHandle {
705
957
  }, delay) ?? null;
706
958
  }
707
959
 
960
+ collectDueOverrun(now: number): OverrunTick | undefined {
961
+ if (this.settledFlag || this.disposed || this.status_ !== "running" || this.decisionPaused || this.softDeadlineAt === null) return undefined;
962
+ if (this.softDeadlineAt > now || this.nextReminderAt === null || this.nextReminderAt > now) return undefined;
963
+ if (this.softTimer !== null) this.timerScope?.clearTimeout(this.softTimer);
964
+ this.softTimer = null;
965
+ this.reminders++;
966
+ this.nextReminderAt = now + this.overrunRepeatMs;
967
+ this.armSoftDeadline(this.generation);
968
+ return {
969
+ childId: this.req.childId,
970
+ elapsedMs: now - this.turnBudgetStart,
971
+ budgetMs: this.softDeadlineAt - this.turnBudgetStart,
972
+ reminder: this.reminders,
973
+ nextReminderMs: this.overrunRepeatMs,
974
+ lastEventAt: this.lastEvent,
975
+ hardRemainingMs: this.hardTimeoutMs > 0 ? Math.max(0, this.turnBudgetStart + this.hardTimeoutMs - now) : null,
976
+ };
977
+ }
978
+
708
979
  private fireOverrun(gen: number): void {
709
980
  const now = this.now();
710
981
  this.reminders++;
@@ -786,6 +1057,10 @@ class InProcessChildHandle implements DisposableChildHandle {
786
1057
  */
787
1058
  private handleStall(gen: number): void {
788
1059
  this.stallAttempts++;
1060
+ if (this.llmStartedAt !== null) {
1061
+ this.closeLlm(this.now());
1062
+ this.wall.approximate = true;
1063
+ }
789
1064
  // Persist whatever the stalled generation produced before it went quiet.
790
1065
  this.notifyActivity();
791
1066
  try {