@gotgenes/pi-subagents 16.1.0 → 16.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,268 @@
1
+ ---
2
+ issue: 374
3
+ issue_title: "Encapsulate run start and notification attachment on Subagent"
4
+ ---
5
+
6
+ # Encapsulate Subagent.start() and read-only promise/notification
7
+
8
+ ## Problem Statement
9
+
10
+ `Subagent.promise` is assigned from outside the class in three places — `SubagentManager.spawn()` (two sites: scheduled and immediate paths) — and `record.notification` is assigned from outside the class in seven test sites.
11
+ Both are output-argument smells (design-review check 3): the object should own the state its own methods read.
12
+ `Subagent.run()` already exists; the promise that tracks it lives outside the object purely so callers can `await record.promise`.
13
+ `notification` was already moved to the constructor in Phase 17 Step 2 (wired from `execution.parentSession?.toolCallId`), but the field is still publicly writable, so tests bypass the constructor path with direct assignment.
14
+
15
+ ## Goals
16
+
17
+ - Add `Subagent.start()` that calls `run()`, stores the resulting promise internally, and returns it.
18
+ - Fold the abort-while-queued status guard into `start()`, removing the inline check from `SubagentManager`.
19
+ - Make `promise` externally read-only: private `_promise` field backed by a public `get promise()` accessor.
20
+ - Make `notification` externally read-only: private `_notification` field backed by a public `get notification()` accessor.
21
+ - Add `toolCallId?: string` to `TestSubagentOptions` so tests wire notification state via the constructor path without external writes.
22
+ - Achieve grep-verifiable outcome: `\.promise =` and `\.notification =` appear only inside `subagent.ts`.
23
+
24
+ ## Non-Goals
25
+
26
+ - Extracting `RunListeners` or workspace-bracket collaborators from `Subagent` (Phase 17 Step 4, Issue [#375]).
27
+ - Extracting the manager observer from `index.ts` (Phase 17 Step 5, Issue [#376]).
28
+ - Any other Phase 17 step beyond Step 3.
29
+
30
+ ## Background
31
+
32
+ Phase 17 Step 1 ([#381]) replaced `ConcurrencyQueue` with a `ConcurrencyLimiter` — the manager now calls `this.limiter.schedule(thunk)` and stores the scheduled promise on `record.promise`.
33
+ Phase 17 Step 2 ([#373]) extracted `SubagentState`, made `SubagentExecution` mandatory, and wired `notification` in the constructor via `execution.parentSession?.toolCallId`.
34
+
35
+ Current external write sites after Step 2:
36
+
37
+ | Field | Location | Count |
38
+ | --------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
39
+ | `record.promise` | `SubagentManager.spawn()` | 2 (scheduled + immediate) |
40
+ | `record.promise` | Test files | 3 (`get-result-tool.test.ts`, `service-adapter.test.ts`, `make-subagent.test.ts`) |
41
+ | `record.notification` | Test files | 7 (`get-result-tool.test.ts` ×2, `subagent-manager.test.ts` ×2, `service-adapter.test.ts` ×1, `notification.test.ts` ×2) |
42
+
43
+ `SubagentManager.spawnAndWait()` and `waitForAll()` read `record.promise` via the public field — these become getter reads after the change.
44
+ `get-result-tool.ts` reads `record.promise` to `await` it when `wait=true` — unchanged (getter).
45
+
46
+ The `AGENTS.md` constraint that applies: **output arguments** — if a function sets a field on a received object, it is doing work that belongs inside the owning object.
47
+
48
+ ## Design Overview
49
+
50
+ ### `Subagent.start()` and the status guard
51
+
52
+ ```typescript
53
+ private _promise?: Promise<void>;
54
+
55
+ /** Awaitable handle to the running promise. Set by start(). */
56
+ get promise(): Promise<void> | undefined {
57
+ return this._promise;
58
+ }
59
+
60
+ /**
61
+ * Start execution: call run(), store the promise, and return it.
62
+ * Guards against non-active states (e.g. abort-while-queued): if the agent
63
+ * is neither queued nor running, the promise resolves immediately (no-op).
64
+ */
65
+ start(): Promise<void> {
66
+ if (this.status !== "queued" && this.status !== "running") {
67
+ this._promise = Promise.resolve();
68
+ return this._promise;
69
+ }
70
+ this._promise = this.run();
71
+ return this._promise;
72
+ }
73
+ ```
74
+
75
+ The guard allows:
76
+
77
+ - `"queued"` — background agent waiting in the limiter; `run()` proceeds normally.
78
+ - `"running"` — foreground agent (status set to `"running"` at construction in the manager); `run()` proceeds normally.
79
+ - Any terminal state (`"stopped"`, `"error"`, `"completed"`, etc.) — agent was aborted while queued; `start()` becomes a no-op returning an immediately-resolving promise.
80
+
81
+ This folds the inline `if (record.status !== "queued") return Promise.resolve()` guard out of the `SubagentManager` limiter callback.
82
+
83
+ ### `SubagentManager.spawn()` after the change
84
+
85
+ ```typescript
86
+ // Queued background path
87
+ this.limiter.schedule(() => record.start());
88
+
89
+ // Immediate path (foreground or bypassQueue)
90
+ record.start();
91
+ ```
92
+
93
+ `spawnAndWait()` continues to `await record.promise` (now uses the getter, no behavior change).
94
+ `waitForAll()`'s `pendingPromises()` continues to `r.promise` (getter — no behavior change).
95
+
96
+ ### `notification` encapsulation
97
+
98
+ The constructor already writes to `this.notification` internally.
99
+ After the change, the constructor writes to `this._notification`:
100
+
101
+ ```typescript
102
+ private _notification?: NotificationState;
103
+
104
+ get notification(): NotificationState | undefined {
105
+ return this._notification;
106
+ }
107
+
108
+ // In constructor:
109
+ const toolCallId = init.execution.parentSession?.toolCallId;
110
+ if (toolCallId) {
111
+ this._notification = new NotificationState(toolCallId);
112
+ }
113
+ ```
114
+
115
+ No production writes to `notification` outside the constructor — only test sites need updating.
116
+
117
+ ### `TestSubagentOptions` shorthand
118
+
119
+ Add `toolCallId?: string` so tests that need a `NotificationState` use the constructor path:
120
+
121
+ ```typescript
122
+ // Before
123
+ const record = createTestSubagent();
124
+ record.notification = new NotificationState("tc-1");
125
+
126
+ // After
127
+ const record = createTestSubagent({ toolCallId: "tc-1" });
128
+ ```
129
+
130
+ In `createTestSubagent`, `toolCallId` routes through `makeStubExecution({ parentSession: { toolCallId } })`.
131
+
132
+ ### Tests that write `record.promise`
133
+
134
+ - **`service-adapter.test.ts`** ("strips promise from the record" tests): the test only needs `promise` to be absent from the serialized output.
135
+ Since `toSubagentRecord()` already builds an explicit object without `promise`, these tests pass without any promise being set on the record.
136
+ Remove the `record.promise = ...` setup.
137
+ - **`make-subagent.test.ts`** ("allows setting promise directly"): the test's intent was to verify the field was settable.
138
+ Replace with a test that `start()` sets `promise` internally via the stub execution.
139
+ - **`get-result-tool.test.ts`** ("waits for promise when wait=true"): the test needs a running agent whose promise resolves and updates status to completed.
140
+ Replace with an execution stub where `runTurnLoop` returns `{ responseText: "Finished after wait.", aborted: false, steered: false }` and call `record.start()`.
141
+ The `createSubagentSessionStub()` default already resolves with `{ responseText: "done", ... }` — override `runTurnLoop` to return the expected text.
142
+
143
+ ### `subagent-manager.test.ts` notification tests (lines 82, 100)
144
+
145
+ Tests that reproduce the race-condition bug (notification set post-spawn) become:
146
+
147
+ ```typescript
148
+ const id = manager.spawn(STUB_SNAPSHOT, "general-purpose", "test", {
149
+ description: "bg",
150
+ isBackground: true,
151
+ parentSession: { toolCallId: "tc-1" },
152
+ });
153
+ const record = manager.getRecord(id)!;
154
+ // notification is already wired from the constructor
155
+ await record.promise;
156
+ record.notification?.markConsumed();
157
+ ```
158
+
159
+ The behavior under test (race: `markConsumed()` after `await` is too late) is unchanged.
160
+
161
+ ## Module-Level Changes
162
+
163
+ - `src/lifecycle/subagent.ts`
164
+ - Remove public writable `promise?: Promise<void>` field.
165
+ - Add `private _promise?: Promise<void>`.
166
+ - Add `get promise(): Promise<void> | undefined`.
167
+ - Add `start(): Promise<void>` with the status guard.
168
+ - Rename `this.notification` write in constructor to `this._notification`.
169
+ - Remove public writable `notification?: NotificationState` field.
170
+ - Add `private _notification?: NotificationState`.
171
+ - Add `get notification(): NotificationState | undefined`.
172
+ - `src/lifecycle/subagent-manager.ts`
173
+ - Replace `record.promise = this.limiter.schedule(() => { if (...) return ...; return record.run(); })` with `this.limiter.schedule(() => record.start())`.
174
+ - Replace `record.promise = record.run()` with `record.start()`.
175
+ - `test/helpers/make-subagent.ts`
176
+ - Add `toolCallId?: string` to `TestSubagentOptions`.
177
+ - In `createTestSubagent`, map `toolCallId` to `makeStubExecution({ parentSession: { toolCallId } })`.
178
+ - `test/helpers/make-subagent.test.ts`
179
+ - Replace "allows setting promise directly after construction" with a test that `start()` stores promise via the execution stub.
180
+ - `test/tools/get-result-tool.test.ts`
181
+ - Replace `record.promise = Promise.resolve().then(...)` setup with a stub execution + `record.start()`.
182
+ - Replace `record.notification = new NotificationState("tc-1")` (×2) with `createTestSubagent({ toolCallId: "tc-1" })`.
183
+ - `test/lifecycle/subagent-manager.test.ts`
184
+ - Replace `record.notification = new NotificationState("tc-1")` (×2) with spawn options carrying `parentSession: { toolCallId: "tc-1" }`.
185
+ - `test/service/service-adapter.test.ts`
186
+ - Remove `record.promise = Promise.resolve()` setup (×2) from tests that only need to verify `toSubagentRecord()` strips the field.
187
+ - Replace `record.notification = new NotificationState("tc-1")` with `createTestSubagent({ toolCallId: "tc-1" })`.
188
+ - `test/observation/notification.test.ts`
189
+ - Replace `record.notification = new NotificationState("tc-123/tc-1")` (×2) with `createTestSubagent({ toolCallId: "tc-123/tc-1" })`.
190
+ - `docs/architecture/architecture.md`
191
+ - Mark Step 3 `✅ Complete` and add a "Landed" note.
192
+
193
+ ## Test Impact Analysis
194
+
195
+ 1. **New unit tests enabled**: `start()` behavior (promise stored, status guard no-op) can be tested directly in `subagent.test.ts` without touching the manager.
196
+ 2. **Existing tests simplified**: The 7 test sites that do `record.notification = ...` drop an artificial mutation and instead use the natural constructor path — the tests are shorter and closer to production semantics.
197
+ 3. **Tests that must stay**: The manager's race-condition tests (lines 74–120) verify ordering of `markConsumed()` vs `await promise` — they change setup only (spawn with toolCallId), not intent.
198
+ 4. **Tests removed**: The `make-subagent.test.ts` "allows setting promise" test is replaced, since direct write is no longer possible.
199
+
200
+ ## TDD Order
201
+
202
+ 1. **Add `Subagent.start()` alongside the existing public `promise?` field**
203
+
204
+ In `test/lifecycle/subagent.test.ts`, add tests:
205
+ - `start()` on a running agent returns a defined promise.
206
+ - `start()` on a stopped agent returns a resolving promise immediately (no-op guard).
207
+ - After `start()`, `record.promise` matches the returned promise.
208
+
209
+ In `src/lifecycle/subagent.ts`: add `private _promise`, `get promise()` (shadowing the old field — TypeScript will require removing the duplicate; advance to step 2 immediately), and `start()`.
210
+ Commit: `test: add Subagent.start() tests and initial implementation (#374)`
211
+
212
+ 2. **Make `promise` read-only — remove public field, update all write sites**
213
+
214
+ Breaking change at the type level.
215
+ Atomic commit must include:
216
+ - `src/lifecycle/subagent.ts` — remove `promise?: Promise<void>` public field (only `private _promise` + getter remain).
217
+ - `src/lifecycle/subagent-manager.ts` — replace both `record.promise = ...` sites with `record.start()` calls; limiter thunk becomes `() => record.start()`.
218
+ - `test/helpers/make-subagent.test.ts` — replace write-promise test with `start()` test.
219
+ - `test/tools/get-result-tool.test.ts` — replace `record.promise = ...` setup; use execution stub + `record.start()`.
220
+ - `test/service/service-adapter.test.ts` — remove `record.promise = Promise.resolve()` setup (×2).
221
+
222
+ Run `pnpm --filter @gotgenes/pi-subagents run check` to verify.
223
+ Commit: `feat: make Subagent.promise read-only, add start() (#374)`
224
+
225
+ 3. **Make `notification` read-only — remove public field, update all write sites**
226
+
227
+ Breaking change at the type level.
228
+ Atomic commit must include:
229
+ - `src/lifecycle/subagent.ts` — rename public `notification?` to `private _notification`; add `get notification()`; constructor write becomes `this._notification = ...`.
230
+ - `test/helpers/make-subagent.ts` — add `toolCallId?: string` to `TestSubagentOptions`; route through `makeStubExecution`.
231
+ - `test/tools/get-result-tool.test.ts` — replace `record.notification = new NotificationState(...)` (×2) with `createTestSubagent({ toolCallId: ... })`.
232
+ - `test/lifecycle/subagent-manager.test.ts` — replace `record.notification = new NotificationState(...)` (×2) with spawn options carrying `parentSession: { toolCallId: ... }`.
233
+ - `test/service/service-adapter.test.ts` — replace `record.notification = new NotificationState(...)` with `createTestSubagent({ toolCallId: ... })`.
234
+ - `test/observation/notification.test.ts` — replace `record.notification = new NotificationState(...)` (×2) with `createTestSubagent({ toolCallId: ... })`.
235
+
236
+ Run `pnpm --filter @gotgenes/pi-subagents exec vitest run` and `pnpm --filter @gotgenes/pi-subagents run check`.
237
+ Commit: `feat: make Subagent.notification read-only, update tests (#374)`
238
+
239
+ 4. **Update architecture doc**
240
+
241
+ In `docs/architecture/architecture.md`, mark Step 3 `✅ Complete` and add a "Landed" note summarizing the outcome.
242
+ Also update the note at line 943 that says "Step 3 later folds the guard into `Subagent.start()`" to reflect it is now done.
243
+ Commit: `docs: mark Phase 17 Step 3 complete in architecture.md (#374)`
244
+
245
+ ## Risks and Mitigations
246
+
247
+ - **Risk**: Adding both `private _promise` and `get promise()` while the public `promise?` field still exists is a TypeScript error (duplicate identifier).
248
+ **Mitigation**: Steps 1 and 2 are merged into one commit: introduce `start()`, remove the public writable field, and fix all consumers atomically.
249
+ The TDD order describes testing `start()` first, but both the public field removal and the consumer updates land in the same `feat:` commit.
250
+ - **Risk**: The status guard in `start()` allows `"running"` for foreground agents, which have `status = "running"` at construction.
251
+ If a foreground agent is stopped before `start()` is called (edge case), `run()` would call `markRunning()` on an already-stopped agent.
252
+ **Mitigation**: Foreground agents are started synchronously at the end of `spawn()` — there is no window between construction and `start()` during which the abort path can fire.
253
+ The guard is conservative and causes no regression.
254
+ - **Risk**: The race-condition test in `subagent-manager.test.ts` (lines 74–107) verifies that `markConsumed()` called after `await record.promise` is "too late" for the observer.
255
+ Switching from `record.notification = new NotificationState("tc-1")` to the constructor path does not change timing semantics.
256
+ **Mitigation**: The test body stays structurally identical; only the setup changes.
257
+ - **Risk**: `service-adapter.test.ts` tests that call `record.promise = Promise.resolve()` might be testing that the field exists on the Subagent type.
258
+ **Mitigation**: The tests are testing `toSubagentRecord()` output, not the field type.
259
+ Removing the setup doesn't change the assertion.
260
+
261
+ ## Open Questions
262
+
263
+ - None.
264
+ The design is fully specified by the Phase 17 Step 3 architecture note and the existing class structure.
265
+
266
+ [#373]: https://github.com/gotgenes/pi-packages/issues/373
267
+ [#375]: https://github.com/gotgenes/pi-packages/issues/375
268
+ [#381]: https://github.com/gotgenes/pi-packages/issues/381
@@ -0,0 +1,180 @@
1
+ ---
2
+ issue: 403
3
+ issue_title: "Pressing Escape does not stop subagent/background agent"
4
+ ---
5
+
6
+ # Abort subagents on parent interrupt (ESC)
7
+
8
+ ## Problem Statement
9
+
10
+ A user reports that pressing Escape in the Pi terminal to cancel the current work does not stop a running subagent — the agent keeps going despite the cancel request.
11
+ The reporter is a third party (`khalid244`); the operator confirmed the direction is to implement ESC-to-abort for both foreground and background subagents, aborting all running and queued background agents on a single ESC.
12
+
13
+ The root cause splits cleanly by execution mode:
14
+
15
+ 1. Foreground subagents already receive the parent's abort signal through the tool boundary (`tool.execute(signal)` → `Subagent.wireSignal` → `abort()` → child `session.abort()`), so they should already stop on ESC.
16
+ 2. Background subagents are detached by design: `spawnBackground()` never forwards the parent signal, and `manager.abortAll()` runs only on `session_shutdown`.
17
+ There is no wiring from a parent interrupt to background-agent abort, so ESC does nothing to them.
18
+ This is the reproducible bug.
19
+
20
+ ## Goals
21
+
22
+ - Pressing ESC (the parent agent-loop interrupt) aborts all running and queued background subagents.
23
+ - Add a regression guard test proving a foreground subagent's child session is aborted when the parent signal fires.
24
+ - Reuse the existing `manager.abortAll()` semantics (abort running, mark queued stopped, clear the limiter) so ESC stops every active subagent in one action.
25
+
26
+ This is an intentional behavior change: background subagents that previously survived ESC will now stop.
27
+ It is a bug fix (`fix:`), not a breaking change — no config key, default value, or output shape changes, and detached-survives-ESC was a limitation rather than a contract.
28
+
29
+ ## Non-Goals
30
+
31
+ - Selective or interactive abort (choosing which agent to stop) — out of scope.
32
+ - A dedicated `abortBackground()` that excludes foreground agents — `abortAll()` is reused; foreground agents are already aborted by their own signal wiring, so the overlap is redundant-but-harmless.
33
+ - Changing background-agent detachment for any path other than the ESC interrupt (e.g., the tool still returns immediately on spawn).
34
+ - Confirmation prompts or status messaging on abort.
35
+
36
+ ## Background
37
+
38
+ Relevant modules and the verified runtime facts behind the design:
39
+
40
+ - `src/tools/foreground-runner.ts` — `runForeground(..., signal, ...)` forwards the parent `signal` into `manager.spawnAndWait({ signal })`.
41
+ - `src/lifecycle/subagent.ts` — `run()` calls `this.wireSignal(this.execution.signal, () => this.abort())`; `abort()` fires `abortController.abort()` and marks the record stopped.
42
+ - `src/lifecycle/subagent-session.ts` — `runTurnLoop` calls `forwardAbortSignal(session, opts.signal)`, which calls `session.abort()` when the signal fires.
43
+ - `src/tools/background-spawner.ts` — `spawnBackground()` omits `signal` entirely; background agents are detached.
44
+ - `src/lifecycle/subagent-manager.ts` — `abortAll()` aborts running, marks queued stopped, and clears the limiter; currently called only from `src/handlers/lifecycle.ts` on shutdown.
45
+ - `src/handlers/tool-start.ts`, `src/handlers/lifecycle.ts`, `src/handlers/index.ts` — the existing `handlers/` pattern: small classes with a narrow injected interface, registered in `index.ts`.
46
+
47
+ Verified SDK facts (from the pinned peer deps under `node_modules/@earendil-works/`):
48
+
49
+ - The interactive ESC handler calls `agent.abort()` while streaming (`pi-coding-agent` `interactive-mode.js`, `restoreQueuedMessagesToEditor({ abort: true })`).
50
+ - `pi-agent-core` `agent.js`: each run creates a fresh `AbortController`; `agent.abort()` calls `activeRun.abortController.abort()`; on normal completion `finishRun()` discards the controller **without** aborting it.
51
+ Therefore the parent signal's `abort` event fires only on a real interrupt, never on normal turn completion — latching `abortAll()` to it will not spuriously kill background agents at turn end.
52
+ - The signal passed to `tool.execute(...)` (`agent-loop.js` line ~419) is that same per-run signal.
53
+ - Extensions read the live per-run parent signal via `ctx.signal` (`ExtensionContext.signal: AbortSignal | undefined`, undefined when idle).
54
+ - `pi.on("turn_start", (event, ctx) => ...)` is a registered event whose handler receives `ExtensionContext`; `turn_start` fires once at the start of every turn while streaming, so its `ctx.signal` is always the current run's signal.
55
+
56
+ AGENTS.md constraint: pi-subagents is a minimal core with dependency arrows pointing inward.
57
+ The new handler depends only on a narrow manager interface; no consumer knowledge leaks into the manager.
58
+
59
+ ## Design Overview
60
+
61
+ Add a small `InterruptHandler` that latches the current parent abort signal and, on abort, tells the manager to abort all subagents.
62
+ Drive it from `turn_start` so the latch always tracks the live per-run signal — including across runs and turns that execute no tools.
63
+
64
+ Why `turn_start` rather than `tool_execution_start`: a background agent can outlive the run that spawned it.
65
+ If the user later interrupts a turn that ran no subagent tool, only a turn-level latch still holds that run's signal.
66
+ `turn_start` fires every turn with the current `ctx.signal`, so the latch is always current.
67
+
68
+ The latch dedups by reference: most turns reuse the same signal (no-op); a new run's signal triggers a detach-and-rewire.
69
+ The `abort` listener is `{ once: true }`; on normal completion the run's `AbortController` is discarded and garbage-collected with its listener, and the next `turn_start` detaches the stale reference.
70
+
71
+ ### Manager interface (narrow, Tell-Don't-Ask)
72
+
73
+ ```typescript
74
+ /** Narrow manager interface — only the method the interrupt handler calls. */
75
+ export interface InterruptManager {
76
+ abortAll(): number;
77
+ }
78
+
79
+ /** Minimal context shape — only the field the handler reads. */
80
+ interface InterruptCtx {
81
+ signal: AbortSignal | undefined;
82
+ }
83
+ ```
84
+
85
+ ### Handler
86
+
87
+ ```typescript
88
+ export class InterruptHandler {
89
+ private latched?: AbortSignal;
90
+ private detach?: () => void;
91
+
92
+ constructor(private readonly manager: InterruptManager) {}
93
+
94
+ handleTurnStart(ctx: InterruptCtx): void {
95
+ const signal = ctx.signal;
96
+ if (signal === this.latched) return;
97
+ this.detach?.();
98
+ this.detach = undefined;
99
+ this.latched = signal;
100
+ if (!signal) return;
101
+ const onAbort = (): void => {
102
+ this.manager.abortAll();
103
+ };
104
+ signal.addEventListener("abort", onAbort, { once: true });
105
+ this.detach = () => signal.removeEventListener("abort", onAbort);
106
+ }
107
+ }
108
+ ```
109
+
110
+ ### Consumer call site (`index.ts`)
111
+
112
+ ```typescript
113
+ const interrupt = new InterruptHandler(manager);
114
+ pi.on("turn_start", (_event, ctx) => interrupt.handleTurnStart(ctx));
115
+ ```
116
+
117
+ The handler talks to `manager` through a one-method interface, reads one field of `ctx`, and performs no chained access — no Law-of-Demeter or output-argument smells.
118
+ The latch state (current signal, detach handle) is owned by the handler.
119
+
120
+ ### Edge cases
121
+
122
+ - Same signal across consecutive turns → reference equality short-circuits; no listener churn.
123
+ - `ctx.signal` undefined (idle, defensive) → detach the old listener and hold no signal.
124
+ - Signal already aborted when latched → `{ once: true }` listener does not fire; the prior signal's listener already ran `abortAll()`, so no agent is missed.
125
+ - ESC during a foreground subagent → the foreground agent is aborted twice (once via its own `wireSignal`, once via `abortAll`); `abort()` is guarded by status and `markStopped` is idempotent, so this is harmless.
126
+
127
+ ## Module-Level Changes
128
+
129
+ - `src/handlers/interrupt.ts` (new) — `InterruptHandler` class and `InterruptManager` interface.
130
+ - `src/handlers/index.ts` — add `export { InterruptHandler } from "#src/handlers/interrupt";`.
131
+ - `src/index.ts` — instantiate `new InterruptHandler(manager)` and register `pi.on("turn_start", (_event, ctx) => interrupt.handleTurnStart(ctx))`.
132
+ - `src/lifecycle/subagent-manager.ts` — no code change; `abortAll()` is reused.
133
+ Its `// fallow-ignore-next-line unused-class-member` comment stays (it is still reached only through narrow interfaces that fallow does not trace); the pre-completion `fallow dead-code` check will confirm.
134
+ - `docs/architecture/architecture.md` — extend the `handlers/` directory listing (around line 354) with `interrupt.ts` (turn_start handler → abort all subagents on interrupt).
135
+ Check the same file for any handler file-count or complexity row that names the `handlers/` domain and update if present.
136
+
137
+ No exports are removed or renamed.
138
+ Grep confirms `.pi/skills/package-pi-subagents/SKILL.md` does not mention `abortAll`, interrupt, or ESC, so no skill update is required.
139
+
140
+ ## Test Impact Analysis
141
+
142
+ This is a feature/fix addition, not an extraction, so no existing tests become redundant.
143
+
144
+ 1. New unit tests enabled — `InterruptHandler`: latches the current signal, fires `abortAll()` on abort, dedups the same signal reference, re-wires on a new signal, and handles an undefined signal.
145
+ 2. New integration guard — foreground abort: aborting the parent signal passed to `runTurnLoop` invokes the child `session.abort()`.
146
+ This pins the currently-untested foreground link in `forwardAbortSignal`.
147
+ 3. Existing tests stay as-is — `test/lifecycle/subagent.test.ts` (`wireSignal`, `abort`), `test/lifecycle/subagent-session.test.ts` (max-turns abort path), and `test/handlers/lifecycle.test.ts` (`abortAll` on shutdown) continue to exercise their layers unchanged.
148
+
149
+ ## TDD Order
150
+
151
+ 1. Foreground guard — `test/lifecycle/subagent-session.test.ts`.
152
+ Add a test: when the `signal` passed to `runTurnLoop` aborts while `session.prompt` is in flight, `session.abort()` is called.
153
+ Expected to pass immediately (proving the foreground chain already works); if the trace is wrong and it fails, fix `forwardAbortSignal` in `src/lifecycle/subagent-session.ts`.
154
+ Commit `test: guard foreground subagent abort on parent signal (#403)` (or `fix:` if a code fix is needed).
155
+ 2. Interrupt handler + wiring — `test/handlers/interrupt.test.ts` (new) → `src/handlers/interrupt.ts`, `src/handlers/index.ts`, `src/index.ts`.
156
+ Red: write the handler unit tests (latch, abort→abortAll, dedup, re-wire, undefined signal) against the not-yet-existing class.
157
+ Green: implement `InterruptHandler` + `InterruptManager`, export from the barrel, and register `pi.on("turn_start", ...)` in `index.ts`.
158
+ The handler, its test, and the composition-root wiring land together because the handler is inert without the registration.
159
+ Commit `fix: abort all subagents on parent interrupt (#403)`.
160
+ 3. Architecture doc — `docs/architecture/architecture.md`.
161
+ Add `interrupt.ts` to the `handlers/` directory listing and update any handler-domain count/row if present.
162
+ Commit `docs: note interrupt handler in subagents architecture (#403)`.
163
+
164
+ ## Risks and Mitigations
165
+
166
+ - ESC now stops background agents the user might have wanted to keep running.
167
+ Mitigation: this is the operator's explicit choice (abort all running + queued); the behavior is documented in the plan and reflected in the `fix:` commit body.
168
+ - Re-latching on every `turn_start` could add overhead.
169
+ Mitigation: the latch is a single reference comparison and short-circuits on the common same-signal case.
170
+ - A `{ once: true }` listener lingers on a signal that completes normally.
171
+ Mitigation: the run's `AbortController` is discarded and GC'd with its listener; the next `turn_start` detaches the stale handle.
172
+ - Non-interactive modes (print/rpc) may not emit `turn_start` the same way.
173
+ Mitigation: ESC interrupt is an interactive concern; the handler is a no-op when no signal is present.
174
+
175
+ ## Open Questions
176
+
177
+ - Should a dedicated `abortBackground()` (excluding foreground) replace `abortAll()` here?
178
+ Deferred: `abortAll()` is simpler and foreground is already signal-aborted; revisit only if the redundant double-abort proves problematic.
179
+ - Should ESC abort surface a confirmation or status message?
180
+ Deferred: out of scope for this fix.
@@ -0,0 +1,94 @@
1
+ ---
2
+ issue: 373
3
+ issue_title: "Extract SubagentState; make Subagent execution deps mandatory"
4
+ ---
5
+
6
+ # Retro: #373 — Extract SubagentState; make Subagent execution deps mandatory
7
+
8
+ ## Stage: Planning (2026-06-14T03:34:51Z)
9
+
10
+ ### Session summary
11
+
12
+ Produced the implementation plan at `packages/pi-subagents/docs/plans/0373-extract-subagent-state.md`.
13
+ The architecture doc (Phase 17 Step 2 + "First-principles refinement") already specified the design precisely and the issue body matched it, so planning was confirmation-and-detailing rather than discovery.
14
+ Issue is first-party (`gotgenes`) and unambiguous — skipped the `ask_user` gate.
15
+
16
+ ### Observations
17
+
18
+ - **Not breaking** for the published surface: `src/service/service.ts` exposes `SubagentRecord`/`SubagentStatus`/spawn-config, never `SubagentInit` or the `Subagent` constructor.
19
+ Only the internal constructor signature changes.
20
+ - **Single production construction site** confirmed: `SubagentManager.spawn` (~line 139) is the only `new Subagent(...)` outside tests — this is what makes mandatory execution deps viable.
21
+ - **Observer retarget is required**, not optional: making execution mandatory would otherwise force `record-observer.test.ts` to stub execution.
22
+ Pointing `subscribeSubagentObserver` at `SubagentState` (and dropping the record from `onCompact`, closing over `this` in `subagent.ts`) is the move that lets observer tests target `SubagentState` directly.
23
+ - **`resume()`'s missing-session throw stays** — it guards a genuine runtime state, not a construction concern.
24
+ Only the two `run()` "not configured for execution" throws are deleted.
25
+ - **`SubagentStatus` home**: moved to `subagent-state.ts` but re-exported from `subagent.ts` to keep `service.ts`'s import path (and the public type bundle path) unchanged, and to avoid a circular import.
26
+ - **Lift-and-shift for the large test file**: `test/lifecycle/subagent.test.ts` (~700 LOC).
27
+ Step 1 funnels constructions through a local helper and moves the state-machine `describe` blocks to the new `subagent-state.test.ts`, so Step 3's mandatory-execution flip is bounded to the helper + two run/resume factories.
28
+ Step 3 is unavoidably one atomic commit (removing optional fields breaks every construction at the type level at once).
29
+ - **Doc updates identified**: `architecture.md` (lifecycle file listing, `Subagent` class diagram, mark Step 2 ✅ Complete, Phase 17 prose ~line 879, type-complexity table ~line 649) and `SKILL.md` (Lifecycle 10→11 modules, total 56→57 files).
30
+ - Deferred per scope boundary: metrics-as-projection and result-delivery domain extraction (the other two of the four conflated domains).
31
+
32
+ ## Stage: Implementation — TDD (2026-06-14T09:23:00Z)
33
+
34
+ ### Session summary
35
+
36
+ Executed all four planned steps as separate commits: (1) extract `SubagentState` value object + new `subagent-state.test.ts`, (2) retarget `subscribeSubagentObserver` at `SubagentState`, (3) the atomic flip making `SubagentExecution` a mandatory collaborator and deleting the two `run()` throws, (4) docs.
37
+ Test count moved 966 → 967 (net): +26 new `SubagentState` tests, minus the migrated state-machine duplicates and the obsolete missing-factory test.
38
+ Pre-completion reviewer returned **PASS**; `check`/`lint`/`test`/`fallow` all clean.
39
+
40
+ ### Observations
41
+
42
+ - The plan held exactly — every file in Module-Level Changes was touched and nothing else.
43
+ The `createTestSubagent` consumers (`conversation-viewer`, `notification`, `get-result-tool`, `make-subagent.test`) stayed untouched as predicted; the helper absorbed the construction change via a `TestSubagentOptions` shape that splits passive-state shorthands from identity/execution.
44
+ - **Explicit-`undefined` preservation** (testing-skill warning) mattered: `createTestSubagent` and the local `makeSubagent` build their `SubagentState` via spread of the rest-captured state overrides (`{ defaults, ...stateOverrides }`) so callers passing `completedAt: undefined` (running-status records in `get-result-tool.test`) still get `undefined`, not the `2000` default.
45
+ - The lift-and-shift prep in Step 1 (local `makeSubagent` helper + perl-routing the single-line constructions) paid off: Step 3's breaking flip only had to edit the helper, `createRunnableAgent`, `createResumableAgent`, `createCompletionAgent`, and the constructor describe — not the whole file.
46
+ - Removed the obsolete "throws when the session factory is missing" test (the guard is gone by construction); the construct-complete invariant is now type-level, not runtime-testable.
47
+ An initial replacement comment was dropped per reviewer/operator feedback as unhelpful.
48
+ - `SubagentExecution` carries 12 fields (4 mandatory).
49
+ Reviewer flagged it as wide but accepted per the plan's recorded decision to keep it concrete rather than split further.
50
+ - Pre-completion reviewer: **PASS** (no WARN findings).
51
+
52
+ ## Stage: Final Retrospective (2026-06-14T17:20:00Z)
53
+
54
+ ### Session summary
55
+
56
+ Shipped #373 end-to-end across one conversation spanning Planning → TDD → Ship → Retro: four implementation commits, CI green, issue closed, no release-please PR (a `refactor:`-only change does not trigger a release).
57
+ The plan held exactly — zero rework, and the pre-completion reviewer returned PASS with nothing to fix.
58
+ The single user intervention was a one-line comment removal during TDD Step 3.
59
+
60
+ ### Observations
61
+
62
+ #### What went well
63
+
64
+ - **Plan-to-ship with zero rework.**
65
+ Every file in the plan's Module-Level Changes was touched and nothing else; the `createTestSubagent` consumers stayed untouched exactly as predicted.
66
+ The lift-and-shift prep (Step 1 funneling constructions through a local `makeSubagent` helper) bounded the breaking Step 3 flip to the helper plus three factories — the atomic-construction-change concern from the plan never materialized as churn.
67
+ - **Clean model allocation across stages.**
68
+ Planning ran on `claude-opus-4-8`, TDD on `claude-sonnet-4-6`, Ship on `opencode-go/deepseek-v4-flash` (mechanical git/CI/close work), the pre-completion reviewer subagent on `claude-sonnet-4-6`, and Retro on `claude-opus-4-8`.
69
+ Judgment-heavy work landed on reasoning-strong models; the cheap model handled only the mechanical ship sequence.
70
+ - **Incremental verification.** `pnpm run check` ran after every TDD step (not just at the end), catching the shared-type breakage at the right boundary; the affected test files were run per-step before the full suite.
71
+
72
+ #### What caused friction (agent side)
73
+
74
+ - `other` (tombstone comment) — after removing the obsolete "throws when the session factory is missing" test in TDD Step 3, left a comment narrating the *absence* of the guard (`// No "missing session factory" guard: execution is a mandatory constructor collaborator …`).
75
+ The user flagged it as unhelpful and asked for removal.
76
+ Impact: one extra `Edit` + a blank-line cleanup + a `--amend` of the Step 3 commit.
77
+ No behavioral rework; user-caught.
78
+
79
+ #### What caused friction (user side)
80
+
81
+ - None of consequence.
82
+ The single intervention (comment removal) was light mechanical oversight on an otherwise self-driving session; no earlier context would have changed the outcome.
83
+
84
+ ### Diagnostic details
85
+
86
+ - **Model-performance correlation** — no mismatch.
87
+ The only subagent dispatch (pre-completion-reviewer) ran on `claude-sonnet-4-6`, appropriate for judgment-heavy review; it returned PASS.
88
+ The Ship stage on `deepseek-v4-flash` was purely mechanical (git push, `ci_find`/`ci_watch`, `issue_close`, `release_pr_find`) and the one judgment point (the batch-vs-release `ask_user`) was handled correctly.
89
+ - **Escalation-delay / unused-tool / feedback-loop** — nothing notable: no rabbit-holes, no error-chasing sequences, and verification ran incrementally throughout.
90
+ Lenses skipped.
91
+
92
+ ### Changes made
93
+
94
+ 1. `.pi/skills/code-design/SKILL.md` (§ Names over comments) — added a line forbidding tombstone comments that narrate removed code or the absence of a guard/test/branch, prompted by the user-caught over-comment in TDD Step 3.
@@ -0,0 +1,38 @@
1
+ ---
2
+ issue: 374
3
+ issue_title: "Encapsulate run start and notification attachment on Subagent"
4
+ ---
5
+
6
+ # Retro: #374 — Encapsulate run start and notification attachment on Subagent
7
+
8
+ ## Stage: Planning (2026-06-14T00:00:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Read issue #374 (Phase 17 Step 3 — output-argument encapsulation), loaded skills, explored `subagent.ts`, `subagent-manager.ts`, `notification-state.ts`, and all seven test files with external writes.
13
+ Produced a 4-step TDD plan in `packages/pi-subagents/docs/plans/0374-encapsulate-subagent-start-notification.md`.
14
+
15
+ ### Observations
16
+
17
+ - The `notification` field was already constructor-wired in Phase 17 Step 2 (from `execution.parentSession?.toolCallId`); the remaining work is making both `promise` and `notification` externally read-only and updating the 7 + 3 test write sites.
18
+ - Steps 1 and 2 in the TDD order are effectively merged: introducing `private _promise` alongside the existing public `promise?` field is a TypeScript duplicate-identifier error, so the public field removal and all consumer updates must land in one atomic commit (`feat: make Subagent.promise read-only, add start() (#374)`).
19
+ - The status guard (`if (status !== "queued" && status !== "running")`) in `start()` allows foreground agents (constructed with `status: "running"`) to pass through cleanly, while stopping aborted-while-queued agents; this folds the inline guard out of the `SubagentManager` limiter callback.
20
+ - `service-adapter.test.ts` tests that set `record.promise = Promise.resolve()` only test that `toSubagentRecord()` strips the field — the setup is vestigial once `promise` becomes a getter; simply removing it is sufficient.
21
+ - The "waits for promise when wait=true" test in `get-result-tool.test.ts` needs a more realistic execution stub (`runTurnLoop` returning `{ responseText: "Finished after wait.", aborted: false, steered: false }`) so `record.start()` triggers the full run pipeline and calls `markCompleted()` internally.
22
+ - `TestSubagentOptions.toolCallId?: string` is the cleanest shorthand for the 5 test files that create passive records but need a `NotificationState`; it routes through `makeStubExecution({ parentSession: { toolCallId } })`, matching the production constructor path exactly.
23
+
24
+ ## Stage: Implementation — TDD (2026-06-14T16:31:00Z)
25
+
26
+ ### Session summary
27
+
28
+ Implemented all 4 plan steps in 2 substantive commits: one atomic `feat:` commit for `start()` + `promise`/`notification` read-only + all test site updates, and one `docs:` commit for the architecture doc.
29
+ Test count went from 975 to 981 (+6 new `start()` unit tests).
30
+ Pre-completion reviewer returned PASS with one WARN (stale test count in `package-pi-subagents` SKILL.md — fixed immediately).
31
+
32
+ ### Observations
33
+
34
+ - Plan steps 1–3 landed in a single commit because making `notification` private required the same `subagent.ts` file as making `promise` private; splitting would have required complex partial staging.
35
+ - The `void record.start()` and `void this.limiter.schedule(...)` patterns were needed in `subagent-manager.ts` to satisfy `@typescript-eslint/no-floating-promises` — `start()` returns a `Promise<void>` but the manager stores the state internally; callers don't need to await it.
36
+ - The "waits for promise when wait=true" test in `get-result-tool.test.ts` required `void record.start()` (intentional fire-and-forget) for the same reason.
37
+ - Grep-verifiable outcome confirmed: `\.promise =` and `\.notification =` appear only inside `subagent.ts` (as `this._promise =` and `this._notification =`).
38
+ - Pre-completion reviewer: PASS (no FAIL findings; WARN on stale skill test count addressed inline).
@@ -46,4 +46,50 @@ Test count went 975 → 966 (−22 deleted queue tests, +13 new limiter tests);
46
46
  - Pre-completion reviewer: WARN (no FAILs).
47
47
  Reviewer warnings: the single stale-comment finding at `index.ts:125` — now fixed in commit `90135005`.
48
48
 
49
+ ## Stage: Final Retrospective (2026-06-14T00:30:00Z)
50
+
51
+ ### Session summary
52
+
53
+ Shipped #381 across planning, TDD, and release: `pi-subagents` `16.0.0` → `16.1.0`, tag `pi-subagents-v16.1.0`.
54
+ Four commits landed (one `feat`, two `refactor`, one `docs`) plus two `docs(retro)` notes; CI passed first try, the issue was closed with an implemented-in summary, and the release-please PR was merged.
55
+ The plan — written down to code sketches — held up across all three TDD cycles with no design rework.
56
+
57
+ ### Observations
58
+
59
+ #### What went well
60
+
61
+ - The plan's fidelity paid off: the `clear()`-settles-pending-promises decision, the atomic step-2 sequencing (migrate consumers + delete queue + delete old test in one commit), and the `void`-prefix prediction for floating promises were all made at planning time and executed without surprise.
62
+ The `queueing and concurrency` manager tests passed unchanged after only the `createManager` helper swap, validating the planning claim that they exercise behavior, not queue internals.
63
+ - The pre-completion-reviewer (on `anthropic/claude-sonnet-4-6`, 161s, 21 tool uses) caught a stale comment at `src/index.ts:125` that all four deterministic gates (`check`, `lint`, `test`, `fallow dead-code`) passed over.
64
+ This is the backstop working exactly as intended — a judgment-model review surfacing residue that pattern-matchers cannot.
65
+ - Verification cadence was incremental, not end-loaded: file-scoped `vitest` + `biome` + `eslint` after step 1, `pnpm run check` immediately after the shared-interface change mid-step-2 (per the plan's own instruction), then lifecycle suite → full suite → full lint, then `rumdl` for the docs step, then the full gates + `fallow` before push.
66
+
67
+ #### What caused friction (agent side)
68
+
69
+ - `missing-context` (self/reviewer-caught) — the stale comment `// before startAgent / queue drain` at `src/index.ts:125` referenced two deleted concepts but was not cataloged in the plan's Module-Level Changes, despite the planning grep output having surfaced that exact line.
70
+ The grep hit was visible but never converted into a plan action or an explicit leave-as-is.
71
+ Impact: one small follow-up commit (`90135005`, `refactor:`); no rework, no design impact — the reviewer backstop absorbed it before ship.
72
+
73
+ #### What caused friction (user side)
74
+
75
+ - None.
76
+ The single user touchpoint — the release-timing gate in `/ship-issue` (release now vs. batch the Phase 17 sequence) — was strategic judgment the agent correctly deferred, not mechanical oversight.
77
+
78
+ ### Diagnostic details
79
+
80
+ - **Model-performance correlation** — one subagent dispatch (`pre-completion-reviewer`) on `anthropic/claude-sonnet-4-6`; appropriate match for judgment-heavy review, and it returned the session's only actionable finding.
81
+ - **Escalation-delay tracking** — no rabbit-holes; the lone lint error (`@typescript-eslint/no-floating-promises`, 18 sites) was resolved in a single test-file rewrite, far under the 5-call escalation threshold.
82
+ - **Unused-tool detection** — nothing under-tooled; `colgrep`/`grep` were used during planning exploration and the reviewer subagent was dispatched as designed.
83
+ - **Feedback-loop gap analysis** — no gap; verification ran after every cycle, with `pnpm run check` correctly invoked right after the shared-interface change rather than at end-of-session.
84
+
85
+ #### Process note (no inline change)
86
+
87
+ - The release-please PR merge required the documented `UNSTABLE` → `gh pr merge` fallback (step 6.4 of `/ship-issue`) because default-`GITHUB_TOKEN` release PRs never get checks.
88
+ This recurs every release; the prompt already handles it, so it is recorded here only as a standing pattern, not a friction point.
89
+
90
+ ### Changes made
91
+
92
+ 1. Added this Final Retrospective stage entry to `packages/pi-subagents/docs/retro/0381-replace-concurrency-queue-with-limiter.md`.
93
+ 2. No prompt or `AGENTS.md` changes — the operator chose retro-file-only, since the single friction (the stale `src/index.ts:125` comment) was a one-off execution slip already caught by the pre-completion-reviewer backstop, and the candidate grep-hit rule was judged not worth the prompt verbosity.
94
+
49
95
  [#378]: https://github.com/gotgenes/pi-packages/issues/378