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.
- package/README.md +3 -1
- package/package.json +8 -6
- package/src/agents/builtins.ts +5 -22
- package/src/agents/definition.ts +4 -6
- package/src/bash-override.ts +8 -21
- package/src/behavior-guidelines.ts +40 -16
- package/src/comms/comms.ts +7 -3
- package/src/comms/tools.ts +11 -36
- package/src/format.ts +31 -14
- package/src/index.ts +123 -33
- package/src/manager-client.ts +265 -114
- package/src/monitor.ts +14 -33
- package/src/prompts.generated.ts +183 -0
- package/src/shell-exec.ts +6 -2
- package/src/subagent/admission.ts +224 -0
- package/src/subagent/agent-records.ts +66 -3
- package/src/subagent/child-bash.ts +6 -14
- package/src/subagent/overrun.ts +36 -21
- package/src/subagent/pi-runtime.ts +116 -31
- package/src/subagent/registry.ts +165 -42
- package/src/subagent/runner.ts +335 -60
- package/src/subagent/tool.ts +49 -72
- package/src/subagent/types.ts +60 -5
- package/src/subagent/usage.ts +48 -0
- package/src/task-tools.ts +15 -25
- package/src/tui/message-renderers.ts +17 -1
- package/src/wake.ts +66 -37
- package/src/work-index.ts +4 -0
package/src/subagent/runner.ts
CHANGED
|
@@ -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
|
-
*
|
|
100
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
391
|
-
this.
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
session.dispose()
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
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.
|
|
501
|
-
|
|
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
|
|
647
|
-
//
|
|
648
|
-
if (result.error !== "disposed")
|
|
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.
|
|
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
|
|
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 {
|