@gotgenes/pi-subagents 16.3.1 → 16.5.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.
@@ -0,0 +1,214 @@
1
+ # Phase 17: Core consolidation
2
+
3
+ ## Summary
4
+
5
+ Phase 17 consolidated the core's remaining structural debt before the UI reconsideration (Phase 18).
6
+ The findings came from the standard discovery pass — fallow suite, entry-point trace, design-review checklist, and test-constructibility audit — run after Phase 16 landed.
7
+
8
+ All nine steps are closed: [#381], [#373], [#374], [#375], [#376], [#377], [#378], [#379], [#380].
9
+ [#412] unified the overlapping session-mock builders identified during Step 7.
10
+ [#415] migrated `pi-subagents-worktrees` to the shared settings helper after the Step 9 published release.
11
+
12
+ ## Context
13
+
14
+ Phase 17 is the consolidation slice of the [first-principles refinement](../architecture.md#first-principles-refinement-and-the-deeper-target), not the full domain split.
15
+ It lands the first cut of the lifecycle-state domain (Step 2's `SubagentState`) plus the wiring, queue, and duplication cleanups.
16
+ The fuller four-domain split — metrics as a projection, result delivery as its own domain, the hook/broadcast reclassification, and the push/pull (DIP) inversion — is recorded in the refinement and sequenced into later phases.
17
+
18
+ ## Findings summary
19
+
20
+ Health metrics at the start of Phase 17 (fallow, package-wide including tests):
21
+
22
+ | Metric | Phase 16 baseline | Phase 17 start |
23
+ | -------------------------- | ------------------------------ | ------------------------------------------------------------------ |
24
+ | Health score | 78/100 (B) | 78/100 (B) |
25
+ | Source LOC | 7,778 (57 files) | 8,356 (61 files, landed Phase 17 Step 5) |
26
+ | Dead code | 0 files, 0 exports | 0 files, 0 exports |
27
+ | Maintainability index | 90.8 (good) | 90.8 (good) |
28
+ | Avg / P90 cyclomatic | 1.4 / 2 | 1.4 / 2 |
29
+ | Production duplication | 11 lines (1 internal group) | 11 lines (1 internal group; cross-package pair resolved in Step 9) |
30
+ | Test duplication | 42 groups, 661 lines | 44 groups, ~750 lines |
31
+ | Fallow refactoring targets | 0 | 0 |
32
+ | Top churn hotspot | `index.ts` 65.0 ▲ accelerating | `index.ts` 31.3 ▼ cooling |
33
+
34
+ The syntactic metrics are healthy and stable — the remaining debt is structural, mostly invisible to fallow, and concentrated in three places:
35
+
36
+ 1. **`Subagent` construction duality.**
37
+ `SubagentInit` carries ~20 fields, nearly all optional with "required for run(), optional for tests" semantics, and `run()` compensates with runtime throws ("not configured for execution").
38
+ This violates principle 8 (construct complete): the class is simultaneously a passive record (tests build display-only snapshots) and an executor (production wires factory, observer, run config, workspace provider).
39
+ The symptoms are in the tests: external writes `record.promise = …` (manager, queue callback, four test files) and `record.notification = new NotificationState(…)` (seven test sites) are output-argument smells on fields the object should own.
40
+ This duality is the two most visible of four domains fused into `Subagent`; Phase 17 resolves it (Step 2) and defers the remaining split (metrics, result delivery) to a later phase per the [first-principles refinement](../architecture.md#first-principles-refinement-and-the-deeper-target).
41
+ 2. **Wiring debt in `index.ts`.**
42
+ Two forward references (settings → queue, queue → manager) are replicated with an `eslint-disable prefer-const` dance in `test/lifecycle/subagent-manager.test.ts`; the queue's start callback (`record.promise = record.run()` after a status check) is duplicated verbatim between `index.ts` and the test helper.
43
+ A ~70-line inline `SubagentManagerObserver` literal mixes three concerns (event emission, `appendEntry` persistence, notification dispatch).
44
+ `runtime.widget` is assigned post-construction behind five relay-only delegation methods on `SubagentRuntime`.
45
+ 3. **Duplication.**
46
+ A 23-line cross-package production clone (`settings.ts:198-211` ↔ `pi-subagents-worktrees/src/config.ts:51-73`: the layered global/project settings-file loader) and 44 test clone groups (~750 lines), with clone families concentrated in `test/lifecycle/` and `test/ui/`.
47
+
48
+ Deferred findings (scored below the priority cut, tracked here rather than as steps): the `resolveModel` error-as-string union return (callers branch on `typeof resolved === "string"`), the file-top SDK `eslint-disable` headers in 14 files (re-audit when the Pi SDK exports improve), missing unit tests for `observation/renderer.ts` (the top CRAP-risk file), and the 11-line internal clone in `ui/agent-config-editor.ts` (folds into the Phase 18 UI extraction).
49
+
50
+ ## Steps
51
+
52
+ Priority = Impact × (6 − Risk).
53
+
54
+ | Step | Title | Category | Impact | Risk | Priority |
55
+ | ---- | ------------------------------------------------------------------------------------ | -------- | ------ | ---- | -------- |
56
+ | 1 | Replace ConcurrencyQueue with a thunk-based ConcurrencyLimiter | A/C | 4 | 2 | 16 |
57
+ | 2 | Extract `SubagentState`; make `Subagent` execution deps mandatory | B/D | 4 | 3 | 12 |
58
+ | 3 | Encapsulate run start and notification attachment on Subagent | C | 3 | 2 | 12 |
59
+ | 4 | Extract run-listener and workspace-bracket collaborators from Subagent | B/C | 3 | 2 | 12 |
60
+ | 5 | Extract the manager observer from index.ts into a class | B/E | 3 | 2 | 12 |
61
+ | 6 | Split widget delegation out of SubagentRuntime | C | 3 | 3 | 9 |
62
+ | 7 | Consolidate lifecycle test fixtures | D | 3 | 1 | 15 |
63
+ | 8 | Consolidate UI and tools test fixtures | D | 2 | 1 | 10 |
64
+ | 9 | Resolve the cross-package settings-loader duplication | A | 2 | 2 | 8 |
65
+
66
+ ### Step 1 — Replace ConcurrencyQueue with a thunk-based ConcurrencyLimiter ([#381])
67
+
68
+ - 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`.
69
+ - 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).
70
+ - 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.
71
+ 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.
72
+ The settings `onMaxConcurrentChanged` hook wires to `limiter.recheck()` in `index.ts`; `dispose()` calls `limiter.clear()` to drop pending thunks.
73
+ - 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.
74
+
75
+ ### Step 2 — Extract `SubagentState`; make `Subagent` execution deps mandatory ([#373])
76
+
77
+ - Targets: `src/lifecycle/subagent.ts` (state fields, transition/accumulation methods, constructor, `run()` guards), `src/lifecycle/subagent-manager.ts` (`spawn`), `test/helpers/make-subagent.ts`, `test/lifecycle/subagent.test.ts`, `test/observation/record-observer.test.ts`.
78
+ - Smell: Category B (god interface — ~20 fields) and Category D (constructibility: "optional for tests" fields with compensating runtime throws).
79
+ The record/executor duality is the two most visible of the four conflated domains (see the first-principles refinement in [architecture.md](../architecture.md#first-principles-refinement-and-the-deeper-target)).
80
+ - Change: extract the passive-record state — status, result, error, timestamps, and the stats (toolUses, lifetimeUsage, compactionCount) — into a `SubagentState` value object that owns the transition and accumulation methods.
81
+ `Subagent` holds one privately; its existing getters and `markX`/`incrementX`/`addUsage` methods become one-line delegations, so the ~40 read sites and the mutation callers are unchanged.
82
+ This is not reach-through: `SubagentState` is a private owned value, not a foreign collaborator (contrast [#277], which removed reach-through to the raw SDK session).
83
+ With the readable state extracted, the remaining execution inputs (snapshot, prompt, model, maxTurns, thinkingLevel, parentSession, signal, createSubagentSession, observer, getRunConfig, getWorkspaceProvider, baseCwd) collapse into a single **mandatory** `SubagentExecution` collaborator: production always supplies it (the one `spawn()` site), the passive-record construction moves entirely into `make-subagent.ts`, and `run()`'s two "not configured" throws vanish by construction.
84
+ - Outcome: state-machine and observer tests target `SubagentState` directly (no stub execution); `Subagent` is construct-complete with no optional execution fields and no runtime throws (grep-verifiable: no "not configured for execution" in `subagent.ts`); the record-vs-executor duality is resolved, not type-encoded.
85
+ - Scope boundary: stats stay on `SubagentState` for now.
86
+ Hoisting **metrics** into a projection over the child session's event stream and extracting **result delivery** (`notification`/`resultConsumed`) into its own domain are the remaining two of the four domains, deferred to a later phase per the refinement.
87
+ - Landed: `SubagentState` (`src/lifecycle/subagent-state.ts`) owns status/result/error/timestamps/stats and the transition/accumulation methods; `Subagent` delegates getters and `markX`/`incrementX`/`addUsage` to it.
88
+ `subscribeSubagentObserver` targets `SubagentState`, so observer and state-machine tests no longer stub execution.
89
+ `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.
90
+
91
+ ### Step 3 — Encapsulate run start and notification attachment on Subagent ([#374])
92
+
93
+ - 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`.
94
+ - 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`).
95
+ - 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.
96
+ - 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).
97
+ - 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.
98
+ - 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.
99
+ Fixed by inverting control: `scheduleVia` captures the limiter promise eagerly inside the agent (no external `.promise =` write), restoring the invariant.
100
+ Lesson: a step's acceptance criteria must include the cross-step invariants it could regress, not only its own grep-verifiable outcome.
101
+
102
+ ### Step 4 — Extract run-listener and workspace-bracket collaborators from Subagent ([#375])
103
+
104
+ - Targets: `src/lifecycle/subagent.ts` (455 LOC after Step 2 extracted SubagentState — still the largest source file).
105
+ - 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`).
106
+ - 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.
107
+ - Outcome: `subagent.ts` ≤ 450 LOC; workspace disposal logic in exactly one place; listener handles no longer raw nullable fields.
108
+ - 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.
109
+ `Subagent.wireSignal`, `attachObserver`, and `releaseListeners` are removed.
110
+ `subagent.ts`: 488 → 448 LOC.
111
+ Test count: 982 → 994 (+12: 7 RunListeners + 13 WorkspaceBracket − 8 redundant Subagent listener tests).
112
+
113
+ ### Step 5 — Extract the manager observer from index.ts into a class ([#376])
114
+
115
+ - Targets: `src/index.ts` (inline `SubagentManagerObserver` literal, ~70 lines), new module under `src/observation/`.
116
+ - Smell: Category B/E — `index.ts` is the dominant churn hotspot (31.3, 91 commits); the literal mixes event emission, record persistence (`appendEntry`), and notification dispatch; principle 9 (state and behavior belong in classes, not closure-captured literals).
117
+ - Change: extract a class (e.g. `SubagentEventsObserver`) constructed with narrow deps (`emit`, `appendEntry`, the `NotificationSystem`).
118
+ - Outcome: `index.ts` < 170 lines; the observer's three concerns unit-tested directly without booting the extension.
119
+ - Landed: `src/observation/subagent-events-observer.ts` (new, 97 LOC); `index.ts` 226 → 177 lines; 60 → 61 source files; 994 → 1009 tests (+15 covering all four observer methods).
120
+
121
+ ### Step 6 — Split widget delegation out of SubagentRuntime ([#377])
122
+
123
+ - Targets: `src/runtime.ts`, `src/tools/agent-tool.ts` (`AgentToolRuntime`), `src/handlers/tool-start.ts` (`ToolStartRuntime`), `src/observation/notification.ts` (`NotificationManager` constructor), `src/ui/agent-widget.ts`, `src/index.ts`.
124
+ - Smell: Category C — relay-only dependency (five delegation methods that only forward to `widget`) and a post-construction `runtime.widget =` write violating principle 8.
125
+ - Constraint discovered in planning: "pass the handle directly to the consumers" is infeasible for `NotificationManager`, which is a _transitive dependency_ of the widget (`NotificationManager → widget → manager → observer → NotificationManager`).
126
+ The `runtime.widget` lazy field exists to break exactly this cycle; removing it forces the one late seam to move, and the operator's principles (no setters, instantiate ready-to-work) rule out relocating it to a setter or forward-ref `let`.
127
+ - Change (tidy-first): first dissolve the cycle by giving `AgentWidget.update()` self-seeding of `finishedTurnAge` for finished agents it observes via `listAgents()`, then drop the `markFinished`/`updateWidget` callbacks from `NotificationManager` (it keeps `agentActivity.delete` + nudge scheduling).
128
+ With the cycle gone, the widget is constructed as a `const` after the manager and injected directly into `AgentTool` and `ToolStartHandler`; the `widget` field, the five relay methods, and the post-construction write delete cleanly, and `AgentToolRuntime` narrows to its context-query slice.
129
+ - Outcome: `SubagentRuntime` has zero widget knowledge; no post-construction field writes in `index.ts`; the construction graph is a cycle-free DAG (`notifications → observer → manager → widget → {AgentTool, ToolStartHandler}`).
130
+ - Landed: `AgentWidget.seedFinishedAgents` owns completion detection; `NotificationManager` has no widget dependency (2-arg constructor); `AgentToolWidget` (in `agent-tool.ts`) and `ToolStartWidget` (in `tool-start.ts`) are narrow per-consumer interfaces the real widget satisfies structurally; tool fixtures stub a `widget` separate from the narrowed `runtime` mock.
131
+ Behavior-preserving: the widget timer runs through every background completion, so self-seeding lands ≤80ms later within the same turn (linger is turn-based).
132
+ Test count: 1009 → 1005 (+3 widget self-seed tests, −7 removed relay/field tests).
133
+
134
+ ### Step 7 — Consolidate lifecycle test fixtures ([#378])
135
+
136
+ - Targets: `test/lifecycle/subagent-manager.test.ts`, `test/lifecycle/subagent-session.test.ts`, `test/lifecycle/create-subagent-session.test.ts`, `test/lifecycle/create-subagent-session-extension-tools.test.ts` (deleted), `test/helpers/subagent-session-io.ts`.
137
+ - Smell: Category D — at planning time fallow reported four clone families across the lifecycle tests (the issue's "five" predated Steps 1–6, which renamed the queue and dissolved the `subagent.test.ts`/`concurrency-limiter.test.ts` families).
138
+ - Change: extract the repeated spawn/run/factory arrangements into shared helpers, migrating incrementally (lift-and-shift, never a single-step rewrite of a large test file).
139
+ - Outcome: package test duplication below 600 lines; lifecycle clone families reduced, with the residual deliberately left as the visible test subject (see Landed).
140
+ - Landed: added a shared `createFactorySession` mock-session builder (`test/helpers/subagent-session-io.ts`); grouped the `createSubagentSession` arrange into describe-scoped `beforeEach` hooks with the `createSubagentSession(...)` act kept explicit per test (AAA); extracted a `programTurns` arrange helper for the turn-limit tests; consolidated the manager spawn/queued-pair arrangements; and folded `create-subagent-session-extension-tools.test.ts` (4 post-bind guard tests) into `create-subagent-session.test.ts`, deleting the file and its duplicated scaffolding.
141
+ Package test duplication: 669 → 512 lines; test files 64 → 63; test count 1005 → 1010 (+5 `createFactorySession` self-tests).
142
+ Two lifecycle clone families remain (`create-subagent-session.test.ts` ~29 lines, `subagent-manager.test.ts` ~23 lines): both are the repeated `await createSubagentSession(...)` / `spawn(...)` **act** with test-specific arrange, intentionally not extracted because hiding the system-under-test behind a helper is the wrong abstraction for test code (Sandi Metz: "duplication is far cheaper than the wrong abstraction").
143
+ Lesson: the original "families ≤ 1" target was a weak signal for _test_ code — an early act-wrapping helper that hit the metric was reverted in favour of the AAA structure above; the metric was relaxed deliberately.
144
+ The three overlapping session-mock builders this surfaced were resolved in [#412] by targeted reuse: `createFactorySession` now spreads the shared `createMockSession` core (inheriting the `messages`/`subscribe`/`emit`/`steer`/`dispose`/`sessionManager` base) and layers only the factory facet on top.
145
+ `createSubagentSessionStub` was left as-is — it already composes `createMockSession` as its wrapped `.session`, so its overlap is intrinsic delegation glue rather than duplication.
146
+
147
+ ### Step 8 — Consolidate UI and tools test fixtures ([#379])
148
+
149
+ - Targets: `test/ui/agent-creation-wizard.test.ts`, `test/ui/agent-config-editor.test.ts`, `test/ui/ui-observer.test.ts`, `test/tools/foreground-runner.test.ts`, `test/tools/background-spawner.test.ts`, `test/session/session-config.test.ts`, and `test/service/service-adapter.test.ts` (a seventh non-lifecycle family fallow reports; added to scope in planning).
150
+ - Smell: Category D — remaining clone families outside the lifecycle tree.
151
+ - Change: extract per-file repeated arrangements into local helpers or `test/helpers/` where shared across files.
152
+ - Outcome: each named family's extractable arrange consolidated; resulting fallow figures reported (the roadmap's original "44 → ≤ 25 groups; ≤ 0.6%" target predated Steps 1–7 and used a different baseline, so acceptance was "consolidate and measure," not a binding number).
153
+ - Landed: promoted the cross-file `ResolvedSpawnConfig` builder to `test/helpers/make-spawn-config.ts` (`createResolvedSpawnConfig`, flat options that derive the mirrored `agentInvocation`/`presentation.detailBase` regions) with a 5-test companion; consolidated the remaining six families with file-local, value-returning arrange builders and `beforeEach` setup (`spawnAndWaitRegistering`, `ui-observer` session/tracker `beforeEach` with `vi.fn()` onUpdate counters, `generateUI`/`manualUI`/`withInputs`, `disabledConfig`, `exploreConfig`, `createManagerStub`).
154
+ Package test duplication: 512 → 355 lines; clone groups 32 → 24; duplication 2.49% → 1.73%; test files 63 → 64; test count 1010 → 1015 (+5 `createResolvedSpawnConfig` self-tests).
155
+ Three multi-group families remain: the two lifecycle residuals from Step 7 (`create-subagent-session.test.ts`, `subagent-manager.test.ts`) and `agent-config-editor.test.ts`, whose residual clones are the repeated `await editor.showAgentDetail(...)` **act** plus its `setupDetail` arrange and `ui.select.mock.calls` menu assertion — left inline because wrapping the system-under-test is the wrong abstraction for test code (Step 7 lesson; Sandi Metz: "duplication is far cheaper than the wrong abstraction").
156
+
157
+ ### Step 9 — Resolve the cross-package settings-loader duplication ([#380])
158
+
159
+ - Targets: `src/settings.ts:198-211`, `packages/pi-subagents-worktrees/src/config.ts:51-73`.
160
+ - Smell: Category A — 23-line production clone: the layered global/project JSON read-sanitize-warn-merge loader.
161
+ - Decision: **extract** — `loadLayeredSettings<T>` added to `src/layered-settings.ts` and published via the `@gotgenes/pi-subagents/settings` dedicated subpath export (`dist/settings.d.ts`); `loadSettings` in `settings.ts` delegates to it internally.
162
+ Option 2 (fallow suppression) was rejected; the public-API cost is accepted as the right long-term tradeoff for a shared convention in the `@gotgenes/pi-*` family.
163
+ Worktrees migration (swap `config.ts`'s inlined loader for the shared helper) is deferred to [#415] — worktrees resolves pi-subagents from the registry, so it must wait for a published release carrying the helper.
164
+ - Outcome: `pnpm fallow:dupes --skip-local` no longer reports the `settings.ts` ↔ `config.ts` pair.
165
+ The parametrised helper's token sequence diverged sufficiently that the contiguous identical run dropped below the reporting threshold even before the worktrees migration lands.
166
+ Definitive semantic elimination completed when worktrees adopted the helper in [#415].
167
+
168
+ ## Step dependency diagram
169
+
170
+ ```mermaid
171
+ flowchart TB
172
+ S1["Step 1 (#381)<br/>ConcurrencyLimiter replacement"]
173
+ S2["Step 2 (#373)<br/>SubagentState extraction"]
174
+ S3["Step 3 (#374)<br/>Encapsulate start + notification"]
175
+ S4["Step 4 (#375)<br/>Run collaborators extraction"]
176
+ S5["Step 5 (#376)<br/>Observer class from index.ts"]
177
+ S6["Step 6 (#377)<br/>Widget handle out of runtime"]
178
+ S7["Step 7 (#378)<br/>Lifecycle test fixtures"]
179
+ S8["Step 8 (#379)<br/>UI/tools test fixtures"]
180
+ S9["Step 9 (#380)<br/>Settings-loader duplication"]
181
+
182
+ S1 --> S3
183
+ S2 --> S3
184
+ S3 --> S4
185
+ S4 --> S7
186
+ S5 --> S6
187
+ ```
188
+
189
+ Steps 8 and 9 have no dependencies and can run at any point.
190
+
191
+ ## Tracks
192
+
193
+ | Track | Steps | Theme |
194
+ | ----------------------------- | ------------- | ---------------------------------------------------------------------------------- |
195
+ | A — Subagent constructibility | 2 → 3 → 4 → 7 | Construct complete; encapsulate run state; then consolidate the tests that churned |
196
+ | B — Wiring debt | 1, 5 → 6 | Shrink index.ts; eliminate forward references and relay delegation |
197
+ | C — Test hygiene | 8 | Clone families outside the lifecycle tree |
198
+ | D — Duplication policy | 9 | Cross-package clone decision |
199
+
200
+ Tracks A and B intersect only at Step 3 (which needs Step 1's queue relocation); otherwise they proceed in parallel.
201
+ Tracks C and D are fully independent.
202
+
203
+ [#277]: https://github.com/gotgenes/pi-packages/issues/277
204
+ [#373]: https://github.com/gotgenes/pi-packages/issues/373
205
+ [#374]: https://github.com/gotgenes/pi-packages/issues/374
206
+ [#375]: https://github.com/gotgenes/pi-packages/issues/375
207
+ [#376]: https://github.com/gotgenes/pi-packages/issues/376
208
+ [#377]: https://github.com/gotgenes/pi-packages/issues/377
209
+ [#378]: https://github.com/gotgenes/pi-packages/issues/378
210
+ [#379]: https://github.com/gotgenes/pi-packages/issues/379
211
+ [#380]: https://github.com/gotgenes/pi-packages/issues/380
212
+ [#381]: https://github.com/gotgenes/pi-packages/issues/381
213
+ [#412]: https://github.com/gotgenes/pi-packages/issues/412
214
+ [#415]: https://github.com/gotgenes/pi-packages/issues/415
@@ -0,0 +1,243 @@
1
+ ---
2
+ issue: 378
3
+ issue_title: "Consolidate lifecycle test fixtures"
4
+ ---
5
+
6
+ # Consolidate lifecycle test fixtures
7
+
8
+ ## Problem Statement
9
+
10
+ `pnpm fallow:dupes` reports clone families concentrated in `test/lifecycle/`: repeated spawn-and-await arrangements and session-factory stubs copy-pasted across files and within them.
11
+ This is Phase 17 Step 7 (core consolidation), sequenced after Steps 2–6 reshaped `Subagent` construction so the lifecycle tests had already churned.
12
+ The roadmap Outcome is: lifecycle clone families 5 → ≤ 1; package test duplication below 600 lines.
13
+
14
+ The issue body cites five families across six files (including `concurrency-queue.test.ts` and `subagent.test.ts` at 766 LOC).
15
+ That snapshot predates Steps 1–6.
16
+ The current state — measured with `fallow dupes -r packages/pi-subagents` against today's `main` — is **four** lifecycle clone families, because the queue was renamed to `concurrency-limiter.test.ts` ([#381]) and `subagent.test.ts`/`concurrency-limiter.test.ts` no longer report families after the Step 2–4 reshaping.
17
+
18
+ ## Goals
19
+
20
+ - Consolidate the four current lifecycle clone families into shared or file-local helpers so the lifecycle tree reports ≤ 1 family.
21
+ - Bring package-wide test duplication below 600 lines (current baseline: 669 lines / 3.3% across 20 files; the four lifecycle families total ~122 lines).
22
+ - Preserve every existing test assertion — the existing suite is the regression guard for this refactor.
23
+ - Keep the change non-breaking: test-only, no production-code, public-surface, or behavior change.
24
+
25
+ ## Non-Goals
26
+
27
+ - Step 8 ([#379]) — UI and tools clone families (`agent-config-editor`, `agent-creation-wizard`, `ui-observer`, `foreground-runner`, `service-adapter`).
28
+ These are separate families outside the lifecycle tree and are deferred to their own issue.
29
+ - Step 9 ([#380]) — the cross-package settings-loader production clone.
30
+ - Any production-code change in `src/`.
31
+ - Adding new behavioral test cases — this is duplication removal, not coverage expansion (helper self-tests are the only new `it` blocks).
32
+
33
+ ## Background
34
+
35
+ The lifecycle suite already has substantial shared scaffolding under `test/helpers/`:
36
+
37
+ - `subagent-session-io.ts` — `createSubagentSessionIO()`, `createAgentLookup()`, `createSubagentSessionDeps()`, `createChildLifecycleMock()`.
38
+ The natural home for shared `createSubagentSession`-test scaffolding.
39
+ - `manager-stubs.ts` — `createBlockingFactory()`, `createSessionFactory()`.
40
+ - `mock-session.ts` — `createMockSession()`, `createSubagentSessionStub()`, `toSubagentSession()`, `toAgentSession()`.
41
+ - `make-subagent.ts` — `createTestSubagent()`, `makeStubExecution()`.
42
+
43
+ Convention: every shared helper module under `test/helpers/` has a companion `*.test.ts` (e.g. `subagent-session-io.test.ts`, `make-subagent.test.ts`).
44
+ New or extended shared helpers must extend that companion test.
45
+
46
+ The four current lifecycle clone families and the repeated blocks driving them:
47
+
48
+ | File | Family | Repeated block |
49
+ | ------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
50
+ | `create-subagent-session.test.ts` | 3 groups, 31 lines | A file-local `createSession()` mock-session builder, then `io.createSession.mockResolvedValue({ session })` + `await createSubagentSession({ snapshot, type }, createSubagentSessionDeps({ io, exec, registry[, lifecycle] }))` |
51
+ | `create-subagent-session-extension-tools.test.ts` | 3 groups, 29 lines | A near-identical file-local `createSessionWithExtensionToolRegistration()` builder, then the same `io.createSession.mockResolvedValue` + `createSubagentSession(...)` invoke block, plus post-bind `setActiveToolsByName` assertions |
52
+ | `subagent-manager.test.ts` | 4 groups, 40 lines | `spawn(STUB_SNAPSHOT, "general-purpose", "test", { description, isBackground: true, parentSession: { toolCallId } })` and the "spawn two bg agents under limit 1, assert one queued" arrange |
53
+ | `subagent-session.test.ts` | 3 groups, 22 lines | Repeated arrange blocks around the already-local `createSession(finalText)` / `makeSubagentSession()` / `emitTurnEnd()` helpers |
54
+
55
+ The two `createSubagentSession`-test session builders (`createSession` and `createSessionWithExtensionToolRegistration`) build near-identical mock sessions — the same eight `vi.fn()` methods — differing only in that the extension-tools variant flips `getActiveToolNames` between a before-bind and after-bind set.
56
+ That overlap is the one genuinely cross-file duplication and the strongest candidate for promotion to `test/helpers/`.
57
+ The manager and `subagent-session` families are intra-file.
58
+
59
+ AGENTS.md / testing-skill constraints that apply:
60
+
61
+ - When consolidating duplicate test factories, diff the default values across all copies before writing the shared factory — different defaults cause cascading assertion failures during migration.
62
+ - When a TDD step deletes a test helper, re-check the file's remaining imports for orphans — Biome's `noUnusedImports` is warning-level (exit 0), so it will not fail `pnpm run lint`.
63
+ - Run the full suite (not just the touched file) after each shared-helper change.
64
+
65
+ ## Design Overview
66
+
67
+ The guiding distinction: **promote to `test/helpers/` only what is genuinely shared across files; keep intra-file families as file-local helpers.**
68
+ Fallow's own recommendation for three of the four families is "extract shared function from `<file>`, `<file>`" (same file on both sides) — promoting those to `test/helpers/` would manufacture cross-file coupling that does not exist.
69
+
70
+ ### 1. Shared: a mock-session builder for `createSubagentSession` tests
71
+
72
+ Add `createFactorySession(overrides?)` to `test/helpers/subagent-session-io.ts`.
73
+ It returns the eight-method mock session both `createSubagentSession` test files build by hand today, with `getActiveToolNames` supporting a static set or a before/after-bind flip:
74
+
75
+ ```typescript
76
+ export interface FactorySessionOptions {
77
+ /** Tools active before bindExtensions(). Default ["read"]. */
78
+ toolsBeforeBind?: string[];
79
+ /** Tools active after bindExtensions(). Defaults to toolsBeforeBind (no extension registration). */
80
+ toolsAfterBind?: string[];
81
+ }
82
+
83
+ export function createFactorySession(options: FactorySessionOptions = {}) {
84
+ const before = options.toolsBeforeBind ?? ["read"];
85
+ const after = options.toolsAfterBind ?? before;
86
+ let bound = false;
87
+ return {
88
+ messages: [] as unknown[],
89
+ subscribe: vi.fn(() => () => {}),
90
+ prompt: vi.fn().mockResolvedValue(undefined),
91
+ abort: vi.fn(),
92
+ steer: vi.fn().mockResolvedValue(undefined),
93
+ dispose: vi.fn(),
94
+ getActiveToolNames: vi.fn(() => (bound ? after : before)),
95
+ setActiveToolsByName: vi.fn(),
96
+ bindExtensions: vi.fn(async () => { bound = true; }),
97
+ };
98
+ }
99
+ ```
100
+
101
+ Return type is deliberately unannotated so callers retain the `Mock<...>` methods (`mock.calls`, `mockResolvedValue`) — matching the existing `createSubagentSessionIO()` convention in this file.
102
+
103
+ `create-subagent-session.test.ts` replaces `createSession()` with `createFactorySession()`; the existing static `getActiveToolNames: () => ["read"]` becomes the default branch (`bound === after === before === ["read"]`), so the lone `bindExtensions` flip is inert there — behavior-preserving.
104
+ `create-subagent-session-extension-tools.test.ts` replaces `createSessionWithExtensionToolRegistration(before, after)` with `createFactorySession({ toolsBeforeBind: before, toolsAfterBind: after })`.
105
+
106
+ Consumer call site (verifies Tell-Don't-Ask: the helper returns a ready mock; the test arranges and asserts on it):
107
+
108
+ ```typescript
109
+ const session = createFactorySession({ toolsBeforeBind: ["read"], toolsAfterBind: ["read", "extension_tool"] });
110
+ io.createSession.mockResolvedValue({ session });
111
+ await createSubagentSession({ snapshot: STUB_SNAPSHOT, type: "test-agent" }, createSubagentSessionDeps({ io, exec, registry }));
112
+ expect(session.setActiveToolsByName.mock.calls[0][0]).toContain("extension_tool");
113
+ ```
114
+
115
+ The repeated `io.createSession.mockResolvedValue({ session })` + `createSubagentSession(...)` invoke pair is left in the tests rather than wrapped in an `invokeCreate()` helper: it is two lines, the `params` and `deps` overrides vary per test, and wrapping it would hide the system-under-test call behind a helper (procedure-splitting, not design improvement).
116
+ Extracting only the session builder removes the bulk of both families because the builder is the larger half of each clone.
117
+
118
+ ### 2. File-local: manager spawn / queued-pair arrangements
119
+
120
+ `subagent-manager.test.ts` already has file-local `spawnBg`/`spawnFg`/`createManager`/`defaultFactory`.
121
+ The remaining 40 lines are two intra-file patterns; add two file-local helpers:
122
+
123
+ ```typescript
124
+ /** Spawn a background agent carrying a parentSession.toolCallId (notification path). */
125
+ function spawnBgWithToolCall(mgr: SubagentManager, toolCallId: string, prompt = "test") {
126
+ return mgr.spawn(STUB_SNAPSHOT, "general-purpose", prompt, {
127
+ description: prompt,
128
+ isBackground: true,
129
+ parentSession: { toolCallId },
130
+ });
131
+ }
132
+
133
+ /** Arrange a manager at limit 1 with two bg agents: first runs, second queues. */
134
+ function arrangeQueuedPair() {
135
+ const { manager } = createManager({ createSubagentSession: createBlockingFactory(), getMaxConcurrent: () => 1 });
136
+ const running = spawnBg(manager, "a");
137
+ const queued = spawnBg(manager, "b");
138
+ return { manager, running, queued };
139
+ }
140
+ ```
141
+
142
+ These stay file-local: they reference manager-specific concerns and fallow scores the family as same-file.
143
+
144
+ ### 3. File-local: `subagent-session.test.ts` arrange blocks
145
+
146
+ The three small groups (22 lines) sit around the existing file-local `createSession(finalText)`, `makeSubagentSession()`, and `emitTurnEnd()` helpers.
147
+ Fold the repeated "build session, build SubagentSession, run/emit" arrange into one file-local helper (e.g. `arrangeSession(finalText, metaOverrides?)` returning `{ session, listeners, sub }`), keeping the per-test assertions inline.
148
+ No promotion to `test/helpers/` — this builder is specific to `subagent-session.test.ts`'s turn-loop assertions and is not used elsewhere.
149
+
150
+ ## Module-Level Changes
151
+
152
+ - `test/helpers/subagent-session-io.ts` — add `createFactorySession()` and `FactorySessionOptions`.
153
+ - `test/helpers/subagent-session-io.test.ts` — add a `describe("createFactorySession")` block: default tool set, before/after-bind flip, all eight methods present.
154
+ - `test/lifecycle/create-subagent-session.test.ts` — delete local `createSession()`; import and use `createFactorySession()`; re-check imports for orphans.
155
+ - `test/lifecycle/create-subagent-session-extension-tools.test.ts` — delete local `createSessionWithExtensionToolRegistration()`; use `createFactorySession({ toolsBeforeBind, toolsAfterBind })`; re-check imports.
156
+ - `test/lifecycle/subagent-manager.test.ts` — add file-local `spawnBgWithToolCall()` and `arrangeQueuedPair()`; route the cloned spawn/queued-pair sites through them.
157
+ - `test/lifecycle/subagent-session.test.ts` — add a file-local `arrangeSession()` helper; route the cloned arrange blocks through it.
158
+ - `docs/architecture/architecture.md` — mark Step 7 ✅ Complete and add a `Landed:` bullet (matching Steps 1–6), updating the families and duplication figures.
159
+
160
+ No `src/` files change.
161
+ No public surface changes, so `verify:public-types` is unaffected.
162
+ The `package-pi-subagents` SKILL.md's test count line ("994 tests across 63 files as of Phase 17 Step 4") is a point-in-time figure, not a Step-7 reference, and is left untouched.
163
+
164
+ ## Test Impact Analysis
165
+
166
+ This is a test-refactoring issue, so the lens is inverted from a production extraction:
167
+
168
+ 1. **New tests the change enables.**
169
+ Only helper self-tests: a `createFactorySession` block in `subagent-session-io.test.ts` (per the `test/helpers/` companion-test convention).
170
+ No new behavioral tests — the refactor must not change coverage.
171
+ 2. **Existing tests that become redundant.**
172
+ None are removed.
173
+ The duplication being removed is *arrange-block* duplication inside `it()` bodies, not duplicate `it()` cases.
174
+ Every existing assertion stays.
175
+ 3. **Tests that must stay as-is.**
176
+ All 228 lifecycle test cases keep their assertions; they are the regression guard.
177
+ Migration is green-to-green: after each file's migration the full suite must stay passing.
178
+ In particular, the post-bind `setActiveToolsByName` assertions in the extension-tools file and the turn-limit/`emitTurnEnd` assertions in `subagent-session.test.ts` are unchanged — only their session-construction arrange is swapped.
179
+
180
+ ## Invariants at risk
181
+
182
+ This step touches `subagent-manager.test.ts`, which pins invariants from earlier Phase 17 steps.
183
+ The `arrangeQueuedPair()` helper must not swallow these:
184
+
185
+ - **Step 1 ([#381]) / Step 3 ([#374]) — "every spawned agent has a `promise` at spawn, even while queued."**
186
+ Pinned by `subagent-manager.test.ts` → `it("gives a queued agent an awaitable promise at spawn (before its slot opens)")`, which asserts `getRecord(queuedId)!.promise` is a `Promise` while status is `"queued"`.
187
+ When this test adopts `arrangeQueuedPair()`, the helper must still return the queued id so the test can assert on `.promise` directly.
188
+ - **Step 3 ([#374]) — "zero external writes to `Subagent.promise`/`.notification` outside `subagent.ts`" (grep-verifiable).**
189
+ The helper migration must not reintroduce `record.promise =` or `record.notification =`.
190
+ The existing `record.notification!.markConsumed()` sites are method calls, not writes, and stay as-is.
191
+ Re-grep `test/lifecycle/` for `\.promise =` and `\.notification =` after the manager migration to confirm none were added.
192
+
193
+ ## TDD Order
194
+
195
+ This is a lift-and-shift refactor of tests; the suite is green at every step (no red phase — the existing assertions are the spec).
196
+ Each migration step runs the full package suite before committing.
197
+
198
+ 1. **Add shared `createFactorySession` + helper test.**
199
+ Surface: `test/helpers/subagent-session-io.ts`, `test/helpers/subagent-session-io.test.ts`.
200
+ Covers: default tool set, before/after-bind flip, method presence.
201
+ Run `pnpm --filter @gotgenes/pi-subagents exec vitest run test/helpers/subagent-session-io.test.ts` then `pnpm run check`.
202
+ Commit: `test: add shared createFactorySession mock-session builder (#378)`.
203
+ 2. **Migrate `create-subagent-session.test.ts` to `createFactorySession`.**
204
+ Delete local `createSession()`, re-check imports, run full suite green.
205
+ Commit: `test: use shared factory session in create-subagent-session tests (#378)`.
206
+ 3. **Migrate `create-subagent-session-extension-tools.test.ts` to `createFactorySession({ toolsBeforeBind, toolsAfterBind })`.**
207
+ Delete local `createSessionWithExtensionToolRegistration()`, re-check imports, run full suite green.
208
+ Commit: `test: use shared factory session in extension-tools tests (#378)`.
209
+ 4. **Consolidate `subagent-manager.test.ts` spawn/queued-pair arrangements.**
210
+ Add `spawnBgWithToolCall()` and `arrangeQueuedPair()`; route the cloned sites through them; re-grep for `.promise =`/`.notification =`; run full suite green.
211
+ Commit: `test: consolidate manager spawn arrangements (#378)`.
212
+ 5. **Consolidate `subagent-session.test.ts` arrange blocks.**
213
+ Add `arrangeSession()`; route cloned sites; run full suite green.
214
+ Commit: `test: consolidate subagent-session arrange blocks (#378)`.
215
+ 6. **Verify and record outcome.**
216
+ Run `pnpm exec fallow dupes -r packages/pi-subagents` and confirm lifecycle families ≤ 1 and package duplication < 600 lines; run `pnpm run check && pnpm run lint && pnpm fallow dead-code`.
217
+ Update `docs/architecture/architecture.md` Step 7 to ✅ Complete with a `Landed:` bullet and refreshed figures.
218
+ Commit: `docs: mark Phase 17 Step 7 complete (#378)`.
219
+
220
+ Steps 2–5 are independent of each other and order-insensitive; each only depends on Step 1's helper.
221
+
222
+ ## Risks and Mitigations
223
+
224
+ - **Divergent defaults between the two session builders cause assertion failures.**
225
+ Mitigation: the two builders are diffed in Background — they share eight identical methods and differ only in `getActiveToolNames`.
226
+ `createFactorySession` defaults `toolsAfterBind` to `toolsBeforeBind` so the non-extension file's static behavior is preserved exactly.
227
+ - **Over-extraction / procedure-splitting to chase the metric.**
228
+ Mitigation: the invoke pair (`mockResolvedValue` + `createSubagentSession(...)`) and the per-test assertions are left inline; only value-returning builders are extracted.
229
+ File-local families stay file-local rather than being force-promoted to `test/helpers/`.
230
+ - **Orphaned imports after deleting local builders (Biome `noUnusedImports` is warning-level, exit 0).**
231
+ Mitigation: re-check each migrated file's imports as part of its step; the Step 6 `pnpm run lint` + dead-code pass is the backstop.
232
+ - **Regressing a cross-step invariant with a green suite (the Step 3 lesson).**
233
+ Mitigation: the "Invariants at risk" section names the pinning tests and the grep checks folded into Step 4.
234
+
235
+ ## Open Questions
236
+
237
+ - Whether package-wide duplication lands below 600 lines from Step 7 alone or needs Step 8 ([#379]).
238
+ Arithmetic says yes (669 − ~122 ≈ 547), but the Step 6 `fallow dupes` measurement is the authority; if it lands above 600, note it and confirm the lifecycle-families target (≤ 1) is still met rather than pulling Step 8 work forward.
239
+
240
+ [#374]: https://github.com/gotgenes/pi-packages/issues/374
241
+ [#379]: https://github.com/gotgenes/pi-packages/issues/379
242
+ [#380]: https://github.com/gotgenes/pi-packages/issues/380
243
+ [#381]: https://github.com/gotgenes/pi-packages/issues/381