@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.
@@ -168,16 +168,14 @@ export class SubagentManager {
168
168
  }
169
169
 
170
170
  if (options.isBackground && !options.bypassQueue) {
171
- // Schedule on the limiter — started when a slot frees. The status guard
172
- // makes an abort-while-queued task a no-op (Step 3 folds it into start()).
173
- record.promise = this.limiter.schedule(() => {
174
- if (record.status !== "queued") return Promise.resolve();
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.promise = record.run();
178
+ record.start();
181
179
  return id;
182
180
  }
183
181
 
@@ -1,21 +1,9 @@
1
1
  /**
2
- * subagent.ts — Subagent class with encapsulated status-transition logic and per-subagent behavior.
2
+ * subagent.ts — Subagent class: identity, lifecycle status, and per-subagent behavior.
3
3
  *
4
- * Status transitions (status, result, error, startedAt, completedAt) are owned
5
- * by the class and exposed via transition methods. External code reads these
6
- * fields through public properties but cannot write them directly.
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 { Workspace, WorkspaceProvider } from "#src/lifecycle/workspace";
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
- /** Promise for the full agent run (including post-processing). Set by run(). */
111
- promise?: Promise<void>;
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
- /** Workspace prepared at run-start by a provider — undefined when none is registered. */
116
- private _workspace?: Workspace;
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
- notification?: NotificationState;
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.notification = new NotificationState(toolCallId);
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
- try {
213
- // A registered workspace provider supplies the child's cwd and owns its
214
- // teardown; with no provider the child runs in the parent cwd.
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
- cwd = this._workspace?.cwd;
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.releaseListeners();
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.releaseListeners();
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.releaseListeners();
420
-
421
- let finalResult = result.responseText;
422
- if (this._workspace) {
423
- const finalStatus: SubagentStatus = result.aborted
424
- ? "aborted"
425
- : result.steered
426
- ? "steered"
427
- : "completed";
428
- const disposeResult = this._workspace.dispose({ status: finalStatus, description: this.description });
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.releaseListeners();
440
+ this.listeners.release();
448
441
 
449
442
  try {
450
- if (this._workspace) this._workspace.dispose({ status: "error", description: this.description });
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
+ }