@gotgenes/pi-subagents 6.7.0 → 6.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/docs/architecture/architecture.md +30 -29
- package/docs/plans/0111-split-agent-record-lifecycle.md +582 -0
- package/docs/plans/0123-remove-vi-fn-cast-smell.md +179 -0
- package/docs/retro/0110-agent-activity-tracker.md +44 -0
- package/docs/retro/0111-split-agent-record-lifecycle.md +61 -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 +9 -6
- package/src/record-observer.ts +6 -7
- package/src/service-adapter.ts +8 -7
- package/src/tools/agent-tool.ts +6 -4
- package/src/tools/get-result-tool.ts +7 -5
- package/src/tools/steer-tool.ts +8 -6
- package/src/ui/agent-menu.ts +2 -2
- package/src/worktree-state.ts +35 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 123
|
|
3
|
+
issue_title: "refactor(pi-subagents): remove vi.fn() cast smell from test helpers"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Remove vi.fn() cast smell from test helpers
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
Several test files construct mock objects typed to narrow interfaces (`AgentManagerLike`, `LifecycleRuntime`, `LifecycleManager`, `ToolStartRuntime`).
|
|
11
|
+
Because the returned objects are typed to the interface — not to Vitest's mock types — tests that need to configure individual method stubs are forced to cast:
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
(deps.manager.abort as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
|
15
|
+
(deps.manager.getRecord as ReturnType<typeof vi.fn>).mockReturnValue(record);
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
This silences TypeScript without constraining the call's return type — if `getRecord`'s return type changes, the cast won't catch it.
|
|
19
|
+
Nine occurrences exist across three test files.
|
|
20
|
+
|
|
21
|
+
## Goals
|
|
22
|
+
|
|
23
|
+
- Eliminate all `as ReturnType<typeof vi.fn>` casts from the test suite.
|
|
24
|
+
- Preserve type safety: mock configuration calls should be checked against the real method signatures.
|
|
25
|
+
- Keep the change minimal — this is a test hygiene fix, not a structural redesign.
|
|
26
|
+
|
|
27
|
+
## Non-Goals
|
|
28
|
+
|
|
29
|
+
- Changing `AgentManagerLike`, `LifecycleRuntime`, `LifecycleManager`, `ToolStartRuntime`, or any production code.
|
|
30
|
+
- Restructuring test layout or merging describe blocks.
|
|
31
|
+
|
|
32
|
+
## Background
|
|
33
|
+
|
|
34
|
+
The cast pattern was noted during #111 implementation and preserved to keep scope tight.
|
|
35
|
+
Issue #111 (split `AgentRecord` lifecycle state) is now closed and implemented.
|
|
36
|
+
|
|
37
|
+
### Affected files
|
|
38
|
+
|
|
39
|
+
| File | Occurrences | Interface |
|
|
40
|
+
| ---------------------------------- | ----------- | -------------------------------------- |
|
|
41
|
+
| `test/service-adapter.test.ts` | 5 | `AgentManagerLike` |
|
|
42
|
+
| `test/handlers/lifecycle.test.ts` | 2 | `LifecycleRuntime`, `LifecycleManager` |
|
|
43
|
+
| `test/handlers/tool-start.test.ts` | 2 | `ToolStartRuntime` |
|
|
44
|
+
|
|
45
|
+
### Cast sites by file
|
|
46
|
+
|
|
47
|
+
`service-adapter.test.ts` — the "steer, abort, waitForAll, hasRunning" block's `createDeps` returns `AdapterDeps` directly.
|
|
48
|
+
Five casts reconfigure `getRecord`, `abort`, or `queueSteer` after construction:
|
|
49
|
+
|
|
50
|
+
1. `(deps.manager.abort as ReturnType<typeof vi.fn>).mockReturnValue(false)`
|
|
51
|
+
2. `(deps.manager.getRecord as ReturnType<typeof vi.fn>).mockReturnValue({...})` (×4)
|
|
52
|
+
|
|
53
|
+
`lifecycle.test.ts` — mock objects are assigned to `let` variables in `beforeEach`, typed to `LifecycleRuntime` and `LifecycleManager`.
|
|
54
|
+
Two casts reconfigure methods to track call order:
|
|
55
|
+
|
|
56
|
+
1. `(runtime.setSessionContext as ReturnType<typeof vi.fn>).mockImplementation(...)`
|
|
57
|
+
2. `(manager.clearCompleted as ReturnType<typeof vi.fn>).mockImplementation(...)`
|
|
58
|
+
|
|
59
|
+
Note: the same file already uses `vi.mocked()` in the shutdown-order test — both patterns coexist, which is itself a consistency smell.
|
|
60
|
+
|
|
61
|
+
`tool-start.test.ts` — mock object assigned to a `let` variable typed to `ToolStartRuntime`.
|
|
62
|
+
Two casts reconfigure methods to track call order:
|
|
63
|
+
|
|
64
|
+
1. `(runtime.setUICtx as ReturnType<typeof vi.fn>).mockImplementation(...)`
|
|
65
|
+
2. `(runtime.onTurnStart as ReturnType<typeof vi.fn>).mockImplementation(...)`
|
|
66
|
+
|
|
67
|
+
### Approach: named-variable extraction
|
|
68
|
+
|
|
69
|
+
Extract individual `vi.fn()` stubs into named variables.
|
|
70
|
+
This is the approach the issue recommends and it aligns with the testing skill's guidance on extractable stubs.
|
|
71
|
+
|
|
72
|
+
The alternative — `vi.mocked()` — is already used in `lifecycle.test.ts` for the shutdown-order test and works for hand-built mocks, but is semantically less clean: `vi.mocked()` asserts that a value is already a mock, which is true here but opaque to readers.
|
|
73
|
+
Named variables make the mock-ness explicit at the construction site.
|
|
74
|
+
|
|
75
|
+
For `lifecycle.test.ts`, the named-variable approach also eliminates the inconsistency between the two ordering tests — one currently uses `vi.mocked()` and the other uses casts.
|
|
76
|
+
After this change both will use named stubs.
|
|
77
|
+
|
|
78
|
+
## Design Overview
|
|
79
|
+
|
|
80
|
+
### service-adapter.test.ts
|
|
81
|
+
|
|
82
|
+
Refactor the "steer, abort, waitForAll, hasRunning" block's `createDeps` to return named stubs:
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
function createDeps(overrides: Partial<AdapterDeps> = {}) {
|
|
86
|
+
const mockGetRecord = vi.fn<AgentManagerLike["getRecord"]>();
|
|
87
|
+
const mockAbort = vi.fn<AgentManagerLike["abort"]>(() => true);
|
|
88
|
+
const mockQueueSteer = vi.fn<AgentManagerLike["queueSteer"]>(() => true);
|
|
89
|
+
|
|
90
|
+
const deps: AdapterDeps = {
|
|
91
|
+
manager: {
|
|
92
|
+
spawn: vi.fn(() => "id"),
|
|
93
|
+
getRecord: mockGetRecord,
|
|
94
|
+
listAgents: vi.fn(() => []),
|
|
95
|
+
abort: mockAbort,
|
|
96
|
+
waitForAll: vi.fn(async () => {}),
|
|
97
|
+
hasRunning: vi.fn(() => true),
|
|
98
|
+
queueSteer: mockQueueSteer,
|
|
99
|
+
},
|
|
100
|
+
resolveModel: vi.fn(),
|
|
101
|
+
getCtx: () => ({ pi: {}, ctx: {} }),
|
|
102
|
+
getModelRegistry: () => ({ find: () => null, getAll: () => [] }),
|
|
103
|
+
...overrides,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return { deps, mockGetRecord, mockAbort, mockQueueSteer };
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Callers destructure what they need:
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
const { deps, mockAbort } = createDeps();
|
|
114
|
+
mockAbort.mockReturnValue(false); // ← type-checked, no cast
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### lifecycle.test.ts
|
|
118
|
+
|
|
119
|
+
Promote the `beforeEach`-scoped `runtime` and `manager` mock construction to use named stubs.
|
|
120
|
+
The stubs that need reconfiguration (`setSessionContext`, `clearCompleted`) become named `let` variables alongside the existing `runtime`/`manager` lets, reset in `beforeEach`:
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
let mockSetSessionContext: MockInstance<LifecycleRuntime["setSessionContext"]>;
|
|
124
|
+
let mockClearCompleted: MockInstance<LifecycleManager["clearCompleted"]>;
|
|
125
|
+
// ...assigned in beforeEach when building runtime/manager
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Also convert the shutdown-order test's `vi.mocked()` calls to the same pattern for consistency — `unpublishService`, `clearSessionContext`, `abortAll`, `disposeNotifications`, `dispose` all become named stubs.
|
|
129
|
+
|
|
130
|
+
### tool-start.test.ts
|
|
131
|
+
|
|
132
|
+
Same pattern: promote `setUICtx` and `onTurnStart` to named `let` variables:
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
let mockSetUICtx: MockInstance<ToolStartRuntime["setUICtx"]>;
|
|
136
|
+
let mockOnTurnStart: MockInstance<ToolStartRuntime["onTurnStart"]>;
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Module-Level Changes
|
|
140
|
+
|
|
141
|
+
| File | Change |
|
|
142
|
+
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
143
|
+
| `test/service-adapter.test.ts` | Refactor `createDeps` in the "steer, abort, waitForAll, hasRunning" block to return named mock stubs alongside `deps`. Update all 5 cast sites to use named stubs. |
|
|
144
|
+
| `test/handlers/lifecycle.test.ts` | Extract `mockSetSessionContext`, `mockClearCompleted`, `mockAbortAll`, `mockDispose`, `mockClearSessionContext` as named `let` variables. Replace 2 casts and 5 `vi.mocked()` calls with named stubs. |
|
|
145
|
+
| `test/handlers/tool-start.test.ts` | Extract `mockSetUICtx` and `mockOnTurnStart` as named `let` variables. Replace 2 casts with named stubs. |
|
|
146
|
+
|
|
147
|
+
No production files are changed.
|
|
148
|
+
|
|
149
|
+
## Test Impact Analysis
|
|
150
|
+
|
|
151
|
+
1. No new tests are added — this is a refactoring of existing test infrastructure.
|
|
152
|
+
2. No tests become redundant — every existing assertion stays.
|
|
153
|
+
3. All existing tests must pass unchanged; only the mock-wiring changes.
|
|
154
|
+
|
|
155
|
+
## TDD Order
|
|
156
|
+
|
|
157
|
+
1. **Commit:** Refactor `createDeps` in `service-adapter.test.ts` to return named stubs; update all 5 cast sites.
|
|
158
|
+
All tests pass before and after.
|
|
159
|
+
Commit: `test: remove vi.fn() cast smell from service-adapter tests (#123)`
|
|
160
|
+
2. **Commit:** Extract named stubs in `lifecycle.test.ts`; replace 2 casts and 5 `vi.mocked()` calls.
|
|
161
|
+
All tests pass.
|
|
162
|
+
Commit: `test: remove vi.fn() cast smell from lifecycle tests (#123)`
|
|
163
|
+
3. **Commit:** Extract named stubs in `tool-start.test.ts`; replace 2 casts.
|
|
164
|
+
All tests pass.
|
|
165
|
+
Commit: `test: remove vi.fn() cast smell from tool-start tests (#123)`
|
|
166
|
+
|
|
167
|
+
Each step is an independent file — order doesn't matter, but one-file-per-commit keeps diffs reviewable.
|
|
168
|
+
|
|
169
|
+
## Risks and Mitigations
|
|
170
|
+
|
|
171
|
+
| Risk | Mitigation |
|
|
172
|
+
| ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
173
|
+
| Overrides via `...overrides` in `service-adapter.test.ts` could replace a manager method, leaving the named stub disconnected | Only `manager`-level overrides are spread; individual method overrides aren't used in this block. |
|
|
174
|
+
| Named stubs add return-surface to helpers | Each helper is test-local and the extra names are self-documenting. The alternative (casting) is worse. |
|
|
175
|
+
| Converting `vi.mocked()` in `lifecycle.test.ts` shutdown test expands scope slightly beyond the cast pattern | Worth it for consistency — mixing `vi.mocked()` and named stubs in the same file is a different smell. |
|
|
176
|
+
|
|
177
|
+
## Open Questions
|
|
178
|
+
|
|
179
|
+
None — the issue is fully scoped and the approach is established in the codebase.
|
|
@@ -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.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 111
|
|
3
|
+
issue_title: "refactor(pi-subagents): split AgentRecord lifecycle state into phase-specific objects"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #111 — split AgentRecord lifecycle state into phase-specific objects
|
|
7
|
+
|
|
8
|
+
## Final Retrospective (2026-05-22T01:50:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Planned and implemented the `AgentRecord` lifecycle split across 12 TDD cycles plus doc updates, released as `pi-subagents-v6.8.0`.
|
|
13
|
+
Three new phase-specific collaborators (`ExecutionState`, `WorktreeState`, `NotificationState`) replace 9 post-construction mutable fields.
|
|
14
|
+
`pendingSteers` moved to a `Map` on `AgentManager`; stats (`toolUses`, `lifetimeUsage`, `compactionCount`) encapsulated behind mutation methods with read-only getters.
|
|
15
|
+
`AgentRecordInit` trimmed from 19 optional fields to 4.
|
|
16
|
+
|
|
17
|
+
### Observations
|
|
18
|
+
|
|
19
|
+
#### What went well
|
|
20
|
+
|
|
21
|
+
- **Lift-and-shift scaled from 7 files (#110) to 18 files (#111) without any intermediate test breakage.**
|
|
22
|
+
Every commit left all 41 test files passing.
|
|
23
|
+
The pattern — add new alongside old, migrate consumers with fallbacks (`record.execution?.session ?? record.session`), strip fallbacks in a final commit — is reliable for multi-step encapsulation refactors.
|
|
24
|
+
- **Stats encapsulation was simpler than expected.**
|
|
25
|
+
Converting `toolUses`, `lifetimeUsage`, `compactionCount` to private fields with getters and mutation methods required zero changes to read-only consumers because the getter names match the old field names.
|
|
26
|
+
Only `record-observer.ts` (the sole writer) needed updating.
|
|
27
|
+
- **The `createTestRecord` factory intersection type trick preserved backward compatibility.**
|
|
28
|
+
The factory accepts `toolUses?: number` via `Partial<AgentRecordInit> & { toolUses?: number; ... }` and internally calls `record.incrementToolUses()` in a loop.
|
|
29
|
+
This let 10+ test files continue passing `toolUses: 5` without rewriting each to call mutation methods directly.
|
|
30
|
+
- **`Promise.withResolvers` timing analysis in the plan was unnecessary.**
|
|
31
|
+
The plan spent ~40 lines analyzing whether `promise` should live inside `ExecutionState` and concluded it should stay separate.
|
|
32
|
+
Implementation confirmed: `record.execution` is set in `onSessionCreated` (async callback), `record.promise` is set after `runner.run()` (synchronous return) — different moments, straightforward.
|
|
33
|
+
|
|
34
|
+
#### What caused friction (agent side)
|
|
35
|
+
|
|
36
|
+
- `missing-context` — In the step 7 test for `record.execution`, the initial mock runner used `mockResolvedValue(...)` which doesn't call `onSessionCreated`, so `record.execution` stayed `undefined`.
|
|
37
|
+
Had to switch to `mockImplementation(async (..., opts) => { opts.onSessionCreated?.(session); ... })`.
|
|
38
|
+
The existing tests in the same file already use this pattern for record-observer tests, but I didn't check them first.
|
|
39
|
+
Impact: one test rewrite (~2 minutes), no rework to production code.
|
|
40
|
+
- `scope-drift` — Step 4 absorbed step 5 (adding collaborator fields) without noting the merge in the commit or session log.
|
|
41
|
+
Step 5 became a no-op.
|
|
42
|
+
Impact: no rework, but the session narrative skipped a plan step without explanation.
|
|
43
|
+
- `wrong-abstraction` — Step 12 was planned as a simple cleanup ("remove old fields and trim `AgentRecordInit`") but required coordinated changes across 18 files: removing 9 fields from `AgentRecordInit`, updating the `createTestRecord` factory, fixing 5 test files that passed removed fields, and stripping all fallback patterns.
|
|
44
|
+
This was 2-3 steps' worth of work compressed into one.
|
|
45
|
+
Impact: step 12 took significantly longer than other steps, though it landed cleanly.
|
|
46
|
+
- `missing-context` — Did not proactively flag the `as ReturnType<typeof vi.fn>` cast smell in `service-adapter.test.ts` while migrating that file.
|
|
47
|
+
The user noticed it and asked about it.
|
|
48
|
+
Filed as #123.
|
|
49
|
+
Impact: added friction but no rework; follow-up issue created.
|
|
50
|
+
User-caught.
|
|
51
|
+
|
|
52
|
+
#### What caused friction (user side)
|
|
53
|
+
|
|
54
|
+
- No material friction observed.
|
|
55
|
+
The user's `ask_user` decisions during planning (NotificationState collaborator, Map on AgentManager) gave clear direction.
|
|
56
|
+
Quick "follow-up" response on the cast smell kept scope tight.
|
|
57
|
+
|
|
58
|
+
### Changes made
|
|
59
|
+
|
|
60
|
+
1. `packages/pi-subagents/docs/retro/0111-split-agent-record-lifecycle.md` — this retro file.
|
|
61
|
+
2. `.pi/skills/testing/SKILL.md` — added field-removal rule symmetric to the existing field-addition rule (esbuild silent pass-through on unknown init properties).
|
package/package.json
CHANGED
package/src/agent-manager.ts
CHANGED
|
@@ -13,11 +13,13 @@ import { AgentRecord } from "./agent-record.js";
|
|
|
13
13
|
import type { AgentRunner } from "./agent-runner.js";
|
|
14
14
|
import { AgentTypeRegistry } from "./agent-types.js";
|
|
15
15
|
import { debugLog } from "./debug.js";
|
|
16
|
+
import type { ExecutionState } from "./execution-state.js";
|
|
16
17
|
import { buildParentSnapshot } from "./parent-snapshot.js";
|
|
17
18
|
import { subscribeRecordObserver } from "./record-observer.js";
|
|
18
19
|
import type { RunConfig } from "./runtime.js";
|
|
19
20
|
import type { AgentInvocation, IsolationMode, ParentSnapshot, ShellExec, SubagentType, ThinkingLevel } from "./types.js";
|
|
20
21
|
import type { WorktreeManager } from "./worktree.js";
|
|
22
|
+
import { WorktreeState } from "./worktree-state.js";
|
|
21
23
|
|
|
22
24
|
export type OnAgentComplete = (record: AgentRecord) => void;
|
|
23
25
|
export type OnAgentStart = (record: AgentRecord) => void;
|
|
@@ -92,6 +94,8 @@ export class AgentManager {
|
|
|
92
94
|
private queue: { id: string; args: SpawnArgs }[] = [];
|
|
93
95
|
/** Number of currently running background agents. */
|
|
94
96
|
private runningBackground = 0;
|
|
97
|
+
/** Steers buffered for agents whose session hasn’t been created yet. */
|
|
98
|
+
private pendingSteers = new Map<string, string[]>();
|
|
95
99
|
|
|
96
100
|
constructor(options: AgentManagerOptions) {
|
|
97
101
|
this.runner = options.runner;
|
|
@@ -116,6 +120,19 @@ export class AgentManager {
|
|
|
116
120
|
this.drainQueue();
|
|
117
121
|
}
|
|
118
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Buffer a steer message for an agent whose session isn’t ready yet.
|
|
125
|
+
* Returns false if the agent id is not tracked (already cleaned up or unknown).
|
|
126
|
+
* Called by steer-tool and service-adapter when record.execution is undefined.
|
|
127
|
+
*/
|
|
128
|
+
queueSteer(id: string, message: string): boolean {
|
|
129
|
+
if (!this.agents.has(id)) return false;
|
|
130
|
+
const steers = this.pendingSteers.get(id) ?? [];
|
|
131
|
+
steers.push(message);
|
|
132
|
+
this.pendingSteers.set(id, steers);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
119
136
|
/**
|
|
120
137
|
* Spawn an agent and return its ID immediately (for background use).
|
|
121
138
|
* If the concurrency limit is reached, the agent is queued.
|
|
@@ -173,7 +190,7 @@ export class AgentManager {
|
|
|
173
190
|
'Initialize git and commit at least once, or omit `isolation`.',
|
|
174
191
|
);
|
|
175
192
|
}
|
|
176
|
-
record.
|
|
193
|
+
record.worktreeState = new WorktreeState(wt);
|
|
177
194
|
worktreeCwd = wt.path;
|
|
178
195
|
}
|
|
179
196
|
|
|
@@ -207,17 +224,18 @@ export class AgentManager {
|
|
|
207
224
|
signal: record.abortController!.signal,
|
|
208
225
|
registry: this.registry,
|
|
209
226
|
onSessionCreated: (session) => {
|
|
210
|
-
record.session = session;
|
|
211
227
|
// Capture the session file path early so it's available for display
|
|
212
228
|
// before the run completes (e.g. in background agent status messages).
|
|
213
|
-
const
|
|
214
|
-
|
|
229
|
+
const outputFile = session.sessionManager?.getSessionFile?.() ?? undefined;
|
|
230
|
+
// Set the execution-state collaborator — born complete at session creation.
|
|
231
|
+
record.execution = { session, outputFile };
|
|
215
232
|
// Flush any steers that arrived before the session was ready
|
|
216
|
-
|
|
217
|
-
|
|
233
|
+
const buffered = this.pendingSteers.get(id);
|
|
234
|
+
if (buffered?.length) {
|
|
235
|
+
for (const msg of buffered) {
|
|
218
236
|
session.steer(msg).catch(() => {});
|
|
219
237
|
}
|
|
220
|
-
|
|
238
|
+
this.pendingSteers.delete(id);
|
|
221
239
|
}
|
|
222
240
|
// Subscribe record observer for stats accumulation
|
|
223
241
|
unsubRecordObserver = subscribeRecordObserver(session, record, {
|
|
@@ -232,9 +250,9 @@ export class AgentManager {
|
|
|
232
250
|
|
|
233
251
|
// Clean up worktree before transition so the final result includes branch text
|
|
234
252
|
let finalResult = responseText;
|
|
235
|
-
if (record.
|
|
236
|
-
const wtResult = this.worktrees.cleanup(record.
|
|
237
|
-
record.
|
|
253
|
+
if (record.worktreeState) {
|
|
254
|
+
const wtResult = this.worktrees.cleanup(record.worktreeState, options.description);
|
|
255
|
+
record.worktreeState.recordCleanup(wtResult);
|
|
238
256
|
if (wtResult.hasChanges && wtResult.branch) {
|
|
239
257
|
finalResult += `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
|
|
240
258
|
}
|
|
@@ -245,8 +263,8 @@ export class AgentManager {
|
|
|
245
263
|
else if (steered) record.markSteered(finalResult);
|
|
246
264
|
else record.markCompleted(finalResult);
|
|
247
265
|
|
|
248
|
-
|
|
249
|
-
|
|
266
|
+
// Update execution collaborator with final session/outputFile from runner
|
|
267
|
+
record.execution = { session, outputFile: sessionFile ?? record.execution?.outputFile };
|
|
250
268
|
|
|
251
269
|
if (options.isBackground) {
|
|
252
270
|
this.runningBackground--;
|
|
@@ -262,10 +280,11 @@ export class AgentManager {
|
|
|
262
280
|
detach();
|
|
263
281
|
|
|
264
282
|
// Best-effort worktree cleanup on error
|
|
265
|
-
if (record.
|
|
283
|
+
if (record.worktreeState) {
|
|
266
284
|
try {
|
|
267
|
-
const wtResult = this.worktrees.cleanup(record.
|
|
268
|
-
record.
|
|
285
|
+
const wtResult = this.worktrees.cleanup(record.worktreeState, options.description);
|
|
286
|
+
record.worktreeState.recordCleanup(wtResult);
|
|
287
|
+
|
|
269
288
|
} catch (err) { debugLog("cleanupWorktree on agent error", err); }
|
|
270
289
|
}
|
|
271
290
|
|
|
@@ -322,16 +341,17 @@ export class AgentManager {
|
|
|
322
341
|
signal?: AbortSignal,
|
|
323
342
|
): Promise<AgentRecord | undefined> {
|
|
324
343
|
const record = this.agents.get(id);
|
|
325
|
-
|
|
344
|
+
const session = record?.execution?.session;
|
|
345
|
+
if (!session) return undefined;
|
|
326
346
|
|
|
327
347
|
record.resetForResume(Date.now());
|
|
328
348
|
|
|
329
|
-
const unsubResume = subscribeRecordObserver(
|
|
349
|
+
const unsubResume = subscribeRecordObserver(session, record, {
|
|
330
350
|
onCompact: (r, info) => this.onCompact?.(r, info),
|
|
331
351
|
});
|
|
332
352
|
|
|
333
353
|
try {
|
|
334
|
-
const responseText = await this.runner.resume(
|
|
354
|
+
const responseText = await this.runner.resume(session, prompt, {
|
|
335
355
|
signal,
|
|
336
356
|
});
|
|
337
357
|
record.markCompleted(responseText);
|
|
@@ -373,9 +393,9 @@ export class AgentManager {
|
|
|
373
393
|
|
|
374
394
|
/** Dispose a record's session and remove it from the map. */
|
|
375
395
|
private removeRecord(id: string, record: AgentRecord): void {
|
|
376
|
-
record.session?.dispose?.();
|
|
377
|
-
record.session = undefined;
|
|
396
|
+
record.execution?.session?.dispose?.();
|
|
378
397
|
this.agents.delete(id);
|
|
398
|
+
this.pendingSteers.delete(id);
|
|
379
399
|
}
|
|
380
400
|
|
|
381
401
|
private cleanup() {
|
|
@@ -448,7 +468,7 @@ export class AgentManager {
|
|
|
448
468
|
// Clear queue
|
|
449
469
|
this.queue = [];
|
|
450
470
|
for (const record of this.agents.values()) {
|
|
451
|
-
record.session?.dispose();
|
|
471
|
+
record.execution?.session?.dispose();
|
|
452
472
|
}
|
|
453
473
|
this.agents.clear();
|
|
454
474
|
// Prune any orphaned git worktrees (crash recovery)
|
package/src/agent-record.ts
CHANGED
|
@@ -5,12 +5,19 @@
|
|
|
5
5
|
* by the class and exposed via transition methods. External code reads these
|
|
6
6
|
* fields through public properties but cannot write them directly.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* Stats (toolUses, lifetimeUsage, compactionCount) are owned by the class and
|
|
9
|
+
* accumulated via mutation methods (incrementToolUses, addUsage, incrementCompactions).
|
|
10
|
+
*
|
|
11
|
+
* Phase-specific collaborators (execution, worktreeState, notification) are attached
|
|
12
|
+
* after construction as lifecycle information becomes available.
|
|
9
13
|
*/
|
|
10
14
|
|
|
11
|
-
import type {
|
|
15
|
+
import type { ExecutionState } from "./execution-state.js";
|
|
16
|
+
import type { NotificationState } from "./notification-state.js";
|
|
12
17
|
import type { AgentInvocation, SubagentType } from "./types.js";
|
|
13
18
|
import type { LifetimeUsage } from "./usage.js";
|
|
19
|
+
import { addUsage } from "./usage.js";
|
|
20
|
+
import type { WorktreeState } from "./worktree-state.js";
|
|
14
21
|
|
|
15
22
|
export type AgentRecordStatus =
|
|
16
23
|
| "queued"
|
|
@@ -30,19 +37,9 @@ export interface AgentRecordInit {
|
|
|
30
37
|
completedAt?: number;
|
|
31
38
|
result?: string;
|
|
32
39
|
error?: string;
|
|
33
|
-
toolUses?: number;
|
|
34
|
-
lifetimeUsage?: LifetimeUsage;
|
|
35
|
-
compactionCount?: number;
|
|
36
40
|
abortController?: AbortController;
|
|
37
41
|
invocation?: AgentInvocation;
|
|
38
|
-
session?: AgentSession;
|
|
39
42
|
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
43
|
}
|
|
47
44
|
|
|
48
45
|
export class AgentRecord {
|
|
@@ -68,19 +65,25 @@ export class AgentRecord {
|
|
|
68
65
|
private _completedAt?: number;
|
|
69
66
|
get completedAt(): number | undefined { return this._completedAt; }
|
|
70
67
|
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
68
|
+
// Stats — accumulated via mutation methods, readable via getters
|
|
69
|
+
private _toolUses: number;
|
|
70
|
+
get toolUses(): number { return this._toolUses; }
|
|
71
|
+
|
|
72
|
+
private _lifetimeUsage: LifetimeUsage;
|
|
73
|
+
get lifetimeUsage(): Readonly<LifetimeUsage> { return this._lifetimeUsage; }
|
|
74
|
+
|
|
75
|
+
private _compactionCount: number;
|
|
76
|
+
get compactionCount(): number { return this._compactionCount; }
|
|
77
|
+
|
|
78
|
+
/** AbortController for cancelling this agent. Set at construction; used only by AgentManager. */
|
|
79
|
+
readonly abortController?: AbortController;
|
|
80
|
+
/** Promise for the full agent run (including post-processing). Set once by AgentManager. */
|
|
77
81
|
promise?: Promise<string>;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
outputFile?: string;
|
|
82
|
+
|
|
83
|
+
// Phase-specific collaborators — each born complete when their info becomes available
|
|
84
|
+
execution?: ExecutionState;
|
|
85
|
+
worktreeState?: WorktreeState;
|
|
86
|
+
notification?: NotificationState;
|
|
84
87
|
|
|
85
88
|
constructor(init: AgentRecordInit) {
|
|
86
89
|
this.id = init.id;
|
|
@@ -94,18 +97,26 @@ export class AgentRecord {
|
|
|
94
97
|
this._startedAt = init.startedAt ?? Date.now();
|
|
95
98
|
this._completedAt = init.completedAt;
|
|
96
99
|
|
|
97
|
-
this.
|
|
98
|
-
this.
|
|
99
|
-
this.
|
|
100
|
+
this._toolUses = 0;
|
|
101
|
+
this._lifetimeUsage = { input: 0, output: 0, cacheWrite: 0 };
|
|
102
|
+
this._compactionCount = 0;
|
|
100
103
|
this.abortController = init.abortController;
|
|
101
|
-
this.session = init.session;
|
|
102
104
|
this.promise = init.promise;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
this.
|
|
108
|
-
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Increment tool use count. Called by record-observer on tool_execution_end. */
|
|
108
|
+
incrementToolUses(): void {
|
|
109
|
+
this._toolUses++;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Accumulate a usage delta into lifetimeUsage. Called by record-observer on message_end. */
|
|
113
|
+
addUsage(delta: { input: number; output: number; cacheWrite: number }): void {
|
|
114
|
+
addUsage(this._lifetimeUsage, delta);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Increment compaction count. Called by record-observer on compaction_end. */
|
|
118
|
+
incrementCompactions(): void {
|
|
119
|
+
this._compactionCount++;
|
|
109
120
|
}
|
|
110
121
|
|
|
111
122
|
/** Transition to running state. Sets status and startedAt. */
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* execution-state.ts — ExecutionState: execution-phase state for a running agent.
|
|
3
|
+
*
|
|
4
|
+
* Constructed and attached to AgentRecord when onSessionCreated fires inside startAgent().
|
|
5
|
+
* Contains the session and output file — the two fields that become known once the
|
|
6
|
+
* runner creates the session. promise stays as a separate AgentRecord field because
|
|
7
|
+
* it is set at a different moment (after runner.run() returns).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
export interface ExecutionState {
|
|
13
|
+
/** The active agent session — available from the moment the session is created. */
|
|
14
|
+
readonly session: AgentSession;
|
|
15
|
+
/** Path to the agent's session JSONL file, or undefined if not yet available. */
|
|
16
|
+
readonly outputFile: string | undefined;
|
|
17
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -88,7 +88,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
88
88
|
});
|
|
89
89
|
|
|
90
90
|
// Skip notification if result was already consumed via get_subagent_result
|
|
91
|
-
if (record.resultConsumed) {
|
|
91
|
+
if (record.notification?.resultConsumed) {
|
|
92
92
|
notifications.cleanupCompleted(record.id);
|
|
93
93
|
return;
|
|
94
94
|
}
|
|
@@ -215,6 +215,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
215
215
|
getRecord: (id) => manager.getRecord(id),
|
|
216
216
|
emitEvent: (name, data) => pi.events.emit(name, data),
|
|
217
217
|
steerAgent: (session, message) => steerAgent(session, message),
|
|
218
|
+
queueSteer: (id, message) => manager.queueSteer(id, message),
|
|
218
219
|
})));
|
|
219
220
|
|
|
220
221
|
// ---- /agents interactive menu ----
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notification-state.ts — NotificationState: notification-scoped tracking per background agent.
|
|
3
|
+
*
|
|
4
|
+
* Constructed once when agent-tool assigns the tool call ID (background agents only).
|
|
5
|
+
* Foreground agents never get a NotificationState — record.notification stays undefined.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class NotificationState {
|
|
9
|
+
/** The tool call ID that spawned this background agent. Used in task-notification XML. */
|
|
10
|
+
readonly toolCallId: string;
|
|
11
|
+
|
|
12
|
+
private _resultConsumed = false;
|
|
13
|
+
|
|
14
|
+
constructor(toolCallId: string) {
|
|
15
|
+
this.toolCallId = toolCallId;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Whether the parent agent has already consumed this result (suppresses duplicate notifications). */
|
|
19
|
+
get resultConsumed(): boolean {
|
|
20
|
+
return this._resultConsumed;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Mark the result as consumed — suppresses the completion notification. */
|
|
24
|
+
markConsumed(): void {
|
|
25
|
+
this._resultConsumed = true;
|
|
26
|
+
}
|
|
27
|
+
}
|