pi-famulus 0.1.1 → 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.
@@ -10,8 +10,10 @@
10
10
  * - lineage: v1 = same runId.
11
11
  *
12
12
  * Wiring contract: the runner must be constructed with
13
- * `acquire: (req) => registry.admitChild(req.childId)` so every generation
14
- * (initial start and resume) passes through admission. startChild() wires the
13
+ * `acquire: (req, ticket) => registry.admitChild(req.childId, ticket)` so every
14
+ * generation (initial start and resume) passes through admission. Forward the
15
+ * ticket: without it admitChild falls back to the handle's current status and
16
+ * a stale queued request can admit a newer resume. startChild() wires the
15
17
  * first generation's result; admitChild() wires subsequent generations.
16
18
  *
17
19
  * Zero pi dependency.
@@ -47,6 +49,8 @@ export interface RunRecord {
47
49
  result?: ChildResult;
48
50
  startedAt: number;
49
51
  endedAt?: number;
52
+ /** User turns so far: 1 for the launch, +1 per accepted resume. */
53
+ turn?: number;
50
54
  }[];
51
55
  status: "running" | "completed" | "partial" | "failed" | "interrupted";
52
56
  createdAt: number;
@@ -109,6 +113,7 @@ interface InternalChild {
109
113
  result?: ChildResult;
110
114
  startedAt: number;
111
115
  endedAt?: number;
116
+ turn: number;
112
117
  handle?: ChildHandle;
113
118
  shouldStart?: () => boolean;
114
119
  }
@@ -157,6 +162,10 @@ class FailedChildHandle implements ChildHandle {
157
162
  return Promise.reject(this.dead());
158
163
  }
159
164
 
165
+ extend(): never {
166
+ throw this.dead();
167
+ }
168
+
160
169
  interrupt(): Promise<void> {
161
170
  return Promise.resolve();
162
171
  }
@@ -303,6 +312,7 @@ export class SubagentRegistry implements RunRegistry {
303
312
  agent: info.agent,
304
313
  status: "pending",
305
314
  startedAt: this.now(),
315
+ turn: 1,
306
316
  };
307
317
  run.children.push(child);
308
318
  this.children.set(child.childId, child);
@@ -360,7 +370,7 @@ export class SubagentRegistry implements RunRegistry {
360
370
  * and wires result settlement for resumed generations. Returns the slot
361
371
  * releaser.
362
372
  */
363
- async admitChild(childId: string): Promise<() => void> {
373
+ async admitChild(childId: string, ticket?: { current: () => boolean }): Promise<() => void> {
364
374
  await this.acquireSlot();
365
375
  let released = false;
366
376
  const release = () => {
@@ -377,6 +387,15 @@ export class SubagentRegistry implements RunRegistry {
377
387
  release();
378
388
  throw new ChildCancelledError();
379
389
  }
390
+ // The generation that queued this request settled while it waited
391
+ // (interrupt/dispose; maybe a newer resume is queued behind other
392
+ // children): hand the slot on, without touching the child's status.
393
+ // Without a ticket, fall back to the handle's status.
394
+ const stale = ticket ? !ticket.current() : (child.handle?.status() ?? "pending") !== "pending";
395
+ if (stale) {
396
+ release();
397
+ throw new ChildCancelledError("settled while queued");
398
+ }
380
399
  this.transitionChild(child, "running");
381
400
  const handle = child.handle;
382
401
  if (handle) {
@@ -387,6 +406,64 @@ export class SubagentRegistry implements RunRegistry {
387
406
  return release;
388
407
  }
389
408
 
409
+ /**
410
+ * Resume an ended child: a new user turn on its session. Returns once the
411
+ * request is accepted; admission runs in the background (design.md §4.6
412
+ * "Child lifecycle", resume rows). Until a slot is granted the child is
413
+ * pending. `queuedBehind` is null when a slot was free at request time,
414
+ * otherwise how many children were already waiting for one.
415
+ */
416
+ async resumeChild(
417
+ childId: string,
418
+ message: string,
419
+ opts: { timeoutMs?: number } = {},
420
+ ): Promise<{ queuedBehind: number | null }> {
421
+ const child = this.children.get(childId);
422
+ if (!child) throw new Error(`unknown child ${childId}`);
423
+ const handle = child.handle;
424
+ if (!handle) throw new Error(`subagent ${childId} has no live session to resume`);
425
+ const queuedBehind = this.activeSlots >= this.maxChildren ? this.slotWaiters.length : null;
426
+ const previous = {
427
+ status: child.status,
428
+ result: child.result,
429
+ startedAt: child.startedAt,
430
+ endedAt: child.endedAt,
431
+ };
432
+ const accepted = handle.resume(message, opts);
433
+ // Mark the child queued before awaiting: with a free slot, admitChild's
434
+ // continuation can run first and move it to running, which must win.
435
+ child.status = "pending";
436
+ child.result = undefined;
437
+ child.endedAt = undefined;
438
+ child.startedAt = this.now();
439
+ child.turn += 1;
440
+ try {
441
+ await accepted;
442
+ } catch (err) {
443
+ // Undo this attempt's +1 even if a concurrent accepted resume has
444
+ // already moved the child to running (the pending guard would skip).
445
+ child.turn -= 1;
446
+ if (child.status === "pending") {
447
+ child.status = previous.status;
448
+ child.result = previous.result;
449
+ child.startedAt = previous.startedAt;
450
+ child.endedAt = previous.endedAt;
451
+ }
452
+ throw err;
453
+ }
454
+ // Wired at request time, not admission: a turn that never gets a slot
455
+ // (interrupt/dispose while queued, abort drain bound) still settles here.
456
+ handle.result.then((result) => this.settleChild(childId, result));
457
+ if (child.status === "pending") {
458
+ const run = this.runs.get(child.runId);
459
+ if (run) {
460
+ this.recomputeRunStatus(run);
461
+ this.emit(run);
462
+ }
463
+ }
464
+ return { queuedBehind };
465
+ }
466
+
390
467
  /** Current generation's result promise for a child (post-resume aware). */
391
468
  getResult(childId: string): Promise<ChildResult> | undefined {
392
469
  return this.children.get(childId)?.handle?.result;
@@ -570,6 +647,7 @@ function snapshot(run: InternalRun): RunRecord {
570
647
  result: c.result,
571
648
  startedAt: c.startedAt,
572
649
  endedAt: c.endedAt,
650
+ turn: c.turn,
573
651
  })),
574
652
  };
575
653
  }
@@ -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();