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.
- package/LICENSE +21 -0
- package/README.md +36 -0
- package/bin/pi-famulus.js +29 -0
- package/package.json +49 -0
- package/src/agents/builtins.ts +41 -0
- package/src/agents/definition.ts +221 -0
- package/src/agents/loader.ts +119 -0
- package/src/bash-override.ts +372 -0
- package/src/behavior-guidelines.ts +42 -0
- package/src/clock.ts +169 -0
- package/src/comms/comms.ts +199 -0
- package/src/comms/mailbox.ts +156 -0
- package/src/comms/registry-host.ts +73 -0
- package/src/comms/reply-command.ts +52 -0
- package/src/comms/routing.ts +23 -0
- package/src/comms/tools.ts +339 -0
- package/src/comms/types.ts +68 -0
- package/src/config.ts +266 -0
- package/src/events.ts +61 -0
- package/src/exit-notify-gate.ts +69 -0
- package/src/exit-watchdog.ts +60 -0
- package/src/file-tail.ts +48 -0
- package/src/format.ts +276 -0
- package/src/index.ts +700 -0
- package/src/manager-client.ts +839 -0
- package/src/monitor-batching.ts +191 -0
- package/src/monitor.ts +517 -0
- package/src/native-manager.d.ts +9 -0
- package/src/native-manager.js +39 -0
- package/src/notify.ts +231 -0
- package/src/shell-exec.ts +145 -0
- package/src/subagent/agent-records.ts +168 -0
- package/src/subagent/child-bash.ts +183 -0
- package/src/subagent/conversation.ts +76 -0
- package/src/subagent/fleet-widget.ts +176 -0
- package/src/subagent/model-spec.ts +108 -0
- package/src/subagent/pi-runtime.ts +295 -0
- package/src/subagent/pool.ts +190 -0
- package/src/subagent/registry.ts +575 -0
- package/src/subagent/runner.ts +709 -0
- package/src/subagent/tool.ts +741 -0
- package/src/subagent/transcript.ts +71 -0
- package/src/subagent/types.ts +125 -0
- package/src/task-exit-notify.ts +21 -0
- package/src/task-tools.ts +330 -0
- package/src/tui/message-renderers.ts +183 -0
- package/src/tui/pi-tui-load.ts +186 -0
- package/src/tui/scroll-detail-view.ts +190 -0
- package/src/tui/task-output-paths.ts +46 -0
- package/src/tui/tasks-command.ts +515 -0
- package/src/tui/tool-component.ts +29 -0
- package/src/wake.ts +219 -0
- 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
|
+
}
|