@ferris1225/pi-subagents 0.32.2 → 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/README.md +35 -16
- package/package.json +2 -2
- package/src/announcements.ts +59 -0
- package/src/dispatch.ts +1833 -1878
- package/src/fixloop.ts +1 -1
- package/src/format.ts +2 -1
- package/src/index.ts +4 -8
- package/src/models.ts +23 -35
- package/src/monitor.ts +29 -88
- package/src/rpc-run.ts +1 -26
- package/src/runtime.ts +284 -285
- package/src/setup.ts +4 -4
- package/src/spawn.ts +1 -6
- package/src/tools.ts +730 -748
- package/src/trajectory.ts +16 -207
- package/src/inspector-panel.ts +0 -363
- package/src/inspector.ts +0 -369
- package/src/widget.ts +0 -195
package/src/runtime.ts
CHANGED
|
@@ -1,285 +1,284 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared per-session runtime state for pi-subagents.
|
|
3
|
-
*
|
|
4
|
-
* The extension registers several tools (subagent, subagent_wait/status/stop)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `createRuntime` builds those once per extension load and hands the same object
|
|
8
|
-
* to every registration site, so state stays in one place without globals.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { rmSync } from "node:fs";
|
|
13
|
-
import { BackgroundTaskQueue } from "./background.ts";
|
|
14
|
-
import {
|
|
15
|
-
completionGroupTriggersTurn,
|
|
16
|
-
createCompletionBatcher,
|
|
17
|
-
formatActiveRunsFooter,
|
|
18
|
-
formatCompletionMessage,
|
|
19
|
-
type CompletionBatcher,
|
|
20
|
-
type CompletionMessageItem,
|
|
21
|
-
} from "./completion.ts";
|
|
22
|
-
import { loadConfigSync, type ThinkingLevel } from "./config.ts";
|
|
23
|
-
import { isRunActiveStatus, monitor } from "./monitor.ts";
|
|
24
|
-
import {
|
|
25
|
-
persistRecoveryRecords,
|
|
26
|
-
recoveryRecordFromFinalization,
|
|
27
|
-
type RecoveryRecord,
|
|
28
|
-
} from "./recovery.ts";
|
|
29
|
-
import type { RpcRunControl } from "./rpc-run.ts";
|
|
30
|
-
import {
|
|
31
|
-
import type { SingleResult } from "./spawn.ts";
|
|
32
|
-
import type { IsolationMode, WorktreeFinalization, WorktreeIsolation } from "./worktree.ts";
|
|
33
|
-
|
|
34
|
-
export type ThreadState =
|
|
35
|
-
| "queued"
|
|
36
|
-
| "resuming"
|
|
37
|
-
| "running"
|
|
38
|
-
| "steering"
|
|
39
|
-
| "interrupting"
|
|
40
|
-
| "parked"
|
|
41
|
-
| "completed"
|
|
42
|
-
| "failed"
|
|
43
|
-
| "stopped";
|
|
44
|
-
|
|
45
|
-
export type ThreadLifecycleOperation = "park" | "resume" | "fork" | "stop" | "settle";
|
|
46
|
-
|
|
47
|
-
export interface SubagentThread {
|
|
48
|
-
id: number;
|
|
49
|
-
generation: number;
|
|
50
|
-
agentName: string;
|
|
51
|
-
task: string;
|
|
52
|
-
/** Caller-facing cwd in the original worktree. */
|
|
53
|
-
cwd: string;
|
|
54
|
-
/** Actual child cwd (the equivalent path inside an isolated worktree). */
|
|
55
|
-
executionCwd: string;
|
|
56
|
-
vision: boolean;
|
|
57
|
-
/** Exact primary→fallback refs inherited by a session fork. */
|
|
58
|
-
modelPool: string[];
|
|
59
|
-
thinkingLevel?: ThinkingLevel;
|
|
60
|
-
isolation: IsolationMode;
|
|
61
|
-
worktree?: WorktreeIsolation;
|
|
62
|
-
state: ThreadState;
|
|
63
|
-
control: RpcRunControl;
|
|
64
|
-
queueController?: AbortController;
|
|
65
|
-
/** Resolves only after the current generation's queue work has fully
|
|
66
|
-
* quiesced and released its concurrency slot. Auto-fix orchestration is part
|
|
67
|
-
* of the parent generation and replaces/extends this promise. */
|
|
68
|
-
generationCompletion: Promise<void>;
|
|
69
|
-
/** Synchronous CAS used by lifecycle controls across their async preflight. */
|
|
70
|
-
lifecycleVersion: number;
|
|
71
|
-
lifecycleOperation?: ThreadLifecycleOperation;
|
|
72
|
-
sessionId?: string;
|
|
73
|
-
sessionDir?: string;
|
|
74
|
-
/** Most recent generation result, retained for parked destructive-stop output. */
|
|
75
|
-
lastResult?: SingleResult;
|
|
76
|
-
/** A destructive stop retires context even if the active child settles later. */
|
|
77
|
-
retireOnSettle?: boolean;
|
|
78
|
-
retired?: boolean;
|
|
79
|
-
/** Abort the active generation to a stable checkpoint and wait until its
|
|
80
|
-
* queue work has published that checkpoint and released its slot. */
|
|
81
|
-
park: () => Promise<"queued" | "active">;
|
|
82
|
-
/** Installed by dispatch so the control tool can restart the same logical id. */
|
|
83
|
-
resume: (objective?: string, ctx?: ExtensionContext) => Promise<SingleResult>;
|
|
84
|
-
/** Create a new logical thread from this thread's retained Pi session branch. */
|
|
85
|
-
fork: (objective?: string, ctx?: ExtensionContext) => Promise<SingleResult>;
|
|
86
|
-
forkedFromRunId?: number;
|
|
87
|
-
forkChildRunIds: number[];
|
|
88
|
-
/** Dispatch-owned, generation-guarded worktree settlement hook. */
|
|
89
|
-
finalizeIsolation: (generation: number, result?: SingleResult) => Promise<WorktreeFinalization | undefined>;
|
|
90
|
-
/** Best-effort shutdown notification for retained integration artifacts. */
|
|
91
|
-
notifyIsolationFailure?: (finalization: WorktreeFinalization) => void;
|
|
92
|
-
isolationFailureNotified?: boolean;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export interface SubagentRuntime {
|
|
96
|
-
configPath: string;
|
|
97
|
-
backgroundQueue: BackgroundTaskQueue;
|
|
98
|
-
/** False after session_shutdown; guards delivery and queue work. */
|
|
99
|
-
sessionActive: boolean;
|
|
100
|
-
/** Deliver a batch of completion messages to the main window, waking it only
|
|
101
|
-
* when the batch needs a turn. */
|
|
102
|
-
sendCompletionGroup: (items: CompletionMessageItem[]) => void;
|
|
103
|
-
completionBatcher: CompletionBatcher<CompletionMessageItem>;
|
|
104
|
-
/** Abort controllers per active run, so subagent_stop can cancel a run in-turn. */
|
|
105
|
-
runControllers: Map<number, AbortController>;
|
|
106
|
-
/** Final results keyed by run id, so subagent_wait can hand the model the
|
|
107
|
-
* actual result in-turn instead of it sleeping/polling for a wake-up message. */
|
|
108
|
-
settledRuns: Map<number, SingleResult>;
|
|
109
|
-
settledListeners: Map<number, Set<(result: SingleResult) => void>>;
|
|
110
|
-
registerRunResult: (runId: number, result: SingleResult) => void;
|
|
111
|
-
/** Logical threads outlive process attempts and completed generations. */
|
|
112
|
-
threads: Map<number, SubagentThread>;
|
|
113
|
-
/** Resume/fork setup that has claimed a thread but has not yet enqueued its
|
|
114
|
-
* next generation. Shutdown invalidates these claims and waits for cleanup. */
|
|
115
|
-
preflightOperations: Set<Promise<void>>;
|
|
116
|
-
/** Every session directory retained for this parent session, including
|
|
117
|
-
* auto-fix internals that are not directly controllable. */
|
|
118
|
-
sessionDirs: Set<string>;
|
|
119
|
-
retainSession: (result: Pick<SingleResult, "sessionDir">) => void;
|
|
120
|
-
retireThreadSession: (thread: SubagentThread) => void;
|
|
121
|
-
/** Worktree/patch paths intentionally retained after a failed integration. */
|
|
122
|
-
retainedArtifactPaths: Set<string>;
|
|
123
|
-
retainWorktreeArtifacts: (finalization: WorktreeFinalization) => void;
|
|
124
|
-
/** Flip sessionActive off and release all session-scoped resources. */
|
|
125
|
-
shutdown: () => Promise<void>;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRuntime {
|
|
129
|
-
// Init-time decisions need the config synchronously; the full (migrating)
|
|
130
|
-
// async load runs per tool call.
|
|
131
|
-
const initialConfig = loadConfigSync(configPath);
|
|
132
|
-
const backgroundQueue = new BackgroundTaskQueue(initialConfig.maxConcurrency);
|
|
133
|
-
|
|
134
|
-
const runtime: SubagentRuntime = {
|
|
135
|
-
configPath,
|
|
136
|
-
backgroundQueue,
|
|
137
|
-
sessionActive: true,
|
|
138
|
-
sendCompletionGroup: (items) => {
|
|
139
|
-
if (!runtime.sessionActive || items.length === 0) return;
|
|
140
|
-
// A result arriving for one run does not mean sibling runs are done.
|
|
141
|
-
// Computing this at delivery (emit) time — not when the item was
|
|
142
|
-
// pushed — reflects the current monitor state, since finishing runs
|
|
143
|
-
// are removed from the monitor before their completion is pushed.
|
|
144
|
-
const active = monitor
|
|
145
|
-
.getRuns()
|
|
146
|
-
.filter((run) => isRunActiveStatus(run.status) || run.retained)
|
|
147
|
-
.map((run) => ({ id: run.id, agent: run.agent, label: run.label }));
|
|
148
|
-
const message = {
|
|
149
|
-
customType: "subagent-result",
|
|
150
|
-
content: formatCompletionMessage(items) + formatActiveRunsFooter(active),
|
|
151
|
-
display: true,
|
|
152
|
-
};
|
|
153
|
-
if (completionGroupTriggersTurn(items)) {
|
|
154
|
-
// steer: the result is injected after the current tool call even mid-turn,
|
|
155
|
-
// or starts a new turn when idle. followUp would sit in the queue until the
|
|
156
|
-
// whole turn ends — a main agent waiting for the result (sleep/poll) would
|
|
157
|
-
// never see it delivered, which is exactly the "returned but never woken"
|
|
158
|
-
// failure mode.
|
|
159
|
-
pi.sendMessage(message, { deliverAs: "steer", triggerTurn: true });
|
|
160
|
-
} else {
|
|
161
|
-
// No-wake delivery: nextTurn rides along with the next user turn and can
|
|
162
|
-
// never start a continuation by itself. followUp would auto-continue
|
|
163
|
-
// whenever pi is already streaming, defeating the opt-out.
|
|
164
|
-
pi.sendMessage(message, { deliverAs: "nextTurn" });
|
|
165
|
-
}
|
|
166
|
-
},
|
|
167
|
-
completionBatcher: undefined as unknown as CompletionBatcher<CompletionMessageItem>,
|
|
168
|
-
runControllers: new Map<number, AbortController>(),
|
|
169
|
-
settledRuns: new Map<number, SingleResult>(),
|
|
170
|
-
settledListeners: new Map<number, Set<(result: SingleResult) => void>>(),
|
|
171
|
-
threads: new Map<number, SubagentThread>(),
|
|
172
|
-
preflightOperations: new Set<Promise<void>>(),
|
|
173
|
-
sessionDirs: new Set<string>(),
|
|
174
|
-
retainedArtifactPaths: new Set<string>(),
|
|
175
|
-
retainWorktreeArtifacts: (finalization) => {
|
|
176
|
-
if (finalization.worktreePath) runtime.retainedArtifactPaths.add(finalization.worktreePath);
|
|
177
|
-
if (finalization.patchPath) runtime.retainedArtifactPaths.add(finalization.patchPath);
|
|
178
|
-
},
|
|
179
|
-
retainSession: (result) => {
|
|
180
|
-
if (result.sessionDir) runtime.sessionDirs.add(result.sessionDir);
|
|
181
|
-
},
|
|
182
|
-
retireThreadSession: (thread) => {
|
|
183
|
-
thread.retired = true;
|
|
184
|
-
if (!thread.sessionDir) return;
|
|
185
|
-
const sessionDir = thread.sessionDir;
|
|
186
|
-
try {
|
|
187
|
-
rmSync(sessionDir, { recursive: true, force: true });
|
|
188
|
-
runtime.sessionDirs.delete(sessionDir);
|
|
189
|
-
thread.sessionDir = undefined;
|
|
190
|
-
thread.sessionId = undefined;
|
|
191
|
-
} catch {
|
|
192
|
-
/* best-effort; shutdown retries the still-retained directory */
|
|
193
|
-
}
|
|
194
|
-
},
|
|
195
|
-
registerRunResult: (runId, result) => {
|
|
196
|
-
runtime.settledRuns.set(runId, result);
|
|
197
|
-
const listeners = runtime.settledListeners.get(runId);
|
|
198
|
-
if (listeners) {
|
|
199
|
-
runtime.settledListeners.delete(runId);
|
|
200
|
-
for (const listener of listeners) {
|
|
201
|
-
try {
|
|
202
|
-
listener(result);
|
|
203
|
-
} catch {
|
|
204
|
-
/* listener errors must never break settling */
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
},
|
|
209
|
-
shutdown: async () => {
|
|
210
|
-
if (!runtime.sessionActive) return;
|
|
211
|
-
runtime.sessionActive = false;
|
|
212
|
-
const shutdownThreads = [...runtime.threads.values()];
|
|
213
|
-
// Invalidate every lifecycle claim synchronously before the first await.
|
|
214
|
-
// Resume/fork preflight checks both this version and sessionActive, then
|
|
215
|
-
// cleans any worktree/session it created before resolving its tracker.
|
|
216
|
-
for (const thread of shutdownThreads) {
|
|
217
|
-
thread.lifecycleVersion++;
|
|
218
|
-
thread.lifecycleOperation = "stop";
|
|
219
|
-
thread.retired = true;
|
|
220
|
-
thread.retireOnSettle = true;
|
|
221
|
-
thread.state = "stopped";
|
|
222
|
-
}
|
|
223
|
-
const preflights = [...runtime.preflightOperations];
|
|
224
|
-
runtime.completionBatcher.dispose();
|
|
225
|
-
runtime.backgroundQueue.cancelAll();
|
|
226
|
-
// Await live RPC process-tree cleanup and continuation preflight rollback
|
|
227
|
-
// before removing sessions/worktrees or clearing ownership maps.
|
|
228
|
-
await Promise.all([
|
|
229
|
-
Promise.all(
|
|
230
|
-
shutdownThreads.map((thread) =>
|
|
231
|
-
thread.control.stop("Parent session shut down").catch(() => undefined),
|
|
232
|
-
),
|
|
233
|
-
),
|
|
234
|
-
Promise.allSettled(preflights),
|
|
235
|
-
runtime.backgroundQueue.waitForIdle(),
|
|
236
|
-
]);
|
|
237
|
-
// Parked work owns no queue task, so shutdown is its final settlement.
|
|
238
|
-
// Active/stopped tasks may already have finalized; the handle and callback
|
|
239
|
-
// are idempotent and generation-guarded.
|
|
240
|
-
const recoveryRecords: RecoveryRecord[] = [];
|
|
241
|
-
for (const thread of runtime.threads.values()) {
|
|
242
|
-
const finalization = await thread.finalizeIsolation(thread.generation).catch(() => undefined);
|
|
243
|
-
if (finalization?.status === "retained") {
|
|
244
|
-
runtime.retainWorktreeArtifacts(finalization);
|
|
245
|
-
recoveryRecords.push(recoveryRecordFromFinalization(thread.id, finalization));
|
|
246
|
-
if (!thread.isolationFailureNotified) {
|
|
247
|
-
thread.isolationFailureNotified = true;
|
|
248
|
-
try {
|
|
249
|
-
thread.notifyIsolationFailure?.(finalization);
|
|
250
|
-
} catch {
|
|
251
|
-
/* the parent UI may already be shutting down */
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
// Persist before tearing down the old runtime. A /new, /resume, or quit
|
|
257
|
-
// must not make the only recovery paths unreachable.
|
|
258
|
-
await persistRecoveryRecords(runtime.configPath, recoveryRecords).catch(() => undefined);
|
|
259
|
-
runtime.settledRuns.clear();
|
|
260
|
-
runtime.settledListeners.clear();
|
|
261
|
-
runtime.runControllers.clear();
|
|
262
|
-
for (const sessionDir of runtime.sessionDirs) {
|
|
263
|
-
try {
|
|
264
|
-
rmSync(sessionDir, { recursive: true, force: true });
|
|
265
|
-
} catch {
|
|
266
|
-
/* best-effort */
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
runtime.sessionDirs.clear();
|
|
270
|
-
runtime.preflightOperations.clear();
|
|
271
|
-
// Deliberately do not remove retainedArtifactPaths: they are the recovery
|
|
272
|
-
// path after a failed patch apply/cleanup.
|
|
273
|
-
runtime.threads.clear();
|
|
274
|
-
monitor.clear();
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Shared per-session runtime state for pi-subagents.
|
|
3
|
+
*
|
|
4
|
+
* The extension registers several tools (subagent, subagent_wait/status/stop)
|
|
5
|
+
* that share the background queue, completion batcher, abort controllers per
|
|
6
|
+
* run, and settled-results store.
|
|
7
|
+
* `createRuntime` builds those once per extension load and hands the same object
|
|
8
|
+
* to every registration site, so state stays in one place without globals.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { rmSync } from "node:fs";
|
|
13
|
+
import { BackgroundTaskQueue } from "./background.ts";
|
|
14
|
+
import {
|
|
15
|
+
completionGroupTriggersTurn,
|
|
16
|
+
createCompletionBatcher,
|
|
17
|
+
formatActiveRunsFooter,
|
|
18
|
+
formatCompletionMessage,
|
|
19
|
+
type CompletionBatcher,
|
|
20
|
+
type CompletionMessageItem,
|
|
21
|
+
} from "./completion.ts";
|
|
22
|
+
import { loadConfigSync, type ThinkingLevel } from "./config.ts";
|
|
23
|
+
import { isRunActiveStatus, monitor } from "./monitor.ts";
|
|
24
|
+
import {
|
|
25
|
+
persistRecoveryRecords,
|
|
26
|
+
recoveryRecordFromFinalization,
|
|
27
|
+
type RecoveryRecord,
|
|
28
|
+
} from "./recovery.ts";
|
|
29
|
+
import type { RpcRunControl } from "./rpc-run.ts";
|
|
30
|
+
import { trajectoryStore } from "./trajectory.ts";
|
|
31
|
+
import type { SingleResult } from "./spawn.ts";
|
|
32
|
+
import type { IsolationMode, WorktreeFinalization, WorktreeIsolation } from "./worktree.ts";
|
|
33
|
+
|
|
34
|
+
export type ThreadState =
|
|
35
|
+
| "queued"
|
|
36
|
+
| "resuming"
|
|
37
|
+
| "running"
|
|
38
|
+
| "steering"
|
|
39
|
+
| "interrupting"
|
|
40
|
+
| "parked"
|
|
41
|
+
| "completed"
|
|
42
|
+
| "failed"
|
|
43
|
+
| "stopped";
|
|
44
|
+
|
|
45
|
+
export type ThreadLifecycleOperation = "park" | "resume" | "fork" | "stop" | "settle";
|
|
46
|
+
|
|
47
|
+
export interface SubagentThread {
|
|
48
|
+
id: number;
|
|
49
|
+
generation: number;
|
|
50
|
+
agentName: string;
|
|
51
|
+
task: string;
|
|
52
|
+
/** Caller-facing cwd in the original worktree. */
|
|
53
|
+
cwd: string;
|
|
54
|
+
/** Actual child cwd (the equivalent path inside an isolated worktree). */
|
|
55
|
+
executionCwd: string;
|
|
56
|
+
vision: boolean;
|
|
57
|
+
/** Exact primary→fallback refs inherited by a session fork. */
|
|
58
|
+
modelPool: string[];
|
|
59
|
+
thinkingLevel?: ThinkingLevel;
|
|
60
|
+
isolation: IsolationMode;
|
|
61
|
+
worktree?: WorktreeIsolation;
|
|
62
|
+
state: ThreadState;
|
|
63
|
+
control: RpcRunControl;
|
|
64
|
+
queueController?: AbortController;
|
|
65
|
+
/** Resolves only after the current generation's queue work has fully
|
|
66
|
+
* quiesced and released its concurrency slot. Auto-fix orchestration is part
|
|
67
|
+
* of the parent generation and replaces/extends this promise. */
|
|
68
|
+
generationCompletion: Promise<void>;
|
|
69
|
+
/** Synchronous CAS used by lifecycle controls across their async preflight. */
|
|
70
|
+
lifecycleVersion: number;
|
|
71
|
+
lifecycleOperation?: ThreadLifecycleOperation;
|
|
72
|
+
sessionId?: string;
|
|
73
|
+
sessionDir?: string;
|
|
74
|
+
/** Most recent generation result, retained for parked destructive-stop output. */
|
|
75
|
+
lastResult?: SingleResult;
|
|
76
|
+
/** A destructive stop retires context even if the active child settles later. */
|
|
77
|
+
retireOnSettle?: boolean;
|
|
78
|
+
retired?: boolean;
|
|
79
|
+
/** Abort the active generation to a stable checkpoint and wait until its
|
|
80
|
+
* queue work has published that checkpoint and released its slot. */
|
|
81
|
+
park: () => Promise<"queued" | "active">;
|
|
82
|
+
/** Installed by dispatch so the control tool can restart the same logical id. */
|
|
83
|
+
resume: (objective?: string, ctx?: ExtensionContext) => Promise<SingleResult>;
|
|
84
|
+
/** Create a new logical thread from this thread's retained Pi session branch. */
|
|
85
|
+
fork: (objective?: string, ctx?: ExtensionContext) => Promise<SingleResult>;
|
|
86
|
+
forkedFromRunId?: number;
|
|
87
|
+
forkChildRunIds: number[];
|
|
88
|
+
/** Dispatch-owned, generation-guarded worktree settlement hook. */
|
|
89
|
+
finalizeIsolation: (generation: number, result?: SingleResult) => Promise<WorktreeFinalization | undefined>;
|
|
90
|
+
/** Best-effort shutdown notification for retained integration artifacts. */
|
|
91
|
+
notifyIsolationFailure?: (finalization: WorktreeFinalization) => void;
|
|
92
|
+
isolationFailureNotified?: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface SubagentRuntime {
|
|
96
|
+
configPath: string;
|
|
97
|
+
backgroundQueue: BackgroundTaskQueue;
|
|
98
|
+
/** False after session_shutdown; guards delivery and queue work. */
|
|
99
|
+
sessionActive: boolean;
|
|
100
|
+
/** Deliver a batch of completion messages to the main window, waking it only
|
|
101
|
+
* when the batch needs a turn. */
|
|
102
|
+
sendCompletionGroup: (items: CompletionMessageItem[]) => void;
|
|
103
|
+
completionBatcher: CompletionBatcher<CompletionMessageItem>;
|
|
104
|
+
/** Abort controllers per active run, so subagent_stop can cancel a run in-turn. */
|
|
105
|
+
runControllers: Map<number, AbortController>;
|
|
106
|
+
/** Final results keyed by run id, so subagent_wait can hand the model the
|
|
107
|
+
* actual result in-turn instead of it sleeping/polling for a wake-up message. */
|
|
108
|
+
settledRuns: Map<number, SingleResult>;
|
|
109
|
+
settledListeners: Map<number, Set<(result: SingleResult) => void>>;
|
|
110
|
+
registerRunResult: (runId: number, result: SingleResult) => void;
|
|
111
|
+
/** Logical threads outlive process attempts and completed generations. */
|
|
112
|
+
threads: Map<number, SubagentThread>;
|
|
113
|
+
/** Resume/fork setup that has claimed a thread but has not yet enqueued its
|
|
114
|
+
* next generation. Shutdown invalidates these claims and waits for cleanup. */
|
|
115
|
+
preflightOperations: Set<Promise<void>>;
|
|
116
|
+
/** Every session directory retained for this parent session, including
|
|
117
|
+
* auto-fix internals that are not directly controllable. */
|
|
118
|
+
sessionDirs: Set<string>;
|
|
119
|
+
retainSession: (result: Pick<SingleResult, "sessionDir">) => void;
|
|
120
|
+
retireThreadSession: (thread: SubagentThread) => void;
|
|
121
|
+
/** Worktree/patch paths intentionally retained after a failed integration. */
|
|
122
|
+
retainedArtifactPaths: Set<string>;
|
|
123
|
+
retainWorktreeArtifacts: (finalization: WorktreeFinalization) => void;
|
|
124
|
+
/** Flip sessionActive off and release all session-scoped resources. */
|
|
125
|
+
shutdown: () => Promise<void>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRuntime {
|
|
129
|
+
// Init-time decisions need the config synchronously; the full (migrating)
|
|
130
|
+
// async load runs per tool call.
|
|
131
|
+
const initialConfig = loadConfigSync(configPath);
|
|
132
|
+
const backgroundQueue = new BackgroundTaskQueue(initialConfig.maxConcurrency);
|
|
133
|
+
|
|
134
|
+
const runtime: SubagentRuntime = {
|
|
135
|
+
configPath,
|
|
136
|
+
backgroundQueue,
|
|
137
|
+
sessionActive: true,
|
|
138
|
+
sendCompletionGroup: (items) => {
|
|
139
|
+
if (!runtime.sessionActive || items.length === 0) return;
|
|
140
|
+
// A result arriving for one run does not mean sibling runs are done.
|
|
141
|
+
// Computing this at delivery (emit) time — not when the item was
|
|
142
|
+
// pushed — reflects the current monitor state, since finishing runs
|
|
143
|
+
// are removed from the monitor before their completion is pushed.
|
|
144
|
+
const active = monitor
|
|
145
|
+
.getRuns()
|
|
146
|
+
.filter((run) => isRunActiveStatus(run.status) || run.retained)
|
|
147
|
+
.map((run) => ({ id: run.id, agent: run.agent, label: run.label }));
|
|
148
|
+
const message = {
|
|
149
|
+
customType: "subagent-result",
|
|
150
|
+
content: formatCompletionMessage(items) + formatActiveRunsFooter(active),
|
|
151
|
+
display: true,
|
|
152
|
+
};
|
|
153
|
+
if (completionGroupTriggersTurn(items)) {
|
|
154
|
+
// steer: the result is injected after the current tool call even mid-turn,
|
|
155
|
+
// or starts a new turn when idle. followUp would sit in the queue until the
|
|
156
|
+
// whole turn ends — a main agent waiting for the result (sleep/poll) would
|
|
157
|
+
// never see it delivered, which is exactly the "returned but never woken"
|
|
158
|
+
// failure mode.
|
|
159
|
+
pi.sendMessage(message, { deliverAs: "steer", triggerTurn: true });
|
|
160
|
+
} else {
|
|
161
|
+
// No-wake delivery: nextTurn rides along with the next user turn and can
|
|
162
|
+
// never start a continuation by itself. followUp would auto-continue
|
|
163
|
+
// whenever pi is already streaming, defeating the opt-out.
|
|
164
|
+
pi.sendMessage(message, { deliverAs: "nextTurn" });
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
completionBatcher: undefined as unknown as CompletionBatcher<CompletionMessageItem>,
|
|
168
|
+
runControllers: new Map<number, AbortController>(),
|
|
169
|
+
settledRuns: new Map<number, SingleResult>(),
|
|
170
|
+
settledListeners: new Map<number, Set<(result: SingleResult) => void>>(),
|
|
171
|
+
threads: new Map<number, SubagentThread>(),
|
|
172
|
+
preflightOperations: new Set<Promise<void>>(),
|
|
173
|
+
sessionDirs: new Set<string>(),
|
|
174
|
+
retainedArtifactPaths: new Set<string>(),
|
|
175
|
+
retainWorktreeArtifacts: (finalization) => {
|
|
176
|
+
if (finalization.worktreePath) runtime.retainedArtifactPaths.add(finalization.worktreePath);
|
|
177
|
+
if (finalization.patchPath) runtime.retainedArtifactPaths.add(finalization.patchPath);
|
|
178
|
+
},
|
|
179
|
+
retainSession: (result) => {
|
|
180
|
+
if (result.sessionDir) runtime.sessionDirs.add(result.sessionDir);
|
|
181
|
+
},
|
|
182
|
+
retireThreadSession: (thread) => {
|
|
183
|
+
thread.retired = true;
|
|
184
|
+
if (!thread.sessionDir) return;
|
|
185
|
+
const sessionDir = thread.sessionDir;
|
|
186
|
+
try {
|
|
187
|
+
rmSync(sessionDir, { recursive: true, force: true });
|
|
188
|
+
runtime.sessionDirs.delete(sessionDir);
|
|
189
|
+
thread.sessionDir = undefined;
|
|
190
|
+
thread.sessionId = undefined;
|
|
191
|
+
} catch {
|
|
192
|
+
/* best-effort; shutdown retries the still-retained directory */
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
registerRunResult: (runId, result) => {
|
|
196
|
+
runtime.settledRuns.set(runId, result);
|
|
197
|
+
const listeners = runtime.settledListeners.get(runId);
|
|
198
|
+
if (listeners) {
|
|
199
|
+
runtime.settledListeners.delete(runId);
|
|
200
|
+
for (const listener of listeners) {
|
|
201
|
+
try {
|
|
202
|
+
listener(result);
|
|
203
|
+
} catch {
|
|
204
|
+
/* listener errors must never break settling */
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
shutdown: async () => {
|
|
210
|
+
if (!runtime.sessionActive) return;
|
|
211
|
+
runtime.sessionActive = false;
|
|
212
|
+
const shutdownThreads = [...runtime.threads.values()];
|
|
213
|
+
// Invalidate every lifecycle claim synchronously before the first await.
|
|
214
|
+
// Resume/fork preflight checks both this version and sessionActive, then
|
|
215
|
+
// cleans any worktree/session it created before resolving its tracker.
|
|
216
|
+
for (const thread of shutdownThreads) {
|
|
217
|
+
thread.lifecycleVersion++;
|
|
218
|
+
thread.lifecycleOperation = "stop";
|
|
219
|
+
thread.retired = true;
|
|
220
|
+
thread.retireOnSettle = true;
|
|
221
|
+
thread.state = "stopped";
|
|
222
|
+
}
|
|
223
|
+
const preflights = [...runtime.preflightOperations];
|
|
224
|
+
runtime.completionBatcher.dispose();
|
|
225
|
+
runtime.backgroundQueue.cancelAll();
|
|
226
|
+
// Await live RPC process-tree cleanup and continuation preflight rollback
|
|
227
|
+
// before removing sessions/worktrees or clearing ownership maps.
|
|
228
|
+
await Promise.all([
|
|
229
|
+
Promise.all(
|
|
230
|
+
shutdownThreads.map((thread) =>
|
|
231
|
+
thread.control.stop("Parent session shut down").catch(() => undefined),
|
|
232
|
+
),
|
|
233
|
+
),
|
|
234
|
+
Promise.allSettled(preflights),
|
|
235
|
+
runtime.backgroundQueue.waitForIdle(),
|
|
236
|
+
]);
|
|
237
|
+
// Parked work owns no queue task, so shutdown is its final settlement.
|
|
238
|
+
// Active/stopped tasks may already have finalized; the handle and callback
|
|
239
|
+
// are idempotent and generation-guarded.
|
|
240
|
+
const recoveryRecords: RecoveryRecord[] = [];
|
|
241
|
+
for (const thread of runtime.threads.values()) {
|
|
242
|
+
const finalization = await thread.finalizeIsolation(thread.generation).catch(() => undefined);
|
|
243
|
+
if (finalization?.status === "retained") {
|
|
244
|
+
runtime.retainWorktreeArtifacts(finalization);
|
|
245
|
+
recoveryRecords.push(recoveryRecordFromFinalization(thread.id, finalization));
|
|
246
|
+
if (!thread.isolationFailureNotified) {
|
|
247
|
+
thread.isolationFailureNotified = true;
|
|
248
|
+
try {
|
|
249
|
+
thread.notifyIsolationFailure?.(finalization);
|
|
250
|
+
} catch {
|
|
251
|
+
/* the parent UI may already be shutting down */
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Persist before tearing down the old runtime. A /new, /resume, or quit
|
|
257
|
+
// must not make the only recovery paths unreachable.
|
|
258
|
+
await persistRecoveryRecords(runtime.configPath, recoveryRecords).catch(() => undefined);
|
|
259
|
+
runtime.settledRuns.clear();
|
|
260
|
+
runtime.settledListeners.clear();
|
|
261
|
+
runtime.runControllers.clear();
|
|
262
|
+
for (const sessionDir of runtime.sessionDirs) {
|
|
263
|
+
try {
|
|
264
|
+
rmSync(sessionDir, { recursive: true, force: true });
|
|
265
|
+
} catch {
|
|
266
|
+
/* best-effort */
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
runtime.sessionDirs.clear();
|
|
270
|
+
runtime.preflightOperations.clear();
|
|
271
|
+
// Deliberately do not remove retainedArtifactPaths: they are the recovery
|
|
272
|
+
// path after a failed patch apply/cleanup.
|
|
273
|
+
runtime.threads.clear();
|
|
274
|
+
monitor.clear();
|
|
275
|
+
// Lifecycle trajectories are parent-session scoped.
|
|
276
|
+
trajectoryStore.clearAll();
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
runtime.completionBatcher = createCompletionBatcher<CompletionMessageItem>({
|
|
281
|
+
emit: runtime.sendCompletionGroup,
|
|
282
|
+
});
|
|
283
|
+
return runtime;
|
|
284
|
+
}
|
package/src/setup.ts
CHANGED
|
@@ -30,10 +30,11 @@ import {
|
|
|
30
30
|
import {
|
|
31
31
|
CURRENT_MAIN_MODEL,
|
|
32
32
|
applyModelPoolChoice,
|
|
33
|
-
|
|
33
|
+
availableModelsInScope,
|
|
34
34
|
buildAgentModelPoolRows,
|
|
35
35
|
buildModelPickerItems,
|
|
36
36
|
currentModelRef,
|
|
37
|
+
modelRef,
|
|
37
38
|
type AgentModelPoolMaps,
|
|
38
39
|
type ModelPickerSlot,
|
|
39
40
|
type ModelPoolSlot,
|
|
@@ -215,11 +216,10 @@ async function pickConfiguredModel(
|
|
|
215
216
|
configuredRef: string | undefined,
|
|
216
217
|
escNote: string,
|
|
217
218
|
): Promise<string | undefined> {
|
|
218
|
-
const
|
|
219
|
-
const models = registry.getAll?.() ?? registry.getAvailable();
|
|
219
|
+
const models = availableModelsInScope(ctx);
|
|
220
220
|
const items = buildModelPickerItems({
|
|
221
221
|
models,
|
|
222
|
-
availableRefs:
|
|
222
|
+
availableRefs: models.map(modelRef),
|
|
223
223
|
slot,
|
|
224
224
|
configuredRef,
|
|
225
225
|
mainRef: currentModelRef(ctx),
|
package/src/spawn.ts
CHANGED
|
@@ -27,7 +27,6 @@ import {
|
|
|
27
27
|
SUBAGENT_KILL_GRACE_MS,
|
|
28
28
|
type RpcSingleResult,
|
|
29
29
|
type SubagentLiveEvent,
|
|
30
|
-
type SubagentRecordEvent,
|
|
31
30
|
type UsageStats,
|
|
32
31
|
} from "./rpc-run.ts";
|
|
33
32
|
|
|
@@ -40,7 +39,7 @@ export {
|
|
|
40
39
|
sessionExists,
|
|
41
40
|
SUBAGENT_KILL_GRACE_MS,
|
|
42
41
|
};
|
|
43
|
-
export type { SubagentLiveEvent,
|
|
42
|
+
export type { SubagentLiveEvent, UsageStats };
|
|
44
43
|
|
|
45
44
|
export const SUBAGENT_THINKING_LEVEL: ThinkingLevel = DEFAULT_THINKING_LEVEL;
|
|
46
45
|
/** 0 disables the watchdog; dispatch supplies the configured timeout. */
|
|
@@ -272,9 +271,6 @@ export interface RunSingleOptions {
|
|
|
272
271
|
stdinText?: string;
|
|
273
272
|
signal?: AbortSignal;
|
|
274
273
|
onLive?: (event: SubagentLiveEvent) => void;
|
|
275
|
-
/** Receives the raw streamed text/thinking deltas for the inspector
|
|
276
|
-
* transcript; forwarded to the transport alongside onLive. */
|
|
277
|
-
onRecord?: (event: SubagentRecordEvent) => void;
|
|
278
274
|
makeDetails: (results: SingleResult[]) => SubagentDetails;
|
|
279
275
|
env?: NodeJS.ProcessEnv;
|
|
280
276
|
/** Stable logical-generation controller shared across retry attempts. */
|
|
@@ -355,7 +351,6 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
355
351
|
prompt,
|
|
356
352
|
signal: options.signal,
|
|
357
353
|
onLive: options.onLive,
|
|
358
|
-
onRecord: options.onRecord,
|
|
359
354
|
env: options.env,
|
|
360
355
|
control,
|
|
361
356
|
});
|