@gotgenes/pi-subagents 16.2.0 → 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 CHANGED
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [16.2.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.2.0...pi-subagents-v16.2.1) (2026-06-15)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * restore at-spawn promise for queued subagents ([#374](https://github.com/gotgenes/pi-packages/issues/374)) ([4f08c6c](https://github.com/gotgenes/pi-packages/commit/4f08c6c37814b5386a23cd60479efc39c4b22612))
14
+
8
15
  ## [16.2.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.1.1...pi-subagents-v16.2.0) (2026-06-14)
9
16
 
10
17
 
@@ -130,9 +130,6 @@ classDiagram
130
130
  +completeRun(result)
131
131
  +failRun(err)
132
132
  +disposeSession()
133
- +wireSignal(signal, onAbort)
134
- +attachObserver(unsub)
135
- +releaseListeners()
136
133
  }
137
134
 
138
135
  class SubagentState {
@@ -309,8 +306,10 @@ src/
309
306
  │ ├── create-subagent-session.ts assembly factory: session creation, binding, tool filtering
310
307
  │ ├── subagent-session.ts born-complete child session: turn loop, steer, dispose
311
308
  │ ├── turn-limits.ts normalizeMaxTurns (turn-count policy)
312
- │ ├── subagent.ts owns full execution lifecycle (run, abort, steer, workspace)
309
+ │ ├── subagent.ts owns full execution lifecycle (run, abort, steer)
313
310
  │ ├── subagent-state.ts lifecycle status + metrics value object (transitions, accumulators)
311
+ │ ├── run-listeners.ts per-run observer-unsub and signal-detach handles
312
+ │ ├── workspace-bracket.ts child workspace prepare/dispose lifecycle
314
313
  │ ├── concurrency-limiter.ts background admission gate: schedules run thunks FIFO against the limit
315
314
  │ ├── parent-snapshot.ts immutable spawn-time parent state
316
315
  │ ├── child-lifecycle.ts child-execution lifecycle event publisher
@@ -644,17 +643,17 @@ That method — testability friction as a boundary probe, with its limits — is
644
643
 
645
644
  ### Health metrics
646
645
 
647
- | Metric | Value |
648
- | -------------------------- | --------------------------------- |
649
- | Health score | 78/100 (B) |
650
- | Total LOC | 7,778 (57 files) |
651
- | Dead code | 0 files, 0 exports |
652
- | Maintainability index | 90.8 (good) |
653
- | Avg cyclomatic complexity | 1.4 |
654
- | P90 cyclomatic complexity | 2 |
655
- | Production duplication | 11 lines (1 internal clone group) |
656
- | Test duplication | 42 clone groups, 661 lines |
657
- | Fallow refactoring targets | 0 |
646
+ | Metric | Value |
647
+ | -------------------------- | --------------------------------------- |
648
+ | Health score | 78/100 (B) |
649
+ | Total LOC | 8,356 (60 files, as of Phase 17 Step 4) |
650
+ | Dead code | 0 files, 0 exports |
651
+ | Maintainability index | 90.8 (good) |
652
+ | Avg cyclomatic complexity | 1.4 |
653
+ | P90 cyclomatic complexity | 2 |
654
+ | Production duplication | 11 lines (1 internal clone group) |
655
+ | Test duplication | 42 clone groups, 661 lines |
656
+ | Fallow refactoring targets | 0 |
658
657
 
659
658
  ### Dependency bag inventory
660
659
 
@@ -894,7 +893,7 @@ Updated health metrics (fallow, package-wide including tests):
894
893
  | Metric | Phase 16 baseline | Current |
895
894
  | -------------------------- | ------------------------------ | --------------------------------------------- |
896
895
  | Health score | 78/100 (B) | 78/100 (B) |
897
- | Source LOC | 7,778 (57 files) | ~7,400 (57 files) |
896
+ | Source LOC | 7,778 (57 files) | 8,356 (60 files, landed Phase 17 Step 4) |
898
897
  | Dead code | 0 files, 0 exports | 0 files, 0 exports |
899
898
  | Maintainability index | 90.8 (good) | 90.8 (good) |
900
899
  | Avg / P90 cyclomatic | 1.4 / 2 | 1.4 / 2 |
@@ -966,14 +965,21 @@ Priority = Impact × (6 − Risk).
966
965
  - Smell: Category C — output arguments: external writes to `record.promise` (2 production sites in `subagent-manager.ts`, 4 test sites) and `record.notification` (7 test sites; the production path was resolved in Step 2 — the constructor creates `notification` from `execution.parentSession?.toolCallId`, so Step 3's remaining work is making the field read-only and updating tests to supply it via `parentSession`).
967
966
  - Change: add `Subagent.start()` that runs and stores its own promise (plus an awaitable accessor for `spawnAndWait`/`waitForAll`); make `promise` and `notification` externally read-only (private `_promise`/`_notification` fields backed by public getters); the abort-while-queued status guard folds into `start()`, removing the inline check from the limiter callback; tests use `createTestSubagent({ toolCallId })` or spawn with `parentSession.toolCallId` instead of post-construction assignment.
968
967
  - Outcome: zero external writes to `Subagent` fields outside its own methods (grep-verifiable: `\.promise =` and `\.notification =` appear only inside `subagent.ts`); 6 new unit tests for `start()` behaviour; test count +6 (975 → 981).
969
- - Landed: `Subagent.start()` in `src/lifecycle/subagent.ts` owns the promise and status guard; `SubagentManager.spawn()` calls `record.start()` (scheduled or immediate); `TestSubagentOptions.toolCallId` wires notification state via the constructor path.
968
+ - Landed: `Subagent.start()` (immediate path) and `Subagent.scheduleVia(schedule)` (queued path) own the promise and the shared `guardedRun()` status guard; `SubagentManager.spawn()` calls one or the other; `TestSubagentOptions.toolCallId` wires notification state via the constructor path.
969
+ - Correction (post-merge): the first cut used `void this.limiter.schedule(() => record.start())`, which left a queued agent's `promise` unset until its slot opened — silently regressing Step 1's "every spawned agent has a `promise` at spawn" invariant.
970
+ Fixed by inverting control: `scheduleVia` captures the limiter promise eagerly inside the agent (no external `.promise =` write), restoring the invariant.
971
+ Lesson: a step's acceptance criteria must include the cross-step invariants it could regress, not only its own grep-verifiable outcome.
970
972
 
971
973
  #### Step 4 — Extract run-listener and workspace-bracket collaborators from Subagent ([#375])
972
974
 
973
975
  - Targets: `src/lifecycle/subagent.ts` (455 LOC after Step 2 extracted SubagentState — still the largest source file).
974
976
  - Smell: Category B (oversized class; per-run listener fields declared mid-class) and Category C (state owns its mutations: workspace dispose logic appears in `run()`'s catch, `completeRun`, and `failRun`).
975
- - Change: extract a `RunListeners` object owning the observer-unsubscribe and signal-detach handles (`attach`/`release`), and a workspace-bracket collaborator owning prepare/dispose-with-addendum, so the three dispose paths collapse into one.
977
+ - Change: extract a `RunListeners` object owning the observer-unsubscribe and signal-detach handles (`wireSignal`/`attachObserver`/`release`), and a `WorkspaceBracket` collaborator owning prepare/dispose-with-addendum, centralising the dispose logic.
976
978
  - Outcome: `subagent.ts` ≤ 450 LOC; workspace disposal logic in exactly one place; listener handles no longer raw nullable fields.
979
+ - Landed: `RunListeners` (`src/lifecycle/run-listeners.ts`) owns the signal-detach and observer-unsub handles with a single `release()` call; `WorkspaceBracket` (`src/lifecycle/workspace-bracket.ts`) owns prepare-at-run-start and dispose-with-addendum — `completeRun` and `failRun` call `workspaceBracket.dispose(outcome)` and receive the addendum string (or `""`) without reaching through to the workspace object directly.
980
+ `Subagent.wireSignal`, `attachObserver`, and `releaseListeners` are removed.
981
+ `subagent.ts`: 488 → 448 LOC.
982
+ Test count: 982 → 994 (+12: 7 RunListeners + 13 WorkspaceBracket − 8 redundant Subagent listener tests).
977
983
 
978
984
  #### Step 5 — Extract the manager observer from index.ts into a class ([#376])
979
985
 
@@ -0,0 +1,300 @@
1
+ ---
2
+ issue: 375
3
+ issue_title: "Extract run-listener and workspace-bracket collaborators from Subagent"
4
+ ---
5
+
6
+ # Extract run-listener and workspace-bracket collaborators from Subagent
7
+
8
+ ## Problem Statement
9
+
10
+ `subagent.ts` is the largest source file in `pi-subagents` and an accelerating churn hotspot.
11
+ Two concerns inflate the `Subagent` class beyond its core responsibility (own a child's execution lifecycle):
12
+
13
+ 1. The per-run listener handles (`_unsub`, `_detachFn`) are raw nullable fields declared mid-class, with `wireSignal`/`attachObserver`/`releaseListeners` scattered around them.
14
+ 2. Workspace teardown logic appears at two run-completion call sites — `completeRun` (success/abort/steer) and `failRun` (error) — each re-deriving the `if (this._workspace) … dispose() … resultAddendum` shape.
15
+
16
+ This is Phase 17 Step 4 (core consolidation).
17
+ The goal is to lift both concerns into small owned collaborators so the class shrinks and the dispose logic lives in one place.
18
+
19
+ ## Goals
20
+
21
+ - Extract a `RunListeners` collaborator that owns the observer-unsubscribe and signal-detach handles, so the two listener handles stop being raw nullable fields on `Subagent`.
22
+ - Extract a `WorkspaceBracket` collaborator that owns prepare-at-run-start and dispose-with-result-addendum, so the workspace-disposal *logic* lives in exactly one place.
23
+ - Bring `subagent.ts` to ≤ 450 LOC (currently 488).
24
+ - Preserve all observable run/resume behavior exactly: observer-callback order, listener release on complete/fail/resume, workspace dispose status mapping, addendum folding, and the distinct error-handling semantics of the two dispose call sites.
25
+
26
+ This change is **not breaking**: it is a pure internal restructuring with no change to observable behavior, output shape, public service surface, or defaults.
27
+ `RunListeners` and `WorkspaceBracket` are internal lifecycle collaborators, not part of the published `dist/public.d.ts` surface.
28
+
29
+ ## Non-Goals
30
+
31
+ - No change to `SubagentState`, `SubagentExecution`, the `WorkspaceProvider`/`Workspace` seam interfaces (`src/lifecycle/workspace.ts`), or `subagent-manager.ts` wiring.
32
+ - No change to the run/resume control flow, the abort path, the steer buffer, or the notification field.
33
+ - No unification of the two dispose call sites' *error-handling* semantics (see Design Overview — they are intentionally different lifecycle contexts).
34
+ - Phase 17 Steps 5–9 (manager observer extraction, widget delegation split, test-fixture consolidation, cross-package settings-loader duplication) are separate issues.
35
+
36
+ ## Background
37
+
38
+ Relevant modules:
39
+
40
+ - `src/lifecycle/subagent.ts` — the `Subagent` class.
41
+ After Phase 17 Step 2 ([#373]) extracted `SubagentState` and Step 3 ([#374]) encapsulated `start`/`promise`/`notification`, the remaining structural debt is the listener fields and the workspace dispose duplication.
42
+ - `src/lifecycle/workspace.ts` — the generative workspace seam (ADR 0002): `WorkspaceProvider.prepare(ctx)` returns a `Workspace` (a `cwd` plus a `dispose(outcome)` hook returning an optional `resultAddendum`).
43
+ - `src/lifecycle/subagent-manager.ts` — the only production constructor of `Subagent` (`spawn`); it resolves the registered provider lazily via `getWorkspaceProvider: () => this._workspaceProvider`.
44
+ It calls `record.disposeSession()` but none of the listener/workspace methods being moved.
45
+ - `test/lifecycle/subagent.test.ts` — directly tests `wireSignal`, `attachObserver`, `releaseListeners` (3 `describe` blocks) and the workspace dispose paths via `run()`.
46
+
47
+ Current listener fields and methods on `Subagent`:
48
+
49
+ ```typescript
50
+ private _unsub?: () => void;
51
+ private _detachFn?: () => void;
52
+ wireSignal(signal: AbortSignal | undefined, onAbort: () => void): void { … } // sets _detachFn
53
+ attachObserver(unsub: () => void): void { … } // sets _unsub
54
+ releaseListeners(): void { … } // clears both
55
+ ```
56
+
57
+ The two handles are attached at different moments: `wireSignal` at run start, `attachObserver` after the session is created; `resume()` only attaches the observer.
58
+ So a single combined `attach(unsub, detach)` (the issue's first-cut sketch) does not match the real call pattern — the collaborator must expose the two attach points separately.
59
+
60
+ Current workspace state and dispose call sites:
61
+
62
+ ```typescript
63
+ private _workspace?: Workspace;
64
+ // run(): const provider = this.execution.getWorkspaceProvider?.(); if (provider) { this._workspace = await provider.prepare({…}); cwd = this._workspace?.cwd; }
65
+ // completeRun(): if (this._workspace) { finalStatus = …; const r = this._workspace.dispose({status, description}); if (r?.resultAddendum) finalResult += r.resultAddendum; }
66
+ // failRun(): try { if (this._workspace) this._workspace.dispose({status:"error", description}); } catch (e) { debugLog(…); }
67
+ ```
68
+
69
+ Note: the issue's "three places" wording counts `run()`'s prepare-failure catch as a teardown path, but that catch has no prepared workspace to dispose — it only releases listeners and notifies.
70
+ The actual `dispose()` call appears at **two** sites (`completeRun`, `failRun`).
71
+
72
+ Constraint from AGENTS.md / code-design: business-logic modules stay SDK-free.
73
+ Both new collaborators use only globals (`AbortSignal`) and the local `workspace.ts` types — no Pi SDK imports.
74
+ Import siblings via `#src/...` aliases.
75
+
76
+ ### Invariants from prior Phase 17 steps (must not regress)
77
+
78
+ Per the [#374] retro lesson — a later phase step must not regress an earlier step's documented `Outcome:` with a green suite.
79
+ This step touches the `Subagent` run/resume surface, so the at-risk invariants are:
80
+
81
+ - **Step 1 ([#381]) — "every spawned agent has a `promise` at spawn."**
82
+ Pinned by the regression test in `test/lifecycle/subagent-manager.test.ts` (queued agent has a `promise` at spawn) and `scheduleVia` unit tests in `subagent.test.ts`.
83
+ This step does **not** touch `start`/`scheduleVia`/`guardedRun`/`_promise` — low risk, but the suite pins it.
84
+ - **Step 2 ([#373]) — "`Subagent` is construct-complete; no 'not configured for execution' throws."**
85
+ Pinned by the grep check (no "not configured for execution" in `subagent.ts`) and the constructor tests.
86
+ Both new collaborators are constructed **inside** the `Subagent` constructor (no new optional init fields), so construct-completeness is preserved.
87
+ - **Step 3 ([#374]) — "zero external writes to `Subagent` fields outside its own methods."**
88
+ Pinned by the grep check (`\.promise =` / `\.notification =` only in `subagent.ts`).
89
+ The removed `_unsub`/`_detachFn`/`_workspace` fields had no external writers; removing the public `wireSignal`/`attachObserver`/`releaseListeners` methods reduces the surface further.
90
+
91
+ ## Design Overview
92
+
93
+ Two small owned value objects, each owning state plus the behavior that reads/writes it (principle 9 — state and behavior in a class, not raw fields scattered through a host).
94
+ Neither is procedure-splitting: each owns mutable state and returns a value (`prepare` → cwd, `dispose` → addendum) or encapsulates lifecycle handles.
95
+
96
+ ### `RunListeners` — `src/lifecycle/run-listeners.ts`
97
+
98
+ Owns the two per-run teardown handles and the wire/release behavior.
99
+
100
+ ```typescript
101
+ export class RunListeners {
102
+ private unsub?: () => void;
103
+ private detach?: () => void;
104
+
105
+ /** Wire a parent AbortSignal so it stops the run when fired. No-op when no signal. */
106
+ wireSignal(signal: AbortSignal | undefined, onAbort: () => void): void {
107
+ if (!signal) return;
108
+ const listener = () => onAbort();
109
+ signal.addEventListener("abort", listener, { once: true });
110
+ this.detach = () => signal.removeEventListener("abort", listener);
111
+ }
112
+
113
+ /** Store the record-observer unsubscribe handle. */
114
+ attachObserver(unsub: () => void): void {
115
+ this.unsub = unsub;
116
+ }
117
+
118
+ /** Release the observer + signal handles. Idempotent. */
119
+ release(): void {
120
+ this.unsub?.();
121
+ this.unsub = undefined;
122
+ this.detach?.();
123
+ this.detach = undefined;
124
+ }
125
+ }
126
+ ```
127
+
128
+ ### `WorkspaceBracket` — `src/lifecycle/workspace-bracket.ts`
129
+
130
+ Owns the prepared `Workspace` and the prepare/dispose logic.
131
+ It captures the provider *resolver* (not the provider) so resolution stays at run-start, matching today's `getWorkspaceProvider?.()` timing.
132
+
133
+ ```typescript
134
+ import type {
135
+ Workspace,
136
+ WorkspaceDisposeOutcome,
137
+ WorkspacePrepareContext,
138
+ WorkspaceProvider,
139
+ } from "#src/lifecycle/workspace";
140
+
141
+ export class WorkspaceBracket {
142
+ private prepared?: Workspace;
143
+
144
+ constructor(private readonly resolveProvider: () => WorkspaceProvider | undefined) {}
145
+
146
+ /** Resolve the registered provider and prepare the child workspace; returns its cwd (undefined when none). */
147
+ async prepare(ctx: WorkspacePrepareContext): Promise<string | undefined> {
148
+ const provider = this.resolveProvider();
149
+ if (!provider) return undefined;
150
+ this.prepared = await provider.prepare(ctx);
151
+ return this.prepared?.cwd;
152
+ }
153
+
154
+ /** Dispose the prepared workspace (if any); returns the result addendum verbatim ("" when none). */
155
+ dispose(outcome: WorkspaceDisposeOutcome): string {
156
+ if (!this.prepared) return "";
157
+ return this.prepared.dispose(outcome)?.resultAddendum ?? "";
158
+ }
159
+ }
160
+ ```
161
+
162
+ ### `Subagent` interaction with the collaborators
163
+
164
+ Constructed in the `Subagent` constructor (construct-complete):
165
+
166
+ ```typescript
167
+ private readonly listeners = new RunListeners();
168
+ private readonly workspaceBracket: WorkspaceBracket;
169
+ // in constructor:
170
+ this.workspaceBracket = new WorkspaceBracket(this.execution.getWorkspaceProvider ?? (() => undefined));
171
+ ```
172
+
173
+ Call sites in `run()` / `resume()` / `completeRun()` / `failRun()`:
174
+
175
+ ```typescript
176
+ // run() start:
177
+ this.listeners.wireSignal(this.execution.signal, () => this.abort());
178
+ const cwd = await this.workspaceBracket.prepare({ agentId: this.id, agentType: this.type, baseCwd: this.execution.baseCwd, invocation: this.invocation });
179
+ // run() after session created:
180
+ this.listeners.attachObserver(subscribeSubagentObserver(this.subagentSession, this.state, { onCompact: … }));
181
+
182
+ // completeRun():
183
+ const finalStatus: SubagentStatus = result.aborted ? "aborted" : result.steered ? "steered" : "completed";
184
+ let finalResult = result.responseText + this.workspaceBracket.dispose({ status: finalStatus, description: this.description });
185
+ // failRun():
186
+ try { this.workspaceBracket.dispose({ status: "error", description: this.description }); }
187
+ catch (cleanupErr) { debugLog("workspace dispose on agent error", cleanupErr); }
188
+ ```
189
+
190
+ This follows Tell-Don't-Ask: callers tell the bracket to `dispose(outcome)` and receive the addendum string; they do not reach through to `workspace.dispose(…).resultAddendum` (the prior Law-of-Demeter reach-through is absorbed into the bracket).
191
+
192
+ ### Why the two dispose call sites stay separate (structural-duplication check)
193
+
194
+ The issue asks to "collapse the three dispose paths into one."
195
+ Tracing why the two `dispose()` calls differ (per the code-design "structural reasons before extracting duplication" heuristic) shows they are **different lifecycle contexts**, not incidental duplication:
196
+
197
+ | Call site | Status source | Addendum | Error handling |
198
+ | ------------- | ------------------------------------------------- | --------------------------- | ----------------------------------------------------------------- |
199
+ | `completeRun` | derived from `result` (completed/aborted/steered) | folded into the result text | propagates (a dispose throw flows to `run()`'s catch → `failRun`) |
200
+ | `failRun` | hardcoded `"error"` | discarded | best-effort `try/catch` + `debugLog` |
201
+
202
+ So the resolution is: the dispose **logic** (the `if (prepared)` guard, the `.dispose()` call, the addendum unwrap) centralizes into `WorkspaceBracket.dispose()` — exactly one place, satisfying the issue's "disposal logic in exactly one place" outcome.
203
+ The two callers retain their distinct status derivation and error handling, because forcing them into one call would require a discriminator parameter that papers over a real lifecycle difference (Sandi Metz: "duplication is cheaper than the wrong abstraction").
204
+ `WorkspaceBracket.dispose()` deliberately does **not** wrap in `try/catch` — the best-effort behavior stays at `failRun`'s call site, preserving the existing semantics line-for-line (including the pre-existing success-path-throw → `failRun` re-dispose behavior).
205
+
206
+ ## Module-Level Changes
207
+
208
+ - **Added** `src/lifecycle/run-listeners.ts` — `RunListeners` class (`wireSignal`, `attachObserver`, `release`).
209
+ - **Added** `src/lifecycle/workspace-bracket.ts` — `WorkspaceBracket` class (`prepare`, `dispose`).
210
+ - **Added** `test/lifecycle/run-listeners.test.ts` — direct unit tests for `RunListeners`.
211
+ - **Added** `test/lifecycle/workspace-bracket.test.ts` — direct unit tests for `WorkspaceBracket`.
212
+ - **Changed** `src/lifecycle/subagent.ts`:
213
+ - Remove fields `_unsub`, `_detachFn`, `_workspace` and the public methods `wireSignal`, `attachObserver`, `releaseListeners`.
214
+ - Add `private readonly listeners = new RunListeners()` and `private readonly workspaceBracket: WorkspaceBracket` (constructed from `execution.getWorkspaceProvider`).
215
+ - Rewire `run()`, `resume()`, `resetForResume()`, `completeRun()`, `failRun()` to call `this.listeners.*` and `this.workspaceBracket.*`.
216
+ - Add imports for the two new modules; drop the now-unused `Workspace` type import if no longer referenced (keep `WorkspaceProvider` only if still referenced — verify with the type checker).
217
+ - **Changed** `test/lifecycle/subagent.test.ts`:
218
+ - Remove the `wireSignal`, `attachObserver / releaseListeners`, and `resetForResume releases listeners` `describe` blocks (their coverage moves to `run-listeners.test.ts`); the `run()`/`completeRun`/`failRun`/`resume` behavioral tests stay and continue to exercise the wired collaborators.
219
+ - Remove the `attachObserver(unsub)` calls inside the `completeRun`/`failRun` "releases listeners" tests — assert listener release via the run/resume path instead, or via a `RunListeners` unit test.
220
+
221
+ Doc updates (architecture references the moved symbols and module count):
222
+
223
+ - **Changed** `docs/architecture/architecture.md`:
224
+ - File-tree listing (`lifecycle/` block) — add `run-listeners.ts` and `workspace-bracket.ts` entries.
225
+ - `Subagent` class diagram (key domain types) — remove `+wireSignal`, `+attachObserver`, `+releaseListeners`; optionally add `RunListeners` / `WorkspaceBracket` classes with composition edges.
226
+ - Findings/health tables — bump `57 files` → `59 files` (lines ~650 and ~897).
227
+ - Phase 17 Step 4 entry — append a `Landed:` note recording the two collaborators and the final `subagent.ts` LOC.
228
+ - **Changed** `.pi/skills/package-pi-subagents/SKILL.md` — "seven domains (57 files)" → "(59 files)"; Lifecycle domain module count `11` → `13` and add the two modules to the directory list; update the test count if it is cited.
229
+
230
+ Grep confirmation done while planning: `wireSignal`/`attachObserver`/`releaseListeners`/`_workspace`/`_unsub`/`_detachFn` appear only in `subagent.ts` and `subagent.test.ts` (plus the architecture class diagram); the SKILL does not name them.
231
+ `disposeSession` (the one listener-adjacent method used by `subagent-manager.ts`) is **not** part of this change.
232
+
233
+ ## Test Impact Analysis
234
+
235
+ 1. **New unit tests the extraction enables.**
236
+ `RunListeners` and `WorkspaceBracket` become directly constructible and testable without booting a `Subagent` or a full `run()`:
237
+ - `run-listeners.test.ts` — `wireSignal` attaches and `release()` detaches the abort listener; `wireSignal(undefined, …)` is a no-op; `attachObserver` + `release()` calls and clears the unsub; `release()` is idempotent (double-call safe).
238
+ - `workspace-bracket.test.ts` — `prepare` with no provider returns `undefined`; with a provider returns `workspace.cwd`; with a provider resolving `undefined` returns `undefined`; `dispose` with no prepared workspace returns `""`; `dispose` returns the `resultAddendum` verbatim; `dispose` returns `""` when the workspace returns no addendum; a throwing `dispose` propagates (not swallowed).
239
+ 2. **Existing tests that become redundant.**
240
+ The `wireSignal`, `attachObserver / releaseListeners`, and `resetForResume releases listeners` `describe` blocks in `subagent.test.ts` (~7 tests) duplicate what `run-listeners.test.ts` now covers at a lower level — remove them.
241
+ 3. **Existing tests that must stay as-is.**
242
+ The `run()` workspace tests (prepare threads cwd into the factory, dispose status mapping for completed/error, addendum folding, no-provider path, prepare-failure path) genuinely exercise the *integration* of the bracket into the run lifecycle — they stay and verify the wiring preserved behavior.
243
+ The `completeRun`/`failRun`/`resume`/`abort` behavioral tests stay.
244
+
245
+ ## Invariants at risk
246
+
247
+ | Invariant (prior step) | Pinning test | Risk from this step |
248
+ | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
249
+ | Every spawned agent has a `promise` at spawn ([#381]) | `subagent-manager.test.ts` queued-agent regression test; `scheduleVia` unit tests | None — `start`/`scheduleVia`/`_promise` untouched |
250
+ | `Subagent` construct-complete; no "not configured for execution" throws ([#373]) | `subagent.test.ts` constructor tests + grep check | None — both collaborators constructed in the constructor, no new optional init fields |
251
+ | Zero external writes to `Subagent` fields ([#374]) | grep check (`\.promise =` / `\.notification =` only in `subagent.ts`) | None — removes fields/methods, adds no external writers |
252
+ | Listener release on complete/fail/resume; observer-callback order; dispose status mapping + addendum folding | `subagent.test.ts` `run()`/`completeRun`/`failRun`/`resume` blocks | Preserved by keeping the call sequence identical; run-suite pins it |
253
+
254
+ No invariant lives only in prose here — each is already pinned by a named test, and the run-lifecycle tests stay green through the wiring step.
255
+
256
+ ## TDD Order
257
+
258
+ 1. **Add `RunListeners` (red → green → commit).**
259
+ Write `test/lifecycle/run-listeners.test.ts` (fails — module absent), then implement `src/lifecycle/run-listeners.ts`.
260
+ Pure addition; `Subagent` not yet touched, suite otherwise unchanged.
261
+ Commit: `test: add RunListeners with wire/attach/release behavior (#375)` (or a single `refactor:` if test+impl land together — prefer the red/green split).
262
+ 2. **Add `WorkspaceBracket` (red → green → commit).**
263
+ Write `test/lifecycle/workspace-bracket.test.ts` (fails — module absent), then implement `src/lifecycle/workspace-bracket.ts`.
264
+ Pure addition.
265
+ Commit: `test: add WorkspaceBracket prepare/dispose behavior (#375)` then `refactor: add WorkspaceBracket collaborator (#375)` — or one `refactor:` commit for the red/green pair.
266
+ 3. **Wire both into `Subagent`; remove the raw fields/methods (atomic).**
267
+ Rewire `run`/`resume`/`resetForResume`/`completeRun`/`failRun` to the collaborators; remove `_unsub`/`_detachFn`/`_workspace` and the public `wireSignal`/`attachObserver`/`releaseListeners`; construct `listeners` and `workspaceBracket` in the constructor.
268
+ In the **same commit**, update `test/lifecycle/subagent.test.ts`: removing the three public methods breaks that file at the type level, so the deletion of the redundant `describe` blocks and the construction/wiring change must land together.
269
+ Run `pnpm run check` immediately after (interface-shape change).
270
+ Commit: `refactor: extract RunListeners and WorkspaceBracket from Subagent (#375)`.
271
+ 4. **Update docs (commit).**
272
+ `architecture.md` (file tree, class diagram, `57 → 59` file counts, Step 4 `Landed:` note with final LOC) and `package-pi-subagents` SKILL.md (file count, Lifecycle module list/count, test count if cited).
273
+ Commit: `docs: record run-listener and workspace-bracket extraction (#375)`.
274
+
275
+ Verification gates after step 3 and before review: `pnpm run check`, `pnpm run lint`, `pnpm -r run test`, `pnpm fallow dead-code` (confirm no orphaned imports in `subagent.test.ts` after the `describe` removals), and `wc -l src/lifecycle/subagent.ts` (assert ≤ 450).
276
+
277
+ ## Risks and Mitigations
278
+
279
+ - **Risk: the wiring step silently changes dispose error semantics.**
280
+ Mitigation: `WorkspaceBracket.dispose()` deliberately does not `try/catch`; the best-effort wrapper stays at `failRun`'s call site, preserving the success-path-throw → `failRun` behavior line-for-line.
281
+ A `workspace-bracket.test.ts` case asserts a throwing `dispose` propagates.
282
+ - **Risk: `subagent.ts` does not reach ≤ 450 LOC.**
283
+ Mitigation: removing 3 fields + 3 methods (≈ 25 lines incl. comments) and simplifying the two dispose blocks nets ≈ 40 lines below the current 488; `wc -l` is a gate.
284
+ If it lands at 451–455, that is an acceptable near-miss to note, not a blocker.
285
+ - **Risk: orphaned imports in `subagent.test.ts` after removing `describe` blocks.**
286
+ Mitigation: Biome `noUnusedImports` is warning-level (exit 0), so run `pnpm fallow dead-code` and re-check imports manually as part of step 3.
287
+ - **Risk: release cadence.**
288
+ This issue is internal-only; with `refactor:`/`docs:`/`test:` commits it will not trigger a release-please version bump — it ships bundled with the next feature release.
289
+ If a standalone release is desired (matching prior Phase 17 per-step cadence), that is a ship-time decision, not a planning one.
290
+
291
+ ## Open Questions
292
+
293
+ - Whether to add `RunListeners` / `WorkspaceBracket` as classes with composition edges to the architecture class diagram, or only update the file tree.
294
+ Defer to the doc step — add them if the diagram stays readable.
295
+ - The pre-existing untested success-path-`dispose`-throw → `failRun` re-dispose behavior is preserved but remains unpinned by a `Subagent`-level test.
296
+ Defer pinning it unless the implementer finds it cheap to add — the extraction does not change it.
297
+
298
+ [#373]: https://github.com/gotgenes/pi-packages/issues/373
299
+ [#374]: https://github.com/gotgenes/pi-packages/issues/374
300
+ [#381]: https://github.com/gotgenes/pi-packages/issues/381
@@ -36,3 +36,136 @@ Pre-completion reviewer returned PASS with one WARN (stale test count in `packag
36
36
  - The "waits for promise when wait=true" test in `get-result-tool.test.ts` required `void record.start()` (intentional fire-and-forget) for the same reason.
37
37
  - Grep-verifiable outcome confirmed: `\.promise =` and `\.notification =` appear only inside `subagent.ts` (as `this._promise =` and `this._notification =`).
38
38
  - Pre-completion reviewer: PASS (no FAIL findings; WARN on stale skill test count addressed inline).
39
+
40
+ ## Stage: Final Retrospective (2026-06-14T17:30:00Z)
41
+
42
+ ### Session summary
43
+
44
+ Shipped issue #374 (Phase 17 Step 3) cleanly across three stages: a planning session produced a 4-step plan, a TDD session implemented it in 2 substantive commits (test count 975 → 981), and a ship session pushed, verified CI, closed the issue, and released `pi-subagents-v16.2.0`.
45
+ No rework, no user corrections, no CI failures — the only mid-stream fixes were two self-identified lint adjustments inside the TDD session.
46
+
47
+ ### Observations
48
+
49
+ #### What went well
50
+
51
+ - The cross-session retro bridge worked exactly as designed: the planning stage wrote three concrete breadcrumbs — steps 1+2 must merge (TypeScript duplicate-identifier), the `get-result-tool.test.ts` "waits for promise" test needs a realistic `runTurnLoop` stub, and `TestSubagentOptions.toolCallId` is the cleanest notification shorthand — and the TDD stage consumed all three directly without re-deriving them.
52
+ This is the first time the breadcrumb-to-implementation handoff is visibly load-bearing rather than ceremonial.
53
+ - The grep-verifiable completion criterion (`\.promise =` and `\.notification =` appear only inside `subagent.ts`) gave an objective, one-command done-check (session turns 123–124) instead of a subjective "looks encapsulated."
54
+
55
+ #### What caused friction (agent side)
56
+
57
+ - `missing-context` — the plan did not anticipate that replacing `record.promise = record.run()` (assignment consumes the promise) with a bare `record.start()` call would trip `@typescript-eslint/no-floating-promises` at the two manager call sites and one test site.
58
+ Self-identified via lint.
59
+ Impact: two extra `void` edits (turns 106, 111) inside the same commit — no commit reorder, no rework beyond the fixups.
60
+ - `wrong-abstraction` (surfaced by operator pushback during the retro) — the `void this.limiter.schedule(() => record.start())` fix is safe but marks an unfinished design seam, not a tidy idiom.
61
+ `run()`/`start()` carry a hard "always resolves, errors captured internally" contract, so `void` is the ESLint-sanctioned annotation (no unhandled-rejection risk).
62
+ But the encapsulation regressed the promise-capture timing: before #374, `record.promise = this.limiter.schedule(...)` set the handle eagerly at spawn (even queued agents had `.promise`); after #374, `.promise` stays undefined until the limiter fires the thunk and `start()` runs, so promise ownership is now split between `Subagent._promise` and the limiter's internal completion handle (which the `void` discards).
63
+ `waitForAll()` still worked only via `pendingPromises()`'s `null`-filter plus re-poll loop, so the in-code comment "a single `allSettled` covers the queued case" became inaccurate.
64
+ This regressed Step 1's (#381) documented invariant "every spawned agent has a `promise` at spawn" — a cross-step regression within the same phase, not (as first claimed in this retro) work deferred to a future domain split.
65
+ Corrected in a follow-up `fix:` (see the second retrospective stage below): `Subagent.scheduleVia(schedule)` captures the limiter promise eagerly inside the agent, restoring the invariant without reintroducing an external `.promise =` write.
66
+ Impact: one follow-up `fix:` commit (+1 regression test, +2 `scheduleVia` unit tests).
67
+ - `wrong-abstraction` — the plan decomposed the work into 3 TDD steps (promise read-only, notification read-only, doc) but steps 1–3 collapsed into a single atomic commit because both fields live in `subagent.ts` and making each read-only is one type-level change.
68
+ The planning retro half-anticipated this (it flagged steps 1+2 merging) but did not extend the reasoning to step 3.
69
+ Impact: plan/reality granularity mismatch, documented as a deviation; no rework.
70
+
71
+ #### What caused friction (user side)
72
+
73
+ - None.
74
+ User involvement was a single well-placed strategic decision (the batch-vs-release-now `ask_user` gate in the ship stage), not mechanical oversight.
75
+
76
+ ### Diagnostic details
77
+
78
+ - **Model-performance correlation** — Planning and TDD ran on `claude-sonnet-4-6` (appropriate); the pre-completion reviewer subagent ran on its frontmatter default; the final retro ran on `claude-opus-4-8` (appropriate for judgment-heavy synthesis).
79
+ The **ship stage ran on `opencode-go/deepseek-v4-flash`** — a weak model driving release management (interpreting the `UNSTABLE` merge state, the batch-vs-release decision, merging the release PR, closing the issue).
80
+ It executed cleanly this time, but these are irreversible high-stakes operations; pinning a stronger model for `/ship-issue` would de-risk them.
81
+ - **Escalation-delay tracking** — no rabbit-holes; each lint failure resolved within 1–2 tool calls.
82
+ - **Unused-tool detection** — exploration used `cat`/`grep` via `bash` rather than the `Read` tool or `colgrep`; for finding exact `.promise =` write sites, grep is the correct choice (exact-pattern matching), so colgrep non-use was appropriate.
83
+ The `cat`-via-`bash` habit (vs `Read`) added no harm here but bypasses structured-read benefits.
84
+ - **Feedback-loop gap analysis** — verification was incremental: affected-file tests after the first edits (turn 81), full suite + `check` + `lint` mid-cycle (turns 102–113), and the `fallow dead-code` gate before review.
85
+ No end-only-verification gap.
86
+
87
+ ### Follow-ups
88
+
89
+ 1. The `start()` / limiter promise-ownership split was reclassified as a regression and **fixed** via `scheduleVia` (see the second retrospective stage), not deferred.
90
+
91
+ ### Considered but not proposed
92
+
93
+ 1. **Floating-promise ESLint rule** (proposed, then retracted on operator pushback): codifying "replace the assignment with `void record.start()`" as a `code-design` idiom would train reflexive lint suppression without checking the always-resolves contract.
94
+ The `void` is correct here but signals unfinished domain work; the lesson lives in this retro, not in the skill.
95
+ 2. **Pin a stronger model for `/ship-issue`**: an operator model-selection choice, not an `AGENTS.md`/prompt rule; noted in Diagnostic details for awareness.
96
+
97
+ ### Changes made
98
+
99
+ 1. `packages/pi-subagents/docs/retro/0374-encapsulate-subagent-start-notification.md` — added the Final Retrospective stage entry (session summary, friction points, diagnostic lenses, follow-ups); no skill or prompt edits landed (the sole proposal was retracted on operator pushback).
100
+
101
+ ## Stage: Regression Correction — Process Retrospective (2026-06-14T18:00:00Z)
102
+
103
+ ### Session summary
104
+
105
+ Operator pushback on the first retro's "`void` is safe, defer it" framing surfaced that #374 had **regressed a sibling step's invariant**: Step 1 (#381) guaranteed "every spawned agent has a `promise` at spawn," and #374's `void limiter.schedule(() => record.start())` made a queued agent's promise lazy.
106
+ Fixed via `Subagent.scheduleVia` (eager capture, control inverted so no external `.promise =` write returns) in commit `4f08c6c3` (+1 regression test, +2 unit tests; suite 981 → 982), then ran this process retrospective on how the regression slipped through plan, implementation, and review.
107
+
108
+ ### How the regression happened (root-cause chain)
109
+
110
+ 1. **Planning blind spot.**
111
+ The #374 plan's acceptance criterion was grep-verifiable encapsulation (`\.promise =` only in `subagent.ts`).
112
+ That measured the *goal* (hide the field) but never the *invariant at risk* (the field is an awaitable handle with an at-spawn timing contract that #381 established).
113
+ The plan treated `promise` as a field to hide, not as a contract to preserve.
114
+ 2. **Implementation masked the semantics.**
115
+ Converting `record.promise = limiter.schedule(...)` to a bare `limiter.schedule(() => record.start())` tripped `no-floating-promises`; the reflexive `void` fix silenced the lint *and* discarded the eager handle in the same stroke.
116
+ The lint fix was the exact site of the behavior change, which made it feel mechanical rather than semantic.
117
+ 3. **No executable guard.**
118
+ The #381 invariant lived only in an architecture-doc "Outcome:" bullet (prose).
119
+ No test pinned "a queued agent has a `promise` at spawn," so the full suite stayed green through the regression.
120
+ 4. **Review inherited the blind spot.**
121
+ The pre-completion reviewer checks deterministic gates + the plan's acceptance criteria; since the criteria never named the cross-step invariant, criteria-driven review could not flag its loss.
122
+
123
+ The through-line: in a phased refactor, each step's "Outcome:" bullets establish invariants later steps inherit implicitly, and nothing converts those prose invariants into executable guards — so a later step regresses an earlier one with a green suite and a passing review.
124
+
125
+ ### Observations
126
+
127
+ #### What caused friction (agent side)
128
+
129
+ - `missing-context` — the plan did not enumerate the invariants that prior Phase 17 steps had established on the shared `Subagent`/limiter surface, so the at-spawn-promise contract was invisible during both planning and implementation.
130
+ Impact: a shipped regression (latent — `waitForAll` re-polls — but a real invariant break), caught only by operator pushback during the retro, requiring a follow-up `fix:`.
131
+ - `wrong-abstraction` — the proximate trigger was treating a `void` lint fix as mechanical.
132
+ `void` on a promise-returning call discards whatever the promise carried (here: the eager capture handle); it deserves a semantic check, not a reflex.
133
+
134
+ #### What went well
135
+
136
+ - Operator pushback ("I'm sure that rule exists for a reason… are we heading toward a better design, or an awkward intermediary state?") was the single intervention that converted a rationalized smell into a found regression.
137
+ This is the bidirectional-feedback ideal: a redirecting question, not a correction, that reframed the agent's own analysis.
138
+
139
+ ### Diagnostic details
140
+
141
+ - **Feedback-loop gap analysis** — every gate (check, lint, test, fallow, pre-completion review) was green across #374; none could see the regression because the invariant was prose, never a test.
142
+ The gap is upstream of the gates: the invariant was never made executable.
143
+
144
+ ### Diagnostic details — model assignment
145
+
146
+ - Operator pushback also corrected a misconception: planning was assumed to run on Opus, but session turns 2–45 (`/plan-issue`) ran on `claude-sonnet-4-6`, and `.pi/prompts/plan-issue.md` had **no `model:` directive** — so planning silently inherited the session model.
147
+ The judgment-heaviest, highest-leverage stage (where this regression originated) was running on an inherited, weaker model by default.
148
+ Resolved by pinning `/plan-issue` and `/retro` to Opus via frontmatter (the `pi-prompt-template-model` extension was already loaded but unused for model selection).
149
+ Caveat recorded: a stronger planner raises the odds of noticing an unstated invariant but is not a substitute for the explicit rule — the rule is the dependable fix, the model is a complementary lever.
150
+
151
+ ### Proposals (all accepted and implemented)
152
+
153
+ 1. `/plan-issue` prompt — "Invariants at risk" plan section: list prior phase steps' documented invariants (roadmap `Outcome:`/`Landed:` bullets) and pin each with a named test.
154
+ 2. `code-design` skill (ESLint section) — "void on a promise-returning call" guard: before `void`-ing to silence `no-floating-promises`, confirm the discarded promise carried no semantics.
155
+ 3. `pre-completion-reviewer` agent — new section `2h. Cross-step invariant preservation`: FAIL on a regressed prior-step invariant, WARN when an invariant holds but is pinned only by prose.
156
+ 4. Model pinning — `/plan-issue` and `/retro` pinned to `anthropic/claude-opus-4-8`.
157
+
158
+ ### Changes made
159
+
160
+ 1. `packages/pi-subagents/src/lifecycle/subagent.ts` — added `scheduleVia(schedule)` (eager limiter-promise capture) and `guardedRun()` (shared abort-while-queued guard); `start()` now returns `void`.
161
+ 2. `packages/pi-subagents/src/lifecycle/subagent-manager.ts` — `spawn()` queued path uses `record.scheduleVia(...)`; removed the `void` workarounds.
162
+ 3. `packages/pi-subagents/test/lifecycle/subagent-manager.test.ts` — added regression test (queued agent has a `promise` at spawn).
163
+ 4. `packages/pi-subagents/test/lifecycle/subagent.test.ts` — rewrote `start()` tests for the `void` return; added two `scheduleVia` unit tests.
164
+ 5. `packages/pi-subagents/test/helpers/make-subagent.test.ts`, `packages/pi-subagents/test/tools/get-result-tool.test.ts` — updated for the `void`-returning `start()`.
165
+ 6. `packages/pi-subagents/docs/architecture/architecture.md` — Step 3 `Landed`/`Correction` notes record the regression and `scheduleVia` fix.
166
+ 7. `.pi/prompts/plan-issue.md` — added the "Invariants at risk" section and pinned `model: anthropic/claude-opus-4-8`.
167
+ 8. `.pi/prompts/retro.md` — pinned `model: anthropic/claude-opus-4-8`.
168
+ 9. `.pi/skills/code-design/SKILL.md` — added the "void on a promise-returning call" ESLint guard.
169
+ 10. `.pi/agents/pre-completion-reviewer.md` — added section `2h`, its output block, and severity-model entries.
170
+ 11. `AGENTS.md` — added cross-step invariant preservation to the reviewer's documented coverage.
171
+ 12. The regression fix landed in commit `4f08c6c3` (`fix:`); these retro/process changes land in the `docs(retro):` commit.
@@ -0,0 +1,51 @@
1
+ ---
2
+ issue: 375
3
+ issue_title: "Extract run-listener and workspace-bracket collaborators from Subagent"
4
+ ---
5
+
6
+ # Retro: #375 — Extract run-listener and workspace-bracket collaborators from Subagent
7
+
8
+ ## Stage: Planning (2026-06-14T19:25:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Read issue #375 (Phase 17 Step 4 — core consolidation), loaded the package, code-design, design-review, testing, colgrep, and markdown skills, and explored `subagent.ts`, `workspace.ts`, `subagent-manager.ts`, and `subagent.test.ts`.
13
+ Produced a 4-step plan in `packages/pi-subagents/docs/plans/0375-extract-run-listener-workspace-bracket.md` extracting a `RunListeners` collaborator and a `WorkspaceBracket` collaborator out of the 488-LOC `Subagent` class.
14
+
15
+ ### Observations
16
+
17
+ - The issue's first-cut `attach(unsub, detach)` sketch does not match the real call pattern: `wireSignal` fires at run-start and `attachObserver` after session creation, and `resume()` only attaches the observer — so `RunListeners` exposes the two attach points separately (`wireSignal` / `attachObserver` / `release`), not a single combined `attach`.
18
+ - The issue's "three dispose paths" is really **two** `dispose()` call sites (`completeRun`, `failRun`); `run()`'s prepare-failure catch has no prepared workspace to dispose.
19
+ - The two dispose sites have genuinely different lifecycle semantics — `completeRun` derives status from the result, folds the addendum, and lets a throw propagate; `failRun` hardcodes `"error"`, discards the addendum, and is best-effort `try/catch`.
20
+ Per the code-design structural-duplication heuristic, I kept them separate: `WorkspaceBracket.dispose()` centralizes the *logic* (the `if (prepared)` guard + addendum unwrap) in one place but deliberately does **not** wrap `try/catch`, so each caller's error handling is preserved line-for-line.
21
+ This honestly satisfies the issue's "disposal logic in exactly one place" without forcing a discriminator parameter.
22
+ - `WorkspaceBracket` captures the provider *resolver* (`execution.getWorkspaceProvider`), not the provider, so resolution stays at run-start — matching today's `getWorkspaceProvider?.()` timing — while letting the bracket be constructed in the `Subagent` constructor (construct-complete, preserving the Step 2 invariant).
23
+ - Per the #374 retro lesson, I added an "Invariants at risk" section: the three prior Phase 17 invariants (at-spawn `promise`, construct-complete, zero external field writes) are each already pinned by a named test and are low-risk here because this step does not touch `start`/`scheduleVia`/`_promise` or add optional init fields.
24
+ - Step 3 (wiring) must be atomic: removing the public `wireSignal`/`attachObserver`/`releaseListeners` methods breaks `subagent.test.ts` at the type level, so the `describe`-block deletions land in the same commit.
25
+ - Suite is at 982 tests (verified by running the suite); expect roughly +5 net (≈ −7 redundant `Subagent` listener tests, +6 each new collaborator suite).
26
+ - First-party issue (author `gotgenes` == gh user) with an unambiguous proposed change, so the `ask-user` gate was skipped.
27
+ - Commit types are `test:`/`refactor:`/`docs:` — internal-only, no release-please bump; release cadence is a ship-time decision flagged in Risks.
28
+
29
+ ## Stage: Implementation — TDD (2026-06-14T21:40:00Z)
30
+
31
+ ### Session summary
32
+
33
+ Implemented all 4 plan steps in 4 commits (2 `refactor:`, 1 additional `refactor:` for the wiring step, 1 `docs:`).
34
+ Test count went from 982 to 994 (+12: 7 `RunListeners` tests + 13 `WorkspaceBracket` tests − 8 redundant `Subagent` listener tests removed).
35
+ `subagent.ts` landed at 448 LOC (target was ≤ 450).
36
+ Pre-completion reviewer returned WARN (2 non-blocking findings; the doc metric rows were fixed inline before committing).
37
+
38
+ ### Observations
39
+
40
+ - **Microtask-boundary deviation from plan**: the plan showed `const cwd = await this.workspaceBracket.prepare(...)` unconditionally in `run()`.
41
+ `async` functions always create a microtask boundary even when they return immediately (no-provider path), which broke `subagent-manager.test.ts`'s synchronous assertion that `factory.toHaveBeenCalledOnce()` — the factory call had been deferred to the next microtask tick.
42
+ Fix: added `WorkspaceBracket.hasProvider()` (a synchronous provider-existence check) and guarded the `await` with `if (this.workspaceBracket.hasProvider())`, restoring the original timing semantics.
43
+ The `hasProvider()` method is a mild Tell-Don't-Ask trade-off (the caller queries bracket state to decide whether to call it), documented at the call site and noted as a WARN by the pre-completion reviewer.
44
+ The underlying cause: `SubagentManager.spawn()` always injects `getWorkspaceProvider: () => this._workspaceProvider` as a function, even when no provider is registered, so the naïve `if (this.execution.getWorkspaceProvider)` guard was always true.
45
+ - **Step 3 collapsed into one commit as expected**: removing `wireSignal`/`attachObserver`/`releaseListeners` from `Subagent` broke `subagent.test.ts` at the type level; the redundant `describe` block deletions and the production wiring landed atomically.
46
+ - **LOC target met**: `subagent.ts` went from 488 → 448 (plan estimated ≤ 450; actual 448).
47
+ The gap between estimated removal (≈ 40 lines) and actual (40 lines) was closed by trimming the stale module-level doc comment and redundant field-level comments.
48
+ - **Prior-step invariants held**: all three Phase 17 cross-step invariants (at-spawn `promise`, construct-complete, zero external field writes) passed grep-verification and the 994-test suite confirms no regressions.
49
+ - **Pre-completion reviewer WARN findings** (both addressed inline):
50
+ 1. `architecture.md` health-metric rows still carried "→ 59 after Step 4" annotations after landing — updated to actual counts (60 files, 8,356 LOC) and the docs commit amended.
51
+ 2. `WorkspaceBracket.hasProvider()` TDA trade-off — documented in the `run()` call-site comment; noted here for Phase 18 awareness.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "16.2.0",
3
+ "version": "16.2.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -0,0 +1,37 @@
1
+ /**
2
+ * run-listeners.ts — Per-run observer-unsubscribe and signal-detach handles.
3
+ *
4
+ * Owns the two teardown handles that a Subagent wires at run start (signal
5
+ * listener) and after session creation (record-observer unsub), releasing
6
+ * both atomically when the run ends or the agent is resumed.
7
+ */
8
+
9
+ /** Owns the per-run observer-unsubscribe and signal-detach handles. */
10
+ export class RunListeners {
11
+ private unsub?: () => void;
12
+ private detach?: () => void;
13
+
14
+ /**
15
+ * Wire a parent AbortSignal so it triggers onAbort when fired.
16
+ * No-op when signal is undefined.
17
+ */
18
+ wireSignal(signal: AbortSignal | undefined, onAbort: () => void): void {
19
+ if (!signal) return;
20
+ const listener = () => onAbort();
21
+ signal.addEventListener("abort", listener, { once: true });
22
+ this.detach = () => signal.removeEventListener("abort", listener);
23
+ }
24
+
25
+ /** Store the record-observer unsubscribe handle. */
26
+ attachObserver(unsub: () => void): void {
27
+ this.unsub = unsub;
28
+ }
29
+
30
+ /** Release the observer + signal handles. Idempotent. */
31
+ release(): void {
32
+ this.unsub?.();
33
+ this.unsub = undefined;
34
+ this.detach?.();
35
+ this.detach = undefined;
36
+ }
37
+ }
@@ -168,12 +168,14 @@ export class SubagentManager {
168
168
  }
169
169
 
170
170
  if (options.isBackground && !options.bypassQueue) {
171
- // Schedule on the limiter — start() guards against abort-while-queued.
172
- void this.limiter.schedule(() => record.start());
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));
173
175
  return id;
174
176
  }
175
177
 
176
- void record.start();
178
+ record.start();
177
179
  return id;
178
180
  }
179
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,23 +95,16 @@ 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
- /** Backing store for the run promise. Set by start(). */
111
99
  private _promise?: Promise<void>;
112
- /** Awaitable handle to the running promise. Set by start(). */
113
100
  get promise(): Promise<void> | undefined { return this._promise; }
114
101
 
115
- // Execution machinery — a single mandatory collaborator (no per-field fallbacks).
116
102
  private readonly execution: SubagentExecution;
117
- /** Workspace prepared at run-start by a provider — undefined when none is registered. */
118
- private _workspace?: Workspace;
103
+ private readonly listeners = new RunListeners();
104
+ private readonly workspaceBracket: WorkspaceBracket;
119
105
 
120
- // Phase-specific collaborators — each born complete when their info becomes available
121
- /** The born-complete child session — set when the factory returns inside run(). */
122
106
  subagentSession?: SubagentSession;
123
107
  private _notification?: NotificationState;
124
- /** Notification state for background agents — wired from parentSession.toolCallId. */
125
108
  get notification(): NotificationState | undefined { return this._notification; }
126
109
 
127
110
  // Steer buffer — messages queued before the session is ready
@@ -191,6 +174,11 @@ export class Subagent {
191
174
  // Execution machinery — a single mandatory collaborator
192
175
  this.execution = init.execution;
193
176
 
177
+ // Per-run lifecycle collaborators
178
+ this.workspaceBracket = new WorkspaceBracket(
179
+ this.execution.getWorkspaceProvider ?? (() => undefined),
180
+ );
181
+
194
182
  // Notification state — created from parentSession.toolCallId if present
195
183
  const toolCallId = init.execution.parentSession?.toolCallId;
196
184
  if (toolCallId) {
@@ -210,27 +198,26 @@ export class Subagent {
210
198
  async run(): Promise<void> {
211
199
  this.markRunning(Date.now());
212
200
  this.execution.observer?.onStarted?.(this);
213
- this.wireSignal(this.execution.signal, () => this.abort());
201
+ this.listeners.wireSignal(this.execution.signal, () => this.abort());
214
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.
215
206
  let cwd: string | undefined;
216
- try {
217
- // A registered workspace provider supplies the child's cwd and owns its
218
- // teardown; with no provider the child runs in the parent cwd.
219
- const provider = this.execution.getWorkspaceProvider?.();
220
- if (provider) {
221
- this._workspace = await provider.prepare({
207
+ if (this.workspaceBracket.hasProvider()) {
208
+ try {
209
+ cwd = await this.workspaceBracket.prepare({
222
210
  agentId: this.id,
223
211
  agentType: this.type,
224
212
  baseCwd: this.execution.baseCwd,
225
213
  invocation: this.invocation,
226
214
  });
227
- cwd = this._workspace?.cwd;
215
+ } catch (err) {
216
+ this.markError(err);
217
+ this.listeners.release();
218
+ this.execution.observer?.onRunFinished?.(this);
219
+ return;
228
220
  }
229
- } catch (err) {
230
- this.markError(err);
231
- this.releaseListeners();
232
- this.execution.observer?.onRunFinished?.(this);
233
- return;
234
221
  }
235
222
 
236
223
  try {
@@ -249,7 +236,7 @@ export class Subagent {
249
236
  }
250
237
 
251
238
  this.flushPendingSteers();
252
- this.attachObserver(subscribeSubagentObserver(this.subagentSession, this.state, {
239
+ this.listeners.attachObserver(subscribeSubagentObserver(this.subagentSession, this.state, {
253
240
  onCompact: (info) => this.execution.observer?.onCompacted?.(this, info),
254
241
  }));
255
242
  this.execution.observer?.onSessionCreated?.(this);
@@ -269,19 +256,32 @@ export class Subagent {
269
256
  }
270
257
 
271
258
  /**
272
- * Start execution: call run(), store the promise internally, and return it.
273
- *
274
- * Guards against non-active states (e.g. abort-while-queued): if the agent
275
- * is neither queued nor running, the promise resolves immediately (no-op).
276
- * This folds the inline status guard out of SubagentManager's limiter callback.
259
+ * Start execution immediately (foreground / bypassQueue paths).
260
+ * Stores the run promise so it is awaitable via the `promise` getter.
277
261
  */
278
- start(): Promise<void> {
279
- if (this.status !== "queued" && this.status !== "running") {
280
- this._promise = Promise.resolve();
281
- return this._promise;
282
- }
283
- this._promise = this.run();
284
- return this._promise;
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
285
  }
286
286
 
287
287
  /**
@@ -300,7 +300,7 @@ export class Subagent {
300
300
  }
301
301
 
302
302
  this.resetForResume(Date.now());
303
- this.attachObserver(subscribeSubagentObserver(subagentSession, this.state, {
303
+ this.listeners.attachObserver(subscribeSubagentObserver(subagentSession, this.state, {
304
304
  onCompact: (info) => this.execution.observer?.onCompacted?.(this, info),
305
305
  }));
306
306
 
@@ -310,7 +310,7 @@ export class Subagent {
310
310
  } catch (err) {
311
311
  this.markError(err);
312
312
  } finally {
313
- this.releaseListeners();
313
+ this.listeners.release();
314
314
  }
315
315
  }
316
316
 
@@ -406,48 +406,21 @@ export class Subagent {
406
406
  /** Reset for resume: running status, new startedAt, clear completedAt/result/error/listeners. */
407
407
  resetForResume(startedAt: number): void {
408
408
  this.state.resetForResume(startedAt);
409
- this.releaseListeners();
410
- }
411
-
412
- // --- Per-run listener state (released on completion or resume reset) ---
413
- private _unsub?: () => void;
414
- private _detachFn?: () => void;
415
-
416
- /** Wire a parent AbortSignal so it stops this agent when fired. */
417
- wireSignal(signal: AbortSignal | undefined, onAbort: () => void): void {
418
- if (!signal) return;
419
- const listener = () => onAbort();
420
- signal.addEventListener("abort", listener, { once: true });
421
- this._detachFn = () => signal.removeEventListener("abort", listener);
422
- }
423
-
424
- /** Store the record-observer unsubscribe handle. */
425
- attachObserver(unsub: () => void): void {
426
- this._unsub = unsub;
427
- }
428
-
429
- /** Release observer + signal listener handles. */
430
- releaseListeners(): void {
431
- this._unsub?.();
432
- this._unsub = undefined;
433
- this._detachFn?.();
434
- this._detachFn = undefined;
409
+ this.listeners.release();
435
410
  }
436
411
 
437
412
  /** Complete a run: release listeners, dispose the workspace, status transition, notify observer. */
438
413
  completeRun(result: TurnLoopResult): void {
439
- this.releaseListeners();
440
-
441
- let finalResult = result.responseText;
442
- if (this._workspace) {
443
- const finalStatus: SubagentStatus = result.aborted
444
- ? "aborted"
445
- : result.steered
446
- ? "steered"
447
- : "completed";
448
- const disposeResult = this._workspace.dispose({ status: finalStatus, description: this.description });
449
- if (disposeResult?.resultAddendum) finalResult += disposeResult.resultAddendum;
450
- }
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 });
451
424
 
452
425
  if (result.aborted) this.markAborted(finalResult);
453
426
  else if (result.steered) this.markSteered(finalResult);
@@ -464,10 +437,10 @@ export class Subagent {
464
437
  /** Fail a run: mark error, release listeners, best-effort workspace dispose, notify observer. */
465
438
  failRun(err: unknown): void {
466
439
  this.markError(err);
467
- this.releaseListeners();
440
+ this.listeners.release();
468
441
 
469
442
  try {
470
- if (this._workspace) this._workspace.dispose({ status: "error", description: this.description });
443
+ this.workspaceBracket.dispose({ status: "error", description: this.description });
471
444
  } catch (cleanupErr) { debugLog("workspace dispose on agent error", cleanupErr); }
472
445
 
473
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
+ }