@gotgenes/pi-subagents 16.1.1 → 16.2.1
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/CHANGELOG.md +14 -0
- package/docs/architecture/architecture.md +31 -24
- package/docs/plans/0374-encapsulate-subagent-start-notification.md +268 -0
- package/docs/plans/0375-extract-run-listener-workspace-bracket.md +300 -0
- package/docs/retro/0374-encapsulate-subagent-start-notification.md +171 -0
- package/docs/retro/0375-extract-run-listener-workspace-bracket.md +51 -0
- package/docs/retro/0403-abort-subagents-on-interrupt.md +41 -0
- package/package.json +1 -1
- package/src/lifecycle/run-listeners.ts +37 -0
- package/src/lifecycle/subagent-manager.ts +5 -7
- package/src/lifecycle/subagent.ts +76 -83
- package/src/lifecycle/workspace-bracket.ts +59 -0
|
@@ -168,16 +168,14 @@ export class SubagentManager {
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
if (options.isBackground && !options.bypassQueue) {
|
|
171
|
-
// Schedule on the limiter —
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
return record.run();
|
|
176
|
-
});
|
|
171
|
+
// Schedule on the limiter — scheduleVia captures the limiter promise
|
|
172
|
+
// eagerly, so a queued agent is awaitable from spawn; guardedRun guards
|
|
173
|
+
// against abort-while-queued when the slot frees.
|
|
174
|
+
record.scheduleVia((thunk) => this.limiter.schedule(thunk));
|
|
177
175
|
return id;
|
|
178
176
|
}
|
|
179
177
|
|
|
180
|
-
record.
|
|
178
|
+
record.start();
|
|
181
179
|
return id;
|
|
182
180
|
}
|
|
183
181
|
|
|
@@ -1,21 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* subagent.ts — Subagent class
|
|
2
|
+
* subagent.ts — Subagent class: identity, lifecycle status, and per-subagent behavior.
|
|
3
3
|
*
|
|
4
|
-
* Status
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* Stats (toolUses, lifetimeUsage, compactionCount) are owned by the class and
|
|
9
|
-
* accumulated via mutation methods (incrementToolUses, addUsage, incrementCompactions).
|
|
10
|
-
*
|
|
11
|
-
* Behavior (abort, steer buffering) lives on the subagent rather than on
|
|
12
|
-
* SubagentManager — each subagent manages its own lifecycle concerns.
|
|
13
|
-
*
|
|
14
|
-
* The child's working directory is supplied by a registered WorkspaceProvider
|
|
15
|
-
* (the workspace seam); with no provider the child runs in the parent cwd.
|
|
16
|
-
*
|
|
17
|
-
* Phase-specific collaborators (subagentSession, notification) are attached
|
|
18
|
-
* after construction as lifecycle information becomes available.
|
|
4
|
+
* Status/stats are delegated to the SubagentState value object; listener
|
|
5
|
+
* lifecycle to RunListeners; workspace prepare/dispose to WorkspaceBracket.
|
|
6
|
+
* Behavior (abort, steer buffering) lives here rather than on SubagentManager.
|
|
19
7
|
*/
|
|
20
8
|
|
|
21
9
|
import type { Model } from "@earendil-works/pi-ai";
|
|
@@ -23,10 +11,12 @@ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
|
23
11
|
import { debugLog } from "#src/debug";
|
|
24
12
|
import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent-session";
|
|
25
13
|
import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
14
|
+
import { RunListeners } from "#src/lifecycle/run-listeners";
|
|
26
15
|
import type { SubagentSession, TurnLoopResult } from "#src/lifecycle/subagent-session";
|
|
27
16
|
import { SubagentState, type SubagentStatus } from "#src/lifecycle/subagent-state";
|
|
28
17
|
import type { LifetimeUsage } from "#src/lifecycle/usage";
|
|
29
|
-
import type {
|
|
18
|
+
import type { WorkspaceProvider } from "#src/lifecycle/workspace";
|
|
19
|
+
import { WorkspaceBracket } from "#src/lifecycle/workspace-bracket";
|
|
30
20
|
import { NotificationState } from "#src/observation/notification-state";
|
|
31
21
|
import { subscribeSubagentObserver } from "#src/observation/record-observer";
|
|
32
22
|
import type { RunConfig } from "#src/runtime";
|
|
@@ -105,20 +95,17 @@ export class Subagent {
|
|
|
105
95
|
get lifetimeUsage(): Readonly<LifetimeUsage> { return this.state.lifetimeUsage; }
|
|
106
96
|
get compactionCount(): number { return this.state.compactionCount; }
|
|
107
97
|
|
|
108
|
-
/** AbortController for cancelling this agent. Created at construction. */
|
|
109
98
|
readonly abortController: AbortController;
|
|
110
|
-
|
|
111
|
-
promise
|
|
99
|
+
private _promise?: Promise<void>;
|
|
100
|
+
get promise(): Promise<void> | undefined { return this._promise; }
|
|
112
101
|
|
|
113
|
-
// Execution machinery — a single mandatory collaborator (no per-field fallbacks).
|
|
114
102
|
private readonly execution: SubagentExecution;
|
|
115
|
-
|
|
116
|
-
private
|
|
103
|
+
private readonly listeners = new RunListeners();
|
|
104
|
+
private readonly workspaceBracket: WorkspaceBracket;
|
|
117
105
|
|
|
118
|
-
// Phase-specific collaborators — each born complete when their info becomes available
|
|
119
|
-
/** The born-complete child session — set when the factory returns inside run(). */
|
|
120
106
|
subagentSession?: SubagentSession;
|
|
121
|
-
|
|
107
|
+
private _notification?: NotificationState;
|
|
108
|
+
get notification(): NotificationState | undefined { return this._notification; }
|
|
122
109
|
|
|
123
110
|
// Steer buffer — messages queued before the session is ready
|
|
124
111
|
private _pendingSteers: string[] = [];
|
|
@@ -187,10 +174,15 @@ export class Subagent {
|
|
|
187
174
|
// Execution machinery — a single mandatory collaborator
|
|
188
175
|
this.execution = init.execution;
|
|
189
176
|
|
|
177
|
+
// Per-run lifecycle collaborators
|
|
178
|
+
this.workspaceBracket = new WorkspaceBracket(
|
|
179
|
+
this.execution.getWorkspaceProvider ?? (() => undefined),
|
|
180
|
+
);
|
|
181
|
+
|
|
190
182
|
// Notification state — created from parentSession.toolCallId if present
|
|
191
183
|
const toolCallId = init.execution.parentSession?.toolCallId;
|
|
192
184
|
if (toolCallId) {
|
|
193
|
-
this.
|
|
185
|
+
this._notification = new NotificationState(toolCallId);
|
|
194
186
|
}
|
|
195
187
|
}
|
|
196
188
|
|
|
@@ -206,27 +198,26 @@ export class Subagent {
|
|
|
206
198
|
async run(): Promise<void> {
|
|
207
199
|
this.markRunning(Date.now());
|
|
208
200
|
this.execution.observer?.onStarted?.(this);
|
|
209
|
-
this.wireSignal(this.execution.signal, () => this.abort());
|
|
201
|
+
this.listeners.wireSignal(this.execution.signal, () => this.abort());
|
|
210
202
|
|
|
203
|
+
// Guard the await so the no-provider path stays synchronous, preserving
|
|
204
|
+
// the original run() timing: the factory is called in the same turn as
|
|
205
|
+
// spawn() when no workspace provider is registered.
|
|
211
206
|
let cwd: string | undefined;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const provider = this.execution.getWorkspaceProvider?.();
|
|
216
|
-
if (provider) {
|
|
217
|
-
this._workspace = await provider.prepare({
|
|
207
|
+
if (this.workspaceBracket.hasProvider()) {
|
|
208
|
+
try {
|
|
209
|
+
cwd = await this.workspaceBracket.prepare({
|
|
218
210
|
agentId: this.id,
|
|
219
211
|
agentType: this.type,
|
|
220
212
|
baseCwd: this.execution.baseCwd,
|
|
221
213
|
invocation: this.invocation,
|
|
222
214
|
});
|
|
223
|
-
|
|
215
|
+
} catch (err) {
|
|
216
|
+
this.markError(err);
|
|
217
|
+
this.listeners.release();
|
|
218
|
+
this.execution.observer?.onRunFinished?.(this);
|
|
219
|
+
return;
|
|
224
220
|
}
|
|
225
|
-
} catch (err) {
|
|
226
|
-
this.markError(err);
|
|
227
|
-
this.releaseListeners();
|
|
228
|
-
this.execution.observer?.onRunFinished?.(this);
|
|
229
|
-
return;
|
|
230
221
|
}
|
|
231
222
|
|
|
232
223
|
try {
|
|
@@ -245,7 +236,7 @@ export class Subagent {
|
|
|
245
236
|
}
|
|
246
237
|
|
|
247
238
|
this.flushPendingSteers();
|
|
248
|
-
this.attachObserver(subscribeSubagentObserver(this.subagentSession, this.state, {
|
|
239
|
+
this.listeners.attachObserver(subscribeSubagentObserver(this.subagentSession, this.state, {
|
|
249
240
|
onCompact: (info) => this.execution.observer?.onCompacted?.(this, info),
|
|
250
241
|
}));
|
|
251
242
|
this.execution.observer?.onSessionCreated?.(this);
|
|
@@ -264,6 +255,35 @@ export class Subagent {
|
|
|
264
255
|
}
|
|
265
256
|
}
|
|
266
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Start execution immediately (foreground / bypassQueue paths).
|
|
260
|
+
* Stores the run promise so it is awaitable via the `promise` getter.
|
|
261
|
+
*/
|
|
262
|
+
start(): void {
|
|
263
|
+
this._promise = this.guardedRun();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Schedule execution through an external concurrency scheduler (the limiter).
|
|
268
|
+
* Captures the scheduler's promise eagerly, so a still-queued agent is
|
|
269
|
+
* awaitable via the `promise` getter from spawn — not only once its slot opens.
|
|
270
|
+
* The guard in guardedRun() makes an abort-while-queued run a no-op when the
|
|
271
|
+
* slot finally frees.
|
|
272
|
+
*/
|
|
273
|
+
scheduleVia(schedule: (thunk: () => Promise<void>) => Promise<void>): void {
|
|
274
|
+
this._promise = schedule(() => this.guardedRun());
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Run unless the agent left the active set before its slot opened
|
|
279
|
+
* (e.g. abort-while-queued): a non-queued, non-running status resolves
|
|
280
|
+
* immediately without running.
|
|
281
|
+
*/
|
|
282
|
+
private guardedRun(): Promise<void> {
|
|
283
|
+
if (this.status !== "queued" && this.status !== "running") return Promise.resolve();
|
|
284
|
+
return this.run();
|
|
285
|
+
}
|
|
286
|
+
|
|
267
287
|
/**
|
|
268
288
|
* Resume an existing session with a new prompt, managing the observer
|
|
269
289
|
* subscription lifecycle internally (same wiring as run()).
|
|
@@ -280,7 +300,7 @@ export class Subagent {
|
|
|
280
300
|
}
|
|
281
301
|
|
|
282
302
|
this.resetForResume(Date.now());
|
|
283
|
-
this.attachObserver(subscribeSubagentObserver(subagentSession, this.state, {
|
|
303
|
+
this.listeners.attachObserver(subscribeSubagentObserver(subagentSession, this.state, {
|
|
284
304
|
onCompact: (info) => this.execution.observer?.onCompacted?.(this, info),
|
|
285
305
|
}));
|
|
286
306
|
|
|
@@ -290,7 +310,7 @@ export class Subagent {
|
|
|
290
310
|
} catch (err) {
|
|
291
311
|
this.markError(err);
|
|
292
312
|
} finally {
|
|
293
|
-
this.
|
|
313
|
+
this.listeners.release();
|
|
294
314
|
}
|
|
295
315
|
}
|
|
296
316
|
|
|
@@ -386,48 +406,21 @@ export class Subagent {
|
|
|
386
406
|
/** Reset for resume: running status, new startedAt, clear completedAt/result/error/listeners. */
|
|
387
407
|
resetForResume(startedAt: number): void {
|
|
388
408
|
this.state.resetForResume(startedAt);
|
|
389
|
-
this.
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// --- Per-run listener state (released on completion or resume reset) ---
|
|
393
|
-
private _unsub?: () => void;
|
|
394
|
-
private _detachFn?: () => void;
|
|
395
|
-
|
|
396
|
-
/** Wire a parent AbortSignal so it stops this agent when fired. */
|
|
397
|
-
wireSignal(signal: AbortSignal | undefined, onAbort: () => void): void {
|
|
398
|
-
if (!signal) return;
|
|
399
|
-
const listener = () => onAbort();
|
|
400
|
-
signal.addEventListener("abort", listener, { once: true });
|
|
401
|
-
this._detachFn = () => signal.removeEventListener("abort", listener);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/** Store the record-observer unsubscribe handle. */
|
|
405
|
-
attachObserver(unsub: () => void): void {
|
|
406
|
-
this._unsub = unsub;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/** Release observer + signal listener handles. */
|
|
410
|
-
releaseListeners(): void {
|
|
411
|
-
this._unsub?.();
|
|
412
|
-
this._unsub = undefined;
|
|
413
|
-
this._detachFn?.();
|
|
414
|
-
this._detachFn = undefined;
|
|
409
|
+
this.listeners.release();
|
|
415
410
|
}
|
|
416
411
|
|
|
417
412
|
/** Complete a run: release listeners, dispose the workspace, status transition, notify observer. */
|
|
418
413
|
completeRun(result: TurnLoopResult): void {
|
|
419
|
-
this.
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
? "
|
|
425
|
-
:
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
if (disposeResult?.resultAddendum) finalResult += disposeResult.resultAddendum;
|
|
430
|
-
}
|
|
414
|
+
this.listeners.release();
|
|
415
|
+
|
|
416
|
+
const finalStatus: SubagentStatus = result.aborted
|
|
417
|
+
? "aborted"
|
|
418
|
+
: result.steered
|
|
419
|
+
? "steered"
|
|
420
|
+
: "completed";
|
|
421
|
+
const finalResult =
|
|
422
|
+
result.responseText +
|
|
423
|
+
this.workspaceBracket.dispose({ status: finalStatus, description: this.description });
|
|
431
424
|
|
|
432
425
|
if (result.aborted) this.markAborted(finalResult);
|
|
433
426
|
else if (result.steered) this.markSteered(finalResult);
|
|
@@ -444,10 +437,10 @@ export class Subagent {
|
|
|
444
437
|
/** Fail a run: mark error, release listeners, best-effort workspace dispose, notify observer. */
|
|
445
438
|
failRun(err: unknown): void {
|
|
446
439
|
this.markError(err);
|
|
447
|
-
this.
|
|
440
|
+
this.listeners.release();
|
|
448
441
|
|
|
449
442
|
try {
|
|
450
|
-
|
|
443
|
+
this.workspaceBracket.dispose({ status: "error", description: this.description });
|
|
451
444
|
} catch (cleanupErr) { debugLog("workspace dispose on agent error", cleanupErr); }
|
|
452
445
|
|
|
453
446
|
this.execution.observer?.onRunFinished?.(this);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workspace-bracket.ts — Owned prepare/dispose lifecycle for a child workspace.
|
|
3
|
+
*
|
|
4
|
+
* Captures the provider resolver (not the provider itself) so provider
|
|
5
|
+
* resolution stays lazy at run-start. The prepared Workspace is held
|
|
6
|
+
* privately; dispose() centralises the guard and addendum-unwrap so callers
|
|
7
|
+
* never reach through to workspace.dispose().resultAddendum directly.
|
|
8
|
+
*
|
|
9
|
+
* dispose() deliberately does NOT catch errors — the best-effort try/catch
|
|
10
|
+
* for failRun() belongs at the call site, preserving the per-caller semantics.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
Workspace,
|
|
15
|
+
WorkspaceDisposeOutcome,
|
|
16
|
+
WorkspacePrepareContext,
|
|
17
|
+
WorkspaceProvider,
|
|
18
|
+
} from "#src/lifecycle/workspace";
|
|
19
|
+
|
|
20
|
+
/** Owns the child workspace lifecycle: prepare at run-start, dispose at run-end. */
|
|
21
|
+
export class WorkspaceBracket {
|
|
22
|
+
private prepared?: Workspace;
|
|
23
|
+
|
|
24
|
+
constructor(private readonly resolveProvider: () => WorkspaceProvider | undefined) {}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Returns true when a workspace provider is currently registered.
|
|
28
|
+
* Use to guard the `await prepare(...)` call and avoid an unnecessary
|
|
29
|
+
* microtask boundary in the no-provider path.
|
|
30
|
+
*/
|
|
31
|
+
hasProvider(): boolean {
|
|
32
|
+
return this.resolveProvider() !== undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the registered provider and prepare the child workspace.
|
|
37
|
+
* Returns the workspace's cwd, or undefined when no provider is registered
|
|
38
|
+
* or the provider resolves to undefined.
|
|
39
|
+
*/
|
|
40
|
+
async prepare(ctx: WorkspacePrepareContext): Promise<string | undefined> {
|
|
41
|
+
const provider = this.resolveProvider();
|
|
42
|
+
if (!provider) return undefined;
|
|
43
|
+
this.prepared = await provider.prepare(ctx);
|
|
44
|
+
return this.prepared?.cwd;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Dispose the prepared workspace (if any) and return the result addendum
|
|
49
|
+
* verbatim. Returns an empty string when no workspace was prepared or when
|
|
50
|
+
* the workspace returns no addendum.
|
|
51
|
+
*
|
|
52
|
+
* Throws propagate — wrap in try/catch at the call site when best-effort
|
|
53
|
+
* disposal is desired (e.g. failRun).
|
|
54
|
+
*/
|
|
55
|
+
dispose(outcome: WorkspaceDisposeOutcome): string {
|
|
56
|
+
if (!this.prepared) return "";
|
|
57
|
+
return this.prepared.dispose(outcome)?.resultAddendum ?? "";
|
|
58
|
+
}
|
|
59
|
+
}
|