@gotgenes/pi-subagents 10.1.0 → 10.2.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 +29 -0
- package/docs/architecture/architecture.md +126 -64
- package/docs/plans/0228-async-start-agent-dissolve-run-handle.md +288 -0
- package/docs/plans/0231-push-exec-registry-to-runner.md +245 -0
- package/docs/retro/0227-evolve-agent-record-into-agent.md +43 -0
- package/docs/retro/0228-async-start-agent-dissolve-run-handle.md +80 -0
- package/docs/retro/0231-push-exec-registry-to-runner.md +40 -0
- package/package.json +1 -1
- package/src/index.ts +17 -15
- package/src/lifecycle/agent-manager.ts +41 -137
- package/src/lifecycle/agent-runner.ts +30 -21
- package/src/lifecycle/agent.ts +83 -3
|
@@ -9,106 +9,16 @@
|
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
10
|
import type { Model } from "@earendil-works/pi-ai";
|
|
11
11
|
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { AgentTypeRegistry } from "#src/config/agent-types";
|
|
13
12
|
import { debugLog } from "#src/debug";
|
|
14
13
|
import { Agent } from "#src/lifecycle/agent";
|
|
15
|
-
import type { AgentRunner
|
|
14
|
+
import type { AgentRunner } from "#src/lifecycle/agent-runner";
|
|
16
15
|
import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
17
16
|
import type { WorktreeManager } from "#src/lifecycle/worktree";
|
|
18
17
|
|
|
19
18
|
import { NotificationState } from "#src/observation/notification-state";
|
|
20
19
|
import { subscribeAgentObserver } from "#src/observation/record-observer";
|
|
21
20
|
import type { RunConfig } from "#src/runtime";
|
|
22
|
-
import type { AgentInvocation, IsolationMode,
|
|
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
|
-
}
|
|
21
|
+
import type { AgentInvocation, IsolationMode, SubagentType, ThinkingLevel } from "#src/types";
|
|
112
22
|
|
|
113
23
|
export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number };
|
|
114
24
|
|
|
@@ -127,8 +37,6 @@ const DEFAULT_MAX_CONCURRENT = 4;
|
|
|
127
37
|
export interface AgentManagerOptions {
|
|
128
38
|
runner: AgentRunner;
|
|
129
39
|
worktrees: WorktreeManager;
|
|
130
|
-
exec: ShellExec;
|
|
131
|
-
registry: AgentTypeRegistry;
|
|
132
40
|
/** Injected getter for the concurrency limit - owned by SettingsManager. */
|
|
133
41
|
getMaxConcurrent?: () => number;
|
|
134
42
|
getRunConfig?: () => RunConfig;
|
|
@@ -183,8 +91,6 @@ export class AgentManager {
|
|
|
183
91
|
private readonly observer?: AgentManagerObserver;
|
|
184
92
|
private readonly runner: AgentRunner;
|
|
185
93
|
private readonly worktrees: WorktreeManager;
|
|
186
|
-
private readonly exec: ShellExec;
|
|
187
|
-
private readonly registry: AgentTypeRegistry;
|
|
188
94
|
private readonly _getMaxConcurrent: () => number;
|
|
189
95
|
private getRunConfig?: () => RunConfig;
|
|
190
96
|
|
|
@@ -195,8 +101,6 @@ export class AgentManager {
|
|
|
195
101
|
constructor(options: AgentManagerOptions) {
|
|
196
102
|
this.runner = options.runner;
|
|
197
103
|
this.worktrees = options.worktrees;
|
|
198
|
-
this.exec = options.exec;
|
|
199
|
-
this.registry = options.registry;
|
|
200
104
|
this.observer = options.observer;
|
|
201
105
|
this.getRunConfig = options.getRunConfig;
|
|
202
106
|
this._getMaxConcurrent = options.getMaxConcurrent ?? (() => DEFAULT_MAX_CONCURRENT);
|
|
@@ -252,10 +156,11 @@ export class AgentManager {
|
|
|
252
156
|
return id;
|
|
253
157
|
}
|
|
254
158
|
|
|
255
|
-
//
|
|
159
|
+
// setupWorktree can throw (e.g. strict worktree-isolation failure) - clean
|
|
256
160
|
// up the record so callers don't see an orphan in `listAgents()`.
|
|
257
161
|
try {
|
|
258
|
-
this.
|
|
162
|
+
record.setupWorktree(this.worktrees, options.isolation);
|
|
163
|
+
record.promise = this.startAgent(id, record, args);
|
|
259
164
|
} catch (err) {
|
|
260
165
|
this.agents.delete(id);
|
|
261
166
|
throw err;
|
|
@@ -264,49 +169,47 @@ export class AgentManager {
|
|
|
264
169
|
}
|
|
265
170
|
|
|
266
171
|
/** 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
|
-
|
|
172
|
+
private async startAgent(id: string, record: Agent, { snapshot, type, prompt, options }: SpawnArgs): Promise<void> {
|
|
270
173
|
record.markRunning(Date.now());
|
|
271
174
|
if (options.isBackground) this.runningBackground++;
|
|
272
175
|
this.observer?.onAgentStarted(record);
|
|
273
176
|
|
|
274
|
-
|
|
275
|
-
record, this.worktrees,
|
|
177
|
+
record.setOnRunFinished(
|
|
276
178
|
options.isBackground ? () => this.finalizeBackgroundRun(record) : undefined,
|
|
277
179
|
);
|
|
278
|
-
|
|
180
|
+
record.wireSignal(options.signal, () => this.abort(id));
|
|
279
181
|
|
|
280
182
|
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
|
-
.
|
|
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
|
+
}
|
|
310
213
|
}
|
|
311
214
|
|
|
312
215
|
/** Decrement background counter, notify observer (crash-safe), and drain the queue. */
|
|
@@ -323,7 +226,8 @@ export class AgentManager {
|
|
|
323
226
|
const record = this.agents.get(next.id);
|
|
324
227
|
if (record?.status !== "queued") continue;
|
|
325
228
|
try {
|
|
326
|
-
|
|
229
|
+
record.setupWorktree(this.worktrees, next.args.options.isolation);
|
|
230
|
+
record.promise = this.startAgent(next.id, record, next.args);
|
|
327
231
|
} catch (err) {
|
|
328
232
|
// Late failure (e.g. strict worktree-isolation) - surface on the record
|
|
329
233
|
// so the user/agent can see it via /agents, then keep draining.
|
|
@@ -471,7 +375,7 @@ export class AgentManager {
|
|
|
471
375
|
const pending = [...this.agents.values()]
|
|
472
376
|
.filter(r => r.status === "running" || r.status === "queued")
|
|
473
377
|
.map(r => r.promise)
|
|
474
|
-
.filter((p): p is Promise<
|
|
378
|
+
.filter((p): p is Promise<void> => p != null);
|
|
475
379
|
if (pending.length === 0) break;
|
|
476
380
|
await Promise.allSettled(pending);
|
|
477
381
|
}
|
|
@@ -114,19 +114,27 @@ export interface SessionFactoryIO {
|
|
|
114
114
|
*/
|
|
115
115
|
export type RunnerIO = EnvironmentIO & SessionFactoryIO;
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Dependencies owned by the runner — injected at construction time.
|
|
119
|
+
*
|
|
120
|
+
* Groups the IO boundary with the two static domain deps (exec, registry)
|
|
121
|
+
* that every run() call needs but that do not vary per call.
|
|
122
|
+
*/
|
|
123
|
+
export interface RunnerDeps {
|
|
124
|
+
io: RunnerIO;
|
|
125
|
+
exec: ShellExec;
|
|
126
|
+
registry: AgentConfigLookup;
|
|
127
|
+
}
|
|
128
|
+
|
|
117
129
|
// ── Public interfaces ─────────────────────────────────────────────────────────
|
|
118
130
|
|
|
119
131
|
/**
|
|
120
|
-
*
|
|
132
|
+
* Per-call execution context — fields that vary per spawn.
|
|
121
133
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
134
|
+
* Static dependencies (exec, registry) live on RunnerDeps; this interface
|
|
135
|
+
* carries only the two per-call fields that AgentManager supplies at spawn time.
|
|
124
136
|
*/
|
|
125
137
|
export interface RunContext {
|
|
126
|
-
/** Shell-exec callback for detectEnv - injected from pi.exec(). */
|
|
127
|
-
exec: ShellExec;
|
|
128
|
-
/** Agent config lookup - provides resolveAgentConfig and getToolNamesForType. */
|
|
129
|
-
registry: AgentConfigLookup;
|
|
130
138
|
/** Override working directory (e.g. for worktree isolation). */
|
|
131
139
|
cwd?: string;
|
|
132
140
|
/** Parent session identity (file path + session ID). */
|
|
@@ -182,15 +190,16 @@ export interface AgentRunner {
|
|
|
182
190
|
}
|
|
183
191
|
|
|
184
192
|
/**
|
|
185
|
-
* Concrete AgentRunner backed by
|
|
193
|
+
* Concrete AgentRunner backed by RunnerDeps.
|
|
186
194
|
*
|
|
187
|
-
* Captures
|
|
195
|
+
* Captures IO, exec, and registry at construction time so AgentManager
|
|
196
|
+
* remains unaware of runner-internal dependencies.
|
|
188
197
|
*/
|
|
189
198
|
export class ConcreteAgentRunner implements AgentRunner {
|
|
190
|
-
constructor(private readonly
|
|
199
|
+
constructor(private readonly deps: RunnerDeps) {}
|
|
191
200
|
|
|
192
201
|
run(snapshot: ParentSnapshot, type: SubagentType, prompt: string, options: RunOptions): Promise<RunResult> {
|
|
193
|
-
return runAgent(snapshot, type, prompt, options, this.
|
|
202
|
+
return runAgent(snapshot, type, prompt, options, this.deps);
|
|
194
203
|
}
|
|
195
204
|
|
|
196
205
|
resume(session: AgentSession, prompt: string, options?: ResumeOptions): Promise<string> {
|
|
@@ -253,11 +262,11 @@ export async function runAgent(
|
|
|
253
262
|
type: SubagentType,
|
|
254
263
|
prompt: string,
|
|
255
264
|
options: RunOptions,
|
|
256
|
-
|
|
265
|
+
deps: RunnerDeps,
|
|
257
266
|
): Promise<RunResult> {
|
|
258
267
|
// Resolve working directory upfront - needed for detectEnv before assembly.
|
|
259
268
|
const effectiveCwd = options.context.cwd ?? snapshot.cwd;
|
|
260
|
-
const env = await io.detectEnv(
|
|
269
|
+
const env = await deps.io.detectEnv(deps.exec, effectiveCwd);
|
|
261
270
|
|
|
262
271
|
// Assemble session configuration (synchronous, no SDK objects).
|
|
263
272
|
const cfg = assembleSessionConfig(
|
|
@@ -275,11 +284,11 @@ export async function runAgent(
|
|
|
275
284
|
thinkingLevel: options.thinkingLevel,
|
|
276
285
|
},
|
|
277
286
|
env,
|
|
278
|
-
|
|
279
|
-
io.assemblerIO,
|
|
287
|
+
deps.registry,
|
|
288
|
+
deps.io.assemblerIO,
|
|
280
289
|
);
|
|
281
290
|
|
|
282
|
-
const agentDir = io.getAgentDir();
|
|
291
|
+
const agentDir = deps.io.getAgentDir();
|
|
283
292
|
|
|
284
293
|
// Load extensions/skills: true → load; false → don't.
|
|
285
294
|
// Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md - upstream's
|
|
@@ -287,7 +296,7 @@ export async function runAgent(
|
|
|
287
296
|
// would defeat prompt_mode: replace and isolated: true. Parent context, if
|
|
288
297
|
// wanted, reaches the subagent via prompt_mode: append (parentSystemPrompt
|
|
289
298
|
// is embedded in systemPromptOverride) or inherit_context (conversation).
|
|
290
|
-
const loader = io.createResourceLoader({
|
|
299
|
+
const loader = deps.io.createResourceLoader({
|
|
291
300
|
cwd: cfg.effectiveCwd,
|
|
292
301
|
agentDir,
|
|
293
302
|
noExtensions: !cfg.extensions,
|
|
@@ -303,15 +312,15 @@ export async function runAgent(
|
|
|
303
312
|
// Create a persisted SessionManager so transcripts are written in Pi's
|
|
304
313
|
// official JSONL format. Falls back to a temp directory when the parent
|
|
305
314
|
// session is not persisted (e.g. headless/API mode).
|
|
306
|
-
const sessionDir = io.deriveSessionDir(options.context.parentSession?.parentSessionFile, cfg.effectiveCwd);
|
|
307
|
-
const sessionManager = io.createSessionManager(cfg.effectiveCwd, sessionDir);
|
|
315
|
+
const sessionDir = deps.io.deriveSessionDir(options.context.parentSession?.parentSessionFile, cfg.effectiveCwd);
|
|
316
|
+
const sessionManager = deps.io.createSessionManager(cfg.effectiveCwd, sessionDir);
|
|
308
317
|
sessionManager.newSession({ parentSession: options.context.parentSession?.parentSessionId });
|
|
309
318
|
|
|
310
|
-
const { session } = await io.createSession({
|
|
319
|
+
const { session } = await deps.io.createSession({
|
|
311
320
|
cwd: cfg.effectiveCwd,
|
|
312
321
|
agentDir,
|
|
313
322
|
sessionManager,
|
|
314
|
-
settingsManager: io.createSettingsManager(cfg.effectiveCwd, agentDir),
|
|
323
|
+
settingsManager: deps.io.createSettingsManager(cfg.effectiveCwd, agentDir),
|
|
315
324
|
modelRegistry: snapshot.modelRegistry,
|
|
316
325
|
model: cfg.model,
|
|
317
326
|
tools: cfg.toolNames,
|
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
|
}
|