@gotgenes/pi-subagents 16.0.0 → 16.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 CHANGED
@@ -5,6 +5,13 @@ 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.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.0.0...pi-subagents-v16.1.0) (2026-06-14)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-subagents:** add ConcurrencyLimiter ([#381](https://github.com/gotgenes/pi-packages/issues/381)) ([26f4203](https://github.com/gotgenes/pi-packages/commit/26f420337094d81d39bcc3e0522e12262c7767b7))
14
+
8
15
  ## [16.0.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v15.0.2...pi-subagents-v16.0.0) (2026-06-14)
9
16
 
10
17
 
@@ -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
- ConcurrencyQueue["ConcurrencyQueue<br/>(scheduling, drain)"]
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)"]
@@ -283,7 +283,7 @@ src/
283
283
  │ ├── subagent-session.ts born-complete child session: turn loop, steer, dispose
284
284
  │ ├── turn-limits.ts normalizeMaxTurns (turn-count policy)
285
285
  │ ├── subagent.ts owns full execution lifecycle (run, abort, steer, workspace)
286
- │ ├── concurrency-queue.ts background agent scheduling with configurable concurrency limit
286
+ │ ├── concurrency-limiter.ts background admission gate: schedules run thunks FIFO against the limit
287
287
  │ ├── parent-snapshot.ts immutable spawn-time parent state
288
288
  │ ├── child-lifecycle.ts child-execution lifecycle event publisher
289
289
  │ ├── workspace.ts workspace provider seam (generative extension surface)
@@ -360,7 +360,7 @@ They declare this package as an optional peer dependency and use dynamic import
360
360
 
361
361
  - The three tools: `subagent` (née `Agent`), `get_subagent_result`, `steer_subagent`.
362
362
  - `SubagentManager` — spawn, abort, resume, collection management, observer wiring.
363
- - `ConcurrencyQueue` — background agent scheduling with configurable concurrency limit.
363
+ - `ConcurrencyLimiter` — background admission gate: schedules run thunks FIFO against a configurable concurrency limit.
364
364
  - `createSubagentSession` — assembly factory: session creation and extension binding; returns a born-complete `SubagentSession`.
365
365
  - `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
366
  - `child-lifecycle` — publishes the child-execution lifecycle (`spawning`, `session-created` before `bindExtensions()`, `completed`, `disposed`) on `pi.events`.
@@ -905,7 +905,7 @@ Priority = Impact × (6 − Risk).
905
905
  | 8 | Consolidate UI and tools test fixtures | D | 2 | 1 | 10 |
906
906
  | 9 | Resolve the cross-package settings-loader duplication | A | 2 | 2 | 8 |
907
907
 
908
- #### Step 1 — Replace ConcurrencyQueue with a thunk-based ConcurrencyLimiter ([#381])
908
+ #### Step 1 — Replace ConcurrencyQueue with a thunk-based ConcurrencyLimiter ([#381]) ✅ Complete
909
909
 
910
910
  - 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
911
  - 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).
@@ -958,7 +958,7 @@ Priority = Impact × (6 − Risk).
958
958
 
959
959
  #### Step 7 — Consolidate lifecycle test fixtures ([#378])
960
960
 
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-queue.test.ts`, `test/helpers/`.
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-limiter.test.ts`, `test/helpers/`.
962
962
  - Smell: Category D — fallow reports five clone families across the lifecycle tests.
963
963
  - 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
964
  - Outcome: lifecycle clone families 5 → ≤ 1; package test duplication below 600 lines.
@@ -0,0 +1,267 @@
1
+ ---
2
+ issue: 381
3
+ issue_title: "Replace ConcurrencyQueue with a thunk-based ConcurrencyLimiter"
4
+ ---
5
+
6
+ # Replace ConcurrencyQueue with a thunk-based ConcurrencyLimiter
7
+
8
+ ## Problem Statement
9
+
10
+ The `ConcurrencyQueue` stores background-agent IDs and decides *when* to start them, but it cannot start an agent itself.
11
+ It compensates with a `startAgent(id)` callback that reaches back into the manager (`getRecord(id)`, status check, `run()`) — a dependency back-edge that forces forward-referenced bindings in both `index.ts` and the manager test helper.
12
+ The queue also keeps its own `running` counter, fed by `markStarted`/`markFinished` relays in the manager's observer, duplicating state the agents already carry.
13
+ A queued agent has `promise === undefined` until the queue starts it, which is the direct cause of `waitForAll`'s `while (true)` drain loop and its `eslint-disable`.
14
+
15
+ These are three symptoms of one root cause: the queue schedules *identifiers it cannot act on* instead of *work it can run*.
16
+ Scheduling thunks (`() => Promise<void>`) instead of IDs dissolves all three at the source.
17
+
18
+ This is Phase 17 Step 1 (core consolidation), recorded in `docs/architecture/architecture.md` under "Improvement roadmap (Phase 17 — core consolidation)".
19
+ It unblocks Phase 17 Step 3 ([#374], run-start encapsulation).
20
+
21
+ ## Goals
22
+
23
+ - Replace `ConcurrencyQueue` (ID registry + back-edge callback) with a `ConcurrencyLimiter` that schedules run closures FIFO against a dynamic limit and knows nothing about agents, IDs, or the manager.
24
+ - Make the dependency direction strictly `SubagentManager → ConcurrencyLimiter`: no callback back-edge, no forward-referenced bindings.
25
+ - Derive the active count from the limiter's own task lifecycle (increment on task start, decrement on settle); delete the observer's `markStarted`/`markFinished` relays.
26
+ - Give every spawned agent a real `promise` at spawn time, collapsing `waitForAll`'s `while (true)` drain loop and its `eslint-disable`.
27
+ - This is a non-breaking internal refactor: the FIFO admission behavior against `maxConcurrent` is preserved, and no public API, config key, or observable behavior changes.
28
+
29
+ ## Non-Goals
30
+
31
+ - Renaming the `bypassQueue` spawn option.
32
+ It is part of the published `SubagentsService` type surface (`src/service/service.ts`), so renaming it would churn the type bundle and break consumers — out of scope; track in Open Questions.
33
+ - Folding the queued-status guard into `Subagent.start()` — that is Phase 17 Step 3 ([#374]).
34
+ This plan keeps the guard inside the scheduled thunk.
35
+ - Extracting `SubagentState` or making execution deps mandatory ([#373], Step 2).
36
+ - Any change to foreground execution (`spawnAndWait`) or to `bypassQueue` runs — both continue to invoke `record.run()` directly, never touching the limiter.
37
+ - Touching `src/service/service.ts` or `src/service/service-adapter.ts` — `bypassQueue` flows through unchanged.
38
+
39
+ ## Background
40
+
41
+ Relevant modules:
42
+
43
+ - `src/lifecycle/concurrency-queue.ts` — the current `ConcurrencyQueue`: `isFull`, `enqueue`, `dequeue`, `markStarted`, `markFinished`, `drain`, `clear`, `queuedIds`.
44
+ Stores IDs; `drain()` calls the injected `startAgent(id)` back-edge.
45
+ - `src/lifecycle/subagent-manager.ts` — injects the queue via `SubagentManagerOptions.queue`.
46
+ `buildObserver` relays `markStarted`/`markFinished`; `spawn` enqueues when `isFull()`; `abort` calls `dequeue`; `abortAll` iterates `queuedIds` + `clear()`; `waitForAll` loops `drain()` + `Promise.allSettled`; `dispose` calls `clear()`.
47
+ - `src/index.ts` — constructs the queue with a `startAgent` callback that forward-references the manager (`manager.getRecord(id)` then `agent.run()`); wires `settings.onMaxConcurrentChanged` to `queue.drain()`.
48
+ - `src/lifecycle/subagent.ts` — `run()` sets status to `running` synchronously (`markRunning`) before its first `await`; `run()` always resolves (errors captured internally).
49
+ `abort()` acts only on `running` agents; its docstring references `ConcurrencyQueue.dequeue()`.
50
+ - `test/lifecycle/subagent-manager.test.ts` — `createManager` helper replicates the `index.ts` start callback with a `prefer-const` `eslint-disable` for the forward reference.
51
+ - `test/lifecycle/concurrency-queue.test.ts` — unit tests for the queue (drain ordering, `markStarted`/`markFinished` counting, `enqueue`/`dequeue`).
52
+
53
+ Constraints from AGENTS.md and skills:
54
+
55
+ - ES2024 `Promise.withResolvers` is available and preferred (`code-design` skill).
56
+ - The `bypassQueue` field lives in the public type bundle (`exports`, `verify:public-types`); renaming public surface is breaking (`package-pi-subagents` skill).
57
+ - `@typescript-eslint/require-await` is enabled for `src/`; a thunk with no `await` must return a `Promise` without `async`.
58
+ - Where the old `drain()` used `while (… && !isFull())` with `this.queue.shift()!`, prefer a bounded loop without a non-null assertion (`code-design` Biome/ESLint notes).
59
+
60
+ The current observer-relay path (`buildObserver` → `queue.markStarted`/`markFinished`) confirmed: the queue's `running` counter mirrors the per-agent status the manager already tracks (the manager filters on `status === "running" || "queued"` in `cleanup`, `clearCompleted`, `hasRunning`, `waitForAll`).
61
+ No production caller awaits a *queued* agent's promise (`get-result-tool.ts` guards on `status === "running"`; `spawnAndWait` is foreground; `waitForAll` filters by status), so giving queued agents a settled-on-completion promise is safe.
62
+
63
+ ## Design Overview
64
+
65
+ ### `ConcurrencyLimiter`
66
+
67
+ A pure FIFO scheduler over thunks.
68
+ It owns the active count and the pending queue; it has no knowledge of agents, IDs, or the manager.
69
+
70
+ ```typescript
71
+ export class ConcurrencyLimiter {
72
+ private active = 0;
73
+ private readonly pending: Array<{ start: () => void; settle: () => void }> = [];
74
+
75
+ constructor(private readonly getLimit: () => number) {}
76
+
77
+ /**
78
+ * Schedule a task to run FIFO once a slot is free.
79
+ * The returned promise always settles: it follows the task's settlement when
80
+ * the task runs, or resolves early if clear() drops it before it starts.
81
+ */
82
+ schedule(task: () => Promise<void>): Promise<void> {
83
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
84
+ this.pending.push({
85
+ start: () => {
86
+ this.active++;
87
+ task().then(resolve, reject).finally(() => {
88
+ this.active--;
89
+ this.recheck();
90
+ });
91
+ },
92
+ settle: resolve,
93
+ });
94
+ this.recheck();
95
+ return promise;
96
+ }
97
+
98
+ /** Start pending tasks until the limit is reached. Call when the limit may have grown. */
99
+ recheck(): void {
100
+ while (this.active < this.getLimit()) {
101
+ const next = this.pending.shift();
102
+ if (!next) break;
103
+ next.start();
104
+ }
105
+ }
106
+
107
+ /** Drop all pending tasks, resolving their promises without running them. */
108
+ clear(): void {
109
+ const dropped = this.pending.splice(0);
110
+ for (const task of dropped) task.settle();
111
+ }
112
+ }
113
+ ```
114
+
115
+ Design decisions:
116
+
117
+ - **Active count derived from task lifecycle.**
118
+ `active++` happens synchronously inside `start()` before the task's first `await`; `active--` runs in `finally`.
119
+ This replaces the queue's `running` counter and the two observer relays.
120
+ - **`recheck()` is bounded.**
121
+ The loop terminates when the limit is reached or the pending queue empties — no `while (true)`, no `this.pending.shift()!` non-null assertion.
122
+ - **`clear()` settles dropped promises.**
123
+ Every `schedule()` promise becomes `record.promise`; the contract is that it always settles.
124
+ Dropping a thunk without resolving would leave a forever-pending `record.promise`.
125
+ `clear()` resolves dropped tasks so `dispose()`/`abortAll()` cannot strand a promise. (This is a few lines beyond the issue's "~40 lines" sketch; the extra `settle` handle is the deliberate cost of that invariant.)
126
+ - **Synchronous start.**
127
+ When a slot is free, `schedule()` runs the thunk synchronously inside `recheck()`, so `record.run()` executes its synchronous prefix (`markRunning`) immediately — preserving today's behavior where `record.promise = record.run()` flips status to `running` at once.
128
+
129
+ ### Manager spawn call site
130
+
131
+ ```typescript
132
+ // spawn(), background and not bypassQueue:
133
+ record.promise = this.limiter.schedule(() => {
134
+ // Guard: an abort-while-queued task is a no-op (Step 3 folds this into Subagent.start()).
135
+ if (record.status !== "queued") return Promise.resolve();
136
+ return record.run();
137
+ });
138
+ // foreground or bypassQueue:
139
+ record.promise = record.run();
140
+ ```
141
+
142
+ This is Tell-Don't-Ask toward the limiter: the manager hands it work, the limiter decides timing.
143
+ The status guard replaces `dequeue` — an aborted queued agent (status `stopped`) becomes a no-op when its slot finally opens.
144
+
145
+ ### Manager lifecycle methods
146
+
147
+ - `buildObserver` — drop the `markStarted` (in `onStarted`) and `markFinished` (in `onRunFinished`) relays; `onRunFinished` keeps the background `onSubagentCompleted` dispatch.
148
+ - `abort(id)` — for a `queued` agent, just `record.markStopped()` (no `dequeue`); otherwise `record.abort()`.
149
+ - `abortAll()` — iterate agents: `markStopped()` each `queued` agent (count it), else `record.abort()`; then `this.limiter.clear()` to drop pending thunks (their promises resolve).
150
+ - `waitForAll()` — every spawned agent has a `promise`, so the manual `drain()` loop collapses:
151
+
152
+ ```typescript
153
+ async waitForAll(): Promise<void> {
154
+ let pending = this.pendingPromises();
155
+ while (pending.length > 0) {
156
+ await Promise.allSettled(pending);
157
+ pending = this.pendingPromises();
158
+ }
159
+ }
160
+
161
+ private pendingPromises(): Promise<void>[] {
162
+ return [...this.agents.values()]
163
+ .filter(r => r.status === "running" || r.status === "queued")
164
+ .map(r => r.promise)
165
+ .filter((p): p is Promise<void> => p != null);
166
+ }
167
+ ```
168
+
169
+ The re-check loop is no longer `while (true)` and no longer drives scheduling — the limiter auto-starts queued agents as slots free, so a single `allSettled` covers the queued case.
170
+ The loop survives only to catch agents spawned *during* the wait.
171
+ The `eslint-disable @typescript-eslint/no-unnecessary-condition` is deleted.
172
+ - `dispose()` — `this.limiter.clear()` (unchanged in intent).
173
+
174
+ ### `index.ts` wiring
175
+
176
+ ```typescript
177
+ const settings = new SettingsManager({
178
+ // …
179
+ onMaxConcurrentChanged: () => limiter.recheck(), // forward-ref closure (settings → limiter); benign
180
+ });
181
+ settings.load();
182
+ // …
183
+ const limiter = new ConcurrencyLimiter(() => settings.maxConcurrent);
184
+ const manager = new SubagentManager({ /* … */ limiter, /* … */ });
185
+ ```
186
+
187
+ The only surviving forward reference is `settings → limiter` (a runtime-only closure, the same shape as today's `settings → queue.drain`).
188
+ The `limiter → manager` back-edge (the `startAgent` callback and its explanatory comment) is **deleted entirely** — that is the structural win.
189
+
190
+ ### Edge cases
191
+
192
+ - **Abort while queued** — `markStopped()` flips status; the scheduled thunk, when run, returns `Promise.resolve()` (no-op), settling `record.promise`.
193
+ - **Limit decreased below active count** — `recheck()` simply starts nothing (`active < getLimit()` is false); in-flight tasks finish normally.
194
+ - **Limit increased** — `onMaxConcurrentChanged → limiter.recheck()` starts newly-admissible pending tasks.
195
+ - **`clear()` with in-flight tasks** — only *pending* tasks are dropped; running tasks complete and `active--` on settle.
196
+
197
+ ## Module-Level Changes
198
+
199
+ | File | Change |
200
+ | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
201
+ | `src/lifecycle/concurrency-limiter.ts` | Add — new `ConcurrencyLimiter` (`schedule`, `recheck`, `clear`). |
202
+ | `src/lifecycle/concurrency-queue.ts` | Remove — replaced by the limiter. |
203
+ | `src/lifecycle/subagent-manager.ts` | Change — import limiter; `SubagentManagerOptions.queue` → `limiter: ConcurrencyLimiter` and the private field; drop `markStarted`/`markFinished` from `buildObserver`; `spawn` schedules a status-guarded thunk; `abort` drops `dequeue`; `abortAll` iterates agents + `limiter.clear()`; `waitForAll` simplified (add `pendingPromises` helper, delete the `while (true)` loop and its `eslint-disable`); `dispose` calls `limiter.clear()`; update the file-header comment. |
204
+ | `src/lifecycle/subagent.ts` | Change — `abort()` docstring: remove the `ConcurrencyQueue.dequeue()` reference (queue removal is now a status-guard no-op). |
205
+ | `src/index.ts` | Change — import `ConcurrencyLimiter`; construct it as `new ConcurrencyLimiter(() => settings.maxConcurrent)`; `onMaxConcurrentChanged: () => limiter.recheck()`; delete the `startAgent` callback and its forward-ref comment; inject `limiter` into the manager. |
206
+ | `test/lifecycle/concurrency-limiter.test.ts` | Add — limiter unit tests (no `startAgent` mock). |
207
+ | `test/lifecycle/concurrency-queue.test.ts` | Remove — the queue is gone. |
208
+ | `test/lifecycle/subagent-manager.test.ts` | Change — `createManager` constructs a `ConcurrencyLimiter`; delete the forward-ref `let mgr` + `prefer-const` `eslint-disable`; drop the unused `queue` field from the returned object. |
209
+ | `docs/architecture/architecture.md` | Change — Mermaid lifecycle node (`ConcurrencyQueue<br/>(scheduling, drain)` → `ConcurrencyLimiter<br/>(thunk admission gate)`); layout listing (`concurrency-queue.ts` → `concurrency-limiter.ts`); "What the core owns" bullet; mark roadmap Step 1 done; fix the Step 7 ([#378]) target filename reference. |
210
+ | `.pi/skills/package-pi-subagents/SKILL.md` | Change — lifecycle-domain table: `concurrency-queue.ts` → `concurrency-limiter.ts` and adjust the "scheduling" wording to "concurrency admission". |
211
+
212
+ Verified by grep that no other `src/`, `test/`, `docs/` (excluding `docs/architecture/history/` and prior plans/retros, which are historical), or `.pi/skills/` file references `ConcurrencyQueue`, `concurrency-queue`, `enqueue`, `dequeue`, `markStarted`/`markFinished` (queue), `drain`, `isFull`, or `queuedIds` for this queue.
213
+ `SKILL.md` line 80 (Phase 15 history) keeps `ConcurrencyQueue` — it is a historical record, not current state.
214
+
215
+ ## Test Impact Analysis
216
+
217
+ 1. **New tests the change enables.**
218
+ `ConcurrencyLimiter` is a pure thunk scheduler with no agent/manager knowledge, so it is unit-testable with plain `() => Promise<void>` tasks and `Promise.withResolvers` gates — no `startAgent` mock, no re-entrant `markStarted` simulation.
219
+ New coverage: FIFO start order; slot gating (only `limit` tasks run concurrently); `active` decrement frees a slot for the next pending task on settle; `recheck()` starts newly-admissible tasks when the limit grows; dynamic limit re-evaluation; `clear()` resolves pending promises without running their tasks; a task that rejects still frees its slot.
220
+ 2. **Tests that become redundant.**
221
+ The entire `test/lifecycle/concurrency-queue.test.ts` (`isFull`, `enqueue`/`dequeue`, `markStarted`/`markFinished`, `drain`, auto-drain, `clear`, `queuedIds`) — those methods no longer exist; the limiter tests replace them at a cleaner seam.
222
+ 3. **Tests that stay as-is (genuinely exercise the layer).**
223
+ The `SubagentManager — queueing and concurrency with injected stubs` describe block asserts manager-level behavior (queued → running transition order, abort-while-queued never runs the factory, `onSubagentStarted` fires on the queued → running transition).
224
+ These remain valid against the manager + limiter integration and need only the `createManager` helper change (construct a `ConcurrencyLimiter`), not a behavioral rewrite.
225
+ The `clearCompleted does not remove running or queued agents` test (maxConcurrent=1, blocking factory) also stays.
226
+
227
+ ## TDD Order
228
+
229
+ Priority = preparatory addition first, then the atomic interface swap, then docs.
230
+
231
+ 1. **Add `ConcurrencyLimiter` (red → green).**
232
+ Surface: new `test/lifecycle/concurrency-limiter.test.ts` against new `src/lifecycle/concurrency-limiter.ts`.
233
+ Covers FIFO start order, slot gating, `active`-frees-slot-on-settle, `recheck()` on limit growth, dynamic limit, `clear()` resolves pending without running, reject-frees-slot.
234
+ Pure addition — `ConcurrencyQueue` still exists and its tests still pass; the suite stays green.
235
+ Commit: `feat(pi-subagents): add ConcurrencyLimiter (#381)`.
236
+
237
+ 2. **Migrate `SubagentManager`, `index.ts`, and the manager test helper to the limiter; delete the queue (red → green).**
238
+ Surface: `src/lifecycle/subagent-manager.ts`, `src/index.ts`, `src/lifecycle/subagent.ts` (docstring), `test/lifecycle/subagent-manager.test.ts`, and deletion of `src/lifecycle/concurrency-queue.ts` + `test/lifecycle/concurrency-queue.test.ts`.
239
+ This is one atomic commit: changing `SubagentManagerOptions.queue` → `limiter` breaks both call sites (`index.ts` and the test helper) at the type level simultaneously, and the old test file imports the deleted source — all must land together.
240
+ Drop the observer relays, the `dequeue`/`drain`/`isFull`/`queuedIds` usage, the `while (true)` loop + its `eslint-disable`, and the test helper's forward-ref `eslint-disable`.
241
+ Run `pnpm run check` immediately after (shared-interface change with multiple call sites), then the full `pnpm --filter @gotgenes/pi-subagents exec vitest run` (the queueing/concurrency integration tests must still pass).
242
+ Commit: `refactor(pi-subagents): replace ConcurrencyQueue with thunk-based ConcurrencyLimiter (#381)`.
243
+
244
+ 3. **Update architecture doc and package skill (docs).**
245
+ Surface: `docs/architecture/architecture.md` (Mermaid node, layout listing, "What the core owns" bullet, roadmap Step 1 marked done, Step 7 filename reference) and `.pi/skills/package-pi-subagents/SKILL.md` (lifecycle-domain table entry + wording).
246
+ Commit: `docs(pi-subagents): update architecture and skill for ConcurrencyLimiter (#381)`.
247
+
248
+ ## Risks and Mitigations
249
+
250
+ | Risk | Mitigation |
251
+ | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
252
+ | A dropped pending thunk leaves `record.promise` forever pending. | `clear()` resolves dropped tasks' promises; the limiter's contract is that every `schedule()` promise settles. |
253
+ | `waitForAll` could spin or miss queued agents. | Queued agents now carry real promises, so a single `Promise.allSettled` covers them; the bounded re-check loop only catches agents spawned during the wait, and terminates when `pendingPromises()` is empty. |
254
+ | An abort-while-queued no-op thunk briefly occupies a slot. | The thunk returns a synchronously-resolved promise; `active++`/`active--` round-trip in one microtask and `recheck()` immediately pulls the next task — negligible. |
255
+ | Renaming the file/class leaves stale references. | Grep-verified inventory in Module-Level Changes; the migration deletes the source and its test in the same commit; docs updated in step 3. |
256
+ | `bypassQueue` public-surface name now slightly misnames the mechanism. | Out of scope (breaking); recorded in Open Questions. |
257
+
258
+ ## Open Questions
259
+
260
+ - Should `bypassQueue` be renamed (e.g. `bypassLimiter`) for accuracy?
261
+ It is public type surface, so a rename is breaking and belongs in its own change — defer.
262
+ - Should the `code-design` "narrow interface, not concrete class" guidance be applied to the manager's `limiter` field (typed as `{ schedule; clear }` rather than the concrete `ConcurrencyLimiter`)?
263
+ Tests construct a real limiter (it is pure and trivially constructible), so no mock-cast pressure exists today; keep the concrete type to match the issue and existing pattern, and revisit only if a test needs to substitute it.
264
+
265
+ [#373]: https://github.com/gotgenes/pi-packages/issues/373
266
+ [#374]: https://github.com/gotgenes/pi-packages/issues/374
267
+ [#378]: https://github.com/gotgenes/pi-packages/issues/378
@@ -0,0 +1,49 @@
1
+ ---
2
+ issue: 381
3
+ issue_title: "Replace ConcurrencyQueue with a thunk-based ConcurrencyLimiter"
4
+ ---
5
+
6
+ # Retro: #381 — Replace ConcurrencyQueue with a thunk-based ConcurrencyLimiter
7
+
8
+ ## Stage: Planning (2026-06-13T00:00:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Produced a 3-step TDD plan to replace the ID-registry `ConcurrencyQueue` (with its `startAgent` back-edge and `markStarted`/`markFinished` relays) with a pure `ConcurrencyLimiter` that schedules thunks FIFO against a dynamic limit.
13
+ The design follows the architecture doc's Phase 17 Step 1 entry and the issue's revised framing closely; the plan adds concrete code sketches for `schedule`/`recheck`/`clear`, the manager call site, the simplified `waitForAll`, and `index.ts` wiring.
14
+
15
+ ### Observations
16
+
17
+ - Author is `gotgenes` (matches the gh CLI user), so the well-specified proposal was treated as the working hypothesis; the design is unambiguous (down to the architecture-doc Step 1), so the `ask_user` gate was skipped.
18
+ - Classified non-breaking: `ConcurrencyQueue`/`ConcurrencyLimiter` are internal — no public API, config, or observable behavior change.
19
+ The FIFO admission gate against `maxConcurrent` is preserved.
20
+ - Key design decision beyond the issue sketch: `clear()` must *settle* dropped pending promises (resolve them), not just drop the thunks.
21
+ Every `schedule()` promise becomes `record.promise`, and the post-spawn contract is that it always settles — dropping without resolving would strand a promise.
22
+ This costs a small `settle` handle per pending entry (a few lines beyond the issue's "~40 lines").
23
+ - Verified no production caller awaits a *queued* agent's promise in a blocking way (`get-result-tool.ts` guards on `status === "running"`; `spawnAndWait` is foreground/direct; `waitForAll` filters by status), confirming it is safe to give queued agents a real promise.
24
+ - Sequencing decision: the `SubagentManagerOptions.queue` → `limiter` swap breaks both call sites (`index.ts` + the manager test helper) and the old test file imports the deleted source, so step 2 is one atomic commit (migrate consumers + delete queue + delete old test).
25
+ - `bypassQueue` is kept as-is — it is in the published `SubagentsService` type bundle, so renaming would be breaking; deferred to Open Questions.
26
+ - Doc inventory: grep confirmed current-state references to update are the Mermaid lifecycle node, the layout listing, the "What the core owns" bullet, the Step 7 ([#378]) target filename, and the `package-pi-subagents` SKILL lifecycle-domain table.
27
+ `SKILL.md` line 80 (Phase 15 history) keeps `ConcurrencyQueue` as a historical record.
28
+
29
+ ## Stage: Implementation — TDD (2026-06-13T22:15:00Z)
30
+
31
+ ### Session summary
32
+
33
+ Executed all 3 planned TDD cycles: (1) added `ConcurrencyLimiter` + 13 unit tests, (2) migrated `SubagentManager`, `index.ts`, `subagent.ts` docstring, and the manager test helper to the limiter while deleting `concurrency-queue.ts` + its test in the same atomic commit, (3) updated `architecture.md` and the package SKILL.
34
+ Test count went 975 → 966 (−22 deleted queue tests, +13 new limiter tests); the full suite, `check`, `lint`, and `pnpm fallow dead-code` are all green.
35
+
36
+ ### Observations
37
+
38
+ - The plan held up cleanly — no surprises in the manager integration tests.
39
+ The `queueing and concurrency` describe block passed unchanged after only the `createManager` helper swap (real `ConcurrencyLimiter` instead of `ConcurrencyQueue` + forward-ref start callback), confirming those tests exercise behavior, not queue internals.
40
+ - One deviation: a 4th commit (`90135005`, `refactor:`) fixes a stale `// before startAgent / queue drain` comment at `src/index.ts:125` that the plan's grep inventory missed (it named no removed symbol, just deleted concepts).
41
+ The pre-completion reviewer caught it.
42
+ Committed separately rather than amending the non-HEAD refactor commit, since AGENTS.md discourages interactive rebase in this environment.
43
+ - ESLint `@typescript-eslint/no-floating-promises` fired on every bare `limiter.schedule(...)` in the limiter test (the queue's `enqueue` returned `void`; `schedule` returns a promise).
44
+ Resolved by prefixing unawaited calls with `void` — all such tasks either stay pending or resolve, so no unhandled rejection.
45
+ - The `clear()`-settles-pending-promises decision (made at planning) proved correct and is covered by a dedicated test ("resolves the promises of dropped pending tasks").
46
+ - Pre-completion reviewer: WARN (no FAILs).
47
+ Reviewer warnings: the single stale-comment finding at `index.ts:125` — now fixed in commit `90135005`.
48
+
49
+ [#378]: https://github.com/gotgenes/pi-packages/issues/378
@@ -41,4 +41,44 @@ Test count went from 973 to 975 (+2 net new tests) across 59 test files.
41
41
  - Pre-completion reviewer: WARN — one finding: `.pi/skills/package-pi-subagents/SKILL.md` still said "prepends" for the `<active_agent>` tag; fixed in a follow-up `docs:` commit before shipping.
42
42
  - No deviations from the plan's Module-Level Changes list; no lockfile changes; fallow dead-code exited zero.
43
43
 
44
+ ## Stage: Final Retrospective (2026-06-14T01:11:10Z)
45
+
46
+ ### Session summary
47
+
48
+ Shipped #400 across three stages (Planning on `claude-opus-4-8`, TDD + Ship on `claude-sonnet-4-6`) as a single-function edit to `buildAgentPrompt()`'s replace branch plus tests and doc updates, released as `pi-subagents` v16.0.0 (major, breaking `perf!:`).
49
+ The run was clean end-to-end: two `ask_user` gates during planning, a 3-cycle TDD pass, one pre-completion WARN resolved before push, and a no-friction release-please merge.
50
+
51
+ ### Observations
52
+
53
+ #### What went well
54
+
55
+ - Cross-extension investigation on demand — when the operator asked mid-`ask_user` how the `genericBase` fallback interacts with `@gotgenes/pi-anthropic-auth`, the agent read that sibling repo's `system-prompt-shaping.ts` and `request-shaping.ts` and proved no new interaction (billing header prepended unconditionally; de-fingerprinting keys off `PI_DEFAULT_PROMPT_PREFIX`, absent from the neutral `genericBase`) before answering.
56
+ This converted an open worry into a documented Risk row rather than a deferred unknown.
57
+ - Emergent-scope surfacing — planning noticed that built-in `Explore`/`Plan` are replace-mode agents and so are visibly affected, then confirmed uniform application via a second `ask_user` instead of assuming.
58
+ - Autoformat discipline — after `pi-autoformat` touched `README.md` mid-edit, the agent re-read the region before the next edit (turns 49–50) rather than matching against stale layout, avoiding a failed `oldText`.
59
+
60
+ #### What caused friction (agent side)
61
+
62
+ - `missing-context` (planning) — the plan listed the README's Patch 3 `<active_agent>` "prepends" wording as a doc update but missed the identical Patch 3 description in `.pi/skills/package-pi-subagents/SKILL.md`.
63
+ Exact-grep during planning keyed on removed strings (`You are a pi coding agent sub-agent`, `prompt_mode`); the stale prose carried none of them, so the skill file's "prepends `<active_agent>`" line was not found.
64
+ Impact: the pre-completion reviewer caught it as a WARN, requiring one follow-up `docs:` commit (8e93d2a4) during TDD before push — no rework beyond that, and the safety net worked as designed.
65
+
66
+ #### What caused friction (user side)
67
+
68
+ - None — the operator's mid-planning OAuth question was a high-value redirect that strengthened the plan, not friction.
69
+
70
+ ### Diagnostic details
71
+
72
+ - **Model-performance correlation** — judgment-heavy planning ran on `claude-opus-4-8`; mechanical TDD execution and the deterministic ship steps ran on `claude-sonnet-4-6`.
73
+ Appropriate assignment in both directions; no mismatch.
74
+ - **Unused-tool detection** — the `colgrep` skill was loaded in planning but never used; exploration was all exact-symbol grep, which was correct for known symbols.
75
+ The one place it would have helped is the `missing-context` friction: a semantic search like "docs describing how the active_agent tag is added to the system prompt" would likely have surfaced both the README and the SKILL.md descriptions that symbol-grep missed.
76
+ - **Feedback-loop gap analysis** — verification ran incrementally throughout (green baseline before cycle 1, per-file `vitest` each cycle, full suite + `check` + `lint` + `fallow` after the last step).
77
+ No end-loaded verification.
78
+ - **Escalation-delay tracking** — no rabbit-holes; no error sequence exceeded one tool call.
79
+
80
+ ### Changes made
81
+
82
+ 1. `.pi/prompts/plan-issue.md` — extended the Module-Level Changes grep bullet: when a step reworks a documented mechanism's behavior (rather than removing a symbol), grep `.pi/skills/package-*/SKILL.md` for the mechanism name, since reworded prose carries no removed symbol to match.
83
+
44
84
  [#180]: https://github.com/gotgenes/pi-packages/issues/180
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "16.0.0",
3
+ "version": "16.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/src/index.ts CHANGED
@@ -24,7 +24,7 @@ import { AgentTypeRegistry } from "#src/config/agent-types";
24
24
  import { loadCustomAgents } from "#src/config/custom-agents";
25
25
  import { SessionLifecycleHandler, ToolStartHandler } from "#src/handlers/index";
26
26
  import { createChildLifecyclePublisher } from "#src/lifecycle/child-lifecycle";
27
- import { ConcurrencyQueue } from "#src/lifecycle/concurrency-queue";
27
+ import { ConcurrencyLimiter } from "#src/lifecycle/concurrency-limiter";
28
28
  import { createSubagentSession, type SubagentSessionDeps } from "#src/lifecycle/create-subagent-session";
29
29
  import { buildParentSnapshot } from "#src/lifecycle/parent-snapshot";
30
30
  import { SubagentManager, type SubagentManagerObserver } from "#src/lifecycle/subagent-manager";
@@ -66,12 +66,12 @@ export default function (pi: ExtensionAPI) {
66
66
  );
67
67
 
68
68
  // Settings: owns all three in-memory values and handles load/save/emit.
69
- // onMaxConcurrentChanged is wired to the queue directly (closure captures by reference).
69
+ // onMaxConcurrentChanged is wired to the limiter directly (closure captures by reference).
70
70
  const settings = new SettingsManager({
71
71
  emit: (event, payload) => pi.events.emit(event, payload),
72
72
  cwd: process.cwd(),
73
73
  agentDir: getAgentDir(),
74
- onMaxConcurrentChanged: () => queue.drain(),
74
+ onMaxConcurrentChanged: () => limiter.recheck(),
75
75
  });
76
76
  settings.load();
77
77
 
@@ -122,7 +122,7 @@ export default function (pi: ExtensionAPI) {
122
122
  });
123
123
  },
124
124
  onSubagentCreated(record) {
125
- // Emit created event for background agents (before startAgent / queue drain).
125
+ // Emit created event for background agents (before limiter admission).
126
126
  pi.events.emit("subagents:created", {
127
127
  id: record.id,
128
128
  type: record.type,
@@ -150,22 +150,15 @@ export default function (pi: ExtensionAPI) {
150
150
  lifecycle: createChildLifecyclePublisher((channel, data) => pi.events.emit(channel, data)),
151
151
  };
152
152
 
153
- // ConcurrencyQueue: scheduling extracted from SubagentManager.
154
- // startAgent callback forward-references manager via closure (safedrain is never called during construction).
155
- const queue = new ConcurrencyQueue(
156
- () => settings.maxConcurrent,
157
- (id) => {
158
- const agent = manager.getRecord(id);
159
- if (agent?.status !== "queued") return;
160
- agent.promise = agent.run();
161
- },
162
- );
153
+ // ConcurrencyLimiter: schedules background run thunks FIFO against the limit.
154
+ // It knows nothing about agents or the manager dependency direction is strictly manager limiter.
155
+ const limiter = new ConcurrencyLimiter(() => settings.maxConcurrent);
163
156
 
164
157
  const manager = new SubagentManager({
165
158
  createSubagentSession: (params) => createSubagentSession(params, subagentSessionDeps),
166
159
  baseCwd: process.cwd(),
167
160
  observer,
168
- queue,
161
+ limiter,
169
162
  getRunConfig: () => settings,
170
163
  });
171
164
 
@@ -0,0 +1,55 @@
1
+ /**
2
+ * concurrency-limiter.ts — FIFO admission gate for background work.
3
+ *
4
+ * Schedules run closures (thunks) against a dynamic limit, running them in
5
+ * scheduling order as slots free. The limiter knows nothing about agents, IDs,
6
+ * or the manager — it owns only the active count and the pending queue.
7
+ *
8
+ * Every scheduled promise settles: it follows the task's settlement when the
9
+ * task runs, or resolves early if clear() drops it before it starts.
10
+ */
11
+
12
+ export class ConcurrencyLimiter {
13
+ private active = 0;
14
+ private readonly pending: Array<{ start: () => void; settle: () => void }> = [];
15
+
16
+ constructor(private readonly getLimit: () => number) {}
17
+
18
+ /**
19
+ * Schedule a task to run FIFO once a slot is free.
20
+ * Returns a promise that settles with the task, or resolves early if the
21
+ * task is dropped by clear() before it starts.
22
+ */
23
+ schedule(task: () => Promise<void>): Promise<void> {
24
+ const { promise, resolve, reject } = Promise.withResolvers<void>(); // eslint-disable-line @typescript-eslint/no-invalid-void-type -- Promise.withResolvers<void> is valid; rule does not allow void in generic fn call type args
25
+ this.pending.push({
26
+ start: () => {
27
+ this.active++;
28
+ task()
29
+ .then(resolve, reject)
30
+ .finally(() => {
31
+ this.active--;
32
+ this.recheck();
33
+ });
34
+ },
35
+ settle: resolve,
36
+ });
37
+ this.recheck();
38
+ return promise;
39
+ }
40
+
41
+ /** Start pending tasks until the limit is reached. Call when the limit may have grown. */
42
+ recheck(): void {
43
+ while (this.active < this.getLimit()) {
44
+ const next = this.pending.shift();
45
+ if (!next) break;
46
+ next.start();
47
+ }
48
+ }
49
+
50
+ /** Drop all pending tasks, resolving their promises without running them. */
51
+ clear(): void {
52
+ const dropped = this.pending.splice(0);
53
+ for (const task of dropped) task.settle();
54
+ }
55
+ }
@@ -2,14 +2,14 @@
2
2
  * subagent-manager.ts - Tracks subagents, background execution, resume support.
3
3
  *
4
4
  * Background agents are subject to a configurable concurrency limit (default: 4).
5
- * Excess agents are queued and auto-started as running agents complete.
6
- * Foreground agents bypass the queue (they block the parent anyway).
5
+ * Excess agents are scheduled on a ConcurrencyLimiter and auto-started as running
6
+ * agents complete. Foreground agents bypass the limiter (they block the parent anyway).
7
7
  */
8
8
 
9
9
  import { randomUUID } from "node:crypto";
10
10
  import type { Model } from "@earendil-works/pi-ai";
11
11
  import { debugLog } from "#src/debug";
12
- import type { ConcurrencyQueue } from "#src/lifecycle/concurrency-queue";
12
+ import type { ConcurrencyLimiter } from "#src/lifecycle/concurrency-limiter";
13
13
  import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent-session";
14
14
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
15
15
  import { Subagent, type SubagentLifecycleObserver } from "#src/lifecycle/subagent";
@@ -31,8 +31,8 @@ export interface SubagentManagerObserver {
31
31
  export interface SubagentManagerOptions {
32
32
  /** Assembly factory that produces a born-complete SubagentSession per spawn. */
33
33
  createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
34
- /** Concurrency queueowns scheduling, limit checks, and drain logic. */
35
- queue: ConcurrencyQueue;
34
+ /** Concurrency limiterschedules background run thunks FIFO against the limit. */
35
+ limiter: ConcurrencyLimiter;
36
36
  /** Base working directory handed to a workspace provider (the parent cwd). */
37
37
  baseCwd: string;
38
38
  getRunConfig?: () => RunConfig;
@@ -67,7 +67,7 @@ export class SubagentManager {
67
67
  private cleanupInterval: ReturnType<typeof setInterval>;
68
68
  private readonly observer?: SubagentManagerObserver;
69
69
  private readonly createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
70
- private readonly queue: ConcurrencyQueue;
70
+ private readonly limiter: ConcurrencyLimiter;
71
71
  private readonly baseCwd: string;
72
72
  private getRunConfig?: () => RunConfig;
73
73
  private _workspaceProvider?: WorkspaceProvider;
@@ -79,7 +79,7 @@ export class SubagentManager {
79
79
 
80
80
  constructor(options: SubagentManagerOptions) {
81
81
  this.createSubagentSession = options.createSubagentSession;
82
- this.queue = options.queue;
82
+ this.limiter = options.limiter;
83
83
  this.baseCwd = options.baseCwd;
84
84
  this.observer = options.observer;
85
85
  this.getRunConfig = options.getRunConfig;
@@ -109,7 +109,6 @@ export class SubagentManager {
109
109
  private buildObserver(options: AgentSpawnConfig): SubagentLifecycleObserver {
110
110
  return {
111
111
  onStarted: (agent) => {
112
- if (options.isBackground) this.queue.markStarted();
113
112
  this.observer?.onSubagentStarted(agent);
114
113
  },
115
114
  onSessionCreated: options.observer?.onSessionCreated
@@ -117,7 +116,6 @@ export class SubagentManager {
117
116
  : undefined,
118
117
  onRunFinished: (agent) => {
119
118
  if (options.isBackground) {
120
- this.queue.markFinished();
121
119
  try { this.observer?.onSubagentCompleted(agent); } catch (err) { debugLog("onSubagentCompleted observer", err); }
122
120
  }
123
121
  },
@@ -166,9 +164,13 @@ export class SubagentManager {
166
164
  this.observer?.onSubagentCreated(record);
167
165
  }
168
166
 
169
- if (options.isBackground && !options.bypassQueue && this.queue.isFull()) {
170
- // Queue it - will be started when a running agent completes
171
- this.queue.enqueue(id);
167
+ if (options.isBackground && !options.bypassQueue) {
168
+ // Schedule on the limiter started when a slot frees. The status guard
169
+ // makes an abort-while-queued task a no-op (Step 3 folds it into start()).
170
+ record.promise = this.limiter.schedule(() => {
171
+ if (record.status !== "queued") return Promise.resolve();
172
+ return record.run();
173
+ });
172
174
  return id;
173
175
  }
174
176
 
@@ -221,9 +223,9 @@ export class SubagentManager {
221
223
  const record = this.agents.get(id);
222
224
  if (!record) return false;
223
225
 
224
- // Remove from queue if queued
226
+ // A queued agent has not started; mark it stopped. Its scheduled thunk
227
+ // becomes a no-op (status guard) when its slot finally opens.
225
228
  if (record.status === "queued") {
226
- this.queue.dequeue(id);
227
229
  record.markStopped();
228
230
  return true;
229
231
  }
@@ -269,43 +271,44 @@ export class SubagentManager {
269
271
  // fallow-ignore-next-line unused-class-member
270
272
  abortAll(): number {
271
273
  let count = 0;
272
- // Clear queued agents first
273
- for (const id of this.queue.queuedIds) {
274
- const record = this.agents.get(id);
275
- if (record) {
274
+ for (const record of this.agents.values()) {
275
+ if (record.status === "queued") {
276
276
  record.markStopped();
277
277
  count++;
278
+ } else if (record.abort()) {
279
+ count++;
278
280
  }
279
281
  }
280
- this.queue.clear();
281
- // Abort running agents
282
- for (const record of this.agents.values()) {
283
- if (record.abort()) count++;
284
- }
282
+ // Drop pending thunks (their promises resolve).
283
+ this.limiter.clear();
285
284
  return count;
286
285
  }
287
286
 
288
287
  /** Wait for all running and queued agents to complete (including queued ones). */
289
288
  // fallow-ignore-next-line unused-class-member
290
289
  async waitForAll(): Promise<void> {
291
- // Loop because queue.drain() respects the concurrency limit - as running
292
- // agents finish they start queued ones, which need awaiting too.
293
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentional infinite loop with explicit break
294
- while (true) {
295
- this.queue.drain();
296
- const pending = [...this.agents.values()]
297
- .filter(r => r.status === "running" || r.status === "queued")
298
- .map(r => r.promise)
299
- .filter((p): p is Promise<void> => p != null);
300
- if (pending.length === 0) break;
290
+ // Every spawned agent has a settled-on-completion promise (the limiter starts
291
+ // queued ones as slots free), so a single allSettled covers the queued case.
292
+ // The loop only catches agents spawned during the wait.
293
+ let pending = this.pendingPromises();
294
+ while (pending.length > 0) {
301
295
  await Promise.allSettled(pending);
296
+ pending = this.pendingPromises();
302
297
  }
303
298
  }
304
299
 
300
+ /** Promises of all running/queued agents that have one. */
301
+ private pendingPromises(): Promise<void>[] {
302
+ return [...this.agents.values()]
303
+ .filter(r => r.status === "running" || r.status === "queued")
304
+ .map(r => r.promise)
305
+ .filter((p): p is Promise<void> => p != null);
306
+ }
307
+
305
308
  dispose() {
306
309
  clearInterval(this.cleanupInterval);
307
- // Clear queue
308
- this.queue.clear();
310
+ // Drop pending thunks
311
+ this.limiter.clear();
309
312
  for (const record of this.agents.values()) {
310
313
  record.disposeSession();
311
314
  }
@@ -428,7 +428,8 @@ export class Subagent {
428
428
  /**
429
429
  * Abort a running agent: fire AbortController and transition to stopped.
430
430
  * Returns false if the agent is not running.
431
- * Queue removal is handled by SubagentManager via ConcurrencyQueue.dequeue().
431
+ * A still-queued agent is stopped by SubagentManager; its scheduled thunk
432
+ * then no-ops on the queued-status guard.
432
433
  */
433
434
  abort(): boolean {
434
435
  if (this._status !== "running") return false;
@@ -1,63 +0,0 @@
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
- }