@gotgenes/pi-subagents 6.6.0 → 6.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/docs/architecture/architecture.md +36 -34
- package/docs/plans/0110-agent-activity-tracker.md +297 -0
- package/docs/plans/0111-split-agent-record-lifecycle.md +582 -0
- package/docs/retro/0110-agent-activity-tracker.md +44 -0
- package/docs/retro/0118-settings-manager-apply-methods.md +40 -0
- package/package.json +1 -1
- package/src/agent-manager.ts +41 -21
- package/src/agent-record.ts +45 -34
- package/src/execution-state.ts +17 -0
- package/src/index.ts +2 -1
- package/src/notification-state.ts +27 -0
- package/src/notification.ts +12 -9
- package/src/record-observer.ts +6 -7
- package/src/runtime.ts +3 -2
- package/src/service-adapter.ts +8 -7
- package/src/tools/agent-tool.ts +13 -24
- package/src/tools/get-result-tool.ts +7 -5
- package/src/tools/steer-tool.ts +8 -6
- package/src/ui/agent-activity-tracker.ts +108 -0
- package/src/ui/agent-menu.ts +4 -4
- package/src/ui/agent-widget.ts +4 -17
- package/src/ui/conversation-viewer.ts +3 -3
- package/src/ui/ui-observer.ts +16 -23
- package/src/worktree-state.ts +35 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 111
|
|
3
|
+
issue_title: "refactor(pi-subagents): split AgentRecord lifecycle state into phase-specific objects"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Split AgentRecord lifecycle state into phase-specific objects
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
`AgentRecord` is constructed in `AgentManager.spawn()` before most of its state exists, then mutated across 4 files as information trickles in.
|
|
11
|
+
The class header documents 12 "non-transition mutable state" public fields written by `agent-manager.ts`, `service-adapter.ts`, `tools/agent-tool.ts`, and `tools/get-result-tool.ts`.
|
|
12
|
+
|
|
13
|
+
Post-construction mutation is an output-argument pattern at the object level — external code stuffs fields into a collaborator it received.
|
|
14
|
+
The fix is not setter methods; it is splitting along lifecycle boundaries so each object is born complete at the moment its information becomes available.
|
|
15
|
+
|
|
16
|
+
This is Phase 7, Step B in `docs/architecture/architecture.md`.
|
|
17
|
+
|
|
18
|
+
## Goals
|
|
19
|
+
|
|
20
|
+
- Split `AgentRecord`'s non-transition mutable state into phase-specific collaborators, each born complete.
|
|
21
|
+
- Introduce `ExecutionState` (session, promise, outputFile) — constructed once when the runner creates the session.
|
|
22
|
+
- Introduce `WorktreeState` (worktree info, cleanup result) — constructed once when isolation is set up; only exists for worktree agents.
|
|
23
|
+
- Introduce `NotificationState` (toolCallId, resultConsumed) — constructed once when agent-tool assigns the tool call ID.
|
|
24
|
+
- Move `pendingSteers` to a `Map<string, string[]>` on `AgentManager`, where the steer buffering is actually coordinated.
|
|
25
|
+
- Reduce `AgentRecordInit` from 19 optional fields to ~7 construction-time fields.
|
|
26
|
+
- Eliminate all post-construction field writes from external code.
|
|
27
|
+
- Preserve all existing behavior — this is a pure encapsulation refactor.
|
|
28
|
+
|
|
29
|
+
## Non-Goals
|
|
30
|
+
|
|
31
|
+
- Replacing `AgentManager` callbacks with an observer (#112) — deferred to Step C.
|
|
32
|
+
- Disambiguating `SpawnOptions` (#113) — deferred to Step D1.
|
|
33
|
+
- Narrowing `AgentToolDeps` or `AgentMenuDeps` (#114) — deferred to Step D2.
|
|
34
|
+
- Converting `createNotificationSystem` to a class (#116) — deferred to Step E2.
|
|
35
|
+
- Splitting `agent-tool.ts` foreground/background (#115) — deferred to Step E1.
|
|
36
|
+
|
|
37
|
+
## Background
|
|
38
|
+
|
|
39
|
+
### Who writes non-transition fields today
|
|
40
|
+
|
|
41
|
+
| Field | Written by | When |
|
|
42
|
+
| ----------------- | --------------------------------------------------------- | ------------------------------------------------- |
|
|
43
|
+
| `session` | `agent-manager.ts` (onSessionCreated, completion .then) | Session created; run completes |
|
|
44
|
+
| `outputFile` | `agent-manager.ts` (onSessionCreated, completion .then) | Session created; run completes |
|
|
45
|
+
| `promise` | `agent-manager.ts` (startAgent) | After runner.run() call |
|
|
46
|
+
| `worktree` | `agent-manager.ts` (startAgent) | Before run, if isolation=worktree |
|
|
47
|
+
| `worktreeResult` | `agent-manager.ts` (.then, .catch) | Completion/error |
|
|
48
|
+
| `pendingSteers` | `steer-tool.ts`, `service-adapter.ts`, `agent-manager.ts` | Queued before session; flushed on session created |
|
|
49
|
+
| `toolCallId` | `tools/agent-tool.ts` | After spawn, for background agents |
|
|
50
|
+
| `resultConsumed` | `tools/get-result-tool.ts` | When parent reads result |
|
|
51
|
+
| `abortController` | constructor only | At spawn |
|
|
52
|
+
| `toolUses` | `record-observer.ts` | On tool_execution_end event |
|
|
53
|
+
| `lifetimeUsage` | `record-observer.ts` | On message_end event |
|
|
54
|
+
| `compactionCount` | `record-observer.ts` | On compaction_end event |
|
|
55
|
+
|
|
56
|
+
### Who reads non-transition fields
|
|
57
|
+
|
|
58
|
+
| Field | Read by |
|
|
59
|
+
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
60
|
+
| `session` | `agent-manager.ts` (resume, abort, cleanup), `service-adapter.ts` (steer), `steer-tool.ts`, `get-result-tool.ts`, `notification.ts`, `ui/agent-menu.ts`, `ui/conversation-viewer.ts` |
|
|
61
|
+
| `promise` | `agent-manager.ts` (spawnAndWait, waitForAll), `get-result-tool.ts` (wait mode) |
|
|
62
|
+
| `outputFile` | `agent-tool.ts`, `notification.ts` |
|
|
63
|
+
| `worktree` | `agent-manager.ts` (cleanup on completion/error) |
|
|
64
|
+
| `worktreeResult` | `service-adapter.ts` (toSubagentRecord) |
|
|
65
|
+
| `toolCallId` | `notification.ts` |
|
|
66
|
+
| `resultConsumed` | `index.ts` (onComplete), `notification.ts` |
|
|
67
|
+
| `pendingSteers` | `agent-manager.ts` (flush), `steer-tool.ts`, `service-adapter.ts` |
|
|
68
|
+
| `abortController` | `agent-manager.ts` (abort, abortAll, signal passing) |
|
|
69
|
+
| `toolUses`, `lifetimeUsage`, `compactionCount` | `notification.ts`, `service-adapter.ts`, `get-result-tool.ts`, `steer-tool.ts`, `agent-tool.ts`, `index.ts`, `ui/conversation-viewer.ts` |
|
|
70
|
+
|
|
71
|
+
### Dependency on #110
|
|
72
|
+
|
|
73
|
+
Issue #110 (AgentActivityTracker) is implemented and merged.
|
|
74
|
+
The `AgentActivityTracker` class established the encapsulation pattern this issue follows: transition methods for writes, read-only accessors for reads.
|
|
75
|
+
|
|
76
|
+
### AGENTS.md constraints
|
|
77
|
+
|
|
78
|
+
- Design principle 8 (construct complete): objects should be born ready-to-go.
|
|
79
|
+
- Output arguments: do not write back into a received dependency bag.
|
|
80
|
+
- One concern per file: each new collaborator gets its own module.
|
|
81
|
+
- Avoid `any`: use typed accessors.
|
|
82
|
+
- Lift-and-shift for large test migrations: introduce new alongside old, migrate incrementally.
|
|
83
|
+
|
|
84
|
+
## Design Overview
|
|
85
|
+
|
|
86
|
+
### Lifecycle phases and their objects
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
spawn() → AgentRecord (identity + status + stats + abortController)
|
|
90
|
+
↓ worktree setup → WorktreeState (path, branch) — optional, only for isolation=worktree
|
|
91
|
+
↓ runner creates session → ExecutionState (session, promise, outputFile)
|
|
92
|
+
↓ agent-tool sets ID → NotificationState (toolCallId, resultConsumed)
|
|
93
|
+
↓ steer before session → pendingSteers Map on AgentManager
|
|
94
|
+
↓ run completes → WorktreeState gains cleanupResult (immutable replacement)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### ExecutionState
|
|
98
|
+
|
|
99
|
+
New file: `src/execution-state.ts`.
|
|
100
|
+
|
|
101
|
+
Born when `onSessionCreated` fires inside `startAgent()`.
|
|
102
|
+
Contains the session, promise, and output file — the three fields that only exist once the runner creates the session.
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
export interface ExecutionState {
|
|
106
|
+
readonly session: AgentSession;
|
|
107
|
+
readonly outputFile: string | undefined;
|
|
108
|
+
readonly promise: Promise<string>;
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
This is a plain interface (not a class) because all fields are set at construction and never mutated.
|
|
113
|
+
The `promise` is set immediately after `runner.run()` returns, but the `session` and `outputFile` are known inside the `onSessionCreated` callback.
|
|
114
|
+
Because these become available at slightly different moments (session in callback, promise from `runner.run()` return), a two-phase construction is needed:
|
|
115
|
+
|
|
116
|
+
1. Inside `onSessionCreated`, capture `session` and `outputFile` in local variables.
|
|
117
|
+
2. After `runner.run()` returns the promise, construct `ExecutionState` with all three and attach it to the record.
|
|
118
|
+
|
|
119
|
+
However, the promise wraps the entire run — it resolves when the agent completes.
|
|
120
|
+
The `onSessionCreated` callback fires during the run.
|
|
121
|
+
So the timeline is: `runner.run()` is called → internally, session is created → `onSessionCreated` fires → run continues → promise resolves.
|
|
122
|
+
The promise is returned by `runner.run()`, so it exists before `onSessionCreated` fires only as a `Promise` variable.
|
|
123
|
+
|
|
124
|
+
In practice, we need `Promise.withResolvers()` to construct the promise shell first, then resolve it when the run completes.
|
|
125
|
+
The `ExecutionState` can then be constructed inside `onSessionCreated`:
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
const { promise, resolve } = Promise.withResolvers<string>();
|
|
129
|
+
|
|
130
|
+
// Inside onSessionCreated:
|
|
131
|
+
record.execution = { session, outputFile, promise };
|
|
132
|
+
|
|
133
|
+
// runner.run() result wired to resolve:
|
|
134
|
+
runner.run(...).then(result => { resolve(result.responseText); ... });
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Wait — this changes the semantics.
|
|
138
|
+
Currently `record.promise` is the full `.then()` chain that includes worktree cleanup, status transitions, and notification.
|
|
139
|
+
Callers like `spawnAndWait` and `get-result-tool` await it to wait for the agent to fully complete (including post-processing).
|
|
140
|
+
|
|
141
|
+
To preserve this, `ExecutionState.promise` should remain the full chain promise (the one currently assigned as `record.promise`).
|
|
142
|
+
We construct the `ExecutionState` after `runner.run()` returns, using the session/outputFile captured in `onSessionCreated`:
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
let capturedSession: AgentSession | undefined;
|
|
146
|
+
let capturedOutputFile: string | undefined;
|
|
147
|
+
|
|
148
|
+
const runPromise = this.runner.run(snapshot, type, prompt, {
|
|
149
|
+
...
|
|
150
|
+
onSessionCreated: (session) => {
|
|
151
|
+
capturedSession = session;
|
|
152
|
+
capturedOutputFile = session.sessionManager?.getSessionFile?.();
|
|
153
|
+
// flush steers, subscribe observer, etc.
|
|
154
|
+
},
|
|
155
|
+
}).then(({ responseText, session, ... }) => {
|
|
156
|
+
// post-processing (worktree cleanup, status transitions)
|
|
157
|
+
// Update session/outputFile from completion if newer
|
|
158
|
+
...
|
|
159
|
+
return responseText;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
record.execution = {
|
|
163
|
+
session: capturedSession!, // guaranteed set before .then runs
|
|
164
|
+
outputFile: capturedOutputFile,
|
|
165
|
+
promise: runPromise,
|
|
166
|
+
};
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
This has a problem: `record.execution` is set synchronously after `runner.run()` is called, but `onSessionCreated` fires asynchronously during the run.
|
|
170
|
+
So `capturedSession` is undefined at the point we'd assign `record.execution`.
|
|
171
|
+
|
|
172
|
+
The cleanest solution: make `execution` settable from the `onSessionCreated` callback, where the session is known, and update `promise` separately after `runner.run()` returns.
|
|
173
|
+
Use two fields on the record:
|
|
174
|
+
|
|
175
|
+
1. `execution?: ExecutionState` — set inside `onSessionCreated` (session + outputFile known)
|
|
176
|
+
2. `promise?: Promise<string>` — set after `runner.run()` returns
|
|
177
|
+
|
|
178
|
+
This keeps `promise` as a separate top-level field.
|
|
179
|
+
It's set once by the manager after `runner.run()`, never mutated, and only exists during execution.
|
|
180
|
+
The execution state (session + outputFile) is a separate concern from the completion promise.
|
|
181
|
+
|
|
182
|
+
Revised design:
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
/** Execution-phase state — set when onSessionCreated fires. */
|
|
186
|
+
export interface ExecutionState {
|
|
187
|
+
readonly session: AgentSession;
|
|
188
|
+
readonly outputFile: string | undefined;
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`promise` stays as a field on `AgentRecord` because it's set at a different moment (after `runner.run()` returns) and its lifecycle differs (it's the full chain including post-processing).
|
|
193
|
+
|
|
194
|
+
### WorktreeState
|
|
195
|
+
|
|
196
|
+
New file: `src/worktree-state.ts`.
|
|
197
|
+
|
|
198
|
+
Born when `startAgent()` creates the worktree (before the run begins).
|
|
199
|
+
Only exists for agents with `isolation: "worktree"`.
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
export class WorktreeState {
|
|
203
|
+
readonly path: string;
|
|
204
|
+
readonly branch: string;
|
|
205
|
+
private _cleanupResult?: WorktreeCleanupResult;
|
|
206
|
+
|
|
207
|
+
constructor(info: WorktreeInfo) {
|
|
208
|
+
this.path = info.path;
|
|
209
|
+
this.branch = info.branch;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
get cleanupResult(): WorktreeCleanupResult | undefined {
|
|
213
|
+
return this._cleanupResult;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Record the cleanup result. Called once on completion or error. */
|
|
217
|
+
recordCleanup(result: WorktreeCleanupResult): void {
|
|
218
|
+
this._cleanupResult = result;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
This is a class (not a plain interface) because `cleanupResult` is set later — at completion/error — and we want to encapsulate that single mutation behind a method.
|
|
224
|
+
|
|
225
|
+
### NotificationState
|
|
226
|
+
|
|
227
|
+
New file: `src/notification-state.ts`.
|
|
228
|
+
|
|
229
|
+
Born when agent-tool sets the tool call ID for background agents.
|
|
230
|
+
Owns the two notification-tracking fields.
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
export class NotificationState {
|
|
234
|
+
readonly toolCallId: string;
|
|
235
|
+
private _resultConsumed = false;
|
|
236
|
+
|
|
237
|
+
constructor(toolCallId: string) {
|
|
238
|
+
this.toolCallId = toolCallId;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
get resultConsumed(): boolean {
|
|
242
|
+
return this._resultConsumed;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Mark the result as consumed — suppresses the completion notification. */
|
|
246
|
+
markConsumed(): void {
|
|
247
|
+
this._resultConsumed = true;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
For foreground agents that never get a `toolCallId`, `record.notification` stays `undefined`.
|
|
253
|
+
|
|
254
|
+
### pendingSteers → Map on AgentManager
|
|
255
|
+
|
|
256
|
+
New private field on `AgentManager`:
|
|
257
|
+
|
|
258
|
+
```typescript
|
|
259
|
+
private pendingSteers = new Map<string, string[]>();
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
New methods:
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
/** Queue a steer for an agent whose session isn't ready yet. */
|
|
266
|
+
queueSteer(id: string, message: string): boolean { ... }
|
|
267
|
+
|
|
268
|
+
/** Whether any steers are queued for this agent. */
|
|
269
|
+
hasPendingSteers(id: string): boolean { ... }
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Flush logic stays in `onSessionCreated` but reads from the map instead of the record.
|
|
273
|
+
`steer-tool.ts` and `service-adapter.ts` call `manager.queueSteer()` instead of writing `record.pendingSteers`.
|
|
274
|
+
|
|
275
|
+
The steer-tool and service-adapter need a way to call `queueSteer`.
|
|
276
|
+
For steer-tool, the `SteerToolDeps` interface gains `queueSteer: (id: string, msg: string) => boolean`.
|
|
277
|
+
For service-adapter, the `AgentManagerLike` interface gains the same method.
|
|
278
|
+
|
|
279
|
+
### Stats fields (toolUses, lifetimeUsage, compactionCount)
|
|
280
|
+
|
|
281
|
+
These are written by `record-observer.ts` via direct field mutation (`record.toolUses++`, `addUsage(record.lifetimeUsage, ...)`, `record.compactionCount++`).
|
|
282
|
+
They are read by many consumers for display.
|
|
283
|
+
|
|
284
|
+
These remain on `AgentRecord` because they're known at spawn time (initialized to zero/empty) and accumulate over the record's lifetime.
|
|
285
|
+
However, the direct mutation should be encapsulated behind methods:
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
/** Increment tool use count. Called by record-observer on tool_execution_end. */
|
|
289
|
+
incrementToolUses(): void { this._toolUses++; }
|
|
290
|
+
|
|
291
|
+
/** Accumulate usage delta. Called by record-observer on message_end. */
|
|
292
|
+
addUsage(delta: { input: number; output: number; cacheWrite: number }): void { ... }
|
|
293
|
+
|
|
294
|
+
/** Increment compaction count. Called by record-observer on compaction_end. */
|
|
295
|
+
incrementCompactions(): void { this._compactionCount++; }
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Read access through getters: `get toolUses()`, `get lifetimeUsage()`, `get compactionCount()`.
|
|
299
|
+
|
|
300
|
+
### Revised AgentRecord shape
|
|
301
|
+
|
|
302
|
+
```typescript
|
|
303
|
+
export class AgentRecord {
|
|
304
|
+
// Identity — set once at construction
|
|
305
|
+
readonly id: string;
|
|
306
|
+
readonly type: SubagentType;
|
|
307
|
+
readonly description: string;
|
|
308
|
+
readonly invocation?: AgentInvocation;
|
|
309
|
+
readonly abortController?: AbortController;
|
|
310
|
+
|
|
311
|
+
// Status-transition state (unchanged from today)
|
|
312
|
+
// ... _status, _result, _error, _startedAt, _completedAt with getters
|
|
313
|
+
|
|
314
|
+
// Stats — initialized at construction, mutated via methods
|
|
315
|
+
private _toolUses: number;
|
|
316
|
+
private _lifetimeUsage: LifetimeUsage;
|
|
317
|
+
private _compactionCount: number;
|
|
318
|
+
|
|
319
|
+
// Phase-specific collaborators — each set once, born complete
|
|
320
|
+
execution?: ExecutionState;
|
|
321
|
+
worktreeState?: WorktreeState;
|
|
322
|
+
notification?: NotificationState;
|
|
323
|
+
promise?: Promise<string>;
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### Revised AgentRecordInit
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
export interface AgentRecordInit {
|
|
331
|
+
id: string;
|
|
332
|
+
type: SubagentType;
|
|
333
|
+
description: string;
|
|
334
|
+
status?: AgentRecordStatus;
|
|
335
|
+
startedAt?: number;
|
|
336
|
+
completedAt?: number;
|
|
337
|
+
result?: string;
|
|
338
|
+
error?: string;
|
|
339
|
+
abortController?: AbortController;
|
|
340
|
+
invocation?: AgentInvocation;
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
Down from 19 optional fields to 8 (plus the 3 required).
|
|
345
|
+
Stats fields (`toolUses`, `lifetimeUsage`, `compactionCount`) initialize to zero/empty in the constructor — no need to pass them.
|
|
346
|
+
|
|
347
|
+
### Consumer call-site changes
|
|
348
|
+
|
|
349
|
+
Callers that read `record.session` change to `record.execution?.session`.
|
|
350
|
+
Callers that read `record.outputFile` change to `record.execution?.outputFile`.
|
|
351
|
+
Callers that read `record.worktree` change to `record.worktreeState`.
|
|
352
|
+
Callers that read `record.worktreeResult` change to `record.worktreeState?.cleanupResult`.
|
|
353
|
+
Callers that read `record.toolCallId` change to `record.notification?.toolCallId`.
|
|
354
|
+
Callers that read `record.resultConsumed` change to `record.notification?.resultConsumed`.
|
|
355
|
+
Callers that write `record.resultConsumed = true` change to `record.notification?.markConsumed()`.
|
|
356
|
+
Callers that write `record.toolUses++` change to `record.incrementToolUses()`.
|
|
357
|
+
Callers that write `addUsage(record.lifetimeUsage, ...)` change to `record.addUsage(...)`.
|
|
358
|
+
Callers that write `record.compactionCount++` change to `record.incrementCompactions()`.
|
|
359
|
+
|
|
360
|
+
## Module-Level Changes
|
|
361
|
+
|
|
362
|
+
### New files
|
|
363
|
+
|
|
364
|
+
| File | What |
|
|
365
|
+
| --------------------------- | ------------------------------------------------------ |
|
|
366
|
+
| `src/execution-state.ts` | `ExecutionState` interface (session + outputFile) |
|
|
367
|
+
| `src/worktree-state.ts` | `WorktreeState` class (path, branch, cleanupResult) |
|
|
368
|
+
| `src/notification-state.ts` | `NotificationState` class (toolCallId, resultConsumed) |
|
|
369
|
+
|
|
370
|
+
### Modified source files
|
|
371
|
+
|
|
372
|
+
| File | What changes |
|
|
373
|
+
| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
374
|
+
| `src/agent-record.ts` | Remove 12 public mutable fields. Add `execution?`, `worktreeState?`, `notification?`, `promise?` collaborator fields. Encapsulate stats behind methods (`incrementToolUses`, `addUsage`, `incrementCompactions`) with read-only getters. Trim `AgentRecordInit` to ~10 fields. |
|
|
375
|
+
| `src/agent-manager.ts` | Add `pendingSteers: Map<string, string[]>` and `queueSteer`/`hasPendingSteers` methods. Set `record.execution` in `onSessionCreated`. Set `record.worktreeState` in `startAgent`. Set `record.promise` after `runner.run()`. Flush steers from map. Update all `record.session`/`record.outputFile`/`record.worktree`/`record.worktreeResult` reads. |
|
|
376
|
+
| `src/service-adapter.ts` | Update `steer()` to read `record.execution?.session` and call `manager.queueSteer()`. Update `toSubagentRecord()` to read from collaborators. Add `queueSteer` to `AgentManagerLike`. |
|
|
377
|
+
| `src/record-observer.ts` | Call `record.incrementToolUses()`, `record.addUsage(...)`, `record.incrementCompactions()` instead of direct field mutation. |
|
|
378
|
+
| `src/notification.ts` | Read `record.notification?.toolCallId`, `record.notification?.resultConsumed`, `record.execution?.session`, `record.execution?.outputFile`. |
|
|
379
|
+
| `src/index.ts` | Read `record.notification?.resultConsumed` in onComplete. |
|
|
380
|
+
| `src/tools/agent-tool.ts` | Create `NotificationState` and assign to `record.notification`. Read `record.execution?.outputFile`. |
|
|
381
|
+
| `src/tools/get-result-tool.ts` | Call `record.notification?.markConsumed()`. Read `record.execution?.session`, `record.promise`. |
|
|
382
|
+
| `src/tools/steer-tool.ts` | Call `deps.queueSteer(id, msg)` instead of writing `record.pendingSteers`. Read `record.execution?.session`. Add `queueSteer` to `SteerToolDeps`. |
|
|
383
|
+
| `src/ui/agent-menu.ts` | Read `record.execution?.session`. |
|
|
384
|
+
| `src/ui/conversation-viewer.ts` | Read from `record` stats via getters (no structural change since property names stay the same via getters). |
|
|
385
|
+
| `src/types.ts` | Re-export `ExecutionState`, `WorktreeState`, `NotificationState`. Remove `AgentRecordInit` fields that are gone. |
|
|
386
|
+
|
|
387
|
+
### Modified test files
|
|
388
|
+
|
|
389
|
+
| File | What changes |
|
|
390
|
+
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
391
|
+
| `test/helpers/make-record.ts` | Remove fields dropped from `AgentRecordInit` (session, promise, outputFile, worktree, worktreeResult, toolCallId, resultConsumed, pendingSteers, toolUses, lifetimeUsage, compactionCount). Set up collaborators where tests need them. |
|
|
392
|
+
| `test/agent-record.test.ts` | Update to test new methods (`incrementToolUses`, `addUsage`, `incrementCompactions`). Remove tests for removed init fields. Add tests for collaborator attachment. |
|
|
393
|
+
| `test/agent-manager.test.ts` | Update to verify steer queueing via manager's map. Verify `record.execution` is set. Verify `record.worktreeState` is set. |
|
|
394
|
+
| `test/record-observer.test.ts` | Call new methods via assertions (verify `record.incrementToolUses` etc. are called, or verify `record.toolUses` value after events). |
|
|
395
|
+
| `test/service-adapter.test.ts` | Update `toSubagentRecord` tests to set up collaborators. Update steer tests to verify `queueSteer` call. |
|
|
396
|
+
| `test/notification.test.ts` | Set up `record.notification` and `record.execution` for tests that read those fields. |
|
|
397
|
+
| `test/tools/agent-tool.test.ts` | Verify `NotificationState` creation. Read `record.execution?.outputFile`. |
|
|
398
|
+
| `test/tools/get-result-tool.test.ts` | Call `record.notification?.markConsumed()`. Set up `record.execution` for tests. |
|
|
399
|
+
| `test/tools/steer-tool.test.ts` | Verify `deps.queueSteer()` call instead of `record.pendingSteers` write. Set up `record.execution`. |
|
|
400
|
+
| `test/ui/agent-menu.test.ts` | Set up `record.execution` for session-dependent tests. |
|
|
401
|
+
| `test/conversation-viewer.test.ts` | Verify stats accessed via getters (likely no structural change). |
|
|
402
|
+
| `test/usage.test.ts` | No change expected (tests `addUsage` and `getSessionContextPercent` directly). |
|
|
403
|
+
|
|
404
|
+
### Removed from AgentRecord
|
|
405
|
+
|
|
406
|
+
| Symbol | Replaced by |
|
|
407
|
+
| -------------------------------- | -------------------------------------------------- |
|
|
408
|
+
| `session` (public field) | `execution?.session` |
|
|
409
|
+
| `outputFile` (public field) | `execution?.outputFile` |
|
|
410
|
+
| `worktree` (public field) | `worktreeState` (WorktreeState) |
|
|
411
|
+
| `worktreeResult` (public field) | `worktreeState?.cleanupResult` |
|
|
412
|
+
| `toolCallId` (public field) | `notification?.toolCallId` |
|
|
413
|
+
| `resultConsumed` (public field) | `notification?.resultConsumed` / `.markConsumed()` |
|
|
414
|
+
| `pendingSteers` (public field) | `AgentManager.pendingSteers` Map |
|
|
415
|
+
| `toolUses` (public field) | `get toolUses()` + `incrementToolUses()` |
|
|
416
|
+
| `lifetimeUsage` (public field) | `get lifetimeUsage()` + `addUsage()` |
|
|
417
|
+
| `compactionCount` (public field) | `get compactionCount()` + `incrementCompactions()` |
|
|
418
|
+
|
|
419
|
+
Grep verification: all 12 non-transition mutable fields and their usage sites across `src/` and `test/` are accounted for in the file lists above.
|
|
420
|
+
|
|
421
|
+
## Test Impact Analysis
|
|
422
|
+
|
|
423
|
+
### New unit tests enabled
|
|
424
|
+
|
|
425
|
+
1. `WorktreeState` — `recordCleanup` method, read-only `path`/`branch`, `cleanupResult` accessor.
|
|
426
|
+
2. `NotificationState` — `markConsumed` transition, `toolCallId` immutability, initial `resultConsumed` is false.
|
|
427
|
+
3. `ExecutionState` — interface only (no class), tested implicitly via AgentRecord attachment.
|
|
428
|
+
4. `AgentRecord.incrementToolUses/addUsage/incrementCompactions` — unit tests for stat accumulation methods that were previously untestable without going through `record-observer.ts` + a mock session.
|
|
429
|
+
5. `AgentManager.queueSteer/hasPendingSteers` — steer buffering logic now testable in isolation.
|
|
430
|
+
|
|
431
|
+
### Existing tests that simplify
|
|
432
|
+
|
|
433
|
+
- `test/helpers/make-record.ts` — the factory drops from ~12 default fields to ~6, since stats initialize to zero internally and collaborators are set up only where needed.
|
|
434
|
+
- `test/record-observer.test.ts` — assertions can verify method calls (`incrementToolUses`, `addUsage`, `incrementCompactions`) or resulting values through getters.
|
|
435
|
+
The tests become cleaner because the mutation contract is explicit.
|
|
436
|
+
|
|
437
|
+
### Existing tests that stay as-is
|
|
438
|
+
|
|
439
|
+
- `test/agent-record.test.ts` — status-transition tests (`markRunning`, `markCompleted`, etc.) are unchanged since that part of AgentRecord is not being modified.
|
|
440
|
+
- `test/usage.test.ts` — tests the `addUsage` utility function directly, not through AgentRecord.
|
|
441
|
+
- `test/ui/agent-activity-tracker.test.ts` — tests the activity tracker, which is unrelated to this change.
|
|
442
|
+
|
|
443
|
+
## TDD Order
|
|
444
|
+
|
|
445
|
+
### 1. Add WorktreeState class with unit tests
|
|
446
|
+
|
|
447
|
+
New file: `src/worktree-state.ts` with `WorktreeState` class.
|
|
448
|
+
New test file: `test/worktree-state.test.ts` — constructor from `WorktreeInfo`, read-only `path`/`branch`, `recordCleanup` sets `cleanupResult`, `cleanupResult` starts undefined.
|
|
449
|
+
|
|
450
|
+
Commit: `feat: add WorktreeState class (#111)`
|
|
451
|
+
|
|
452
|
+
### 2. Add NotificationState class with unit tests
|
|
453
|
+
|
|
454
|
+
New file: `src/notification-state.ts` with `NotificationState` class.
|
|
455
|
+
New test file: `test/notification-state.test.ts` — constructor sets `toolCallId`, `resultConsumed` starts false, `markConsumed` sets it true.
|
|
456
|
+
|
|
457
|
+
Commit: `feat: add NotificationState class (#111)`
|
|
458
|
+
|
|
459
|
+
### 3. Add ExecutionState interface
|
|
460
|
+
|
|
461
|
+
New file: `src/execution-state.ts` with `ExecutionState` interface.
|
|
462
|
+
No test file needed (interface only).
|
|
463
|
+
|
|
464
|
+
Commit: `feat: add ExecutionState interface (#111)`
|
|
465
|
+
|
|
466
|
+
### 4. Encapsulate stats on AgentRecord (lift phase)
|
|
467
|
+
|
|
468
|
+
Add private `_toolUses`, `_lifetimeUsage`, `_compactionCount` with getters and mutation methods (`incrementToolUses`, `addUsage`, `incrementCompactions`).
|
|
469
|
+
Keep the old public fields as aliases during migration (or remove them and update callers in the same step — since `record-observer.ts` is the only writer and the tools/notification are read-only consumers, a single step works).
|
|
470
|
+
|
|
471
|
+
Update `record-observer.ts` to call the new methods.
|
|
472
|
+
Update `test/agent-record.test.ts` to cover the new methods.
|
|
473
|
+
Update `test/record-observer.test.ts` to verify via getters.
|
|
474
|
+
Update `test/helpers/make-record.ts` to remove `toolUses`, `lifetimeUsage`, `compactionCount` from defaults (they auto-initialize to zero).
|
|
475
|
+
|
|
476
|
+
Run `pnpm run check` to catch any remaining direct field writes.
|
|
477
|
+
|
|
478
|
+
Commit: `refactor: encapsulate stats fields on AgentRecord (#111)`
|
|
479
|
+
|
|
480
|
+
### 5. Add collaborator fields to AgentRecord (lift phase)
|
|
481
|
+
|
|
482
|
+
Add `execution?: ExecutionState`, `worktreeState?: WorktreeState`, `notification?: NotificationState` fields to `AgentRecord`.
|
|
483
|
+
Keep old fields (`session`, `outputFile`, `worktree`, `worktreeResult`, `toolCallId`, `resultConsumed`, `pendingSteers`) for now — both old and new coexist.
|
|
484
|
+
|
|
485
|
+
Update `AgentRecordInit` to accept the new optional fields.
|
|
486
|
+
Update `test/helpers/make-record.ts` as needed.
|
|
487
|
+
|
|
488
|
+
Commit: `refactor: add phase-specific collaborator fields to AgentRecord (#111)`
|
|
489
|
+
|
|
490
|
+
### 6. Migrate AgentManager to use WorktreeState
|
|
491
|
+
|
|
492
|
+
In `startAgent()`, construct `WorktreeState` instead of setting `record.worktree`.
|
|
493
|
+
In `.then()` and `.catch()`, call `record.worktreeState.recordCleanup()` instead of setting `record.worktreeResult`.
|
|
494
|
+
Read `record.worktreeState` instead of `record.worktree` for cleanup logic.
|
|
495
|
+
|
|
496
|
+
Update `test/agent-manager.test.ts` for worktree-related assertions.
|
|
497
|
+
|
|
498
|
+
Commit: `refactor: migrate AgentManager to WorktreeState (#111)`
|
|
499
|
+
|
|
500
|
+
### 7. Migrate AgentManager to use ExecutionState
|
|
501
|
+
|
|
502
|
+
In `onSessionCreated`, construct `ExecutionState` and set `record.execution`.
|
|
503
|
+
After `runner.run()`, set `record.promise` (kept separate from ExecutionState).
|
|
504
|
+
In `.then()`, update `record.execution` with final session/outputFile if changed.
|
|
505
|
+
Update all `record.session` reads in `agent-manager.ts` to `record.execution?.session`.
|
|
506
|
+
|
|
507
|
+
Update `test/agent-manager.test.ts` for execution-state assertions.
|
|
508
|
+
|
|
509
|
+
Commit: `refactor: migrate AgentManager to ExecutionState (#111)`
|
|
510
|
+
|
|
511
|
+
### 8. Move pendingSteers to AgentManager
|
|
512
|
+
|
|
513
|
+
Add `pendingSteers: Map<string, string[]>` and `queueSteer(id, msg)`/`hasPendingSteers(id)` to `AgentManager`.
|
|
514
|
+
Flush steers from the map in `onSessionCreated` instead of reading `record.pendingSteers`.
|
|
515
|
+
Remove `record.pendingSteers` field.
|
|
516
|
+
|
|
517
|
+
Update `steer-tool.ts` and `service-adapter.ts` to call `queueSteer` via their deps interfaces.
|
|
518
|
+
Update `SteerToolDeps` and `AgentManagerLike` interfaces.
|
|
519
|
+
Update `test/agent-manager.test.ts`, `test/tools/steer-tool.test.ts`, `test/service-adapter.test.ts`.
|
|
520
|
+
|
|
521
|
+
Commit: `refactor: move pendingSteers to AgentManager (#111)`
|
|
522
|
+
|
|
523
|
+
### 9. Migrate agent-tool to use NotificationState
|
|
524
|
+
|
|
525
|
+
After `manager.spawn()` for background agents, create `NotificationState(toolCallId)` and assign to `record.notification`.
|
|
526
|
+
Read `record.execution?.outputFile` instead of `record.outputFile`.
|
|
527
|
+
|
|
528
|
+
Update `test/tools/agent-tool.test.ts`.
|
|
529
|
+
|
|
530
|
+
Commit: `refactor: migrate agent-tool to NotificationState (#111)`
|
|
531
|
+
|
|
532
|
+
### 10. Migrate get-result-tool, notification, and index.ts
|
|
533
|
+
|
|
534
|
+
Update `get-result-tool.ts`: read `record.execution?.session`, `record.promise`, call `record.notification?.markConsumed()`.
|
|
535
|
+
Update `notification.ts`: read `record.notification?.toolCallId`, `record.notification?.resultConsumed`, `record.execution?.session`, `record.execution?.outputFile`.
|
|
536
|
+
Update `index.ts`: read `record.notification?.resultConsumed` in onComplete.
|
|
537
|
+
|
|
538
|
+
Update `test/tools/get-result-tool.test.ts`, `test/notification.test.ts`.
|
|
539
|
+
|
|
540
|
+
Commit: `refactor: migrate notification consumers to phase-specific state (#111)`
|
|
541
|
+
|
|
542
|
+
### 11. Migrate remaining consumers (steer-tool UI, agent-menu, service-adapter, conversation-viewer)
|
|
543
|
+
|
|
544
|
+
Update `steer-tool.ts`: read `record.execution?.session`.
|
|
545
|
+
Update `ui/agent-menu.ts`: read `record.execution?.session`.
|
|
546
|
+
Update `ui/conversation-viewer.ts`: stats accessed via getters (likely no change needed since getter names match old field names).
|
|
547
|
+
Update `service-adapter.ts`: `toSubagentRecord()` reads from collaborators; `steer()` reads `record.execution?.session`.
|
|
548
|
+
|
|
549
|
+
Update corresponding test files.
|
|
550
|
+
|
|
551
|
+
Commit: `refactor: migrate remaining consumers to phase-specific state (#111)`
|
|
552
|
+
|
|
553
|
+
### 12. Remove old fields and trim AgentRecordInit
|
|
554
|
+
|
|
555
|
+
Remove `session`, `outputFile`, `worktree`, `worktreeResult`, `toolCallId`, `resultConsumed`, `pendingSteers` from `AgentRecord` and `AgentRecordInit`.
|
|
556
|
+
Remove `toolUses`, `lifetimeUsage`, `compactionCount` from `AgentRecordInit` (they auto-initialize).
|
|
557
|
+
|
|
558
|
+
Run `pnpm run check` to verify no remaining references.
|
|
559
|
+
Run full test suite.
|
|
560
|
+
|
|
561
|
+
Update `src/types.ts` re-exports if needed.
|
|
562
|
+
|
|
563
|
+
Commit: `refactor: remove legacy fields from AgentRecord, trim AgentRecordInit (#111)`
|
|
564
|
+
|
|
565
|
+
## Risks and Mitigations
|
|
566
|
+
|
|
567
|
+
| Risk | Mitigation |
|
|
568
|
+
| ------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
569
|
+
| `record.execution` is undefined before `onSessionCreated` fires — callers that read `record.session` without null-checking will break | All current read sites already use optional chaining or guard checks (`if (!record.session)` etc.). Changing to `record.execution?.session` preserves the same null-safety. Grep all read sites to verify. |
|
|
570
|
+
| `promise` kept as a separate field rather than inside `ExecutionState` adds a conceptual split | Documented in design: the promise is the full post-processing chain, set at a different moment than the session. Keeping it separate avoids lifecycle confusion. The field count on AgentRecord still drops from 12 to 5. |
|
|
571
|
+
| Lift-and-shift creates a window where both old and new fields coexist | Each step migrates writers and readers together; the old field is removed in the final cleanup step. `pnpm run check` after each step catches stale references. |
|
|
572
|
+
| `NotificationState` is undefined for foreground agents — code that unconditionally reads `record.toolCallId` will get undefined | Today `toolCallId` is already optional (`toolCallId?: string`). All read sites already handle undefined. `record.notification?.toolCallId` has the same semantics. |
|
|
573
|
+
| Test factory (`make-record.ts`) changes break many test files at once | Step 4 updates the factory and all test files that construct records with stats fields. Steps 5-11 update test files incrementally per-collaborator. No single step rewrites all tests. |
|
|
574
|
+
| `WorktreeState.recordCleanup` is a post-construction mutation | It's encapsulated behind a single method on the owning object. The alternative (immutable replacement) would require re-attaching a new `WorktreeState` to the record on cleanup, which is more disruptive for the same semantic. |
|
|
575
|
+
|
|
576
|
+
## Open Questions
|
|
577
|
+
|
|
578
|
+
- Whether `record.execution` should be writable once via a `setExecution()` method or simply a public assignable field.
|
|
579
|
+
Leaning toward public field for simplicity since it's set exactly once and the constructor-complete principle applies to the *collaborator* (ExecutionState), not the field that holds it.
|
|
580
|
+
Revisit if more than one site needs to write it.
|
|
581
|
+
- Whether `record.promise` should move into `ExecutionState` in a follow-up once the timing concern is resolved (e.g., by using `Promise.withResolvers`).
|
|
582
|
+
Not blocking for this issue — the field count reduction is still significant.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 110
|
|
3
|
+
issue_title: "refactor(pi-subagents): wrap AgentActivity in AgentActivityTracker class"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #110 — wrap AgentActivity in AgentActivityTracker class
|
|
7
|
+
|
|
8
|
+
## Final Retrospective (2026-05-21T23:30:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Planned and implemented `AgentActivityTracker` class across 6 TDD cycles plus doc updates, released as `pi-subagents-v6.7.0`.
|
|
13
|
+
The 7-field mutable `AgentActivity` interface was replaced with a class exposing explicit transition methods (`onToolStart`, `onToolEnd`, `onMessageStart`, `onMessageUpdate`, `onTurnEnd`, `onUsageUpdate`, `setSession`) and read-only accessors.
|
|
14
|
+
All 7 source files and 3 test files were migrated incrementally without any big-bang commit.
|
|
15
|
+
|
|
16
|
+
### Observations
|
|
17
|
+
|
|
18
|
+
#### What went well
|
|
19
|
+
|
|
20
|
+
- **TDD Red phase caught all three implementation bugs.**
|
|
21
|
+
1. `onToolEnd` initially incremented `toolUses` unconditionally (ported from original code), but the plan specified no-op defensive behavior.
|
|
22
|
+
The Red phase test `"onToolEnd with no matching tool is a no-op"` caught it instantly.
|
|
23
|
+
2. `Date.now()` key collision in `activeTools` Map — two `onToolStart("Read")` calls in the same millisecond produced identical keys, so the second overwrote the first.
|
|
24
|
+
The Red phase test `"multiple concurrent tools with same name tracked independently"` caught it.
|
|
25
|
+
3. `describeActivity` signature needed `ReadonlyMap<string, string>` after the accessor change — caught by `pnpm run check` in step 3.
|
|
26
|
+
All three were fixed immediately with no cascading rework.
|
|
27
|
+
- **Incremental migration avoided type breakage.**
|
|
28
|
+
The plan kept `AgentActivity` alive in `agent-widget.ts` until step 3, so steps 1–2 compiled without touching downstream files.
|
|
29
|
+
Each step only broke the files it was about to migrate, keeping intermediate states valid.
|
|
30
|
+
- **Monotonic counter is strictly better than `Date.now()` for tool keys.**
|
|
31
|
+
The extraction enabled replacing the `toolName + "_" + Date.now()` key strategy with `toolName + "_" + (++this._toolKeySeq)`, which never collides regardless of timing.
|
|
32
|
+
This is a concrete improvement the original inline code couldn't easily adopt.
|
|
33
|
+
|
|
34
|
+
#### What caused friction (agent side)
|
|
35
|
+
|
|
36
|
+
- `missing-context` — The plan specified the `Date.now()` key strategy from the original code, but didn't account for same-millisecond collisions in test execution.
|
|
37
|
+
Impact: ~1 minute debugging in step 1; trivial fix to monotonic counter.
|
|
38
|
+
- `premature-convergence` — Initial `onToolEnd` implementation copied the original's unconditional `toolUses++` before checking the plan's specified no-op behavior.
|
|
39
|
+
Impact: caught immediately by the Red phase test, single-line fix.
|
|
40
|
+
|
|
41
|
+
#### What caused friction (user side)
|
|
42
|
+
|
|
43
|
+
- No material friction observed.
|
|
44
|
+
The session ran end-to-end (plan → implement → ship → release) without user intervention.
|