@gotgenes/pi-subagents 11.0.0 → 11.1.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.
- package/CHANGELOG.md +19 -0
- package/docs/architecture/architecture.md +7 -4
- package/docs/plans/0230-extract-concurrency-queue.md +265 -0
- package/docs/retro/0229-agent-born-complete.md +42 -0
- package/docs/retro/0230-extract-concurrency-queue.md +38 -0
- package/package.json +1 -1
- package/src/index.ts +15 -3
- package/src/lifecycle/agent-manager.ts +18 -46
- package/src/lifecycle/agent.ts +1 -1
- package/src/lifecycle/concurrency-queue.ts +63 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ 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
|
+
## [11.1.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v11.0.1...pi-subagents-v11.1.0) (2026-05-28)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pi-subagents:** add ConcurrencyQueue class ([#230](https://github.com/gotgenes/pi-packages/issues/230)) ([9fff9b7](https://github.com/gotgenes/pi-packages/commit/9fff9b7fc318ad8bf5ac3a218ee7bf1c5e11104b))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* **pi-subagents:** update architecture for ConcurrencyQueue extraction ([#230](https://github.com/gotgenes/pi-packages/issues/230)) ([4bd69e1](https://github.com/gotgenes/pi-packages/commit/4bd69e16164132400c7e0f9e4ecfd9f41842247a))
|
|
19
|
+
|
|
20
|
+
## [11.0.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v11.0.0...pi-subagents-v11.0.1) (2026-05-28)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Documentation
|
|
24
|
+
|
|
25
|
+
* **retro:** add retro notes for issue [#229](https://github.com/gotgenes/pi-packages/issues/229) ([13e9873](https://github.com/gotgenes/pi-packages/commit/13e9873f6dc3e8522cd6359085f86c99507e9db9))
|
|
26
|
+
|
|
8
27
|
## [11.0.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v10.2.1...pi-subagents-v11.0.0) (2026-05-28)
|
|
9
28
|
|
|
10
29
|
|
|
@@ -53,7 +53,8 @@ flowchart TB
|
|
|
53
53
|
|
|
54
54
|
subgraph lifecycle["Lifecycle domain"]
|
|
55
55
|
direction TB
|
|
56
|
-
AgentManager["AgentManager<br/>(spawn,
|
|
56
|
+
AgentManager["AgentManager<br/>(spawn, abort, collection)"]
|
|
57
|
+
ConcurrencyQueue["ConcurrencyQueue<br/>(scheduling, drain)"]
|
|
57
58
|
AgentRunner["agent-runner<br/>(session, turns, results)"]
|
|
58
59
|
Agent["Agent<br/>(status, behavior: abort/steer/worktree/run lifecycle)"]
|
|
59
60
|
ParentSnapshot["ParentSnapshot<br/>(frozen parent state)"]
|
|
@@ -266,9 +267,10 @@ src/
|
|
|
266
267
|
│ └── session-dir.ts session directory derivation
|
|
267
268
|
│
|
|
268
269
|
├── lifecycle/ agent execution and state tracking
|
|
269
|
-
│ ├── agent-manager.ts collection manager +
|
|
270
|
+
│ ├── agent-manager.ts collection manager + observer wiring
|
|
270
271
|
│ ├── agent-runner.ts session creation, turn loop, tool filtering
|
|
271
272
|
│ ├── agent.ts owns full execution lifecycle (run, abort, steer, worktree)
|
|
273
|
+
│ ├── concurrency-queue.ts background agent scheduling with configurable concurrency limit
|
|
272
274
|
│ ├── parent-snapshot.ts immutable spawn-time parent state
|
|
273
275
|
│ ├── execution-state.ts session/output phase state
|
|
274
276
|
│ ├── permission-bridge.ts optional bridge to pi-permission-system registry
|
|
@@ -346,7 +348,8 @@ They declare this package as an optional peer dependency and use dynamic import
|
|
|
346
348
|
### What the core owns
|
|
347
349
|
|
|
348
350
|
- The three tools: `subagent` (née `Agent`), `get_subagent_result`, `steer_subagent`.
|
|
349
|
-
- `AgentManager` — spawn,
|
|
351
|
+
- `AgentManager` — spawn, abort, resume, collection management, observer wiring.
|
|
352
|
+
- `ConcurrencyQueue` — background agent scheduling with configurable concurrency limit.
|
|
350
353
|
- `agent-runner` — session creation, turn loop, extension binding.
|
|
351
354
|
- `permission-bridge` — optional cross-extension bridge to `@gotgenes/pi-permission-system`; registers each child session with `SubagentSessionRegistry` before `bindExtensions()` so the permission system detects in-process children deterministically.
|
|
352
355
|
Scheduled for removal in Phase 16 — replaced by lifecycle events that consumers listen for.
|
|
@@ -712,7 +715,7 @@ Agent receives three concerns at construction:
|
|
|
712
715
|
5. Clean up worktree on completion or error.
|
|
713
716
|
6. Transition status.
|
|
714
717
|
|
|
715
|
-
`AgentManager` becomes a collection manager +
|
|
718
|
+
`AgentManager` becomes a collection manager + observer wiring:
|
|
716
719
|
|
|
717
720
|
- Creates complete Agent objects, stores them in the map.
|
|
718
721
|
- Decides when to run (immediate or queue) and calls `agent.run()`.
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 230
|
|
3
|
+
issue_title: "Extract ConcurrencyQueue from AgentManager (Phase 15, Step 5)"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Extract ConcurrencyQueue from AgentManager
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
`AgentManager` tangles two concerns: agent collection management and scheduling.
|
|
11
|
+
The scheduling concern — `queue[]`, `runningBackground`, `_getMaxConcurrent`, `drainQueue()`, `finalizeBackgroundRun()`, `notifyConcurrencyChanged()` — is 3 fields + 3 methods of cohesive, separable logic.
|
|
12
|
+
`notifyConcurrencyChanged()` is a scheduling method exposed as a public API on the wrong object so that `SettingsManager` can poke the queue after a concurrency limit change.
|
|
13
|
+
This cross-concern leak violates SRP and prevents independent testing of the queue.
|
|
14
|
+
|
|
15
|
+
## Goals
|
|
16
|
+
|
|
17
|
+
- Extract scheduling logic into a `ConcurrencyQueue` class in `src/lifecycle/concurrency-queue.ts`.
|
|
18
|
+
- Delete `notifyConcurrencyChanged()` from `AgentManager` — settings triggers drain on the queue directly via the existing callback wiring.
|
|
19
|
+
- Make scheduling independently testable with fast, focused unit tests.
|
|
20
|
+
- `AgentManager` becomes a pure collection manager (agents Map, lookup, cleanup, iteration) + observer wiring.
|
|
21
|
+
|
|
22
|
+
## Non-Goals
|
|
23
|
+
|
|
24
|
+
- Changing `SettingsManager` internals — `onMaxConcurrentChanged` callback stays; only the wiring target changes (queue.drain instead of manager.notifyConcurrencyChanged).
|
|
25
|
+
- Extracting `Agent.resume()` — tracked in #232.
|
|
26
|
+
- Changing the concurrency semantics (limits, drain order, foreground bypass).
|
|
27
|
+
|
|
28
|
+
## Background
|
|
29
|
+
|
|
30
|
+
### Dependencies
|
|
31
|
+
|
|
32
|
+
Both dependencies are implemented:
|
|
33
|
+
|
|
34
|
+
- Issue #229 (Agent.run()) — ✅ closed.
|
|
35
|
+
Agent owns its full execution lifecycle; `startAgent` and `SpawnArgs` are gone.
|
|
36
|
+
- Issue #231 (runner self-contained) — ✅ closed.
|
|
37
|
+
Agent holds the runner at construction.
|
|
38
|
+
|
|
39
|
+
### Current queue surface in AgentManager
|
|
40
|
+
|
|
41
|
+
| Member | Kind | Purpose |
|
|
42
|
+
| --------------------------------- | ------ | ------------------------------------------------ |
|
|
43
|
+
| `queue: string[]` | field | IDs of background agents waiting to start |
|
|
44
|
+
| `runningBackground: number` | field | Count of currently running background agents |
|
|
45
|
+
| `_getMaxConcurrent: () => number` | field | Injected getter for the concurrency limit |
|
|
46
|
+
| `drainQueue()` | method | Start queued agents up to the limit |
|
|
47
|
+
| `finalizeBackgroundRun()` | method | Decrement counter, notify observer, drain |
|
|
48
|
+
| `notifyConcurrencyChanged()` | method | Public entry point for settings to trigger drain |
|
|
49
|
+
|
|
50
|
+
These 6 members form a cohesive unit — they only reference each other and the agents Map (for status checks during drain).
|
|
51
|
+
|
|
52
|
+
### Callers of queue logic in AgentManager
|
|
53
|
+
|
|
54
|
+
- `spawn()` — checks `runningBackground >= getMaxConcurrent()`, pushes to `queue` or starts.
|
|
55
|
+
- `buildObserver().onStarted` — increments `runningBackground`.
|
|
56
|
+
- `buildObserver().onRunFinished` — calls `finalizeBackgroundRun()`.
|
|
57
|
+
- `abort()` — filters `queue` to remove an aborted ID.
|
|
58
|
+
- `abortAll()` — iterates `queue`, clears it.
|
|
59
|
+
- `waitForAll()` — calls `drainQueue()`.
|
|
60
|
+
- `dispose()` — clears `queue`.
|
|
61
|
+
|
|
62
|
+
### Agent comment to update
|
|
63
|
+
|
|
64
|
+
`agent.ts` line 366 has a comment: "Queue removal stays on AgentManager until #230 extracts ConcurrencyQueue."
|
|
65
|
+
This comment should be updated to remove the forward reference.
|
|
66
|
+
|
|
67
|
+
## Design Overview
|
|
68
|
+
|
|
69
|
+
### ConcurrencyQueue class
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
export class ConcurrencyQueue {
|
|
73
|
+
private queue: string[] = [];
|
|
74
|
+
private running = 0;
|
|
75
|
+
|
|
76
|
+
constructor(
|
|
77
|
+
private readonly getMaxConcurrent: () => number,
|
|
78
|
+
private readonly startAgent: (id: string) => void,
|
|
79
|
+
) {}
|
|
80
|
+
|
|
81
|
+
isFull(): boolean;
|
|
82
|
+
enqueue(id: string): void;
|
|
83
|
+
dequeue(id: string): boolean;
|
|
84
|
+
markStarted(): void;
|
|
85
|
+
markFinished(): void; // running--, drain()
|
|
86
|
+
drain(): void;
|
|
87
|
+
get queuedIds(): readonly string[];
|
|
88
|
+
clear(): void;
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Design decision: stored start callback
|
|
93
|
+
|
|
94
|
+
The issue proposes `drain(start: (id: string) => void)` with the callback as a parameter.
|
|
95
|
+
However, the issue also proposes `markFinished()` as no-arg with "running--, drain()" semantics — which contradicts `drain` requiring a callback parameter.
|
|
96
|
+
|
|
97
|
+
Resolution: store the `startAgent` callback at construction.
|
|
98
|
+
This makes `drain()` and `markFinished()` both no-arg, follows Tell-Don't-Ask (the queue is a self-contained unit), and avoids requiring callers to pass the same callback repeatedly.
|
|
99
|
+
|
|
100
|
+
The `startAgent` callback is provided by the wiring layer (`index.ts`) using the established forward-reference-via-closure pattern already used for `onMaxConcurrentChanged`:
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
// index.ts
|
|
104
|
+
const queue = new ConcurrencyQueue(
|
|
105
|
+
() => settings.maxConcurrent,
|
|
106
|
+
(id) => {
|
|
107
|
+
const agent = manager.getRecord(id);
|
|
108
|
+
if (agent?.status !== "queued") return;
|
|
109
|
+
agent.promise = agent.run();
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Ordering note
|
|
115
|
+
|
|
116
|
+
`markFinished()` calls `drain()` internally.
|
|
117
|
+
The current `finalizeBackgroundRun()` order is: decrement → observer notification → drain.
|
|
118
|
+
After extraction: `queue.markFinished()` (decrement + drain) → observer notification.
|
|
119
|
+
Drain fires before the observer notification.
|
|
120
|
+
|
|
121
|
+
This reordering is safe: `drain()` only starts promises (no await), and the observer notification (`onAgentCompleted`) processes the completed agent's data without referencing queue state.
|
|
122
|
+
|
|
123
|
+
### AgentManager after extraction
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
export interface AgentManagerOptions {
|
|
127
|
+
runner: AgentRunner;
|
|
128
|
+
worktrees: WorktreeManager;
|
|
129
|
+
queue: ConcurrencyQueue; // was: getMaxConcurrent
|
|
130
|
+
getRunConfig?: () => RunConfig;
|
|
131
|
+
observer?: AgentManagerObserver;
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`AgentManager` loses `queue`, `runningBackground`, `_getMaxConcurrent`, `drainQueue()`, `finalizeBackgroundRun()`, `notifyConcurrencyChanged()`.
|
|
136
|
+
|
|
137
|
+
### Settings wiring
|
|
138
|
+
|
|
139
|
+
Before:
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
onMaxConcurrentChanged: () => manager.notifyConcurrencyChanged(),
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
After:
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
onMaxConcurrentChanged: () => queue.drain(),
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`SettingsManager` itself does not change — it still invokes the stored callback.
|
|
152
|
+
The callback wiring in `index.ts` targets the queue directly instead of the manager.
|
|
153
|
+
|
|
154
|
+
### Consumer call site (AgentManager.buildObserver)
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
private buildObserver(options: AgentSpawnConfig): AgentLifecycleObserver {
|
|
158
|
+
return {
|
|
159
|
+
onStarted: (agent) => {
|
|
160
|
+
if (options.isBackground) this.queue.markStarted();
|
|
161
|
+
this.observer?.onAgentStarted(agent);
|
|
162
|
+
},
|
|
163
|
+
onRunFinished: (agent) => {
|
|
164
|
+
if (options.isBackground) {
|
|
165
|
+
this.queue.markFinished();
|
|
166
|
+
try { this.observer?.onAgentCompleted(agent); }
|
|
167
|
+
catch (err) { debugLog("onAgentCompleted observer", err); }
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
// onSessionCreated, onCompacted unchanged
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Test helper (createManager)
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
function createManager(overrides?: { ...; getMaxConcurrent?: () => number; }) {
|
|
179
|
+
let mgr: AgentManager;
|
|
180
|
+
const queue = new ConcurrencyQueue(
|
|
181
|
+
overrides?.getMaxConcurrent ?? (() => 4),
|
|
182
|
+
(id) => {
|
|
183
|
+
const record = mgr.getRecord(id);
|
|
184
|
+
if (record?.status !== "queued") return;
|
|
185
|
+
record.promise = record.run();
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
mgr = new AgentManager({ ..., queue });
|
|
189
|
+
return { manager: mgr, ..., queue };
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The forward-reference-via-closure is safe because `drain()` is never called during construction.
|
|
194
|
+
The `getMaxConcurrent` parameter name stays in the test helper for readability; it's passed to `ConcurrencyQueue`.
|
|
195
|
+
|
|
196
|
+
## Module-Level Changes
|
|
197
|
+
|
|
198
|
+
| File | Change |
|
|
199
|
+
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
200
|
+
| `src/lifecycle/concurrency-queue.ts` | **Add** — new `ConcurrencyQueue` class |
|
|
201
|
+
| `src/lifecycle/agent-manager.ts` | **Change** — remove 3 fields, 3 methods; add `queue: ConcurrencyQueue` to options; update `buildObserver`, `spawn`, `abort`, `abortAll`, `waitForAll`, `dispose` |
|
|
202
|
+
| `src/lifecycle/agent.ts` | **Change** — update comment on `abort()` (remove #230 forward reference) |
|
|
203
|
+
| `src/index.ts` | **Change** — create `ConcurrencyQueue`, pass to manager, wire settings to `queue.drain()` |
|
|
204
|
+
| `test/lifecycle/concurrency-queue.test.ts` | **Add** — unit tests for ConcurrencyQueue |
|
|
205
|
+
| `test/lifecycle/agent-manager.test.ts` | **Change** — update `createManager` helper to construct ConcurrencyQueue; no queue-behavior tests removed (they remain as integration tests) |
|
|
206
|
+
| `docs/architecture/architecture.md` | **Change** — add `concurrency-queue.ts` to layout listing; update agent-manager description |
|
|
207
|
+
|
|
208
|
+
## Test Impact Analysis
|
|
209
|
+
|
|
210
|
+
### New unit tests enabled by extraction
|
|
211
|
+
|
|
212
|
+
1. `isFull()` boundary — returns false when running < max, true when running >= max.
|
|
213
|
+
2. `enqueue()` / `dequeue()` — add/remove from queue, dequeue returns false for missing ID.
|
|
214
|
+
3. `markStarted()` / `markFinished()` — increment/decrement running count.
|
|
215
|
+
4. `drain()` — calls `startAgent` for each queued ID until full; skips when already full; handles empty queue.
|
|
216
|
+
5. `markFinished()` auto-drain — decrement triggers drain of next queued agent.
|
|
217
|
+
6. `clear()` — empties queue without starting agents.
|
|
218
|
+
7. `queuedIds` — snapshot of queue for iteration.
|
|
219
|
+
|
|
220
|
+
These tests were previously impossible because queue logic was interleaved with agent creation, observer notifications, and session management in `AgentManager`.
|
|
221
|
+
|
|
222
|
+
### Existing tests that stay as-is
|
|
223
|
+
|
|
224
|
+
- "queueing and concurrency with injected stubs" — integration tests verifying end-to-end spawn→queue→drain through the full AgentManager stack.
|
|
225
|
+
They still provide value as wiring tests.
|
|
226
|
+
- All observer notification tests — test observer wiring which stays in AgentManager.
|
|
227
|
+
- Bug race condition tests, worktree tests, execution state tests, lifecycle observer forwarding tests — independent of queue.
|
|
228
|
+
|
|
229
|
+
### Existing tests that need updating
|
|
230
|
+
|
|
231
|
+
- `createManager` helper — accepts `getMaxConcurrent` but passes it to `ConcurrencyQueue` constructor instead of `AgentManagerOptions`.
|
|
232
|
+
|
|
233
|
+
## TDD Order
|
|
234
|
+
|
|
235
|
+
1. **Red→Green: ConcurrencyQueue class + tests.**
|
|
236
|
+
New `src/lifecycle/concurrency-queue.ts` with `isFull`, `enqueue`, `dequeue`, `markStarted`, `markFinished`, `drain`, `clear`, `queuedIds`.
|
|
237
|
+
New `test/lifecycle/concurrency-queue.test.ts` covering: full boundary, enqueue/dequeue, start/finish counting, drain ordering, markFinished auto-drain, clear, empty-queue no-op.
|
|
238
|
+
Commit: `feat(pi-subagents): add ConcurrencyQueue class (#230)`
|
|
239
|
+
|
|
240
|
+
2. **Red→Green: Migrate AgentManager to use ConcurrencyQueue.**
|
|
241
|
+
Update `AgentManagerOptions`: replace `getMaxConcurrent` with `queue: ConcurrencyQueue`.
|
|
242
|
+
Update constructor, `buildObserver`, `spawn`, `abort`, `abortAll`, `waitForAll`, `dispose`.
|
|
243
|
+
Delete: `queue` field, `runningBackground` field, `_getMaxConcurrent` field, `notifyConcurrencyChanged()`, `drainQueue()`, `finalizeBackgroundRun()`.
|
|
244
|
+
Update `test/lifecycle/agent-manager.test.ts`: revise `createManager` helper to construct `ConcurrencyQueue` internally.
|
|
245
|
+
Update `src/index.ts`: construct `ConcurrencyQueue`, pass to `AgentManager`, wire `onMaxConcurrentChanged` to `queue.drain()`.
|
|
246
|
+
Update `src/lifecycle/agent.ts`: remove #230 forward-reference comment on `abort()`.
|
|
247
|
+
Run `pnpm run check` after this step.
|
|
248
|
+
Commit: `refactor(pi-subagents): replace inline queue with ConcurrencyQueue (#230)`
|
|
249
|
+
|
|
250
|
+
3. **Docs: Update architecture.**
|
|
251
|
+
Update `docs/architecture/architecture.md`: add `concurrency-queue.ts` to layout listing under `lifecycle/`, update `agent-manager.ts` description from "collection manager + concurrency controller" to "collection manager + observer wiring".
|
|
252
|
+
Commit: `docs(pi-subagents): update architecture for ConcurrencyQueue extraction (#230)`
|
|
253
|
+
|
|
254
|
+
## Risks and Mitigations
|
|
255
|
+
|
|
256
|
+
| Risk | Mitigation |
|
|
257
|
+
| ------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
258
|
+
| Forward-reference-via-closure in test helper and index.ts could break if drain is called during construction | ConcurrencyQueue constructor does not call drain; drain is only called after agents exist. Same pattern already used for `onMaxConcurrentChanged`. |
|
|
259
|
+
| `markFinished()` auto-drain changes ordering (drain before observer notification) | Verified: observer notification only processes the completed agent's data and does not reference queue state. Drain starts promises without awaiting — no observable behavior change. |
|
|
260
|
+
| `markStarted()` called synchronously inside drain loop could miscount | Verified: `Agent.run()` calls `observer.onStarted()` synchronously before the first await, so `markStarted()` fires before control returns to the drain while-loop. The running count is always current. |
|
|
261
|
+
| Integration tests in agent-manager.test.ts break after migration | Tests continue to work because the `createManager` helper constructs the ConcurrencyQueue internally with the same `getMaxConcurrent` semantics. Queue behavior is preserved. |
|
|
262
|
+
|
|
263
|
+
## Open Questions
|
|
264
|
+
|
|
265
|
+
None — the issue's proposed change is unambiguous and both dependencies are implemented.
|
|
@@ -45,3 +45,45 @@ Test count went from 1005 to 1020 (+15 tests).
|
|
|
45
45
|
- **Worktree error surface confirmed async:** The `agent-manager.test.ts` test for synchronous worktree throw was rewritten to verify the error surfaces on `record.error` after awaiting the promise.
|
|
46
46
|
`background-spawner.ts` try/catch around `spawn()` retained for robustness.
|
|
47
47
|
- **Pre-completion reviewer:** WARN — 3 non-blocking findings: `setupWorktree` not marked private, `isBackground` dead field on `AgentInit`, and `package-pi-subagents` SKILL.md Phase 15 description referencing deleted `startAgent`.
|
|
48
|
+
|
|
49
|
+
## Stage: Final Retrospective (2026-05-28T01:30:00Z)
|
|
50
|
+
|
|
51
|
+
### Session summary
|
|
52
|
+
|
|
53
|
+
Planned, implemented (9 TDD steps), shipped, and released `pi-subagents-v11.0.0` in a single session spanning planning → TDD → ship → retro.
|
|
54
|
+
Test count: 1005 → 1020 (+15).
|
|
55
|
+
Also filed #249 (`pi-permission-system` bash external-directory gate bug) discovered during the pre-completion review.
|
|
56
|
+
|
|
57
|
+
### Observations
|
|
58
|
+
|
|
59
|
+
#### What went well
|
|
60
|
+
|
|
61
|
+
- The lift-and-shift TDD strategy (steps 1–5 incrementally preparing, step 6 adding `Agent.run()`, step 7 rewriting `spawn()`) kept every commit compilable and green.
|
|
62
|
+
No step required backtracking.
|
|
63
|
+
- The `ask-user` call during planning (observer pattern vs callbacks, sync vs async error surface) front-loaded design decisions that would have caused rework if deferred.
|
|
64
|
+
- Pre-commit hooks caught both lint failures (`no-unnecessary-condition` on `abortController?.abort()`, `unbound-method` on observer forwarding) before they reached CI.
|
|
65
|
+
|
|
66
|
+
#### What caused friction (agent side)
|
|
67
|
+
|
|
68
|
+
- `wrong-abstraction` — The plan separated step 7 (remove `onSessionCreated` from `AgentSpawnConfig`) and step 8 (update tool-layer consumers) as distinct commits.
|
|
69
|
+
Removing the field immediately broke `background-spawner.ts` and `foreground-runner.ts` at compilation, forcing a merge.
|
|
70
|
+
Impact: added friction but no rework — the merge was straightforward since both files needed the same `onSessionCreated` → `observer` transformation.
|
|
71
|
+
Added a testing skill rule to catch this pattern in future plans.
|
|
72
|
+
|
|
73
|
+
#### What caused friction (user side)
|
|
74
|
+
|
|
75
|
+
- The pre-completion reviewer's Mermaid validation (`mmdc -o /tmp/mermaid-check.svg`) triggered a permission prompt from `pi-permission-system` despite `/tmp/*` being configured as `"allow"` in the global config.
|
|
76
|
+
This was a genuine bug (#249) in the bash external-directory gate, not a config mistake.
|
|
77
|
+
The prompt interrupted the automated review flow, requiring manual approval.
|
|
78
|
+
|
|
79
|
+
### Diagnostic details
|
|
80
|
+
|
|
81
|
+
- **Model-performance correlation** — Pre-completion reviewer ran on `claude-sonnet-4-6-20260526`; appropriate for judgment-heavy review work (code design, acceptance criteria, mermaid validation).
|
|
82
|
+
No mismatches detected.
|
|
83
|
+
- **Feedback-loop gap analysis** — `pnpm run check` and `pnpm run test` were run after every TDD step commit, not just at the end.
|
|
84
|
+
`pnpm run lint` ran at the end (post-TDD checks) and at pre-push, which is correct since lint is slower and pre-commit hooks catch most issues incrementally.
|
|
85
|
+
|
|
86
|
+
### Changes made
|
|
87
|
+
|
|
88
|
+
1. `.pi/skills/testing/SKILL.md` — added TDD planning rule: when a step removes a field from a shared interface, all downstream readers must update in the same step.
|
|
89
|
+
2. `packages/pi-subagents/docs/retro/0229-agent-born-complete.md` — appended Final Retrospective stage entry.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 230
|
|
3
|
+
issue_title: "Extract ConcurrencyQueue from AgentManager (Phase 15, Step 5)"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #230 — Extract ConcurrencyQueue from AgentManager
|
|
7
|
+
|
|
8
|
+
## Stage: Planning (2026-05-28T20:00:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Produced a 3-step TDD plan for extracting the scheduling concern (3 fields, 3 methods) from `AgentManager` into a new `ConcurrencyQueue` class.
|
|
13
|
+
Both dependencies (#229 Agent.run(), #231 runner self-contained) are confirmed closed.
|
|
14
|
+
|
|
15
|
+
### Observations
|
|
16
|
+
|
|
17
|
+
- The issue's proposed API has `drain(start: (id: string) => void)` but also `markFinished()` as no-arg with "running--, drain()" semantics — a contradiction.
|
|
18
|
+
Resolved by storing the `startAgent` callback at construction, making both `drain()` and `markFinished()` no-arg.
|
|
19
|
+
This follows Tell-Don't-Ask and matches the established forward-reference-via-closure pattern already used for `onMaxConcurrentChanged`.
|
|
20
|
+
- `markFinished()` auto-drain changes the ordering from "decrement → observer → drain" to "decrement + drain → observer."
|
|
21
|
+
Verified this is safe: observer notification only processes the completed agent and drain only starts promises without awaiting.
|
|
22
|
+
- `SettingsManager` does not change — only the callback wiring in `index.ts` changes target from `manager.notifyConcurrencyChanged()` to `queue.drain()`.
|
|
23
|
+
- The `agent.ts` `abort()` method has a comment referencing #230 that should be updated in the implementation step.
|
|
24
|
+
|
|
25
|
+
## Stage: Implementation — TDD (2026-05-28T21:35:00Z)
|
|
26
|
+
|
|
27
|
+
### Session summary
|
|
28
|
+
|
|
29
|
+
Implemented all 3 TDD steps: (1) `ConcurrencyQueue` class + 22 unit tests, (2) migrated `AgentManager` to use injected `ConcurrencyQueue` and updated `index.ts` wiring + test helper, (3) architecture docs and SKILL.md updates.
|
|
30
|
+
Test count delta: 1020 → 1042 (+22 new `ConcurrencyQueue` tests, 0 removed).
|
|
31
|
+
|
|
32
|
+
### Observations
|
|
33
|
+
|
|
34
|
+
- The `createManager` test helper required the forward-reference-via-closure pattern (`let mgr` then closure then assignment) with a `prefer-const` ESLint suppression — same pattern used in production `index.ts` for `onMaxConcurrentChanged`.
|
|
35
|
+
- Pre-completion reviewer returned WARN for one stale comment (`drainQueue` reference in `waitForAll`) — fixed by amending the docs commit.
|
|
36
|
+
- No plan deviations.
|
|
37
|
+
All module-level changes matched the plan exactly.
|
|
38
|
+
- Pre-completion reviewer: WARN → fixed (stale comment).
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { loadCustomAgents } from "#src/config/custom-agents";
|
|
|
25
25
|
import { SessionLifecycleHandler, ToolStartHandler } from "#src/handlers/index";
|
|
26
26
|
import { AgentManager, type AgentManagerObserver } from "#src/lifecycle/agent-manager";
|
|
27
27
|
import { ConcreteAgentRunner, type RunnerDeps } from "#src/lifecycle/agent-runner";
|
|
28
|
+
import { ConcurrencyQueue } from "#src/lifecycle/concurrency-queue";
|
|
28
29
|
import { buildParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
29
30
|
import { GitWorktreeManager } from "#src/lifecycle/worktree";
|
|
30
31
|
import { buildEventData, type NotificationDetails, NotificationManager } from "#src/observation/notification";
|
|
@@ -66,12 +67,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
66
67
|
);
|
|
67
68
|
|
|
68
69
|
// Settings: owns all three in-memory values and handles load/save/emit.
|
|
69
|
-
// onMaxConcurrentChanged is wired
|
|
70
|
+
// onMaxConcurrentChanged is wired to the queue directly (closure captures by reference).
|
|
70
71
|
const settings = new SettingsManager({
|
|
71
72
|
emit: (event, payload) => pi.events.emit(event, payload),
|
|
72
73
|
cwd: process.cwd(),
|
|
73
74
|
agentDir: getAgentDir(),
|
|
74
|
-
onMaxConcurrentChanged: () =>
|
|
75
|
+
onMaxConcurrentChanged: () => queue.drain(),
|
|
75
76
|
});
|
|
76
77
|
settings.load();
|
|
77
78
|
|
|
@@ -150,11 +151,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
150
151
|
registry,
|
|
151
152
|
};
|
|
152
153
|
|
|
154
|
+
// ConcurrencyQueue: scheduling extracted from AgentManager.
|
|
155
|
+
// startAgent callback forward-references manager via closure (safe — drain is never called during construction).
|
|
156
|
+
const queue = new ConcurrencyQueue(
|
|
157
|
+
() => settings.maxConcurrent,
|
|
158
|
+
(id) => {
|
|
159
|
+
const agent = manager.getRecord(id);
|
|
160
|
+
if (agent?.status !== "queued") return;
|
|
161
|
+
agent.promise = agent.run();
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
|
|
153
165
|
const manager = new AgentManager({
|
|
154
166
|
runner: new ConcreteAgentRunner(runnerDeps),
|
|
155
167
|
worktrees: new GitWorktreeManager(process.cwd()),
|
|
156
168
|
observer,
|
|
157
|
-
|
|
169
|
+
queue,
|
|
158
170
|
getRunConfig: () => settings,
|
|
159
171
|
});
|
|
160
172
|
|
|
@@ -11,6 +11,7 @@ import type { Model } from "@earendil-works/pi-ai";
|
|
|
11
11
|
import { debugLog } from "#src/debug";
|
|
12
12
|
import { Agent, type AgentLifecycleObserver } from "#src/lifecycle/agent";
|
|
13
13
|
import type { AgentRunner } from "#src/lifecycle/agent-runner";
|
|
14
|
+
import type { ConcurrencyQueue } from "#src/lifecycle/concurrency-queue";
|
|
14
15
|
import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
15
16
|
import type { WorktreeManager } from "#src/lifecycle/worktree";
|
|
16
17
|
|
|
@@ -27,14 +28,11 @@ export interface AgentManagerObserver {
|
|
|
27
28
|
onAgentCreated(record: Agent): void;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
/** Default max concurrent background agents. */
|
|
31
|
-
const DEFAULT_MAX_CONCURRENT = 4;
|
|
32
|
-
|
|
33
31
|
export interface AgentManagerOptions {
|
|
34
32
|
runner: AgentRunner;
|
|
35
33
|
worktrees: WorktreeManager;
|
|
36
|
-
/**
|
|
37
|
-
|
|
34
|
+
/** Concurrency queue — owns scheduling, limit checks, and drain logic. */
|
|
35
|
+
queue: ConcurrencyQueue;
|
|
38
36
|
getRunConfig?: () => RunConfig;
|
|
39
37
|
observer?: AgentManagerObserver;
|
|
40
38
|
}
|
|
@@ -71,44 +69,35 @@ export class AgentManager {
|
|
|
71
69
|
private readonly observer?: AgentManagerObserver;
|
|
72
70
|
private readonly runner: AgentRunner;
|
|
73
71
|
private readonly worktrees: WorktreeManager;
|
|
74
|
-
private readonly
|
|
72
|
+
private readonly queue: ConcurrencyQueue;
|
|
75
73
|
private getRunConfig?: () => RunConfig;
|
|
76
74
|
|
|
77
|
-
/** Queue of background agent IDs waiting to start. */
|
|
78
|
-
private queue: string[] = [];
|
|
79
|
-
/** Number of currently running background agents. */
|
|
80
|
-
private runningBackground = 0;
|
|
81
75
|
constructor(options: AgentManagerOptions) {
|
|
82
76
|
this.runner = options.runner;
|
|
83
77
|
this.worktrees = options.worktrees;
|
|
78
|
+
this.queue = options.queue;
|
|
84
79
|
this.observer = options.observer;
|
|
85
80
|
this.getRunConfig = options.getRunConfig;
|
|
86
|
-
this._getMaxConcurrent = options.getMaxConcurrent ?? (() => DEFAULT_MAX_CONCURRENT);
|
|
87
81
|
// Cleanup completed agents after 10 minutes (but keep sessions for resume)
|
|
88
82
|
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
|
|
89
83
|
this.cleanupInterval.unref();
|
|
90
84
|
}
|
|
91
85
|
|
|
92
|
-
/**
|
|
93
|
-
* Drain the concurrency queue after SettingsManager has updated maxConcurrent.
|
|
94
|
-
* Call this whenever the concurrency limit increases so queued agents can start.
|
|
95
|
-
*/
|
|
96
|
-
notifyConcurrencyChanged(): void {
|
|
97
|
-
this.drainQueue();
|
|
98
|
-
}
|
|
99
|
-
|
|
100
86
|
/** Compose a per-agent lifecycle observer from manager and spawn-config concerns. */
|
|
101
87
|
private buildObserver(options: AgentSpawnConfig): AgentLifecycleObserver {
|
|
102
88
|
return {
|
|
103
89
|
onStarted: (agent) => {
|
|
104
|
-
if (options.isBackground) this.
|
|
90
|
+
if (options.isBackground) this.queue.markStarted();
|
|
105
91
|
this.observer?.onAgentStarted(agent);
|
|
106
92
|
},
|
|
107
93
|
onSessionCreated: options.observer?.onSessionCreated
|
|
108
94
|
? (agent, session) => options.observer!.onSessionCreated!(agent, session)
|
|
109
95
|
: undefined,
|
|
110
96
|
onRunFinished: (agent) => {
|
|
111
|
-
if (options.isBackground)
|
|
97
|
+
if (options.isBackground) {
|
|
98
|
+
this.queue.markFinished();
|
|
99
|
+
try { this.observer?.onAgentCompleted(agent); } catch (err) { debugLog("onAgentCompleted observer", err); }
|
|
100
|
+
}
|
|
112
101
|
},
|
|
113
102
|
onCompacted: (agent, info) => {
|
|
114
103
|
this.observer?.onAgentCompacted(agent, info);
|
|
@@ -156,9 +145,9 @@ export class AgentManager {
|
|
|
156
145
|
this.observer?.onAgentCreated(record);
|
|
157
146
|
}
|
|
158
147
|
|
|
159
|
-
if (options.isBackground && !options.bypassQueue && this.
|
|
148
|
+
if (options.isBackground && !options.bypassQueue && this.queue.isFull()) {
|
|
160
149
|
// Queue it - will be started when a running agent completes
|
|
161
|
-
this.queue.
|
|
150
|
+
this.queue.enqueue(id);
|
|
162
151
|
return id;
|
|
163
152
|
}
|
|
164
153
|
|
|
@@ -166,23 +155,6 @@ export class AgentManager {
|
|
|
166
155
|
return id;
|
|
167
156
|
}
|
|
168
157
|
|
|
169
|
-
/** Decrement background counter, notify observer (crash-safe), and drain the queue. */
|
|
170
|
-
private finalizeBackgroundRun(record: Agent): void {
|
|
171
|
-
this.runningBackground--;
|
|
172
|
-
try { this.observer?.onAgentCompleted(record); } catch (err) { debugLog("onAgentCompleted observer", err); }
|
|
173
|
-
this.drainQueue();
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** Start queued agents up to the concurrency limit. */
|
|
177
|
-
private drainQueue() {
|
|
178
|
-
while (this.queue.length > 0 && this.runningBackground < this._getMaxConcurrent()) {
|
|
179
|
-
const id = this.queue.shift()!;
|
|
180
|
-
const record = this.agents.get(id);
|
|
181
|
-
if (record?.status !== "queued") continue;
|
|
182
|
-
record.promise = record.run();
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
158
|
/**
|
|
187
159
|
* Spawn an agent and wait for completion (foreground use).
|
|
188
160
|
* Foreground agents bypass the concurrency queue.
|
|
@@ -247,7 +219,7 @@ export class AgentManager {
|
|
|
247
219
|
|
|
248
220
|
// Remove from queue if queued
|
|
249
221
|
if (record.status === "queued") {
|
|
250
|
-
this.queue
|
|
222
|
+
this.queue.dequeue(id);
|
|
251
223
|
record.markStopped();
|
|
252
224
|
return true;
|
|
253
225
|
}
|
|
@@ -295,14 +267,14 @@ export class AgentManager {
|
|
|
295
267
|
abortAll(): number {
|
|
296
268
|
let count = 0;
|
|
297
269
|
// Clear queued agents first
|
|
298
|
-
for (const id of this.queue) {
|
|
270
|
+
for (const id of this.queue.queuedIds) {
|
|
299
271
|
const record = this.agents.get(id);
|
|
300
272
|
if (record) {
|
|
301
273
|
record.markStopped();
|
|
302
274
|
count++;
|
|
303
275
|
}
|
|
304
276
|
}
|
|
305
|
-
this.queue
|
|
277
|
+
this.queue.clear();
|
|
306
278
|
// Abort running agents
|
|
307
279
|
for (const record of this.agents.values()) {
|
|
308
280
|
if (record.abort()) count++;
|
|
@@ -313,11 +285,11 @@ export class AgentManager {
|
|
|
313
285
|
/** Wait for all running and queued agents to complete (including queued ones). */
|
|
314
286
|
// fallow-ignore-next-line unused-class-member
|
|
315
287
|
async waitForAll(): Promise<void> {
|
|
316
|
-
// Loop because
|
|
288
|
+
// Loop because queue.drain() respects the concurrency limit - as running
|
|
317
289
|
// agents finish they start queued ones, which need awaiting too.
|
|
318
290
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentional infinite loop with explicit break
|
|
319
291
|
while (true) {
|
|
320
|
-
this.
|
|
292
|
+
this.queue.drain();
|
|
321
293
|
const pending = [...this.agents.values()]
|
|
322
294
|
.filter(r => r.status === "running" || r.status === "queued")
|
|
323
295
|
.map(r => r.promise)
|
|
@@ -330,7 +302,7 @@ export class AgentManager {
|
|
|
330
302
|
dispose() {
|
|
331
303
|
clearInterval(this.cleanupInterval);
|
|
332
304
|
// Clear queue
|
|
333
|
-
this.queue
|
|
305
|
+
this.queue.clear();
|
|
334
306
|
for (const record of this.agents.values()) {
|
|
335
307
|
record.session?.dispose();
|
|
336
308
|
}
|
package/src/lifecycle/agent.ts
CHANGED
|
@@ -363,7 +363,7 @@ export class Agent {
|
|
|
363
363
|
/**
|
|
364
364
|
* Abort a running agent: fire AbortController and transition to stopped.
|
|
365
365
|
* Returns false if the agent is not running.
|
|
366
|
-
* Queue removal
|
|
366
|
+
* Queue removal is handled by AgentManager via ConcurrencyQueue.dequeue().
|
|
367
367
|
*/
|
|
368
368
|
abort(): boolean {
|
|
369
369
|
if (this._status !== "running") return false;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* concurrency-queue.ts — Manages background agent scheduling with a configurable concurrency limit.
|
|
3
|
+
*
|
|
4
|
+
* Stores agent IDs (not full agent objects) and decides *when* to start them.
|
|
5
|
+
* The startAgent callback provided at construction handles the actual agent lifecycle.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class ConcurrencyQueue {
|
|
9
|
+
private queue: string[] = [];
|
|
10
|
+
private running = 0;
|
|
11
|
+
|
|
12
|
+
constructor(
|
|
13
|
+
private readonly getMaxConcurrent: () => number,
|
|
14
|
+
private readonly startAgent: (id: string) => void,
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
/** Whether the concurrency limit has been reached. */
|
|
18
|
+
isFull(): boolean {
|
|
19
|
+
return this.running >= this.getMaxConcurrent();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Add an agent ID to the wait queue. */
|
|
23
|
+
enqueue(id: string): void {
|
|
24
|
+
this.queue.push(id);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Remove an agent ID from the queue (e.g., aborted before starting). Returns true if found. */
|
|
28
|
+
dequeue(id: string): boolean {
|
|
29
|
+
const idx = this.queue.indexOf(id);
|
|
30
|
+
if (idx === -1) return false;
|
|
31
|
+
this.queue.splice(idx, 1);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Increment the running count. Called when an agent transitions to running. */
|
|
36
|
+
markStarted(): void {
|
|
37
|
+
this.running++;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Decrement the running count and drain the queue. Called when a background agent finishes. */
|
|
41
|
+
markFinished(): void {
|
|
42
|
+
this.running--;
|
|
43
|
+
this.drain();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Start queued agents until the concurrency limit is reached. */
|
|
47
|
+
drain(): void {
|
|
48
|
+
while (this.queue.length > 0 && !this.isFull()) {
|
|
49
|
+
const id = this.queue.shift()!;
|
|
50
|
+
this.startAgent(id);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Snapshot of queued IDs for iteration (e.g., abortAll). */
|
|
55
|
+
get queuedIds(): readonly string[] {
|
|
56
|
+
return this.queue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Clear the queue without starting any agents. */
|
|
60
|
+
clear(): void {
|
|
61
|
+
this.queue = [];
|
|
62
|
+
}
|
|
63
|
+
}
|