@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.
@@ -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
@@ -0,0 +1,171 @@
1
+ ---
2
+ issue: 374
3
+ issue_title: "Encapsulate run start and notification attachment on Subagent"
4
+ ---
5
+
6
+ # Retro: #374 — Encapsulate run start and notification attachment on Subagent
7
+
8
+ ## Stage: Planning (2026-06-14T00:00:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Read issue #374 (Phase 17 Step 3 — output-argument encapsulation), loaded skills, explored `subagent.ts`, `subagent-manager.ts`, `notification-state.ts`, and all seven test files with external writes.
13
+ Produced a 4-step TDD plan in `packages/pi-subagents/docs/plans/0374-encapsulate-subagent-start-notification.md`.
14
+
15
+ ### Observations
16
+
17
+ - The `notification` field was already constructor-wired in Phase 17 Step 2 (from `execution.parentSession?.toolCallId`); the remaining work is making both `promise` and `notification` externally read-only and updating the 7 + 3 test write sites.
18
+ - Steps 1 and 2 in the TDD order are effectively merged: introducing `private _promise` alongside the existing public `promise?` field is a TypeScript duplicate-identifier error, so the public field removal and all consumer updates must land in one atomic commit (`feat: make Subagent.promise read-only, add start() (#374)`).
19
+ - The status guard (`if (status !== "queued" && status !== "running")`) in `start()` allows foreground agents (constructed with `status: "running"`) to pass through cleanly, while stopping aborted-while-queued agents; this folds the inline guard out of the `SubagentManager` limiter callback.
20
+ - `service-adapter.test.ts` tests that set `record.promise = Promise.resolve()` only test that `toSubagentRecord()` strips the field — the setup is vestigial once `promise` becomes a getter; simply removing it is sufficient.
21
+ - The "waits for promise when wait=true" test in `get-result-tool.test.ts` needs a more realistic execution stub (`runTurnLoop` returning `{ responseText: "Finished after wait.", aborted: false, steered: false }`) so `record.start()` triggers the full run pipeline and calls `markCompleted()` internally.
22
+ - `TestSubagentOptions.toolCallId?: string` is the cleanest shorthand for the 5 test files that create passive records but need a `NotificationState`; it routes through `makeStubExecution({ parentSession: { toolCallId } })`, matching the production constructor path exactly.
23
+
24
+ ## Stage: Implementation — TDD (2026-06-14T16:31:00Z)
25
+
26
+ ### Session summary
27
+
28
+ Implemented all 4 plan steps in 2 substantive commits: one atomic `feat:` commit for `start()` + `promise`/`notification` read-only + all test site updates, and one `docs:` commit for the architecture doc.
29
+ Test count went from 975 to 981 (+6 new `start()` unit tests).
30
+ Pre-completion reviewer returned PASS with one WARN (stale test count in `package-pi-subagents` SKILL.md — fixed immediately).
31
+
32
+ ### Observations
33
+
34
+ - Plan steps 1–3 landed in a single commit because making `notification` private required the same `subagent.ts` file as making `promise` private; splitting would have required complex partial staging.
35
+ - The `void record.start()` and `void this.limiter.schedule(...)` patterns were needed in `subagent-manager.ts` to satisfy `@typescript-eslint/no-floating-promises` — `start()` returns a `Promise<void>` but the manager stores the state internally; callers don't need to await it.
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
+ - Grep-verifiable outcome confirmed: `\.promise =` and `\.notification =` appear only inside `subagent.ts` (as `this._promise =` and `this._notification =`).
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.
@@ -47,3 +47,44 @@ Test count went from 967 to 975 (+8: 6 `InterruptHandler` unit tests, 2 foregrou
47
47
  Fixed the current-state prose claim (`56` → `58` source files).
48
48
  Left the fallow health-metrics snapshot rows (line ~650, `7,778 (57 files)`) intact — those are point-in-time analysis tables where the file count was computed alongside LOC and other metrics, so bumping one cell in isolation would desync the snapshot.
49
49
  Amended the fix into the docs commit (not yet pushed).
50
+
51
+ ## Stage: Final Retrospective (2026-06-14T20:00:00Z)
52
+
53
+ ### Session summary
54
+
55
+ Shipped issue #403 end-to-end across four stages (plan → TDD → ship → live verification): root-caused the bug, implemented the `InterruptHandler` (single `fix:` commit), guarded the already-working foreground path, and released `pi-subagents-v16.1.1`.
56
+ The operator then live-tested all three abort paths (background subagent, foreground subagent, main agent) and confirmed a single Escape aborts each immediately.
57
+ Near-zero rework: one reviewer WARN (stale doc file count) fixed by amend, no follow-up commits, no failed CI.
58
+
59
+ ### Observations
60
+
61
+ #### What went well
62
+
63
+ 1. The planning-stage SDK trace paid dividends two stages later.
64
+ When the operator asked during live testing "is it supposed to take two Escapes or just one?", the answer came straight from the `restoreQueuedMessagesToEditor → agent.abort()` trace captured at planning time — no re-investigation.
65
+ The same trace explained the main-agent and foreground-subagent abort paths immediately.
66
+ 2. The keystone de-risking finding (`finishRun()` discards the per-run `AbortController` without aborting it, so the `abort` event fires only on a real interrupt) held up in practice — no spurious turn-end aborts were observed in live testing.
67
+ 3. The foreground guard test passed on its first run, confirming the planning trace, so the plan's pre-typed `test:` commit type was correct and the whole implementation landed with zero rework.
68
+ 4. Verification was incremental throughout TDD: green baseline first, per-step affected-file runs, `pnpm run check` after the interface-touching step, and full `test`/`check`/`lint`/`fallow` at the end.
69
+
70
+ #### What caused friction (agent side)
71
+
72
+ 1. `missing-context` — when adding the new source file `interrupt.ts`, I updated the `handlers/` directory listing in `architecture.md` but not the prose total-file count at line 277 (which was already stale: `56` vs the pre-change actual of `57`).
73
+ Impact: one pre-completion reviewer WARN, fixed by amending the docs commit before push — no rework, no extra commit, no CI cost.
74
+
75
+ #### What caused friction (user side)
76
+
77
+ 1. None.
78
+ The operator's involvement was high-value: the third-party-issue direction gate (planning) and the live three-path abort verification (post-ship) validated behavior that unit tests cannot reach (real ESC keypress through the interactive TUI).
79
+
80
+ ### Diagnostic details
81
+
82
+ 1. Model-performance correlation — ship stage and the `pre-completion-reviewer` subagent both ran on `claude-sonnet-4-6` (mechanical orchestration and checklist review — appropriate); retro synthesis on `claude-opus-4-8` (judgment — appropriate).
83
+ No mismatch.
84
+ 2. Escalation-delay tracking — no `rabbit-hole` friction points; the planning SDK dig was productive forward exploration, not repeated calls against one error.
85
+ 3. Unused-tool detection — the planning SDK trace navigated minified `node_modules/.pnpm` dist files by hand; `colgrep` (project-code semantic search) and an Explore subagent (project-code understanding) were not suited to reverse-engineering pinned third-party `dist` JS, so no tool was wrongly skipped.
86
+ 4. Feedback-loop gap analysis — no gap; verification ran incrementally per TDD step, not only at the end.
87
+
88
+ ### Changes made
89
+
90
+ 1. Added an "Abort / interrupt signal lifecycle" section to `.pi/skills/pi-extension-lifecycle/SKILL.md` documenting the per-run `AbortController`, the ESC → `agent.abort()` path, the `finishRun()` discard-without-abort behavior, and the `ctx.signal` / `tool.execute(signal)` exposure — so future interrupt-timing work need not re-derive it from the pinned SDK `dist` files.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "16.1.1",
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
+ }