@gotgenes/pi-subagents 16.1.1 → 16.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ 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
+
15
+ ## [16.2.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.1.1...pi-subagents-v16.2.0) (2026-06-14)
16
+
17
+
18
+ ### Features
19
+
20
+ * encapsulate Subagent.start(), promise, and notification ([#374](https://github.com/gotgenes/pi-packages/issues/374)) ([048b4a0](https://github.com/gotgenes/pi-packages/commit/048b4a0a859ec83e1c73c1386484a747e37ba224))
21
+
8
22
  ## [16.1.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.1.0...pi-subagents-v16.1.1) (2026-06-14)
9
23
 
10
24
 
@@ -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 |
@@ -940,7 +939,7 @@ Priority = Impact × (6 − Risk).
940
939
  - 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
940
  - 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
941
  - 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.
942
+ 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
943
  The settings `onMaxConcurrentChanged` hook wires to `limiter.recheck()` in `index.ts`; `dispose()` calls `limiter.clear()` to drop pending thunks.
945
944
  - 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
945
 
@@ -960,19 +959,27 @@ Priority = Impact × (6 − Risk).
960
959
  `subscribeSubagentObserver` targets `SubagentState`, so observer and state-machine tests no longer stub execution.
961
960
  `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
961
 
963
- #### Step 3 — Encapsulate run start and notification attachment on Subagent ([#374])
962
+ #### Step 3 — Encapsulate run start and notification attachment on Subagent ([#374]) ✅ Complete
964
963
 
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`).
964
+ - 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`.
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`).
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.
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).
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.
969
972
 
970
973
  #### Step 4 — Extract run-listener and workspace-bracket collaborators from Subagent ([#375])
971
974
 
972
- - Targets: `src/lifecycle/subagent.ts` (533 LOC — largest source file, accelerating churn).
975
+ - Targets: `src/lifecycle/subagent.ts` (455 LOC after Step 2 extracted SubagentState still the largest source file).
973
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`).
974
- - 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.
975
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).
976
983
 
977
984
  #### Step 5 — Extract the manager observer from index.ts into a class ([#376])
978
985
 
@@ -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