@gotgenes/pi-subagents 16.0.0 → 16.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/public.d.ts +19 -22
- package/docs/architecture/architecture.md +49 -17
- package/docs/plans/0373-extract-subagent-state.md +250 -0
- package/docs/plans/0381-replace-concurrency-queue-with-limiter.md +267 -0
- package/docs/plans/0403-abort-subagents-on-interrupt.md +180 -0
- package/docs/retro/0373-extract-subagent-state.md +94 -0
- package/docs/retro/0381-replace-concurrency-queue-with-limiter.md +95 -0
- package/docs/retro/0400-include-parent-prompt-in-replace-mode.md +40 -0
- package/docs/retro/0403-abort-subagents-on-interrupt.md +49 -0
- package/package.json +1 -1
- package/src/handlers/index.ts +1 -0
- package/src/handlers/interrupt.ts +49 -0
- package/src/index.ts +13 -16
- package/src/lifecycle/concurrency-limiter.ts +55 -0
- package/src/lifecycle/subagent-manager.ts +57 -51
- package/src/lifecycle/subagent-state.ts +156 -0
- package/src/lifecycle/subagent.ts +86 -163
- package/src/observation/record-observer.ts +15 -13
- package/src/lifecycle/concurrency-queue.ts +0 -63
|
@@ -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,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.
|