pi-famulus 0.1.2 → 0.1.3

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.
@@ -9,7 +9,13 @@
9
9
  * starts a new generation on the same session):
10
10
  * - a per-generation admission slot is acquired via the optional `acquire`
11
11
  * hook (the registry uses it for the global concurrency cap);
12
- * - `timeoutMs` is a hard timeout: abort -> result {status:"interrupted", error:"timeout"};
12
+ * - `timeoutMs` is a SOFT deadline per user turn: reaching it calls
13
+ * `onOverrun` (the parent gets a subagent-overrun wake) and repeats every
14
+ * `overrunRepeatMs`; the child is not aborted and its shell keeps running.
15
+ * `extend()` re-arms the deadline; steer/followUp after a reminder postpone
16
+ * the next one. See design.md §4.6 "Child lifecycle".
17
+ * - `hardTimeoutMs` (opt-in, default off) is the abort ceiling per turn:
18
+ * abort -> result {status:"interrupted", error:"timeout"};
13
19
  * - a stall watchdog aborts the child after `stallMs` (default 10min) without
14
20
  * any session event. A stall is treated as transient (a silently dropped
15
21
  * provider stream): the child is aborted and auto-resumed on the SAME
@@ -21,9 +27,10 @@
21
27
  * rejects prompt() while a run is still active ("Agent is already
22
28
  * processing"). The wait is bounded by `stallMs` — an abort that never
23
29
  * completes means the stream ignored it and the session is dead.
24
- * - `timeoutMs` is a budget for the whole user turn: stall retries arm only
25
- * the remaining time, and a timeout landing during a retry delay still
26
- * fires (it is not masked by the retired generation).
30
+ * - deadlines belong to the whole user turn: stall retries re-arm the soft
31
+ * deadline / reminder schedule and the hard ceiling from turn-level fields,
32
+ * never from the retry. A reminder landing during a retry delay is sent and
33
+ * the retry still runs; a hard ceiling landing there still settles.
27
34
  * - session/prompt exceptions -> {status:"failed", error}.
28
35
  *
29
36
  * Note on pi semantics: AgentSession.prompt() resolves only after the whole
@@ -32,6 +39,7 @@
32
39
  * steer/followUp processing.
33
40
  */
34
41
  import { realClock, TimerScope, type Clock, type ClockTimer } from "../clock";
42
+ import type { OverrunTick } from "./overrun";
35
43
  import type {
36
44
  ChildResult,
37
45
  ChildRunRequest,
@@ -48,6 +56,8 @@ export const DEFAULT_STALL_MS = 5 * 60 * 1000;
48
56
  export const DEFAULT_STALL_RETRIES = 1;
49
57
  /** Pause between the stall abort and the retry prompt (ms). */
50
58
  export const DEFAULT_STALL_RETRY_DELAY_MS = 5_000;
59
+ /** Overrun reminder interval while a child stays past its soft budget (ms). */
60
+ export const DEFAULT_OVERRUN_REPEAT_MS = 10 * 60 * 1000;
51
61
 
52
62
  /** Continuation prompt for a stall retry: the transcript holds the context. */
53
63
  export function stallRetryPrompt(stallMs: number): string {
@@ -57,6 +67,12 @@ export function stallRetryPrompt(stallMs: number): string {
57
67
  );
58
68
  }
59
69
 
70
+ /** Identity of one generation's slot request (see InProcessRunnerOptions.acquire). */
71
+ export interface AdmissionTicket {
72
+ /** False once the requesting generation settled or was superseded. */
73
+ current: () => boolean;
74
+ }
75
+
60
76
  export interface InProcessRunnerOptions {
61
77
  createSession: CreateSessionFn;
62
78
  /** Stall watchdog timeout (ms). Default 10 minutes. */
@@ -67,6 +83,15 @@ export interface InProcessRunnerOptions {
67
83
  stallRetryDelayMs?: number;
68
84
  /** Called on every stall detection (before any retry), with the 1-based attempt. */
69
85
  onStall?: (childId: string, attempt: number) => void;
86
+ /** Overrun reminder interval (ms). Default 10 minutes. */
87
+ overrunRepeatMs?: number;
88
+ /** Abort ceiling per user turn (ms). 0/absent = off: the soft deadline never aborts. */
89
+ hardTimeoutMs?: number;
90
+ /**
91
+ * The child passed its soft deadline (first reminder) or is still past it
92
+ * (later reminders). The child keeps running; the parent decides.
93
+ */
94
+ onOverrun?: (tick: OverrunTick) => void;
70
95
  /** Shared time source and scheduler. */
71
96
  clock?: Clock;
72
97
  /**
@@ -74,7 +99,7 @@ export interface InProcessRunnerOptions {
74
99
  * resolved releaser is called when the generation settles. Rejecting
75
100
  * cancels the generation as {status:"interrupted", error}.
76
101
  */
77
- acquire?: (req: ChildRunRequest) => Promise<() => void>;
102
+ acquire?: (req: ChildRunRequest, ticket: AdmissionTicket) => Promise<() => void>;
78
103
  /**
79
104
  * Called after the child's conversation may have changed (a message or
80
105
  * tool finished, or the generation settled). Used to persist transcripts.
@@ -107,8 +132,11 @@ class InProcessChildHandle implements DisposableChildHandle {
107
132
  private readonly stallRetries: number;
108
133
  private readonly stallRetryDelayMs: number;
109
134
  private readonly onStall?: (childId: string, attempt: number) => void;
135
+ private readonly overrunRepeatMs: number;
136
+ private readonly hardTimeoutMs: number;
137
+ private readonly onOverrun?: (tick: OverrunTick) => void;
110
138
  private readonly clock: Clock;
111
- private readonly acquire?: (req: ChildRunRequest) => Promise<() => void>;
139
+ private readonly acquire?: (req: ChildRunRequest, ticket: AdmissionTicket) => Promise<() => void>;
112
140
  private readonly onActivity?: (childId: string) => void;
113
141
 
114
142
  private session: ChildSessionAdapter | null = null;
@@ -121,11 +149,19 @@ class InProcessChildHandle implements DisposableChildHandle {
121
149
  /** User-turn start: reset on launch and user resume(), not on stall retries. */
122
150
  private runStartedAt: number;
123
151
  /**
124
- * When the timeout budget starts counting. Set after admission and session
152
+ * When the turn's deadlines start counting. Set after admission and session
125
153
  * creation (queue wait must not eat the budget), and NOT reset by stall
126
154
  * retries — a stall already consumes budget time by definition.
127
155
  */
128
156
  private turnBudgetStart: number;
157
+ /** This turn's soft budget: spawn timeoutMs, or resume()'s timeoutMs. */
158
+ private turnBudgetMs: number;
159
+ /** Soft deadline (absolute). Moved by extend(). Null = no soft deadline. */
160
+ private softDeadlineAt: number | null = null;
161
+ /** When the soft timer fires next: softDeadlineAt, then each reminder. */
162
+ private nextReminderAt: number | null = null;
163
+ /** Overrun reminders sent this turn. */
164
+ private reminders = 0;
129
165
  /** Abort kicked off by the latest stall detection; awaited (bounded) by the retry. */
130
166
  private abortPromise: Promise<void> | null = null;
131
167
  private generation = 0;
@@ -140,7 +176,8 @@ class InProcessChildHandle implements DisposableChildHandle {
140
176
  * the previous turn's settle by definition).
141
177
  */
142
178
  private settleWaiters: Array<() => void> = [];
143
- private timeoutTimer: ClockTimer | null = null;
179
+ private softTimer: ClockTimer | null = null;
180
+ private hardTimer: ClockTimer | null = null;
144
181
  private stallTimer: ClockTimer | null = null;
145
182
  private retryTimer: { scope: TimerScope; id: ClockTimer } | null = null;
146
183
  private timerScope: TimerScope | null = null;
@@ -168,6 +205,15 @@ class InProcessChildHandle implements DisposableChildHandle {
168
205
  ? Math.max(0, Math.floor(rawDelay))
169
206
  : DEFAULT_STALL_RETRY_DELAY_MS;
170
207
  this.onStall = opts.onStall;
208
+ const rawRepeat = opts.overrunRepeatMs ?? DEFAULT_OVERRUN_REPEAT_MS;
209
+ this.overrunRepeatMs =
210
+ Number.isFinite(rawRepeat) && rawRepeat > 0
211
+ ? Math.max(1, Math.floor(rawRepeat))
212
+ : DEFAULT_OVERRUN_REPEAT_MS;
213
+ const rawHard = opts.hardTimeoutMs ?? 0;
214
+ this.hardTimeoutMs = Number.isFinite(rawHard) && rawHard > 0 ? Math.floor(rawHard) : 0;
215
+ this.onOverrun = opts.onOverrun;
216
+ this.turnBudgetMs = req.timeoutMs;
171
217
  this.clock = opts.clock ?? realClock;
172
218
  this.acquire = opts.acquire;
173
219
  this.onActivity = opts.onActivity;
@@ -201,7 +247,10 @@ class InProcessChildHandle implements DisposableChildHandle {
201
247
  return this.resolvedModel_ ?? this.session?.resolvedModel;
202
248
  }
203
249
 
204
- /** Pause the stall watchdog (need_decision). Nested with tool execution. */
250
+ /**
251
+ * need_decision pending: pause the stall watchdog (nested with tool
252
+ * execution) and hold overrun reminders. The soft budget keeps counting.
253
+ */
205
254
  pauseStall(): void {
206
255
  this.decisionPaused = true;
207
256
  this.clearStall();
@@ -211,6 +260,9 @@ class InProcessChildHandle implements DisposableChildHandle {
211
260
  this.decisionPaused = false;
212
261
  this.lastEvent = this.clock.now();
213
262
  if (this.status_ === "running" && this.toolDepth === 0) this.armStall(this.generation);
263
+ // Deliver a reminder held during the decision (due now, so it fires at
264
+ // once); otherwise this re-arms the unchanged schedule.
265
+ if (this.status_ === "running" && !this.settledFlag) this.armSoftDeadline(this.generation);
214
266
  }
215
267
 
216
268
  conversation() {
@@ -230,7 +282,9 @@ class InProcessChildHandle implements DisposableChildHandle {
230
282
  // The previous generation was aborted; delivery would be undefined.
231
283
  throw new Error(`subagent ${this.req.childId} is restarting after a stall; retry shortly`);
232
284
  }
285
+ const gen = this.generation;
233
286
  await this.session.steer(message);
287
+ this.parentActed(gen);
234
288
  }
235
289
 
236
290
  async followUp(message: string): Promise<void> {
@@ -240,10 +294,57 @@ class InProcessChildHandle implements DisposableChildHandle {
240
294
  if (this.retiredGen !== null) {
241
295
  throw new Error(`subagent ${this.req.childId} is restarting after a stall; retry shortly`);
242
296
  }
297
+ const gen = this.generation;
243
298
  await this.session.followUp(message);
299
+ this.parentActed(gen);
300
+ }
301
+
302
+ /**
303
+ * Parent action on an overrun: move the soft deadline to now + timeoutMs
304
+ * (default: the spawn budget). The reminder count continues; the hard
305
+ * ceiling (if any) does not move.
306
+ */
307
+ extend(timeoutMs?: number): { deadlineAt: number; hardDeadlineAt: number | null } {
308
+ if (this.status_ !== "running") {
309
+ throw new Error(`subagent ${this.req.childId} is not running (status: ${this.status_})`);
310
+ }
311
+ const ms = timeoutMs ?? this.req.timeoutMs;
312
+ if (!(ms > 0)) throw new Error("extend needs a positive timeout_ms");
313
+ this.softDeadlineAt = this.now() + ms;
314
+ this.nextReminderAt = this.softDeadlineAt;
315
+ this.armSoftDeadline(this.generation);
316
+ return {
317
+ deadlineAt: this.softDeadlineAt,
318
+ hardDeadlineAt: this.hardTimeoutMs > 0 ? this.turnBudgetStart + this.hardTimeoutMs : null,
319
+ };
244
320
  }
245
321
 
246
- async resume(message: string): Promise<void> {
322
+ /**
323
+ * steer/followUp after a reminder: the parent is handling the overrun, so
324
+ * the next reminder waits a full interval. Before the first reminder the
325
+ * deadline is untouched, and so is a deadline that extend() moved into the
326
+ * future: the child is inside its budget again, and the next wake belongs
327
+ * at that deadline, not one repeat from now.
328
+ */
329
+ private parentActed(gen: number): void {
330
+ // The delivery was awaited: if its turn settled (and maybe a resume
331
+ // started a new one) meanwhile, it must not move the new schedule.
332
+ if (gen !== this.generation || this.settledFlag) return;
333
+ if (this.reminders === 0 || this.status_ !== "running") return;
334
+ const now = this.now();
335
+ if (this.softDeadlineAt !== null && now < this.softDeadlineAt) return;
336
+ this.nextReminderAt = now + this.overrunRepeatMs;
337
+ this.armSoftDeadline(this.generation);
338
+ }
339
+
340
+ /**
341
+ * Start a new user turn. Resolves once the request is accepted, not when
342
+ * the turn starts: draining a still-unwinding abort and waiting for an
343
+ * admission slot run in the background, so the parent's tool call never
344
+ * waits for a slot. Until admission the child is "pending" (queued).
345
+ * Failures after acceptance settle the new turn instead of rejecting.
346
+ */
347
+ async resume(message: string, opts: { timeoutMs?: number } = {}): Promise<void> {
247
348
  if (this.status_ === "running" || this.status_ === "pending") {
248
349
  throw new Error(
249
350
  `subagent ${this.req.childId} is still ${this.status_}; use steer for a running subagent`,
@@ -255,35 +356,57 @@ class InProcessChildHandle implements DisposableChildHandle {
255
356
  if (!this.session) {
256
357
  throw new Error(`subagent ${this.req.childId} has no session to resume`);
257
358
  }
258
- // Drain a stall/timeout abort that is still unwinding (real pi rejects
259
- // prompt() while the aborted run is active). Bounded: a hung abort means
260
- // the session is dead and cannot be resumed.
261
- const pendingAbort = this.abortPromise;
262
- this.abortPromise = null;
263
- if (pendingAbort && !(await this.awaitAbortBounded(pendingAbort))) {
264
- throw new Error(
265
- `subagent ${this.req.childId} session did not go idle after the abort; cannot resume`,
266
- );
267
- }
268
- if (this.disposed) {
269
- throw new Error(`subagent ${this.req.childId} has been disposed`);
270
- }
271
- // A new user turn gets a fresh stall budget and a fresh timeout budget.
359
+ // A new user turn gets a fresh stall budget and a fresh soft budget:
360
+ // the one passed with resume, else the spawn budget.
272
361
  this.stallAttempts = 0;
362
+ this.turnBudgetMs =
363
+ opts.timeoutMs !== undefined && opts.timeoutMs > 0 ? opts.timeoutMs : this.req.timeoutMs;
364
+ this.reminders = 0;
273
365
  this.runStartedAt = this.clock.now();
274
- // Swap in the new generation's result promise synchronously so that
275
- // registry.getResult() observes it before/while admission runs.
366
+ // The turn's generation exists from here: interrupt()/dispose() while it
367
+ // is queued settle it, and its result promise is observable through
368
+ // registry.getResult() before admission.
369
+ const gen = ++this.generation;
370
+ this.settledFlag = false;
371
+ this.retiredGen = null;
372
+ this.status_ = "pending";
373
+ this.startedAt = this.runStartedAt;
374
+ this.lastEvent = this.runStartedAt;
276
375
  this.resultPromise = new Promise((resolve) => {
277
376
  this.resolveResult = resolve;
278
377
  });
279
- await this.beginGeneration(message, false);
378
+ const pendingAbort = this.abortPromise;
379
+ this.abortPromise = null;
380
+ void this.startResumedTurn(gen, message, pendingAbort);
381
+ }
382
+
383
+ /**
384
+ * Background half of resume(): drain a stall/timeout abort that is still
385
+ * unwinding (real pi rejects prompt() while the aborted run is active),
386
+ * then admission and prompt. Bounded: a hung abort means the session is
387
+ * dead, and the turn settles failed.
388
+ */
389
+ 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;
398
+ }
399
+ if (this.disposed || this.isSettled(gen)) return;
400
+ await this.beginGeneration(message, false, false, gen);
280
401
  }
281
402
 
282
403
  async interrupt(): Promise<void> {
283
404
  if (this.status_ !== "running" && this.status_ !== "pending") return;
284
405
  this.settle(this.generation, {
285
406
  status: "interrupted",
286
- text: this.partialText(),
407
+ // A pending turn has produced nothing yet; the last assistant text
408
+ // belongs to the previous turn.
409
+ text: this.status_ === "pending" ? "" : this.partialText(),
287
410
  durationMs: this.now() - this.startedAt,
288
411
  });
289
412
  try {
@@ -326,8 +449,14 @@ class InProcessChildHandle implements DisposableChildHandle {
326
449
  * slot acquired by the stalled generation is still held (no settle happened),
327
450
  * so admission is not re-run and the slot is not double-counted.
328
451
  */
329
- private async beginGeneration(prompt: string, first: boolean, reuseSlot = false): Promise<void> {
330
- const gen = ++this.generation;
452
+ private async beginGeneration(
453
+ prompt: string,
454
+ first: boolean,
455
+ reuseSlot = false,
456
+ resumedGen?: number,
457
+ ): Promise<void> {
458
+ // A resumed turn allocated its generation when it was requested.
459
+ const gen = resumedGen ?? ++this.generation;
331
460
  this.retiredGen = null;
332
461
  this.clearRetryTimer();
333
462
  this.timerScope?.dispose();
@@ -343,7 +472,12 @@ class InProcessChildHandle implements DisposableChildHandle {
343
472
 
344
473
  if (this.acquire && !reuseSlot) {
345
474
  try {
346
- this.releaseSlot = await this.acquire(this.req);
475
+ // The ticket ties the slot request to THIS generation: a request
476
+ // whose generation settled while queued (interrupt, then a new
477
+ // resume) must not admit the child's next generation.
478
+ this.releaseSlot = await this.acquire(this.req, {
479
+ current: () => !this.disposed && !this.isSettled(gen),
480
+ });
347
481
  } catch (err) {
348
482
  // Admission denied (e.g. fail_fast cancellation while queued).
349
483
  this.settle(gen, {
@@ -414,13 +548,19 @@ class InProcessChildHandle implements DisposableChildHandle {
414
548
  return;
415
549
  }
416
550
 
417
- // The timeout budget starts only after admission and session creation:
551
+ // The turn's deadlines start only after admission and session creation:
418
552
  // time spent queued for an admission slot is not the child's budget.
419
- if (!reuseSlot) this.turnBudgetStart = this.now();
420
- this.armTimeout(gen);
421
- // A stall can spend the whole timeout budget before this generation
422
- // starts; armTimeout settles in that case and prompting must not proceed.
553
+ // Stall retries (reuseSlot) keep the turn's deadlines and schedule.
554
+ if (!reuseSlot) {
555
+ this.turnBudgetStart = this.now();
556
+ this.softDeadlineAt = this.turnBudgetMs > 0 ? this.turnBudgetStart + this.turnBudgetMs : null;
557
+ this.nextReminderAt = this.softDeadlineAt;
558
+ }
559
+ this.armHardCeiling(gen);
560
+ // A stall can spend the whole hard budget before this generation starts;
561
+ // armHardCeiling settles in that case and prompting must not proceed.
423
562
  if (this.isSettled(gen)) return;
563
+ this.armSoftDeadline(gen);
424
564
  this.armStall(gen);
425
565
  // Tell the child which model it is — otherwise only the parent/fleet knows.
426
566
  const prompted =
@@ -541,16 +681,63 @@ class InProcessChildHandle implements DisposableChildHandle {
541
681
  }
542
682
  }
543
683
 
544
- private armTimeout(gen: number): void {
545
- if (this.timeoutTimer !== null) {
546
- this.timerScope?.clearTimeout(this.timeoutTimer);
547
- this.timeoutTimer = null;
684
+ /**
685
+ * Soft deadline / reminder timer for the current generation scope, due at
686
+ * nextReminderAt (turn-level, so a stall retry re-arms the same schedule).
687
+ */
688
+ private armSoftDeadline(gen: number): void {
689
+ if (this.softTimer !== null) {
690
+ this.timerScope?.clearTimeout(this.softTimer);
691
+ this.softTimer = null;
692
+ }
693
+ if (this.nextReminderAt === null) return;
694
+ const delay = Math.max(0, this.nextReminderAt - this.now());
695
+ this.softTimer = this.timerScope?.setTimeout(() => {
696
+ this.softTimer = null;
697
+ // Deliberately NOT isCurrent(): during a stall's retry delay the child
698
+ // is still running from the parent's view, so the reminder is due.
699
+ if (this.settledFlag || this.disposed || gen !== this.generation) return;
700
+ // A pending need_decision already put a supervisor-request wake in
701
+ // front of the parent: hold the reminder (nextReminderAt stays due)
702
+ // until resumeStall() re-arms it when the decision resolves.
703
+ if (this.decisionPaused) return;
704
+ this.fireOverrun(gen);
705
+ }, delay) ?? null;
706
+ }
707
+
708
+ private fireOverrun(gen: number): void {
709
+ const now = this.now();
710
+ this.reminders++;
711
+ this.nextReminderAt = now + this.overrunRepeatMs;
712
+ try {
713
+ this.onOverrun?.({
714
+ childId: this.req.childId,
715
+ elapsedMs: now - this.turnBudgetStart,
716
+ budgetMs: (this.softDeadlineAt ?? now) - this.turnBudgetStart,
717
+ reminder: this.reminders,
718
+ nextReminderMs: this.overrunRepeatMs,
719
+ lastEventAt: this.lastEvent,
720
+ hardRemainingMs:
721
+ this.hardTimeoutMs > 0 ? Math.max(0, this.turnBudgetStart + this.hardTimeoutMs - now) : null,
722
+ });
723
+ } catch {
724
+ // overrun observers must not break the schedule
548
725
  }
549
- if (!(this.req.timeoutMs > 0)) return;
550
- // The timeout is a budget for the whole user turn: a stall retry arms
551
- // only what the stalled generation did not already consume. The budget
552
- // clock starts after admission (turnBudgetStart), not at launch.
553
- const remaining = this.req.timeoutMs - (this.now() - this.turnBudgetStart);
726
+ this.armSoftDeadline(gen);
727
+ }
728
+
729
+ /**
730
+ * Opt-in abort ceiling (hardTimeoutMs). The ceiling is per user turn: a
731
+ * stall retry arms only what the stalled generation did not already
732
+ * consume, and the budget clock starts after admission (turnBudgetStart).
733
+ */
734
+ private armHardCeiling(gen: number): void {
735
+ if (this.hardTimer !== null) {
736
+ this.timerScope?.clearTimeout(this.hardTimer);
737
+ this.hardTimer = null;
738
+ }
739
+ if (!(this.hardTimeoutMs > 0)) return;
740
+ const remaining = this.hardTimeoutMs - (this.now() - this.turnBudgetStart);
554
741
  if (remaining <= 0) {
555
742
  this.settle(gen, {
556
743
  status: "interrupted",
@@ -560,9 +747,9 @@ class InProcessChildHandle implements DisposableChildHandle {
560
747
  });
561
748
  return;
562
749
  }
563
- this.timeoutTimer = this.timerScope?.setTimeout(() => {
564
- this.timeoutTimer = null;
565
- // Deliberately NOT isCurrent(): a timeout landing during a stall's
750
+ this.hardTimer = this.timerScope?.setTimeout(() => {
751
+ this.hardTimer = null;
752
+ // Deliberately NOT isCurrent(): a ceiling landing during a stall's
566
753
  // retry delay must still fire — the retry has not started a new
567
754
  // generation, and the budget is spent.
568
755
  if (this.settledFlag || this.disposed || gen !== this.generation) return;
@@ -697,9 +884,13 @@ class InProcessChildHandle implements DisposableChildHandle {
697
884
  }
698
885
 
699
886
  private clearTimers(): void {
700
- if (this.timeoutTimer !== null) {
701
- this.timerScope?.clearTimeout(this.timeoutTimer);
702
- this.timeoutTimer = null;
887
+ if (this.softTimer !== null) {
888
+ this.timerScope?.clearTimeout(this.softTimer);
889
+ this.softTimer = null;
890
+ }
891
+ if (this.hardTimer !== null) {
892
+ this.timerScope?.clearTimeout(this.hardTimer);
893
+ this.hardTimer = null;
703
894
  }
704
895
  this.clearStall();
705
896
  this.clearRetryTimer();
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { Type } from "typebox";
17
17
  import type { AgentToolResult, ToolDefinition } from "@earendil-works/pi-coding-agent";
18
+ import { VALID_THINKING_LEVELS } from "../thinking-levels";
18
19
  import { formatSubagentHandover, formatSubagentNotification, truncateTail } from "../format";
19
20
  import { realClock, type Clock, type ClockTimer } from "../clock";
20
21
 
@@ -73,13 +74,18 @@ const subagentParameters = Type.Object({
73
74
  Type.String({
74
75
  description:
75
76
  'Model override for all subagents: fuzzy ("haiku"), qualified ("provider/id"), ' +
76
- 'optionally with ":<thinking>" suffix. Default: current model. ' +
77
+ `optionally with ":<thinking>" suffix (${VALID_THINKING_LEVELS.join(", ")}; ` +
78
+ 'an unknown suffix is dropped with a warning when the base resolves; ' +
79
+ 'an unknown base still errors). Default: current model. ' +
77
80
  'Use action:"models" to list selectable values.',
78
81
  }),
79
82
  ),
80
83
  timeout_ms: Type.Optional(
81
84
  Type.Number({
82
- description: `Hard timeout per subagent in ms (default 1800000, max ${MAX_TIMEOUT_MS})`,
85
+ description:
86
+ `Time budget per subagent turn in ms (default 1800000, max ${MAX_TIMEOUT_MS}). ` +
87
+ 'Passing it does not stop the subagent: you get <pi-famulus-wake kind="subagent-overrun"> ' +
88
+ "and choose extend, steer, or interrupt. With resume: the resumed turn's budget. With extend: the new budget from now.",
83
89
  maximum: MAX_TIMEOUT_MS,
84
90
  }),
85
91
  ),
@@ -92,13 +98,16 @@ const subagentParameters = Type.Object({
92
98
  Type.Literal("interrupt"),
93
99
  Type.Literal("resume"),
94
100
  Type.Literal("steer"),
101
+ Type.Literal("extend"),
95
102
  Type.Literal("models"),
96
103
  ],
97
104
  { description: "Manage an existing run (or list selectable models) instead of starting a new one" },
98
105
  ),
99
106
  ),
100
107
  run_id: Type.Optional(Type.String({ description: "Target run for action" })),
101
- child_id: Type.Optional(Type.String({ description: "Target child (id or name) for steer/interrupt/resume" })),
108
+ child_id: Type.Optional(
109
+ Type.String({ description: "Target child (id or name) for steer/interrupt/resume/extend" }),
110
+ ),
102
111
  message: Type.Optional(Type.String({ description: "Message content for steer/resume" })),
103
112
  });
104
113
 
@@ -110,7 +119,7 @@ type SubagentParams = {
110
119
  fail_fast?: boolean;
111
120
  model?: string;
112
121
  timeout_ms?: number;
113
- action?: "list" | "get" | "status" | "interrupt" | "resume" | "steer" | "models";
122
+ action?: "list" | "get" | "status" | "interrupt" | "resume" | "steer" | "extend" | "models";
114
123
  run_id?: string;
115
124
  child_id?: string;
116
125
  message?: string;
@@ -222,6 +231,7 @@ function toNotificationInfo(record: RunRecord, now: number) {
222
231
  status: c.status,
223
232
  text: c.result?.text ?? "",
224
233
  error: c.result?.error,
234
+ ...(c.result?.warning !== undefined ? { warning: c.result.warning } : {}),
225
235
  ...(c.prompt !== undefined ? { prompt: c.prompt } : {}),
226
236
  })),
227
237
  };
@@ -285,9 +295,23 @@ export function createSubagentTool(
285
295
  return registry;
286
296
  };
287
297
 
298
+ /** Last terminal state a subagent-done was sent for, per run. */
299
+ const runDoneSent = new Map<string, string>();
288
300
  const notifyRunCompleted = (registry: SubagentRegistry, runId: string): void => {
289
301
  const record = registry.get(runId);
290
- if (!record) return;
302
+ if (!record) {
303
+ runDoneSent.delete(runId);
304
+ return;
305
+ }
306
+ // A resumed child can outlive the run's first-launch completion. The
307
+ // run is not done while it runs; its own settle sends this wake.
308
+ if (record.children.some((c) => c.status === "pending" || c.status === "running")) return;
309
+ // Several observers (first-launch completion, each resumed turn's settle)
310
+ // can see the same all-terminal state. One subagent-done per terminal
311
+ // state, identified by every child's turn number.
312
+ const terminalKey = record.children.map((c) => `${c.childId}:${c.turn ?? 1}`).join("|");
313
+ if (runDoneSent.get(runId) === terminalKey) return;
314
+ runDoneSent.set(runId, terminalKey);
291
315
  deps.getNotifyCenter()?.notify(formatSubagentNotification(toNotificationInfo(record, clock.now())));
292
316
  };
293
317
 
@@ -320,6 +344,7 @@ export function createSubagentTool(
320
344
  prompt: child.prompt ?? "",
321
345
  text: child.result?.text ?? "",
322
346
  ...(child.result?.error !== undefined ? { error: child.result.error } : {}),
347
+ ...(child.result?.warning !== undefined ? { warning: child.result.warning } : {}),
323
348
  stillRunning,
324
349
  }),
325
350
  );
@@ -590,12 +615,41 @@ export function createSubagentTool(
590
615
  };
591
616
  }
592
617
 
618
+ if (action === "extend") {
619
+ const handle = resolveSingleActiveChild(registry, record, params.child_id, "extend");
620
+ const child = record.children.find((c) => c.childId === handle.childId);
621
+ const ms = params.timeout_ms === undefined ? undefined : clampTimeout(params.timeout_ms, deps.defaultTimeoutMs);
622
+ const { deadlineAt, hardDeadlineAt } = handle.extend(ms);
623
+ const now = clock.now();
624
+ const hard =
625
+ hardDeadlineAt === null
626
+ ? ""
627
+ : ` The configured hard ceiling (hardTimeoutMs) is not moved: it stops this subagent in ${formatDurationMs(Math.max(0, hardDeadlineAt - now))}.`;
628
+ return {
629
+ content: [
630
+ {
631
+ type: "text",
632
+ text:
633
+ `Extended subagent ${child?.name ?? handle.childId} (${handle.childId}) in run ${record.runId}: ` +
634
+ `the next <pi-famulus-wake kind="subagent-overrun"> comes in ${formatDurationMs(deadlineAt - now)} if it is still running. ` +
635
+ `Its result arrives as a wake when it finishes; do not poll.${hard}`,
636
+ },
637
+ ],
638
+ details: { run_id: record.runId, child_id: handle.childId, deadline_at: deadlineAt, hard_deadline_at: hardDeadlineAt },
639
+ };
640
+ }
641
+
593
642
  // resume
594
643
  if (!params.message) throw new Error("message is required for resume");
595
644
  const child = resolveSingleTerminalChild(record, params.child_id);
596
- const handle = registry.handle(child.childId);
597
- if (!handle) throw new Error(`subagent ${child.childId} has no live session to resume`);
598
- await handle.resume(params.message);
645
+ // A resume is a new turn with its own soft budget: timeout_ms when given,
646
+ // else the child's spawn budget (the runner's default). Returns once the
647
+ // request is accepted; it never waits for an admission slot.
648
+ const { queuedBehind } = await registry.resumeChild(
649
+ child.childId,
650
+ params.message,
651
+ params.timeout_ms === undefined ? {} : { timeoutMs: clampTimeout(params.timeout_ms, deps.defaultTimeoutMs) },
652
+ );
599
653
  // Resume is asynchronous. If siblings are still running, hand this child
600
654
  // back as soon as it finishes; otherwise the run-complete wake covers it.
601
655
  const handedOver = new Set<string>();
@@ -610,11 +664,12 @@ export function createSubagentTool(
610
664
  type: "text",
611
665
  text:
612
666
  `Resumed subagent ${child.name} (${child.childId}) in run ${record.runId}. ` +
667
+ queuedText(queuedBehind) +
613
668
  "You will be notified via <pi-famulus-wake kind=\"subagent-handover\"> if others are still running, " +
614
669
  "otherwise via <pi-famulus-wake kind=\"subagent-done\"> when it completes. Do not poll.",
615
670
  },
616
671
  ],
617
- details: { run_id: record.runId, child_id: child.childId },
672
+ details: { run_id: record.runId, child_id: child.childId, queued_behind: queuedBehind },
618
673
  };
619
674
  };
620
675
 
@@ -626,7 +681,10 @@ export function createSubagentTool(
626
681
  "interpolation). By default the call waits up to a foreground budget (default 45s); longer runs " +
627
682
  "continue in the background. Each child that finishes while others are still running wakes you with " +
628
683
  "<pi-famulus-wake kind=\"subagent-handover\"> (its prompt and result). The whole run wakes you with <pi-famulus-wake kind=\"subagent-done\">. " +
629
- "Never poll or sleep to wait. Use action=list/get/status/interrupt/resume/steer to manage existing runs.",
684
+ "Never poll or sleep to wait. " +
685
+ "A subagent still running past its timeout_ms is not stopped; it wakes you with " +
686
+ "<pi-famulus-wake kind=\"subagent-overrun\"> so you can extend, steer, or interrupt it. " +
687
+ "Use action=list/get/status/interrupt/resume/steer/extend to manage existing runs.",
630
688
  promptSnippet: "Fan out subagents in parallel or sequence them in a chain",
631
689
  promptGuidelines: [
632
690
  'When a <pi-famulus-wake kind="subagent-handover"> arrives, read <prompt> and <result> immediately and continue: subagent({action:"resume", run_id, child_id, message}) for that child, or agent_message to steer children that are still running. Do not wait for the rest of the run.',
@@ -694,6 +752,13 @@ export function createSubagentTool(
694
752
  };
695
753
  }
696
754
 
755
+ /** Resume result clause for a turn waiting for an admission slot ("" when it started at once). */
756
+ function queuedText(queuedBehind: number | null): string {
757
+ if (queuedBehind === null) return "";
758
+ const ahead = queuedBehind === 0 ? "" : ` and ${queuedBehind} subagent(s) are waiting ahead of it`;
759
+ return `Every subagent slot is busy${ahead}, so it is queued and starts when a slot frees. `;
760
+ }
761
+
697
762
  function resolveSingleActiveChild(
698
763
  registry: SubagentRegistry,
699
764
  record: RunRecord,
@@ -728,6 +793,14 @@ function resolveSingleTerminalChild(record: RunRecord, childIdOrName: string | u
728
793
  const known = record.children.map((c) => `${c.name} (${c.childId})`).join(", ");
729
794
  throw new Error(`no child "${childIdOrName}" in run ${record.runId} (children: ${known})`);
730
795
  }
796
+ if (child.status === "pending") {
797
+ // Queued for an admission slot (a launch or an earlier resume): steer
798
+ // would fail too, and a second resume adds nothing.
799
+ throw new Error(
800
+ `subagent ${child.name} (${child.childId}) is already queued and starts when a subagent slot frees; ` +
801
+ "its result arrives as a wake when it finishes. Do not resume it again.",
802
+ );
803
+ }
731
804
  if (!matches(child)) {
732
805
  throw new Error(`subagent ${child.name} (${child.childId}) is still ${child.status}; use steer instead`);
733
806
  }