@gotgenes/pi-subagents 10.1.0 → 10.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,35 @@ 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
+ ## [10.2.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v10.2.0...pi-subagents-v10.2.1) (2026-05-27)
9
+
10
+
11
+ ### Documentation
12
+
13
+ * **pi-subagents:** renumber Phase 15 steps to match execution order ([598bb65](https://github.com/gotgenes/pi-packages/commit/598bb653ac8b63756e8c00dfcf19d3167e2dbc37))
14
+ * **pi-subagents:** revise Phase 15 roadmap for Agent-born-complete vision ([e04583e](https://github.com/gotgenes/pi-packages/commit/e04583e75bfc1314674a6f3181762a26733fb830))
15
+ * plan push exec/registry relay deps to runner construction ([#231](https://github.com/gotgenes/pi-packages/issues/231)) ([646b4d5](https://github.com/gotgenes/pi-packages/commit/646b4d5085e0f7d36a397b43b3b46e0537c3141f))
16
+ * **retro:** add planning stage notes for issue [#231](https://github.com/gotgenes/pi-packages/issues/231) ([dc0daee](https://github.com/gotgenes/pi-packages/commit/dc0daee634c17cf2a40336e27f551bfa2ce0e249))
17
+ * **retro:** add retro notes for issue [#228](https://github.com/gotgenes/pi-packages/issues/228) ([d5b563b](https://github.com/gotgenes/pi-packages/commit/d5b563b6484cbd6a89cd7e9e87ebd431aed128fc))
18
+ * **retro:** add TDD stage notes for issue [#231](https://github.com/gotgenes/pi-packages/issues/231) ([28094ae](https://github.com/gotgenes/pi-packages/commit/28094ae812141ea1c93a22be50ed29d31b7a979a))
19
+ * update architecture for runner self-contained ([#231](https://github.com/gotgenes/pi-packages/issues/231)) ([80dd339](https://github.com/gotgenes/pi-packages/commit/80dd339d7dee9b312b52af2b74756c5748619a49))
20
+
21
+ ## [10.2.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v10.1.0...pi-subagents-v10.2.0) (2026-05-27)
22
+
23
+
24
+ ### Features
25
+
26
+ * **pi-subagents:** add run lifecycle methods to Agent ([2a378f1](https://github.com/gotgenes/pi-packages/commit/2a378f1c82e977bdfee25931ab449757e364d589))
27
+
28
+
29
+ ### Documentation
30
+
31
+ * **pi-subagents:** update architecture for async startAgent ([941eb10](https://github.com/gotgenes/pi-packages/commit/941eb109e71e4c51d5bb37a2a46ffc12f618d949))
32
+ * plan async startAgent and RunHandle dissolution ([#228](https://github.com/gotgenes/pi-packages/issues/228)) ([647adf8](https://github.com/gotgenes/pi-packages/commit/647adf853fec63ea53afd63bc8204c89a6194bbe))
33
+ * **retro:** add planning stage notes for issue [#228](https://github.com/gotgenes/pi-packages/issues/228) ([8dd9f8a](https://github.com/gotgenes/pi-packages/commit/8dd9f8ab7082c08e424b1b4a9557253af2ce584b))
34
+ * **retro:** add retro notes for issue [#227](https://github.com/gotgenes/pi-packages/issues/227) ([78a4d64](https://github.com/gotgenes/pi-packages/commit/78a4d645f524465c64bf0b6ba1bcca37858e8721))
35
+ * **retro:** add TDD stage notes for issue [#228](https://github.com/gotgenes/pi-packages/issues/228) ([ab497c5](https://github.com/gotgenes/pi-packages/commit/ab497c57723666d0635a0a08f9eecc06576da549))
36
+
8
37
  ## [10.1.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v10.0.1...pi-subagents-v10.1.0) (2026-05-27)
9
38
 
10
39
 
@@ -55,7 +55,7 @@ flowchart TB
55
55
  direction TB
56
56
  AgentManager["AgentManager<br/>(spawn, queue, abort)"]
57
57
  AgentRunner["agent-runner<br/>(session, turns, results)"]
58
- AgentRecord["Agent<br/>(status, behavior: abort/steer/worktree)"]
58
+ Agent["Agent<br/>(status, behavior: abort/steer/worktree/run lifecycle)"]
59
59
  ParentSnapshot["ParentSnapshot<br/>(frozen parent state)"]
60
60
  Worktree["worktree<br/>(git isolation)"]
61
61
  end
@@ -101,7 +101,7 @@ flowchart TB
101
101
 
102
102
  ```mermaid
103
103
  classDiagram
104
- class AgentRecord {
104
+ class Agent {
105
105
  +id: string
106
106
  +type: SubagentType
107
107
  +description: string
@@ -124,6 +124,12 @@ classDiagram
124
124
  +queueSteer(message)
125
125
  +flushPendingSteers(session)
126
126
  +setupWorktree(worktrees, isolation)
127
+ +completeRun(result, worktrees)
128
+ +failRun(err, worktrees)
129
+ +wireSignal(signal, onAbort)
130
+ +attachObserver(unsub)
131
+ +releaseListeners()
132
+ +setOnRunFinished(fn)
127
133
  }
128
134
 
129
135
  class AgentManager {
@@ -160,7 +166,7 @@ classDiagram
160
166
  +hasRunning(): boolean
161
167
  }
162
168
 
163
- AgentManager --> AgentRecord : creates/manages
169
+ AgentManager --> Agent : creates/manages
164
170
  AgentManager --> ParentSnapshot : receives at spawn
165
171
  SubagentsService --> AgentManager : wraps via adapter
166
172
  AgentManager --> AgentTypeRegistry : resolves types
@@ -266,7 +272,6 @@ src/
266
272
  │ ├── parent-snapshot.ts immutable spawn-time parent state
267
273
  │ ├── execution-state.ts session/output phase state
268
274
  │ ├── permission-bridge.ts optional bridge to pi-permission-system registry
269
- │ ├── run-handle.ts per-run cleanup lifecycle
270
275
  │ ├── worktree.ts git worktree isolation
271
276
  │ ├── worktree-state.ts worktree phase state
272
277
  │ └── usage.ts token usage tracking
@@ -594,23 +599,21 @@ export interface ParentSessionInfo {
594
599
 
595
600
  `AgentSpawnConfig` now carries `parentSession?: ParentSessionInfo` instead of three flat optional fields.
596
601
 
597
- #### RunOptions (12 fields → extract RunContext) — done ([#169][169])
602
+ #### RunOptions (12 fields → extract RunContext) — done ([#169][169]), updated by [#231]
598
603
 
599
- The `RunOptions` bag mixes execution parameters with context information.
600
- `RunContext` was extracted and nested as `RunOptions.context`:
604
+ `RunContext` was extracted and nested as `RunOptions.context` in #169.
605
+ Issue #231 moved the two static dependencies (`exec`, `registry`) to `RunnerDeps` on `ConcreteAgentRunner`, leaving `RunContext` with only per-call fields:
601
606
 
602
607
  ```typescript
603
- /** Parent execution context — where/who is running. */
608
+ /** Per-call execution context — fields that vary per spawn. */
604
609
  export interface RunContext {
605
- exec: ShellExec;
606
- registry: AgentConfigLookup;
607
610
  cwd?: string;
608
611
  parentSession?: ParentSessionInfo;
609
612
  }
610
613
  ```
611
614
 
612
615
  The remaining `RunOptions` fields (`model`, `maxTurns`, `signal`, `isolated`, `thinkingLevel`, `defaultMaxTurns`, `graceTurns`, `onSessionCreated`) are genuine execution parameters.
613
- `RunOptions` now has 9 fields: 1 nested `context: RunContext` plus 8 flat execution fields.
616
+ `RunOptions` now has 9 fields: 1 nested `context: RunContext` (2 per-call fields) plus 8 flat execution fields.
614
617
 
615
618
  #### SessionConfig (11 fields → extract ToolFilterConfig) — done ([#168][168])
616
619
 
@@ -681,24 +684,66 @@ See [phase-14-strip-policy.md](history/phase-14-strip-policy.md) for details.
681
684
 
682
685
  ## Improvement roadmap (Phase 15 — domain model evolution)
683
686
 
684
- Phase 15 addresses the anemic domain model in the lifecycle layer.
685
- `AgentRecord` is a data bag — identity, status transitions, and stats — but no behavior.
686
- `AgentManager` reaches into records 37 times, doing work that belongs on the agent.
687
- Per-agent state (pending steers, abort logic, run lifecycle) is scattered across the manager, `RunHandle`, and a manager-level Map.
687
+ Phase 15 evolves `Agent` from a passive state machine into an object that **owns its entire execution lifecycle**.
688
+
689
+ Steps 1–2 (complete) moved per-agent behavior from `AgentManager` onto `Agent`: abort, steer buffering, worktree setup, and run lifecycle methods (`completeRun`, `failRun`).
690
+ However, Agent still cannot *run itself*.
691
+ `AgentManager.startAgent()` orchestrates the entire execution: calling the runner, handling session creation, wiring observers, and cleaning up worktrees.
692
+ The manager reaches into Agent 10 times across `spawn()` + `startAgent()` — writing to `notification`, `execution`, and `promise` after construction, passing its own `worktrees` and `runner` as method arguments, and threading `onSessionCreated` callbacks through three layers.
693
+
694
+ The remaining steps address this by making **Agent born complete**: constructed with all dependencies and configuration, owning its entire execution lifecycle.
695
+
696
+ ### Architecture target
697
+
698
+ Agent receives three concerns at construction:
699
+
700
+ | Concern | Fields | Lifetime |
701
+ | ----------- | ----------------------------------------------------------------------------- | ------------------------- |
702
+ | Identity | id, type, description, invocation | Immutable |
703
+ | Run config | snapshot, prompt, model, isolation, maxTurns, thinking, signal, parentSession | Immutable per-run |
704
+ | Shared deps | runner, worktrees | Shared service references |
705
+
706
+ `Agent.run()` encapsulates the full execution lifecycle:
707
+
708
+ 1. Set up worktree internally (knows its own isolation mode, has worktrees).
709
+ 2. Call `this.runner.run()` (has the runner).
710
+ 3. Handle session creation internally: set `execution`, flush pending steers, attach record-observer.
711
+ 4. Notify lifecycle observer (started, session created, completed, compacted).
712
+ 5. Clean up worktree on completion or error.
713
+ 6. Transition status.
714
+
715
+ `AgentManager` becomes a collection manager + concurrency controller:
716
+
717
+ - Creates complete Agent objects, stores them in the map.
718
+ - Decides when to run (immediate or queue) and calls `agent.run()`.
719
+ - Provides high-level actions: abort, list, cleanup.
720
+ - Does *not* own the runner, worktrees, or any run-orchestration logic.
721
+
722
+ The queue stores agent IDs, not `SpawnArgs`.
723
+ When capacity opens, the manager looks up the agent and calls `agent.run()` — the agent already has everything.
724
+
725
+ The `onSessionCreated` callback that currently threads through `AgentSpawnConfig` → `startAgent` → `RunOptions` → runner disappears.
726
+ Agent handles session creation internally during `run()` and notifies external observers via the lifecycle observer pattern.
727
+
728
+ The synchronous-throw contract for worktree failure (introduced in Step 2's hoist) is replaced by a uniform async error surface.
729
+ Worktree failures inside `agent.run()` propagate through the promise.
730
+ For background agents, errors surface via `get_subagent_result` and appear in `/agents`.
731
+ For foreground agents, `spawnAndWait` awaits the promise naturally.
688
732
 
689
733
  The scheduling concern (queue, concurrency counter, drain) is tangled into `AgentManager` alongside collection management and run orchestration.
690
734
  `notifyConcurrencyChanged()` is a scheduling method exposed as a public API so settings can poke the queue — a cross-concern leak.
691
735
 
692
736
  ### Findings summary
693
737
 
694
- | Finding | Category | Impact | Risk | Priority |
695
- | ------------------------------------------------------------- | ------------ | ------ | ---- | -------- |
696
- | `AgentRecord` is anemic — no behavior, manager reaches in 37× | B: Oversized | 5 | 3 | 15 |
697
- | Scheduling tangled into `AgentManager` (3 fields, 3 methods) | A: Coupling | 4 | 2 | 12 |
698
- | `startAgent` uses `.then()`/`.catch()` instead of async/await | C: Callbacks | 3 | 2 | 10 |
699
- | `onSessionCreated` callback flows through 3 layers | C: Callbacks | 3 | 2 | 10 |
700
- | `resume()` duplicates observer subscribe/unsubscribe pattern | A: Redundant | 2 | 1 | 8 |
701
- | `exec`/`registry` relay-only deps on `AgentManager` | C: Coupling | 2 | 1 | 6 |
738
+ | Finding | Category | Impact | Risk | Priority |
739
+ | ------------------------------------------------------------------ | ------------ | ------ | ---- | -------- |
740
+ | ~~`AgentRecord` is anemic — no behavior, manager reaches in 37×~~ | B: Oversized | 5 | 3 | |
741
+ | Agent cannot run itself manager orchestrates 10 external touches | C: Coupling | 5 | 3 | 15 |
742
+ | Scheduling tangled into `AgentManager` (3 fields, 3 methods) | A: Coupling | 4 | 2 | 12 |
743
+ | ~~`startAgent` uses `.then()`/`.catch()` instead of async/await~~ | C: Callbacks | 3 | 2 | |
744
+ | ~~`onSessionCreated` callback flows through 3 layers~~ | C: Callbacks | 3 | 2 | subsumed |
745
+ | `resume()` duplicates observer subscribe/unsubscribe pattern | A: Redundant | 2 | 1 | 8 |
746
+ | `exec`/`registry` relay-only deps on `AgentManager` | C: Coupling | 2 | 1 | 6 |
702
747
 
703
748
  ### Step 1: Evolve AgentRecord into Agent with behavior — [#227] ✅ Complete
704
749
 
@@ -713,53 +758,66 @@ Move per-agent behavior from `AgentManager` into the agent:
713
758
  - Smell: B (anemic domain model) + C (manager reaching into records)
714
759
  - Outcome: `AgentManager` delegates via Tell-Don't-Ask; per-agent state lives on the agent
715
760
 
716
- ### Step 2: Convert startAgent to async/await — [#228]
761
+ ### Step 2: Convert startAgent to async/await — [#228] ✅ Complete
717
762
 
718
- Convert `startAgent` from synchronous (returns void, assigns `record.promise` to a `.then()`/`.catch()` chain) to `async` (returns `Promise<void>`, uses try/catch).
763
+ Converted `startAgent` to `async` with `try/catch` and dissolved `RunHandle` into `Agent` methods.
719
764
  `spawn()` assigns `record.promise = this.startAgent(...)` instead of calling `startAgent()` synchronously.
765
+ `Agent` gained run lifecycle methods: `completeRun`, `failRun`, `wireSignal`, `attachObserver`, `releaseListeners`, `setOnRunFinished`.
766
+ Worktree setup was hoisted to callers (`spawn`, `drainQueue`) to preserve the synchronous-throw contract.
720
767
 
721
768
  - Depends on: #227
722
- - Target: `src/lifecycle/agent-manager.ts`
769
+ - Target: `src/lifecycle/agent-manager.ts`, `src/lifecycle/agent.ts`
723
770
  - Smell: C (raw promise callbacks)
724
- - Outcome: zero `.then()`/`.catch()` in `agent-manager.ts`
771
+ - Outcome: zero `.then()`/`.catch()` in `agent-manager.ts`; `RunHandle` deleted; Agent owns run lifecycle
725
772
 
726
- ### Step 3: Replace onSessionCreated callback with observer method — [#229]
773
+ ### Step 3: Push exec/registry relay deps to runner construction — [#231]
727
774
 
728
- Add `onSessionCreated(agent, session)` to `AgentManagerObserver`.
729
- Remove the `onSessionCreated` callback from `AgentSpawnConfig`.
730
- Tool-layer code subscribes via the observer pattern instead of passing callbacks through the spawn config.
775
+ `exec` and `registry` moved from `AgentManager` to `ConcreteAgentRunner` via a new `RunnerDeps` interface.
776
+ `RunContext` shrunk from 4 to 2 per-call fields (`cwd`, `parentSession`).
777
+ `AgentManagerOptions` shrunk from 7 to 5 fields.
731
778
 
732
- - Target: `src/lifecycle/agent-manager.ts`, `src/tools/background-spawner.ts`, `src/tools/foreground-runner.ts`
733
- - Smell: C (callback flowing through 3 layers)
734
- - Outcome: `AgentSpawnConfig` loses one callback field; session notification uses the observer pattern
779
+ - Target: `src/lifecycle/agent-manager.ts`, `src/lifecycle/agent-runner.ts`, `src/index.ts`
780
+ - Smell: C (relay-only dependencies)
781
+ - Outcome: `AgentManager` loses 2 fields; `AgentManagerOptions` shrinks from 7 to 5 fields; runner is self-contained
782
+
783
+ ### Step 4: Agent born complete — Agent.run() absorbs startAgent — [#229]
784
+
785
+ Agent receives `runner`, `worktrees`, and a lifecycle observer at construction.
786
+ Agent creates its own `NotificationState` from `parentSession.toolCallId` — no external write.
787
+ `Agent.run()` encapsulates the entire execution lifecycle: worktree setup, runner invocation, session-creation handling, observer wiring, worktree cleanup, and status transitions.
788
+ `startAgent` is deleted from `AgentManager`.
789
+ The `onSessionCreated` callback is removed from `AgentSpawnConfig` — Agent handles session creation internally and notifies via the lifecycle observer.
790
+ `SpawnArgs` is deleted — Agent has its config from construction.
791
+
792
+ `AgentManager.spawn()` becomes: create complete Agent, put in map, call `agent.run()` or queue the agent ID.
793
+
794
+ - Depends on: #228, #231
795
+ - Target: `src/lifecycle/agent.ts`, `src/lifecycle/agent-manager.ts`, `src/tools/background-spawner.ts`, `src/tools/foreground-runner.ts`
796
+ - Smell: C (manager orchestrates 10 external touches on Agent) + C (callback flowing through 3 layers)
797
+ - Outcome: Agent owns its entire execution lifecycle; `startAgent`, `SpawnArgs`, `onSessionCreated` callback deleted; zero post-construction writes from `AgentManager`
735
798
 
736
- ### Step 4: Extract ConcurrencyQueue from AgentManager — [#230]
799
+ ### Step 5: Extract ConcurrencyQueue from AgentManager — [#230]
737
800
 
738
801
  Extract `queue[]`, `runningBackground`, `_getMaxConcurrent`, `drainQueue()`, `finalizeBackgroundRun()` into a `ConcurrencyQueue` class.
802
+ The queue stores agent IDs — not `SpawnArgs`.
803
+ Drain calls `agent.run()` directly — no worktree setup, no args threading.
739
804
  `SettingsManager` talks to the queue directly — `notifyConcurrencyChanged()` is eliminated from `AgentManager`.
740
805
 
806
+ - Depends on: #229
741
807
  - Target: new `src/lifecycle/concurrency-queue.ts`, `src/lifecycle/agent-manager.ts`, `src/index.ts`
742
808
  - Smell: A (tangled concerns) + C (cross-concern leak via `notifyConcurrencyChanged`)
743
- - Outcome: `AgentManager` loses 3 fields, 3 methods (~40 lines); scheduling is independently testable
744
-
745
- ### Step 5: Push exec/registry relay deps to runner construction — [#231]
746
-
747
- `AgentManager` receives `exec` and `registry` in its constructor but only relays them to `runner.run()` via `context`.
748
- Move them to `ConcreteAgentRunner` construction.
749
-
750
- - Target: `src/lifecycle/agent-manager.ts`, `src/lifecycle/agent-runner.ts`, `src/index.ts`
751
- - Smell: C (relay-only dependencies)
752
- - Outcome: `AgentManager` loses 2 fields; `AgentManagerOptions` shrinks from 7 to 5 fields
809
+ - Outcome: `AgentManager` loses 3 fields, 3 methods (~40 lines); scheduling is independently testable; queue interface is trivial (agent has everything)
753
810
 
754
- ### Step 6: Unify resume() with RunHandle pattern — [#232]
811
+ ### Step 6: Agent.resume() with internal observer lifecycle — [#232]
755
812
 
756
- After #227 moves `RunHandle` ownership to the `Agent`, `resume()` on `AgentManager` becomes a 4-line delegation to `agent.resume(runner, prompt, signal)`.
757
- The agent manages its own observer subscription lifecycle.
813
+ Agent has the runner from construction.
814
+ `Agent.resume(prompt, signal)` manages its own observer subscription lifecycle using the same internal wiring as `run()`.
815
+ `AgentManager.resume()` becomes a one-liner delegation to `agent.resume(prompt, signal)` — no manual `subscribeRecordObserver` / try-finally.
758
816
 
759
- - Depends on: #227, #228
760
- - Target: `src/lifecycle/agent-manager.ts`
817
+ - Depends on: #229
818
+ - Target: `src/lifecycle/agent.ts`, `src/lifecycle/agent-manager.ts`
761
819
  - Smell: A (duplicated observer subscribe/unsubscribe pattern)
762
- - Outcome: no manual `subscribeRecordObserver` / try-finally in the manager
820
+ - Outcome: `AgentManager.resume()` is a 4-line delegation; observer lifecycle is Agent-internal
763
821
 
764
822
  ### Step dependency diagram
765
823
 
@@ -767,28 +825,32 @@ The agent manages its own observer subscription lifecycle.
767
825
  flowchart LR
768
826
  S1["Step 1<br/>Agent with behavior"]
769
827
  S2["Step 2<br/>async startAgent"]
770
- S3["Step 3<br/>onSessionCreated observer"]
771
- S4["Step 4<br/>ConcurrencyQueue"]
772
- S5["Step 5<br/>relay deps"]
773
- S6["Step 6<br/>resume unification"]
828
+ S3["Step 3<br/>runner self-contained"]
829
+ S4["Step 4<br/>Agent.run()"]
830
+ S5["Step 5<br/>ConcurrencyQueue"]
831
+ S6["Step 6<br/>Agent.resume()"]
774
832
 
775
833
  S1 --> S2
776
- S1 --> S6
777
- S2 --> S6
778
- S3 ~~~ S4
779
- S4 ~~~ S5
834
+ S2 --> S4
835
+ S3 --> S4
836
+ S4 --> S5
837
+ S4 --> S6
780
838
  ```
781
839
 
782
840
  ### Tracks
783
841
 
784
- 1. **Track A — Domain model** (Steps 1, 2, 6): Agent with behavior, async runs, resume unification.
785
- Sequential each depends on the previous.
786
- 2. **Track B — Decoupling** (Steps 3, 4, 5): independent, can proceed in parallel with Track A.
842
+ 1. **Track A — Foundation** (Step 3): Runner becomes self-contained.
843
+ No dependencies on other Phase 15 steps; can start immediately.
844
+ 2. **Track B — Agent lifecycle** (Steps 4, 6): Agent born complete, owns run + resume.
845
+ Step 4 depends on Track A + Step 2.
846
+ Step 6 depends on Step 4.
847
+ 3. **Track C — Scheduling** (Step 5): ConcurrencyQueue extraction.
848
+ Depends on Step 4 (queue drains via `agent.run()`).
787
849
 
788
850
  ## Improvement roadmap (Phase 16 — invert dependencies)
789
851
 
790
852
  Phase 16 completes the architectural inversion by removing the outbound permission bridge and the `extensions: false` / `isolated` concepts.
791
- It depends on Phase 15's observer pattern (#229) as the replacement mechanism.
853
+ It depends on Phase 15's lifecycle observer (#229) as the replacement mechanism.
792
854
 
793
855
  Phase 16 is scoped but not yet broken into steps.
794
856
  Key changes:
@@ -848,7 +910,7 @@ Detailed records are preserved in per-phase history files:
848
910
  | Phase 12 | #205, #206, #207, #208 | renderWidgetLines, showAgentDetail, widget update, shared test fixtures |
849
911
  | Phase 13 | #214, #215, #216, #217, #218, #219 | Closure-to-class, buildParentContext, startAgent decomp, overwrite guard, settings SDK, test duplication |
850
912
  | Phase 14 | #237, #238, #239, #242 | Remove disallowed_tools, remove extensions filtering, collapse filterActiveTools, rename Agent to subagent |
851
- | Phase 15 | #227, #228, #229, #230, #231, #232 | Agent domain model, async startAgent, onSessionCreated observer, ConcurrencyQueue, relay deps, resume unification |
913
+ | Phase 15 | #227, #228, #231, #229, #230, #232 | Agent domain model, async startAgent, runner self-contained, Agent.run(), ConcurrencyQueue, Agent.resume() |
852
914
 
853
915
  The remaining open issue is #22 (parent-session resolution), a cross-extension track that does not gate the structural work.
854
916
 
@@ -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).