pi-famulus 0.1.0

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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/bin/pi-famulus.js +29 -0
  4. package/package.json +49 -0
  5. package/src/agents/builtins.ts +41 -0
  6. package/src/agents/definition.ts +221 -0
  7. package/src/agents/loader.ts +119 -0
  8. package/src/bash-override.ts +372 -0
  9. package/src/behavior-guidelines.ts +42 -0
  10. package/src/clock.ts +169 -0
  11. package/src/comms/comms.ts +199 -0
  12. package/src/comms/mailbox.ts +156 -0
  13. package/src/comms/registry-host.ts +73 -0
  14. package/src/comms/reply-command.ts +52 -0
  15. package/src/comms/routing.ts +23 -0
  16. package/src/comms/tools.ts +339 -0
  17. package/src/comms/types.ts +68 -0
  18. package/src/config.ts +266 -0
  19. package/src/events.ts +61 -0
  20. package/src/exit-notify-gate.ts +69 -0
  21. package/src/exit-watchdog.ts +60 -0
  22. package/src/file-tail.ts +48 -0
  23. package/src/format.ts +276 -0
  24. package/src/index.ts +700 -0
  25. package/src/manager-client.ts +839 -0
  26. package/src/monitor-batching.ts +191 -0
  27. package/src/monitor.ts +517 -0
  28. package/src/native-manager.d.ts +9 -0
  29. package/src/native-manager.js +39 -0
  30. package/src/notify.ts +231 -0
  31. package/src/shell-exec.ts +145 -0
  32. package/src/subagent/agent-records.ts +168 -0
  33. package/src/subagent/child-bash.ts +183 -0
  34. package/src/subagent/conversation.ts +76 -0
  35. package/src/subagent/fleet-widget.ts +176 -0
  36. package/src/subagent/model-spec.ts +108 -0
  37. package/src/subagent/pi-runtime.ts +295 -0
  38. package/src/subagent/pool.ts +190 -0
  39. package/src/subagent/registry.ts +575 -0
  40. package/src/subagent/runner.ts +709 -0
  41. package/src/subagent/tool.ts +741 -0
  42. package/src/subagent/transcript.ts +71 -0
  43. package/src/subagent/types.ts +125 -0
  44. package/src/task-exit-notify.ts +21 -0
  45. package/src/task-tools.ts +330 -0
  46. package/src/tui/message-renderers.ts +183 -0
  47. package/src/tui/pi-tui-load.ts +186 -0
  48. package/src/tui/scroll-detail-view.ts +190 -0
  49. package/src/tui/task-output-paths.ts +46 -0
  50. package/src/tui/tasks-command.ts +515 -0
  51. package/src/tui/tool-component.ts +29 -0
  52. package/src/wake.ts +219 -0
  53. package/src/work-index.ts +182 -0
