@gotgenes/pi-subagents 10.0.1 → 10.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.
@@ -0,0 +1,322 @@
1
+ ---
2
+ issue: 227
3
+ issue_title: "Evolve AgentRecord into Agent with behavior (Phase 15, Step 1)"
4
+ ---
5
+
6
+ # Evolve AgentRecord into Agent with behavior
7
+
8
+ ## Problem Statement
9
+
10
+ `AgentRecord` is an anemic domain model — it holds identity, status transitions, and stats but no behavior.
11
+ `AgentManager` reaches into records 37 times, performing work that belongs on the agent:
12
+
13
+ - **abort**: `AgentManager.abort()` checks `record.status`, calls `record.abortController?.abort()`, calls `record.markStopped()` — this is the agent aborting itself, but the logic lives on the manager.
14
+ - **pending steers**: per-agent steer buffers live in a manager-level `Map<string, string[]>`, not on the agent.
15
+ - **steer flushing**: `flushPendingSteers(id, session)` iterates the manager map — should be `agent.flushPendingSteers(session)`.
16
+ - **worktree setup**: `setupWorktree()` creates a worktree and attaches it to the record — the agent should set up its own worktree.
17
+
18
+ ## Goals
19
+
20
+ - Move per-agent behavior (`abort`, `queueSteer`/`flushPendingSteers`, `setupWorktree`) from `AgentManager` to the agent.
21
+ - `AgentManager` delegates to agents via Tell-Don't-Ask instead of reaching into records.
22
+ - Rename `AgentRecord` → `Agent`, `AgentRecordStatus` → `AgentStatus`, `AgentRecordInit` → `AgentInit` across the codebase.
23
+ - All changes are internal — the public `SubagentsService` API (`service.ts`) is unaffected.
24
+
25
+ ## Non-Goals
26
+
27
+ - **`RunHandle` ownership** — moves to `Agent` in #228, not here.
28
+ - **Async `startAgent`** — deferred to #228.
29
+ - **`onSessionCreated` observer** — deferred to #229.
30
+ - **`ConcurrencyQueue` extraction** — deferred to #230.
31
+ Queue removal logic stays on `AgentManager.abort()` until then.
32
+ - **Relay deps** — deferred to #231.
33
+ - **Resume unification** — deferred to #232.
34
+
35
+ ## Background
36
+
37
+ ### Relevant modules
38
+
39
+ | Module | Responsibility | Relationship to this change |
40
+ | ------------------------------------------ | ------------------------------------------------------- | ---------------------------------------------------------------------------------- |
41
+ | `src/lifecycle/agent-record.ts` (201 LOC) | Status state machine, stats accumulation | Gains behavior methods, renames to `agent.ts` |
42
+ | `src/lifecycle/agent-manager.ts` (541 LOC) | Agent collection, spawn, abort, queue, steer buffering | Loses private methods, delegates to agent |
43
+ | `src/lifecycle/worktree.ts` | `WorktreeManager` interface and git worktree operations | Agent calls `worktrees.create()` directly |
44
+ | `src/lifecycle/worktree-state.ts` | Per-agent worktree lifecycle state | Already attached to agent — `setupWorktree` formalizes this |
45
+ | `src/tools/steer-tool.ts` | LLM-facing steer tool | Calls `record.queueSteer()` directly instead of `manager.queueSteer()` |
46
+ | `src/service/service-adapter.ts` | Cross-extension API adapter | Calls `record.queueSteer()` directly; `queueSteer` removed from `AgentManagerLike` |
47
+ | `src/observation/record-observer.ts` | Session event → agent stats accumulation | Import rename only |
48
+ | `src/types.ts` | Internal re-exports | Re-export updates |
49
+ | `test/helpers/make-record.ts` | Shared test factory | Renames to `make-agent.ts`, factory → `createTestAgent()` |
50
+
51
+ ### Constraints
52
+
53
+ - The public export from `package.json` is `"./src/service.ts"` only — `AgentRecord` is internal, so the rename is not breaking for consumers.
54
+ - `WorktreeManager` is injected via the manager's constructor — `Agent.setupWorktree()` receives it as a parameter (no new constructor dependency).
55
+ - Queue removal in `abort()` stays on `AgentManager` because the queue is manager-owned until #230 extracts `ConcurrencyQueue`.
56
+
57
+ ## Design Overview
58
+
59
+ ### New methods on Agent
60
+
61
+ ```typescript
62
+ class Agent {
63
+ // Existing: markRunning, markCompleted, markAborted, markSteered, markError, markStopped,
64
+ // incrementToolUses, addUsage, incrementCompactions, resetForResume
65
+
66
+ // --- New behavior ---
67
+
68
+ /** Buffer a steer message for delivery once the session is ready. */
69
+ queueSteer(message: string): void;
70
+
71
+ /** Flush buffered steers to the session and clear the buffer. */
72
+ flushPendingSteers(session: AgentSession): void;
73
+
74
+ /** Abort a running agent: fire AbortController, transition to stopped. */
75
+ abort(): boolean;
76
+
77
+ /** Create a worktree for isolated execution. Throws if impossible. */
78
+ setupWorktree(worktrees: WorktreeManager, isolation: IsolationMode | undefined): string | undefined;
79
+ }
80
+ ```
81
+
82
+ ### Steer buffering moves to agent
83
+
84
+ Before: `AgentManager` owns `pendingSteers: Map<string, string[]>` and exposes `queueSteer(id, msg)`.
85
+ After: each `Agent` owns `private pendingSteers: string[] = []`.
86
+ Callers that already hold a record reference (steer tool, service adapter) call `agent.queueSteer(msg)` directly — the manager's `queueSteer` method and the `pendingSteers` map are removed.
87
+
88
+ Consumer call-site (steer tool):
89
+
90
+ ```typescript
91
+ // Before:
92
+ this.manager.queueSteer(record.id, params.message);
93
+ // After:
94
+ record.queueSteer(params.message);
95
+ ```
96
+
97
+ ### Abort moves to agent
98
+
99
+ `Agent.abort()` encapsulates the running-check + controller.abort + markStopped sequence:
100
+
101
+ ```typescript
102
+ abort(): boolean {
103
+ if (this._status !== "running") return false;
104
+ this.abortController?.abort();
105
+ this.markStopped();
106
+ return true;
107
+ }
108
+ ```
109
+
110
+ `AgentManager.abort(id)` retains queue-removal logic (queue is manager-owned until #230) and delegates the running case to `agent.abort()`.
111
+ `AgentManager.abortAll()` calls `agent.abort()` for running agents.
112
+
113
+ ### Worktree setup moves to agent
114
+
115
+ `Agent.setupWorktree(worktrees, isolation)` replaces `AgentManager.setupWorktree(id, record, isolation)`.
116
+ The agent creates the worktree, sets `this.worktreeState`, and returns the worktree path.
117
+ The error message for impossible worktree creation stays identical.
118
+
119
+ ### Rename strategy
120
+
121
+ The rename (`AgentRecord` → `Agent`) is the final step — a purely mechanical search-and-replace with no behavior change.
122
+ This keeps behavior-adding commits small and reviewable, then consolidates the rename noise into one commit.
123
+
124
+ Files affected by the rename:
125
+
126
+ | Layer | Files |
127
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
128
+ | Source (lifecycle) | `agent-record.ts` → `agent.ts`, `agent-manager.ts`, `execution-state.ts` |
129
+ | Source (observation) | `record-observer.ts`, `notification.ts` |
130
+ | Source (tools) | `agent-tool.ts`, `steer-tool.ts`, `get-result-tool.ts`, `background-spawner.ts`, `foreground-runner.ts` |
131
+ | Source (UI) | `agent-menu.ts`, `agent-creation-wizard.ts`, `conversation-viewer.ts` |
132
+ | Source (service) | `service-adapter.ts` |
133
+ | Source (types) | `types.ts` |
134
+ | Tests | `agent-record.test.ts` → `agent.test.ts`, `agent-manager.test.ts`, `record-observer.test.ts`, `steer-tool.test.ts`, `get-result-tool.test.ts`, `service-adapter.test.ts` |
135
+ | Test helpers | `make-record.ts` → `make-agent.ts` |
136
+
137
+ ## Module-Level Changes
138
+
139
+ ### `src/lifecycle/agent-record.ts` → `src/lifecycle/agent.ts`
140
+
141
+ 1. Add `private pendingSteers: string[] = []` field.
142
+ 2. Add `queueSteer(message: string): void` — pushes to `pendingSteers`.
143
+ 3. Add `flushPendingSteers(session: AgentSession): void` — iterates buffer, calls `session.steer()`, clears array.
144
+ 4. Add `abort(): boolean` — if running, fires controller and marks stopped.
145
+ 5. Add `setupWorktree(worktrees: WorktreeManager, isolation: IsolationMode | undefined): string | undefined` — creates worktree, sets `worktreeState`, returns path.
146
+ 6. Add import for `WorktreeState`, `WorktreeManager`, `IsolationMode`.
147
+ 7. Rename class `AgentRecord` → `Agent`, type `AgentRecordStatus` → `AgentStatus`, interface `AgentRecordInit` → `AgentInit`.
148
+
149
+ ### `src/lifecycle/agent-manager.ts`
150
+
151
+ 1. Remove `private pendingSteers = new Map<string, string[]>()`.
152
+ 2. Remove `queueSteer(id, message)` public method.
153
+ 3. Remove `private flushPendingSteers(id, session)` method.
154
+ 4. In `startAgent`'s `onSessionCreated` callback: replace `this.flushPendingSteers(id, session)` with `record.flushPendingSteers(session)`.
155
+ 5. Remove `private setupWorktree(id, record, isolation)` method.
156
+ 6. In `startAgent`: replace `this.setupWorktree(id, record, options.isolation)` with `record.setupWorktree(this.worktrees, options.isolation)`.
157
+ 7. Simplify `abort(id)`: delegate running case to `record.abort()`.
158
+ 8. Simplify `abortAll()`: call `record.abort()` for running agents.
159
+ 9. In `removeRecord`: remove `this.pendingSteers.delete(id)`.
160
+ 10. Update imports: `AgentRecord` → `Agent`, `AgentRecordInit` → `AgentInit` (if used).
161
+
162
+ ### `src/tools/steer-tool.ts`
163
+
164
+ 1. Remove `queueSteer` from `SteerToolManager` interface.
165
+ 2. Replace `this.manager.queueSteer(record.id, params.message)` with `record.queueSteer(params.message)`.
166
+ 3. Update import: `AgentRecord` → `Agent`.
167
+
168
+ ### `src/service/service-adapter.ts`
169
+
170
+ 1. Remove `queueSteer` from `AgentManagerLike` interface.
171
+ 2. In `steer()`: replace `this.manager.queueSteer(id, message)` with `record.queueSteer(message)` and return `true`.
172
+ 3. Update import: `AgentRecord` → `Agent`.
173
+
174
+ ### `src/types.ts`
175
+
176
+ 1. Update re-export: `AgentRecord` → `Agent`, source path `#src/lifecycle/agent-record` → `#src/lifecycle/agent`.
177
+
178
+ ### `src/observation/record-observer.ts`
179
+
180
+ 1. Update import and parameter types: `AgentRecord` → `Agent`.
181
+
182
+ ### `src/observation/notification.ts`
183
+
184
+ 1. Update import and parameter types: `AgentRecord` → `Agent`.
185
+
186
+ ### `src/tools/*.ts` (agent-tool, get-result-tool, background-spawner, foreground-runner)
187
+
188
+ 1. Update imports and type annotations: `AgentRecord` → `Agent`.
189
+
190
+ ### `src/ui/*.ts` (agent-menu, agent-creation-wizard, conversation-viewer)
191
+
192
+ 1. Update imports and type annotations: `AgentRecord` → `Agent`.
193
+
194
+ ### `test/helpers/make-record.ts` → `test/helpers/make-agent.ts`
195
+
196
+ 1. Rename file.
197
+ 2. Update imports: `AgentRecord` → `Agent`, `AgentRecordInit` → `AgentInit`.
198
+ 3. Rename factory: `createTestRecord` → `createTestAgent`.
199
+ 4. Update return type annotation.
200
+
201
+ ### `test/lifecycle/agent-record.test.ts` → `test/lifecycle/agent.test.ts`
202
+
203
+ 1. Rename file.
204
+ 2. Update import: `AgentRecord` → `Agent`.
205
+ 3. Update all `describe` block names and `new AgentRecord(...)` calls.
206
+ 4. Add new test blocks for `queueSteer`, `flushPendingSteers`, `abort`, `setupWorktree`.
207
+
208
+ ### `test/lifecycle/agent-manager.test.ts`
209
+
210
+ 1. Remove tests for `AgentManager.queueSteer` (behavior moved to agent).
211
+ 2. Update `abort()` tests to verify delegation.
212
+ 3. Update imports if `AgentRecord` type is referenced.
213
+
214
+ ### `test/tools/steer-tool.test.ts`
215
+
216
+ 1. Remove `queueSteer` from mock manager.
217
+ 2. Update "session not ready" test to verify `record.queueSteer()` is called.
218
+
219
+ ### `test/service/service-adapter.test.ts`
220
+
221
+ 1. Remove `queueSteer` from mock managers.
222
+ 2. Update steer tests to verify `record.queueSteer()`.
223
+
224
+ ### `packages/pi-subagents/docs/architecture/architecture.md`
225
+
226
+ 1. Update file listing: `agent-record.ts` → `agent.ts`.
227
+ 2. Update `AgentRecordInit` reference in interface width table.
228
+
229
+ ## Test Impact Analysis
230
+
231
+ ### New unit tests enabled by the extraction
232
+
233
+ 1. **`Agent.queueSteer()` / `Agent.flushPendingSteers()`** — isolated tests for steer buffering without needing a full `AgentManager` setup.
234
+ Previously the steer buffering was only testable via `AgentManager` integration tests.
235
+ 2. **`Agent.abort()`** — isolated tests for the abort state machine (running → stopped, not-running → no-op) without needing manager scaffolding.
236
+ 3. **`Agent.setupWorktree()`** — isolated tests for worktree creation and error handling with a mock `WorktreeManager`, without full spawn infrastructure.
237
+
238
+ ### Existing tests that become redundant
239
+
240
+ 1. `AgentManager — queueSteer` tests — the behavior is now tested directly on `Agent`.
241
+ The manager no longer has a `queueSteer` method.
242
+ 2. Parts of `AgentManager — abort` tests that verify controller.abort + markStopped — these are now `Agent.abort()` tests.
243
+ The manager abort tests should focus on queue-removal logic and delegation.
244
+
245
+ ### Existing tests that must stay
246
+
247
+ 1. `AgentManager — abort` tests for the "queued" case (queue removal is still manager-owned).
248
+ 2. `AgentManager — abortAll` tests (orchestrates both queue clearing and agent abort).
249
+ 3. All `AgentManager — spawn/spawnAndWait` tests — the spawn flow still lives on the manager.
250
+ 4. `steer-tool` and `service-adapter` tests for the steer path — updated to verify the new call pattern.
251
+
252
+ ## TDD Order
253
+
254
+ 1. **Red/Green: add `queueSteer()` and `flushPendingSteers()` to `AgentRecord`**
255
+ - Add tests in `agent-record.test.ts` for buffering and flushing steers.
256
+ - Implement the methods on `AgentRecord`.
257
+ - Commit: `feat(pi-subagents): add steer buffering to AgentRecord`
258
+
259
+ 2. **Refactor: delegate steer buffering from manager to agent**
260
+ - Remove `pendingSteers` map, `queueSteer()`, `flushPendingSteers()` from `AgentManager`.
261
+ - In `startAgent`'s `onSessionCreated`: call `record.flushPendingSteers(session)`.
262
+ - In `removeRecord`: remove `pendingSteers.delete(id)`.
263
+ - Update `steer-tool.ts`: remove `queueSteer` from `SteerToolManager`, call `record.queueSteer()`.
264
+ - Update `service-adapter.ts`: remove `queueSteer` from `AgentManagerLike`, call `record.queueSteer()`.
265
+ - Remove `AgentManager — queueSteer` tests; update steer-tool and service-adapter tests.
266
+ - Run `pnpm run check` to verify no type errors.
267
+ - Commit: `refactor(pi-subagents): delegate steer buffering from manager to agent`
268
+
269
+ 3. **Red/Green: add `abort()` to `AgentRecord`**
270
+ - Add tests in `agent-record.test.ts`: running → aborts and returns true; non-running → returns false; no controller → still marks stopped.
271
+ - Implement the method.
272
+ - Commit: `feat(pi-subagents): add abort() to AgentRecord`
273
+
274
+ 4. **Refactor: delegate abort from manager to agent**
275
+ - Simplify `AgentManager.abort()`: queued case stays, running case delegates to `record.abort()`.
276
+ - Simplify `AgentManager.abortAll()`: call `record.abort()` for running agents.
277
+ - Update manager abort tests to focus on queue removal and delegation.
278
+ - Commit: `refactor(pi-subagents): delegate abort from manager to agent`
279
+
280
+ 5. **Red/Green: add `setupWorktree()` to `AgentRecord`**
281
+ - Add tests in `agent-record.test.ts`: non-worktree returns undefined; worktree created → sets `worktreeState` and returns path; creation fails → throws.
282
+ - Implement the method (import `WorktreeState`, `WorktreeManager`, `IsolationMode`).
283
+ - Commit: `feat(pi-subagents): add setupWorktree() to AgentRecord`
284
+
285
+ 6. **Refactor: delegate worktree setup from manager to agent**
286
+ - Remove `private setupWorktree()` from `AgentManager`.
287
+ - In `startAgent`: replace `this.setupWorktree(id, record, options.isolation)` with `record.setupWorktree(this.worktrees, options.isolation)`.
288
+ - Update any tests that verify worktree setup delegation.
289
+ - Commit: `refactor(pi-subagents): delegate worktree setup from manager to agent`
290
+
291
+ 7. **Rename `AgentRecord` → `Agent` across codebase**
292
+ - Rename `src/lifecycle/agent-record.ts` → `src/lifecycle/agent.ts`.
293
+ - Rename class `AgentRecord` → `Agent`, type `AgentRecordStatus` → `AgentStatus`, interface `AgentRecordInit` → `AgentInit`.
294
+ - Update all source imports and type references (~20 source files).
295
+ - Rename `test/lifecycle/agent-record.test.ts` → `test/lifecycle/agent.test.ts`.
296
+ - Rename `test/helpers/make-record.ts` → `test/helpers/make-agent.ts`, factory `createTestRecord` → `createTestAgent`.
297
+ - Update all test imports and references (~10 test files).
298
+ - Rename `subscribeRecordObserver` → `subscribeAgentObserver` and `RecordObserverOptions` → `AgentObserverOptions` in `record-observer.ts`.
299
+ - Run `pnpm run check` and full test suite.
300
+ - Commit: `refactor(pi-subagents): rename AgentRecord to Agent`
301
+
302
+ 8. **Update architecture docs**
303
+ - Update `docs/architecture/architecture.md` file listing: `agent-record.ts` → `agent.ts`.
304
+ - Update `AgentRecordInit` → `AgentInit` in the interface width table.
305
+ - Update the Phase 15 Step 1 entry to reflect completion.
306
+ - Commit: `docs(pi-subagents): update architecture for Agent rename`
307
+
308
+ ## Risks and Mitigations
309
+
310
+ 1. **Large rename diff in step 7** — The rename touches ~30 files.
311
+ Mitigated by making it a purely mechanical change (no behavior change) in a dedicated commit, so reviewers can verify it's a clean rename.
312
+ 2. **Queue-removal leaks into agent** — `Agent.abort()` must NOT remove from the manager's queue (that's #230's concern).
313
+ Mitigated by scoping `Agent.abort()` to only handle the running case; `AgentManager.abort()` retains queue removal.
314
+ 3. **Interface changes cascade** — Removing `queueSteer` from `SteerToolManager` and `AgentManagerLike` requires updating test mocks.
315
+ Mitigated by handling interface changes and test updates in the same step (step 2).
316
+ 4. **Test factory rename ripple** — `createTestRecord` → `createTestAgent` touches many test files.
317
+ Mitigated by including this in the rename step (step 7), which is already a mechanical change.
318
+
319
+ ## Open Questions
320
+
321
+ None — the issue's proposed change is unambiguous and scoped.
322
+ `RunHandle` ownership and other Phase 15 steps are explicitly deferred to their own issues.
@@ -0,0 +1,288 @@
1
+ ---
2
+ issue: 228
3
+ issue_title: "Convert startAgent to async/await, move run lifecycle to Agent (Phase 15, Step 2)"
4
+ ---
5
+
6
+ # Convert startAgent to async/await, dissolve RunHandle into Agent
7
+
8
+ ## Problem Statement
9
+
10
+ `startAgent` is synchronous and uses `.then()`/`.catch()` to handle the runner promise.
11
+ This forces a promise-chain callback style even though `Agent` (as of #227) already owns per-agent behavior.
12
+
13
+ `RunHandle` is a private class in `agent-manager.ts` that does 6 things — 5 of which are Agent concerns (status transitions, worktree cleanup, execution state updates, listener lifecycle, signal wiring).
14
+ The only non-Agent concern is `onFinished`, a callback that connects to the manager's concurrency queue drain.
15
+
16
+ `resume()` duplicates the same pattern manually: subscribe observer, try/catch with `markCompleted`/`markError`, finally unsub.
17
+ Issue #232 wants to unify resume with the run lifecycle, and the architecture doc says "resume becomes a 4-line delegation."
18
+ If we just move `RunHandle` to `Agent` as a separate class, `resume()` still can't use it naturally — the signatures differ.
19
+ But if we dissolve `RunHandle` into Agent methods, both paths use the same primitives.
20
+
21
+ ## Goals
22
+
23
+ - Zero `.then()`/`.catch()` in `agent-manager.ts`.
24
+ - Dissolve `RunHandle` into Agent methods: `completeRun`, `failRun`, `wireSignal`, `attachObserver`, `releaseListeners`, `onRunFinished` setter.
25
+ - `startAgent` is a straightforward async method: setup → await → handle result.
26
+ - `spawn()` assigns `record.promise = this.startAgent(...)`.
27
+ - Prepare the ground for #232 (resume unification) by giving Agent the run lifecycle primitives that `resume()` can reuse.
28
+
29
+ ## Non-Goals
30
+
31
+ - **Resume unification** — deferred to #232.
32
+ That issue will use the new Agent methods to simplify `AgentManager.resume()`.
33
+ - **`onSessionCreated` observer** — deferred to #229.
34
+ The `onSessionCreated` callback in `startAgent` stays as-is.
35
+ - **`ConcurrencyQueue` extraction** — deferred to #230.
36
+ - **Relay deps** — deferred to #231.
37
+
38
+ ## Background
39
+
40
+ ### Relevant modules
41
+
42
+ | Module | LOC | Relationship to this change |
43
+ | -------------------------------------- | --- | ------------------------------------------------------------- |
44
+ | `src/lifecycle/agent-manager.ts` | 492 | Loses `RunHandle` class (~85 LOC), `startAgent` becomes async |
45
+ | `src/lifecycle/agent.ts` | 260 | Gains run lifecycle methods (~80 LOC) |
46
+ | `src/lifecycle/agent-runner.ts` | — | Exports `RunResult` type, now imported by `agent.ts` |
47
+ | `test/lifecycle/agent.test.ts` | 501 | Gains ~120 LOC of run lifecycle tests |
48
+ | `test/lifecycle/agent-manager.test.ts` | 768 | One assertion update (`Promise<void>`) |
49
+
50
+ ### What RunHandle does today
51
+
52
+ | Concern | RunHandle method | Who should own it |
53
+ | ---------------------------------------------------------------------- | -------------------- | ----------------------------------------------- |
54
+ | Listener lifecycle (unsub + detachFn) | `releaseListeners()` | Agent — per-run cleanup handles |
55
+ | Run completion (worktree cleanup, status transition, execution update) | `complete(result)` | Agent — all state mutations target Agent fields |
56
+ | Run failure (error marking, best-effort worktree cleanup) | `fail(err)` | Agent — same |
57
+ | Signal wiring (parent abort → child abort) | `wireSignal()` | Agent — per-run handle, released on completion |
58
+ | Observer attachment (session event subscription) | `attachObserver()` | Agent — per-run handle, released on completion |
59
+ | onFinished callback (concurrency drain) | `fireOnFinished()` | Manager concern, but just a stored `() => void` |
60
+
61
+ Five of six are Agent concerns.
62
+ RunHandle reaches into `this.record` for every operation and talks through `this.record.worktreeState` to a stranger.
63
+
64
+ ### Dependency flow (no cycles)
65
+
66
+ `agent.ts` gains a type-only import of `RunResult` from `agent-runner.ts`.
67
+ `agent-runner.ts` imports from `agent-manager.ts` (not `agent.ts`), so no cycle is created.
68
+
69
+ ### Constraints from AGENTS.md
70
+
71
+ - `promise` type change from `Promise<string>` to `Promise<void>` is internal — `Agent` is not exported from `package.json`.
72
+ - Worktree setup hoist preserves the synchronous-throw contract in `spawn()` (callers rely on catching `isolation: "worktree"` errors synchronously).
73
+
74
+ ## Design Overview
75
+
76
+ ### Dissolve RunHandle into Agent methods
77
+
78
+ Agent gains per-run listener fields and run lifecycle methods:
79
+
80
+ ```typescript
81
+ class Agent {
82
+ // --- Per-run listener state (released on completion or resume reset) ---
83
+ private _unsub?: () => void;
84
+ private _detachFn?: () => void;
85
+ private _onRunFinished?: () => void;
86
+
87
+ /** Wire a parent AbortSignal so it stops this agent when fired. */
88
+ wireSignal(signal: AbortSignal | undefined, onAbort: () => void): void;
89
+
90
+ /** Store the record-observer unsubscribe handle. */
91
+ attachObserver(unsub: () => void): void;
92
+
93
+ /** Release observer + signal listener handles. */
94
+ releaseListeners(): void;
95
+
96
+ /** Set the callback fired once when the run finishes (for concurrency drain). */
97
+ setOnRunFinished(fn: () => void): void;
98
+
99
+ /** Complete a run: release listeners, worktree cleanup, status transition,
100
+ execution update, fire onRunFinished. */
101
+ completeRun(result: RunResult, worktrees: WorktreeManager): void;
102
+
103
+ /** Fail a run: mark error, release listeners, best-effort worktree cleanup,
104
+ fire onRunFinished. */
105
+ failRun(err: unknown, worktrees: WorktreeManager): void;
106
+ }
107
+ ```
108
+
109
+ `completeRun` and `failRun` take `worktrees: WorktreeManager` as a parameter rather than storing it on Agent.
110
+ Worktrees are only needed at run end — storing the reference would widen Agent's dependency surface for a single use.
111
+
112
+ Consumer call-site after the change (`startAgent`):
113
+
114
+ ```typescript
115
+ record.setOnRunFinished(
116
+ options.isBackground ? () => this.finalizeBackgroundRun(record) : undefined,
117
+ );
118
+ record.wireSignal(options.signal, () => this.abort(id));
119
+ try {
120
+ const result = await this.runner.run(...);
121
+ record.completeRun(result, this.worktrees);
122
+ } catch (err) {
123
+ record.failRun(err, this.worktrees);
124
+ }
125
+ ```
126
+
127
+ ### Narrow `promise` to `Promise<void>`
128
+
129
+ The resolved string value of `record.promise` is dead — every consumer just `await`s it and reads `record.result`.
130
+ One test asserts `resolves.toBe("done")`; all others use `await record.promise`.
131
+ Narrowing to `Promise<void>` first makes the async conversion clean (async `startAgent` naturally returns `Promise<void>`).
132
+
133
+ ### Hoist worktree setup from `startAgent` to callers
134
+
135
+ `record.setupWorktree()` can throw synchronously (strict isolation failure).
136
+ `spawn()` catches this and removes the orphan record.
137
+ `drainQueue()` catches it and marks the record as errored.
138
+
139
+ If `startAgent` becomes `async`, synchronous throws become rejected promises — neither caller catches them.
140
+ Fix: move `record.setupWorktree()` into the callers' existing try-catch blocks before calling async `startAgent`.
141
+ `startAgent` reads `record.worktreeState?.path` for the cwd instead.
142
+
143
+ ### `resetForResume` releases listeners
144
+
145
+ After dissolution, `resetForResume` must call `releaseListeners()` and clear `_onRunFinished` to prevent stale handles from a previous run leaking into the resumed run.
146
+
147
+ ## Module-Level Changes
148
+
149
+ ### `src/lifecycle/agent.ts`
150
+
151
+ 1. Add per-run listener fields: `_unsub`, `_detachFn`, `_onRunFinished`.
152
+ 2. Add `wireSignal(signal, onAbort)` — logic from `RunHandle.wireSignal`.
153
+ 3. Add `attachObserver(unsub)` — logic from `RunHandle.attachObserver`.
154
+ 4. Add `releaseListeners()` — logic from `RunHandle.releaseListeners` (public).
155
+ 5. Add `setOnRunFinished(fn)` — stores the callback.
156
+ 6. Add private `fireOnRunFinished()` — idempotent clear-then-call pattern from `RunHandle.fireOnFinished`.
157
+ 7. Add `completeRun(result, worktrees)` — logic from `RunHandle.complete`, returns `void` (not `string`).
158
+ 8. Add `failRun(err, worktrees)` — logic from `RunHandle.fail`.
159
+ 9. Update `resetForResume` — call `releaseListeners()` and clear `_onRunFinished`.
160
+ 10. Change `promise` type from `Promise<string>` to `Promise<void>` (on both `AgentInit` and the class field).
161
+ 11. Add imports: `type RunResult` from `agent-runner`, `debugLog` from `debug`.
162
+
163
+ ### `src/lifecycle/agent-manager.ts`
164
+
165
+ 1. Delete `RunHandle` class (~85 lines).
166
+ 2. Remove `import type { RunResult }` (moved to `agent.ts`; `AgentRunner` import stays).
167
+ 3. Convert `startAgent` to `async`, returning `Promise<void>`.
168
+ 4. Replace RunHandle creation with Agent method calls: `record.setOnRunFinished(...)`, `record.wireSignal(...)`.
169
+ 5. Replace `handle.attachObserver(...)` with `record.attachObserver(...)` in `onSessionCreated`.
170
+ 6. Replace `.then()`/`.catch()` chain with `try { await ...; record.completeRun(...) } catch { record.failRun(...) }`.
171
+ 7. Remove `record.promise = this.runner.run(...)` assignment — `record.promise` is now assigned by `spawn`/`drainQueue`.
172
+ 8. In `spawn()`: hoist `record.setupWorktree(...)` before `startAgent` call (inside existing try-catch); assign `record.promise = this.startAgent(...)`.
173
+ 9. In `drainQueue()`: hoist `record.setupWorktree(...)` before `startAgent` call (inside existing try-catch); assign `record.promise = this.startAgent(...)`.
174
+ 10. In `startAgent`: remove `record.setupWorktree()` call; read `record.worktreeState?.path` for cwd.
175
+ 11. Update `waitForAll` filter: `Promise<string>` → `Promise<void>`.
176
+
177
+ ### `test/lifecycle/agent.test.ts`
178
+
179
+ 1. Add `describe("Agent — completeRun")` — status transitions (completed/aborted/steered), worktree cleanup with branch append, execution state update, `onRunFinished` fires once, listeners released.
180
+ 2. Add `describe("Agent — failRun")` — marks error, best-effort worktree cleanup, `onRunFinished` fires once, listeners released.
181
+ 3. Add `describe("Agent — wireSignal")` — connects parent signal to abort callback, `releaseListeners` detaches.
182
+ 4. Add `describe("Agent — attachObserver / releaseListeners")` — stores unsub, calls it on release, idempotent.
183
+ 5. Update `describe("Agent — resetForResume")` — verify listeners are released and `_onRunFinished` is cleared.
184
+
185
+ ### `test/lifecycle/agent-manager.test.ts`
186
+
187
+ 1. Update one assertion: `resolves.toBe("done")` → `resolves.toBeUndefined()`.
188
+
189
+ ### `packages/pi-subagents/docs/architecture/architecture.md`
190
+
191
+ 1. Update Phase 15 smell table — mark `startAgent` callback row as resolved.
192
+ 2. Update Step 2 description to note RunHandle dissolution (not just async conversion).
193
+ 3. Update Step 6 (#232) description — RunHandle no longer exists; Agent already has `completeRun`/`failRun`/`releaseListeners` that `resume()` can use directly.
194
+
195
+ ## Test Impact Analysis
196
+
197
+ ### New unit tests enabled by the dissolution
198
+
199
+ 1. **`Agent.completeRun()`** — isolated tests for run completion logic (status transitions based on `RunResult` flags, worktree cleanup, execution update, onRunFinished firing) without needing a full `AgentManager` scaffold with a mock runner.
200
+ 2. **`Agent.failRun()`** — isolated tests for error handling and best-effort cleanup.
201
+ 3. **`Agent.wireSignal()` / `Agent.attachObserver()` / `Agent.releaseListeners()`** — isolated tests for listener lifecycle without spawning a real agent.
202
+
203
+ These behaviors were previously only testable through `AgentManager` integration tests that required setting up a mock runner, worktrees, and observer.
204
+
205
+ ### Existing tests that must stay
206
+
207
+ 1. All `AgentManager — spawn/spawnAndWait` tests — they verify the full spawn flow including async orchestration.
208
+ 2. All worktree isolation tests — they verify the synchronous-throw contract in `spawn()`.
209
+ 3. All queue/concurrency tests — they verify the manager's orchestration around `drainQueue`.
210
+ 4. All completion/notification tests — they verify end-to-end flow through the observer.
211
+
212
+ ### Existing tests that change
213
+
214
+ 1. One assertion in `agent-manager.test.ts`: `resolves.toBe("done")` → `resolves.toBeUndefined()` (promise type narrowing).
215
+
216
+ ## TDD Order
217
+
218
+ 1. **Narrow `Agent.promise` from `Promise<string>` to `Promise<void>`**
219
+ - Change `AgentInit.promise` and `Agent.promise` field types.
220
+ - In `startAgent`: wrap `.then()` callback body in braces (discard `handle.complete` return); remove `return ""` from `.catch()` callback.
221
+ - Update `waitForAll` filter type guard.
222
+ - Update one test assertion: `resolves.toBe("done")` → `resolves.toBeUndefined()`.
223
+ - Run `pnpm run check` + `pnpm vitest run`.
224
+ - Commit: `refactor(pi-subagents): narrow Agent.promise to Promise<void>`
225
+
226
+ 2. **Red/Green: add run lifecycle methods to Agent**
227
+ - Red: add tests in `agent.test.ts` for `completeRun`, `failRun`, `wireSignal`, `attachObserver`/`releaseListeners`, `resetForResume` listener cleanup.
228
+ - Green: implement the methods on `Agent` — `wireSignal`, `attachObserver`, `releaseListeners`, `setOnRunFinished`, `fireOnRunFinished`, `completeRun`, `failRun`; update `resetForResume`.
229
+ - Add `import type { RunResult }` and `import { debugLog }` to `agent.ts`.
230
+ - Run `pnpm run check` + `pnpm vitest run`.
231
+ - Commit: `feat(pi-subagents): add run lifecycle methods to Agent`
232
+
233
+ 3. **Replace RunHandle with Agent methods in `startAgent`, delete RunHandle**
234
+ - Replace `new RunHandle(record, this.worktrees, onFinished)` with `record.setOnRunFinished(onFinished)`.
235
+ - Replace `handle.wireSignal(...)` with `record.wireSignal(...)`.
236
+ - Replace `handle.attachObserver(...)` with `record.attachObserver(...)`.
237
+ - Replace `handle.complete(result)` with `record.completeRun(result, this.worktrees)`.
238
+ - Replace `handle.fail(err)` with `record.failRun(err, this.worktrees)`.
239
+ - Delete `RunHandle` class.
240
+ - Remove `import type { RunResult }` from `agent-manager.ts` (moved to `agent.ts`).
241
+ - Run `pnpm run check` + `pnpm vitest run`.
242
+ - Commit: `refactor(pi-subagents): replace RunHandle with Agent run lifecycle methods`
243
+
244
+ 4. **Hoist worktree setup from `startAgent` to callers**
245
+ - In `spawn()`: move `record.setupWorktree(this.worktrees, options.isolation)` before `this.startAgent()`, inside the existing try-catch.
246
+ - In `drainQueue()`: move `record.setupWorktree(this.worktrees, next.args.options.isolation)` before `this.startAgent()`, inside its try-catch.
247
+ - In `startAgent`: remove `record.setupWorktree()` call; use `record.worktreeState?.path` for `context.cwd`.
248
+ - Existing worktree isolation tests pass unchanged.
249
+ - Run `pnpm run check` + `pnpm vitest run`.
250
+ - Commit: `refactor(pi-subagents): hoist worktree setup from startAgent to callers`
251
+
252
+ 5. **Convert `startAgent` to async/await**
253
+ - Make `startAgent` async, returning `Promise<void>`.
254
+ - Replace `.then()`/`.catch()` chain with `try { const result = await this.runner.run(...); record.completeRun(result, this.worktrees); } catch (err) { record.failRun(err, this.worktrees); }`.
255
+ - Remove `record.promise = this.runner.run(...)` assignment from inside `startAgent`.
256
+ - In `spawn()`: assign `record.promise = this.startAgent(id, record, args)`.
257
+ - In `drainQueue()`: assign `record.promise = this.startAgent(next.id, record, next.args)`.
258
+ - Run `pnpm run check` + `pnpm vitest run`.
259
+ - Commit: `refactor(pi-subagents): convert startAgent to async/await`
260
+
261
+ 6. **Update architecture docs**
262
+ - Mark Phase 15 Step 2 smell row as resolved.
263
+ - Update Step 2 description to note RunHandle dissolution.
264
+ - Update Step 6 (#232) description: RunHandle no longer exists; Agent has `completeRun`/`failRun`/`releaseListeners` that `resume()` can use directly.
265
+ - Commit: `docs(pi-subagents): update architecture for async startAgent`
266
+
267
+ ## Risks and Mitigations
268
+
269
+ 1. **`resetForResume` must release listeners** — If not updated, resumed agents retain stale listener handles from the previous run.
270
+ Mitigated by step 2 explicitly updating `resetForResume` to call `releaseListeners()` and clear `_onRunFinished`, with a test.
271
+
272
+ 2. **Worktree hoist changes observer-throw semantics** — Currently, if `observer.onAgentStarted()` throws inside `startAgent`, `spawn()`'s try-catch catches it and removes the record.
273
+ After async conversion, that throw becomes a rejected promise.
274
+ This is a pre-existing inconsistency (`onAgentCompleted` is already wrapped in try-catch, `onAgentStarted` is not) and observers should not throw.
275
+ Mitigated by noting the inconsistency; a future step could add try-catch around `onAgentStarted`.
276
+
277
+ 3. **Agent grows by ~80 LOC** — Dissolving RunHandle adds methods to an already-substantial class.
278
+ Mitigated by the fact that these methods replace logic that already operated on Agent's fields — they belong here by SRP.
279
+ The net effect on `agent-manager.ts` is -85 LOC (RunHandle deletion), so the total codebase shrinks.
280
+
281
+ 4. **`completeRun` takes `worktrees` parameter instead of storing it** — This means every caller must pass worktrees.
282
+ Mitigated by there being exactly two callers today (startAgent and the future resume), both of which already have access to worktrees.
283
+ Storing it would widen Agent's dependency surface for a single use.
284
+
285
+ ## Open Questions
286
+
287
+ None — the design direction (dissolve rather than move) is settled.
288
+ The `worktrees` parameter vs. stored-reference question is resolved in favor of the parameter (ISP).