@gotgenes/pi-subagents 16.1.1 → 16.2.0

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.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.1.1...pi-subagents-v16.2.0) (2026-06-14)
9
+
10
+
11
+ ### Features
12
+
13
+ * encapsulate Subagent.start(), promise, and notification ([#374](https://github.com/gotgenes/pi-packages/issues/374)) ([048b4a0](https://github.com/gotgenes/pi-packages/commit/048b4a0a859ec83e1c73c1386484a747e37ba224))
14
+
8
15
  ## [16.1.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.1.0...pi-subagents-v16.1.1) (2026-06-14)
9
16
 
10
17
 
@@ -940,7 +940,7 @@ Priority = Impact × (6 − Risk).
940
940
  - Targets: `src/lifecycle/concurrency-queue.ts` (→ `concurrency-limiter.ts`), `src/lifecycle/subagent-manager.ts`, `src/index.ts`, `test/lifecycle/concurrency-queue.test.ts`, `test/lifecycle/subagent-manager.test.ts`.
941
941
  - Smell: Category C (forward references: the queue's ID-registry design forces a start callback that reaches back into the manager, duplicated between `index.ts` and the test helper) and Category A (dual counting: the queue's `running` counter is fed by `markStarted`/`markFinished` relays in the manager's observer, mirroring state the agents already carry).
942
942
  - Change: replace the ID-registry queue with a `ConcurrencyLimiter` that schedules thunks FIFO against a dynamic `getLimit()` — the injected limiter knows nothing about agents, IDs, or the manager.
943
- Spawn gates background runs with `limiter.schedule(() => record.run())` (the thunk guards on `queued` status, covering abort-while-queued; Step 3 later folds the guard into `Subagent.start()`); foreground and `bypassQueue` runs invoke directly.
943
+ Spawn gates background runs with `limiter.schedule(() => record.start())` `start()` owns the abort-while-queued status guard and stores the promise internally; foreground and `bypassQueue` runs invoke `record.start()` directly.
944
944
  The settings `onMaxConcurrentChanged` hook wires to `limiter.recheck()` in `index.ts`; `dispose()` calls `limiter.clear()` to drop pending thunks.
945
945
  - Outcome: dependency direction is strictly manager → limiter (no callback back-edge; the `prefer-const` eslint-disable in the test helper is deleted); the observer's two queue relays are gone; every spawned agent has a `promise` at spawn, collapsing `waitForAll`'s `while (true)` drain loop and its eslint-disable.
946
946
 
@@ -960,16 +960,17 @@ Priority = Impact × (6 − Risk).
960
960
  `subscribeSubagentObserver` targets `SubagentState`, so observer and state-machine tests no longer stub execution.
961
961
  `SubagentExecution` is a mandatory constructor collaborator (production wires it in the single `spawn()` site; passive records build via `make-subagent.ts`), and the two `run()` throws are gone.
962
962
 
963
- #### Step 3 — Encapsulate run start and notification attachment on Subagent ([#374])
963
+ #### Step 3 — Encapsulate run start and notification attachment on Subagent ([#374]) ✅ Complete
964
964
 
965
- - Targets: `src/lifecycle/subagent.ts`, `src/lifecycle/subagent-manager.ts`, `test/tools/get-result-tool.test.ts`, `test/lifecycle/subagent-manager.test.ts`, `test/service/service-adapter.test.ts`, `test/observation/notification.test.ts`, `test/helpers/make-subagent.ts`.
966
- - Smell: Category C — output arguments: external writes to `record.promise` (3 production/test sites) and `record.notification` (7 test sites).
967
- - 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; tests attach notification state through `SubagentExecution.parentSession.toolCallId` or a dedicated options field.
968
- - Outcome: zero external writes to `Subagent` fields outside its own methods (grep-verifiable: `\.promise =` and `\.notification =` appear only inside `subagent.ts`).
965
+ - Targets: `src/lifecycle/subagent.ts`, `src/lifecycle/subagent-manager.ts`, `test/tools/get-result-tool.test.ts`, `test/lifecycle/subagent-manager.test.ts`, `test/service/service-adapter.test.ts`, `test/observation/notification.test.ts`, `test/helpers/make-subagent.test.ts`, `test/lifecycle/subagent.test.ts`.
966
+ - 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
+ - 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
+ - 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.
969
970
 
970
971
  #### Step 4 — Extract run-listener and workspace-bracket collaborators from Subagent ([#375])
971
972
 
972
- - Targets: `src/lifecycle/subagent.ts` (533 LOC — largest source file, accelerating churn).
973
+ - Targets: `src/lifecycle/subagent.ts` (455 LOC after Step 2 extracted SubagentState still the largest source file).
973
974
  - 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`).
974
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.
975
976
  - Outcome: `subagent.ts` ≤ 450 LOC; workspace disposal logic in exactly one place; listener handles no longer raw nullable fields.
@@ -0,0 +1,268 @@
1
+ ---
2
+ issue: 374
3
+ issue_title: "Encapsulate run start and notification attachment on Subagent"
4
+ ---
5
+
6
+ # Encapsulate Subagent.start() and read-only promise/notification
7
+
8
+ ## Problem Statement
9
+
10
+ `Subagent.promise` is assigned from outside the class in three places — `SubagentManager.spawn()` (two sites: scheduled and immediate paths) — and `record.notification` is assigned from outside the class in seven test sites.
11
+ Both are output-argument smells (design-review check 3): the object should own the state its own methods read.
12
+ `Subagent.run()` already exists; the promise that tracks it lives outside the object purely so callers can `await record.promise`.
13
+ `notification` was already moved to the constructor in Phase 17 Step 2 (wired from `execution.parentSession?.toolCallId`), but the field is still publicly writable, so tests bypass the constructor path with direct assignment.
14
+
15
+ ## Goals
16
+
17
+ - Add `Subagent.start()` that calls `run()`, stores the resulting promise internally, and returns it.
18
+ - Fold the abort-while-queued status guard into `start()`, removing the inline check from `SubagentManager`.
19
+ - Make `promise` externally read-only: private `_promise` field backed by a public `get promise()` accessor.
20
+ - Make `notification` externally read-only: private `_notification` field backed by a public `get notification()` accessor.
21
+ - Add `toolCallId?: string` to `TestSubagentOptions` so tests wire notification state via the constructor path without external writes.
22
+ - Achieve grep-verifiable outcome: `\.promise =` and `\.notification =` appear only inside `subagent.ts`.
23
+
24
+ ## Non-Goals
25
+
26
+ - Extracting `RunListeners` or workspace-bracket collaborators from `Subagent` (Phase 17 Step 4, Issue [#375]).
27
+ - Extracting the manager observer from `index.ts` (Phase 17 Step 5, Issue [#376]).
28
+ - Any other Phase 17 step beyond Step 3.
29
+
30
+ ## Background
31
+
32
+ Phase 17 Step 1 ([#381]) replaced `ConcurrencyQueue` with a `ConcurrencyLimiter` — the manager now calls `this.limiter.schedule(thunk)` and stores the scheduled promise on `record.promise`.
33
+ Phase 17 Step 2 ([#373]) extracted `SubagentState`, made `SubagentExecution` mandatory, and wired `notification` in the constructor via `execution.parentSession?.toolCallId`.
34
+
35
+ Current external write sites after Step 2:
36
+
37
+ | Field | Location | Count |
38
+ | --------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
39
+ | `record.promise` | `SubagentManager.spawn()` | 2 (scheduled + immediate) |
40
+ | `record.promise` | Test files | 3 (`get-result-tool.test.ts`, `service-adapter.test.ts`, `make-subagent.test.ts`) |
41
+ | `record.notification` | Test files | 7 (`get-result-tool.test.ts` ×2, `subagent-manager.test.ts` ×2, `service-adapter.test.ts` ×1, `notification.test.ts` ×2) |
42
+
43
+ `SubagentManager.spawnAndWait()` and `waitForAll()` read `record.promise` via the public field — these become getter reads after the change.
44
+ `get-result-tool.ts` reads `record.promise` to `await` it when `wait=true` — unchanged (getter).
45
+
46
+ The `AGENTS.md` constraint that applies: **output arguments** — if a function sets a field on a received object, it is doing work that belongs inside the owning object.
47
+
48
+ ## Design Overview
49
+
50
+ ### `Subagent.start()` and the status guard
51
+
52
+ ```typescript
53
+ private _promise?: Promise<void>;
54
+
55
+ /** Awaitable handle to the running promise. Set by start(). */
56
+ get promise(): Promise<void> | undefined {
57
+ return this._promise;
58
+ }
59
+
60
+ /**
61
+ * Start execution: call run(), store the promise, and return it.
62
+ * Guards against non-active states (e.g. abort-while-queued): if the agent
63
+ * is neither queued nor running, the promise resolves immediately (no-op).
64
+ */
65
+ start(): Promise<void> {
66
+ if (this.status !== "queued" && this.status !== "running") {
67
+ this._promise = Promise.resolve();
68
+ return this._promise;
69
+ }
70
+ this._promise = this.run();
71
+ return this._promise;
72
+ }
73
+ ```
74
+
75
+ The guard allows:
76
+
77
+ - `"queued"` — background agent waiting in the limiter; `run()` proceeds normally.
78
+ - `"running"` — foreground agent (status set to `"running"` at construction in the manager); `run()` proceeds normally.
79
+ - Any terminal state (`"stopped"`, `"error"`, `"completed"`, etc.) — agent was aborted while queued; `start()` becomes a no-op returning an immediately-resolving promise.
80
+
81
+ This folds the inline `if (record.status !== "queued") return Promise.resolve()` guard out of the `SubagentManager` limiter callback.
82
+
83
+ ### `SubagentManager.spawn()` after the change
84
+
85
+ ```typescript
86
+ // Queued background path
87
+ this.limiter.schedule(() => record.start());
88
+
89
+ // Immediate path (foreground or bypassQueue)
90
+ record.start();
91
+ ```
92
+
93
+ `spawnAndWait()` continues to `await record.promise` (now uses the getter, no behavior change).
94
+ `waitForAll()`'s `pendingPromises()` continues to `r.promise` (getter — no behavior change).
95
+
96
+ ### `notification` encapsulation
97
+
98
+ The constructor already writes to `this.notification` internally.
99
+ After the change, the constructor writes to `this._notification`:
100
+
101
+ ```typescript
102
+ private _notification?: NotificationState;
103
+
104
+ get notification(): NotificationState | undefined {
105
+ return this._notification;
106
+ }
107
+
108
+ // In constructor:
109
+ const toolCallId = init.execution.parentSession?.toolCallId;
110
+ if (toolCallId) {
111
+ this._notification = new NotificationState(toolCallId);
112
+ }
113
+ ```
114
+
115
+ No production writes to `notification` outside the constructor — only test sites need updating.
116
+
117
+ ### `TestSubagentOptions` shorthand
118
+
119
+ Add `toolCallId?: string` so tests that need a `NotificationState` use the constructor path:
120
+
121
+ ```typescript
122
+ // Before
123
+ const record = createTestSubagent();
124
+ record.notification = new NotificationState("tc-1");
125
+
126
+ // After
127
+ const record = createTestSubagent({ toolCallId: "tc-1" });
128
+ ```
129
+
130
+ In `createTestSubagent`, `toolCallId` routes through `makeStubExecution({ parentSession: { toolCallId } })`.
131
+
132
+ ### Tests that write `record.promise`
133
+
134
+ - **`service-adapter.test.ts`** ("strips promise from the record" tests): the test only needs `promise` to be absent from the serialized output.
135
+ Since `toSubagentRecord()` already builds an explicit object without `promise`, these tests pass without any promise being set on the record.
136
+ Remove the `record.promise = ...` setup.
137
+ - **`make-subagent.test.ts`** ("allows setting promise directly"): the test's intent was to verify the field was settable.
138
+ Replace with a test that `start()` sets `promise` internally via the stub execution.
139
+ - **`get-result-tool.test.ts`** ("waits for promise when wait=true"): the test needs a running agent whose promise resolves and updates status to completed.
140
+ Replace with an execution stub where `runTurnLoop` returns `{ responseText: "Finished after wait.", aborted: false, steered: false }` and call `record.start()`.
141
+ The `createSubagentSessionStub()` default already resolves with `{ responseText: "done", ... }` — override `runTurnLoop` to return the expected text.
142
+
143
+ ### `subagent-manager.test.ts` notification tests (lines 82, 100)
144
+
145
+ Tests that reproduce the race-condition bug (notification set post-spawn) become:
146
+
147
+ ```typescript
148
+ const id = manager.spawn(STUB_SNAPSHOT, "general-purpose", "test", {
149
+ description: "bg",
150
+ isBackground: true,
151
+ parentSession: { toolCallId: "tc-1" },
152
+ });
153
+ const record = manager.getRecord(id)!;
154
+ // notification is already wired from the constructor
155
+ await record.promise;
156
+ record.notification?.markConsumed();
157
+ ```
158
+
159
+ The behavior under test (race: `markConsumed()` after `await` is too late) is unchanged.
160
+
161
+ ## Module-Level Changes
162
+
163
+ - `src/lifecycle/subagent.ts`
164
+ - Remove public writable `promise?: Promise<void>` field.
165
+ - Add `private _promise?: Promise<void>`.
166
+ - Add `get promise(): Promise<void> | undefined`.
167
+ - Add `start(): Promise<void>` with the status guard.
168
+ - Rename `this.notification` write in constructor to `this._notification`.
169
+ - Remove public writable `notification?: NotificationState` field.
170
+ - Add `private _notification?: NotificationState`.
171
+ - Add `get notification(): NotificationState | undefined`.
172
+ - `src/lifecycle/subagent-manager.ts`
173
+ - Replace `record.promise = this.limiter.schedule(() => { if (...) return ...; return record.run(); })` with `this.limiter.schedule(() => record.start())`.
174
+ - Replace `record.promise = record.run()` with `record.start()`.
175
+ - `test/helpers/make-subagent.ts`
176
+ - Add `toolCallId?: string` to `TestSubagentOptions`.
177
+ - In `createTestSubagent`, map `toolCallId` to `makeStubExecution({ parentSession: { toolCallId } })`.
178
+ - `test/helpers/make-subagent.test.ts`
179
+ - Replace "allows setting promise directly after construction" with a test that `start()` stores promise via the execution stub.
180
+ - `test/tools/get-result-tool.test.ts`
181
+ - Replace `record.promise = Promise.resolve().then(...)` setup with a stub execution + `record.start()`.
182
+ - Replace `record.notification = new NotificationState("tc-1")` (×2) with `createTestSubagent({ toolCallId: "tc-1" })`.
183
+ - `test/lifecycle/subagent-manager.test.ts`
184
+ - Replace `record.notification = new NotificationState("tc-1")` (×2) with spawn options carrying `parentSession: { toolCallId: "tc-1" }`.
185
+ - `test/service/service-adapter.test.ts`
186
+ - Remove `record.promise = Promise.resolve()` setup (×2) from tests that only need to verify `toSubagentRecord()` strips the field.
187
+ - Replace `record.notification = new NotificationState("tc-1")` with `createTestSubagent({ toolCallId: "tc-1" })`.
188
+ - `test/observation/notification.test.ts`
189
+ - Replace `record.notification = new NotificationState("tc-123/tc-1")` (×2) with `createTestSubagent({ toolCallId: "tc-123/tc-1" })`.
190
+ - `docs/architecture/architecture.md`
191
+ - Mark Step 3 `✅ Complete` and add a "Landed" note.
192
+
193
+ ## Test Impact Analysis
194
+
195
+ 1. **New unit tests enabled**: `start()` behavior (promise stored, status guard no-op) can be tested directly in `subagent.test.ts` without touching the manager.
196
+ 2. **Existing tests simplified**: The 7 test sites that do `record.notification = ...` drop an artificial mutation and instead use the natural constructor path — the tests are shorter and closer to production semantics.
197
+ 3. **Tests that must stay**: The manager's race-condition tests (lines 74–120) verify ordering of `markConsumed()` vs `await promise` — they change setup only (spawn with toolCallId), not intent.
198
+ 4. **Tests removed**: The `make-subagent.test.ts` "allows setting promise" test is replaced, since direct write is no longer possible.
199
+
200
+ ## TDD Order
201
+
202
+ 1. **Add `Subagent.start()` alongside the existing public `promise?` field**
203
+
204
+ In `test/lifecycle/subagent.test.ts`, add tests:
205
+ - `start()` on a running agent returns a defined promise.
206
+ - `start()` on a stopped agent returns a resolving promise immediately (no-op guard).
207
+ - After `start()`, `record.promise` matches the returned promise.
208
+
209
+ In `src/lifecycle/subagent.ts`: add `private _promise`, `get promise()` (shadowing the old field — TypeScript will require removing the duplicate; advance to step 2 immediately), and `start()`.
210
+ Commit: `test: add Subagent.start() tests and initial implementation (#374)`
211
+
212
+ 2. **Make `promise` read-only — remove public field, update all write sites**
213
+
214
+ Breaking change at the type level.
215
+ Atomic commit must include:
216
+ - `src/lifecycle/subagent.ts` — remove `promise?: Promise<void>` public field (only `private _promise` + getter remain).
217
+ - `src/lifecycle/subagent-manager.ts` — replace both `record.promise = ...` sites with `record.start()` calls; limiter thunk becomes `() => record.start()`.
218
+ - `test/helpers/make-subagent.test.ts` — replace write-promise test with `start()` test.
219
+ - `test/tools/get-result-tool.test.ts` — replace `record.promise = ...` setup; use execution stub + `record.start()`.
220
+ - `test/service/service-adapter.test.ts` — remove `record.promise = Promise.resolve()` setup (×2).
221
+
222
+ Run `pnpm --filter @gotgenes/pi-subagents run check` to verify.
223
+ Commit: `feat: make Subagent.promise read-only, add start() (#374)`
224
+
225
+ 3. **Make `notification` read-only — remove public field, update all write sites**
226
+
227
+ Breaking change at the type level.
228
+ Atomic commit must include:
229
+ - `src/lifecycle/subagent.ts` — rename public `notification?` to `private _notification`; add `get notification()`; constructor write becomes `this._notification = ...`.
230
+ - `test/helpers/make-subagent.ts` — add `toolCallId?: string` to `TestSubagentOptions`; route through `makeStubExecution`.
231
+ - `test/tools/get-result-tool.test.ts` — replace `record.notification = new NotificationState(...)` (×2) with `createTestSubagent({ toolCallId: ... })`.
232
+ - `test/lifecycle/subagent-manager.test.ts` — replace `record.notification = new NotificationState(...)` (×2) with spawn options carrying `parentSession: { toolCallId: ... }`.
233
+ - `test/service/service-adapter.test.ts` — replace `record.notification = new NotificationState(...)` with `createTestSubagent({ toolCallId: ... })`.
234
+ - `test/observation/notification.test.ts` — replace `record.notification = new NotificationState(...)` (×2) with `createTestSubagent({ toolCallId: ... })`.
235
+
236
+ Run `pnpm --filter @gotgenes/pi-subagents exec vitest run` and `pnpm --filter @gotgenes/pi-subagents run check`.
237
+ Commit: `feat: make Subagent.notification read-only, update tests (#374)`
238
+
239
+ 4. **Update architecture doc**
240
+
241
+ In `docs/architecture/architecture.md`, mark Step 3 `✅ Complete` and add a "Landed" note summarizing the outcome.
242
+ Also update the note at line 943 that says "Step 3 later folds the guard into `Subagent.start()`" to reflect it is now done.
243
+ Commit: `docs: mark Phase 17 Step 3 complete in architecture.md (#374)`
244
+
245
+ ## Risks and Mitigations
246
+
247
+ - **Risk**: Adding both `private _promise` and `get promise()` while the public `promise?` field still exists is a TypeScript error (duplicate identifier).
248
+ **Mitigation**: Steps 1 and 2 are merged into one commit: introduce `start()`, remove the public writable field, and fix all consumers atomically.
249
+ The TDD order describes testing `start()` first, but both the public field removal and the consumer updates land in the same `feat:` commit.
250
+ - **Risk**: The status guard in `start()` allows `"running"` for foreground agents, which have `status = "running"` at construction.
251
+ If a foreground agent is stopped before `start()` is called (edge case), `run()` would call `markRunning()` on an already-stopped agent.
252
+ **Mitigation**: Foreground agents are started synchronously at the end of `spawn()` — there is no window between construction and `start()` during which the abort path can fire.
253
+ The guard is conservative and causes no regression.
254
+ - **Risk**: The race-condition test in `subagent-manager.test.ts` (lines 74–107) verifies that `markConsumed()` called after `await record.promise` is "too late" for the observer.
255
+ Switching from `record.notification = new NotificationState("tc-1")` to the constructor path does not change timing semantics.
256
+ **Mitigation**: The test body stays structurally identical; only the setup changes.
257
+ - **Risk**: `service-adapter.test.ts` tests that call `record.promise = Promise.resolve()` might be testing that the field exists on the Subagent type.
258
+ **Mitigation**: The tests are testing `toSubagentRecord()` output, not the field type.
259
+ Removing the setup doesn't change the assertion.
260
+
261
+ ## Open Questions
262
+
263
+ - None.
264
+ The design is fully specified by the Phase 17 Step 3 architecture note and the existing class structure.
265
+
266
+ [#373]: https://github.com/gotgenes/pi-packages/issues/373
267
+ [#375]: https://github.com/gotgenes/pi-packages/issues/375
268
+ [#381]: https://github.com/gotgenes/pi-packages/issues/381
@@ -0,0 +1,38 @@
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).
@@ -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.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -168,16 +168,12 @@ export class SubagentManager {
168
168
  }
169
169
 
170
170
  if (options.isBackground && !options.bypassQueue) {
171
- // Schedule on the limiter — started when a slot frees. The status guard
172
- // makes an abort-while-queued task a no-op (Step 3 folds it into start()).
173
- record.promise = this.limiter.schedule(() => {
174
- if (record.status !== "queued") return Promise.resolve();
175
- return record.run();
176
- });
171
+ // Schedule on the limiter — start() guards against abort-while-queued.
172
+ void this.limiter.schedule(() => record.start());
177
173
  return id;
178
174
  }
179
175
 
180
- record.promise = record.run();
176
+ void record.start();
181
177
  return id;
182
178
  }
183
179
 
@@ -107,8 +107,10 @@ export class Subagent {
107
107
 
108
108
  /** AbortController for cancelling this agent. Created at construction. */
109
109
  readonly abortController: AbortController;
110
- /** Promise for the full agent run (including post-processing). Set by run(). */
111
- promise?: Promise<void>;
110
+ /** Backing store for the run promise. Set by start(). */
111
+ private _promise?: Promise<void>;
112
+ /** Awaitable handle to the running promise. Set by start(). */
113
+ get promise(): Promise<void> | undefined { return this._promise; }
112
114
 
113
115
  // Execution machinery — a single mandatory collaborator (no per-field fallbacks).
114
116
  private readonly execution: SubagentExecution;
@@ -118,7 +120,9 @@ export class Subagent {
118
120
  // Phase-specific collaborators — each born complete when their info becomes available
119
121
  /** The born-complete child session — set when the factory returns inside run(). */
120
122
  subagentSession?: SubagentSession;
121
- notification?: NotificationState;
123
+ private _notification?: NotificationState;
124
+ /** Notification state for background agents — wired from parentSession.toolCallId. */
125
+ get notification(): NotificationState | undefined { return this._notification; }
122
126
 
123
127
  // Steer buffer — messages queued before the session is ready
124
128
  private _pendingSteers: string[] = [];
@@ -190,7 +194,7 @@ export class Subagent {
190
194
  // Notification state — created from parentSession.toolCallId if present
191
195
  const toolCallId = init.execution.parentSession?.toolCallId;
192
196
  if (toolCallId) {
193
- this.notification = new NotificationState(toolCallId);
197
+ this._notification = new NotificationState(toolCallId);
194
198
  }
195
199
  }
196
200
 
@@ -264,6 +268,22 @@ export class Subagent {
264
268
  }
265
269
  }
266
270
 
271
+ /**
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.
277
+ */
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;
285
+ }
286
+
267
287
  /**
268
288
  * Resume an existing session with a new prompt, managing the observer
269
289
  * subscription lifecycle internally (same wiring as run()).