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.
- package/package.json +32 -16
- package/src/agents/definition.ts +12 -6
- package/src/bash-override.ts +89 -9
- package/src/behavior-guidelines.ts +1 -0
- package/src/clock.ts +5 -0
- package/src/config.ts +25 -1
- package/src/format.ts +62 -1
- package/src/index.ts +36 -1
- package/src/manager-client.ts +1 -1
- package/src/notify.ts +7 -7
- package/src/subagent/child-bash.ts +59 -42
- package/src/subagent/fleet-widget.ts +46 -6
- package/src/subagent/model-spec.ts +134 -16
- package/src/subagent/overrun.ts +176 -0
- package/src/subagent/pi-runtime.ts +7 -5
- package/src/subagent/registry.ts +81 -3
- package/src/subagent/runner.ts +242 -51
- package/src/subagent/tool.ts +83 -10
- package/src/subagent/types.ts +11 -2
- package/src/thinking-levels.ts +26 -0
- package/src/tui/message-renderers.ts +36 -2
- package/src/tui/tasks-command.ts +36 -6
- package/src/wake-delivery.ts +137 -0
- package/src/wake.ts +238 -4
- package/src/work-index.ts +16 -0
package/src/subagent/registry.ts
CHANGED
|
@@ -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
|
|
14
|
-
* (initial start and resume) passes through admission.
|
|
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
|
}
|
package/src/subagent/runner.ts
CHANGED
|
@@ -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
|
|
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
|
-
* -
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
//
|
|
259
|
-
//
|
|
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
|
-
//
|
|
275
|
-
//
|
|
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
|
-
|
|
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
|
|
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(
|
|
330
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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.
|
|
564
|
-
this.
|
|
565
|
-
// Deliberately NOT isCurrent(): a
|
|
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.
|
|
701
|
-
this.timerScope?.clearTimeout(this.
|
|
702
|
-
this.
|
|
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();
|