@gotgenes/pi-subagents 6.0.1 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [6.1.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.0.1...pi-subagents-v6.1.0) (2026-05-20)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* create AgentRecord class with transition methods ([#98](https://github.com/gotgenes/pi-packages/issues/98)) ([e5b2170](https://github.com/gotgenes/pi-packages/commit/e5b21704de2d693e4feff8b0c0f80a4f6e3fdabd))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* plan AgentRecord state machine extraction ([#98](https://github.com/gotgenes/pi-packages/issues/98)) ([5ca6613](https://github.com/gotgenes/pi-packages/commit/5ca6613ba8e73e042c7f06e2f303ad573048138e))
|
|
19
|
+
* **retro:** add retro notes for issue [#102](https://github.com/gotgenes/pi-packages/issues/102) ([594a61a](https://github.com/gotgenes/pi-packages/commit/594a61a59f04aafba06ab8649c183b5003e95822))
|
|
20
|
+
|
|
8
21
|
## [6.0.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.0.0...pi-subagents-v6.0.1) (2026-05-20)
|
|
9
22
|
|
|
10
23
|
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 98
|
|
3
|
+
issue_title: "Extract AgentRecord state machine from scattered status transitions"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Extract AgentRecord state machine
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
`AgentRecord` status transitions are scattered across 6 locations in `agent-manager.ts`: `startAgent()` `.then()`, `startAgent()` `.catch()`, `resume()`, `abort()`, `abortAll()`, and `drainQueue()` catch.
|
|
11
|
+
Each site sets `record.status` plus associated fields (`completedAt`, `result`, `error`) in ad-hoc combinations, with repeated guards like `if (record.status !== "stopped")`.
|
|
12
|
+
|
|
13
|
+
There are 15 direct `record.status = ...` writes and 17 associated field writes (`result`, `error`, `completedAt`, `startedAt`) — ~32 scattered mutation sites total.
|
|
14
|
+
`startAgent()` is ~130 lines partly because it manages these transitions inline.
|
|
15
|
+
|
|
16
|
+
## Goals
|
|
17
|
+
|
|
18
|
+
- Convert `AgentRecord` from a plain interface to a class that owns and encapsulates its transition state.
|
|
19
|
+
- Extract status-transition methods that centralize the "don't overwrite stopped" guard.
|
|
20
|
+
- Reduce scattered field writes in `agent-manager.ts` to method calls.
|
|
21
|
+
- Encapsulate transition fields (`status`, `result`, `error`, `startedAt`, `completedAt`) behind private backing fields with getters — external code can read but not write.
|
|
22
|
+
- Use lift-and-shift to migrate incrementally: introduce the class alongside the interface, migrate construction sites, switch the re-export, replace writes, then encapsulate.
|
|
23
|
+
|
|
24
|
+
## Non-Goals
|
|
25
|
+
|
|
26
|
+
- Parent snapshot extraction (architecture.md Step 2) — separate issue.
|
|
27
|
+
- Session-event observation / callback threading removal (architecture.md Step 3) — separate issue.
|
|
28
|
+
- Stat accumulation methods (`toolUses++`, `addUsage()`, `compactionCount++`) — these are running counters, not status transitions.
|
|
29
|
+
They could become methods in a follow-up but are out of scope here.
|
|
30
|
+
- Encapsulating non-transition field writes (`session`, `outputFile`, `worktree`, `worktreeResult`, `promise`, `toolCallId`, `resultConsumed`, `pendingSteers`) — these are data capture, not status transitions.
|
|
31
|
+
|
|
32
|
+
## Prerequisites
|
|
33
|
+
|
|
34
|
+
- Issue #102 (shared test record factory) — shipped.
|
|
35
|
+
All 8 test files construct `AgentRecord` objects through `createTestRecord()` in `test/helpers/make-record.ts`.
|
|
36
|
+
Converting the interface to a class requires updating only this one factory (plus `agent-manager.ts` construction).
|
|
37
|
+
|
|
38
|
+
## Background
|
|
39
|
+
|
|
40
|
+
### Relevant modules
|
|
41
|
+
|
|
42
|
+
| Module | Role |
|
|
43
|
+
| ----------------------------- | ------------------------------------------------------------------------------ |
|
|
44
|
+
| `src/types.ts` | Defines `AgentRecord` interface — 20+ consumers read from it |
|
|
45
|
+
| `src/agent-manager.ts` | Only file that writes status-transition fields |
|
|
46
|
+
| `src/service.ts` | Defines `SubagentRecord` (serializable snapshot) — unchanged |
|
|
47
|
+
| `src/service-adapter.ts` | Converts `AgentRecord` → `SubagentRecord` via `toSubagentRecord()` — unchanged |
|
|
48
|
+
| `test/helpers/make-record.ts` | Shared test factory — single construction site for all tests (#102) |
|
|
49
|
+
|
|
50
|
+
### External writes to `AgentRecord` (not status transitions)
|
|
51
|
+
|
|
52
|
+
Several files outside `agent-manager.ts` write non-transition fields:
|
|
53
|
+
|
|
54
|
+
- `tools/get-result-tool.ts` — `record.resultConsumed = true` (2 sites)
|
|
55
|
+
- `tools/steer-tool.ts` / `service-adapter.ts` — `record.pendingSteers` (push messages)
|
|
56
|
+
- `tools/agent-tool.ts` — `record.toolCallId = toolCallId`
|
|
57
|
+
|
|
58
|
+
These are data-capture writes, not status transitions, and remain public fields.
|
|
59
|
+
|
|
60
|
+
### Code-style constraints
|
|
61
|
+
|
|
62
|
+
The code-style skill's "output arguments" rule applies directly: "If a function sets a field on a received object, it is doing work that belongs inside the owning object.
|
|
63
|
+
Encapsulate the mutation behind a method."
|
|
64
|
+
The "scattered resets" rule also applies: "When the same set of fields is reset to the same values in multiple places, extract a single method."
|
|
65
|
+
|
|
66
|
+
## Design Overview
|
|
67
|
+
|
|
68
|
+
### `AgentRecord` becomes a class
|
|
69
|
+
|
|
70
|
+
`AgentRecord` moves from an interface in `types.ts` to a class in a new `src/agent-record.ts` module.
|
|
71
|
+
`types.ts` re-exports the class so existing `import type { AgentRecord } from "./types.js"` across the codebase continues to work with no import-path changes.
|
|
72
|
+
|
|
73
|
+
The class encapsulates the 5 transition fields behind private backing fields with getters.
|
|
74
|
+
All other fields remain public (identity fields are `readonly`; non-transition mutable fields stay public).
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
export type AgentRecordStatus =
|
|
78
|
+
| "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
|
|
79
|
+
|
|
80
|
+
export class AgentRecord {
|
|
81
|
+
// Identity — readonly, set once at construction
|
|
82
|
+
readonly id: string;
|
|
83
|
+
readonly type: SubagentType;
|
|
84
|
+
readonly description: string;
|
|
85
|
+
readonly invocation?: AgentInvocation;
|
|
86
|
+
|
|
87
|
+
// Transition state — private backing fields, public getters
|
|
88
|
+
private _status: AgentRecordStatus;
|
|
89
|
+
get status(): AgentRecordStatus { return this._status; }
|
|
90
|
+
|
|
91
|
+
private _result?: string;
|
|
92
|
+
get result(): string | undefined { return this._result; }
|
|
93
|
+
|
|
94
|
+
private _error?: string;
|
|
95
|
+
get error(): string | undefined { return this._error; }
|
|
96
|
+
|
|
97
|
+
private _startedAt: number;
|
|
98
|
+
get startedAt(): number { return this._startedAt; }
|
|
99
|
+
|
|
100
|
+
private _completedAt?: number;
|
|
101
|
+
get completedAt(): number | undefined { return this._completedAt; }
|
|
102
|
+
|
|
103
|
+
// Non-transition mutable state — public fields
|
|
104
|
+
toolUses: number;
|
|
105
|
+
lifetimeUsage: LifetimeUsage;
|
|
106
|
+
compactionCount: number;
|
|
107
|
+
session?: AgentSession;
|
|
108
|
+
abortController?: AbortController;
|
|
109
|
+
promise?: Promise<string>;
|
|
110
|
+
resultConsumed?: boolean;
|
|
111
|
+
pendingSteers?: string[];
|
|
112
|
+
worktree?: { path: string; branch: string };
|
|
113
|
+
worktreeResult?: { hasChanges: boolean; branch?: string };
|
|
114
|
+
toolCallId?: string;
|
|
115
|
+
outputFile?: string;
|
|
116
|
+
|
|
117
|
+
constructor(init: AgentRecordInit) { /* ... */ }
|
|
118
|
+
|
|
119
|
+
// Transition methods — see below
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Constructor and `AgentRecordInit`
|
|
124
|
+
|
|
125
|
+
The constructor accepts a wide init bag that covers all fields.
|
|
126
|
+
Required fields: `id`, `type`, `description`.
|
|
127
|
+
All others are optional with sensible defaults (`status` defaults to `"queued"`, `toolUses` to 0, etc.).
|
|
128
|
+
|
|
129
|
+
This wide init bag serves two purposes:
|
|
130
|
+
|
|
131
|
+
1. Production use (`agent-manager.ts`): passes the 7 fields it needs at spawn time.
|
|
132
|
+
2. Test use (`createTestRecord`): sets arbitrary state (e.g., `status: "completed"`, `result: "done"`) without going through transition methods.
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
export interface AgentRecordInit {
|
|
136
|
+
id: string;
|
|
137
|
+
type: SubagentType;
|
|
138
|
+
description: string;
|
|
139
|
+
status?: AgentRecordStatus;
|
|
140
|
+
startedAt?: number;
|
|
141
|
+
completedAt?: number;
|
|
142
|
+
result?: string;
|
|
143
|
+
error?: string;
|
|
144
|
+
toolUses?: number;
|
|
145
|
+
lifetimeUsage?: LifetimeUsage;
|
|
146
|
+
compactionCount?: number;
|
|
147
|
+
abortController?: AbortController;
|
|
148
|
+
invocation?: AgentInvocation;
|
|
149
|
+
session?: AgentSession;
|
|
150
|
+
promise?: Promise<string>;
|
|
151
|
+
resultConsumed?: boolean;
|
|
152
|
+
pendingSteers?: string[];
|
|
153
|
+
worktree?: { path: string; branch: string };
|
|
154
|
+
worktreeResult?: { hasChanges: boolean; branch?: string };
|
|
155
|
+
toolCallId?: string;
|
|
156
|
+
outputFile?: string;
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Transition methods
|
|
161
|
+
|
|
162
|
+
7 methods covering all 6 transition sites plus the resume-reset:
|
|
163
|
+
|
|
164
|
+
#### `markRunning(startedAt: number): void`
|
|
165
|
+
|
|
166
|
+
Sets `status = "running"` and `startedAt`.
|
|
167
|
+
Called in `startAgent()` when transitioning from queued to running.
|
|
168
|
+
|
|
169
|
+
#### `markCompleted(result: string, completedAt?: number): void`
|
|
170
|
+
|
|
171
|
+
Always sets `result` and `completedAt` (via `??=`).
|
|
172
|
+
Only changes `status` to `"completed"` if not already `"stopped"`.
|
|
173
|
+
Called in the `.then()` success path and in `resume()` try block.
|
|
174
|
+
|
|
175
|
+
#### `markAborted(result: string, completedAt?: number): void`
|
|
176
|
+
|
|
177
|
+
Same guard as `markCompleted` — preserves stopped status.
|
|
178
|
+
Sets `status = "aborted"`.
|
|
179
|
+
Called in `.then()` when runner returns `aborted: true`.
|
|
180
|
+
|
|
181
|
+
#### `markSteered(result: string, completedAt?: number): void`
|
|
182
|
+
|
|
183
|
+
Same guard as `markCompleted` — preserves stopped status.
|
|
184
|
+
Sets `status = "steered"`.
|
|
185
|
+
Called in `.then()` when runner returns `steered: true`.
|
|
186
|
+
|
|
187
|
+
#### `markError(error: unknown, completedAt?: number): void`
|
|
188
|
+
|
|
189
|
+
Always sets `error` (formatted: `Error` → `.message`, otherwise `String(...)`) and `completedAt` (via `??=`).
|
|
190
|
+
Only changes `status` to `"error"` if not already `"stopped"`.
|
|
191
|
+
Called in `.catch()`, `resume()` catch block, and `drainQueue()` catch.
|
|
192
|
+
|
|
193
|
+
#### `markStopped(completedAt?: number): void`
|
|
194
|
+
|
|
195
|
+
Always sets `status = "stopped"` and `completedAt`.
|
|
196
|
+
No guard — stopping is always valid.
|
|
197
|
+
Called in `abort()` (2 sites) and `abortAll()` (2 sites).
|
|
198
|
+
|
|
199
|
+
#### `resetForResume(startedAt: number): void`
|
|
200
|
+
|
|
201
|
+
Sets `status = "running"`, `startedAt`.
|
|
202
|
+
Clears `completedAt`, `result`, `error` to `undefined`.
|
|
203
|
+
Called in `resume()` before re-running.
|
|
204
|
+
|
|
205
|
+
### Guard semantics: preserve current behavior
|
|
206
|
+
|
|
207
|
+
The current `.then()` and `.catch()` blocks guard only the `status` field but always set `result`/`error` and `completedAt`:
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
// Current .then() — result and completedAt are set even when stopped
|
|
211
|
+
if (record.status !== "stopped") {
|
|
212
|
+
record.status = aborted ? "aborted" : steered ? "steered" : "completed";
|
|
213
|
+
}
|
|
214
|
+
record.result = responseText;
|
|
215
|
+
record.completedAt ??= Date.now();
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
This is intentional — `get-result-tool.ts` reads `record.result` from stopped records.
|
|
219
|
+
The transition methods preserve this behavior: data fields are always set, status is only changed when not stopped.
|
|
220
|
+
The `??=` on `completedAt` preserves the abort timestamp when `markStopped()` fires before `.then()`.
|
|
221
|
+
|
|
222
|
+
### Worktree result-append: reorder, don't add a method
|
|
223
|
+
|
|
224
|
+
The `.then()` block currently appends worktree branch text to `record.result` after the transition.
|
|
225
|
+
With encapsulated fields, direct writes to `result` are forbidden.
|
|
226
|
+
|
|
227
|
+
Instead of adding an `appendToResult()` method, reorder the `.then()` block to compute the final result (including worktree text) before calling the transition method:
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
.then(({ responseText, session, aborted, steered, sessionFile }) => {
|
|
231
|
+
// Worktree cleanup first — compute final result
|
|
232
|
+
let finalResult = responseText;
|
|
233
|
+
if (record.worktree) {
|
|
234
|
+
const wtResult = this.worktrees.cleanup(record.worktree, options.description);
|
|
235
|
+
record.worktreeResult = wtResult;
|
|
236
|
+
if (wtResult.hasChanges && wtResult.branch) {
|
|
237
|
+
finalResult += `\n\n---\nChanges saved to branch ...`;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Transition with complete result
|
|
242
|
+
if (aborted) record.markAborted(finalResult);
|
|
243
|
+
else if (steered) record.markSteered(finalResult);
|
|
244
|
+
else record.markCompleted(finalResult);
|
|
245
|
+
|
|
246
|
+
record.session = session;
|
|
247
|
+
if (sessionFile) record.outputFile = sessionFile;
|
|
248
|
+
|
|
249
|
+
detach();
|
|
250
|
+
// ...
|
|
251
|
+
})
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
The worktree cleanup reads `record.worktree` (a public field set before the promise) and writes `record.worktreeResult` (a public field).
|
|
255
|
+
It does not depend on `record.status` or `record.result`, so the reorder is safe.
|
|
256
|
+
|
|
257
|
+
### Correctness improvement in `resume()`
|
|
258
|
+
|
|
259
|
+
The current `resume()` method lacks the "don't overwrite stopped" guard:
|
|
260
|
+
|
|
261
|
+
```typescript
|
|
262
|
+
// Current — no guard against abort race
|
|
263
|
+
record.status = "completed";
|
|
264
|
+
record.result = responseText;
|
|
265
|
+
record.completedAt = Date.now();
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
After migration, `record.markCompleted(responseText)` includes the guard.
|
|
269
|
+
This closes a latent race where `abort()` sets status to `"stopped"` between `resetForResume()` and the runner returning.
|
|
270
|
+
The current code would overwrite `"stopped"` back to `"completed"` — the method call does not.
|
|
271
|
+
|
|
272
|
+
### Circular import avoidance
|
|
273
|
+
|
|
274
|
+
`agent-record.ts` imports types from `types.ts` (`SubagentType`, `AgentInvocation`).
|
|
275
|
+
`types.ts` re-exports the class from `agent-record.ts`.
|
|
276
|
+
|
|
277
|
+
This is safe because `agent-record.ts` uses `import type` for its `types.ts` imports — these are erased at runtime, so no runtime circular dependency exists.
|
|
278
|
+
|
|
279
|
+
## Module-Level Changes
|
|
280
|
+
|
|
281
|
+
### New files
|
|
282
|
+
|
|
283
|
+
1. `src/agent-record.ts` — `AgentRecord` class, `AgentRecordInit` interface, `AgentRecordStatus` type.
|
|
284
|
+
2. `test/agent-record.test.ts` — unit tests for constructor and all 7 transition methods.
|
|
285
|
+
|
|
286
|
+
### Changed files
|
|
287
|
+
|
|
288
|
+
1. `src/types.ts` — remove `AgentRecord` interface; add re-export: `export { AgentRecord, type AgentRecordInit, type AgentRecordStatus } from "./agent-record.js"`.
|
|
289
|
+
2. `src/agent-manager.ts` — import `AgentRecord` as a value from `./agent-record.js`; replace object literal construction with `new AgentRecord(...)`; replace ~32 scattered field writes with 11 transition method calls.
|
|
290
|
+
3. `test/helpers/make-record.ts` — import `AgentRecord` from `../../src/agent-record.js`; construct with `new AgentRecord(...)` instead of plain object.
|
|
291
|
+
|
|
292
|
+
### Unchanged files
|
|
293
|
+
|
|
294
|
+
All other source and test files — they use `import type { AgentRecord } from "./types.js"` and only read fields.
|
|
295
|
+
The re-export from `types.ts` means zero import-path changes.
|
|
296
|
+
|
|
297
|
+
## Test Impact Analysis
|
|
298
|
+
|
|
299
|
+
### New tests enabled by the extraction
|
|
300
|
+
|
|
301
|
+
The state machine is currently untestable in isolation — transitions are buried inside `agent-manager.ts`.
|
|
302
|
+
After extraction:
|
|
303
|
+
|
|
304
|
+
- Guard logic tested directly (e.g., `markCompleted` on a stopped record preserves status but sets result).
|
|
305
|
+
- Invalid transition sequences tested without mocking the runner.
|
|
306
|
+
- Error formatting (`Error` vs string) tested in isolation.
|
|
307
|
+
- `resetForResume` field clearing tested without the full resume flow.
|
|
308
|
+
|
|
309
|
+
### Existing tests that stay as-is
|
|
310
|
+
|
|
311
|
+
`agent-manager.test.ts` tests verify end-to-end behavior (spawn, complete, abort, resume, queue drain, worktree).
|
|
312
|
+
They remain unchanged — they verify the wiring between `AgentManager` and the `AgentRecord` class.
|
|
313
|
+
|
|
314
|
+
### No test files need updating (except the shared factory)
|
|
315
|
+
|
|
316
|
+
Issue #102 consolidated all test record construction into `createTestRecord()`.
|
|
317
|
+
Only that one factory changes.
|
|
318
|
+
All 8 consumer test files are untouched.
|
|
319
|
+
|
|
320
|
+
## TDD Order
|
|
321
|
+
|
|
322
|
+
### Cycle 1: AgentRecord class tests
|
|
323
|
+
|
|
324
|
+
Test surface: `test/agent-record.test.ts` (new file).
|
|
325
|
+
|
|
326
|
+
Tests cover:
|
|
327
|
+
|
|
328
|
+
- Constructor: defaults (`status` → `"queued"`, `toolUses` → 0, `lifetimeUsage` → zeros, `compactionCount` → 0), passthrough of init values (including optional transition fields like `result`, `completedAt`).
|
|
329
|
+
- `markRunning`: sets status and startedAt.
|
|
330
|
+
- `markCompleted`: sets status/result/completedAt when not stopped; preserves status but still sets result/completedAt when stopped; `completedAt` uses `??=` semantics (does not overwrite existing value).
|
|
331
|
+
- `markAborted` and `markSteered`: same guard behavior as `markCompleted`.
|
|
332
|
+
- `markError`: sets status/error/completedAt when not stopped; preserves status but still sets error/completedAt when stopped; formats `Error` objects to `.message`, non-Error to `String(...)`.
|
|
333
|
+
- `markStopped`: always sets status and completedAt, no guard.
|
|
334
|
+
- `resetForResume`: sets status to `"running"` and startedAt; clears completedAt, result, error.
|
|
335
|
+
|
|
336
|
+
Commit: `test: add AgentRecord class tests (#98)`
|
|
337
|
+
|
|
338
|
+
### Cycle 2: AgentRecord class implementation
|
|
339
|
+
|
|
340
|
+
Source: `src/agent-record.ts` (new file).
|
|
341
|
+
Exports: `AgentRecord` class, `AgentRecordInit` interface, `AgentRecordStatus` type.
|
|
342
|
+
All fields public initially (encapsulation deferred to cycle 6).
|
|
343
|
+
|
|
344
|
+
All cycle 1 tests pass.
|
|
345
|
+
|
|
346
|
+
Commit: `feat: create AgentRecord class with transition methods (#98)`
|
|
347
|
+
|
|
348
|
+
### Cycle 3: Lift — migrate construction sites
|
|
349
|
+
|
|
350
|
+
Changed files: `test/helpers/make-record.ts`, `src/agent-manager.ts`.
|
|
351
|
+
|
|
352
|
+
`createTestRecord`: import `AgentRecord` from `agent-record.ts`; construct with `new AgentRecord(...)`.
|
|
353
|
+
`agent-manager.ts`: import `AgentRecord` from `agent-record.ts` (value import); replace object literal in `spawn()` with `new AgentRecord(...)`.
|
|
354
|
+
|
|
355
|
+
At this point, both the interface (in `types.ts`) and the class (in `agent-record.ts`) coexist.
|
|
356
|
+
`agent-manager.ts` uses the class; all other consumers use the interface.
|
|
357
|
+
Scattered field writes still work because fields are public.
|
|
358
|
+
|
|
359
|
+
Run: full test suite + `pnpm run check`.
|
|
360
|
+
|
|
361
|
+
Commit: `refactor: construct AgentRecord class in agent-manager and test factory (#98)`
|
|
362
|
+
|
|
363
|
+
### Cycle 4: Shift — switch `types.ts` from interface to re-export
|
|
364
|
+
|
|
365
|
+
Changed file: `src/types.ts`.
|
|
366
|
+
|
|
367
|
+
Remove the `AgentRecord` interface definition.
|
|
368
|
+
Add re-exports:
|
|
369
|
+
|
|
370
|
+
```typescript
|
|
371
|
+
export { AgentRecord } from "./agent-record.js";
|
|
372
|
+
export type { AgentRecordInit, AgentRecordStatus } from "./agent-record.js";
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
All `import type { AgentRecord } from "./types.js"` across the codebase now resolve to the class.
|
|
376
|
+
No consumer constructs `AgentRecord` as a plain object (construction was migrated in cycle 3).
|
|
377
|
+
|
|
378
|
+
Run: `pnpm run check` (catches any structural incompatibilities).
|
|
379
|
+
|
|
380
|
+
Commit: `refactor: replace AgentRecord interface with class re-export in types.ts (#98)`
|
|
381
|
+
|
|
382
|
+
### Cycle 5: Replace scattered field writes with transition methods
|
|
383
|
+
|
|
384
|
+
Changed file: `src/agent-manager.ts`.
|
|
385
|
+
|
|
386
|
+
Replace all 11 transition sites:
|
|
387
|
+
|
|
388
|
+
| Location | Before | After |
|
|
389
|
+
| ---------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------- |
|
|
390
|
+
| `startAgent()` running | `record.status = "running"; record.startedAt = ...` | `record.markRunning(Date.now())` |
|
|
391
|
+
| `.then()` success | `if (!stopped) status = ...; result = ...; completedAt ??= ...` | `record.markCompleted(finalResult)` / `markAborted` / `markSteered` |
|
|
392
|
+
| `.catch()` error | `if (!stopped) status = "error"; error = ...; completedAt ??= ...` | `record.markError(err)` |
|
|
393
|
+
| `resume()` reset | `status = ...; startedAt = ...; clear 3 fields` | `record.resetForResume(Date.now())` |
|
|
394
|
+
| `resume()` try | `status = "completed"; result = ...; completedAt = ...` | `record.markCompleted(responseText)` |
|
|
395
|
+
| `resume()` catch | `status = "error"; error = ...; completedAt = ...` | `record.markError(err)` |
|
|
396
|
+
| `abort()` queued | `status = "stopped"; completedAt = ...` | `record.markStopped()` |
|
|
397
|
+
| `abort()` running | `status = "stopped"; completedAt = ...` | `record.markStopped()` |
|
|
398
|
+
| `abortAll()` queued | `status = "stopped"; completedAt = ...` | `record.markStopped()` |
|
|
399
|
+
| `abortAll()` running | `status = "stopped"; completedAt = ...` | `record.markStopped()` |
|
|
400
|
+
| `drainQueue()` catch | `status = "error"; error = ...; completedAt = ...` | `record.markError(err)` |
|
|
401
|
+
|
|
402
|
+
Also reorder the `.then()` block: worktree cleanup moves before the transition call so the final result includes worktree branch text (see Design Overview).
|
|
403
|
+
|
|
404
|
+
Run: full test suite.
|
|
405
|
+
|
|
406
|
+
Commit: `refactor: replace scattered status transitions with AgentRecord methods (#98)`
|
|
407
|
+
|
|
408
|
+
### Cycle 6: Encapsulate transition fields
|
|
409
|
+
|
|
410
|
+
Changed file: `src/agent-record.ts`.
|
|
411
|
+
|
|
412
|
+
Make `status`, `result`, `error`, `startedAt`, `completedAt` private (`_status`, `_result`, etc.) with public getters.
|
|
413
|
+
Make `id`, `type`, `description`, `invocation` readonly.
|
|
414
|
+
|
|
415
|
+
Run: `pnpm run check` — verifies no remaining direct writes to encapsulated fields anywhere in `src/` or `test/`.
|
|
416
|
+
Run: full test suite.
|
|
417
|
+
|
|
418
|
+
Commit: `refactor: encapsulate AgentRecord transition state (#98)`
|
|
419
|
+
|
|
420
|
+
## Risks and Mitigations
|
|
421
|
+
|
|
422
|
+
| Risk | Mitigation |
|
|
423
|
+
| ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
424
|
+
| Guard semantics drift: transition methods might not exactly match scattered guards | Each method's guard logic has dedicated tests (cycle 1); full agent-manager test suite runs after each cycle |
|
|
425
|
+
| Data fields on stopped records: `markCompleted` etc. must still set `result`/`completedAt` even when status is guarded | Explicit "stopped guard" test cases verify data fields are set; mirrors current `.then()` / `.catch()` behavior |
|
|
426
|
+
| Worktree result-append after encapsulation: direct `record.result = ...` no longer compiles | `.then()` block reordered to compute full result before calling transition method (cycle 5) |
|
|
427
|
+
| `types.ts` re-export introduces type-only circular dependency | `agent-record.ts` → `types.ts` imports are `import type` only (erased at runtime); no runtime cycle |
|
|
428
|
+
| `resume()` gains a "stopped" guard it didn't have before | This is a correctness improvement: closes a latent abort race; noted explicitly in design |
|
|
429
|
+
|
|
430
|
+
## Open Questions
|
|
431
|
+
|
|
432
|
+
- The `markCompleted`/`markAborted`/`markSteered` split is not in the original issue's 5-method list (which only shows `markCompleted`).
|
|
433
|
+
The split is needed because the `.then()` block dispatches to 3 distinct terminal statuses.
|
|
434
|
+
- Stat accumulation (`toolUses++`, `addUsage(lifetimeUsage, ...)`, `compactionCount++`) is left as direct field writes on public fields.
|
|
435
|
+
These could become methods in a follow-up if the callback-threading removal (architecture.md Step 3) makes it natural.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 102
|
|
3
|
+
issue_title: "Consolidate test AgentRecord construction into a shared factory"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #102 — Consolidate test AgentRecord construction into a shared factory
|
|
7
|
+
|
|
8
|
+
## Final Retrospective (2026-05-20T15:30:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Planned, implemented, and shipped a shared `createTestRecord()` factory in `test/helpers/make-record.ts`, migrating 7 test files from local factories and inline literals.
|
|
13
|
+
The session completed in three slash-command phases (plan → TDD → ship) with zero rework, zero test failures, and zero deviations from the plan.
|
|
14
|
+
Released as `pi-subagents-v6.0.1`.
|
|
15
|
+
|
|
16
|
+
### Observations
|
|
17
|
+
|
|
18
|
+
#### What went well
|
|
19
|
+
|
|
20
|
+
- The Explore subagent surveyed all 8 test files' factory patterns in parallel, producing a structured comparison table that directly informed the plan's default-value decisions.
|
|
21
|
+
- Before migrating `service-adapter.test.ts`, reading the `toSubagentRecord` source confirmed that `!== undefined` guards make absent-property vs. property-set-to-undefined semantically equivalent — avoiding a subtle test failure.
|
|
22
|
+
- All 4 TDD steps passed on first run, confirming the plan's migration strategy (preserve old defaults via overrides) was sound.
|
|
23
|
+
|
|
24
|
+
#### What caused friction (agent side)
|
|
25
|
+
|
|
26
|
+
No friction points — the mechanical nature of the refactoring and the well-specified plan eliminated ambiguity.
|
|
27
|
+
|
|
28
|
+
#### What caused friction (user side)
|
|
29
|
+
|
|
30
|
+
No friction points — the three-phase slash-command workflow required no manual intervention.
|
package/package.json
CHANGED
package/src/agent-manager.ts
CHANGED
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
10
|
import type { Model } from "@earendil-works/pi-ai";
|
|
11
11
|
import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { AgentRecord } from "./agent-record.js";
|
|
12
13
|
import type { AgentRunner, ToolActivity } from "./agent-runner.js";
|
|
13
14
|
import { debugLog } from "./debug.js";
|
|
14
15
|
import type { RunConfig } from "./runtime.js";
|
|
15
|
-
import type { AgentInvocation,
|
|
16
|
+
import type { AgentInvocation, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
|
|
16
17
|
import { addUsage } from "./usage.js";
|
|
17
18
|
import type { WorktreeManager } from "./worktree.js";
|
|
18
19
|
|
|
@@ -133,18 +134,15 @@ export class AgentManager {
|
|
|
133
134
|
): string {
|
|
134
135
|
const id = randomUUID().slice(0, 17);
|
|
135
136
|
const abortController = new AbortController();
|
|
136
|
-
const record
|
|
137
|
+
const record = new AgentRecord({
|
|
137
138
|
id,
|
|
138
139
|
type,
|
|
139
140
|
description: options.description,
|
|
140
141
|
status: options.isBackground ? "queued" : "running",
|
|
141
|
-
toolUses: 0,
|
|
142
142
|
startedAt: Date.now(),
|
|
143
143
|
abortController,
|
|
144
|
-
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
|
|
145
|
-
compactionCount: 0,
|
|
146
144
|
invocation: options.invocation,
|
|
147
|
-
};
|
|
145
|
+
});
|
|
148
146
|
this.agents.set(id, record);
|
|
149
147
|
|
|
150
148
|
const args: SpawnArgs = { pi, ctx, type, prompt, options };
|
|
@@ -184,8 +182,7 @@ export class AgentManager {
|
|
|
184
182
|
worktreeCwd = wt.path;
|
|
185
183
|
}
|
|
186
184
|
|
|
187
|
-
record.
|
|
188
|
-
record.startedAt = Date.now();
|
|
185
|
+
record.markRunning(Date.now());
|
|
189
186
|
if (options.isBackground) this.runningBackground++;
|
|
190
187
|
this.onStart?.(record);
|
|
191
188
|
|
|
@@ -244,27 +241,26 @@ export class AgentManager {
|
|
|
244
241
|
},
|
|
245
242
|
})
|
|
246
243
|
.then(({ responseText, session, aborted, steered, sessionFile }) => {
|
|
247
|
-
// Don't overwrite status if externally stopped via abort()
|
|
248
|
-
if (record.status !== "stopped") {
|
|
249
|
-
record.status = aborted ? "aborted" : steered ? "steered" : "completed";
|
|
250
|
-
}
|
|
251
|
-
record.result = responseText;
|
|
252
|
-
record.session = session;
|
|
253
|
-
record.completedAt ??= Date.now();
|
|
254
|
-
if (sessionFile) record.outputFile = sessionFile;
|
|
255
|
-
|
|
256
244
|
detach();
|
|
257
245
|
|
|
258
|
-
// Clean up worktree
|
|
246
|
+
// Clean up worktree before transition so the final result includes branch text
|
|
247
|
+
let finalResult = responseText;
|
|
259
248
|
if (record.worktree) {
|
|
260
249
|
const wtResult = this.worktrees.cleanup(record.worktree, options.description);
|
|
261
250
|
record.worktreeResult = wtResult;
|
|
262
251
|
if (wtResult.hasChanges && wtResult.branch) {
|
|
263
|
-
|
|
264
|
-
`\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
|
|
252
|
+
finalResult += `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
|
|
265
253
|
}
|
|
266
254
|
}
|
|
267
255
|
|
|
256
|
+
// Transition — guards against overwriting externally-stopped status
|
|
257
|
+
if (aborted) record.markAborted(finalResult);
|
|
258
|
+
else if (steered) record.markSteered(finalResult);
|
|
259
|
+
else record.markCompleted(finalResult);
|
|
260
|
+
|
|
261
|
+
record.session = session;
|
|
262
|
+
if (sessionFile) record.outputFile = sessionFile;
|
|
263
|
+
|
|
268
264
|
if (options.isBackground) {
|
|
269
265
|
this.runningBackground--;
|
|
270
266
|
try { this.onComplete?.(record); } catch (err) { debugLog("onComplete callback", err); }
|
|
@@ -273,17 +269,10 @@ export class AgentManager {
|
|
|
273
269
|
return responseText;
|
|
274
270
|
})
|
|
275
271
|
.catch((err) => {
|
|
276
|
-
|
|
277
|
-
if (record.status !== "stopped") {
|
|
278
|
-
record.status = "error";
|
|
279
|
-
}
|
|
280
|
-
record.error = err instanceof Error ? err.message : String(err);
|
|
281
|
-
record.completedAt ??= Date.now();
|
|
272
|
+
record.markError(err);
|
|
282
273
|
|
|
283
274
|
detach();
|
|
284
275
|
|
|
285
|
-
|
|
286
|
-
|
|
287
276
|
// Best-effort worktree cleanup on error
|
|
288
277
|
if (record.worktree) {
|
|
289
278
|
try {
|
|
@@ -314,9 +303,7 @@ export class AgentManager {
|
|
|
314
303
|
} catch (err) {
|
|
315
304
|
// Late failure (e.g. strict worktree-isolation) — surface on the record
|
|
316
305
|
// so the user/agent can see it via /agents, then keep draining.
|
|
317
|
-
record.
|
|
318
|
-
record.error = err instanceof Error ? err.message : String(err);
|
|
319
|
-
record.completedAt = Date.now();
|
|
306
|
+
record.markError(err);
|
|
320
307
|
this.onComplete?.(record);
|
|
321
308
|
}
|
|
322
309
|
}
|
|
@@ -350,11 +337,7 @@ export class AgentManager {
|
|
|
350
337
|
const record = this.agents.get(id);
|
|
351
338
|
if (!record?.session) return undefined;
|
|
352
339
|
|
|
353
|
-
record.
|
|
354
|
-
record.startedAt = Date.now();
|
|
355
|
-
record.completedAt = undefined;
|
|
356
|
-
record.result = undefined;
|
|
357
|
-
record.error = undefined;
|
|
340
|
+
record.resetForResume(Date.now());
|
|
358
341
|
|
|
359
342
|
try {
|
|
360
343
|
const responseText = await this.runner.resume(record.session, prompt, {
|
|
@@ -370,13 +353,9 @@ export class AgentManager {
|
|
|
370
353
|
},
|
|
371
354
|
signal,
|
|
372
355
|
});
|
|
373
|
-
record.
|
|
374
|
-
record.result = responseText;
|
|
375
|
-
record.completedAt = Date.now();
|
|
356
|
+
record.markCompleted(responseText);
|
|
376
357
|
} catch (err) {
|
|
377
|
-
record.
|
|
378
|
-
record.error = err instanceof Error ? err.message : String(err);
|
|
379
|
-
record.completedAt = Date.now();
|
|
358
|
+
record.markError(err);
|
|
380
359
|
}
|
|
381
360
|
|
|
382
361
|
return record;
|
|
@@ -399,15 +378,13 @@ export class AgentManager {
|
|
|
399
378
|
// Remove from queue if queued
|
|
400
379
|
if (record.status === "queued") {
|
|
401
380
|
this.queue = this.queue.filter(q => q.id !== id);
|
|
402
|
-
record.
|
|
403
|
-
record.completedAt = Date.now();
|
|
381
|
+
record.markStopped();
|
|
404
382
|
return true;
|
|
405
383
|
}
|
|
406
384
|
|
|
407
385
|
if (record.status !== "running") return false;
|
|
408
386
|
record.abortController?.abort();
|
|
409
|
-
record.
|
|
410
|
-
record.completedAt = Date.now();
|
|
387
|
+
record.markStopped();
|
|
411
388
|
return true;
|
|
412
389
|
}
|
|
413
390
|
|
|
@@ -452,8 +429,7 @@ export class AgentManager {
|
|
|
452
429
|
for (const queued of this.queue) {
|
|
453
430
|
const record = this.agents.get(queued.id);
|
|
454
431
|
if (record) {
|
|
455
|
-
record.
|
|
456
|
-
record.completedAt = Date.now();
|
|
432
|
+
record.markStopped();
|
|
457
433
|
count++;
|
|
458
434
|
}
|
|
459
435
|
}
|
|
@@ -462,8 +438,7 @@ export class AgentManager {
|
|
|
462
438
|
for (const record of this.agents.values()) {
|
|
463
439
|
if (record.status === "running") {
|
|
464
440
|
record.abortController?.abort();
|
|
465
|
-
record.
|
|
466
|
-
record.completedAt = Date.now();
|
|
441
|
+
record.markStopped();
|
|
467
442
|
count++;
|
|
468
443
|
}
|
|
469
444
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-record.ts — AgentRecord class with encapsulated status-transition logic.
|
|
3
|
+
*
|
|
4
|
+
* Status transitions (status, result, error, startedAt, completedAt) are owned
|
|
5
|
+
* by the class and exposed via transition methods. External code reads these
|
|
6
|
+
* fields through public properties but cannot write them directly.
|
|
7
|
+
*
|
|
8
|
+
* Non-transition state (session, toolUses, lifetimeUsage, etc.) remains public.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { AgentInvocation, SubagentType } from "./types.js";
|
|
13
|
+
import type { LifetimeUsage } from "./usage.js";
|
|
14
|
+
|
|
15
|
+
export type AgentRecordStatus =
|
|
16
|
+
| "queued"
|
|
17
|
+
| "running"
|
|
18
|
+
| "completed"
|
|
19
|
+
| "steered"
|
|
20
|
+
| "aborted"
|
|
21
|
+
| "stopped"
|
|
22
|
+
| "error";
|
|
23
|
+
|
|
24
|
+
export interface AgentRecordInit {
|
|
25
|
+
id: string;
|
|
26
|
+
type: SubagentType;
|
|
27
|
+
description: string;
|
|
28
|
+
status?: AgentRecordStatus;
|
|
29
|
+
startedAt?: number;
|
|
30
|
+
completedAt?: number;
|
|
31
|
+
result?: string;
|
|
32
|
+
error?: string;
|
|
33
|
+
toolUses?: number;
|
|
34
|
+
lifetimeUsage?: LifetimeUsage;
|
|
35
|
+
compactionCount?: number;
|
|
36
|
+
abortController?: AbortController;
|
|
37
|
+
invocation?: AgentInvocation;
|
|
38
|
+
session?: AgentSession;
|
|
39
|
+
promise?: Promise<string>;
|
|
40
|
+
resultConsumed?: boolean;
|
|
41
|
+
pendingSteers?: string[];
|
|
42
|
+
worktree?: { path: string; branch: string };
|
|
43
|
+
worktreeResult?: { hasChanges: boolean; branch?: string };
|
|
44
|
+
toolCallId?: string;
|
|
45
|
+
outputFile?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class AgentRecord {
|
|
49
|
+
// Identity — set once at construction
|
|
50
|
+
readonly id: string;
|
|
51
|
+
readonly type: SubagentType;
|
|
52
|
+
readonly description: string;
|
|
53
|
+
readonly invocation?: AgentInvocation;
|
|
54
|
+
|
|
55
|
+
// Transition state — encapsulated behind getters, mutated only via transition methods
|
|
56
|
+
private _status: AgentRecordStatus;
|
|
57
|
+
get status(): AgentRecordStatus { return this._status; }
|
|
58
|
+
|
|
59
|
+
private _result?: string;
|
|
60
|
+
get result(): string | undefined { return this._result; }
|
|
61
|
+
|
|
62
|
+
private _error?: string;
|
|
63
|
+
get error(): string | undefined { return this._error; }
|
|
64
|
+
|
|
65
|
+
private _startedAt: number;
|
|
66
|
+
get startedAt(): number { return this._startedAt; }
|
|
67
|
+
|
|
68
|
+
private _completedAt?: number;
|
|
69
|
+
get completedAt(): number | undefined { return this._completedAt; }
|
|
70
|
+
|
|
71
|
+
// Non-transition mutable state
|
|
72
|
+
toolUses: number;
|
|
73
|
+
lifetimeUsage: LifetimeUsage;
|
|
74
|
+
compactionCount: number;
|
|
75
|
+
session?: AgentSession;
|
|
76
|
+
abortController?: AbortController;
|
|
77
|
+
promise?: Promise<string>;
|
|
78
|
+
resultConsumed?: boolean;
|
|
79
|
+
pendingSteers?: string[];
|
|
80
|
+
worktree?: { path: string; branch: string };
|
|
81
|
+
worktreeResult?: { hasChanges: boolean; branch?: string };
|
|
82
|
+
toolCallId?: string;
|
|
83
|
+
outputFile?: string;
|
|
84
|
+
|
|
85
|
+
constructor(init: AgentRecordInit) {
|
|
86
|
+
this.id = init.id;
|
|
87
|
+
this.type = init.type;
|
|
88
|
+
this.description = init.description;
|
|
89
|
+
this.invocation = init.invocation;
|
|
90
|
+
|
|
91
|
+
this._status = init.status ?? "queued";
|
|
92
|
+
this._result = init.result;
|
|
93
|
+
this._error = init.error;
|
|
94
|
+
this._startedAt = init.startedAt ?? Date.now();
|
|
95
|
+
this._completedAt = init.completedAt;
|
|
96
|
+
|
|
97
|
+
this.toolUses = init.toolUses ?? 0;
|
|
98
|
+
this.lifetimeUsage = init.lifetimeUsage ?? { input: 0, output: 0, cacheWrite: 0 };
|
|
99
|
+
this.compactionCount = init.compactionCount ?? 0;
|
|
100
|
+
this.abortController = init.abortController;
|
|
101
|
+
this.session = init.session;
|
|
102
|
+
this.promise = init.promise;
|
|
103
|
+
this.resultConsumed = init.resultConsumed;
|
|
104
|
+
this.pendingSteers = init.pendingSteers;
|
|
105
|
+
this.worktree = init.worktree;
|
|
106
|
+
this.worktreeResult = init.worktreeResult;
|
|
107
|
+
this.toolCallId = init.toolCallId;
|
|
108
|
+
this.outputFile = init.outputFile;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Transition to running state. Sets status and startedAt. */
|
|
112
|
+
markRunning(startedAt: number): void {
|
|
113
|
+
this._status = "running";
|
|
114
|
+
this._startedAt = startedAt;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Transition to completed state.
|
|
119
|
+
* Always sets result and completedAt (??=). Only changes status if not stopped.
|
|
120
|
+
*/
|
|
121
|
+
markCompleted(result: string, completedAt?: number): void {
|
|
122
|
+
this._result = result;
|
|
123
|
+
this._completedAt ??= completedAt ?? Date.now();
|
|
124
|
+
if (this._status !== "stopped") {
|
|
125
|
+
this._status = "completed";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Transition to aborted state.
|
|
131
|
+
* Always sets result and completedAt (??=). Only changes status if not stopped.
|
|
132
|
+
*/
|
|
133
|
+
markAborted(result: string, completedAt?: number): void {
|
|
134
|
+
this._result = result;
|
|
135
|
+
this._completedAt ??= completedAt ?? Date.now();
|
|
136
|
+
if (this._status !== "stopped") {
|
|
137
|
+
this._status = "aborted";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Transition to steered state.
|
|
143
|
+
* Always sets result and completedAt (??=). Only changes status if not stopped.
|
|
144
|
+
*/
|
|
145
|
+
markSteered(result: string, completedAt?: number): void {
|
|
146
|
+
this._result = result;
|
|
147
|
+
this._completedAt ??= completedAt ?? Date.now();
|
|
148
|
+
if (this._status !== "stopped") {
|
|
149
|
+
this._status = "steered";
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Transition to error state.
|
|
155
|
+
* Always sets error (formatted) and completedAt (??=). Only changes status if not stopped.
|
|
156
|
+
*/
|
|
157
|
+
markError(error: unknown, completedAt?: number): void {
|
|
158
|
+
this._error = error instanceof Error ? error.message : String(error);
|
|
159
|
+
this._completedAt ??= completedAt ?? Date.now();
|
|
160
|
+
if (this._status !== "stopped") {
|
|
161
|
+
this._status = "error";
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Transition to stopped state. Always valid — no guard. */
|
|
166
|
+
markStopped(completedAt?: number): void {
|
|
167
|
+
this._status = "stopped";
|
|
168
|
+
this._completedAt = completedAt ?? Date.now();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Reset for resume: running status, new startedAt, clear completedAt/result/error. */
|
|
172
|
+
resetForResume(startedAt: number): void {
|
|
173
|
+
this._status = "running";
|
|
174
|
+
this._startedAt = startedAt;
|
|
175
|
+
this._completedAt = undefined;
|
|
176
|
+
this._result = undefined;
|
|
177
|
+
this._error = undefined;
|
|
178
|
+
}
|
|
179
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
6
|
-
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import type { LifetimeUsage } from "./usage.js";
|
|
8
6
|
|
|
7
|
+
export type { AgentRecordInit, AgentRecordStatus } from "./agent-record.js";
|
|
8
|
+
|
|
9
|
+
export { AgentRecord } from "./agent-record.js";
|
|
9
10
|
export type { ThinkingLevel };
|
|
10
11
|
|
|
11
12
|
/** Agent type: any string name (built-in defaults or user-defined). */
|
|
@@ -55,43 +56,6 @@ export interface AgentConfig {
|
|
|
55
56
|
source?: "default" | "project" | "global";
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
export interface AgentRecord {
|
|
59
|
-
id: string;
|
|
60
|
-
type: SubagentType;
|
|
61
|
-
description: string;
|
|
62
|
-
status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
|
|
63
|
-
result?: string;
|
|
64
|
-
error?: string;
|
|
65
|
-
toolUses: number;
|
|
66
|
-
startedAt: number;
|
|
67
|
-
completedAt?: number;
|
|
68
|
-
session?: AgentSession;
|
|
69
|
-
abortController?: AbortController;
|
|
70
|
-
promise?: Promise<string>;
|
|
71
|
-
/** Set when result was already consumed via get_subagent_result — suppresses completion notification. */
|
|
72
|
-
resultConsumed?: boolean;
|
|
73
|
-
/** Steering messages queued before the session was ready. */
|
|
74
|
-
pendingSteers?: string[];
|
|
75
|
-
/** Worktree info if the agent is running in an isolated worktree. */
|
|
76
|
-
worktree?: { path: string; branch: string };
|
|
77
|
-
/** Worktree cleanup result after agent completion. */
|
|
78
|
-
worktreeResult?: { hasChanges: boolean; branch?: string };
|
|
79
|
-
/** The tool_use_id from the original Agent tool call. */
|
|
80
|
-
toolCallId?: string;
|
|
81
|
-
/** Path to the persisted session transcript file. */
|
|
82
|
-
outputFile?: string;
|
|
83
|
-
/**
|
|
84
|
-
* Lifetime usage breakdown, accumulated via `message_end` events. Survives
|
|
85
|
-
* compaction. Total = input + output + cacheWrite (cacheRead deliberately
|
|
86
|
-
* excluded — see issue #38). Initialized to zeros at spawn.
|
|
87
|
-
*/
|
|
88
|
-
lifetimeUsage: LifetimeUsage;
|
|
89
|
-
/** Number of times this agent's session has compacted. Initialized to 0 at spawn. */
|
|
90
|
-
compactionCount: number;
|
|
91
|
-
/** Resolved spawn params, captured for UI display. Fixed at spawn time. */
|
|
92
|
-
invocation?: AgentInvocation;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
59
|
export interface AgentInvocation {
|
|
96
60
|
/** Short display name, e.g. "haiku" — only set when different from parent. */
|
|
97
61
|
modelName?: string;
|