@gotgenes/pi-subagents 16.2.0 → 16.2.2
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/README.md +9 -67
- package/docs/architecture/architecture.md +27 -20
- package/docs/comparison-with-upstream.md +76 -0
- package/docs/plans/0375-extract-run-listener-workspace-bracket.md +300 -0
- package/docs/plans/0376-extract-manager-observer.md +232 -0
- package/docs/retro/0374-encapsulate-subagent-start-notification.md +133 -0
- package/docs/retro/0375-extract-run-listener-workspace-bracket.md +104 -0
- package/docs/retro/0376-extract-manager-observer.md +40 -0
- package/package.json +1 -1
- package/src/index.ts +8 -57
- package/src/lifecycle/run-listeners.ts +37 -0
- package/src/lifecycle/subagent-manager.ts +5 -3
- package/src/lifecycle/subagent.ts +67 -94
- package/src/lifecycle/workspace-bracket.ts +59 -0
- package/src/observation/subagent-events-observer.ts +97 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 376
|
|
3
|
+
issue_title: "Extract the manager observer from index.ts into a class"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Extract the manager observer from index.ts into a class
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
`index.ts` constructs an inline `SubagentManagerObserver` object literal (~50 lines, four methods) at the composition root and hands it to `SubagentManager`.
|
|
11
|
+
The literal mixes three concerns: emitting `pi.events` lifecycle events, persisting the final record via `pi.appendEntry`, and dispatching completion notifications through the `NotificationManager`.
|
|
12
|
+
Per code-design principle 9 (state and behavior belong in a class, not a closure-captured literal), these three concerns cannot be unit-tested today without booting the entire extension.
|
|
13
|
+
`index.ts` is the package's dominant churn hotspot (31.3, 91 commits); shrinking it is the goal of Phase 17 Track B.
|
|
14
|
+
|
|
15
|
+
## Goals
|
|
16
|
+
|
|
17
|
+
- Extract the inline observer literal into a `SubagentEventsObserver` class under `src/observation/`, constructed with narrow deps: an `emit` function, an `appendEntry` function, and the `NotificationSystem`.
|
|
18
|
+
- `index.ts` instantiates the class and passes it to `SubagentManager` in place of the literal.
|
|
19
|
+
- Unit-test the observer's three concerns directly (event shapes, record persistence payload, notification dispatch branching) without booting the extension.
|
|
20
|
+
- Preserve every emitted event payload and `appendEntry` shape byte-for-byte — this is a pure extraction with no observable behavior change (not breaking).
|
|
21
|
+
- Bring `index.ts` below 170 lines.
|
|
22
|
+
|
|
23
|
+
## Non-Goals
|
|
24
|
+
|
|
25
|
+
- Splitting widget delegation out of `SubagentRuntime` (Phase 17 Step 6, Issue [#377]) — Step 5 is its prerequisite but lands separately.
|
|
26
|
+
- Moving `buildEventData` out of `notification.ts` — it stays where it is tested and is imported by the new observer.
|
|
27
|
+
- Pushing event-payload construction onto `Subagent` (a Tell-Don't-Ask improvement) — tracked as an Open Question, out of scope here.
|
|
28
|
+
- Narrowing `NotificationSystem` to a two-method interface — see Open Questions.
|
|
29
|
+
- Any other Phase 17 step.
|
|
30
|
+
|
|
31
|
+
## Background
|
|
32
|
+
|
|
33
|
+
Relevant modules:
|
|
34
|
+
|
|
35
|
+
- `src/index.ts` (226 lines) — composition root.
|
|
36
|
+
Defines `const observer: SubagentManagerObserver = { … }` at lines 79–134 and passes it to `new SubagentManager({ …, observer, … })`.
|
|
37
|
+
- `src/lifecycle/subagent-manager.ts` — owns the `SubagentManagerObserver` interface (four methods: `onSubagentStarted`, `onSubagentCompleted`, `onSubagentCompacted`, `onSubagentCreated`).
|
|
38
|
+
This interface is the manager's contract and stays here.
|
|
39
|
+
- `src/observation/notification.ts` — exports `buildEventData(record)` (pure, tested in `notification.test.ts`), the `NotificationSystem` interface (`cancelNudge`, `sendCompletion`, `cleanupCompleted`, `dispose`), and `NotificationManager`.
|
|
40
|
+
- `src/observation/record-observer.ts` — the established pattern for an observation-domain module that subscribes/dispatches with narrow deps; the new class is a sibling.
|
|
41
|
+
|
|
42
|
+
The current literal's four methods:
|
|
43
|
+
|
|
44
|
+
- `onSubagentStarted(record)` → `emit("subagents:started", { id, type, description })`.
|
|
45
|
+
- `onSubagentCompleted(record)` → branch on terminal status to `emit("subagents:failed" | "subagents:completed", buildEventData(record))`; `appendEntry("subagents:record", { …8 fields… })`; then either `notifications.cleanupCompleted(record.id)` (when `record.notification?.resultConsumed`) or `notifications.sendCompletion(record)`.
|
|
46
|
+
- `onSubagentCompacted(record, info)` → `emit("subagents:compacted", { id, type, description, reason, tokensBefore, compactionCount })`.
|
|
47
|
+
- `onSubagentCreated(record)` → `emit("subagents:created", { id, type, description, isBackground: true })`.
|
|
48
|
+
|
|
49
|
+
SDK signatures the narrow deps mirror:
|
|
50
|
+
|
|
51
|
+
- `EventBus.emit(channel: string, data: unknown): void`.
|
|
52
|
+
- `ExtensionAPI.appendEntry<T = unknown>(customType: string, data?: T): void`.
|
|
53
|
+
|
|
54
|
+
The applicable AGENTS.md constraints: Pi SDK imports stay out of the observation module — the class accepts `emit`/`appendEntry` as injected callbacks (the same pattern `SettingsManager` uses with `SettingsEmit`), and `index.ts` wires them to `pi.events.emit`/`pi.appendEntry` via arrows (avoids `@typescript-eslint/unbound-method`).
|
|
55
|
+
|
|
56
|
+
## Design Overview
|
|
57
|
+
|
|
58
|
+
### Class shape
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
/** Emit callback — a subset of `pi.events.emit`. */
|
|
62
|
+
export type EventEmit = (channel: string, data: unknown) => void;
|
|
63
|
+
|
|
64
|
+
/** Append callback — a subset of `pi.appendEntry`. */
|
|
65
|
+
export type AppendEntry = (customType: string, data: unknown) => void;
|
|
66
|
+
|
|
67
|
+
export interface SubagentEventsObserverDeps {
|
|
68
|
+
emit: EventEmit;
|
|
69
|
+
appendEntry: AppendEntry;
|
|
70
|
+
notifications: NotificationSystem;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class SubagentEventsObserver implements SubagentManagerObserver {
|
|
74
|
+
private readonly emit: EventEmit;
|
|
75
|
+
private readonly appendEntry: AppendEntry;
|
|
76
|
+
private readonly notifications: NotificationSystem;
|
|
77
|
+
|
|
78
|
+
constructor(deps: SubagentEventsObserverDeps) {
|
|
79
|
+
this.emit = deps.emit;
|
|
80
|
+
this.appendEntry = deps.appendEntry;
|
|
81
|
+
this.notifications = deps.notifications;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
onSubagentStarted(record: Subagent): void { /* emit started */ }
|
|
85
|
+
onSubagentCompleted(record: Subagent): void { /* emit failed|completed, appendEntry, dispatch */ }
|
|
86
|
+
onSubagentCompacted(record: Subagent, info: CompactionInfo): void { /* emit compacted */ }
|
|
87
|
+
onSubagentCreated(record: Subagent): void { /* emit created */ }
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The four method bodies are moved verbatim from the literal — same event channels, same payload fields, same branching.
|
|
92
|
+
`buildEventData` is imported from `#src/observation/notification`.
|
|
93
|
+
|
|
94
|
+
### Call site in index.ts
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
const observer = new SubagentEventsObserver({
|
|
98
|
+
emit: (channel, data) => pi.events.emit(channel, data),
|
|
99
|
+
appendEntry: (customType, data) => pi.appendEntry(customType, data),
|
|
100
|
+
notifications,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const manager = new SubagentManager({
|
|
104
|
+
createSubagentSession: (params) => createSubagentSession(params, subagentSessionDeps),
|
|
105
|
+
baseCwd: process.cwd(),
|
|
106
|
+
observer,
|
|
107
|
+
limiter,
|
|
108
|
+
getRunConfig: () => settings,
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`notifications` (the `NotificationManager`) is already constructed earlier in `index.ts`, so the observer construction slots in where the literal was.
|
|
113
|
+
|
|
114
|
+
### Extracted-module interaction with upstream deps
|
|
115
|
+
|
|
116
|
+
The observer only *reads* from its inputs and *tells* its collaborators — no output-argument mutation, no reverse-search, no reach-back into the manager:
|
|
117
|
+
|
|
118
|
+
```text
|
|
119
|
+
onSubagentCompleted(record):
|
|
120
|
+
reads record.status / record.id / … (Subagent getters)
|
|
121
|
+
calls buildEventData(record) (pure helper)
|
|
122
|
+
tells this.emit("subagents:completed", data) (injected callback)
|
|
123
|
+
tells this.appendEntry("subagents:record", {…}) (injected callback)
|
|
124
|
+
reads record.notification?.resultConsumed (LoD chain — pre-existing)
|
|
125
|
+
tells this.notifications.cleanupCompleted | sendCompletion
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The one Law-of-Demeter chain (`record.notification?.resultConsumed`) is carried over unchanged from the literal; it is pre-existing and out of scope (see Open Questions).
|
|
129
|
+
|
|
130
|
+
### Design-review checklist result
|
|
131
|
+
|
|
132
|
+
| Smell | Location | Evidence | Disposition |
|
|
133
|
+
| ---------------- | ------------------------------------- | -------------------------------------------------------- | -------------------------------------------- |
|
|
134
|
+
| Dependency width | `SubagentEventsObserverDeps` (3 deps) | all methods use `emit`; `onSubagentCompleted` uses all 3 | OK — narrow |
|
|
135
|
+
| LoD violation | `onSubagentCompleted` | `record.notification?.resultConsumed` | Track and watch — pre-existing, out of scope |
|
|
136
|
+
| Output argument | — | none | Clean |
|
|
137
|
+
| ISP | `NotificationSystem` (4 methods) | observer uses `sendCompletion`, `cleanupCompleted` | Accept per issue spec — see Open Questions |
|
|
138
|
+
|
|
139
|
+
The extraction introduces a real collaborator (a class that owns three behaviors and is independently testable), not a procedure split — each method returns no value but performs a distinct side-effecting concern that was previously untestable in isolation.
|
|
140
|
+
|
|
141
|
+
## Module-Level Changes
|
|
142
|
+
|
|
143
|
+
- `src/observation/subagent-events-observer.ts` (new)
|
|
144
|
+
- Export `SubagentEventsObserver implements SubagentManagerObserver`, plus `EventEmit`, `AppendEntry`, and `SubagentEventsObserverDeps` types.
|
|
145
|
+
- Imports: `SubagentManagerObserver` from `#src/lifecycle/subagent-manager`, `buildEventData` and `NotificationSystem` from `#src/observation/notification`, `Subagent` and `CompactionInfo` from `#src/types`.
|
|
146
|
+
- `src/index.ts` (changed)
|
|
147
|
+
- Remove the inline `const observer: SubagentManagerObserver = { … }` literal (lines 79–134).
|
|
148
|
+
- Add `import { SubagentEventsObserver } from "#src/observation/subagent-events-observer"`.
|
|
149
|
+
- Construct `const observer = new SubagentEventsObserver({ emit, appendEntry, notifications })`.
|
|
150
|
+
- Remove now-unused imports: `buildEventData` (moves to the new module) and the `type SubagentManagerObserver` import (the literal annotation is gone).
|
|
151
|
+
- Keep `NotificationDetails` and `NotificationManager` imports (still used by `registerMessageRenderer` and the `notifications` construction).
|
|
152
|
+
- `test/observation/subagent-events-observer.test.ts` (new)
|
|
153
|
+
- Unit tests for all four methods (see TDD Order).
|
|
154
|
+
- `docs/architecture/architecture.md` (changed)
|
|
155
|
+
- Mark Phase 17 Step 5 `✅ Complete` and add a "Landed" note (index.ts line count, file/test counts).
|
|
156
|
+
- Update the "Total LOC … (60 files …)" listings to 61 files and refresh the test count.
|
|
157
|
+
- The churn-hotspot note for `index.ts` stays — Step 5 continues the shrink trend.
|
|
158
|
+
|
|
159
|
+
Grep confirms no `package-*/SKILL.md` documents the inline observer literal or `SubagentEventsObserver` by name; no skill-doc update needed.
|
|
160
|
+
|
|
161
|
+
## Test Impact Analysis
|
|
162
|
+
|
|
163
|
+
1. **New unit tests enabled.**
|
|
164
|
+
All four observer methods become directly testable with `vi.fn()` stubs for `emit`/`appendEntry` and a stubbed `NotificationSystem`.
|
|
165
|
+
Previously the only way to exercise these paths was through `SubagentManager` integration tests that drove the manager's `buildObserver` forwarding — the index-level observer body itself had zero coverage.
|
|
166
|
+
2. **No existing tests become redundant.**
|
|
167
|
+
The `subagent-manager.test.ts` observer tests (lines 76–148) exercise the manager's *forwarding* (`buildObserver` → `this.observer?.onSubagent…`), a different layer that stays.
|
|
168
|
+
`notification.test.ts` keeps the `buildEventData` tests.
|
|
169
|
+
`agent-tool.test.ts:153` keeps its note that `subagents:created` is delegated to the observer.
|
|
170
|
+
3. **Tests that must stay as-is.**
|
|
171
|
+
The manager forwarding tests and the `buildEventData` purity tests genuinely exercise layers the extraction does not touch.
|
|
172
|
+
|
|
173
|
+
## Invariants at risk
|
|
174
|
+
|
|
175
|
+
This step touches `index.ts`, which prior Phase 17 steps shrank, and the event/notification dispatch surface.
|
|
176
|
+
Step 6 ([#377]) depends on this step; it must not regress the index.ts shrink.
|
|
177
|
+
|
|
178
|
+
| Invariant | Pinned by |
|
|
179
|
+
| ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
|
|
180
|
+
| Completion of a successful background agent emits `subagents:completed` (not `subagents:failed`) with `buildEventData` shape | new `onSubagentCompleted` success test |
|
|
181
|
+
| Terminal `error`/`stopped`/`aborted` status emits `subagents:failed` | new `onSubagentCompleted` error test |
|
|
182
|
+
| `appendEntry("subagents:record", …)` persists the eight-field record on every completion | new `onSubagentCompleted` appendEntry test |
|
|
183
|
+
| Already-consumed result skips `sendCompletion` and calls `cleanupCompleted` | new `onSubagentCompleted` resultConsumed test |
|
|
184
|
+
| `subagents:created` carries `isBackground: true` | new `onSubagentCreated` test |
|
|
185
|
+
|
|
186
|
+
These invariants lived only in the untested inline literal before; the new tests pin them for the first time.
|
|
187
|
+
|
|
188
|
+
## TDD Order
|
|
189
|
+
|
|
190
|
+
1. **Red → Green → Commit: extract `SubagentEventsObserver` and wire `index.ts`.**
|
|
191
|
+
|
|
192
|
+
Write `test/observation/subagent-events-observer.test.ts` first (red), covering:
|
|
193
|
+
- `onSubagentStarted` emits `subagents:started` with `{ id, type, description }`.
|
|
194
|
+
- `onSubagentCompleted` (success status) emits `subagents:completed` with `buildEventData(record)`, calls `appendEntry("subagents:record", …)` with the eight fields, and calls `notifications.sendCompletion(record)`.
|
|
195
|
+
- `onSubagentCompleted` (error/stopped/aborted) emits `subagents:failed`.
|
|
196
|
+
- `onSubagentCompleted` with `record.notification?.resultConsumed` calls `notifications.cleanupCompleted(record.id)` and does *not* call `sendCompletion`.
|
|
197
|
+
- `onSubagentCompacted` emits `subagents:compacted` with `{ id, type, description, reason, tokensBefore, compactionCount }`.
|
|
198
|
+
- `onSubagentCreated` emits `subagents:created` with `{ id, type, description, isBackground: true }`.
|
|
199
|
+
|
|
200
|
+
Use `createTestSubagent({ … })` from `#test/helpers/make-subagent` for records (it provides status, result, error, `toolCallId` for `NotificationState`, `compactionCount`); `vi.fn()` for `emit`/`appendEntry`; a stub object for `NotificationSystem`.
|
|
201
|
+
|
|
202
|
+
Then create `src/observation/subagent-events-observer.ts` (green) and update `index.ts` in the **same commit**: replace the literal with `new SubagentEventsObserver({ … })` and drop the now-unused `buildEventData` / `type SubagentManagerObserver` imports.
|
|
203
|
+
The class and the index.ts swap are coupled — index.ts is the sole call site of the literal being replaced, and the new class needs a consumer to satisfy `pnpm fallow dead-code`.
|
|
204
|
+
|
|
205
|
+
Run `pnpm --filter @gotgenes/pi-subagents run check` and `pnpm --filter @gotgenes/pi-subagents exec vitest run`.
|
|
206
|
+
Commit: `refactor: extract SubagentEventsObserver from index.ts (#376)`
|
|
207
|
+
|
|
208
|
+
2. **Commit: mark Phase 17 Step 5 complete in the architecture doc.**
|
|
209
|
+
|
|
210
|
+
In `docs/architecture/architecture.md`, mark Step 5 `✅ Complete`, add a "Landed" note (new index.ts line count, file count 60 → 61, refreshed test count), and update the two "(60 files …)" LOC listings.
|
|
211
|
+
Commit: `docs: mark Phase 17 Step 5 complete in architecture.md (#376)`
|
|
212
|
+
|
|
213
|
+
## Risks and Mitigations
|
|
214
|
+
|
|
215
|
+
- **Risk:** the extracted methods drift from the literal's exact event payloads or `appendEntry` shape.
|
|
216
|
+
**Mitigation:** move the bodies verbatim; the new tests assert each payload field explicitly, pinning the shapes.
|
|
217
|
+
- **Risk:** passing `pi.events.emit` / `pi.appendEntry` as bare values trips `@typescript-eslint/unbound-method`.
|
|
218
|
+
**Mitigation:** wire them as arrow callbacks in `index.ts` (`(channel, data) => pi.events.emit(channel, data)`), matching the existing `SettingsManager` emit wiring.
|
|
219
|
+
- **Risk:** the new class is flagged as dead code by `pnpm fallow dead-code` if it lands before its consumer.
|
|
220
|
+
**Mitigation:** the class and the `index.ts` wiring land in one commit (TDD step 1).
|
|
221
|
+
- **Risk:** `index.ts` does not drop below 170 lines.
|
|
222
|
+
**Mitigation:** the literal is ~50 lines and the replacement is ~6; 226 − ~50 + ~6 ≈ 182 minus the removed import lines lands it near/below 170 — verify with `wc -l` during implementation and, if just over, the net is still a clear shrink (the Outcome target is directional, not a hard gate).
|
|
223
|
+
|
|
224
|
+
## Open Questions
|
|
225
|
+
|
|
226
|
+
- Should `NotificationSystem` be narrowed to a two-method `CompletionNotifier` (`sendCompletion`, `cleanupCompleted`) for the observer per ISP?
|
|
227
|
+
Deferred: the issue prescribes passing `NotificationSystem`, and the interface is already cohesive.
|
|
228
|
+
Revisit only if a third consumer wants the narrow slice.
|
|
229
|
+
- Should event-payload construction (and the `record.notification?.resultConsumed` read) move onto `Subagent` as Tell-Don't-Ask methods (`record.toEventData()`, `record.isResultConsumed()`)?
|
|
230
|
+
Deferred: out of scope for a faithful extraction; `buildEventData` already exists as a pure helper.
|
|
231
|
+
|
|
232
|
+
[#377]: https://github.com/gotgenes/pi-packages/issues/377
|
|
@@ -36,3 +36,136 @@ Pre-completion reviewer returned PASS with one WARN (stale test count in `packag
|
|
|
36
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
37
|
- Grep-verifiable outcome confirmed: `\.promise =` and `\.notification =` appear only inside `subagent.ts` (as `this._promise =` and `this._notification =`).
|
|
38
38
|
- Pre-completion reviewer: PASS (no FAIL findings; WARN on stale skill test count addressed inline).
|
|
39
|
+
|
|
40
|
+
## Stage: Final Retrospective (2026-06-14T17:30:00Z)
|
|
41
|
+
|
|
42
|
+
### Session summary
|
|
43
|
+
|
|
44
|
+
Shipped issue #374 (Phase 17 Step 3) cleanly across three stages: a planning session produced a 4-step plan, a TDD session implemented it in 2 substantive commits (test count 975 → 981), and a ship session pushed, verified CI, closed the issue, and released `pi-subagents-v16.2.0`.
|
|
45
|
+
No rework, no user corrections, no CI failures — the only mid-stream fixes were two self-identified lint adjustments inside the TDD session.
|
|
46
|
+
|
|
47
|
+
### Observations
|
|
48
|
+
|
|
49
|
+
#### What went well
|
|
50
|
+
|
|
51
|
+
- The cross-session retro bridge worked exactly as designed: the planning stage wrote three concrete breadcrumbs — steps 1+2 must merge (TypeScript duplicate-identifier), the `get-result-tool.test.ts` "waits for promise" test needs a realistic `runTurnLoop` stub, and `TestSubagentOptions.toolCallId` is the cleanest notification shorthand — and the TDD stage consumed all three directly without re-deriving them.
|
|
52
|
+
This is the first time the breadcrumb-to-implementation handoff is visibly load-bearing rather than ceremonial.
|
|
53
|
+
- The grep-verifiable completion criterion (`\.promise =` and `\.notification =` appear only inside `subagent.ts`) gave an objective, one-command done-check (session turns 123–124) instead of a subjective "looks encapsulated."
|
|
54
|
+
|
|
55
|
+
#### What caused friction (agent side)
|
|
56
|
+
|
|
57
|
+
- `missing-context` — the plan did not anticipate that replacing `record.promise = record.run()` (assignment consumes the promise) with a bare `record.start()` call would trip `@typescript-eslint/no-floating-promises` at the two manager call sites and one test site.
|
|
58
|
+
Self-identified via lint.
|
|
59
|
+
Impact: two extra `void` edits (turns 106, 111) inside the same commit — no commit reorder, no rework beyond the fixups.
|
|
60
|
+
- `wrong-abstraction` (surfaced by operator pushback during the retro) — the `void this.limiter.schedule(() => record.start())` fix is safe but marks an unfinished design seam, not a tidy idiom.
|
|
61
|
+
`run()`/`start()` carry a hard "always resolves, errors captured internally" contract, so `void` is the ESLint-sanctioned annotation (no unhandled-rejection risk).
|
|
62
|
+
But the encapsulation regressed the promise-capture timing: before #374, `record.promise = this.limiter.schedule(...)` set the handle eagerly at spawn (even queued agents had `.promise`); after #374, `.promise` stays undefined until the limiter fires the thunk and `start()` runs, so promise ownership is now split between `Subagent._promise` and the limiter's internal completion handle (which the `void` discards).
|
|
63
|
+
`waitForAll()` still worked only via `pendingPromises()`'s `null`-filter plus re-poll loop, so the in-code comment "a single `allSettled` covers the queued case" became inaccurate.
|
|
64
|
+
This regressed Step 1's (#381) documented invariant "every spawned agent has a `promise` at spawn" — a cross-step regression within the same phase, not (as first claimed in this retro) work deferred to a future domain split.
|
|
65
|
+
Corrected in a follow-up `fix:` (see the second retrospective stage below): `Subagent.scheduleVia(schedule)` captures the limiter promise eagerly inside the agent, restoring the invariant without reintroducing an external `.promise =` write.
|
|
66
|
+
Impact: one follow-up `fix:` commit (+1 regression test, +2 `scheduleVia` unit tests).
|
|
67
|
+
- `wrong-abstraction` — the plan decomposed the work into 3 TDD steps (promise read-only, notification read-only, doc) but steps 1–3 collapsed into a single atomic commit because both fields live in `subagent.ts` and making each read-only is one type-level change.
|
|
68
|
+
The planning retro half-anticipated this (it flagged steps 1+2 merging) but did not extend the reasoning to step 3.
|
|
69
|
+
Impact: plan/reality granularity mismatch, documented as a deviation; no rework.
|
|
70
|
+
|
|
71
|
+
#### What caused friction (user side)
|
|
72
|
+
|
|
73
|
+
- None.
|
|
74
|
+
User involvement was a single well-placed strategic decision (the batch-vs-release-now `ask_user` gate in the ship stage), not mechanical oversight.
|
|
75
|
+
|
|
76
|
+
### Diagnostic details
|
|
77
|
+
|
|
78
|
+
- **Model-performance correlation** — Planning and TDD ran on `claude-sonnet-4-6` (appropriate); the pre-completion reviewer subagent ran on its frontmatter default; the final retro ran on `claude-opus-4-8` (appropriate for judgment-heavy synthesis).
|
|
79
|
+
The **ship stage ran on `opencode-go/deepseek-v4-flash`** — a weak model driving release management (interpreting the `UNSTABLE` merge state, the batch-vs-release decision, merging the release PR, closing the issue).
|
|
80
|
+
It executed cleanly this time, but these are irreversible high-stakes operations; pinning a stronger model for `/ship-issue` would de-risk them.
|
|
81
|
+
- **Escalation-delay tracking** — no rabbit-holes; each lint failure resolved within 1–2 tool calls.
|
|
82
|
+
- **Unused-tool detection** — exploration used `cat`/`grep` via `bash` rather than the `Read` tool or `colgrep`; for finding exact `.promise =` write sites, grep is the correct choice (exact-pattern matching), so colgrep non-use was appropriate.
|
|
83
|
+
The `cat`-via-`bash` habit (vs `Read`) added no harm here but bypasses structured-read benefits.
|
|
84
|
+
- **Feedback-loop gap analysis** — verification was incremental: affected-file tests after the first edits (turn 81), full suite + `check` + `lint` mid-cycle (turns 102–113), and the `fallow dead-code` gate before review.
|
|
85
|
+
No end-only-verification gap.
|
|
86
|
+
|
|
87
|
+
### Follow-ups
|
|
88
|
+
|
|
89
|
+
1. The `start()` / limiter promise-ownership split was reclassified as a regression and **fixed** via `scheduleVia` (see the second retrospective stage), not deferred.
|
|
90
|
+
|
|
91
|
+
### Considered but not proposed
|
|
92
|
+
|
|
93
|
+
1. **Floating-promise ESLint rule** (proposed, then retracted on operator pushback): codifying "replace the assignment with `void record.start()`" as a `code-design` idiom would train reflexive lint suppression without checking the always-resolves contract.
|
|
94
|
+
The `void` is correct here but signals unfinished domain work; the lesson lives in this retro, not in the skill.
|
|
95
|
+
2. **Pin a stronger model for `/ship-issue`**: an operator model-selection choice, not an `AGENTS.md`/prompt rule; noted in Diagnostic details for awareness.
|
|
96
|
+
|
|
97
|
+
### Changes made
|
|
98
|
+
|
|
99
|
+
1. `packages/pi-subagents/docs/retro/0374-encapsulate-subagent-start-notification.md` — added the Final Retrospective stage entry (session summary, friction points, diagnostic lenses, follow-ups); no skill or prompt edits landed (the sole proposal was retracted on operator pushback).
|
|
100
|
+
|
|
101
|
+
## Stage: Regression Correction — Process Retrospective (2026-06-14T18:00:00Z)
|
|
102
|
+
|
|
103
|
+
### Session summary
|
|
104
|
+
|
|
105
|
+
Operator pushback on the first retro's "`void` is safe, defer it" framing surfaced that #374 had **regressed a sibling step's invariant**: Step 1 (#381) guaranteed "every spawned agent has a `promise` at spawn," and #374's `void limiter.schedule(() => record.start())` made a queued agent's promise lazy.
|
|
106
|
+
Fixed via `Subagent.scheduleVia` (eager capture, control inverted so no external `.promise =` write returns) in commit `4f08c6c3` (+1 regression test, +2 unit tests; suite 981 → 982), then ran this process retrospective on how the regression slipped through plan, implementation, and review.
|
|
107
|
+
|
|
108
|
+
### How the regression happened (root-cause chain)
|
|
109
|
+
|
|
110
|
+
1. **Planning blind spot.**
|
|
111
|
+
The #374 plan's acceptance criterion was grep-verifiable encapsulation (`\.promise =` only in `subagent.ts`).
|
|
112
|
+
That measured the *goal* (hide the field) but never the *invariant at risk* (the field is an awaitable handle with an at-spawn timing contract that #381 established).
|
|
113
|
+
The plan treated `promise` as a field to hide, not as a contract to preserve.
|
|
114
|
+
2. **Implementation masked the semantics.**
|
|
115
|
+
Converting `record.promise = limiter.schedule(...)` to a bare `limiter.schedule(() => record.start())` tripped `no-floating-promises`; the reflexive `void` fix silenced the lint *and* discarded the eager handle in the same stroke.
|
|
116
|
+
The lint fix was the exact site of the behavior change, which made it feel mechanical rather than semantic.
|
|
117
|
+
3. **No executable guard.**
|
|
118
|
+
The #381 invariant lived only in an architecture-doc "Outcome:" bullet (prose).
|
|
119
|
+
No test pinned "a queued agent has a `promise` at spawn," so the full suite stayed green through the regression.
|
|
120
|
+
4. **Review inherited the blind spot.**
|
|
121
|
+
The pre-completion reviewer checks deterministic gates + the plan's acceptance criteria; since the criteria never named the cross-step invariant, criteria-driven review could not flag its loss.
|
|
122
|
+
|
|
123
|
+
The through-line: in a phased refactor, each step's "Outcome:" bullets establish invariants later steps inherit implicitly, and nothing converts those prose invariants into executable guards — so a later step regresses an earlier one with a green suite and a passing review.
|
|
124
|
+
|
|
125
|
+
### Observations
|
|
126
|
+
|
|
127
|
+
#### What caused friction (agent side)
|
|
128
|
+
|
|
129
|
+
- `missing-context` — the plan did not enumerate the invariants that prior Phase 17 steps had established on the shared `Subagent`/limiter surface, so the at-spawn-promise contract was invisible during both planning and implementation.
|
|
130
|
+
Impact: a shipped regression (latent — `waitForAll` re-polls — but a real invariant break), caught only by operator pushback during the retro, requiring a follow-up `fix:`.
|
|
131
|
+
- `wrong-abstraction` — the proximate trigger was treating a `void` lint fix as mechanical.
|
|
132
|
+
`void` on a promise-returning call discards whatever the promise carried (here: the eager capture handle); it deserves a semantic check, not a reflex.
|
|
133
|
+
|
|
134
|
+
#### What went well
|
|
135
|
+
|
|
136
|
+
- Operator pushback ("I'm sure that rule exists for a reason… are we heading toward a better design, or an awkward intermediary state?") was the single intervention that converted a rationalized smell into a found regression.
|
|
137
|
+
This is the bidirectional-feedback ideal: a redirecting question, not a correction, that reframed the agent's own analysis.
|
|
138
|
+
|
|
139
|
+
### Diagnostic details
|
|
140
|
+
|
|
141
|
+
- **Feedback-loop gap analysis** — every gate (check, lint, test, fallow, pre-completion review) was green across #374; none could see the regression because the invariant was prose, never a test.
|
|
142
|
+
The gap is upstream of the gates: the invariant was never made executable.
|
|
143
|
+
|
|
144
|
+
### Diagnostic details — model assignment
|
|
145
|
+
|
|
146
|
+
- Operator pushback also corrected a misconception: planning was assumed to run on Opus, but session turns 2–45 (`/plan-issue`) ran on `claude-sonnet-4-6`, and `.pi/prompts/plan-issue.md` had **no `model:` directive** — so planning silently inherited the session model.
|
|
147
|
+
The judgment-heaviest, highest-leverage stage (where this regression originated) was running on an inherited, weaker model by default.
|
|
148
|
+
Resolved by pinning `/plan-issue` and `/retro` to Opus via frontmatter (the `pi-prompt-template-model` extension was already loaded but unused for model selection).
|
|
149
|
+
Caveat recorded: a stronger planner raises the odds of noticing an unstated invariant but is not a substitute for the explicit rule — the rule is the dependable fix, the model is a complementary lever.
|
|
150
|
+
|
|
151
|
+
### Proposals (all accepted and implemented)
|
|
152
|
+
|
|
153
|
+
1. `/plan-issue` prompt — "Invariants at risk" plan section: list prior phase steps' documented invariants (roadmap `Outcome:`/`Landed:` bullets) and pin each with a named test.
|
|
154
|
+
2. `code-design` skill (ESLint section) — "void on a promise-returning call" guard: before `void`-ing to silence `no-floating-promises`, confirm the discarded promise carried no semantics.
|
|
155
|
+
3. `pre-completion-reviewer` agent — new section `2h. Cross-step invariant preservation`: FAIL on a regressed prior-step invariant, WARN when an invariant holds but is pinned only by prose.
|
|
156
|
+
4. Model pinning — `/plan-issue` and `/retro` pinned to `anthropic/claude-opus-4-8`.
|
|
157
|
+
|
|
158
|
+
### Changes made
|
|
159
|
+
|
|
160
|
+
1. `packages/pi-subagents/src/lifecycle/subagent.ts` — added `scheduleVia(schedule)` (eager limiter-promise capture) and `guardedRun()` (shared abort-while-queued guard); `start()` now returns `void`.
|
|
161
|
+
2. `packages/pi-subagents/src/lifecycle/subagent-manager.ts` — `spawn()` queued path uses `record.scheduleVia(...)`; removed the `void` workarounds.
|
|
162
|
+
3. `packages/pi-subagents/test/lifecycle/subagent-manager.test.ts` — added regression test (queued agent has a `promise` at spawn).
|
|
163
|
+
4. `packages/pi-subagents/test/lifecycle/subagent.test.ts` — rewrote `start()` tests for the `void` return; added two `scheduleVia` unit tests.
|
|
164
|
+
5. `packages/pi-subagents/test/helpers/make-subagent.test.ts`, `packages/pi-subagents/test/tools/get-result-tool.test.ts` — updated for the `void`-returning `start()`.
|
|
165
|
+
6. `packages/pi-subagents/docs/architecture/architecture.md` — Step 3 `Landed`/`Correction` notes record the regression and `scheduleVia` fix.
|
|
166
|
+
7. `.pi/prompts/plan-issue.md` — added the "Invariants at risk" section and pinned `model: anthropic/claude-opus-4-8`.
|
|
167
|
+
8. `.pi/prompts/retro.md` — pinned `model: anthropic/claude-opus-4-8`.
|
|
168
|
+
9. `.pi/skills/code-design/SKILL.md` — added the "void on a promise-returning call" ESLint guard.
|
|
169
|
+
10. `.pi/agents/pre-completion-reviewer.md` — added section `2h`, its output block, and severity-model entries.
|
|
170
|
+
11. `AGENTS.md` — added cross-step invariant preservation to the reviewer's documented coverage.
|
|
171
|
+
12. The regression fix landed in commit `4f08c6c3` (`fix:`); these retro/process changes land in the `docs(retro):` commit.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 375
|
|
3
|
+
issue_title: "Extract run-listener and workspace-bracket collaborators from Subagent"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #375 — Extract run-listener and workspace-bracket collaborators from Subagent
|
|
7
|
+
|
|
8
|
+
## Stage: Planning (2026-06-14T19:25:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Read issue #375 (Phase 17 Step 4 — core consolidation), loaded the package, code-design, design-review, testing, colgrep, and markdown skills, and explored `subagent.ts`, `workspace.ts`, `subagent-manager.ts`, and `subagent.test.ts`.
|
|
13
|
+
Produced a 4-step plan in `packages/pi-subagents/docs/plans/0375-extract-run-listener-workspace-bracket.md` extracting a `RunListeners` collaborator and a `WorkspaceBracket` collaborator out of the 488-LOC `Subagent` class.
|
|
14
|
+
|
|
15
|
+
### Observations
|
|
16
|
+
|
|
17
|
+
- The issue's first-cut `attach(unsub, detach)` sketch does not match the real call pattern: `wireSignal` fires at run-start and `attachObserver` after session creation, and `resume()` only attaches the observer — so `RunListeners` exposes the two attach points separately (`wireSignal` / `attachObserver` / `release`), not a single combined `attach`.
|
|
18
|
+
- The issue's "three dispose paths" is really **two** `dispose()` call sites (`completeRun`, `failRun`); `run()`'s prepare-failure catch has no prepared workspace to dispose.
|
|
19
|
+
- The two dispose sites have genuinely different lifecycle semantics — `completeRun` derives status from the result, folds the addendum, and lets a throw propagate; `failRun` hardcodes `"error"`, discards the addendum, and is best-effort `try/catch`.
|
|
20
|
+
Per the code-design structural-duplication heuristic, I kept them separate: `WorkspaceBracket.dispose()` centralizes the *logic* (the `if (prepared)` guard + addendum unwrap) in one place but deliberately does **not** wrap `try/catch`, so each caller's error handling is preserved line-for-line.
|
|
21
|
+
This honestly satisfies the issue's "disposal logic in exactly one place" without forcing a discriminator parameter.
|
|
22
|
+
- `WorkspaceBracket` captures the provider *resolver* (`execution.getWorkspaceProvider`), not the provider, so resolution stays at run-start — matching today's `getWorkspaceProvider?.()` timing — while letting the bracket be constructed in the `Subagent` constructor (construct-complete, preserving the Step 2 invariant).
|
|
23
|
+
- Per the #374 retro lesson, I added an "Invariants at risk" section: the three prior Phase 17 invariants (at-spawn `promise`, construct-complete, zero external field writes) are each already pinned by a named test and are low-risk here because this step does not touch `start`/`scheduleVia`/`_promise` or add optional init fields.
|
|
24
|
+
- Step 3 (wiring) must be atomic: removing the public `wireSignal`/`attachObserver`/`releaseListeners` methods breaks `subagent.test.ts` at the type level, so the `describe`-block deletions land in the same commit.
|
|
25
|
+
- Suite is at 982 tests (verified by running the suite); expect roughly +5 net (≈ −7 redundant `Subagent` listener tests, +6 each new collaborator suite).
|
|
26
|
+
- First-party issue (author `gotgenes` == gh user) with an unambiguous proposed change, so the `ask-user` gate was skipped.
|
|
27
|
+
- Commit types are `test:`/`refactor:`/`docs:` — internal-only, no release-please bump; release cadence is a ship-time decision flagged in Risks.
|
|
28
|
+
|
|
29
|
+
## Stage: Implementation — TDD (2026-06-14T21:40:00Z)
|
|
30
|
+
|
|
31
|
+
### Session summary
|
|
32
|
+
|
|
33
|
+
Implemented all 4 plan steps in 4 commits (2 `refactor:`, 1 additional `refactor:` for the wiring step, 1 `docs:`).
|
|
34
|
+
Test count went from 982 to 994 (+12: 7 `RunListeners` tests + 13 `WorkspaceBracket` tests − 8 redundant `Subagent` listener tests removed).
|
|
35
|
+
`subagent.ts` landed at 448 LOC (target was ≤ 450).
|
|
36
|
+
Pre-completion reviewer returned WARN (2 non-blocking findings; the doc metric rows were fixed inline before committing).
|
|
37
|
+
|
|
38
|
+
### Observations
|
|
39
|
+
|
|
40
|
+
- **Microtask-boundary deviation from plan**: the plan showed `const cwd = await this.workspaceBracket.prepare(...)` unconditionally in `run()`.
|
|
41
|
+
`async` functions always create a microtask boundary even when they return immediately (no-provider path), which broke `subagent-manager.test.ts`'s synchronous assertion that `factory.toHaveBeenCalledOnce()` — the factory call had been deferred to the next microtask tick.
|
|
42
|
+
Fix: added `WorkspaceBracket.hasProvider()` (a synchronous provider-existence check) and guarded the `await` with `if (this.workspaceBracket.hasProvider())`, restoring the original timing semantics.
|
|
43
|
+
The `hasProvider()` method is a mild Tell-Don't-Ask trade-off (the caller queries bracket state to decide whether to call it), documented at the call site and noted as a WARN by the pre-completion reviewer.
|
|
44
|
+
The underlying cause: `SubagentManager.spawn()` always injects `getWorkspaceProvider: () => this._workspaceProvider` as a function, even when no provider is registered, so the naïve `if (this.execution.getWorkspaceProvider)` guard was always true.
|
|
45
|
+
- **Step 3 collapsed into one commit as expected**: removing `wireSignal`/`attachObserver`/`releaseListeners` from `Subagent` broke `subagent.test.ts` at the type level; the redundant `describe` block deletions and the production wiring landed atomically.
|
|
46
|
+
- **LOC target met**: `subagent.ts` went from 488 → 448 (plan estimated ≤ 450; actual 448).
|
|
47
|
+
The gap between estimated removal (≈ 40 lines) and actual (40 lines) was closed by trimming the stale module-level doc comment and redundant field-level comments.
|
|
48
|
+
- **Prior-step invariants held**: all three Phase 17 cross-step invariants (at-spawn `promise`, construct-complete, zero external field writes) passed grep-verification and the 994-test suite confirms no regressions.
|
|
49
|
+
- **Pre-completion reviewer WARN findings** (both addressed inline):
|
|
50
|
+
1. `architecture.md` health-metric rows still carried "→ 59 after Step 4" annotations after landing — updated to actual counts (60 files, 8,356 LOC) and the docs commit amended.
|
|
51
|
+
2. `WorkspaceBracket.hasProvider()` TDA trade-off — documented in the `run()` call-site comment; noted here for Phase 18 awareness.
|
|
52
|
+
|
|
53
|
+
## Stage: Final Retrospective (2026-06-15T02:04:42Z)
|
|
54
|
+
|
|
55
|
+
### Session summary
|
|
56
|
+
|
|
57
|
+
Shipped issue #375 (Phase 17 Step 4) cleanly across four stages in one continuous session: planning produced a 4-step plan, TDD implemented it in 4 commits (suite 982 → 994), shipping pushed/verified-CI/closed-the-issue and merged release-please PR #406 (`pi-subagents-v16.2.1`, which actually carried #374's `fix:` — #375's `refactor:`/`docs:` commits trigger no bump).
|
|
58
|
+
The only substantive friction was a single self-identified plan deviation in TDD (a microtask-boundary timing trap), resolved inside the same commit with no rework, reorder, or user correction.
|
|
59
|
+
|
|
60
|
+
### Observations
|
|
61
|
+
|
|
62
|
+
#### What went well
|
|
63
|
+
|
|
64
|
+
- **The cross-step-invariant discipline from the #374 retro paid off a second time.**
|
|
65
|
+
The #374 process retro added the "Invariants at risk" plan section; the #375 plan used it to list the three prior Phase 17 invariants (at-spawn `promise`, construct-complete, zero external field writes), each already pinned by a named test.
|
|
66
|
+
All three held through the extraction with a green suite — the regression class that bit #374 did not recur.
|
|
67
|
+
This is the first time the new section was load-bearing on a *fresh* issue rather than as a post-hoc correction.
|
|
68
|
+
- **Planning corrected the issue's own design sketch instead of implementing it literally.**
|
|
69
|
+
The issue proposed `RunListeners.attach(unsub, detach)` and "three dispose paths collapse into one"; the plan recognized the two handles attach at different lifecycle moments (so `attach` had to split into `wireSignal`/`attachObserver`/`release`) and that the two dispose sites have genuinely different error-handling semantics (so they stay separate per the structural-duplication heuristic).
|
|
70
|
+
Treating the issue body as a hypothesis, not a spec, avoided a wrong abstraction.
|
|
71
|
+
|
|
72
|
+
#### What caused friction (agent side)
|
|
73
|
+
|
|
74
|
+
- `missing-context` — the plan's Design Overview sketched `const cwd = await this.workspaceBracket.prepare(...)` **unconditionally**, but the original `run()` only awaited inside `if (provider) { ... }`, keeping the no-provider path synchronous up to the factory call.
|
|
75
|
+
An always-`async` helper adds a microtask boundary even when it returns immediately, so the queued-abort test in `subagent-manager.test.ts` ("abort removes a queued agent without ever running it") failed: it asserts `factory.toHaveBeenCalledOnce()` synchronously, and the factory call had been deferred a tick.
|
|
76
|
+
The first fix attempt (`if (this.execution.getWorkspaceProvider)`) also failed because `SubagentManager.spawn()` always injects `getWorkspaceProvider: () => this._workspaceProvider` as a function regardless of whether a provider is registered, so the guard was always true.
|
|
77
|
+
Resolved by adding `WorkspaceBracket.hasProvider()` (a synchronous predicate) and guarding the `await` with it.
|
|
78
|
+
Impact: ~2 test-run cycles inside TDD step 3, one extra method (`hasProvider`) plus 2 unit tests not in the plan, and a mild Tell-Don't-Ask trade-off the pre-completion reviewer flagged as WARN.
|
|
79
|
+
Self-identified via the failing test; no commit reorder, no rework beyond the in-commit fix.
|
|
80
|
+
- `other` (minor) — `subagent.ts` first landed at 469 LOC, above the plan's ≤ 450 gate; two trim passes (stale module-level doc comment, redundant field comments) brought it to 448.
|
|
81
|
+
The trimmed comments were genuinely stale post-extraction, so the trim was legitimate, but the LOC estimate ("≈ 40 lines removed") was optimistic about the structural change alone.
|
|
82
|
+
Impact: 2 extra edits, no rework.
|
|
83
|
+
|
|
84
|
+
#### What caused friction (user side)
|
|
85
|
+
|
|
86
|
+
- None.
|
|
87
|
+
The four-stage flow ran end-to-end on prompt templates with zero mid-stream user corrections or strategic interventions — the work was clean enough that none were needed.
|
|
88
|
+
|
|
89
|
+
### Diagnostic details
|
|
90
|
+
|
|
91
|
+
- **Model-performance correlation** — Planning ran on `anthropic/claude-opus-4-8` (pinned via `plan-issue.md`, appropriate for judgment-heavy design); TDD on `anthropic/claude-sonnet-4-6` (appropriate); the pre-completion reviewer subagent on its frontmatter default; Retro on `anthropic/claude-opus-4-8` (pinned).
|
|
92
|
+
The **ship stage ran on `opencode-go/deepseek-v4-flash`** — the same weak-model-on-release-management pattern flagged in the #374 retro.
|
|
93
|
+
It again executed cleanly, including the non-trivial reasoning that PR #406 mapped to #374 not #375 and the `UNSTABLE`/empty-rollup `GITHUB_TOKEN` diagnosis.
|
|
94
|
+
Second consecutive clean run, so the risk remains theoretical (irreversible ops on a weak model) rather than demonstrated harm.
|
|
95
|
+
- **Escalation-delay tracking** — the microtask friction was not a rabbit hole: the diagnosis moved methodically (failing test → `concurrency-limiter.ts` → `subagent-manager.ts:156` injection point → root cause) across ~2 test-run cycles, under the 5-call flag threshold.
|
|
96
|
+
- **Unused-tool detection** — the friction was exact-symbol tracing (`grep` for `getWorkspaceProvider`), which `grep` handled correctly; no Explore/colgrep dispatch was warranted.
|
|
97
|
+
- **Feedback-loop gap analysis** — verification was incremental throughout: per-file `vitest` after each red/green, `pnpm run check` after the interface-change step, full suite + lint + `fallow dead-code` before the pre-completion review.
|
|
98
|
+
No end-only-verification gap.
|
|
99
|
+
|
|
100
|
+
### Changes made
|
|
101
|
+
|
|
102
|
+
1. `.pi/skills/testing/SKILL.md` — added a bullet under `### Interface and type changes` on the conditional-`await` → always-`async` microtask-boundary trap (sibling to the existing runtime-vs-typecheck timing rule).
|
|
103
|
+
2. `packages/pi-subagents/docs/retro/0375-extract-run-listener-workspace-bracket.md` — added this Final Retrospective stage entry.
|
|
104
|
+
3. Considered but not landed (operator-declined or out of scope): pinning `/ship-issue` to a stronger model (recurrence of the #374 finding, no demonstrated harm this session), a `plan-issue` rule on preserving conditional awaits in extracted-method sketches, and a rule against trimming comments to hit a LOC gate.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 376
|
|
3
|
+
issue_title: "Extract the manager observer from index.ts into a class"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #376 — Extract the manager observer from index.ts into a class
|
|
7
|
+
|
|
8
|
+
## Stage: Planning (2026-06-15T00:00:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Planned Phase 17 Step 5: extracting the inline `SubagentManagerObserver` literal from `index.ts` into a `SubagentEventsObserver` class under `src/observation/`, constructed with narrow `emit` / `appendEntry` / `NotificationSystem` deps.
|
|
13
|
+
The plan is a single red→green→commit extraction (class + tests + `index.ts` wiring in one commit) plus a docs commit marking the step complete.
|
|
14
|
+
Classified as a non-breaking, pure internal extraction with no observable behavior change.
|
|
15
|
+
|
|
16
|
+
### Observations
|
|
17
|
+
|
|
18
|
+
- The issue is the operator's own (author `gotgenes` matches the gh user) and the architecture doc already specifies Step 5 precisely, so the `ask-user` gate was skipped.
|
|
19
|
+
- The class + `index.ts` swap must land in one commit: `index.ts` is the sole call site of the literal being replaced, and the new class needs a consumer to satisfy `pnpm fallow dead-code`.
|
|
20
|
+
- Kept `buildEventData` in `notification.ts` (it is tested there) and imported it into the new module — avoids churning `notification.test.ts`.
|
|
21
|
+
- Used `refactor:` for the extraction commit, matching the precedent of Phase 17 Step 4 (#375); `refactor` is hidden from the release-please changelog.
|
|
22
|
+
- Two structural smells were noted as out of scope: the `record.notification?.resultConsumed` Law-of-Demeter chain (track-and-watch) and narrowing `NotificationSystem` to a two-method `CompletionNotifier` per ISP (the issue prescribes passing `NotificationSystem`).
|
|
23
|
+
- Wiring `pi.events.emit` / `pi.appendEntry` as arrow callbacks in `index.ts` avoids the `@typescript-eslint/unbound-method` trap; mirrors the existing `SettingsManager` emit pattern.
|
|
24
|
+
- Step 6 (#377) depends on this step; the plan pins the previously-untested event/notification dispatch invariants so Step 6 cannot regress them.
|
|
25
|
+
|
|
26
|
+
## Stage: Implementation — TDD (2026-06-15T18:40:00Z)
|
|
27
|
+
|
|
28
|
+
### Session summary
|
|
29
|
+
|
|
30
|
+
Completed 2 TDD cycles: (1) extracted `SubagentEventsObserver` into `src/observation/subagent-events-observer.ts`, added 15 tests covering all four observer methods, and updated `src/index.ts` to replace the inline literal — all in one coupled commit; (2) marked Phase 17 Step 5 ✅ Complete in the architecture doc with a Landed note.
|
|
31
|
+
Test delta: 994 → 1009 (+15); file count 63 → 64 test files; `index.ts` 226 → 177 lines.
|
|
32
|
+
Pre-completion reviewer: PASS.
|
|
33
|
+
|
|
34
|
+
### Observations
|
|
35
|
+
|
|
36
|
+
- The typed `vi.fn<(channel: string, data: unknown) => void>()` mock triggered `@typescript-eslint/no-unnecessary-type-assertion` on `mock.calls[0]!` indexing — fixed by switching the error-status assertions to `toHaveBeenCalledWith("subagents:failed", expect.anything())` and the success-status assertion to `toHaveBeenCalledWith("subagents:completed", buildEventData(record))`.
|
|
37
|
+
This eliminated all raw `mock.calls[0]` indexing from the file, which is cleaner.
|
|
38
|
+
- The autoformatter (`pi-autoformat`) ran after writing `index.ts`, so the import block was reflowed; re-reading before further edits would be required in any follow-up session.
|
|
39
|
+
- Architecture doc: Step 4's header was already missing its ✅ Complete marker (it had a Landed note); the reviewer noted this was corrected correctly, not a regression.
|
|
40
|
+
- All five previously-untested observer-behavior invariants are now pinned by tests for the first time.
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -27,9 +27,10 @@ import { createChildLifecyclePublisher } from "#src/lifecycle/child-lifecycle";
|
|
|
27
27
|
import { ConcurrencyLimiter } from "#src/lifecycle/concurrency-limiter";
|
|
28
28
|
import { createSubagentSession, type SubagentSessionDeps } from "#src/lifecycle/create-subagent-session";
|
|
29
29
|
import { buildParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
30
|
-
import { SubagentManager
|
|
31
|
-
import {
|
|
30
|
+
import { SubagentManager } from "#src/lifecycle/subagent-manager";
|
|
31
|
+
import { type NotificationDetails, NotificationManager } from "#src/observation/notification";
|
|
32
32
|
import { createNotificationRenderer } from "#src/observation/renderer";
|
|
33
|
+
import { SubagentEventsObserver } from "#src/observation/subagent-events-observer";
|
|
33
34
|
import { createSubagentRuntime } from "#src/runtime";
|
|
34
35
|
import { publishSubagentsService, unpublishSubagentsService } from "#src/service/service";
|
|
35
36
|
import { SubagentsServiceAdapter } from "#src/service/service-adapter";
|
|
@@ -76,61 +77,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
76
77
|
settings.load();
|
|
77
78
|
|
|
78
79
|
// Observer: receives agent lifecycle notifications and dispatches events/notifications.
|
|
79
|
-
const observer
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
type: record.type,
|
|
85
|
-
description: record.description,
|
|
86
|
-
});
|
|
87
|
-
},
|
|
88
|
-
onSubagentCompleted(record) {
|
|
89
|
-
// Emit lifecycle event based on terminal status.
|
|
90
|
-
const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
|
|
91
|
-
const eventData = buildEventData(record);
|
|
92
|
-
if (isError) {
|
|
93
|
-
pi.events.emit("subagents:failed", eventData);
|
|
94
|
-
} else {
|
|
95
|
-
pi.events.emit("subagents:completed", eventData);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Persist final record for cross-extension history reconstruction.
|
|
99
|
-
pi.appendEntry("subagents:record", {
|
|
100
|
-
id: record.id, type: record.type, description: record.description,
|
|
101
|
-
status: record.status, result: record.result, error: record.error,
|
|
102
|
-
startedAt: record.startedAt, completedAt: record.completedAt,
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
// Skip notification if result was already consumed via get_subagent_result.
|
|
106
|
-
if (record.notification?.resultConsumed) {
|
|
107
|
-
notifications.cleanupCompleted(record.id);
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
notifications.sendCompletion(record);
|
|
112
|
-
},
|
|
113
|
-
onSubagentCompacted(record, info) {
|
|
114
|
-
// Emit compacted event when agent's session compacts (preserves count on record).
|
|
115
|
-
pi.events.emit("subagents:compacted", {
|
|
116
|
-
id: record.id,
|
|
117
|
-
type: record.type,
|
|
118
|
-
description: record.description,
|
|
119
|
-
reason: info.reason,
|
|
120
|
-
tokensBefore: info.tokensBefore,
|
|
121
|
-
compactionCount: record.compactionCount,
|
|
122
|
-
});
|
|
123
|
-
},
|
|
124
|
-
onSubagentCreated(record) {
|
|
125
|
-
// Emit created event for background agents (before limiter admission).
|
|
126
|
-
pi.events.emit("subagents:created", {
|
|
127
|
-
id: record.id,
|
|
128
|
-
type: record.type,
|
|
129
|
-
description: record.description,
|
|
130
|
-
isBackground: true,
|
|
131
|
-
});
|
|
132
|
-
},
|
|
133
|
-
};
|
|
80
|
+
const observer = new SubagentEventsObserver({
|
|
81
|
+
emit: (channel, data) => pi.events.emit(channel, data),
|
|
82
|
+
appendEntry: (customType, data) => pi.appendEntry(customType, data),
|
|
83
|
+
notifications,
|
|
84
|
+
});
|
|
134
85
|
|
|
135
86
|
const subagentSessionDeps: SubagentSessionDeps = {
|
|
136
87
|
io: {
|