@jopqior/pi-subagents 1.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 +2705 -0
- package/LICENSE +21 -0
- package/README.md +503 -0
- package/dist/public.d.ts +331 -0
- package/dist/settings.d.ts +82 -0
- package/docs/architecture/architecture.md +1566 -0
- package/docs/architecture/client-server-opportunities.md +127 -0
- package/docs/architecture/history/phase-1-api-boundary.md +8 -0
- package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
- package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
- package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
- package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
- package/docs/architecture/history/phase-14-strip-policy.md +49 -0
- package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
- package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
- package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
- package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
- package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
- package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
- package/docs/architecture/history/phase-20-result-delivery.md +245 -0
- package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
- package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
- package/docs/architecture/history/phase-4-implement-service.md +8 -0
- package/docs/architecture/history/phase-5-decompose-index.md +42 -0
- package/docs/architecture/history/phase-7-encapsulation.md +173 -0
- package/docs/architecture/history/phase-8-testability.md +103 -0
- package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
- package/docs/comparison-with-upstream.md +77 -0
- package/docs/configuration.md +364 -0
- package/docs/decisions/0001-deferred-patches.md +80 -0
- package/docs/decisions/0002-extensions-on-a-minimal-core.md +125 -0
- package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
- package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
- package/docs/decisions/0005-subagent-record-admission-policy.md +106 -0
- package/docs/decisions/0006-inherited-prompt-is-identity-only.md +104 -0
- package/docs/decisions/0007-transcript-viewer-is-not-an-overlay.md +228 -0
- package/docs/decisions/0008-inherited-region-is-shared-parts.md +81 -0
- package/docs/decisions/0009-portable-inheritance-is-provider-scoped.md +116 -0
- package/package.json +91 -0
- package/src/config/agent-types.ts +135 -0
- package/src/config/custom-agents.ts +151 -0
- package/src/config/default-agents.ts +121 -0
- package/src/config/invocation-config.ts +167 -0
- package/src/config/thinking-level.ts +58 -0
- package/src/debug.ts +14 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/interrupt.ts +58 -0
- package/src/handlers/lifecycle.ts +71 -0
- package/src/handlers/widget-events.ts +49 -0
- package/src/index.ts +292 -0
- package/src/layered-settings.ts +105 -0
- package/src/lifecycle/child-lifecycle.ts +115 -0
- package/src/lifecycle/child-shutdown.ts +105 -0
- package/src/lifecycle/concurrency-limiter.ts +55 -0
- package/src/lifecycle/create-subagent-session.ts +335 -0
- package/src/lifecycle/parent-snapshot.ts +119 -0
- package/src/lifecycle/run-listeners.ts +37 -0
- package/src/lifecycle/selection-scope.ts +116 -0
- package/src/lifecycle/spawn-selection.ts +259 -0
- package/src/lifecycle/subagent-manager.ts +546 -0
- package/src/lifecycle/subagent-session.ts +347 -0
- package/src/lifecycle/subagent-state.ts +404 -0
- package/src/lifecycle/subagent.ts +885 -0
- package/src/lifecycle/turn-limits.ts +13 -0
- package/src/lifecycle/usage.ts +60 -0
- package/src/lifecycle/workspace-bracket.ts +76 -0
- package/src/lifecycle/workspace.ts +46 -0
- package/src/observation/composite-subagent-observer.ts +74 -0
- package/src/observation/notification.ts +430 -0
- package/src/observation/outcome-delivery.ts +239 -0
- package/src/observation/record-observer.ts +78 -0
- package/src/observation/renderer.ts +161 -0
- package/src/observation/subagent-events-observer.ts +148 -0
- package/src/runtime.ts +137 -0
- package/src/service/service-adapter.ts +201 -0
- package/src/service/service.ts +246 -0
- package/src/session/ask-parent-tool.ts +69 -0
- package/src/session/content-items.ts +53 -0
- package/src/session/context.ts +80 -0
- package/src/session/conversation.ts +49 -0
- package/src/session/env.ts +40 -0
- package/src/session/model-resolver.ts +126 -0
- package/src/session/notify-parent-tool.ts +83 -0
- package/src/session/package-exclusions.ts +75 -0
- package/src/session/prompts.ts +231 -0
- package/src/session/provider-inheritance.ts +56 -0
- package/src/session/selection-catalogue.ts +143 -0
- package/src/session/session-config.ts +202 -0
- package/src/session/session-dir.ts +38 -0
- package/src/settings.ts +447 -0
- package/src/tools/agent-tool.ts +305 -0
- package/src/tools/background-spawner.ts +83 -0
- package/src/tools/foreground-runner.ts +159 -0
- package/src/tools/get-result-renderer.ts +119 -0
- package/src/tools/get-result-report.ts +84 -0
- package/src/tools/get-result-tool.ts +192 -0
- package/src/tools/helpers.ts +118 -0
- package/src/tools/result-renderer.ts +153 -0
- package/src/tools/spawn-config.ts +192 -0
- package/src/tools/steer-tool.ts +109 -0
- package/src/types.ts +143 -0
- package/src/ui/agent-widget.ts +333 -0
- package/src/ui/bounded-lines.ts +45 -0
- package/src/ui/display.ts +180 -0
- package/src/ui/glyphs.ts +62 -0
- package/src/ui/session-navigation.ts +150 -0
- package/src/ui/session-navigator.ts +255 -0
- package/src/ui/subagents-settings.ts +179 -0
- package/src/ui/transcript-content.ts +374 -0
- package/src/ui/widget-renderer.ts +301 -0
|
@@ -0,0 +1,885 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent.ts — Subagent class: identity, lifecycle status, and per-subagent behavior.
|
|
3
|
+
*
|
|
4
|
+
* Status/stats are delegated to the SubagentState value object; listener
|
|
5
|
+
* lifecycle to RunListeners; workspace prepare/dispose to WorkspaceBracket.
|
|
6
|
+
* Behavior (abort, steer buffering) lives here rather than on SubagentManager.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
10
|
+
import type { AgentSessionEvent, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { debugLog } from "#src/debug";
|
|
12
|
+
import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent-session";
|
|
13
|
+
import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
14
|
+
import { RunListeners } from "#src/lifecycle/run-listeners";
|
|
15
|
+
import type { SelectionScopeHandle } from "#src/lifecycle/selection-scope";
|
|
16
|
+
import {
|
|
17
|
+
isSelectionCancellation,
|
|
18
|
+
SelectionCancelledError,
|
|
19
|
+
} from "#src/lifecycle/spawn-selection";
|
|
20
|
+
import type { SubagentSession, TurnLoopResult } from "#src/lifecycle/subagent-session";
|
|
21
|
+
import { SubagentState, type SubagentStatus } from "#src/lifecycle/subagent-state";
|
|
22
|
+
import type { LifetimeUsage } from "#src/lifecycle/usage";
|
|
23
|
+
import type { WorkspaceProvider } from "#src/lifecycle/workspace";
|
|
24
|
+
import { WorkspaceBracket } from "#src/lifecycle/workspace-bracket";
|
|
25
|
+
import { subscribeSubagentObserver } from "#src/observation/record-observer";
|
|
26
|
+
import type { RunConfig } from "#src/runtime";
|
|
27
|
+
import type { SpawnSelectionProvider } from "#src/service/service";
|
|
28
|
+
import {
|
|
29
|
+
readSelectionChoices,
|
|
30
|
+
type ValidatedSpawnSelection,
|
|
31
|
+
validateSpawnSelection,
|
|
32
|
+
} from "#src/session/selection-catalogue";
|
|
33
|
+
import type { CompactionInfo, ParentSessionInfo, SessionMessage, SubagentType, ThinkingLevel } from "#src/types";
|
|
34
|
+
|
|
35
|
+
/** Per-subagent lifecycle observer — created by SubagentManager for each spawn. */
|
|
36
|
+
export interface SubagentLifecycleObserver {
|
|
37
|
+
/** Fires when the subagent transitions to running (inside run(), after markRunning). */
|
|
38
|
+
onStarted?(agent: Subagent): void;
|
|
39
|
+
/** Fires once the session is created — the subagent's subagentSession is now available. */
|
|
40
|
+
onSessionCreated?(agent: Subagent): void;
|
|
41
|
+
/** Fires once when the run completes or fails (for concurrency drain). */
|
|
42
|
+
onRunFinished?(agent: Subagent): void;
|
|
43
|
+
/**
|
|
44
|
+
* Fires once a resumed run is under way — after the record is rewound, so a
|
|
45
|
+
* subscriber reading it sees the run that just started rather than the
|
|
46
|
+
* outcome of the one it replaced.
|
|
47
|
+
*/
|
|
48
|
+
onResumeStarted?(agent: Subagent): void;
|
|
49
|
+
/** Fires once when a resumed run reaches a terminal state. */
|
|
50
|
+
onResumeFinished?(agent: Subagent): void;
|
|
51
|
+
/** Fires when the running agent sends its parent a mid-run message. */
|
|
52
|
+
onUpdateSent?(agent: Subagent, message: string): void;
|
|
53
|
+
/**
|
|
54
|
+
* Fires when a teardown after the agent's result was delivered reported where
|
|
55
|
+
* its work went. Not fired for a failed run or resume: those reach a terminal
|
|
56
|
+
* notification of their own, which carries the notice.
|
|
57
|
+
*/
|
|
58
|
+
onWorkspaceNotice?(agent: Subagent, notice: string): void;
|
|
59
|
+
/** Fires on compaction events during the run. */
|
|
60
|
+
onCompacted?(agent: Subagent, info: CompactionInfo): void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type { SubagentStatus } from "#src/lifecycle/subagent-state";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Why a resume of an agent would be refused.
|
|
67
|
+
*
|
|
68
|
+
* One vocabulary for a fact three record-level conditions used to answer
|
|
69
|
+
* separately: the resume door decided it from `isSessionReady()`,
|
|
70
|
+
* `sessionReleased`, and `workspaceDisposed`, while the result carriers never
|
|
71
|
+
* consulted any of them and advertised the resume regardless.
|
|
72
|
+
*
|
|
73
|
+
* `still-running` is the one transient member: it is a refusal of *now* rather
|
|
74
|
+
* than of ever, and the carriers word it accordingly.
|
|
75
|
+
*/
|
|
76
|
+
export type ResumeRefusal =
|
|
77
|
+
| "still-running"
|
|
78
|
+
| "no-session"
|
|
79
|
+
| "session-released"
|
|
80
|
+
| "workspace-disposed";
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The result of a steer attempt. `Subagent.steer` owns the non-running
|
|
84
|
+
* rejection rule and reports it here, so coordinators switch on the outcome
|
|
85
|
+
* instead of pre-checking status (tell by id, with outcomes).
|
|
86
|
+
*/
|
|
87
|
+
export type SteerOutcome =
|
|
88
|
+
| { kind: "delivered" }
|
|
89
|
+
| { kind: "buffered" }
|
|
90
|
+
| { kind: "rejected"; status: SubagentStatus };
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The execution machinery a Subagent needs to run. A single mandatory
|
|
94
|
+
* collaborator: production (SubagentManager.spawn) always supplies it, so run()
|
|
95
|
+
* needs no "not configured" guards. The genuinely-optional behavior knobs stay
|
|
96
|
+
* optional; the four inputs run() cannot proceed without are required.
|
|
97
|
+
*/
|
|
98
|
+
export interface SubagentExecution {
|
|
99
|
+
/** Assembly factory that produces a born-complete SubagentSession. */
|
|
100
|
+
createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
|
|
101
|
+
/** Immutable spawn-time parent snapshot handed to the session factory. */
|
|
102
|
+
snapshot: ParentSnapshot;
|
|
103
|
+
/** Initial prompt for the turn loop. */
|
|
104
|
+
prompt: string;
|
|
105
|
+
/** Parent working directory handed to a workspace provider's prepare(). */
|
|
106
|
+
baseCwd: string;
|
|
107
|
+
observer?: SubagentLifecycleObserver;
|
|
108
|
+
getRunConfig?: () => RunConfig;
|
|
109
|
+
/** Resolves the registered workspace provider (if any) at run-start. */
|
|
110
|
+
getWorkspaceProvider?: () => WorkspaceProvider | undefined;
|
|
111
|
+
/**
|
|
112
|
+
* The spawning session's retained selection scope. Supplied in production
|
|
113
|
+
* for every spawn; a run consults it only while the root lease is active.
|
|
114
|
+
*/
|
|
115
|
+
selectionScope?: SelectionScopeHandle;
|
|
116
|
+
model?: Model<any>;
|
|
117
|
+
maxTurns?: number;
|
|
118
|
+
thinkingLevel?: ThinkingLevel;
|
|
119
|
+
parentSession?: ParentSessionInfo;
|
|
120
|
+
signal?: AbortSignal;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface SubagentInit {
|
|
124
|
+
// Identity
|
|
125
|
+
id: string;
|
|
126
|
+
type: SubagentType;
|
|
127
|
+
description: string;
|
|
128
|
+
/** The mode SubagentManager resolved for this spawn; drives scheduling and announcement. */
|
|
129
|
+
isBackground: boolean;
|
|
130
|
+
|
|
131
|
+
/** Execution machinery — always supplied; construct-complete, no test fallbacks. */
|
|
132
|
+
execution: SubagentExecution;
|
|
133
|
+
|
|
134
|
+
/** Lifecycle status and metrics. Defaults to a fresh queued state. */
|
|
135
|
+
state?: SubagentState;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export class Subagent {
|
|
139
|
+
// Identity — set once at construction
|
|
140
|
+
readonly id: string;
|
|
141
|
+
readonly type: SubagentType;
|
|
142
|
+
readonly description: string;
|
|
143
|
+
/**
|
|
144
|
+
* Whether this agent runs in the background. Resolved once at the manager
|
|
145
|
+
* choke point, so a consumer asks the record rather than re-deriving it from
|
|
146
|
+
* a per-call display snapshot only the tool door ever built (#724).
|
|
147
|
+
*/
|
|
148
|
+
readonly isBackground: boolean;
|
|
149
|
+
|
|
150
|
+
// Lifecycle status and metrics — owned by a private value object; getters and
|
|
151
|
+
// mutation methods below delegate to it one line.
|
|
152
|
+
private readonly state: SubagentState;
|
|
153
|
+
get status(): SubagentStatus { return this.state.status; }
|
|
154
|
+
get result(): string | undefined { return this.state.result; }
|
|
155
|
+
get error(): string | undefined { return this.state.error; }
|
|
156
|
+
get stoppedWhileQueued(): boolean { return this.state.stoppedWhileQueued; }
|
|
157
|
+
get startedAt(): number { return this.state.startedAt; }
|
|
158
|
+
get completedAt(): number | undefined { return this.state.completedAt; }
|
|
159
|
+
get consumedAt(): number | undefined { return this.state.consumedAt; }
|
|
160
|
+
get consumed(): boolean { return this.state.consumed; }
|
|
161
|
+
get claimed(): boolean { return this.state.claimed; }
|
|
162
|
+
get pendingQuestion(): string | undefined { return this.state.pendingQuestion; }
|
|
163
|
+
get runUpdates(): readonly string[] { return this.state.runUpdates; }
|
|
164
|
+
/**
|
|
165
|
+
* What the workspace reported at a teardown with no result text to fold it
|
|
166
|
+
* into — the provider's own wording for where the child's work ended up.
|
|
167
|
+
*
|
|
168
|
+
* Undefined for a run that completed normally: there the addendum rides the
|
|
169
|
+
* result, and duplicating it here would have every carrier report it twice.
|
|
170
|
+
*/
|
|
171
|
+
get workspaceNotice(): string | undefined { return this.state.workspaceNotice; }
|
|
172
|
+
get toolUses(): number { return this.state.toolUses; }
|
|
173
|
+
get lifetimeUsage(): Readonly<LifetimeUsage> { return this.state.lifetimeUsage; }
|
|
174
|
+
get compactionCount(): number { return this.state.compactionCount; }
|
|
175
|
+
get turnCount(): number { return this.state.turnCount; }
|
|
176
|
+
get activeTools(): ReadonlyMap<string, string> { return this.state.activeTools; }
|
|
177
|
+
get responseText(): string { return this.state.responseText; }
|
|
178
|
+
/** True while this run is waiting for a human model/thinking selection. */
|
|
179
|
+
get awaitingSelection(): boolean { return this.state.awaitingSelection; }
|
|
180
|
+
isActive(): boolean { return this.state.isActive(); }
|
|
181
|
+
isTerminalError(): boolean { return this.state.isTerminalError(); }
|
|
182
|
+
isRunning(): boolean { return this.state.isRunning(); }
|
|
183
|
+
canBeSteered(): boolean { return this.state.canBeSteered(); }
|
|
184
|
+
get maxTurns(): number | undefined { return this.execution.maxTurns; }
|
|
185
|
+
|
|
186
|
+
readonly abortController: AbortController;
|
|
187
|
+
private _promise?: Promise<void>;
|
|
188
|
+
/** Handle on the agent's current run — the initial run, or the live resume that replaced it. */
|
|
189
|
+
get promise(): Promise<void> | undefined { return this._promise; }
|
|
190
|
+
|
|
191
|
+
private readonly execution: SubagentExecution;
|
|
192
|
+
private readonly listeners = new RunListeners();
|
|
193
|
+
private readonly workspaceBracket: WorkspaceBracket;
|
|
194
|
+
|
|
195
|
+
subagentSession?: SubagentSession;
|
|
196
|
+
|
|
197
|
+
// Retained after releaseSession() disposes the heavy session, so outputFile
|
|
198
|
+
// (transcript pointer) survives and the resume path can tell "released" from
|
|
199
|
+
// "never had a session."
|
|
200
|
+
private _releasedOutputFile?: string;
|
|
201
|
+
private _sessionReleased = false;
|
|
202
|
+
/** True once releaseSession() has freed a live session (distinct from never having had one). */
|
|
203
|
+
get sessionReleased(): boolean { return this._sessionReleased; }
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* True once this agent's provider-supplied workspace has been torn down.
|
|
207
|
+
* False for an agent that never had one, so it names the resume the session
|
|
208
|
+
* would re-enter a removed directory for — not merely a session with a
|
|
209
|
+
* workspace provider registered.
|
|
210
|
+
*/
|
|
211
|
+
get workspaceDisposed(): boolean { return this.workspaceBracket.wasDisposed(); }
|
|
212
|
+
|
|
213
|
+
// Steer buffer — messages queued before the session is ready
|
|
214
|
+
private _pendingSteers: string[] = [];
|
|
215
|
+
/** Number of steer messages waiting to be delivered. */
|
|
216
|
+
get pendingSteerCount(): number { return this._pendingSteers.length; }
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Path to the agent's session JSONL file, or undefined if not yet available.
|
|
220
|
+
* Falls back to the path captured at releaseSession() once the live session is gone.
|
|
221
|
+
*/
|
|
222
|
+
get outputFile(): string | undefined {
|
|
223
|
+
return this.subagentSession?.outputFile ?? this._releasedOutputFile;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The tool call ID that spawned this background agent, if any. */
|
|
227
|
+
get toolCallId(): string | undefined {
|
|
228
|
+
return this.execution.parentSession?.toolCallId;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Returns true when a SubagentSession is available (session is ready). */
|
|
232
|
+
isSessionReady(): boolean {
|
|
233
|
+
return this.subagentSession != null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Why a resume of this agent would be refused, or undefined when one would be
|
|
238
|
+
* accepted.
|
|
239
|
+
*
|
|
240
|
+
* The conditions are checked in the order the resume door checks them, so a
|
|
241
|
+
* record whose session was released *and* whose workspace is gone reports the
|
|
242
|
+
* session — the door's message for it names the retention window, which is
|
|
243
|
+
* the fact that explains both. A live run outranks all of them: nothing about
|
|
244
|
+
* a settled record is decided yet.
|
|
245
|
+
*
|
|
246
|
+
* A getter rather than a predicate method because the result carriers read it
|
|
247
|
+
* as a field: `OutcomeAddenda` and `AgentReport` both declare it, and a live
|
|
248
|
+
* record satisfies them structurally only if it is a property.
|
|
249
|
+
*/
|
|
250
|
+
get resumeRefusal(): ResumeRefusal | undefined {
|
|
251
|
+
// Before the session check: a run transitions to running before it creates
|
|
252
|
+
// its session, and "still running" describes that record better than "no
|
|
253
|
+
// session" does. A queued agent is not running and keeps the no-session
|
|
254
|
+
// answer, which is the truth about it.
|
|
255
|
+
if (this.isRunning()) return "still-running";
|
|
256
|
+
if (!this.isSessionReady()) return this._sessionReleased ? "session-released" : "no-session";
|
|
257
|
+
if (this.workspaceDisposed) return "workspace-disposed";
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Steer a running agent, owning the non-running rejection rule.
|
|
263
|
+
* Returns a `rejected` outcome (with the observed status) when the agent is
|
|
264
|
+
* not running, a `buffered` outcome when the session is not yet ready, or a
|
|
265
|
+
* `delivered` outcome once the message reaches the session.
|
|
266
|
+
*/
|
|
267
|
+
async steer(message: string): Promise<SteerOutcome> {
|
|
268
|
+
if (!this.canBeSteered()) {
|
|
269
|
+
return { kind: "rejected", status: this.status };
|
|
270
|
+
}
|
|
271
|
+
if (!this.subagentSession) {
|
|
272
|
+
this.queueSteer(message);
|
|
273
|
+
return { kind: "buffered" };
|
|
274
|
+
}
|
|
275
|
+
await this.subagentSession.steer(message);
|
|
276
|
+
return { kind: "delivered" };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Return the session conversation as formatted text, or undefined if no session. */
|
|
280
|
+
getConversation(): string | undefined {
|
|
281
|
+
return this.subagentSession?.getConversation();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Return the session context window utilization (0-100), or null if unavailable. */
|
|
285
|
+
getContextPercent(): number | null {
|
|
286
|
+
return this.subagentSession?.getContextPercent() ?? null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Subscribe to session events for live updates (e.g., conversation viewer).
|
|
291
|
+
* Returns an unsubscribe function, or undefined if no session is available.
|
|
292
|
+
*/
|
|
293
|
+
subscribeToUpdates(fn: (event: AgentSessionEvent) => void): (() => void) | undefined {
|
|
294
|
+
return this.subagentSession?.subscribe(fn);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The session's message history, or an empty array if no session. */
|
|
298
|
+
get messages(): readonly unknown[] {
|
|
299
|
+
return this.subagentSession?.messages ?? [];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The session's message history typed for Pi's session-rendering machinery, or empty if no session. */
|
|
303
|
+
get agentMessages(): readonly SessionMessage[] {
|
|
304
|
+
return this.subagentSession?.agentMessages ?? [];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Resolve a registered tool definition by name, or undefined if no session. */
|
|
308
|
+
getToolDefinition(name: string): ToolDefinition | undefined {
|
|
309
|
+
return this.subagentSession?.getToolDefinition(name);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
constructor(init: SubagentInit) {
|
|
313
|
+
// Identity
|
|
314
|
+
this.id = init.id;
|
|
315
|
+
this.type = init.type;
|
|
316
|
+
this.description = init.description;
|
|
317
|
+
this.isBackground = init.isBackground;
|
|
318
|
+
|
|
319
|
+
// Lifecycle status and metrics — fresh queued state unless one is supplied
|
|
320
|
+
this.state = init.state ?? new SubagentState();
|
|
321
|
+
|
|
322
|
+
// Abort controller — always created, never injected
|
|
323
|
+
this.abortController = new AbortController();
|
|
324
|
+
|
|
325
|
+
// Execution machinery — a single mandatory collaborator
|
|
326
|
+
this.execution = init.execution;
|
|
327
|
+
|
|
328
|
+
// Per-run lifecycle collaborators
|
|
329
|
+
this.workspaceBracket = new WorkspaceBracket(
|
|
330
|
+
this.execution.getWorkspaceProvider ?? (() => undefined),
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Execute the full agent lifecycle: workspace preparation, session creation
|
|
336
|
+
* via the factory, observer wiring, the turn loop, workspace disposal, and
|
|
337
|
+
* status transitions.
|
|
338
|
+
*
|
|
339
|
+
* Execution is supplied at construction (mandatory), so run() needs no
|
|
340
|
+
* "not configured" guards. The returned promise always resolves (errors are
|
|
341
|
+
* captured internally).
|
|
342
|
+
*/
|
|
343
|
+
async run(): Promise<void> {
|
|
344
|
+
this.markRunning(Date.now());
|
|
345
|
+
this.execution.observer?.onStarted?.(this);
|
|
346
|
+
this.listeners.wireSignal(this.execution.signal, () => this.abort());
|
|
347
|
+
|
|
348
|
+
const runConfig = this.execution.getRunConfig?.();
|
|
349
|
+
try {
|
|
350
|
+
this.subagentSession = await this.prepareSession(runConfig);
|
|
351
|
+
} catch (err) {
|
|
352
|
+
// A cancelled selection stops the record (the user declined, the run
|
|
353
|
+
// aborted, or the lease closed); every other prepare failure is an error.
|
|
354
|
+
// Either way the terminal funnel owns the cleanup.
|
|
355
|
+
if (isSelectionCancellation(err)) this.stopRunForCancelledSelection();
|
|
356
|
+
else this.failRun(err);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
this.flushPendingSteers();
|
|
361
|
+
this.listeners.attachObserver(subscribeSubagentObserver(this.subagentSession, this.state, {
|
|
362
|
+
onCompact: (info) => this.execution.observer?.onCompacted?.(this, info),
|
|
363
|
+
}));
|
|
364
|
+
this.execution.observer?.onSessionCreated?.(this);
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
const result = await this.subagentSession.runTurnLoop(this.execution.prompt, {
|
|
368
|
+
maxTurns: this.execution.maxTurns,
|
|
369
|
+
defaultMaxTurns: runConfig?.defaultMaxTurns,
|
|
370
|
+
graceTurns: runConfig?.graceTurns,
|
|
371
|
+
signal: this.abortController.signal,
|
|
372
|
+
});
|
|
373
|
+
this.completeRun(result);
|
|
374
|
+
} catch (err) {
|
|
375
|
+
this.failRun(err);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** The selection gate this run passes through, or undefined on the ordinary path. */
|
|
380
|
+
private openSelectionGate(): { provider: SpawnSelectionProvider; signal: AbortSignal } | undefined {
|
|
381
|
+
const scope = this.execution.selectionScope;
|
|
382
|
+
if (!scope) return undefined;
|
|
383
|
+
const provider = scope.activeSelectionProvider();
|
|
384
|
+
if (!provider) return undefined;
|
|
385
|
+
return {
|
|
386
|
+
provider,
|
|
387
|
+
// The run's own abort and the handle's closure (its shutdown or the
|
|
388
|
+
// root's revocation) invalidate the selection together.
|
|
389
|
+
signal: AbortSignal.any([this.abortController.signal, scope.closureSignal]),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Ask the scope's provider for the pair this run will use, and validate its
|
|
395
|
+
* answer against the spawning session's authenticated catalogue.
|
|
396
|
+
*
|
|
397
|
+
* `undefined` from the provider is user cancellation — a stop, not an error.
|
|
398
|
+
* Catalogue and validation failures throw and fail the run: a gated run
|
|
399
|
+
* never falls back to the resolved or inherited pair.
|
|
400
|
+
*/
|
|
401
|
+
private async obtainSelection(gate: {
|
|
402
|
+
provider: SpawnSelectionProvider;
|
|
403
|
+
signal: AbortSignal;
|
|
404
|
+
}): Promise<ValidatedSpawnSelection> {
|
|
405
|
+
const registry = this.execution.snapshot.modelRegistry;
|
|
406
|
+
const choices = readSelectionChoices(registry);
|
|
407
|
+
const outcome = await gate.provider.select(
|
|
408
|
+
{
|
|
409
|
+
agentId: this.id,
|
|
410
|
+
agentType: this.type,
|
|
411
|
+
description: this.description,
|
|
412
|
+
availableModels: choices,
|
|
413
|
+
},
|
|
414
|
+
gate.signal,
|
|
415
|
+
);
|
|
416
|
+
if (outcome === undefined) {
|
|
417
|
+
throw new SelectionCancelledError();
|
|
418
|
+
}
|
|
419
|
+
return validateSpawnSelection(outcome, choices, registry);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Recheck after an await: an aborted run or closed lease must not reach the factory. */
|
|
423
|
+
private assertSelectionLive(signal: AbortSignal): void {
|
|
424
|
+
if (signal.aborted) throw new SelectionCancelledError();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Prepare the run's child session: the selection gate (when the root lease
|
|
429
|
+
* is active), workspace preparation (provider path only), and the
|
|
430
|
+
* assembly-factory call — every side effect before the child session
|
|
431
|
+
* exists, in order. Returns the born-complete session; a failure throws
|
|
432
|
+
* after no partial state survives (a throwing prepare leaves no workspace
|
|
433
|
+
* bracketed, a throwing factory disposes its own session).
|
|
434
|
+
*
|
|
435
|
+
* The hasProvider() guard keeps the no-provider path synchronous, preserving
|
|
436
|
+
* the original run() timing: the factory is called in the same turn as
|
|
437
|
+
* spawn() when no workspace provider is registered. The gate is likewise
|
|
438
|
+
* absent unless the scope holds an active provider, so an unconfigured or
|
|
439
|
+
* revoked lease changes nothing on this path.
|
|
440
|
+
*/
|
|
441
|
+
private async prepareSession(runConfig: RunConfig | undefined): Promise<SubagentSession> {
|
|
442
|
+
const gate = this.openSelectionGate();
|
|
443
|
+
let selected: ValidatedSpawnSelection | undefined;
|
|
444
|
+
if (gate) {
|
|
445
|
+
this.state.markAwaitingSelection();
|
|
446
|
+
try {
|
|
447
|
+
selected = await this.obtainSelection(gate);
|
|
448
|
+
} finally {
|
|
449
|
+
this.state.clearAwaitingSelection();
|
|
450
|
+
}
|
|
451
|
+
// The chooser finished, but the run may have aborted or the lease
|
|
452
|
+
// closed while it was open.
|
|
453
|
+
this.assertSelectionLive(gate.signal);
|
|
454
|
+
}
|
|
455
|
+
let cwd: string | undefined;
|
|
456
|
+
if (this.workspaceBracket.hasProvider()) {
|
|
457
|
+
cwd = await this.workspaceBracket.prepare({
|
|
458
|
+
agentId: this.id,
|
|
459
|
+
agentType: this.type,
|
|
460
|
+
baseCwd: this.execution.baseCwd,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
// Immediately before the factory call: the lease may have closed while
|
|
464
|
+
// the workspace was being prepared.
|
|
465
|
+
if (gate) this.assertSelectionLive(gate.signal);
|
|
466
|
+
return this.execution.createSubagentSession({
|
|
467
|
+
snapshot: this.execution.snapshot,
|
|
468
|
+
type: this.type,
|
|
469
|
+
cwd,
|
|
470
|
+
parentSession: this.execution.parentSession,
|
|
471
|
+
model: selected?.model ?? this.execution.model,
|
|
472
|
+
thinkingLevel: selected?.thinkingLevel ?? this.execution.thinkingLevel,
|
|
473
|
+
...(gate ? { selectionSignal: gate.signal } : {}),
|
|
474
|
+
askParent: (question) => { this.state.setPendingQuestion(question); },
|
|
475
|
+
notifyParent: this.canSendUpdates(runConfig)
|
|
476
|
+
? (message) => { this.announceUpdate(message); }
|
|
477
|
+
: undefined,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Whether this run gets the mid-run update channel.
|
|
483
|
+
*
|
|
484
|
+
* The operator's setting is the whole gate: where an update lands is decided
|
|
485
|
+
* per message by announceUpdate(), not per child at session creation, so no
|
|
486
|
+
* child has to be refused the tool for a condition that can change mid-run.
|
|
487
|
+
* Defaults to on when no run config is supplied, matching the setting.
|
|
488
|
+
*/
|
|
489
|
+
private canSendUpdates(runConfig: RunConfig | undefined): boolean {
|
|
490
|
+
return runConfig?.midRunUpdates ?? true;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Record an update the child sent, then offer it to the announcement channel.
|
|
495
|
+
*
|
|
496
|
+
* Every update joins the run's ledger, whoever ends up delivering it: this
|
|
497
|
+
* side cannot know whether an announcement will reach the parent in time, or
|
|
498
|
+
* at all, so it records unconditionally and lets the channel that delivers
|
|
499
|
+
* mark what it took. What the ledger still owes is what an outcome carrier
|
|
500
|
+
* renders alongside the result.
|
|
501
|
+
*
|
|
502
|
+
* The observer is told either way: an update is a fact about the run, like
|
|
503
|
+
* the terminal transitions, so the lifecycle event fires regardless of which
|
|
504
|
+
* carrier delivers it.
|
|
505
|
+
*/
|
|
506
|
+
private announceUpdate(message: string): void {
|
|
507
|
+
this.state.recordUpdate(message);
|
|
508
|
+
this.execution.observer?.onUpdateSent?.(this, message);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Start execution immediately (foreground / bypassQueue paths).
|
|
513
|
+
* Stores the run promise so it is awaitable via the `promise` getter.
|
|
514
|
+
*/
|
|
515
|
+
start(): void {
|
|
516
|
+
this._promise = this.guardedRun();
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Schedule execution through an external concurrency scheduler (the limiter).
|
|
521
|
+
* Captures the scheduler's promise eagerly, so a still-queued agent is
|
|
522
|
+
* awaitable via the `promise` getter from spawn — not only once its slot opens.
|
|
523
|
+
* The guard in guardedRun() makes an abort-while-queued run a no-op when the
|
|
524
|
+
* slot finally frees.
|
|
525
|
+
*/
|
|
526
|
+
scheduleVia(schedule: (thunk: () => Promise<void>) => Promise<void>): void {
|
|
527
|
+
this._promise = schedule(() => this.guardedRun());
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Run unless the agent left the active set before its slot opened
|
|
532
|
+
* (e.g. abort-while-queued): a non-queued, non-running status resolves
|
|
533
|
+
* immediately without running.
|
|
534
|
+
*/
|
|
535
|
+
private guardedRun(): Promise<void> {
|
|
536
|
+
if (!this.isActive()) return Promise.resolve();
|
|
537
|
+
return this.run();
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Wait until this agent's current run settles.
|
|
542
|
+
* Resolves immediately when the agent is no longer active or has no run
|
|
543
|
+
* handle. A queued agent is awaitable because scheduleVia() captures the
|
|
544
|
+
* limiter promise at spawn, so the wait spans both the queue slot and the
|
|
545
|
+
* run that follows it.
|
|
546
|
+
*
|
|
547
|
+
* When `signal` fires the wait ends early and the agent keeps running: this
|
|
548
|
+
* is a query, so interrupting it must not cancel the work. Cancelling the
|
|
549
|
+
* work on a parent interrupt is InterruptHandler's separate decision.
|
|
550
|
+
*/
|
|
551
|
+
async waitUntilSettled(signal: AbortSignal): Promise<void> {
|
|
552
|
+
const run = this._promise;
|
|
553
|
+
if (!run || !this.isActive()) return;
|
|
554
|
+
await settleOrAbort(run, signal);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Resume an existing session with a new prompt, managing the observer
|
|
559
|
+
* subscription lifecycle internally (same wiring as run()).
|
|
560
|
+
*
|
|
561
|
+
* Requires an existing SubagentSession (set when the original run created it).
|
|
562
|
+
* The returned promise always resolves (errors are captured internally) and is
|
|
563
|
+
* published as the `promise` getter, so waiters track the resume rather than
|
|
564
|
+
* the settled handle of the original run.
|
|
565
|
+
* The parent signal flows straight through to resumeTurnLoop — resume does not
|
|
566
|
+
* route through this.abortController.
|
|
567
|
+
*/
|
|
568
|
+
resume(prompt: string, signal?: AbortSignal): Promise<void> {
|
|
569
|
+
const subagentSession = this.subagentSession;
|
|
570
|
+
if (!subagentSession) {
|
|
571
|
+
// Rejection, not a throw: this method is not async, and a synchronous
|
|
572
|
+
// throw would escape a caller's `.rejects` assertion.
|
|
573
|
+
return Promise.reject(new Error("Subagent not configured for resume — missing session"));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
this._promise = this.runResume(subagentSession, prompt, signal);
|
|
577
|
+
return this._promise;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** The resume body. Always resolves — errors terminate through failResume(). */
|
|
581
|
+
private async runResume(subagentSession: SubagentSession, prompt: string, signal?: AbortSignal): Promise<void> {
|
|
582
|
+
this.resetForResume(Date.now());
|
|
583
|
+
this.execution.observer?.onResumeStarted?.(this);
|
|
584
|
+
this.listeners.attachObserver(subscribeSubagentObserver(subagentSession, this.state, {
|
|
585
|
+
onCompact: (info) => this.execution.observer?.onCompacted?.(this, info),
|
|
586
|
+
}));
|
|
587
|
+
|
|
588
|
+
try {
|
|
589
|
+
this.completeResume(await subagentSession.resumeTurnLoop(prompt, signal));
|
|
590
|
+
} catch (err) {
|
|
591
|
+
this.failResume(err);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Terminate a resume as completed: mark, dispose or hold the workspace, release listeners, notify observer. */
|
|
596
|
+
completeResume(result: string): void {
|
|
597
|
+
// A child answering one question may need to ask another, which holds the
|
|
598
|
+
// workspace for the next resume the same way the original run did.
|
|
599
|
+
const finalResult = this.pendingQuestion !== undefined
|
|
600
|
+
? result
|
|
601
|
+
: result + this.workspaceBracket.dispose({ status: "completed", description: this.description });
|
|
602
|
+
this.markCompleted(finalResult);
|
|
603
|
+
this.listeners.release();
|
|
604
|
+
this.execution.observer?.onResumeFinished?.(this);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Terminate a resume as errored: mark, release listeners, best-effort workspace dispose, notify observer. */
|
|
608
|
+
failResume(err: unknown): void {
|
|
609
|
+
this.markError(err);
|
|
610
|
+
this.clearPendingQuestion();
|
|
611
|
+
this.listeners.release();
|
|
612
|
+
this.disposeWorkspaceQuietly("error");
|
|
613
|
+
this.execution.observer?.onResumeFinished?.(this);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Transition to running state. Sets status and startedAt. */
|
|
617
|
+
markRunning(startedAt: number): void {
|
|
618
|
+
this.state.markRunning(startedAt);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Transition to completed state.
|
|
623
|
+
* Always sets result and completedAt (??=). Only changes status if not stopped.
|
|
624
|
+
*/
|
|
625
|
+
markCompleted(result: string, completedAt?: number): void {
|
|
626
|
+
this.state.markCompleted(result, completedAt);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Transition to aborted state.
|
|
631
|
+
* Always sets result and completedAt (??=). Only changes status if not stopped.
|
|
632
|
+
*/
|
|
633
|
+
markAborted(result: string, completedAt?: number): void {
|
|
634
|
+
this.state.markAborted(result, completedAt);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Transition to steered state.
|
|
639
|
+
* Always sets result and completedAt (??=). Only changes status if not stopped.
|
|
640
|
+
*/
|
|
641
|
+
markSteered(result: string, completedAt?: number): void {
|
|
642
|
+
this.state.markSteered(result, completedAt);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Transition to error state.
|
|
647
|
+
* Always sets error (formatted) and completedAt (??=). Only changes status if not stopped.
|
|
648
|
+
*/
|
|
649
|
+
markError(error: unknown, completedAt?: number): void {
|
|
650
|
+
this.state.markError(error, completedAt);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** Transition to stopped state. Always valid — no guard. */
|
|
654
|
+
markStopped(completedAt?: number): void {
|
|
655
|
+
this.state.markStopped(completedAt);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** Record the parent collected this agent's outcome. Idempotent. */
|
|
659
|
+
markConsumed(at?: number): void {
|
|
660
|
+
this.state.markConsumed(at);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** The announcement channel delivered this update; no outcome carrier repeats it. */
|
|
664
|
+
markUpdateAnnounced(message: string): void {
|
|
665
|
+
this.state.markUpdateAnnounced(message);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** A carrier has committed to delivering this outcome; nothing else announces it. */
|
|
669
|
+
claim(): void {
|
|
670
|
+
this.state.claim();
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** The carrier abandoned its commitment; announcing is owed again. */
|
|
674
|
+
// Called on the `Subagent` returned by `getRecord()` from get-result-tool.ts
|
|
675
|
+
// and agent-tool.ts, both of which declare it through their own structural
|
|
676
|
+
// interface — fallow cannot trace through interfaces, and reaches this only
|
|
677
|
+
// through the release-then-announce test.
|
|
678
|
+
release(): void {
|
|
679
|
+
this.state.release();
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Stop an agent that never started, then notify like every other terminal
|
|
684
|
+
* transition. No listener release: nothing is wired before run().
|
|
685
|
+
* The record leaves the active set here, so the thunk the limiter runs when
|
|
686
|
+
* the slot finally frees no-ops on guardedRun()'s guard — one notification.
|
|
687
|
+
*/
|
|
688
|
+
stopQueued(): void {
|
|
689
|
+
this.state.stopQueued();
|
|
690
|
+
this.execution.observer?.onRunFinished?.(this);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Abort a running agent: fire AbortController and transition to stopped.
|
|
695
|
+
* Returns false if the agent is not running.
|
|
696
|
+
* A still-queued agent is stopped via stopQueued(); its scheduled thunk
|
|
697
|
+
* then no-ops on the queued-status guard.
|
|
698
|
+
*/
|
|
699
|
+
abort(): boolean {
|
|
700
|
+
if (!this.isRunning()) return false;
|
|
701
|
+
this.abortController.abort();
|
|
702
|
+
this.markStopped();
|
|
703
|
+
return true;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Buffer a steer message for delivery once the session is ready.
|
|
708
|
+
* Called internally from steer() before the session is ready.
|
|
709
|
+
*/
|
|
710
|
+
private queueSteer(message: string): void {
|
|
711
|
+
this._pendingSteers.push(message);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Flush all buffered steer messages to the session and clear the buffer.
|
|
716
|
+
* Called once the session is available (inside run()).
|
|
717
|
+
*/
|
|
718
|
+
private flushPendingSteers(): void {
|
|
719
|
+
for (const msg of this._pendingSteers) {
|
|
720
|
+
this.subagentSession?.steer(msg).catch(() => {});
|
|
721
|
+
}
|
|
722
|
+
this._pendingSteers = [];
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** Reset for resume: running status, new startedAt, clear completedAt/result/error/consumedAt/listeners. */
|
|
726
|
+
resetForResume(startedAt: number): void {
|
|
727
|
+
this.state.resetForResume(startedAt);
|
|
728
|
+
this.listeners.release();
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** Complete a run: release listeners, dispose the workspace, status transition, notify observer. */
|
|
732
|
+
completeRun(result: TurnLoopResult): void {
|
|
733
|
+
this.listeners.release();
|
|
734
|
+
|
|
735
|
+
const finalStatus: SubagentStatus = result.aborted
|
|
736
|
+
? "aborted"
|
|
737
|
+
: result.steered
|
|
738
|
+
? "steered"
|
|
739
|
+
: "completed";
|
|
740
|
+
// A completed child that declared a question is inviting a resume, so its
|
|
741
|
+
// workspace stays live for the resume to re-enter. Every other outcome ends
|
|
742
|
+
// the run for good and tears it down here. The question was recorded by
|
|
743
|
+
// ask_parent during the run, so it is already on the record here.
|
|
744
|
+
const holdForResume = finalStatus === "completed" && this.pendingQuestion !== undefined;
|
|
745
|
+
const finalResult = holdForResume
|
|
746
|
+
? result.responseText
|
|
747
|
+
: result.responseText +
|
|
748
|
+
this.workspaceBracket.dispose({ status: finalStatus, description: this.description });
|
|
749
|
+
|
|
750
|
+
if (result.aborted) this.markAborted(finalResult);
|
|
751
|
+
else if (result.steered) this.markSteered(finalResult);
|
|
752
|
+
else this.markCompleted(finalResult);
|
|
753
|
+
|
|
754
|
+
this.execution.observer?.onRunFinished?.(this);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Dispose the wrapped session, firing the `disposed` lifecycle event.
|
|
759
|
+
* Resolves once the child's extensions have shut down; a failing teardown is
|
|
760
|
+
* swallowed so the caller's remaining cleanup still runs.
|
|
761
|
+
*/
|
|
762
|
+
async disposeSession(): Promise<void> {
|
|
763
|
+
this.disposeHeldWorkspace();
|
|
764
|
+
await disposeQuietly(this.subagentSession, "child session dispose");
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Release the heavy session while keeping the record: capture the transcript
|
|
769
|
+
* pointer, dispose the session (firing `disposed`), clear it, and mark released.
|
|
770
|
+
* A no-op once the session is gone — the retention sweep may call it repeatedly.
|
|
771
|
+
*
|
|
772
|
+
* The record's own state is updated before the teardown is awaited, so a sweep
|
|
773
|
+
* tick arriving mid-teardown sees a released record rather than starting a
|
|
774
|
+
* second one.
|
|
775
|
+
*/
|
|
776
|
+
async releaseSession(): Promise<void> {
|
|
777
|
+
const session = this.subagentSession;
|
|
778
|
+
if (!session) return;
|
|
779
|
+
this.disposeHeldWorkspace();
|
|
780
|
+
this._releasedOutputFile = session.outputFile;
|
|
781
|
+
this.subagentSession = undefined;
|
|
782
|
+
this._sessionReleased = true;
|
|
783
|
+
await disposeQuietly(session, "child session release");
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** Fail a run: mark error, release listeners, best-effort workspace dispose, notify observer. */
|
|
787
|
+
failRun(err: unknown): void {
|
|
788
|
+
this.markError(err);
|
|
789
|
+
this.clearPendingQuestion();
|
|
790
|
+
this.listeners.release();
|
|
791
|
+
this.disposeWorkspaceQuietly("error");
|
|
792
|
+
this.execution.observer?.onRunFinished?.(this);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Terminate a run whose selection was cancelled — the user closed the
|
|
797
|
+
* dialog, the run aborted, or the lease closed. A cancellation is a stop,
|
|
798
|
+
* not an error: the record ends `stopped`, a prepared workspace (if the
|
|
799
|
+
* cancellation landed after preparation) is torn down, and the terminal
|
|
800
|
+
* observer fires exactly once, like every other funnel.
|
|
801
|
+
*/
|
|
802
|
+
private stopRunForCancelledSelection(): void {
|
|
803
|
+
this.abortController.abort();
|
|
804
|
+
this.markStopped();
|
|
805
|
+
this.listeners.release();
|
|
806
|
+
this.disposeWorkspaceQuietly("stopped");
|
|
807
|
+
this.execution.observer?.onRunFinished?.(this);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Drop a question the child recorded before the run failed.
|
|
812
|
+
*
|
|
813
|
+
* Every carrier renders a pending question as "answer by resuming me", which
|
|
814
|
+
* is not the right next action after a failure — and the failure text already
|
|
815
|
+
* tells the parent to look. An aborted or steered run keeps its question:
|
|
816
|
+
* those reached a terminal transition with an outcome to report.
|
|
817
|
+
*/
|
|
818
|
+
private clearPendingQuestion(): void {
|
|
819
|
+
this.state.setPendingQuestion(undefined);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Tear down a workspace still held once the agent's run is over — the child
|
|
824
|
+
* asked a question nobody answered, and its session is now going away.
|
|
825
|
+
*
|
|
826
|
+
* A no-op while the agent is active: an in-flight run's own terminal
|
|
827
|
+
* transition owns disposal, and pulling the directory out from under a live
|
|
828
|
+
* child is not this path's business.
|
|
829
|
+
*/
|
|
830
|
+
private disposeHeldWorkspace(): void {
|
|
831
|
+
if (this.isActive()) return;
|
|
832
|
+
// Announce what *this* disposal produced, not what the record holds: both
|
|
833
|
+
// release and teardown reach here, and the second finds nothing to dispose.
|
|
834
|
+
const notice = this.disposeWorkspaceQuietly(this.status);
|
|
835
|
+
if (notice) this.execution.observer?.onWorkspaceNotice?.(this, notice);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Dispose the workspace without letting a provider failure escape, recording
|
|
840
|
+
* what it reported and handing that back.
|
|
841
|
+
*
|
|
842
|
+
* These are the paths with no result text left to fold the addendum into, so
|
|
843
|
+
* it is kept on the record for the carriers to report instead. The value is
|
|
844
|
+
* returned as well as stored, so a caller can tell an addendum this call
|
|
845
|
+
* produced from one an earlier disposal already recorded.
|
|
846
|
+
*/
|
|
847
|
+
private disposeWorkspaceQuietly(status: SubagentStatus): string {
|
|
848
|
+
try {
|
|
849
|
+
const notice = this.workspaceBracket.dispose({ status, description: this.description });
|
|
850
|
+
if (notice) this.state.setWorkspaceNotice(notice);
|
|
851
|
+
return notice;
|
|
852
|
+
} catch (err) { debugLog(`workspace dispose (${status})`, err); return ""; }
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Tear a child session down without letting its failure escape.
|
|
858
|
+
* Both teardown paths are cleanup: a child that will not shut down cleanly must
|
|
859
|
+
* not stop the caller from finishing the rest of its own cleanup.
|
|
860
|
+
*/
|
|
861
|
+
async function disposeQuietly(
|
|
862
|
+
session: SubagentSession | undefined,
|
|
863
|
+
context: string,
|
|
864
|
+
): Promise<void> {
|
|
865
|
+
try {
|
|
866
|
+
await session?.dispose();
|
|
867
|
+
} catch (err) {
|
|
868
|
+
debugLog(context, err);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Settle with `run`, or early when `signal` fires — whichever comes first.
|
|
874
|
+
* The inner controller is the listener-cleanup channel: it detaches the abort
|
|
875
|
+
* listener whichever branch wins, so repeated waits within one parent turn do
|
|
876
|
+
* not accumulate listeners on that turn's signal.
|
|
877
|
+
*/
|
|
878
|
+
function settleOrAbort(run: Promise<void>, signal: AbortSignal): Promise<void> {
|
|
879
|
+
if (signal.aborted) return Promise.resolve();
|
|
880
|
+
const detach = new AbortController();
|
|
881
|
+
const interrupted = new Promise<void>((resolve) => {
|
|
882
|
+
signal.addEventListener("abort", () => { resolve(); }, { once: true, signal: detach.signal });
|
|
883
|
+
});
|
|
884
|
+
return Promise.race([run, interrupted]).finally(() => { detach.abort(); });
|
|
885
|
+
}
|