@gotgenes/pi-subagents 10.2.1 → 11.0.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 +23 -0
- package/docs/architecture/architecture.md +6 -5
- package/docs/plans/0229-agent-born-complete.md +564 -0
- package/docs/retro/0229-agent-born-complete.md +47 -0
- package/docs/retro/0231-push-exec-registry-to-runner.md +31 -0
- package/package.json +1 -1
- package/src/lifecycle/agent-manager.ts +49 -103
- package/src/lifecycle/agent-runner.ts +1 -2
- package/src/lifecycle/agent.ts +166 -39
- package/src/observation/record-observer.ts +1 -2
- package/src/tools/agent-tool.ts +2 -2
- package/src/tools/background-spawner.ts +7 -5
- package/src/tools/foreground-runner.ts +11 -9
- package/src/types.ts +13 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 229
|
|
3
|
+
issue_title: "Agent born complete: Agent.run() absorbs startAgent (Phase 15, Step 4)"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #229 — Agent born complete: Agent.run() absorbs startAgent
|
|
7
|
+
|
|
8
|
+
## Stage: Planning (2026-05-27T18:00:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Produced a 9-step TDD plan for absorbing `AgentManager.startAgent()` into `Agent.run()`.
|
|
13
|
+
Key design decisions: per-agent `AgentLifecycleObserver` interface passed at construction (chosen over callback fields and EventEmitter), and fully async worktree error surface (chosen over split sync/async).
|
|
14
|
+
|
|
15
|
+
### Observations
|
|
16
|
+
|
|
17
|
+
- **Observer pattern chosen over callbacks:** The per-agent `AgentLifecycleObserver` interface replaces three separate mechanisms (`onSessionCreated` callback, `setOnRunFinished`, `onCompact` callback).
|
|
18
|
+
All methods are optional, composed by `AgentManager.buildObserver()` per spawn.
|
|
19
|
+
- **`ParentSessionInfo`/`CompactionInfo` relocation needed:** `agent.ts` importing from `agent-manager.ts` would create a circular type import (agent-manager already imports `Agent`).
|
|
20
|
+
Moving both types to `types.ts` in step 1 avoids the cycle.
|
|
21
|
+
- **`AgentInit` grows wide (15+ optional fields):** Making run-config fields optional preserves backward compat for the 55+ `new Agent()` calls in tests.
|
|
22
|
+
Noted as a known smell — follow-up issues (#230 ConcurrencyQueue, potential `AgentInit` restructuring) may address this.
|
|
23
|
+
- **Async error surface changes tool behavior:** `background-spawner.ts`'s try/catch around `manager.spawn()` becomes unreachable for worktree errors.
|
|
24
|
+
Keeping it for robustness; the error surfaces on `record.error` instead.
|
|
25
|
+
- **Lift-and-shift TDD order:** Steps 3–5 incrementally change `AgentInit`, `setupWorktree`, and `completeRun`/`failRun` before step 6 adds `Agent.run()`.
|
|
26
|
+
This avoids a single massive step that rewrites everything at once.
|
|
27
|
+
|
|
28
|
+
## Stage: Implementation — TDD (2026-05-28T01:00:00Z)
|
|
29
|
+
|
|
30
|
+
### Session summary
|
|
31
|
+
|
|
32
|
+
Completed all 9 TDD steps in 9 commits (plus 2 planning/retro docs commits).
|
|
33
|
+
Test count went from 1005 to 1020 (+15 tests).
|
|
34
|
+
`AgentManager.startAgent()`, `SpawnArgs`, and `onSessionCreated` callback are deleted.
|
|
35
|
+
`Agent.run()` now owns the full execution lifecycle.
|
|
36
|
+
|
|
37
|
+
### Observations
|
|
38
|
+
|
|
39
|
+
- **Steps 7–8 merged in practice:** The tool-layer `onSessionCreated` → `observer` migration (step 8) had to be done alongside the `AgentSpawnConfig` change (step 7) because removing the `onSessionCreated` field broke compilation of `background-spawner.ts` and `foreground-runner.ts`.
|
|
40
|
+
This was expected — they share the same type.
|
|
41
|
+
- **`setupWorktree` kept public:** The plan called for making it private in step 4, but it was kept public through step 6 since the manager still called it.
|
|
42
|
+
After step 7 (Agent.run() absorbs the call), it could be made private; left as a minor follow-up (reviewer flagged as WARN).
|
|
43
|
+
- **`isBackground` removed from Agent storage:** The field was declared on `AgentInit` but Agent never reads it — the manager resolves `isBackground` before construction (setting initial status and composing the observer).
|
|
44
|
+
Biome flagged it as unused; removed from stored fields, kept on `AgentInit` for the manager's use.
|
|
45
|
+
- **Worktree error surface confirmed async:** The `agent-manager.test.ts` test for synchronous worktree throw was rewritten to verify the error surfaces on `record.error` after awaiting the promise.
|
|
46
|
+
`background-spawner.ts` try/catch around `spawn()` retained for robustness.
|
|
47
|
+
- **Pre-completion reviewer:** WARN — 3 non-blocking findings: `setupWorktree` not marked private, `isBackground` dead field on `AgentInit`, and `package-pi-subagents` SKILL.md Phase 15 description referencing deleted `startAgent`.
|
|
@@ -38,3 +38,34 @@ Pre-completion reviewer returned PASS.
|
|
|
38
38
|
Fixed as a lint cleanup in the doc commit.
|
|
39
39
|
- The `sed`-based bulk replacement for `runAgent(..., io)` → `runAgent(..., { io, exec, registry: mockAgentLookup })` missed one multi-line call site (the `rejects.toThrow` test wrapping the call in `expect()`).
|
|
40
40
|
Caught immediately by the test run.
|
|
41
|
+
|
|
42
|
+
## Stage: Final Retrospective (2026-05-27T22:43:52Z)
|
|
43
|
+
|
|
44
|
+
### Session summary
|
|
45
|
+
|
|
46
|
+
Shipped #231 cleanly: CI passed on first push, issue closed, release `pi-subagents-v10.2.1` published.
|
|
47
|
+
The entire issue (plan → TDD → ship) completed in one sitting with no user intervention needed.
|
|
48
|
+
|
|
49
|
+
### Observations
|
|
50
|
+
|
|
51
|
+
#### What went well
|
|
52
|
+
|
|
53
|
+
- The `RunnerDeps` design was unambiguous — the `ask_user` gate in planning correctly identified the one genuine design choice (`RunContext` fate) and got user input before proceeding.
|
|
54
|
+
- Pre-completion reviewer returned PASS with zero findings, confirming the mechanical refactoring was clean.
|
|
55
|
+
- Merging plan steps 3–5 during TDD was the right call; the testing skill rule about single-call-site interfaces caught the plan's error before any broken commit landed.
|
|
56
|
+
|
|
57
|
+
#### What caused friction (agent side)
|
|
58
|
+
|
|
59
|
+
- `wrong-abstraction` — The plan listed steps 3, 4, and 5 as separate commits and claimed "each commit is independently valid," but removing fields from `RunContext` (step 3) immediately caused TypeScript excess-property errors in `AgentManager` (step 4) and `index.ts` (step 5).
|
|
60
|
+
The existing `/plan-issue` rule (line 109) covers removing exports with single call sites, but did not trigger recognition because this was *shrinking* an interface, not removing one.
|
|
61
|
+
Impact: the TDD agent had to merge three steps on the fly — no rework, but the plan was misleading.
|
|
62
|
+
- `missing-context` — The `sed`-based bulk replacement for `runAgent(..., io)` missed one multi-line call site where `}, io)` appeared on a different line than the opening `runAgent(`.
|
|
63
|
+
Impact: one extra manual edit; caught immediately by the test run.
|
|
64
|
+
|
|
65
|
+
#### What caused friction (user side)
|
|
66
|
+
|
|
67
|
+
- No friction observed — the user's involvement was limited to confirming the `RunContext` design choice during planning.
|
|
68
|
+
|
|
69
|
+
### Changes made
|
|
70
|
+
|
|
71
|
+
1. `.pi/prompts/plan-issue.md` — added a rule under TDD Order: when a step removes fields from an interface, include downstream object-literal call-site updates in the same step (TypeScript excess property checking).
|
package/package.json
CHANGED
|
@@ -8,26 +8,22 @@
|
|
|
8
8
|
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
10
|
import type { Model } from "@earendil-works/pi-ai";
|
|
11
|
-
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
12
11
|
import { debugLog } from "#src/debug";
|
|
13
|
-
import { Agent } from "#src/lifecycle/agent";
|
|
12
|
+
import { Agent, type AgentLifecycleObserver } from "#src/lifecycle/agent";
|
|
14
13
|
import type { AgentRunner } from "#src/lifecycle/agent-runner";
|
|
15
14
|
import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
16
15
|
import type { WorktreeManager } from "#src/lifecycle/worktree";
|
|
17
16
|
|
|
18
|
-
import { NotificationState } from "#src/observation/notification-state";
|
|
19
17
|
import { subscribeAgentObserver } from "#src/observation/record-observer";
|
|
20
18
|
import type { RunConfig } from "#src/runtime";
|
|
21
|
-
import type { AgentInvocation, IsolationMode, SubagentType, ThinkingLevel } from "#src/types";
|
|
22
|
-
|
|
23
|
-
export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number };
|
|
19
|
+
import type { AgentInvocation, CompactionInfo, IsolationMode, ParentSessionInfo, SubagentType, ThinkingLevel } from "#src/types";
|
|
24
20
|
|
|
25
21
|
/** Observer interface for agent lifecycle notifications. */
|
|
26
22
|
export interface AgentManagerObserver {
|
|
27
23
|
onAgentStarted(record: Agent): void;
|
|
28
24
|
onAgentCompleted(record: Agent): void;
|
|
29
25
|
onAgentCompacted(record: Agent, info: CompactionInfo): void;
|
|
30
|
-
/** Fires synchronously after a background agent record is created (before
|
|
26
|
+
/** Fires synchronously after a background agent record is created (before run). */
|
|
31
27
|
onAgentCreated(record: Agent): void;
|
|
32
28
|
}
|
|
33
29
|
|
|
@@ -43,22 +39,6 @@ export interface AgentManagerOptions {
|
|
|
43
39
|
observer?: AgentManagerObserver;
|
|
44
40
|
}
|
|
45
41
|
|
|
46
|
-
interface SpawnArgs {
|
|
47
|
-
snapshot: ParentSnapshot;
|
|
48
|
-
type: SubagentType;
|
|
49
|
-
prompt: string;
|
|
50
|
-
options: AgentSpawnConfig;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface ParentSessionInfo {
|
|
54
|
-
/** Path to the parent session's JSONL file (for deriving the subagent session directory). */
|
|
55
|
-
parentSessionFile?: string;
|
|
56
|
-
/** Session ID of the parent agent (stored in the child session's parentSession header). */
|
|
57
|
-
parentSessionId?: string;
|
|
58
|
-
/** Tool call ID for background notification wiring. When set, spawn attaches NotificationState. */
|
|
59
|
-
toolCallId?: string;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
42
|
export interface AgentSpawnConfig {
|
|
63
43
|
description: string;
|
|
64
44
|
model?: Model<any>;
|
|
@@ -79,8 +59,8 @@ export interface AgentSpawnConfig {
|
|
|
79
59
|
invocation?: AgentInvocation;
|
|
80
60
|
/** Parent abort signal - when aborted, the subagent is also stopped. */
|
|
81
61
|
signal?: AbortSignal;
|
|
82
|
-
/**
|
|
83
|
-
|
|
62
|
+
/** Per-agent lifecycle observer — replaces onSessionCreated callback. */
|
|
63
|
+
observer?: AgentLifecycleObserver;
|
|
84
64
|
/** Parent session identity - grouped fields that travel together from the tool boundary. */
|
|
85
65
|
parentSession?: ParentSessionInfo;
|
|
86
66
|
}
|
|
@@ -94,8 +74,8 @@ export class AgentManager {
|
|
|
94
74
|
private readonly _getMaxConcurrent: () => number;
|
|
95
75
|
private getRunConfig?: () => RunConfig;
|
|
96
76
|
|
|
97
|
-
/** Queue of background
|
|
98
|
-
private queue:
|
|
77
|
+
/** Queue of background agent IDs waiting to start. */
|
|
78
|
+
private queue: string[] = [];
|
|
99
79
|
/** Number of currently running background agents. */
|
|
100
80
|
private runningBackground = 0;
|
|
101
81
|
constructor(options: AgentManagerOptions) {
|
|
@@ -117,6 +97,25 @@ export class AgentManager {
|
|
|
117
97
|
this.drainQueue();
|
|
118
98
|
}
|
|
119
99
|
|
|
100
|
+
/** Compose a per-agent lifecycle observer from manager and spawn-config concerns. */
|
|
101
|
+
private buildObserver(options: AgentSpawnConfig): AgentLifecycleObserver {
|
|
102
|
+
return {
|
|
103
|
+
onStarted: (agent) => {
|
|
104
|
+
if (options.isBackground) this.runningBackground++;
|
|
105
|
+
this.observer?.onAgentStarted(agent);
|
|
106
|
+
},
|
|
107
|
+
onSessionCreated: options.observer?.onSessionCreated
|
|
108
|
+
? (agent, session) => options.observer!.onSessionCreated!(agent, session)
|
|
109
|
+
: undefined,
|
|
110
|
+
onRunFinished: (agent) => {
|
|
111
|
+
if (options.isBackground) this.finalizeBackgroundRun(agent);
|
|
112
|
+
},
|
|
113
|
+
onCompacted: (agent, info) => {
|
|
114
|
+
this.observer?.onAgentCompacted(agent, info);
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
120
119
|
/**
|
|
121
120
|
* Spawn an agent and return its ID immediately (for background use).
|
|
122
121
|
* If the concurrency limit is reached, the agent is queued.
|
|
@@ -128,90 +127,45 @@ export class AgentManager {
|
|
|
128
127
|
options: AgentSpawnConfig,
|
|
129
128
|
): string {
|
|
130
129
|
const id = randomUUID().slice(0, 17);
|
|
131
|
-
const abortController = new AbortController();
|
|
132
130
|
const record = new Agent({
|
|
133
131
|
id,
|
|
134
132
|
type,
|
|
135
133
|
description: options.description,
|
|
136
134
|
status: options.isBackground ? "queued" : "running",
|
|
137
135
|
startedAt: Date.now(),
|
|
138
|
-
abortController,
|
|
139
136
|
invocation: options.invocation,
|
|
137
|
+
// Run config
|
|
138
|
+
snapshot,
|
|
139
|
+
prompt,
|
|
140
|
+
model: options.model,
|
|
141
|
+
maxTurns: options.maxTurns,
|
|
142
|
+
isolated: options.isolated,
|
|
143
|
+
thinkingLevel: options.thinkingLevel,
|
|
144
|
+
isolation: options.isolation,
|
|
145
|
+
parentSession: options.parentSession,
|
|
146
|
+
signal: options.signal,
|
|
147
|
+
// Shared deps
|
|
148
|
+
runner: this.runner,
|
|
149
|
+
worktrees: this.worktrees,
|
|
150
|
+
observer: this.buildObserver(options),
|
|
151
|
+
getRunConfig: this.getRunConfig,
|
|
140
152
|
});
|
|
141
153
|
this.agents.set(id, record);
|
|
142
154
|
|
|
143
|
-
if (options.parentSession?.toolCallId) {
|
|
144
|
-
record.notification = new NotificationState(options.parentSession.toolCallId);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
155
|
if (options.isBackground) {
|
|
148
156
|
this.observer?.onAgentCreated(record);
|
|
149
157
|
}
|
|
150
158
|
|
|
151
|
-
const args: SpawnArgs = { snapshot, type, prompt, options };
|
|
152
|
-
|
|
153
159
|
if (options.isBackground && !options.bypassQueue && this.runningBackground >= this._getMaxConcurrent()) {
|
|
154
160
|
// Queue it - will be started when a running agent completes
|
|
155
|
-
this.queue.push(
|
|
161
|
+
this.queue.push(id);
|
|
156
162
|
return id;
|
|
157
163
|
}
|
|
158
164
|
|
|
159
|
-
|
|
160
|
-
// up the record so callers don't see an orphan in `listAgents()`.
|
|
161
|
-
try {
|
|
162
|
-
record.setupWorktree(this.worktrees, options.isolation);
|
|
163
|
-
record.promise = this.startAgent(id, record, args);
|
|
164
|
-
} catch (err) {
|
|
165
|
-
this.agents.delete(id);
|
|
166
|
-
throw err;
|
|
167
|
-
}
|
|
165
|
+
record.promise = record.run();
|
|
168
166
|
return id;
|
|
169
167
|
}
|
|
170
168
|
|
|
171
|
-
/** Actually start an agent (called immediately or from queue drain). */
|
|
172
|
-
private async startAgent(id: string, record: Agent, { snapshot, type, prompt, options }: SpawnArgs): Promise<void> {
|
|
173
|
-
record.markRunning(Date.now());
|
|
174
|
-
if (options.isBackground) this.runningBackground++;
|
|
175
|
-
this.observer?.onAgentStarted(record);
|
|
176
|
-
|
|
177
|
-
record.setOnRunFinished(
|
|
178
|
-
options.isBackground ? () => this.finalizeBackgroundRun(record) : undefined,
|
|
179
|
-
);
|
|
180
|
-
record.wireSignal(options.signal, () => this.abort(id));
|
|
181
|
-
|
|
182
|
-
const runConfig = this.getRunConfig?.();
|
|
183
|
-
try {
|
|
184
|
-
const result = await this.runner.run(snapshot, type, prompt, {
|
|
185
|
-
context: {
|
|
186
|
-
cwd: record.worktreeState?.path,
|
|
187
|
-
parentSession: options.parentSession,
|
|
188
|
-
},
|
|
189
|
-
model: options.model,
|
|
190
|
-
maxTurns: options.maxTurns,
|
|
191
|
-
defaultMaxTurns: runConfig?.defaultMaxTurns,
|
|
192
|
-
graceTurns: runConfig?.graceTurns,
|
|
193
|
-
isolated: options.isolated,
|
|
194
|
-
thinkingLevel: options.thinkingLevel,
|
|
195
|
-
signal: record.abortController!.signal,
|
|
196
|
-
onSessionCreated: (session) => {
|
|
197
|
-
// Capture the session file path early so it's available for display
|
|
198
|
-
// before the run completes (e.g. in background agent status messages).
|
|
199
|
-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- sessionManager is typed as always present but Pi SDK may not provide it
|
|
200
|
-
const outputFile = session.sessionManager?.getSessionFile?.() ?? undefined;
|
|
201
|
-
record.execution = { session, outputFile };
|
|
202
|
-
record.flushPendingSteers(session);
|
|
203
|
-
record.attachObserver(subscribeAgentObserver(session, record, {
|
|
204
|
-
onCompact: (r, info) => this.observer?.onAgentCompacted(r, info),
|
|
205
|
-
}));
|
|
206
|
-
options.onSessionCreated?.(session, record);
|
|
207
|
-
},
|
|
208
|
-
});
|
|
209
|
-
record.completeRun(result, this.worktrees);
|
|
210
|
-
} catch (err) {
|
|
211
|
-
record.failRun(err, this.worktrees);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
169
|
/** Decrement background counter, notify observer (crash-safe), and drain the queue. */
|
|
216
170
|
private finalizeBackgroundRun(record: Agent): void {
|
|
217
171
|
this.runningBackground--;
|
|
@@ -222,18 +176,10 @@ export class AgentManager {
|
|
|
222
176
|
/** Start queued agents up to the concurrency limit. */
|
|
223
177
|
private drainQueue() {
|
|
224
178
|
while (this.queue.length > 0 && this.runningBackground < this._getMaxConcurrent()) {
|
|
225
|
-
const
|
|
226
|
-
const record = this.agents.get(
|
|
179
|
+
const id = this.queue.shift()!;
|
|
180
|
+
const record = this.agents.get(id);
|
|
227
181
|
if (record?.status !== "queued") continue;
|
|
228
|
-
|
|
229
|
-
record.setupWorktree(this.worktrees, next.args.options.isolation);
|
|
230
|
-
record.promise = this.startAgent(next.id, record, next.args);
|
|
231
|
-
} catch (err) {
|
|
232
|
-
// Late failure (e.g. strict worktree-isolation) - surface on the record
|
|
233
|
-
// so the user/agent can see it via /agents, then keep draining.
|
|
234
|
-
record.markError(err);
|
|
235
|
-
this.observer?.onAgentCompleted(record);
|
|
236
|
-
}
|
|
182
|
+
record.promise = record.run();
|
|
237
183
|
}
|
|
238
184
|
}
|
|
239
185
|
|
|
@@ -301,7 +247,7 @@ export class AgentManager {
|
|
|
301
247
|
|
|
302
248
|
// Remove from queue if queued
|
|
303
249
|
if (record.status === "queued") {
|
|
304
|
-
this.queue = this.queue.filter(
|
|
250
|
+
this.queue = this.queue.filter(qid => qid !== id);
|
|
305
251
|
record.markStopped();
|
|
306
252
|
return true;
|
|
307
253
|
}
|
|
@@ -349,8 +295,8 @@ export class AgentManager {
|
|
|
349
295
|
abortAll(): number {
|
|
350
296
|
let count = 0;
|
|
351
297
|
// Clear queued agents first
|
|
352
|
-
for (const
|
|
353
|
-
const record = this.agents.get(
|
|
298
|
+
for (const id of this.queue) {
|
|
299
|
+
const record = this.agents.get(id);
|
|
354
300
|
if (record) {
|
|
355
301
|
record.markStopped();
|
|
356
302
|
count++;
|
|
@@ -9,14 +9,13 @@ import {
|
|
|
9
9
|
type SettingsManager,
|
|
10
10
|
} from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import type { AgentConfigLookup } from "#src/config/agent-types";
|
|
12
|
-
import type { ParentSessionInfo } from "#src/lifecycle/agent-manager";
|
|
13
12
|
import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
14
13
|
import { registerChildSession, unregisterChildSession } from "#src/lifecycle/permission-bridge";
|
|
15
14
|
import { extractAssistantContent } from "#src/session/content-items";
|
|
16
15
|
import { extractText } from "#src/session/context";
|
|
17
16
|
import type { EnvInfo } from "#src/session/env";
|
|
18
17
|
import { type AssemblerIO, assembleSessionConfig } from "#src/session/session-config";
|
|
19
|
-
import type { ShellExec, SubagentType, ThinkingLevel } from "#src/types";
|
|
18
|
+
import type { ParentSessionInfo, ShellExec, SubagentType, ThinkingLevel } from "#src/types";
|
|
20
19
|
|
|
21
20
|
/** Names of tools registered by this extension that subagents must NOT inherit. */
|
|
22
21
|
const EXCLUDED_TOOL_NAMES = ["subagent", "get_subagent_result", "steer_subagent"];
|
package/src/lifecycle/agent.ts
CHANGED
|
@@ -15,16 +15,32 @@
|
|
|
15
15
|
* after construction as lifecycle information becomes available.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
18
19
|
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
19
20
|
import { debugLog } from "#src/debug";
|
|
20
|
-
import type { RunResult } from "#src/lifecycle/agent-runner";
|
|
21
|
+
import type { AgentRunner, RunResult } from "#src/lifecycle/agent-runner";
|
|
21
22
|
import type { ExecutionState } from "#src/lifecycle/execution-state";
|
|
23
|
+
import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
22
24
|
import type { LifetimeUsage } from "#src/lifecycle/usage";
|
|
23
25
|
import { addUsage } from "#src/lifecycle/usage";
|
|
24
26
|
import type { WorktreeManager } from "#src/lifecycle/worktree";
|
|
25
27
|
import { WorktreeState } from "#src/lifecycle/worktree-state";
|
|
26
|
-
import
|
|
27
|
-
import
|
|
28
|
+
import { NotificationState } from "#src/observation/notification-state";
|
|
29
|
+
import { subscribeAgentObserver } from "#src/observation/record-observer";
|
|
30
|
+
import type { RunConfig } from "#src/runtime";
|
|
31
|
+
import type { AgentInvocation, CompactionInfo, IsolationMode, ParentSessionInfo, SubagentType, ThinkingLevel } from "#src/types";
|
|
32
|
+
|
|
33
|
+
/** Per-agent lifecycle observer — created by AgentManager for each spawn. */
|
|
34
|
+
export interface AgentLifecycleObserver {
|
|
35
|
+
/** Fires when the agent transitions to running (inside run(), after markRunning). */
|
|
36
|
+
onStarted?(agent: Agent): void;
|
|
37
|
+
/** Fires when the runner creates the session — delivers the session to external consumers. */
|
|
38
|
+
onSessionCreated?(agent: Agent, session: AgentSession): void;
|
|
39
|
+
/** Fires once when the run completes or fails (for concurrency drain). */
|
|
40
|
+
onRunFinished?(agent: Agent): void;
|
|
41
|
+
/** Fires on compaction events during the run. */
|
|
42
|
+
onCompacted?(agent: Agent, info: CompactionInfo): void;
|
|
43
|
+
}
|
|
28
44
|
|
|
29
45
|
export type AgentStatus =
|
|
30
46
|
| "queued"
|
|
@@ -36,17 +52,36 @@ export type AgentStatus =
|
|
|
36
52
|
| "error";
|
|
37
53
|
|
|
38
54
|
export interface AgentInit {
|
|
55
|
+
// Identity
|
|
39
56
|
id: string;
|
|
40
57
|
type: SubagentType;
|
|
41
58
|
description: string;
|
|
59
|
+
invocation?: AgentInvocation;
|
|
60
|
+
|
|
61
|
+
// Status (for tests and restore scenarios)
|
|
42
62
|
status?: AgentStatus;
|
|
43
63
|
startedAt?: number;
|
|
44
64
|
completedAt?: number;
|
|
45
65
|
result?: string;
|
|
46
66
|
error?: string;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
67
|
+
|
|
68
|
+
// Shared deps (required for run(), optional for tests)
|
|
69
|
+
runner?: AgentRunner;
|
|
70
|
+
worktrees?: WorktreeManager;
|
|
71
|
+
observer?: AgentLifecycleObserver;
|
|
72
|
+
getRunConfig?: () => RunConfig;
|
|
73
|
+
|
|
74
|
+
// Run config (required for run(), optional for tests)
|
|
75
|
+
snapshot?: ParentSnapshot;
|
|
76
|
+
prompt?: string;
|
|
77
|
+
model?: Model<any>;
|
|
78
|
+
maxTurns?: number;
|
|
79
|
+
isolated?: boolean;
|
|
80
|
+
thinkingLevel?: ThinkingLevel;
|
|
81
|
+
isolation?: IsolationMode;
|
|
82
|
+
parentSession?: ParentSessionInfo;
|
|
83
|
+
isBackground?: boolean;
|
|
84
|
+
signal?: AbortSignal;
|
|
50
85
|
}
|
|
51
86
|
|
|
52
87
|
export class Agent {
|
|
@@ -82,11 +117,28 @@ export class Agent {
|
|
|
82
117
|
private _compactionCount: number;
|
|
83
118
|
get compactionCount(): number { return this._compactionCount; }
|
|
84
119
|
|
|
85
|
-
/** AbortController for cancelling this agent.
|
|
86
|
-
readonly abortController
|
|
87
|
-
/** Promise for the full agent run (including post-processing). Set
|
|
120
|
+
/** AbortController for cancelling this agent. Created at construction. */
|
|
121
|
+
readonly abortController: AbortController;
|
|
122
|
+
/** Promise for the full agent run (including post-processing). Set by run(). */
|
|
88
123
|
promise?: Promise<void>;
|
|
89
124
|
|
|
125
|
+
// Shared deps — optional (required for run())
|
|
126
|
+
private readonly _runner?: AgentRunner;
|
|
127
|
+
private readonly _worktrees?: WorktreeManager;
|
|
128
|
+
readonly observer?: AgentLifecycleObserver;
|
|
129
|
+
private readonly _getRunConfig?: () => RunConfig;
|
|
130
|
+
|
|
131
|
+
// Run config — optional (required for run())
|
|
132
|
+
private readonly _snapshot?: ParentSnapshot;
|
|
133
|
+
private readonly _prompt?: string;
|
|
134
|
+
private readonly _model?: Model<any>;
|
|
135
|
+
private readonly _maxTurns?: number;
|
|
136
|
+
private readonly _isolated?: boolean;
|
|
137
|
+
private readonly _thinkingLevel?: ThinkingLevel;
|
|
138
|
+
private readonly _isolation?: IsolationMode;
|
|
139
|
+
private readonly _parentSession?: ParentSessionInfo;
|
|
140
|
+
private readonly _signal?: AbortSignal;
|
|
141
|
+
|
|
90
142
|
// Phase-specific collaborators — each born complete when their info becomes available
|
|
91
143
|
execution?: ExecutionState;
|
|
92
144
|
worktreeState?: WorktreeState;
|
|
@@ -96,10 +148,14 @@ export class Agent {
|
|
|
96
148
|
* Create a git worktree for isolated execution, set worktreeState, and return the worktree path.
|
|
97
149
|
* Returns undefined if isolation is not "worktree".
|
|
98
150
|
* Throws if worktree creation fails (strict isolation).
|
|
151
|
+
* Uses this._worktrees and this._isolation (set at construction).
|
|
99
152
|
*/
|
|
100
|
-
setupWorktree(
|
|
101
|
-
if (
|
|
102
|
-
|
|
153
|
+
setupWorktree(): string | undefined {
|
|
154
|
+
if (this._isolation !== "worktree") return undefined;
|
|
155
|
+
if (!this._worktrees) {
|
|
156
|
+
throw new Error("Agent not configured for worktree isolation — missing worktrees dependency");
|
|
157
|
+
}
|
|
158
|
+
const wt = this._worktrees.create(this.id);
|
|
103
159
|
if (!wt) {
|
|
104
160
|
throw new Error(
|
|
105
161
|
'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
|
|
@@ -126,22 +182,107 @@ export class Agent {
|
|
|
126
182
|
}
|
|
127
183
|
|
|
128
184
|
constructor(init: AgentInit) {
|
|
185
|
+
// Identity
|
|
129
186
|
this.id = init.id;
|
|
130
187
|
this.type = init.type;
|
|
131
188
|
this.description = init.description;
|
|
132
189
|
this.invocation = init.invocation;
|
|
133
190
|
|
|
191
|
+
// Status
|
|
134
192
|
this._status = init.status ?? "queued";
|
|
135
193
|
this._result = init.result;
|
|
136
194
|
this._error = init.error;
|
|
137
195
|
this._startedAt = init.startedAt ?? Date.now();
|
|
138
196
|
this._completedAt = init.completedAt;
|
|
139
197
|
|
|
198
|
+
// Stats
|
|
140
199
|
this._toolUses = 0;
|
|
141
200
|
this._lifetimeUsage = { input: 0, output: 0, cacheWrite: 0 };
|
|
142
201
|
this._compactionCount = 0;
|
|
143
|
-
|
|
144
|
-
|
|
202
|
+
|
|
203
|
+
// Abort controller — always created, never injected
|
|
204
|
+
this.abortController = new AbortController();
|
|
205
|
+
|
|
206
|
+
// Shared deps
|
|
207
|
+
this._runner = init.runner;
|
|
208
|
+
this._worktrees = init.worktrees;
|
|
209
|
+
this.observer = init.observer;
|
|
210
|
+
this._getRunConfig = init.getRunConfig;
|
|
211
|
+
|
|
212
|
+
// Run config
|
|
213
|
+
this._snapshot = init.snapshot;
|
|
214
|
+
this._prompt = init.prompt;
|
|
215
|
+
this._model = init.model;
|
|
216
|
+
this._maxTurns = init.maxTurns;
|
|
217
|
+
this._isolated = init.isolated;
|
|
218
|
+
this._thinkingLevel = init.thinkingLevel;
|
|
219
|
+
this._isolation = init.isolation;
|
|
220
|
+
this._parentSession = init.parentSession;
|
|
221
|
+
this._signal = init.signal;
|
|
222
|
+
|
|
223
|
+
// Notification state — created from parentSession.toolCallId if present
|
|
224
|
+
if (init.parentSession?.toolCallId) {
|
|
225
|
+
this.notification = new NotificationState(init.parentSession.toolCallId);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Execute the full agent lifecycle: worktree setup, runner invocation,
|
|
231
|
+
* session-creation handling, observer wiring, worktree cleanup, and
|
|
232
|
+
* status transitions.
|
|
233
|
+
*
|
|
234
|
+
* Requires runner and snapshot to be set at construction.
|
|
235
|
+
* The returned promise always resolves (errors are captured internally).
|
|
236
|
+
*/
|
|
237
|
+
async run(): Promise<void> {
|
|
238
|
+
if (!this._runner) {
|
|
239
|
+
throw new Error("Agent not configured for execution — missing runner");
|
|
240
|
+
}
|
|
241
|
+
if (!this._snapshot || !this._prompt) {
|
|
242
|
+
throw new Error("Agent not configured for execution — missing snapshot or prompt");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
this.markRunning(Date.now());
|
|
246
|
+
this.observer?.onStarted?.(this);
|
|
247
|
+
this.wireSignal(this._signal, () => this.abort());
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
this.setupWorktree();
|
|
251
|
+
} catch (err) {
|
|
252
|
+
this.markError(err);
|
|
253
|
+
this.observer?.onRunFinished?.(this);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const runConfig = this._getRunConfig?.();
|
|
258
|
+
try {
|
|
259
|
+
const result = await this._runner.run(this._snapshot, this.type, this._prompt, {
|
|
260
|
+
context: {
|
|
261
|
+
cwd: this.worktreeState?.path,
|
|
262
|
+
parentSession: this._parentSession,
|
|
263
|
+
},
|
|
264
|
+
model: this._model,
|
|
265
|
+
maxTurns: this._maxTurns,
|
|
266
|
+
defaultMaxTurns: runConfig?.defaultMaxTurns,
|
|
267
|
+
graceTurns: runConfig?.graceTurns,
|
|
268
|
+
isolated: this._isolated,
|
|
269
|
+
thinkingLevel: this._thinkingLevel,
|
|
270
|
+
signal: this.abortController.signal,
|
|
271
|
+
onSessionCreated: (session) => {
|
|
272
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- sessionManager is typed as always present but Pi SDK may not provide it
|
|
273
|
+
const outputFile = session.sessionManager?.getSessionFile?.() ?? undefined;
|
|
274
|
+
this.execution = { session, outputFile };
|
|
275
|
+
this.flushPendingSteers(session);
|
|
276
|
+
this.attachObserver(subscribeAgentObserver(session, this, {
|
|
277
|
+
onCompact: (r, info) => this.observer?.onCompacted?.(r, info),
|
|
278
|
+
}));
|
|
279
|
+
this.observer?.onSessionCreated?.(this, session);
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
this.completeRun(result);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
this.failRun(err);
|
|
285
|
+
}
|
|
145
286
|
}
|
|
146
287
|
|
|
147
288
|
/** Increment tool use count. Called by record-observer on tool_execution_end. */
|
|
@@ -226,7 +367,7 @@ export class Agent {
|
|
|
226
367
|
*/
|
|
227
368
|
abort(): boolean {
|
|
228
369
|
if (this._status !== "running") return false;
|
|
229
|
-
this.abortController
|
|
370
|
+
this.abortController.abort();
|
|
230
371
|
this.markStopped();
|
|
231
372
|
return true;
|
|
232
373
|
}
|
|
@@ -258,13 +399,11 @@ export class Agent {
|
|
|
258
399
|
this._result = undefined;
|
|
259
400
|
this._error = undefined;
|
|
260
401
|
this.releaseListeners();
|
|
261
|
-
this._onRunFinished = undefined;
|
|
262
402
|
}
|
|
263
403
|
|
|
264
404
|
// --- Per-run listener state (released on completion or resume reset) ---
|
|
265
405
|
private _unsub?: () => void;
|
|
266
406
|
private _detachFn?: () => void;
|
|
267
|
-
private _onRunFinished?: () => void;
|
|
268
407
|
|
|
269
408
|
/** Wire a parent AbortSignal so it stops this agent when fired. */
|
|
270
409
|
wireSignal(signal: AbortSignal | undefined, onAbort: () => void): void {
|
|
@@ -287,25 +426,13 @@ export class Agent {
|
|
|
287
426
|
this._detachFn = undefined;
|
|
288
427
|
}
|
|
289
428
|
|
|
290
|
-
/**
|
|
291
|
-
|
|
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 {
|
|
429
|
+
/** Complete a run: release listeners, worktree cleanup, status transition, execution update, notify observer. */
|
|
430
|
+
completeRun(result: RunResult): void {
|
|
304
431
|
this.releaseListeners();
|
|
305
432
|
|
|
306
433
|
let finalResult = result.responseText;
|
|
307
|
-
if (this.worktreeState) {
|
|
308
|
-
const wtResult = this.worktreeState.performCleanup(
|
|
434
|
+
if (this.worktreeState && this._worktrees) {
|
|
435
|
+
const wtResult = this.worktreeState.performCleanup(this._worktrees, this.description);
|
|
309
436
|
if (wtResult.hasChanges && wtResult.branch) {
|
|
310
437
|
finalResult += `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
|
|
311
438
|
}
|
|
@@ -320,20 +447,20 @@ export class Agent {
|
|
|
320
447
|
outputFile: result.sessionFile ?? this.execution?.outputFile,
|
|
321
448
|
};
|
|
322
449
|
|
|
323
|
-
this.
|
|
450
|
+
this.observer?.onRunFinished?.(this);
|
|
324
451
|
}
|
|
325
452
|
|
|
326
|
-
/** Fail a run: mark error, release listeners, best-effort worktree cleanup,
|
|
327
|
-
failRun(err: unknown
|
|
453
|
+
/** Fail a run: mark error, release listeners, best-effort worktree cleanup, notify observer. */
|
|
454
|
+
failRun(err: unknown): void {
|
|
328
455
|
this.markError(err);
|
|
329
456
|
this.releaseListeners();
|
|
330
457
|
|
|
331
|
-
if (this.worktreeState) {
|
|
458
|
+
if (this.worktreeState && this._worktrees) {
|
|
332
459
|
try {
|
|
333
|
-
this.worktreeState.performCleanup(
|
|
460
|
+
this.worktreeState.performCleanup(this._worktrees, this.description);
|
|
334
461
|
} catch (cleanupErr) { debugLog("cleanupWorktree on agent error", cleanupErr); }
|
|
335
462
|
}
|
|
336
463
|
|
|
337
|
-
this.
|
|
464
|
+
this.observer?.onRunFinished?.(this);
|
|
338
465
|
}
|
|
339
466
|
}
|