@gotgenes/pi-subagents 10.1.0 → 10.2.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 +16 -0
- package/docs/architecture/architecture.md +28 -20
- package/docs/plans/0228-async-start-agent-dissolve-run-handle.md +288 -0
- package/docs/retro/0227-evolve-agent-record-into-agent.md +43 -0
- package/docs/retro/0228-async-start-agent-dissolve-run-handle.md +42 -0
- package/package.json +1 -1
- package/src/lifecycle/agent-manager.ts +42 -129
- package/src/lifecycle/agent.ts +83 -3
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ 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
|
+
## [10.2.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v10.1.0...pi-subagents-v10.2.0) (2026-05-27)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pi-subagents:** add run lifecycle methods to Agent ([2a378f1](https://github.com/gotgenes/pi-packages/commit/2a378f1c82e977bdfee25931ab449757e364d589))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* **pi-subagents:** update architecture for async startAgent ([941eb10](https://github.com/gotgenes/pi-packages/commit/941eb109e71e4c51d5bb37a2a46ffc12f618d949))
|
|
19
|
+
* plan async startAgent and RunHandle dissolution ([#228](https://github.com/gotgenes/pi-packages/issues/228)) ([647adf8](https://github.com/gotgenes/pi-packages/commit/647adf853fec63ea53afd63bc8204c89a6194bbe))
|
|
20
|
+
* **retro:** add planning stage notes for issue [#228](https://github.com/gotgenes/pi-packages/issues/228) ([8dd9f8a](https://github.com/gotgenes/pi-packages/commit/8dd9f8ab7082c08e424b1b4a9557253af2ce584b))
|
|
21
|
+
* **retro:** add retro notes for issue [#227](https://github.com/gotgenes/pi-packages/issues/227) ([78a4d64](https://github.com/gotgenes/pi-packages/commit/78a4d645f524465c64bf0b6ba1bcca37858e8721))
|
|
22
|
+
* **retro:** add TDD stage notes for issue [#228](https://github.com/gotgenes/pi-packages/issues/228) ([ab497c5](https://github.com/gotgenes/pi-packages/commit/ab497c57723666d0635a0a08f9eecc06576da549))
|
|
23
|
+
|
|
8
24
|
## [10.1.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v10.0.1...pi-subagents-v10.1.0) (2026-05-27)
|
|
9
25
|
|
|
10
26
|
|
|
@@ -55,7 +55,7 @@ flowchart TB
|
|
|
55
55
|
direction TB
|
|
56
56
|
AgentManager["AgentManager<br/>(spawn, queue, abort)"]
|
|
57
57
|
AgentRunner["agent-runner<br/>(session, turns, results)"]
|
|
58
|
-
|
|
58
|
+
Agent["Agent<br/>(status, behavior: abort/steer/worktree/run lifecycle)"]
|
|
59
59
|
ParentSnapshot["ParentSnapshot<br/>(frozen parent state)"]
|
|
60
60
|
Worktree["worktree<br/>(git isolation)"]
|
|
61
61
|
end
|
|
@@ -101,7 +101,7 @@ flowchart TB
|
|
|
101
101
|
|
|
102
102
|
```mermaid
|
|
103
103
|
classDiagram
|
|
104
|
-
class
|
|
104
|
+
class Agent {
|
|
105
105
|
+id: string
|
|
106
106
|
+type: SubagentType
|
|
107
107
|
+description: string
|
|
@@ -124,6 +124,12 @@ classDiagram
|
|
|
124
124
|
+queueSteer(message)
|
|
125
125
|
+flushPendingSteers(session)
|
|
126
126
|
+setupWorktree(worktrees, isolation)
|
|
127
|
+
+completeRun(result, worktrees)
|
|
128
|
+
+failRun(err, worktrees)
|
|
129
|
+
+wireSignal(signal, onAbort)
|
|
130
|
+
+attachObserver(unsub)
|
|
131
|
+
+releaseListeners()
|
|
132
|
+
+setOnRunFinished(fn)
|
|
127
133
|
}
|
|
128
134
|
|
|
129
135
|
class AgentManager {
|
|
@@ -160,7 +166,7 @@ classDiagram
|
|
|
160
166
|
+hasRunning(): boolean
|
|
161
167
|
}
|
|
162
168
|
|
|
163
|
-
AgentManager -->
|
|
169
|
+
AgentManager --> Agent : creates/manages
|
|
164
170
|
AgentManager --> ParentSnapshot : receives at spawn
|
|
165
171
|
SubagentsService --> AgentManager : wraps via adapter
|
|
166
172
|
AgentManager --> AgentTypeRegistry : resolves types
|
|
@@ -266,7 +272,6 @@ src/
|
|
|
266
272
|
│ ├── parent-snapshot.ts immutable spawn-time parent state
|
|
267
273
|
│ ├── execution-state.ts session/output phase state
|
|
268
274
|
│ ├── permission-bridge.ts optional bridge to pi-permission-system registry
|
|
269
|
-
│ ├── run-handle.ts per-run cleanup lifecycle
|
|
270
275
|
│ ├── worktree.ts git worktree isolation
|
|
271
276
|
│ ├── worktree-state.ts worktree phase state
|
|
272
277
|
│ └── usage.ts token usage tracking
|
|
@@ -684,21 +689,22 @@ See [phase-14-strip-policy.md](history/phase-14-strip-policy.md) for details.
|
|
|
684
689
|
Phase 15 addresses the anemic domain model in the lifecycle layer.
|
|
685
690
|
`AgentRecord` is a data bag — identity, status transitions, and stats — but no behavior.
|
|
686
691
|
`AgentManager` reaches into records 37 times, doing work that belongs on the agent.
|
|
687
|
-
Per-agent state (pending steers, abort logic, run lifecycle)
|
|
692
|
+
Per-agent state (pending steers, abort logic, run lifecycle) was scattered across the manager, `RunHandle`, and a manager-level Map.
|
|
693
|
+
`RunHandle` has been dissolved into `Agent` methods — see Step 2.
|
|
688
694
|
|
|
689
695
|
The scheduling concern (queue, concurrency counter, drain) is tangled into `AgentManager` alongside collection management and run orchestration.
|
|
690
696
|
`notifyConcurrencyChanged()` is a scheduling method exposed as a public API so settings can poke the queue — a cross-concern leak.
|
|
691
697
|
|
|
692
698
|
### Findings summary
|
|
693
699
|
|
|
694
|
-
| Finding
|
|
695
|
-
|
|
|
696
|
-
| `AgentRecord` is anemic — no behavior, manager reaches in 37×
|
|
697
|
-
| Scheduling tangled into `AgentManager` (3 fields, 3 methods)
|
|
698
|
-
|
|
|
699
|
-
| `onSessionCreated` callback flows through 3 layers
|
|
700
|
-
| `resume()` duplicates observer subscribe/unsubscribe pattern
|
|
701
|
-
| `exec`/`registry` relay-only deps on `AgentManager`
|
|
700
|
+
| Finding | Category | Impact | Risk | Priority |
|
|
701
|
+
| ----------------------------------------------------------------- | ------------ | ------ | ---- | -------- |
|
|
702
|
+
| `AgentRecord` is anemic — no behavior, manager reaches in 37× | B: Oversized | 5 | 3 | 15 |
|
|
703
|
+
| Scheduling tangled into `AgentManager` (3 fields, 3 methods) | A: Coupling | 4 | 2 | 12 |
|
|
704
|
+
| ~~`startAgent` uses `.then()`/`.catch()` instead of async/await~~ | C: Callbacks | 3 | 2 | ✅ |
|
|
705
|
+
| `onSessionCreated` callback flows through 3 layers | C: Callbacks | 3 | 2 | 10 |
|
|
706
|
+
| `resume()` duplicates observer subscribe/unsubscribe pattern | A: Redundant | 2 | 1 | 8 |
|
|
707
|
+
| `exec`/`registry` relay-only deps on `AgentManager` | C: Coupling | 2 | 1 | 6 |
|
|
702
708
|
|
|
703
709
|
### Step 1: Evolve AgentRecord into Agent with behavior — [#227] ✅ Complete
|
|
704
710
|
|
|
@@ -713,15 +719,17 @@ Move per-agent behavior from `AgentManager` into the agent:
|
|
|
713
719
|
- Smell: B (anemic domain model) + C (manager reaching into records)
|
|
714
720
|
- Outcome: `AgentManager` delegates via Tell-Don't-Ask; per-agent state lives on the agent
|
|
715
721
|
|
|
716
|
-
### Step 2: Convert startAgent to async/await — [#228]
|
|
722
|
+
### Step 2: Convert startAgent to async/await — [#228] ✅ Complete
|
|
717
723
|
|
|
718
|
-
|
|
724
|
+
Converted `startAgent` to `async` with `try/catch` and dissolved `RunHandle` into `Agent` methods.
|
|
719
725
|
`spawn()` assigns `record.promise = this.startAgent(...)` instead of calling `startAgent()` synchronously.
|
|
726
|
+
`Agent` gained run lifecycle methods: `completeRun`, `failRun`, `wireSignal`, `attachObserver`, `releaseListeners`, `setOnRunFinished`.
|
|
727
|
+
Worktree setup was hoisted to callers (`spawn`, `drainQueue`) to preserve the synchronous-throw contract.
|
|
720
728
|
|
|
721
729
|
- Depends on: #227
|
|
722
|
-
- Target: `src/lifecycle/agent-manager.ts`
|
|
730
|
+
- Target: `src/lifecycle/agent-manager.ts`, `src/lifecycle/agent.ts`
|
|
723
731
|
- Smell: C (raw promise callbacks)
|
|
724
|
-
- Outcome: zero `.then()`/`.catch()` in `agent-manager.ts`
|
|
732
|
+
- Outcome: zero `.then()`/`.catch()` in `agent-manager.ts`; `RunHandle` deleted; Agent owns run lifecycle
|
|
725
733
|
|
|
726
734
|
### Step 3: Replace onSessionCreated callback with observer method — [#229]
|
|
727
735
|
|
|
@@ -751,10 +759,10 @@ Move them to `ConcreteAgentRunner` construction.
|
|
|
751
759
|
- Smell: C (relay-only dependencies)
|
|
752
760
|
- Outcome: `AgentManager` loses 2 fields; `AgentManagerOptions` shrinks from 7 to 5 fields
|
|
753
761
|
|
|
754
|
-
### Step 6: Unify resume() with
|
|
762
|
+
### Step 6: Unify resume() with Agent run lifecycle methods — [#232]
|
|
755
763
|
|
|
756
|
-
After #
|
|
757
|
-
The agent manages its own observer subscription lifecycle.
|
|
764
|
+
After #228 dissolved `RunHandle` into Agent methods (`completeRun`, `failRun`, `releaseListeners`), `resume()` on `AgentManager` becomes a short delegation to `agent.resume(runner, prompt, signal)`.
|
|
765
|
+
The agent manages its own observer subscription lifecycle using the same methods that `startAgent` uses.
|
|
758
766
|
|
|
759
767
|
- Depends on: #227, #228
|
|
760
768
|
- Target: `src/lifecycle/agent-manager.ts`
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 228
|
|
3
|
+
issue_title: "Convert startAgent to async/await, move run lifecycle to Agent (Phase 15, Step 2)"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Convert startAgent to async/await, dissolve RunHandle into Agent
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
`startAgent` is synchronous and uses `.then()`/`.catch()` to handle the runner promise.
|
|
11
|
+
This forces a promise-chain callback style even though `Agent` (as of #227) already owns per-agent behavior.
|
|
12
|
+
|
|
13
|
+
`RunHandle` is a private class in `agent-manager.ts` that does 6 things — 5 of which are Agent concerns (status transitions, worktree cleanup, execution state updates, listener lifecycle, signal wiring).
|
|
14
|
+
The only non-Agent concern is `onFinished`, a callback that connects to the manager's concurrency queue drain.
|
|
15
|
+
|
|
16
|
+
`resume()` duplicates the same pattern manually: subscribe observer, try/catch with `markCompleted`/`markError`, finally unsub.
|
|
17
|
+
Issue #232 wants to unify resume with the run lifecycle, and the architecture doc says "resume becomes a 4-line delegation."
|
|
18
|
+
If we just move `RunHandle` to `Agent` as a separate class, `resume()` still can't use it naturally — the signatures differ.
|
|
19
|
+
But if we dissolve `RunHandle` into Agent methods, both paths use the same primitives.
|
|
20
|
+
|
|
21
|
+
## Goals
|
|
22
|
+
|
|
23
|
+
- Zero `.then()`/`.catch()` in `agent-manager.ts`.
|
|
24
|
+
- Dissolve `RunHandle` into Agent methods: `completeRun`, `failRun`, `wireSignal`, `attachObserver`, `releaseListeners`, `onRunFinished` setter.
|
|
25
|
+
- `startAgent` is a straightforward async method: setup → await → handle result.
|
|
26
|
+
- `spawn()` assigns `record.promise = this.startAgent(...)`.
|
|
27
|
+
- Prepare the ground for #232 (resume unification) by giving Agent the run lifecycle primitives that `resume()` can reuse.
|
|
28
|
+
|
|
29
|
+
## Non-Goals
|
|
30
|
+
|
|
31
|
+
- **Resume unification** — deferred to #232.
|
|
32
|
+
That issue will use the new Agent methods to simplify `AgentManager.resume()`.
|
|
33
|
+
- **`onSessionCreated` observer** — deferred to #229.
|
|
34
|
+
The `onSessionCreated` callback in `startAgent` stays as-is.
|
|
35
|
+
- **`ConcurrencyQueue` extraction** — deferred to #230.
|
|
36
|
+
- **Relay deps** — deferred to #231.
|
|
37
|
+
|
|
38
|
+
## Background
|
|
39
|
+
|
|
40
|
+
### Relevant modules
|
|
41
|
+
|
|
42
|
+
| Module | LOC | Relationship to this change |
|
|
43
|
+
| -------------------------------------- | --- | ------------------------------------------------------------- |
|
|
44
|
+
| `src/lifecycle/agent-manager.ts` | 492 | Loses `RunHandle` class (~85 LOC), `startAgent` becomes async |
|
|
45
|
+
| `src/lifecycle/agent.ts` | 260 | Gains run lifecycle methods (~80 LOC) |
|
|
46
|
+
| `src/lifecycle/agent-runner.ts` | — | Exports `RunResult` type, now imported by `agent.ts` |
|
|
47
|
+
| `test/lifecycle/agent.test.ts` | 501 | Gains ~120 LOC of run lifecycle tests |
|
|
48
|
+
| `test/lifecycle/agent-manager.test.ts` | 768 | One assertion update (`Promise<void>`) |
|
|
49
|
+
|
|
50
|
+
### What RunHandle does today
|
|
51
|
+
|
|
52
|
+
| Concern | RunHandle method | Who should own it |
|
|
53
|
+
| ---------------------------------------------------------------------- | -------------------- | ----------------------------------------------- |
|
|
54
|
+
| Listener lifecycle (unsub + detachFn) | `releaseListeners()` | Agent — per-run cleanup handles |
|
|
55
|
+
| Run completion (worktree cleanup, status transition, execution update) | `complete(result)` | Agent — all state mutations target Agent fields |
|
|
56
|
+
| Run failure (error marking, best-effort worktree cleanup) | `fail(err)` | Agent — same |
|
|
57
|
+
| Signal wiring (parent abort → child abort) | `wireSignal()` | Agent — per-run handle, released on completion |
|
|
58
|
+
| Observer attachment (session event subscription) | `attachObserver()` | Agent — per-run handle, released on completion |
|
|
59
|
+
| onFinished callback (concurrency drain) | `fireOnFinished()` | Manager concern, but just a stored `() => void` |
|
|
60
|
+
|
|
61
|
+
Five of six are Agent concerns.
|
|
62
|
+
RunHandle reaches into `this.record` for every operation and talks through `this.record.worktreeState` to a stranger.
|
|
63
|
+
|
|
64
|
+
### Dependency flow (no cycles)
|
|
65
|
+
|
|
66
|
+
`agent.ts` gains a type-only import of `RunResult` from `agent-runner.ts`.
|
|
67
|
+
`agent-runner.ts` imports from `agent-manager.ts` (not `agent.ts`), so no cycle is created.
|
|
68
|
+
|
|
69
|
+
### Constraints from AGENTS.md
|
|
70
|
+
|
|
71
|
+
- `promise` type change from `Promise<string>` to `Promise<void>` is internal — `Agent` is not exported from `package.json`.
|
|
72
|
+
- Worktree setup hoist preserves the synchronous-throw contract in `spawn()` (callers rely on catching `isolation: "worktree"` errors synchronously).
|
|
73
|
+
|
|
74
|
+
## Design Overview
|
|
75
|
+
|
|
76
|
+
### Dissolve RunHandle into Agent methods
|
|
77
|
+
|
|
78
|
+
Agent gains per-run listener fields and run lifecycle methods:
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
class Agent {
|
|
82
|
+
// --- Per-run listener state (released on completion or resume reset) ---
|
|
83
|
+
private _unsub?: () => void;
|
|
84
|
+
private _detachFn?: () => void;
|
|
85
|
+
private _onRunFinished?: () => void;
|
|
86
|
+
|
|
87
|
+
/** Wire a parent AbortSignal so it stops this agent when fired. */
|
|
88
|
+
wireSignal(signal: AbortSignal | undefined, onAbort: () => void): void;
|
|
89
|
+
|
|
90
|
+
/** Store the record-observer unsubscribe handle. */
|
|
91
|
+
attachObserver(unsub: () => void): void;
|
|
92
|
+
|
|
93
|
+
/** Release observer + signal listener handles. */
|
|
94
|
+
releaseListeners(): void;
|
|
95
|
+
|
|
96
|
+
/** Set the callback fired once when the run finishes (for concurrency drain). */
|
|
97
|
+
setOnRunFinished(fn: () => void): void;
|
|
98
|
+
|
|
99
|
+
/** Complete a run: release listeners, worktree cleanup, status transition,
|
|
100
|
+
execution update, fire onRunFinished. */
|
|
101
|
+
completeRun(result: RunResult, worktrees: WorktreeManager): void;
|
|
102
|
+
|
|
103
|
+
/** Fail a run: mark error, release listeners, best-effort worktree cleanup,
|
|
104
|
+
fire onRunFinished. */
|
|
105
|
+
failRun(err: unknown, worktrees: WorktreeManager): void;
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`completeRun` and `failRun` take `worktrees: WorktreeManager` as a parameter rather than storing it on Agent.
|
|
110
|
+
Worktrees are only needed at run end — storing the reference would widen Agent's dependency surface for a single use.
|
|
111
|
+
|
|
112
|
+
Consumer call-site after the change (`startAgent`):
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
record.setOnRunFinished(
|
|
116
|
+
options.isBackground ? () => this.finalizeBackgroundRun(record) : undefined,
|
|
117
|
+
);
|
|
118
|
+
record.wireSignal(options.signal, () => this.abort(id));
|
|
119
|
+
try {
|
|
120
|
+
const result = await this.runner.run(...);
|
|
121
|
+
record.completeRun(result, this.worktrees);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
record.failRun(err, this.worktrees);
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Narrow `promise` to `Promise<void>`
|
|
128
|
+
|
|
129
|
+
The resolved string value of `record.promise` is dead — every consumer just `await`s it and reads `record.result`.
|
|
130
|
+
One test asserts `resolves.toBe("done")`; all others use `await record.promise`.
|
|
131
|
+
Narrowing to `Promise<void>` first makes the async conversion clean (async `startAgent` naturally returns `Promise<void>`).
|
|
132
|
+
|
|
133
|
+
### Hoist worktree setup from `startAgent` to callers
|
|
134
|
+
|
|
135
|
+
`record.setupWorktree()` can throw synchronously (strict isolation failure).
|
|
136
|
+
`spawn()` catches this and removes the orphan record.
|
|
137
|
+
`drainQueue()` catches it and marks the record as errored.
|
|
138
|
+
|
|
139
|
+
If `startAgent` becomes `async`, synchronous throws become rejected promises — neither caller catches them.
|
|
140
|
+
Fix: move `record.setupWorktree()` into the callers' existing try-catch blocks before calling async `startAgent`.
|
|
141
|
+
`startAgent` reads `record.worktreeState?.path` for the cwd instead.
|
|
142
|
+
|
|
143
|
+
### `resetForResume` releases listeners
|
|
144
|
+
|
|
145
|
+
After dissolution, `resetForResume` must call `releaseListeners()` and clear `_onRunFinished` to prevent stale handles from a previous run leaking into the resumed run.
|
|
146
|
+
|
|
147
|
+
## Module-Level Changes
|
|
148
|
+
|
|
149
|
+
### `src/lifecycle/agent.ts`
|
|
150
|
+
|
|
151
|
+
1. Add per-run listener fields: `_unsub`, `_detachFn`, `_onRunFinished`.
|
|
152
|
+
2. Add `wireSignal(signal, onAbort)` — logic from `RunHandle.wireSignal`.
|
|
153
|
+
3. Add `attachObserver(unsub)` — logic from `RunHandle.attachObserver`.
|
|
154
|
+
4. Add `releaseListeners()` — logic from `RunHandle.releaseListeners` (public).
|
|
155
|
+
5. Add `setOnRunFinished(fn)` — stores the callback.
|
|
156
|
+
6. Add private `fireOnRunFinished()` — idempotent clear-then-call pattern from `RunHandle.fireOnFinished`.
|
|
157
|
+
7. Add `completeRun(result, worktrees)` — logic from `RunHandle.complete`, returns `void` (not `string`).
|
|
158
|
+
8. Add `failRun(err, worktrees)` — logic from `RunHandle.fail`.
|
|
159
|
+
9. Update `resetForResume` — call `releaseListeners()` and clear `_onRunFinished`.
|
|
160
|
+
10. Change `promise` type from `Promise<string>` to `Promise<void>` (on both `AgentInit` and the class field).
|
|
161
|
+
11. Add imports: `type RunResult` from `agent-runner`, `debugLog` from `debug`.
|
|
162
|
+
|
|
163
|
+
### `src/lifecycle/agent-manager.ts`
|
|
164
|
+
|
|
165
|
+
1. Delete `RunHandle` class (~85 lines).
|
|
166
|
+
2. Remove `import type { RunResult }` (moved to `agent.ts`; `AgentRunner` import stays).
|
|
167
|
+
3. Convert `startAgent` to `async`, returning `Promise<void>`.
|
|
168
|
+
4. Replace RunHandle creation with Agent method calls: `record.setOnRunFinished(...)`, `record.wireSignal(...)`.
|
|
169
|
+
5. Replace `handle.attachObserver(...)` with `record.attachObserver(...)` in `onSessionCreated`.
|
|
170
|
+
6. Replace `.then()`/`.catch()` chain with `try { await ...; record.completeRun(...) } catch { record.failRun(...) }`.
|
|
171
|
+
7. Remove `record.promise = this.runner.run(...)` assignment — `record.promise` is now assigned by `spawn`/`drainQueue`.
|
|
172
|
+
8. In `spawn()`: hoist `record.setupWorktree(...)` before `startAgent` call (inside existing try-catch); assign `record.promise = this.startAgent(...)`.
|
|
173
|
+
9. In `drainQueue()`: hoist `record.setupWorktree(...)` before `startAgent` call (inside existing try-catch); assign `record.promise = this.startAgent(...)`.
|
|
174
|
+
10. In `startAgent`: remove `record.setupWorktree()` call; read `record.worktreeState?.path` for cwd.
|
|
175
|
+
11. Update `waitForAll` filter: `Promise<string>` → `Promise<void>`.
|
|
176
|
+
|
|
177
|
+
### `test/lifecycle/agent.test.ts`
|
|
178
|
+
|
|
179
|
+
1. Add `describe("Agent — completeRun")` — status transitions (completed/aborted/steered), worktree cleanup with branch append, execution state update, `onRunFinished` fires once, listeners released.
|
|
180
|
+
2. Add `describe("Agent — failRun")` — marks error, best-effort worktree cleanup, `onRunFinished` fires once, listeners released.
|
|
181
|
+
3. Add `describe("Agent — wireSignal")` — connects parent signal to abort callback, `releaseListeners` detaches.
|
|
182
|
+
4. Add `describe("Agent — attachObserver / releaseListeners")` — stores unsub, calls it on release, idempotent.
|
|
183
|
+
5. Update `describe("Agent — resetForResume")` — verify listeners are released and `_onRunFinished` is cleared.
|
|
184
|
+
|
|
185
|
+
### `test/lifecycle/agent-manager.test.ts`
|
|
186
|
+
|
|
187
|
+
1. Update one assertion: `resolves.toBe("done")` → `resolves.toBeUndefined()`.
|
|
188
|
+
|
|
189
|
+
### `packages/pi-subagents/docs/architecture/architecture.md`
|
|
190
|
+
|
|
191
|
+
1. Update Phase 15 smell table — mark `startAgent` callback row as resolved.
|
|
192
|
+
2. Update Step 2 description to note RunHandle dissolution (not just async conversion).
|
|
193
|
+
3. Update Step 6 (#232) description — RunHandle no longer exists; Agent already has `completeRun`/`failRun`/`releaseListeners` that `resume()` can use directly.
|
|
194
|
+
|
|
195
|
+
## Test Impact Analysis
|
|
196
|
+
|
|
197
|
+
### New unit tests enabled by the dissolution
|
|
198
|
+
|
|
199
|
+
1. **`Agent.completeRun()`** — isolated tests for run completion logic (status transitions based on `RunResult` flags, worktree cleanup, execution update, onRunFinished firing) without needing a full `AgentManager` scaffold with a mock runner.
|
|
200
|
+
2. **`Agent.failRun()`** — isolated tests for error handling and best-effort cleanup.
|
|
201
|
+
3. **`Agent.wireSignal()` / `Agent.attachObserver()` / `Agent.releaseListeners()`** — isolated tests for listener lifecycle without spawning a real agent.
|
|
202
|
+
|
|
203
|
+
These behaviors were previously only testable through `AgentManager` integration tests that required setting up a mock runner, worktrees, and observer.
|
|
204
|
+
|
|
205
|
+
### Existing tests that must stay
|
|
206
|
+
|
|
207
|
+
1. All `AgentManager — spawn/spawnAndWait` tests — they verify the full spawn flow including async orchestration.
|
|
208
|
+
2. All worktree isolation tests — they verify the synchronous-throw contract in `spawn()`.
|
|
209
|
+
3. All queue/concurrency tests — they verify the manager's orchestration around `drainQueue`.
|
|
210
|
+
4. All completion/notification tests — they verify end-to-end flow through the observer.
|
|
211
|
+
|
|
212
|
+
### Existing tests that change
|
|
213
|
+
|
|
214
|
+
1. One assertion in `agent-manager.test.ts`: `resolves.toBe("done")` → `resolves.toBeUndefined()` (promise type narrowing).
|
|
215
|
+
|
|
216
|
+
## TDD Order
|
|
217
|
+
|
|
218
|
+
1. **Narrow `Agent.promise` from `Promise<string>` to `Promise<void>`**
|
|
219
|
+
- Change `AgentInit.promise` and `Agent.promise` field types.
|
|
220
|
+
- In `startAgent`: wrap `.then()` callback body in braces (discard `handle.complete` return); remove `return ""` from `.catch()` callback.
|
|
221
|
+
- Update `waitForAll` filter type guard.
|
|
222
|
+
- Update one test assertion: `resolves.toBe("done")` → `resolves.toBeUndefined()`.
|
|
223
|
+
- Run `pnpm run check` + `pnpm vitest run`.
|
|
224
|
+
- Commit: `refactor(pi-subagents): narrow Agent.promise to Promise<void>`
|
|
225
|
+
|
|
226
|
+
2. **Red/Green: add run lifecycle methods to Agent**
|
|
227
|
+
- Red: add tests in `agent.test.ts` for `completeRun`, `failRun`, `wireSignal`, `attachObserver`/`releaseListeners`, `resetForResume` listener cleanup.
|
|
228
|
+
- Green: implement the methods on `Agent` — `wireSignal`, `attachObserver`, `releaseListeners`, `setOnRunFinished`, `fireOnRunFinished`, `completeRun`, `failRun`; update `resetForResume`.
|
|
229
|
+
- Add `import type { RunResult }` and `import { debugLog }` to `agent.ts`.
|
|
230
|
+
- Run `pnpm run check` + `pnpm vitest run`.
|
|
231
|
+
- Commit: `feat(pi-subagents): add run lifecycle methods to Agent`
|
|
232
|
+
|
|
233
|
+
3. **Replace RunHandle with Agent methods in `startAgent`, delete RunHandle**
|
|
234
|
+
- Replace `new RunHandle(record, this.worktrees, onFinished)` with `record.setOnRunFinished(onFinished)`.
|
|
235
|
+
- Replace `handle.wireSignal(...)` with `record.wireSignal(...)`.
|
|
236
|
+
- Replace `handle.attachObserver(...)` with `record.attachObserver(...)`.
|
|
237
|
+
- Replace `handle.complete(result)` with `record.completeRun(result, this.worktrees)`.
|
|
238
|
+
- Replace `handle.fail(err)` with `record.failRun(err, this.worktrees)`.
|
|
239
|
+
- Delete `RunHandle` class.
|
|
240
|
+
- Remove `import type { RunResult }` from `agent-manager.ts` (moved to `agent.ts`).
|
|
241
|
+
- Run `pnpm run check` + `pnpm vitest run`.
|
|
242
|
+
- Commit: `refactor(pi-subagents): replace RunHandle with Agent run lifecycle methods`
|
|
243
|
+
|
|
244
|
+
4. **Hoist worktree setup from `startAgent` to callers**
|
|
245
|
+
- In `spawn()`: move `record.setupWorktree(this.worktrees, options.isolation)` before `this.startAgent()`, inside the existing try-catch.
|
|
246
|
+
- In `drainQueue()`: move `record.setupWorktree(this.worktrees, next.args.options.isolation)` before `this.startAgent()`, inside its try-catch.
|
|
247
|
+
- In `startAgent`: remove `record.setupWorktree()` call; use `record.worktreeState?.path` for `context.cwd`.
|
|
248
|
+
- Existing worktree isolation tests pass unchanged.
|
|
249
|
+
- Run `pnpm run check` + `pnpm vitest run`.
|
|
250
|
+
- Commit: `refactor(pi-subagents): hoist worktree setup from startAgent to callers`
|
|
251
|
+
|
|
252
|
+
5. **Convert `startAgent` to async/await**
|
|
253
|
+
- Make `startAgent` async, returning `Promise<void>`.
|
|
254
|
+
- Replace `.then()`/`.catch()` chain with `try { const result = await this.runner.run(...); record.completeRun(result, this.worktrees); } catch (err) { record.failRun(err, this.worktrees); }`.
|
|
255
|
+
- Remove `record.promise = this.runner.run(...)` assignment from inside `startAgent`.
|
|
256
|
+
- In `spawn()`: assign `record.promise = this.startAgent(id, record, args)`.
|
|
257
|
+
- In `drainQueue()`: assign `record.promise = this.startAgent(next.id, record, next.args)`.
|
|
258
|
+
- Run `pnpm run check` + `pnpm vitest run`.
|
|
259
|
+
- Commit: `refactor(pi-subagents): convert startAgent to async/await`
|
|
260
|
+
|
|
261
|
+
6. **Update architecture docs**
|
|
262
|
+
- Mark Phase 15 Step 2 smell row as resolved.
|
|
263
|
+
- Update Step 2 description to note RunHandle dissolution.
|
|
264
|
+
- Update Step 6 (#232) description: RunHandle no longer exists; Agent has `completeRun`/`failRun`/`releaseListeners` that `resume()` can use directly.
|
|
265
|
+
- Commit: `docs(pi-subagents): update architecture for async startAgent`
|
|
266
|
+
|
|
267
|
+
## Risks and Mitigations
|
|
268
|
+
|
|
269
|
+
1. **`resetForResume` must release listeners** — If not updated, resumed agents retain stale listener handles from the previous run.
|
|
270
|
+
Mitigated by step 2 explicitly updating `resetForResume` to call `releaseListeners()` and clear `_onRunFinished`, with a test.
|
|
271
|
+
|
|
272
|
+
2. **Worktree hoist changes observer-throw semantics** — Currently, if `observer.onAgentStarted()` throws inside `startAgent`, `spawn()`'s try-catch catches it and removes the record.
|
|
273
|
+
After async conversion, that throw becomes a rejected promise.
|
|
274
|
+
This is a pre-existing inconsistency (`onAgentCompleted` is already wrapped in try-catch, `onAgentStarted` is not) and observers should not throw.
|
|
275
|
+
Mitigated by noting the inconsistency; a future step could add try-catch around `onAgentStarted`.
|
|
276
|
+
|
|
277
|
+
3. **Agent grows by ~80 LOC** — Dissolving RunHandle adds methods to an already-substantial class.
|
|
278
|
+
Mitigated by the fact that these methods replace logic that already operated on Agent's fields — they belong here by SRP.
|
|
279
|
+
The net effect on `agent-manager.ts` is -85 LOC (RunHandle deletion), so the total codebase shrinks.
|
|
280
|
+
|
|
281
|
+
4. **`completeRun` takes `worktrees` parameter instead of storing it** — This means every caller must pass worktrees.
|
|
282
|
+
Mitigated by there being exactly two callers today (startAgent and the future resume), both of which already have access to worktrees.
|
|
283
|
+
Storing it would widen Agent's dependency surface for a single use.
|
|
284
|
+
|
|
285
|
+
## Open Questions
|
|
286
|
+
|
|
287
|
+
None — the design direction (dissolve rather than move) is settled.
|
|
288
|
+
The `worktrees` parameter vs. stored-reference question is resolved in favor of the parameter (ISP).
|
|
@@ -35,3 +35,46 @@ Test count went from 977 to 986 across 62 test files.
|
|
|
35
35
|
- Biome auto-formatted several test files during the rename commit; re-staged and re-committed.
|
|
36
36
|
- Pre-completion reviewer returned **WARN** for 4 stale diagram/table references in `architecture.md` and the `package-pi-subagents` skill table; all fixed before the final commit.
|
|
37
37
|
- No deviations from the plan's behavior design; the `queueSteer` removal from manager interfaces worked exactly as anticipated in the retro notes.
|
|
38
|
+
|
|
39
|
+
## Stage: Final Retrospective (2026-05-27T17:22:00Z)
|
|
40
|
+
|
|
41
|
+
### Session summary
|
|
42
|
+
|
|
43
|
+
Completed all stages in a single session: planning, 8 TDD steps, pre-completion review, shipping, and release as `pi-subagents-v10.1.0`.
|
|
44
|
+
Three behaviors (`abort`, steer buffering, worktree setup) moved from `AgentManager` to `Agent`, followed by a codebase-wide rename (33 files).
|
|
45
|
+
|
|
46
|
+
### Observations
|
|
47
|
+
|
|
48
|
+
#### What went well
|
|
49
|
+
|
|
50
|
+
- The "add behavior first, rename last" strategy kept behavior-adding commits small (1–2 files each) and the rename commit purely mechanical.
|
|
51
|
+
- Planning identified that `queueSteer` could be removed from `AgentManagerLike` and `SteerToolManager` entirely — this simplified the delegation step and eliminated an unnecessary indirection layer.
|
|
52
|
+
- Pre-completion reviewer caught 4 stale Mermaid diagram references and a skill table entry that the plan's step 8 did not anticipate; all fixed before shipping.
|
|
53
|
+
|
|
54
|
+
#### What caused friction (agent side)
|
|
55
|
+
|
|
56
|
+
1. `scope-drift` — Added `AgentInit` and `AgentStatus` to the `types.ts` re-export barrel during the rename step without verifying any file imports them from that path.
|
|
57
|
+
Impact: fallow flagged dead code, triggering a 4-call suppression trial (`unused-export` → `unused-types` → `unused-type`), then the user identified the real fix (remove the speculative re-exports entirely), requiring a follow-up `fix:` commit after docs were already done.
|
|
58
|
+
2. `missing-context` — During the mechanical rename (step 7), `sed` commands matched `#test/helpers/make-record` but missed the relative import `"./helpers/make-record"` in `conversation-viewer.test.ts`.
|
|
59
|
+
Impact: `pnpm run check` caught it in 1 tool call; minimal rework.
|
|
60
|
+
3. `missing-context` — The fallow skill documents `unused-export` as a suppression kind but not `unused-type`.
|
|
61
|
+
Impact: 3 wrong guesses before the correct suppression syntax.
|
|
62
|
+
Self-identified after fallow's error message suggested the correct kind name.
|
|
63
|
+
|
|
64
|
+
#### What caused friction (user side)
|
|
65
|
+
|
|
66
|
+
- The user's question about whether the fallow suppressions could be removed in a future step was a valuable prompt — it surfaced that the re-exports were speculative and could be removed immediately.
|
|
67
|
+
Earlier intervention (e.g., during the TDD stage when the suppressions were added) would have avoided the `fix:` commit.
|
|
68
|
+
|
|
69
|
+
### Diagnostic details
|
|
70
|
+
|
|
71
|
+
- **Model-performance correlation** — Pre-completion reviewer ran as `pre-completion-reviewer` subagent (default model); appropriate for judgment-heavy work (doc staleness, code design review).
|
|
72
|
+
No model mismatches.
|
|
73
|
+
- **Feedback-loop gap analysis** — `pnpm run check` was run after every delegation step (steps 2, 4, 6, 7) and after every behavior-adding step (steps 1, 3, 5).
|
|
74
|
+
Verification was incremental throughout, not deferred to the end.
|
|
75
|
+
The `conversation-viewer.test.ts` import miss in step 7 was caught immediately by the type checker.
|
|
76
|
+
|
|
77
|
+
### Changes made
|
|
78
|
+
|
|
79
|
+
1. `.pi/skills/fallow/SKILL.md` — Added `unused-type` suppression example alongside existing `unused-export` example.
|
|
80
|
+
2. `AGENTS.md` — Added "no speculative re-exports" rule to Code Style section.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 228
|
|
3
|
+
issue_title: "Convert startAgent to async/await, move run lifecycle to Agent (Phase 15, Step 2)"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #228 — Convert startAgent to async/await, move run lifecycle to Agent
|
|
7
|
+
|
|
8
|
+
## Stage: Planning (2026-05-27T20:00:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Planned the async `startAgent` conversion and decided to dissolve `RunHandle` into Agent methods rather than moving it as a separate class.
|
|
13
|
+
Identified three preparatory steps (narrow promise type, add Agent methods, hoist worktree setup) that make the final async conversion a minimal diff.
|
|
14
|
+
|
|
15
|
+
### Observations
|
|
16
|
+
|
|
17
|
+
- The original issue proposed `Agent.createRunHandle()` as a factory, keeping RunHandle as a separate class.
|
|
18
|
+
Analysis showed 5 of 6 RunHandle concerns are Agent state mutations — RunHandle is doing work that belongs on Agent.
|
|
19
|
+
The clincher was `resume()` in `agent-manager.ts`: it duplicates RunHandle's pattern manually, and #232 wants to unify them.
|
|
20
|
+
Dissolving RunHandle gives both `startAgent` and `resume` the same primitives (`completeRun`, `failRun`, `releaseListeners`).
|
|
21
|
+
- The synchronous-throw contract in `spawn()` for worktree failures requires hoisting `record.setupWorktree()` out of `startAgent` before the async conversion.
|
|
22
|
+
Without this prep step, async `startAgent` would turn the throw into a rejected promise that `spawn()` doesn't catch.
|
|
23
|
+
- `promise: Promise<string>` → `Promise<void>` is safe because the resolved string is dead — every consumer reads `record.result` instead.
|
|
24
|
+
Only one test assertion reads the resolved value.
|
|
25
|
+
- `completeRun`/`failRun` take `worktrees: WorktreeManager` as a parameter rather than storing it on Agent (ISP — only needed at run end, exactly two callers).
|
|
26
|
+
|
|
27
|
+
## Stage: Implementation — TDD (2026-05-27T20:40:00Z)
|
|
28
|
+
|
|
29
|
+
### Session summary
|
|
30
|
+
|
|
31
|
+
Implemented all 6 TDD steps: narrowed `promise` to `Promise<void>`, added 6 run lifecycle methods to Agent (+19 tests), replaced `RunHandle` with Agent methods (-85 LOC), hoisted worktree setup to callers, converted `startAgent` to async/await, and updated architecture docs.
|
|
32
|
+
Test count: 986 → 1005.
|
|
33
|
+
|
|
34
|
+
### Observations
|
|
35
|
+
|
|
36
|
+
- Step 1 (promise narrowing) required fixing 3 additional test files not listed in the plan: `make-agent.test.ts`, `service-adapter.test.ts`, `get-result-tool.test.ts`.
|
|
37
|
+
All were trivial `Promise.resolve("done")` → `Promise.resolve()` changes and a cast removal.
|
|
38
|
+
- The lift-and-shift approach worked cleanly — each of the 5 implementation commits was small and independently green.
|
|
39
|
+
The most impactful commit was step 3 (replace RunHandle, -96/+6 lines) which was risk-free because step 2 had already introduced the Agent methods.
|
|
40
|
+
- Pre-completion reviewer returned WARN for stale `AgentRecord` and `run-handle.ts` references in `architecture.md` class diagram and layout listing.
|
|
41
|
+
These were pre-existing staleness from #227's rename that wasn't fully propagated to Mermaid diagrams.
|
|
42
|
+
Fixed by amending the docs commit.
|
package/package.json
CHANGED
|
@@ -12,7 +12,7 @@ import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
|
12
12
|
import { AgentTypeRegistry } from "#src/config/agent-types";
|
|
13
13
|
import { debugLog } from "#src/debug";
|
|
14
14
|
import { Agent } from "#src/lifecycle/agent";
|
|
15
|
-
import type { AgentRunner
|
|
15
|
+
import type { AgentRunner } from "#src/lifecycle/agent-runner";
|
|
16
16
|
import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
17
17
|
import type { WorktreeManager } from "#src/lifecycle/worktree";
|
|
18
18
|
|
|
@@ -21,95 +21,6 @@ import { subscribeAgentObserver } from "#src/observation/record-observer";
|
|
|
21
21
|
import type { RunConfig } from "#src/runtime";
|
|
22
22
|
import type { AgentInvocation, IsolationMode, ShellExec, SubagentType, ThinkingLevel } from "#src/types";
|
|
23
23
|
|
|
24
|
-
/**
|
|
25
|
-
* RunHandle - per-run lifecycle object that owns cleanup state.
|
|
26
|
-
*
|
|
27
|
-
* Owns the observer unsubscribe and parent-signal detach handles acquired during
|
|
28
|
-
* a run. Exposes `complete()` and `fail()` as the only way to finish a run,
|
|
29
|
-
* eliminating mutable closure variables from `startAgent`.
|
|
30
|
-
* `fireOnFinished` is idempotent - safe to call from both success and error paths.
|
|
31
|
-
*/
|
|
32
|
-
class RunHandle {
|
|
33
|
-
private unsub?: () => void;
|
|
34
|
-
private detachFn?: () => void;
|
|
35
|
-
private onFinished?: () => void;
|
|
36
|
-
|
|
37
|
-
constructor(
|
|
38
|
-
private readonly record: Agent,
|
|
39
|
-
private readonly worktrees: WorktreeManager,
|
|
40
|
-
onFinished?: () => void,
|
|
41
|
-
) {
|
|
42
|
-
this.onFinished = onFinished;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Wire a parent AbortSignal so it stops this agent when fired. */
|
|
46
|
-
wireSignal(signal: AbortSignal | undefined, onAbort: () => void): void {
|
|
47
|
-
if (!signal) return;
|
|
48
|
-
const listener = () => onAbort();
|
|
49
|
-
signal.addEventListener("abort", listener, { once: true });
|
|
50
|
-
this.detachFn = () => signal.removeEventListener("abort", listener);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Store the record-observer unsubscribe handle (called from onSessionCreated). */
|
|
54
|
-
attachObserver(unsub: () => void): void {
|
|
55
|
-
this.unsub = unsub;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Complete a run successfully - clean up, transition record, fire onFinished. */
|
|
59
|
-
complete(result: RunResult): string {
|
|
60
|
-
this.releaseListeners();
|
|
61
|
-
|
|
62
|
-
let finalResult = result.responseText;
|
|
63
|
-
if (this.record.worktreeState) {
|
|
64
|
-
const wtResult = this.record.worktreeState.performCleanup(this.worktrees, this.record.description);
|
|
65
|
-
if (wtResult.hasChanges && wtResult.branch) {
|
|
66
|
-
finalResult += `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (result.aborted) this.record.markAborted(finalResult);
|
|
71
|
-
else if (result.steered) this.record.markSteered(finalResult);
|
|
72
|
-
else this.record.markCompleted(finalResult);
|
|
73
|
-
|
|
74
|
-
// Update execution with the final session/outputFile from the runner
|
|
75
|
-
this.record.execution = {
|
|
76
|
-
session: result.session,
|
|
77
|
-
outputFile: result.sessionFile ?? this.record.execution?.outputFile,
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
this.fireOnFinished();
|
|
81
|
-
return result.responseText;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Fail a run - mark error, best-effort worktree cleanup, fire onFinished. */
|
|
85
|
-
fail(err: unknown): void {
|
|
86
|
-
this.record.markError(err);
|
|
87
|
-
this.releaseListeners();
|
|
88
|
-
|
|
89
|
-
if (this.record.worktreeState) {
|
|
90
|
-
try {
|
|
91
|
-
this.record.worktreeState.performCleanup(this.worktrees, this.record.description);
|
|
92
|
-
} catch (cleanupErr) { debugLog("cleanupWorktree on agent error", cleanupErr); }
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
this.fireOnFinished();
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
private releaseListeners(): void {
|
|
99
|
-
this.unsub?.();
|
|
100
|
-
this.unsub = undefined;
|
|
101
|
-
this.detachFn?.();
|
|
102
|
-
this.detachFn = undefined;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** Fire the onFinished callback at most once. */
|
|
106
|
-
private fireOnFinished(): void {
|
|
107
|
-
const fn = this.onFinished;
|
|
108
|
-
this.onFinished = undefined;
|
|
109
|
-
fn?.();
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
24
|
export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number };
|
|
114
25
|
|
|
115
26
|
/** Observer interface for agent lifecycle notifications. */
|
|
@@ -252,10 +163,11 @@ export class AgentManager {
|
|
|
252
163
|
return id;
|
|
253
164
|
}
|
|
254
165
|
|
|
255
|
-
//
|
|
166
|
+
// setupWorktree can throw (e.g. strict worktree-isolation failure) - clean
|
|
256
167
|
// up the record so callers don't see an orphan in `listAgents()`.
|
|
257
168
|
try {
|
|
258
|
-
this.
|
|
169
|
+
record.setupWorktree(this.worktrees, options.isolation);
|
|
170
|
+
record.promise = this.startAgent(id, record, args);
|
|
259
171
|
} catch (err) {
|
|
260
172
|
this.agents.delete(id);
|
|
261
173
|
throw err;
|
|
@@ -264,49 +176,49 @@ export class AgentManager {
|
|
|
264
176
|
}
|
|
265
177
|
|
|
266
178
|
/** Actually start an agent (called immediately or from queue drain). */
|
|
267
|
-
private startAgent(id: string, record: Agent, { snapshot, type, prompt, options }: SpawnArgs) {
|
|
268
|
-
const worktreeCwd = record.setupWorktree(this.worktrees, options.isolation);
|
|
269
|
-
|
|
179
|
+
private async startAgent(id: string, record: Agent, { snapshot, type, prompt, options }: SpawnArgs): Promise<void> {
|
|
270
180
|
record.markRunning(Date.now());
|
|
271
181
|
if (options.isBackground) this.runningBackground++;
|
|
272
182
|
this.observer?.onAgentStarted(record);
|
|
273
183
|
|
|
274
|
-
|
|
275
|
-
record, this.worktrees,
|
|
184
|
+
record.setOnRunFinished(
|
|
276
185
|
options.isBackground ? () => this.finalizeBackgroundRun(record) : undefined,
|
|
277
186
|
);
|
|
278
|
-
|
|
187
|
+
record.wireSignal(options.signal, () => this.abort(id));
|
|
279
188
|
|
|
280
189
|
const runConfig = this.getRunConfig?.();
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
.
|
|
190
|
+
try {
|
|
191
|
+
const result = await this.runner.run(snapshot, type, prompt, {
|
|
192
|
+
context: {
|
|
193
|
+
exec: this.exec,
|
|
194
|
+
registry: this.registry,
|
|
195
|
+
cwd: record.worktreeState?.path,
|
|
196
|
+
parentSession: options.parentSession,
|
|
197
|
+
},
|
|
198
|
+
model: options.model,
|
|
199
|
+
maxTurns: options.maxTurns,
|
|
200
|
+
defaultMaxTurns: runConfig?.defaultMaxTurns,
|
|
201
|
+
graceTurns: runConfig?.graceTurns,
|
|
202
|
+
isolated: options.isolated,
|
|
203
|
+
thinkingLevel: options.thinkingLevel,
|
|
204
|
+
signal: record.abortController!.signal,
|
|
205
|
+
onSessionCreated: (session) => {
|
|
206
|
+
// Capture the session file path early so it's available for display
|
|
207
|
+
// before the run completes (e.g. in background agent status messages).
|
|
208
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- sessionManager is typed as always present but Pi SDK may not provide it
|
|
209
|
+
const outputFile = session.sessionManager?.getSessionFile?.() ?? undefined;
|
|
210
|
+
record.execution = { session, outputFile };
|
|
211
|
+
record.flushPendingSteers(session);
|
|
212
|
+
record.attachObserver(subscribeAgentObserver(session, record, {
|
|
213
|
+
onCompact: (r, info) => this.observer?.onAgentCompacted(r, info),
|
|
214
|
+
}));
|
|
215
|
+
options.onSessionCreated?.(session, record);
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
record.completeRun(result, this.worktrees);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
record.failRun(err, this.worktrees);
|
|
221
|
+
}
|
|
310
222
|
}
|
|
311
223
|
|
|
312
224
|
/** Decrement background counter, notify observer (crash-safe), and drain the queue. */
|
|
@@ -323,7 +235,8 @@ export class AgentManager {
|
|
|
323
235
|
const record = this.agents.get(next.id);
|
|
324
236
|
if (record?.status !== "queued") continue;
|
|
325
237
|
try {
|
|
326
|
-
|
|
238
|
+
record.setupWorktree(this.worktrees, next.args.options.isolation);
|
|
239
|
+
record.promise = this.startAgent(next.id, record, next.args);
|
|
327
240
|
} catch (err) {
|
|
328
241
|
// Late failure (e.g. strict worktree-isolation) - surface on the record
|
|
329
242
|
// so the user/agent can see it via /agents, then keep draining.
|
|
@@ -471,7 +384,7 @@ export class AgentManager {
|
|
|
471
384
|
const pending = [...this.agents.values()]
|
|
472
385
|
.filter(r => r.status === "running" || r.status === "queued")
|
|
473
386
|
.map(r => r.promise)
|
|
474
|
-
.filter((p): p is Promise<
|
|
387
|
+
.filter((p): p is Promise<void> => p != null);
|
|
475
388
|
if (pending.length === 0) break;
|
|
476
389
|
await Promise.allSettled(pending);
|
|
477
390
|
}
|
package/src/lifecycle/agent.ts
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { debugLog } from "#src/debug";
|
|
20
|
+
import type { RunResult } from "#src/lifecycle/agent-runner";
|
|
19
21
|
import type { ExecutionState } from "#src/lifecycle/execution-state";
|
|
20
22
|
import type { LifetimeUsage } from "#src/lifecycle/usage";
|
|
21
23
|
import { addUsage } from "#src/lifecycle/usage";
|
|
@@ -44,7 +46,7 @@ export interface AgentInit {
|
|
|
44
46
|
error?: string;
|
|
45
47
|
abortController?: AbortController;
|
|
46
48
|
invocation?: AgentInvocation;
|
|
47
|
-
promise?: Promise<
|
|
49
|
+
promise?: Promise<void>;
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
export class Agent {
|
|
@@ -83,7 +85,7 @@ export class Agent {
|
|
|
83
85
|
/** AbortController for cancelling this agent. Set at construction; used only by AgentManager. */
|
|
84
86
|
readonly abortController?: AbortController;
|
|
85
87
|
/** Promise for the full agent run (including post-processing). Set once by AgentManager. */
|
|
86
|
-
promise?: Promise<
|
|
88
|
+
promise?: Promise<void>;
|
|
87
89
|
|
|
88
90
|
// Phase-specific collaborators — each born complete when their info becomes available
|
|
89
91
|
execution?: ExecutionState;
|
|
@@ -248,12 +250,90 @@ export class Agent {
|
|
|
248
250
|
this._pendingSteers = [];
|
|
249
251
|
}
|
|
250
252
|
|
|
251
|
-
/** Reset for resume: running status, new startedAt, clear completedAt/result/error. */
|
|
253
|
+
/** Reset for resume: running status, new startedAt, clear completedAt/result/error/listeners. */
|
|
252
254
|
resetForResume(startedAt: number): void {
|
|
253
255
|
this._status = "running";
|
|
254
256
|
this._startedAt = startedAt;
|
|
255
257
|
this._completedAt = undefined;
|
|
256
258
|
this._result = undefined;
|
|
257
259
|
this._error = undefined;
|
|
260
|
+
this.releaseListeners();
|
|
261
|
+
this._onRunFinished = undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// --- Per-run listener state (released on completion or resume reset) ---
|
|
265
|
+
private _unsub?: () => void;
|
|
266
|
+
private _detachFn?: () => void;
|
|
267
|
+
private _onRunFinished?: () => void;
|
|
268
|
+
|
|
269
|
+
/** Wire a parent AbortSignal so it stops this agent when fired. */
|
|
270
|
+
wireSignal(signal: AbortSignal | undefined, onAbort: () => void): void {
|
|
271
|
+
if (!signal) return;
|
|
272
|
+
const listener = () => onAbort();
|
|
273
|
+
signal.addEventListener("abort", listener, { once: true });
|
|
274
|
+
this._detachFn = () => signal.removeEventListener("abort", listener);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Store the record-observer unsubscribe handle. */
|
|
278
|
+
attachObserver(unsub: () => void): void {
|
|
279
|
+
this._unsub = unsub;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Release observer + signal listener handles. */
|
|
283
|
+
releaseListeners(): void {
|
|
284
|
+
this._unsub?.();
|
|
285
|
+
this._unsub = undefined;
|
|
286
|
+
this._detachFn?.();
|
|
287
|
+
this._detachFn = undefined;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Set the callback fired once when the run finishes (for concurrency drain). */
|
|
291
|
+
setOnRunFinished(fn: (() => void) | undefined): void {
|
|
292
|
+
this._onRunFinished = fn;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Fire the onRunFinished callback at most once. */
|
|
296
|
+
private fireOnRunFinished(): void {
|
|
297
|
+
const fn = this._onRunFinished;
|
|
298
|
+
this._onRunFinished = undefined;
|
|
299
|
+
fn?.();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Complete a run: release listeners, worktree cleanup, status transition, execution update, fire onRunFinished. */
|
|
303
|
+
completeRun(result: RunResult, worktrees: WorktreeManager): void {
|
|
304
|
+
this.releaseListeners();
|
|
305
|
+
|
|
306
|
+
let finalResult = result.responseText;
|
|
307
|
+
if (this.worktreeState) {
|
|
308
|
+
const wtResult = this.worktreeState.performCleanup(worktrees, this.description);
|
|
309
|
+
if (wtResult.hasChanges && wtResult.branch) {
|
|
310
|
+
finalResult += `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (result.aborted) this.markAborted(finalResult);
|
|
315
|
+
else if (result.steered) this.markSteered(finalResult);
|
|
316
|
+
else this.markCompleted(finalResult);
|
|
317
|
+
|
|
318
|
+
this.execution = {
|
|
319
|
+
session: result.session,
|
|
320
|
+
outputFile: result.sessionFile ?? this.execution?.outputFile,
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
this.fireOnRunFinished();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Fail a run: mark error, release listeners, best-effort worktree cleanup, fire onRunFinished. */
|
|
327
|
+
failRun(err: unknown, worktrees: WorktreeManager): void {
|
|
328
|
+
this.markError(err);
|
|
329
|
+
this.releaseListeners();
|
|
330
|
+
|
|
331
|
+
if (this.worktreeState) {
|
|
332
|
+
try {
|
|
333
|
+
this.worktreeState.performCleanup(worktrees, this.description);
|
|
334
|
+
} catch (cleanupErr) { debugLog("cleanupWorktree on agent error", cleanupErr); }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
this.fireOnRunFinished();
|
|
258
338
|
}
|
|
259
339
|
}
|