@@ -0,0 +1,709 @@
1
+ /**
2
+ * InProcessRunner (design doc §4.6): ChildRunner backed by an in-process
3
+ * child session created through an injected CreateSessionFn.
4
+ *
5
+ * Zero pi dependency — the real session factory lives in pi-runtime.ts and
6
+ * tests inject fakes.
7
+ *
8
+ * Lifecycle per child ("generation" = one prompt..settle cycle; resume()
9
+ * starts a new generation on the same session):
10
+ * - a per-generation admission slot is acquired via the optional `acquire`
11
+ * hook (the registry uses it for the global concurrency cap);
12
+ * - `timeoutMs` is a hard timeout: abort -> result {status:"interrupted", error:"timeout"};
13
+ * - a stall watchdog aborts the child after `stallMs` (default 10min) without
14
+ * any session event. A stall is treated as transient (a silently dropped
15
+ * provider stream): the child is aborted and auto-resumed on the SAME
16
+ * session with a continuation prompt, up to `stallRetries` times
17
+ * (default 1). Only when the retries are exhausted does the run settle as
18
+ * {status:"failed", error:"stalled"}. Retries keep the admission slot and
19
+ * the result promise; the transcript so far is preserved.
20
+ * - the retry waits for the abort to finish before re-prompting: real pi
21
+ * rejects prompt() while a run is still active ("Agent is already
22
+ * processing"). The wait is bounded by `stallMs` — an abort that never
23
+ * 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).
27
+ * - session/prompt exceptions -> {status:"failed", error}.
28
+ *
29
+ * Note on pi semantics: AgentSession.prompt() resolves only after the whole
30
+ * agent run settles, so the prompt promise itself is the completion signal;
31
+ * waitForIdle() is awaited afterwards as belt-and-braces for queued
32
+ * steer/followUp processing.
33
+ */
34
+ import { realClock, TimerScope, type Clock, type ClockTimer } from "../clock";
35
+ import type {
36
+ ChildResult,
37
+ ChildRunRequest,
38
+ ChildRunner,
39
+ ChildSessionAdapter,
40
+ ChildStatus,
41
+ CreateSessionFn,
42
+ DisposableChildHandle,
43
+ } from "./types";
44
+
45
+ /** Inactivity abort. Paused while a tool is executing or a need_decision is pending. */
46
+ export const DEFAULT_STALL_MS = 5 * 60 * 1000;
47
+ /** Auto-resumes per stall before the run settles as failed. 0 disables retries. */
48
+ export const DEFAULT_STALL_RETRIES = 1;
49
+ /** Pause between the stall abort and the retry prompt (ms). */
50
+ export const DEFAULT_STALL_RETRY_DELAY_MS = 5_000;
51
+
52
+ /** Continuation prompt for a stall retry: the transcript holds the context. */
53
+ export function stallRetryPrompt(stallMs: number): string {
54
+ return (
55
+ `[system: the previous attempt stalled with no activity for over ${Math.round(stallMs / 1000)}s ` +
56
+ "and was interrupted mid-run; the transcript so far is preserved. Continue from where you left off.]"
57
+ );
58
+ }
59
+
60
+ export interface InProcessRunnerOptions {
61
+ createSession: CreateSessionFn;
62
+ /** Stall watchdog timeout (ms). Default 10 minutes. */
63
+ stallMs?: number;
64
+ /** Auto-resume attempts after a stall. Default 1; 0 settles stalled at once. */
65
+ stallRetries?: number;
66
+ /** Delay between stall abort and the retry prompt (ms). Default 5s. */
67
+ stallRetryDelayMs?: number;
68
+ /** Called on every stall detection (before any retry), with the 1-based attempt. */
69
+ onStall?: (childId: string, attempt: number) => void;
70
+ /** Shared time source and scheduler. */
71
+ clock?: Clock;
72
+ /**
73
+ * Per-generation admission hook. Awaited before each (re)start; the
74
+ * resolved releaser is called when the generation settles. Rejecting
75
+ * cancels the generation as {status:"interrupted", error}.
76
+ */
77
+ acquire?: (req: ChildRunRequest) => Promise<() => void>;
78
+ /**
79
+ * Called after the child's conversation may have changed (a message or
80
+ * tool finished, or the generation settled). Used to persist transcripts.
81
+ */
82
+ onActivity?: (childId: string) => void;
83
+ }
84
+
85
+ function errorMessage(err: unknown): string {
86
+ return err instanceof Error ? err.message : String(err);
87
+ }
88
+
89
+ export class InProcessRunner implements ChildRunner {
90
+ private readonly opts: InProcessRunnerOptions;
91
+
92
+ constructor(opts: InProcessRunnerOptions) {
93
+ this.opts = opts;
94
+ }
95
+
96
+ async start(req: ChildRunRequest): Promise<DisposableChildHandle> {
97
+ const handle = new InProcessChildHandle(req, this.opts);
98
+ await handle.launch();
99
+ return handle;
100
+ }
101
+ }
102
+
103
+ class InProcessChildHandle implements DisposableChildHandle {
104
+ private readonly req: ChildRunRequest;
105
+ private readonly createSession: CreateSessionFn;
106
+ private readonly stallMs: number;
107
+ private readonly stallRetries: number;
108
+ private readonly stallRetryDelayMs: number;
109
+ private readonly onStall?: (childId: string, attempt: number) => void;
110
+ private readonly clock: Clock;
111
+ private readonly acquire?: (req: ChildRunRequest) => Promise<() => void>;
112
+ private readonly onActivity?: (childId: string) => void;
113
+
114
+ private session: ChildSessionAdapter | null = null;
115
+ private resolvedModel_: string | undefined;
116
+ private unsubscribe: (() => void) | null = null;
117
+ private status_: ChildStatus = "pending";
118
+ private lastEvent: number;
119
+ /** Generation start (reset per generation; durationMs is relative to runStartedAt). */
120
+ private startedAt: number;
121
+ /** User-turn start: reset on launch and user resume(), not on stall retries. */
122
+ private runStartedAt: number;
123
+ /**
124
+ * When the timeout budget starts counting. Set after admission and session
125
+ * creation (queue wait must not eat the budget), and NOT reset by stall
126
+ * retries — a stall already consumes budget time by definition.
127
+ */
128
+ private turnBudgetStart: number;
129
+ /** Abort kicked off by the latest stall detection; awaited (bounded) by the retry. */
130
+ private abortPromise: Promise<void> | null = null;
131
+ private generation = 0;
132
+ private settledFlag = false;
133
+ private resolveResult!: (result: ChildResult) => void;
134
+ private resultPromise: Promise<ChildResult>;
135
+ /**
136
+ * One-shot waiters resolved by the NEXT settle() — an interrupt/dispose/
137
+ * timeout landing during an abort wait unwinds the waiter instead of
138
+ * leaking it when the abort hangs forever. Waiters are per-wait: a settle
139
+ * that already happened must not poison a later wait (resume runs after
140
+ * the previous turn's settle by definition).
141
+ */
142
+ private settleWaiters: Array<() => void> = [];
143
+ private timeoutTimer: ClockTimer | null = null;
144
+ private stallTimer: ClockTimer | null = null;
145
+ private retryTimer: { scope: TimerScope; id: ClockTimer } | null = null;
146
+ private timerScope: TimerScope | null = null;
147
+ /** Stall detections so far (across generations, per handle). */
148
+ private stallAttempts = 0;
149
+ /** Generation retired by a stall detection awaiting its retry. */
150
+ private retiredGen: number | null = null;
151
+ private releaseSlot: (() => void) | null = null;
152
+ private disposed = false;
153
+ /** Nested tool_execution_start/end. Stall stays paused while > 0. */
154
+ private toolDepth = 0;
155
+ /** contact_supervisor need_decision. Stall stays paused while true. */
156
+ private decisionPaused = false;
157
+
158
+ constructor(req: ChildRunRequest, opts: InProcessRunnerOptions) {
159
+ this.req = req;
160
+ this.createSession = opts.createSession;
161
+ this.stallMs = opts.stallMs ?? DEFAULT_STALL_MS;
162
+ const rawRetries = opts.stallRetries ?? DEFAULT_STALL_RETRIES;
163
+ this.stallRetries = Number.isFinite(rawRetries)
164
+ ? Math.max(0, Math.floor(rawRetries))
165
+ : DEFAULT_STALL_RETRIES;
166
+ const rawDelay = opts.stallRetryDelayMs ?? DEFAULT_STALL_RETRY_DELAY_MS;
167
+ this.stallRetryDelayMs = Number.isFinite(rawDelay)
168
+ ? Math.max(0, Math.floor(rawDelay))
169
+ : DEFAULT_STALL_RETRY_DELAY_MS;
170
+ this.onStall = opts.onStall;
171
+ this.clock = opts.clock ?? realClock;
172
+ this.acquire = opts.acquire;
173
+ this.onActivity = opts.onActivity;
174
+ this.startedAt = this.clock.now();
175
+ this.runStartedAt = this.startedAt;
176
+ this.turnBudgetStart = this.startedAt;
177
+ this.lastEvent = this.startedAt;
178
+ this.resultPromise = new Promise((resolve) => {
179
+ this.resolveResult = resolve;
180
+ });
181
+ }
182
+
183
+ get childId(): string {
184
+ return this.req.childId;
185
+ }
186
+
187
+ /** Current generation's result promise (see Appendix B note on resume). */
188
+ get result(): Promise<ChildResult> {
189
+ return this.resultPromise;
190
+ }
191
+
192
+ status(): ChildStatus {
193
+ return this.status_;
194
+ }
195
+
196
+ lastEventAt(): number {
197
+ return this.lastEvent;
198
+ }
199
+
200
+ resolvedModel(): string | undefined {
201
+ return this.resolvedModel_ ?? this.session?.resolvedModel;
202
+ }
203
+
204
+ /** Pause the stall watchdog (need_decision). Nested with tool execution. */
205
+ pauseStall(): void {
206
+ this.decisionPaused = true;
207
+ this.clearStall();
208
+ }
209
+
210
+ resumeStall(): void {
211
+ this.decisionPaused = false;
212
+ this.lastEvent = this.clock.now();
213
+ if (this.status_ === "running" && this.toolDepth === 0) this.armStall(this.generation);
214
+ }
215
+
216
+ conversation() {
217
+ return this.session?.getConversation() ?? [];
218
+ }
219
+
220
+ /** Launch generation 1. Resolves once the prompt is issued (not completed). */
221
+ async launch(): Promise<void> {
222
+ await this.beginGeneration(this.req.prompt, true);
223
+ }
224
+
225
+ async steer(message: string): Promise<void> {
226
+ if (this.status_ !== "running" || !this.session) {
227
+ throw new Error(`subagent ${this.req.childId} is not running (status: ${this.status_})`);
228
+ }
229
+ if (this.retiredGen !== null) {
230
+ // The previous generation was aborted; delivery would be undefined.
231
+ throw new Error(`subagent ${this.req.childId} is restarting after a stall; retry shortly`);
232
+ }
233
+ await this.session.steer(message);
234
+ }
235
+
236
+ async followUp(message: string): Promise<void> {
237
+ if (this.status_ !== "running" || !this.session) {
238
+ throw new Error(`subagent ${this.req.childId} is not running (status: ${this.status_})`);
239
+ }
240
+ if (this.retiredGen !== null) {
241
+ throw new Error(`subagent ${this.req.childId} is restarting after a stall; retry shortly`);
242
+ }
243
+ await this.session.followUp(message);
244
+ }
245
+
246
+ async resume(message: string): Promise<void> {
247
+ if (this.status_ === "running" || this.status_ === "pending") {
248
+ throw new Error(
249
+ `subagent ${this.req.childId} is still ${this.status_}; use steer for a running subagent`,
250
+ );
251
+ }
252
+ if (this.disposed) {
253
+ throw new Error(`subagent ${this.req.childId} has been disposed`);
254
+ }
255
+ if (!this.session) {
256
+ throw new Error(`subagent ${this.req.childId} has no session to resume`);
257
+ }
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.
272
+ this.stallAttempts = 0;
273
+ 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.
276
+ this.resultPromise = new Promise((resolve) => {
277
+ this.resolveResult = resolve;
278
+ });
279
+ await this.beginGeneration(message, false);
280
+ }
281
+
282
+ async interrupt(): Promise<void> {
283
+ if (this.status_ !== "running" && this.status_ !== "pending") return;
284
+ this.settle(this.generation, {
285
+ status: "interrupted",
286
+ text: this.partialText(),
287
+ durationMs: this.now() - this.startedAt,
288
+ });
289
+ try {
290
+ await this.session?.abort();
291
+ } catch {
292
+ // abort is best-effort; the result is already settled
293
+ }
294
+ }
295
+
296
+ dispose(): void {
297
+ this.disposed = true;
298
+ this.clearTimers();
299
+ if (this.unsubscribe) {
300
+ try {
301
+ this.unsubscribe();
302
+ } catch {
303
+ // ignore
304
+ }
305
+ this.unsubscribe = null;
306
+ }
307
+ const session = this.session;
308
+ this.session = null;
309
+ if (session) {
310
+ try {
311
+ session.dispose();
312
+ } catch {
313
+ // ignore
314
+ }
315
+ }
316
+ // Never leave result waiters hanging.
317
+ this.settle(this.generation, { status: "interrupted", text: "", error: "disposed", durationMs: 0 });
318
+ }
319
+
320
+ // -------------------------------------------------------------------------
321
+ // Generation machinery
322
+ // -------------------------------------------------------------------------
323
+
324
+ /**
325
+ * Start a generation. `reuseSlot` is used by stall retries: the admission
326
+ * slot acquired by the stalled generation is still held (no settle happened),
327
+ * so admission is not re-run and the slot is not double-counted.
328
+ */
329
+ private async beginGeneration(prompt: string, first: boolean, reuseSlot = false): Promise<void> {
330
+ const gen = ++this.generation;
331
+ this.retiredGen = null;
332
+ this.clearRetryTimer();
333
+ this.timerScope?.dispose();
334
+ this.timerScope = new TimerScope(this.clock);
335
+ this.settledFlag = false;
336
+ this.status_ = "pending";
337
+ this.startedAt = this.clock.now();
338
+ this.lastEvent = this.startedAt;
339
+ // A tool_execution_end from the previous generation may have been dropped
340
+ // after settle. Don't carry that depth (or a pending decision) into this one.
341
+ this.toolDepth = 0;
342
+ this.decisionPaused = false;
343
+
344
+ if (this.acquire && !reuseSlot) {
345
+ try {
346
+ this.releaseSlot = await this.acquire(this.req);
347
+ } catch (err) {
348
+ // Admission denied (e.g. fail_fast cancellation while queued).
349
+ this.settle(gen, {
350
+ status: "interrupted",
351
+ text: "",
352
+ error: errorMessage(err),
353
+ durationMs: this.now() - this.startedAt,
354
+ });
355
+ return;
356
+ }
357
+ }
358
+ if (this.disposed || this.isSettled(gen)) {
359
+ this.release();
360
+ return;
361
+ }
362
+ this.status_ = "running";
363
+
364
+ if (first) {
365
+ try {
366
+ this.session = await this.createSession(this.req);
367
+ this.resolvedModel_ = this.session.resolvedModel;
368
+ } catch (err) {
369
+ this.settle(gen, {
370
+ status: "failed",
371
+ text: "",
372
+ error: errorMessage(err),
373
+ durationMs: this.now() - this.startedAt,
374
+ });
375
+ return;
376
+ }
377
+ if (this.disposed || this.isSettled(gen)) {
378
+ this.release();
379
+ return;
380
+ }
381
+ this.unsubscribe = this.session.subscribe((event) => {
382
+ if (event.type === "message_end" || event.type === "tool_execution_end" || event.type === "agent_end") {
383
+ this.notifyActivity();
384
+ }
385
+ // Track depth even after settle. A late tool_execution_end must not
386
+ // leak into the next resume, and must not rearm a stale generation.
387
+ if (event.type === "tool_execution_start") {
388
+ this.toolDepth++;
389
+ if (this.status_ === "running") this.clearStall();
390
+ return;
391
+ }
392
+ if (event.type === "tool_execution_end") {
393
+ this.toolDepth = Math.max(0, this.toolDepth - 1);
394
+ this.lastEvent = this.now();
395
+ if (this.status_ === "running" && this.toolDepth === 0 && !this.decisionPaused) {
396
+ this.armStall(this.generation);
397
+ }
398
+ return;
399
+ }
400
+ if (this.status_ !== "running") return;
401
+ this.lastEvent = this.now();
402
+ if (this.toolDepth === 0 && !this.decisionPaused) this.armStall(this.generation);
403
+ });
404
+ }
405
+
406
+ const session = this.session;
407
+ if (!session) {
408
+ this.settle(gen, {
409
+ status: "failed",
410
+ text: "",
411
+ error: "no session available",
412
+ durationMs: this.now() - this.startedAt,
413
+ });
414
+ return;
415
+ }
416
+
417
+ // The timeout budget starts only after admission and session creation:
418
+ // 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.
423
+ if (this.isSettled(gen)) return;
424
+ this.armStall(gen);
425
+ // Tell the child which model it is — otherwise only the parent/fleet knows.
426
+ const prompted =
427
+ first && session.resolvedModel
428
+ ? `You are running as model ${session.resolvedModel}.\n\n${prompt}`
429
+ : prompt;
430
+ // Floating: prompt() resolves when the whole run settles (pi semantics).
431
+ session.prompt(prompted).then(
432
+ () => {
433
+ void this.finishGeneration(gen);
434
+ },
435
+ (err) => {
436
+ if (!this.isCurrent(gen)) return;
437
+ this.settle(gen, {
438
+ status: "failed",
439
+ text: this.partialText(),
440
+ error: errorMessage(err),
441
+ durationMs: this.now() - this.startedAt,
442
+ });
443
+ },
444
+ );
445
+ }
446
+
447
+ private async finishGeneration(gen: number): Promise<void> {
448
+ if (!this.isCurrent(gen)) return;
449
+ const session = this.session;
450
+ if (session) {
451
+ try {
452
+ await session.waitForIdle();
453
+ } catch {
454
+ // A waitForIdle failure after a resolved prompt is not actionable;
455
+ // fall through and report whatever text we have.
456
+ }
457
+ }
458
+ if (!this.isCurrent(gen)) return;
459
+ const failure = session?.getLastAssistantFailure?.();
460
+ if (failure) {
461
+ this.settle(gen, {
462
+ status: "failed",
463
+ text: this.partialText(),
464
+ error: failure.errorMessage?.trim() || `Model stopped with ${failure.stopReason}`,
465
+ endReason: "model-error",
466
+ durationMs: this.now() - this.startedAt,
467
+ });
468
+ return;
469
+ }
470
+ this.settle(gen, {
471
+ status: "completed",
472
+ text: this.partialText(),
473
+ durationMs: this.now() - this.startedAt,
474
+ });
475
+ }
476
+
477
+ private partialText(): string {
478
+ return this.session?.getLastAssistantText() || "(no output)";
479
+ }
480
+
481
+ private now(): number {
482
+ return this.clock.now();
483
+ }
484
+
485
+ private isSettled(gen: number): boolean {
486
+ return gen !== this.generation || this.settledFlag;
487
+ }
488
+
489
+ private isCurrent(gen: number): boolean {
490
+ // A stall-retired generation is neither settled nor current: its aborted
491
+ // prompt resolves shortly after detection and must not be reported as
492
+ // the child's completion while the retry is still pending.
493
+ return gen === this.generation && !this.settledFlag && !this.disposed && gen !== this.retiredGen;
494
+ }
495
+
496
+ private settle(gen: number, result: ChildResult): void {
497
+ if (gen !== this.generation || this.settledFlag) return;
498
+ this.settledFlag = true;
499
+ this.clearTimers();
500
+ // Generations run: 1 + resumes + stall retries. Forensics for the
501
+ // incident class this exists for (a stalled child that needed retries).
502
+ // Omitted on the first generation so a clean run keeps a lean shape.
503
+ if (result.attempts === undefined && this.generation > 1) {
504
+ result.attempts = this.generation;
505
+ }
506
+ // durationMs covers the whole user turn (launch/resume -> settle),
507
+ // including time lost to stalls and retry delays. dispose keeps its 0.
508
+ if (result.error !== "disposed") result.durationMs = this.now() - this.runStartedAt;
509
+ if (result.stalls === undefined && this.stallAttempts > 0) {
510
+ result.stalls = this.stallAttempts;
511
+ }
512
+ const waiters = this.settleWaiters.splice(0);
513
+ for (const wake of waiters) wake();
514
+ // Surface non-fatal setup caveats (e.g. agent-def model fallback) once.
515
+ if (result.warning === undefined && this.session?.warning) {
516
+ result.warning = this.session.warning;
517
+ }
518
+ this.status_ = result.status;
519
+ this.release();
520
+ this.notifyActivity();
521
+ this.resolveResult(result);
522
+ }
523
+
524
+ private notifyActivity(): void {
525
+ try {
526
+ this.onActivity?.(this.req.childId);
527
+ } catch {
528
+ // persistence observers must not break the child lifecycle
529
+ }
530
+ }
531
+
532
+ private release(): void {
533
+ const release = this.releaseSlot;
534
+ this.releaseSlot = null;
535
+ if (release) {
536
+ try {
537
+ release();
538
+ } catch {
539
+ // ignore
540
+ }
541
+ }
542
+ }
543
+
544
+ private armTimeout(gen: number): void {
545
+ if (this.timeoutTimer !== null) {
546
+ this.timerScope?.clearTimeout(this.timeoutTimer);
547
+ this.timeoutTimer = null;
548
+ }
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);
554
+ if (remaining <= 0) {
555
+ this.settle(gen, {
556
+ status: "interrupted",
557
+ text: this.partialText(),
558
+ error: "timeout",
559
+ durationMs: this.now() - this.runStartedAt,
560
+ });
561
+ return;
562
+ }
563
+ this.timeoutTimer = this.timerScope?.setTimeout(() => {
564
+ this.timeoutTimer = null;
565
+ // Deliberately NOT isCurrent(): a timeout landing during a stall's
566
+ // retry delay must still fire — the retry has not started a new
567
+ // generation, and the budget is spent.
568
+ if (this.settledFlag || this.disposed || gen !== this.generation) return;
569
+ this.settle(gen, {
570
+ status: "interrupted",
571
+ text: this.partialText(),
572
+ error: "timeout",
573
+ durationMs: this.now() - this.runStartedAt,
574
+ });
575
+ // Track the abort: a quick user resume() must wait for it before
576
+ // re-prompting, or real pi rejects the prompt ("already processing").
577
+ this.abortPromise = this.session ? this.session.abort().catch(() => {}) : null;
578
+ }, remaining) ?? null;
579
+ }
580
+
581
+ private armStall(gen: number): void {
582
+ if (this.stallTimer !== null) {
583
+ this.timerScope?.clearTimeout(this.stallTimer);
584
+ this.stallTimer = null;
585
+ }
586
+ if (!(this.stallMs > 0)) return;
587
+ this.stallTimer = this.timerScope?.setTimeout(() => {
588
+ this.stallTimer = null;
589
+ if (!this.isCurrent(gen)) return;
590
+ this.handleStall(gen);
591
+ }, this.stallMs) ?? null;
592
+ }
593
+
594
+ /**
595
+ * Stall detection. Abort the hung generation (frees a silently dropped
596
+ * provider stream), then either settle failed (retries exhausted) or
597
+ * schedule an auto-resume on the same session after `stallRetryDelayMs`.
598
+ * The admission slot and result promise survive; the transcript is kept.
599
+ */
600
+ private handleStall(gen: number): void {
601
+ this.stallAttempts++;
602
+ // Persist whatever the stalled generation produced before it went quiet.
603
+ this.notifyActivity();
604
+ try {
605
+ this.onStall?.(this.req.childId, this.stallAttempts);
606
+ } catch {
607
+ // stall observers must not break the retry machinery
608
+ }
609
+ this.retiredGen = gen;
610
+ // Kick the abort off now so the unwind overlaps the retry delay; the
611
+ // retry timer awaits it (bounded) before re-prompting. Real pi rejects
612
+ // prompt() while the aborted run is still active.
613
+ this.abortPromise = this.session ? this.session.abort().catch(() => {}) : null;
614
+ if (this.stallAttempts > this.stallRetries) {
615
+ this.settle(gen, {
616
+ status: "failed",
617
+ text: this.partialText(),
618
+ error: "stalled",
619
+ durationMs: this.now() - this.runStartedAt,
620
+ });
621
+ return;
622
+ }
623
+ const scope = this.timerScope;
624
+ if (!scope) return;
625
+ const id = scope.setTimeout(() => {
626
+ this.retryTimer = null;
627
+ // interrupt()/dispose() during the delay settles this generation. The
628
+ // retiredGen exclusion in isCurrent() must NOT apply here: retrying the
629
+ // retired generation is precisely this timer's job.
630
+ if (this.settledFlag || this.disposed || gen !== this.generation) return;
631
+ void this.retryAfterAbort(gen);
632
+ }, this.stallRetryDelayMs);
633
+ this.retryTimer = { scope, id };
634
+ }
635
+
636
+ /**
637
+ * Resume after a stall: wait for the abort to finish (bounded by stallMs),
638
+ * then re-prompt the same session. An abort that never completes means the
639
+ * stream ignored it — the session is dead, so the retry is skipped and the
640
+ * run settles failed (stalled).
641
+ */
642
+ private async retryAfterAbort(gen: number): Promise<void> {
643
+ if (this.settledFlag || this.disposed || gen !== this.generation) return;
644
+ const abort = this.abortPromise;
645
+ this.abortPromise = null;
646
+ if (abort && !(await this.awaitAbortBounded(abort))) {
647
+ // Bound expired: the hung stream ignored the abort and the session is
648
+ // dead, so the retry is skipped.
649
+ this.settle(gen, {
650
+ status: "failed",
651
+ text: this.partialText(),
652
+ error: "stalled",
653
+ durationMs: this.now() - this.runStartedAt,
654
+ });
655
+ return;
656
+ }
657
+ if (this.settledFlag || this.disposed || gen !== this.generation) return;
658
+ await this.beginGeneration(stallRetryPrompt(this.stallMs), false, true);
659
+ }
660
+
661
+ /**
662
+ * Await an abort with a bound (stallMs). Resolves true when the abort
663
+ * finished, false on bound expiry or after settle() — the settled signal
664
+ * is raced in so an interrupt/dispose/timeout during the wait unwinds this
665
+ * closure even when the abort hangs forever (no handle-graph leak).
666
+ * Uses the raw clock, not a TimerScope: callers may run after settle,
667
+ * when the generation scope is already disposed.
668
+ */
669
+ private async awaitAbortBounded(abort: Promise<void>): Promise<boolean> {
670
+ let boundTimer: ClockTimer | null = null;
671
+ const bound = new Promise<false>((resolve) => {
672
+ boundTimer = this.clock.setTimeout(() => resolve(false), this.stallMs);
673
+ });
674
+ let waiter: (() => void) | null = null;
675
+ const settledDuringWait = new Promise<false>((resolve) => {
676
+ waiter = () => resolve(false);
677
+ this.settleWaiters.push(waiter);
678
+ });
679
+ const ok = await Promise.race([abort.then(() => true as const), bound, settledDuringWait]);
680
+ if (boundTimer !== null) this.clock.clearTimeout(boundTimer);
681
+ if (waiter !== null) this.settleWaiters = this.settleWaiters.filter((w) => w !== waiter);
682
+ return ok;
683
+ }
684
+
685
+ private clearRetryTimer(): void {
686
+ if (this.retryTimer !== null) {
687
+ this.retryTimer.scope.clearTimeout(this.retryTimer.id);
688
+ this.retryTimer = null;
689
+ }
690
+ }
691
+
692
+ private clearStall(): void {
693
+ if (this.stallTimer !== null) {
694
+ this.timerScope?.clearTimeout(this.stallTimer);
695
+ this.stallTimer = null;
696
+ }
697
+ }
698
+
699
+ private clearTimers(): void {
700
+ if (this.timeoutTimer !== null) {
701
+ this.timerScope?.clearTimeout(this.timeoutTimer);
702
+ this.timeoutTimer = null;
703
+ }
704
+ this.clearStall();
705
+ this.clearRetryTimer();
706
+ this.timerScope?.dispose();
707
+ this.timerScope = null;
708
+ }
709
+ }