@gotgenes/pi-subagents 16.0.0 → 16.1.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 +14 -0
- package/dist/public.d.ts +19 -22
- package/docs/architecture/architecture.md +49 -17
- package/docs/plans/0373-extract-subagent-state.md +250 -0
- package/docs/plans/0381-replace-concurrency-queue-with-limiter.md +267 -0
- package/docs/plans/0403-abort-subagents-on-interrupt.md +180 -0
- package/docs/retro/0373-extract-subagent-state.md +94 -0
- package/docs/retro/0381-replace-concurrency-queue-with-limiter.md +95 -0
- package/docs/retro/0400-include-parent-prompt-in-replace-mode.md +40 -0
- package/docs/retro/0403-abort-subagents-on-interrupt.md +49 -0
- package/package.json +1 -1
- package/src/handlers/index.ts +1 -0
- package/src/handlers/interrupt.ts +49 -0
- package/src/index.ts +13 -16
- package/src/lifecycle/concurrency-limiter.ts +55 -0
- package/src/lifecycle/subagent-manager.ts +57 -51
- package/src/lifecycle/subagent-state.ts +156 -0
- package/src/lifecycle/subagent.ts +86 -163
- package/src/observation/record-observer.ts +15 -13
- package/src/lifecycle/concurrency-queue.ts +0 -63
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [16.1.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.1.0...pi-subagents-v16.1.1) (2026-06-14)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* abort all subagents on parent interrupt ([#403](https://github.com/gotgenes/pi-packages/issues/403)) ([0c951d3](https://github.com/gotgenes/pi-packages/commit/0c951d3da27123a61c95a7f9a07ddb4cf5ed7e89))
|
|
14
|
+
|
|
15
|
+
## [16.1.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.0.0...pi-subagents-v16.1.0) (2026-06-14)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* **pi-subagents:** add ConcurrencyLimiter ([#381](https://github.com/gotgenes/pi-packages/issues/381)) ([26f4203](https://github.com/gotgenes/pi-packages/commit/26f420337094d81d39bcc3e0522e12262c7767b7))
|
|
21
|
+
|
|
8
22
|
## [16.0.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v15.0.2...pi-subagents-v16.0.0) (2026-06-14)
|
|
9
23
|
|
|
10
24
|
|
package/dist/public.d.ts
CHANGED
|
@@ -29,6 +29,25 @@ type LifetimeUsage = {
|
|
|
29
29
|
cacheWrite: number;
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* subagent-state.ts — SubagentState value object: lifecycle status and metrics.
|
|
34
|
+
*
|
|
35
|
+
* Owns the passive, readable state of a subagent — status, result, error,
|
|
36
|
+
* timestamps, and stats (toolUses, lifetimeUsage, compactionCount) — together
|
|
37
|
+
* with the transition methods (markRunning, markCompleted, …) and accumulation
|
|
38
|
+
* methods (incrementToolUses, addUsage, incrementCompactions) that mutate it.
|
|
39
|
+
*
|
|
40
|
+
* State is encapsulated behind getters; external code reads through them but
|
|
41
|
+
* mutates only via the transition/accumulation methods. The value object owns
|
|
42
|
+
* all of its own mutations — no field is written from outside.
|
|
43
|
+
*
|
|
44
|
+
* Subagent holds one of these privately and delegates its getters and mutation
|
|
45
|
+
* methods to it. Extracting it lets the lifecycle state machine and the
|
|
46
|
+
* session-event observer be unit-tested without constructing an executor.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
type SubagentStatus = "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
|
|
50
|
+
|
|
32
51
|
/**
|
|
33
52
|
* workspace.ts — The single generative extension seam (ADR 0002, Phase 16 Step 2).
|
|
34
53
|
*
|
|
@@ -70,28 +89,6 @@ interface WorkspaceProvider {
|
|
|
70
89
|
prepare(ctx: WorkspacePrepareContext): Promise<Workspace | undefined>;
|
|
71
90
|
}
|
|
72
91
|
|
|
73
|
-
/**
|
|
74
|
-
* subagent.ts — Subagent class with encapsulated status-transition logic and per-subagent behavior.
|
|
75
|
-
*
|
|
76
|
-
* Status transitions (status, result, error, startedAt, completedAt) are owned
|
|
77
|
-
* by the class and exposed via transition methods. External code reads these
|
|
78
|
-
* fields through public properties but cannot write them directly.
|
|
79
|
-
*
|
|
80
|
-
* Stats (toolUses, lifetimeUsage, compactionCount) are owned by the class and
|
|
81
|
-
* accumulated via mutation methods (incrementToolUses, addUsage, incrementCompactions).
|
|
82
|
-
*
|
|
83
|
-
* Behavior (abort, steer buffering) lives on the subagent rather than on
|
|
84
|
-
* SubagentManager — each subagent manages its own lifecycle concerns.
|
|
85
|
-
*
|
|
86
|
-
* The child's working directory is supplied by a registered WorkspaceProvider
|
|
87
|
-
* (the workspace seam); with no provider the child runs in the parent cwd.
|
|
88
|
-
*
|
|
89
|
-
* Phase-specific collaborators (subagentSession, notification) are attached
|
|
90
|
-
* after construction as lifecycle information becomes available.
|
|
91
|
-
*/
|
|
92
|
-
|
|
93
|
-
type SubagentStatus = "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
|
|
94
|
-
|
|
95
92
|
/**
|
|
96
93
|
* service.ts — Public API surface for cross-extension access to subagents.
|
|
97
94
|
*
|
|
@@ -53,7 +53,7 @@ flowchart TB
|
|
|
53
53
|
subgraph lifecycle["Lifecycle domain"]
|
|
54
54
|
direction TB
|
|
55
55
|
SubagentManager["SubagentManager<br/>(spawn, abort, collection)"]
|
|
56
|
-
|
|
56
|
+
ConcurrencyLimiter["ConcurrencyLimiter<br/>(thunk admission gate)"]
|
|
57
57
|
CreateSubagentSession["createSubagentSession<br/>(assembly factory)"]
|
|
58
58
|
SubagentSession["SubagentSession<br/>(turn loop, steer, dispose)"]
|
|
59
59
|
Subagent["Subagent<br/>(status, behavior: abort/steer/run lifecycle)"]
|
|
@@ -107,6 +107,8 @@ classDiagram
|
|
|
107
107
|
+id: string
|
|
108
108
|
+type: SubagentType
|
|
109
109
|
+description: string
|
|
110
|
+
-state: SubagentState
|
|
111
|
+
-execution: SubagentExecution
|
|
110
112
|
+status: SubagentStatus
|
|
111
113
|
+result?: string
|
|
112
114
|
+error?: string
|
|
@@ -114,13 +116,8 @@ classDiagram
|
|
|
114
116
|
+lifetimeUsage: LifetimeUsage
|
|
115
117
|
+subagentSession?: SubagentSession
|
|
116
118
|
+notification?: NotificationState
|
|
117
|
-
+markRunning()
|
|
118
|
-
+markCompleted()
|
|
119
|
-
+markAborted()
|
|
120
|
-
+markSteered()
|
|
121
|
-
+markError()
|
|
122
|
-
+markStopped()
|
|
123
|
-
+resetForResume()
|
|
119
|
+
+markRunning() delegates
|
|
120
|
+
+markCompleted() delegates
|
|
124
121
|
+run()
|
|
125
122
|
+resume(prompt, signal)
|
|
126
123
|
+abort(): boolean
|
|
@@ -138,6 +135,34 @@ classDiagram
|
|
|
138
135
|
+releaseListeners()
|
|
139
136
|
}
|
|
140
137
|
|
|
138
|
+
class SubagentState {
|
|
139
|
+
+status: SubagentStatus
|
|
140
|
+
+result?: string
|
|
141
|
+
+error?: string
|
|
142
|
+
+startedAt: number
|
|
143
|
+
+completedAt?: number
|
|
144
|
+
+toolUses: number
|
|
145
|
+
+lifetimeUsage: LifetimeUsage
|
|
146
|
+
+compactionCount: number
|
|
147
|
+
+markRunning() ... markStopped()
|
|
148
|
+
+resetForResume()
|
|
149
|
+
+incrementToolUses()
|
|
150
|
+
+addUsage(delta)
|
|
151
|
+
+incrementCompactions()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
class SubagentExecution {
|
|
155
|
+
+createSubagentSession(params)
|
|
156
|
+
+snapshot: ParentSnapshot
|
|
157
|
+
+prompt: string
|
|
158
|
+
+baseCwd: string
|
|
159
|
+
+observer?: SubagentLifecycleObserver
|
|
160
|
+
+getRunConfig?()
|
|
161
|
+
+getWorkspaceProvider?()
|
|
162
|
+
+model?, maxTurns?, thinkingLevel?
|
|
163
|
+
+parentSession?, signal?
|
|
164
|
+
}
|
|
165
|
+
|
|
141
166
|
class SubagentManager {
|
|
142
167
|
+spawn(snapshot, type, prompt, config)
|
|
143
168
|
+spawnAndWait(snapshot, type, prompt, config)
|
|
@@ -173,6 +198,8 @@ classDiagram
|
|
|
173
198
|
}
|
|
174
199
|
|
|
175
200
|
SubagentManager --> Subagent : creates/manages
|
|
201
|
+
Subagent --> SubagentState : owns (private)
|
|
202
|
+
Subagent --> SubagentExecution : runs via (mandatory)
|
|
176
203
|
SubagentManager --> ParentSnapshot : receives at spawn
|
|
177
204
|
SubagentsService --> SubagentManager : wraps via adapter
|
|
178
205
|
SubagentManager --> AgentTypeRegistry : resolves types
|
|
@@ -247,7 +274,7 @@ sequenceDiagram
|
|
|
247
274
|
|
|
248
275
|
## Module organization
|
|
249
276
|
|
|
250
|
-
The extension has
|
|
277
|
+
The extension has 58 source files organized into six domains plus entry-point wiring.
|
|
251
278
|
All eight domains have directories: `config/`, `session/`, `lifecycle/`, `observation/`, `service/`, `tools/`, `ui/`, and `handlers/`.
|
|
252
279
|
Issue #164 moved the 26 previously flat root-level files into five new domain directories, reducing the root to 5 files + 8 directories.
|
|
253
280
|
|
|
@@ -283,7 +310,8 @@ src/
|
|
|
283
310
|
│ ├── subagent-session.ts born-complete child session: turn loop, steer, dispose
|
|
284
311
|
│ ├── turn-limits.ts normalizeMaxTurns (turn-count policy)
|
|
285
312
|
│ ├── subagent.ts owns full execution lifecycle (run, abort, steer, workspace)
|
|
286
|
-
│ ├──
|
|
313
|
+
│ ├── subagent-state.ts lifecycle status + metrics value object (transitions, accumulators)
|
|
314
|
+
│ ├── concurrency-limiter.ts background admission gate: schedules run thunks FIFO against the limit
|
|
287
315
|
│ ├── parent-snapshot.ts immutable spawn-time parent state
|
|
288
316
|
│ ├── child-lifecycle.ts child-execution lifecycle event publisher
|
|
289
317
|
│ ├── workspace.ts workspace provider seam (generative extension surface)
|
|
@@ -325,6 +353,7 @@ src/
|
|
|
325
353
|
│
|
|
326
354
|
└── handlers/ event handlers
|
|
327
355
|
├── index.ts barrel re-export
|
|
356
|
+
├── interrupt.ts turn_start handler — abort all subagents on parent interrupt (ESC)
|
|
328
357
|
├── lifecycle.ts session_start, session_before_switch, session_shutdown
|
|
329
358
|
└── tool-start.ts tool_execution_start handler
|
|
330
359
|
```
|
|
@@ -360,7 +389,7 @@ They declare this package as an optional peer dependency and use dynamic import
|
|
|
360
389
|
|
|
361
390
|
- The three tools: `subagent` (née `Agent`), `get_subagent_result`, `steer_subagent`.
|
|
362
391
|
- `SubagentManager` — spawn, abort, resume, collection management, observer wiring.
|
|
363
|
-
- `
|
|
392
|
+
- `ConcurrencyLimiter` — background admission gate: schedules run thunks FIFO against a configurable concurrency limit.
|
|
364
393
|
- `createSubagentSession` — assembly factory: session creation and extension binding; returns a born-complete `SubagentSession`.
|
|
365
394
|
- `SubagentSession` — the born-complete child session: drives the turn loop (`runTurnLoop`/`resumeTurnLoop`), steers, and disposes (firing `disposed` at true session disposal, so resume executions are registry-detected).
|
|
366
395
|
- `child-lifecycle` — publishes the child-execution lifecycle (`spawning`, `session-created` before `bindExtensions()`, `completed`, `disposed`) on `pi.events`.
|
|
@@ -646,7 +675,8 @@ Bags with 10+ fields are the highest priority for decomposition.
|
|
|
646
675
|
| `AgentToolDeps` | 8 | agent-tool | ✓ done |
|
|
647
676
|
| `AgentMenuDeps` | 8 | agent-menu | ✓ done |
|
|
648
677
|
| `ConversationViewerOptions` | 8 | conversation-viewer | Low |
|
|
649
|
-
| `SubagentInit` |
|
|
678
|
+
| `SubagentInit` | 5 (id, type, description, invocation, execution, state) | subagent (one production site) | ✓ done |
|
|
679
|
+
| `SubagentExecution` | 12 (4 mandatory: factory, snapshot, prompt, baseCwd) | subagent (mandatory collaborator) | ✓ done |
|
|
650
680
|
|
|
651
681
|
### Complexity hotspots
|
|
652
682
|
|
|
@@ -864,7 +894,7 @@ Updated health metrics (fallow, package-wide including tests):
|
|
|
864
894
|
| Metric | Phase 16 baseline | Current |
|
|
865
895
|
| -------------------------- | ------------------------------ | --------------------------------------------- |
|
|
866
896
|
| Health score | 78/100 (B) | 78/100 (B) |
|
|
867
|
-
| Source LOC | 7,778 (57 files) | ~7,400 (
|
|
897
|
+
| Source LOC | 7,778 (57 files) | ~7,400 (57 files) |
|
|
868
898
|
| Dead code | 0 files, 0 exports | 0 files, 0 exports |
|
|
869
899
|
| Maintainability index | 90.8 (good) | 90.8 (good) |
|
|
870
900
|
| Avg / P90 cyclomatic | 1.4 / 2 | 1.4 / 2 |
|
|
@@ -905,7 +935,7 @@ Priority = Impact × (6 − Risk).
|
|
|
905
935
|
| 8 | Consolidate UI and tools test fixtures | D | 2 | 1 | 10 |
|
|
906
936
|
| 9 | Resolve the cross-package settings-loader duplication | A | 2 | 2 | 8 |
|
|
907
937
|
|
|
908
|
-
#### Step 1 — Replace ConcurrencyQueue with a thunk-based ConcurrencyLimiter ([#381])
|
|
938
|
+
#### Step 1 — Replace ConcurrencyQueue with a thunk-based ConcurrencyLimiter ([#381]) ✅ Complete
|
|
909
939
|
|
|
910
940
|
- Targets: `src/lifecycle/concurrency-queue.ts` (→ `concurrency-limiter.ts`), `src/lifecycle/subagent-manager.ts`, `src/index.ts`, `test/lifecycle/concurrency-queue.test.ts`, `test/lifecycle/subagent-manager.test.ts`.
|
|
911
941
|
- Smell: Category C (forward references: the queue's ID-registry design forces a start callback that reaches back into the manager, duplicated between `index.ts` and the test helper) and Category A (dual counting: the queue's `running` counter is fed by `markStarted`/`markFinished` relays in the manager's observer, mirroring state the agents already carry).
|
|
@@ -914,7 +944,7 @@ Priority = Impact × (6 − Risk).
|
|
|
914
944
|
The settings `onMaxConcurrentChanged` hook wires to `limiter.recheck()` in `index.ts`; `dispose()` calls `limiter.clear()` to drop pending thunks.
|
|
915
945
|
- Outcome: dependency direction is strictly manager → limiter (no callback back-edge; the `prefer-const` eslint-disable in the test helper is deleted); the observer's two queue relays are gone; every spawned agent has a `promise` at spawn, collapsing `waitForAll`'s `while (true)` drain loop and its eslint-disable.
|
|
916
946
|
|
|
917
|
-
#### Step 2 — Extract `SubagentState`; make `Subagent` execution deps mandatory ([#373])
|
|
947
|
+
#### Step 2 — Extract `SubagentState`; make `Subagent` execution deps mandatory ([#373]) ✅ Complete
|
|
918
948
|
|
|
919
949
|
- 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`.
|
|
920
950
|
- Smell: Category B (god interface — ~20 fields) and Category D (constructibility: "optional for tests" fields with compensating runtime throws).
|
|
@@ -926,7 +956,9 @@ Priority = Impact × (6 − Risk).
|
|
|
926
956
|
- 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.
|
|
927
957
|
- Scope boundary: stats stay on `SubagentState` for now.
|
|
928
958
|
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.
|
|
929
|
-
-
|
|
959
|
+
- 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.
|
|
960
|
+
`subscribeSubagentObserver` targets `SubagentState`, so observer and state-machine tests no longer stub execution.
|
|
961
|
+
`SubagentExecution` is a mandatory constructor collaborator (production wires it in the single `spawn()` site; passive records build via `make-subagent.ts`), and the two `run()` throws are gone.
|
|
930
962
|
|
|
931
963
|
#### Step 3 — Encapsulate run start and notification attachment on Subagent ([#374])
|
|
932
964
|
|
|
@@ -958,7 +990,7 @@ Priority = Impact × (6 − Risk).
|
|
|
958
990
|
|
|
959
991
|
#### Step 7 — Consolidate lifecycle test fixtures ([#378])
|
|
960
992
|
|
|
961
|
-
- Targets: `test/lifecycle/subagent-manager.test.ts` (766 LOC), `test/lifecycle/subagent.test.ts`, `test/lifecycle/subagent-session.test.ts`, `test/lifecycle/create-subagent-session.test.ts`, `test/lifecycle/create-subagent-session-extension-tools.test.ts`, `test/lifecycle/concurrency-
|
|
993
|
+
- Targets: `test/lifecycle/subagent-manager.test.ts` (766 LOC), `test/lifecycle/subagent.test.ts`, `test/lifecycle/subagent-session.test.ts`, `test/lifecycle/create-subagent-session.test.ts`, `test/lifecycle/create-subagent-session-extension-tools.test.ts`, `test/lifecycle/concurrency-limiter.test.ts`, `test/helpers/`.
|
|
962
994
|
- Smell: Category D — fallow reports five clone families across the lifecycle tests.
|
|
963
995
|
- 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).
|
|
964
996
|
- Outcome: lifecycle clone families 5 → ≤ 1; package test duplication below 600 lines.
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 373
|
|
3
|
+
issue_title: "Extract SubagentState; make Subagent execution deps mandatory"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Extract `SubagentState`; make `Subagent` execution deps mandatory
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
`Subagent` is simultaneously a passive record and an executor.
|
|
11
|
+
Its `SubagentInit` carries ~20 fields — nearly all optional with "required for `run()`, optional for tests" semantics — and `run()` compensates with two runtime guards that throw `"Subagent not configured for execution"`.
|
|
12
|
+
This violates the construct-complete principle: one class is both a display-only snapshot (tests and the UI read `.status`/`.result`/stats) and an executor (production wires the session factory, observer, run config, and workspace provider).
|
|
13
|
+
|
|
14
|
+
The earlier framing — split `SubagentInit` into a present-or-absent `SubagentRunSpec` + `SubagentExecutionDeps` pair — only *type-encodes* the duality; it does not remove it.
|
|
15
|
+
The stronger move (captured in the architecture doc's "First-principles refinement") observes that the passive-record need is *test-only*: production constructs a `Subagent` in exactly one place (`SubagentManager.spawn`), always fully wired.
|
|
16
|
+
So the readable state is the common denominator (extract it as a value object), and the execution deps are the optional-only-for-tests part (make them mandatory; push the passive case into the test factory).
|
|
17
|
+
|
|
18
|
+
## Goals
|
|
19
|
+
|
|
20
|
+
- Extract `SubagentState` — a value object owning status, result, error, timestamps, and stats (`toolUses`, `lifetimeUsage`, `compactionCount`) plus their transition (`markRunning`, `markCompleted`, …) and accumulation (`incrementToolUses`, `addUsage`, `incrementCompactions`) methods.
|
|
21
|
+
- `Subagent` holds one `SubagentState` privately; its existing getters and `markX`/`incrementX`/`addUsage` methods become one-line delegations, leaving the ~40 read sites and the external mutation callers (`markStopped` in the manager, `markCompleted` in `get-result-tool.test.ts`) unchanged.
|
|
22
|
+
- Collapse the ~12 remaining execution inputs into a single **mandatory** `SubagentExecution` collaborator; `SubagentManager.spawn` always supplies it.
|
|
23
|
+
- Delete the two `"not configured for execution"` throws in `run()` — impossible by construction.
|
|
24
|
+
- Move the passive-record construction entirely into `test/helpers/make-subagent.ts`.
|
|
25
|
+
- Retarget `subscribeSubagentObserver` at `SubagentState` so state-machine and observer tests need no stub execution.
|
|
26
|
+
|
|
27
|
+
This change is **not breaking** for the published service surface.
|
|
28
|
+
`src/service/service.ts` exposes `SubagentRecord`, `SubagentStatus`, and the spawn-config types — not `SubagentInit` or the `Subagent` constructor.
|
|
29
|
+
The read API (getters, `SubagentStatus`) is unchanged; only the internal constructor signature changes.
|
|
30
|
+
|
|
31
|
+
## Non-Goals
|
|
32
|
+
|
|
33
|
+
- Hoisting **metrics** (tool uses, token usage, compaction count) into a projection over the child session's event stream — stats stay on `SubagentState` for now (the third of four domains in the refinement, deferred).
|
|
34
|
+
- Extracting **result delivery** (`notification` / `resultConsumed`) into its own domain — the fourth domain, deferred.
|
|
35
|
+
- Encapsulating run start / making `promise` and `notification` read-only — that is Step 3 (#374); `notification` stays created in the constructor here.
|
|
36
|
+
- Extracting run-listener / workspace-bracket collaborators — Step 4 (#375).
|
|
37
|
+
- Renaming `record-observer.ts` — only its parameter type changes.
|
|
38
|
+
|
|
39
|
+
## Background
|
|
40
|
+
|
|
41
|
+
- `src/lifecycle/subagent.ts` — the class under change.
|
|
42
|
+
State fields (`_status`, `_result`, `_error`, `_startedAt`, `_completedAt`, `_toolUses`, `_lifetimeUsage`, `_compactionCount`) sit behind getters; transition/accumulation methods mutate them.
|
|
43
|
+
`run()` reads the execution inputs and throws if `createSubagentSession`/`snapshot`/`prompt` are missing.
|
|
44
|
+
`resume()` reads `observer` and throws only on a missing session (a genuine runtime condition — kept).
|
|
45
|
+
- `src/lifecycle/subagent-manager.ts` — `spawn()` (line ~139) is the **only** production `new Subagent(...)` site; it sets the initial status (`queued` for background so the limiter thunk's `status !== "queued"` guard works, `running` for foreground).
|
|
46
|
+
- `src/observation/record-observer.ts` — `subscribeSubagentObserver(session, record, { onCompact })` calls `record.incrementToolUses()`, `record.addUsage(…)`, `record.incrementCompactions()`, and forwards `onCompact(record, info)`.
|
|
47
|
+
- `src/lifecycle/usage.ts` — `LifetimeUsage` type and the `addUsage(into, delta)` accumulator that `SubagentState` will own.
|
|
48
|
+
- `test/helpers/make-subagent.ts` — `createTestSubagent` builds passive records; stat shorthands apply via mutation methods.
|
|
49
|
+
- `test/lifecycle/subagent.test.ts` (~700 LOC) and `test/observation/record-observer.test.ts` — the test files that construct `Subagent` directly.
|
|
50
|
+
|
|
51
|
+
AGENTS.md / code-design constraints that apply:
|
|
52
|
+
|
|
53
|
+
- Keep Pi SDK imports out of `SubagentState` — it is a pure value object (imports only `LifetimeUsage`/`addUsage`).
|
|
54
|
+
- `Subagent` is exported from the `types.ts` barrel; verify the barrel re-export still resolves after the file split.
|
|
55
|
+
- `SubagentStatus` is re-exported by `src/service/service.ts` from `#src/lifecycle/subagent` — keep that import path valid (re-export `SubagentStatus` from `subagent.ts` even if its definition moves to `subagent-state.ts`), so the public type bundle path is unchanged.
|
|
56
|
+
|
|
57
|
+
## Design Overview
|
|
58
|
+
|
|
59
|
+
### `SubagentState` value object (`src/lifecycle/subagent-state.ts`)
|
|
60
|
+
|
|
61
|
+
A pure, independently constructible value object. `SubagentStatus` moves here (its natural home) and is re-exported from `subagent.ts`.
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
export type SubagentStatus =
|
|
65
|
+
| "queued" | "running" | "completed" | "steered"
|
|
66
|
+
| "aborted" | "stopped" | "error";
|
|
67
|
+
|
|
68
|
+
export interface SubagentStateInit {
|
|
69
|
+
status?: SubagentStatus;
|
|
70
|
+
result?: string;
|
|
71
|
+
error?: string;
|
|
72
|
+
startedAt?: number;
|
|
73
|
+
completedAt?: number;
|
|
74
|
+
// stats always start at zero; callers accumulate via mutation methods
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class SubagentState {
|
|
78
|
+
constructor(init?: SubagentStateInit); // status ?? "queued", startedAt ?? Date.now()
|
|
79
|
+
// getters: status, result, error, startedAt, completedAt,
|
|
80
|
+
// toolUses, lifetimeUsage (Readonly), compactionCount
|
|
81
|
+
// transitions: markRunning, markCompleted, markAborted, markSteered,
|
|
82
|
+
// markError, markStopped, resetForResume
|
|
83
|
+
// accumulators: incrementToolUses, addUsage, incrementCompactions
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
It owns its mutations (no output arguments — methods write only its own private fields) and carries no upstream dependency beyond `usage.ts`.
|
|
88
|
+
|
|
89
|
+
### `SubagentExecution` collaborator (in `src/lifecycle/subagent.ts`)
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
export interface SubagentExecution {
|
|
93
|
+
createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
|
|
94
|
+
snapshot: ParentSnapshot;
|
|
95
|
+
prompt: string;
|
|
96
|
+
baseCwd: string;
|
|
97
|
+
observer?: SubagentLifecycleObserver;
|
|
98
|
+
getRunConfig?: () => RunConfig;
|
|
99
|
+
getWorkspaceProvider?: () => WorkspaceProvider | undefined;
|
|
100
|
+
model?: Model<any>;
|
|
101
|
+
maxTurns?: number;
|
|
102
|
+
thinkingLevel?: ThinkingLevel;
|
|
103
|
+
parentSession?: ParentSessionInfo;
|
|
104
|
+
signal?: AbortSignal;
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The four fields the old `run()` guards required (`createSubagentSession`, `snapshot`, `prompt`, plus `baseCwd`) are mandatory; the genuinely-optional behavior knobs stay optional.
|
|
109
|
+
|
|
110
|
+
### `Subagent` constructor
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
export interface SubagentInit {
|
|
114
|
+
id: string;
|
|
115
|
+
type: SubagentType;
|
|
116
|
+
description: string;
|
|
117
|
+
invocation?: AgentInvocation;
|
|
118
|
+
execution: SubagentExecution; // mandatory — no more "optional for tests"
|
|
119
|
+
state?: SubagentState; // defaults to new SubagentState() (fresh "queued")
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`SubagentInit` drops from ~20 fields to 5.
|
|
124
|
+
`Subagent` keeps `id`/`type`/`description`/`invocation`, `abortController`, `subagentSession?`, `notification?`, steer buffer, and per-run listener handles; it holds `private readonly state` and `private readonly execution`.
|
|
125
|
+
Getters and mutation methods delegate one line to `this.state`.
|
|
126
|
+
`notification` is still created in the constructor from `execution.parentSession?.toolCallId` (Step 3 moves it).
|
|
127
|
+
|
|
128
|
+
### Consumer call site — `SubagentManager.spawn`
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
const execution: SubagentExecution = {
|
|
132
|
+
createSubagentSession: this.createSubagentSession,
|
|
133
|
+
snapshot, prompt, baseCwd: this.baseCwd,
|
|
134
|
+
observer: this.buildObserver(options),
|
|
135
|
+
getRunConfig: this.getRunConfig,
|
|
136
|
+
getWorkspaceProvider: () => this._workspaceProvider,
|
|
137
|
+
model: options.model, maxTurns: options.maxTurns,
|
|
138
|
+
thinkingLevel: options.thinkingLevel,
|
|
139
|
+
parentSession: options.parentSession, signal: options.signal,
|
|
140
|
+
};
|
|
141
|
+
const record = new Subagent({
|
|
142
|
+
id, type, description: options.description, invocation: options.invocation,
|
|
143
|
+
execution,
|
|
144
|
+
state: new SubagentState({ status: options.isBackground ? "queued" : "running", startedAt: Date.now() }),
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Tell-Don't-Ask: the execution bundle is assembled once and handed over; nothing reaches back into `spawn`'s locals afterward.
|
|
149
|
+
|
|
150
|
+
### Observer retarget (`src/observation/record-observer.ts`)
|
|
151
|
+
|
|
152
|
+
The observer accumulates *stats*, not lifecycle — point it at `SubagentState` and drop the record from `onCompact`:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
export function subscribeSubagentObserver(
|
|
156
|
+
session: SubscribableSession,
|
|
157
|
+
state: SubagentState,
|
|
158
|
+
options?: { onCompact?: (info: CompactionInfo) => void },
|
|
159
|
+
): () => void
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`Subagent.run()`/`resume()` wire it with `this.state` and close over `this` to forward itself to the lifecycle observer:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
this.attachObserver(subscribeSubagentObserver(this.subagentSession, this.state, {
|
|
166
|
+
onCompact: (info) => this.execution.observer?.onCompacted?.(this, info),
|
|
167
|
+
}));
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
This removes the observer's only dependency on `Subagent`, so observer tests construct a `SubagentState` directly.
|
|
171
|
+
`SubagentLifecycleObserver.onCompacted(agent, info)` is unchanged — it still receives the `Subagent`.
|
|
172
|
+
|
|
173
|
+
Decision: the observer takes `SubagentState` concretely rather than a one-off narrow `incrementToolUses`/`addUsage`/`incrementCompactions` interface.
|
|
174
|
+
`SubagentState` is a cohesive owned value object, not a wide dependency bag, and a single internal call site does not justify a speculative interface (recorded in Open Questions).
|
|
175
|
+
|
|
176
|
+
### Edge cases
|
|
177
|
+
|
|
178
|
+
- **Initial status for the limiter guard** — production passes `state: new SubagentState({ status: queued|running })`; the background path still observes `queued` so `limiter.schedule(() => record.status !== "queued" ? … : record.run())` behaves identically.
|
|
179
|
+
- **`resume()` without a prior run** — the `"not configured for resume — missing session"` throw stays; it guards a genuine runtime state (no session was ever created), not a construction concern.
|
|
180
|
+
- **`make-subagent.ts` passive records** — built as `state: new SubagentState({ status: "completed", result, startedAt, completedAt })` plus a `makeStubExecution()`; stat shorthands keep delegating through `Subagent`'s methods.
|
|
181
|
+
|
|
182
|
+
## Module-Level Changes
|
|
183
|
+
|
|
184
|
+
- **`src/lifecycle/subagent-state.ts`** (new) — `SubagentState`, `SubagentStateInit`, and the `SubagentStatus` type (moved from `subagent.ts`).
|
|
185
|
+
- **`src/lifecycle/subagent.ts`** —
|
|
186
|
+
- Re-export `SubagentStatus` from `subagent-state.ts` (keeps service.ts import path valid).
|
|
187
|
+
- Add `SubagentExecution`; rewrite `SubagentInit` to the 5-field shape; remove the flat execution/run-config fields.
|
|
188
|
+
- Remove the (unused) `isBackground?` field from `SubagentInit` — never read in this class.
|
|
189
|
+
- Hold `private readonly state` / `private readonly execution`; convert getters and `markX`/`incrementX`/`addUsage`/`resetForResume` to delegations.
|
|
190
|
+
- `run()`: delete the two `"not configured for execution"` throws; read inputs from `this.execution`; wire the observer at `this.state`.
|
|
191
|
+
- `resume()`: read `observer` from `this.execution`; keep the missing-session throw.
|
|
192
|
+
- **`src/lifecycle/subagent-manager.ts`** — `spawn()` builds the `SubagentExecution` bundle and the initial `SubagentState`; no other change (`markStopped` calls untouched).
|
|
193
|
+
- **`src/observation/record-observer.ts`** — param `record: Subagent` → `state: SubagentState`; `onCompact` signature `(record, info)` → `(info)`; update the JSDoc bullet wording.
|
|
194
|
+
- **`test/helpers/make-subagent.ts`** — add `makeStubExecution()`; build the passive record via `state` + `execution`.
|
|
195
|
+
- **`test/lifecycle/subagent.test.ts`** — funnel constructions through a local helper (prep refactor); move the pure state-machine `describe` blocks to the new state test; supply `execution` everywhere; update the missing-factory test (the throw is gone — replace with a type-level/construction assertion or remove).
|
|
196
|
+
- **`test/lifecycle/subagent-state.test.ts`** (new) — state-machine + accumulation tests targeting `SubagentState` directly.
|
|
197
|
+
- **`test/observation/record-observer.test.ts`** — `makeRecord` → build a `SubagentState`; assert stats on it; `onCompact` test uses `(info)`.
|
|
198
|
+
- **Docs** —
|
|
199
|
+
- `docs/architecture/architecture.md`: add `subagent-state.ts` to the lifecycle directory listing; update the `Subagent` class diagram (state delegations, new collaborators); mark **Step 2 ✅ Complete**; refresh the Phase 17 problem-statement prose (line ~879) and the type-complexity table (`SubagentInit` row at line ~649, add `SubagentExecution`).
|
|
200
|
+
- `.pi/skills/package-pi-subagents/SKILL.md`: Lifecycle domain `10 → 11` modules (add `subagent-state.ts`); total `56 → 57` files.
|
|
201
|
+
|
|
202
|
+
Grep sweep confirmed no other `src/`, `test/`, or `SKILL.md` references to the removed flat fields or the `"not configured for execution"` string outside `subagent.ts`.
|
|
203
|
+
|
|
204
|
+
## Test Impact Analysis
|
|
205
|
+
|
|
206
|
+
1. **New tests the extraction enables** — `test/lifecycle/subagent-state.test.ts` exercises every transition and accumulator on `SubagentState` with no `Subagent`, no execution stub, no session.
|
|
207
|
+
This is the construct-complete payoff: the state machine is now unit-testable in isolation.
|
|
208
|
+
2. **Tests that become redundant** — the state-machine `describe` blocks in `subagent.test.ts` (`markRunning`, `markCompleted`, `markAborted`, `markSteered`, `markError`, `markStopped`, `incrementToolUses`, `addUsage`, `incrementCompactions`, `resetForResume`, and the pure constructor-defaults cases) move out, leaving `subagent.test.ts` to cover identity, session-encapsulation delegation, abort, `run()`/`resume()`, `completeRun`/`failRun`, and listeners.
|
|
209
|
+
`record-observer.test.ts` no longer constructs a `Subagent` — it builds a `SubagentState`.
|
|
210
|
+
3. **Tests that must stay as-is** — `run()`/`resume()` integration tests genuinely exercise the executor (factory wiring, observer lifecycle, workspace bracket); the `createTestSubagent` consumers across `test/ui/` and `test/tools/` exercise read sites and are unaffected (the helper absorbs the construction change).
|
|
211
|
+
|
|
212
|
+
## TDD Order
|
|
213
|
+
|
|
214
|
+
1. **Extract `SubagentState` (pure addition; lift-and-shift prep).**
|
|
215
|
+
- Create `src/lifecycle/subagent-state.ts` and `test/lifecycle/subagent-state.test.ts` (red → green for every transition/accumulator).
|
|
216
|
+
- Refactor `Subagent` to hold a `SubagentState` built from the *existing* `SubagentInit` fields; getters/mutators delegate. `SubagentStatus` moves to `subagent-state.ts`, re-exported from `subagent.ts`.
|
|
217
|
+
- In `subagent.test.ts`, introduce a local construction helper and route call sites through it; move the pure state-machine `describe` blocks into `subagent-state.test.ts`.
|
|
218
|
+
- No consumer breaks (init shape unchanged). `pnpm run check` + `test`.
|
|
219
|
+
- Commit: `refactor: extract SubagentState value object (#373)`.
|
|
220
|
+
2. **Retarget the observer at `SubagentState`.**
|
|
221
|
+
- Change `subscribeSubagentObserver` to accept `SubagentState` and an `onCompact(info)`; update the two `subagent.ts` call sites (pass `this.state`, close over `this`).
|
|
222
|
+
- Rewrite `record-observer.test.ts` to build a `SubagentState`.
|
|
223
|
+
- Commit: `refactor: target SubagentState in subagent observer (#373)`.
|
|
224
|
+
3. **Make execution mandatory (the atomic construction flip).**
|
|
225
|
+
- Add `SubagentExecution`; rewrite `SubagentInit` to the 5-field shape; remove flat execution/run-config fields and `isBackground?`; delete the two `run()` throws; read from `this.execution`.
|
|
226
|
+
- Update the single production site (`subagent-manager.ts spawn`) and `make-subagent.ts` (`makeStubExecution` + `state`) and the `subagent.test.ts` helper/`createRunnableAgent`/`createResumableAgent` in the **same commit** — removing the optional fields breaks every construction at the type level simultaneously.
|
|
227
|
+
- Adjust the now-obsolete "missing session factory" test (throw is gone).
|
|
228
|
+
- `pnpm run check`, `lint`, `test`, `fallow dead-code`.
|
|
229
|
+
- Commit: `refactor: make Subagent execution deps mandatory (#373)`.
|
|
230
|
+
4. **Docs.**
|
|
231
|
+
- Update `architecture.md` (file listing, class diagram, mark Step 2 complete, prose, type table) and `SKILL.md` (domain counts).
|
|
232
|
+
- Commit: `docs: record SubagentState extraction in architecture and skill (#373)`.
|
|
233
|
+
|
|
234
|
+
Optionally run `pnpm run verify:public-types` after Step 3 to confirm the public bundle is unaffected (expected: no change — the bundle does not reference `SubagentInit`).
|
|
235
|
+
|
|
236
|
+
## Risks and Mitigations
|
|
237
|
+
|
|
238
|
+
- **Large test file churn (`subagent.test.ts`, ~700 LOC).**
|
|
239
|
+
Mitigated by lift-and-shift: Step 1 funnels constructions through a local helper and moves state tests out, so Step 3's mandatory-execution flip edits the helper + two run/resume factories rather than rewriting the file.
|
|
240
|
+
- **Initial-status regression for the limiter.**
|
|
241
|
+
Production explicitly sets `state` status in `spawn`; a `subagent-manager` test should assert a background spawn reports `queued` before its slot opens (existing coverage; re-verify).
|
|
242
|
+
- **`SubagentStatus` re-export path.**
|
|
243
|
+
Keep `SubagentStatus` re-exported from `subagent.ts`; `verify:public-types` (and `pnpm run check`) confirm `service.ts` still resolves it.
|
|
244
|
+
- **Circular import (`subagent.ts` ↔ `subagent-state.ts`).**
|
|
245
|
+
Avoided: `subagent-state.ts` defines `SubagentStatus` and imports nothing from `subagent.ts`; `subagent.ts` imports `SubagentState`/`SubagentStatus` from `subagent-state.ts`.
|
|
246
|
+
|
|
247
|
+
## Open Questions
|
|
248
|
+
|
|
249
|
+
- Whether the observer should accept a narrow `{ incrementToolUses; addUsage; incrementCompactions }` interface instead of `SubagentState` concretely — deferred; revisit if a second consumer of the stats sink appears (defer-until-needed, per the metrics-projection work in a later phase).
|
|
250
|
+
- Whether the `make-subagent.ts` stat shorthands should construct `SubagentState` directly rather than delegating through `Subagent` — cosmetic; left until Step 7's fixture consolidation (#378).
|