@ferris1225/pi-subagents 4.2.4 → 4.2.7
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 +352 -346
- package/agents/executor.md +53 -53
- package/package.json +53 -55
- package/src/dispatch.ts +552 -541
- package/src/durable.ts +517 -438
- package/src/monitor.ts +1 -1
- package/src/prompt.ts +69 -69
- package/src/rpc-run.ts +9 -15
- package/src/runtime.ts +315 -312
- package/src/thread-lifecycle.ts +1341 -1320
- package/src/tools.ts +384 -384
package/src/tools.ts
CHANGED
|
@@ -1,384 +1,384 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Thread controls around the subagent runtime: subagent_control (resume) and
|
|
3
|
-
* destructive subagent_stop. There is no status/poll tool — completions carry
|
|
4
|
-
* each result (with an on-disk artifact when truncated) and wake the main
|
|
5
|
-
* model, so waiting is never a tool call; the only in-turn block is `wait:
|
|
6
|
-
* true` on a dispatch, for one-shot parents that exit at end of turn.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { StringEnum } from "@earendil-works/pi-ai";
|
|
10
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
12
|
-
import { existsSync } from "node:fs";
|
|
13
|
-
import { Type } from "typebox";
|
|
14
|
-
import { DEFAULT_MAX_RESULT_LINES, loadConfig } from "./config.ts";
|
|
15
|
-
import { removeThreadRecord } from "./durable.ts";
|
|
16
|
-
import { formatCompletionBlock, matchRunIds } from "./format.ts";
|
|
17
|
-
import { emptyUsage } from "./rpc-run.ts";
|
|
18
|
-
import { formatTaskSummary, monitor } from "./monitor.ts";
|
|
19
|
-
import { persistRecoveryRecords, recoveryRecordFromFinalization } from "./recovery.ts";
|
|
20
|
-
import type { SubagentRuntime, SubagentThread } from "./runtime.ts";
|
|
21
|
-
import { CONTROL_QUIESCE_TIMEOUT_MS, projectResultsRoot, quiesced } from "./thread-lifecycle.ts";
|
|
22
|
-
import { getResultOutput, isFailedResult, type SingleResult } from "./spawn.ts";
|
|
23
|
-
import type { WorktreeFinalization } from "./worktree.ts";
|
|
24
|
-
|
|
25
|
-
function renderFirstLine(result: { content?: unknown }, label: string, theme: any): Text {
|
|
26
|
-
const parts = (result.content ?? []) as Array<{ type: string; text?: string }>;
|
|
27
|
-
const text = parts
|
|
28
|
-
.map((part) => (typeof part.text === "string" ? part.text : ""))
|
|
29
|
-
.join(" ")
|
|
30
|
-
.trim();
|
|
31
|
-
const firstLine = text.split("\n").find((line) => line.trim()) ?? "(no output)";
|
|
32
|
-
return new Text(`${theme.fg("toolTitle", theme.bold(label))}${theme.fg("dim", firstLine.slice(0, 60))}`, 0, 0);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime): void {
|
|
36
|
-
const SubagentControlParams = Type.Object({
|
|
37
|
-
action: StringEnum(["resume"] as const, {
|
|
38
|
-
description: "Control operation for the logical sub-agent thread.",
|
|
39
|
-
}),
|
|
40
|
-
id: Type.Integer({ minimum: 1, description: "Stable run id shown by subagent dispatch output." }),
|
|
41
|
-
objective: Type.Optional(
|
|
42
|
-
Type.String({ description: "Optional appended objective for resume. Omit to continue the current retained objective." }),
|
|
43
|
-
),
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
pi.registerTool({
|
|
47
|
-
name: "subagent_control",
|
|
48
|
-
label: "Subagent Control",
|
|
49
|
-
description: [
|
|
50
|
-
"Resume an existing sub-agent thread by stable run id: a parked, completed, or failed retained thread restarts with the same run id and cumulative active time.",
|
|
51
|
-
"Omit objective to continue the current goal, or provide one to append it to retained context and make it the displayed goal. Threads parked or interrupted by a shutdown/reload are restorable; use subagent_stop for destructive cancellation.",
|
|
52
|
-
].join(" "),
|
|
53
|
-
promptSnippet: "Resume a parked or settled subagent thread with its retained context.",
|
|
54
|
-
promptGuidelines: [
|
|
55
|
-
"Resume keeps the run id and retained context; use subagent_stop only for destructive cancellation, which retires that thread's session.",
|
|
56
|
-
],
|
|
57
|
-
parameters: SubagentControlParams,
|
|
58
|
-
|
|
59
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
60
|
-
// A thread parked by the previous process exists only once restore has
|
|
61
|
-
// read the manifest; resuming before that would deny a live run id.
|
|
62
|
-
await runtime.durableRestore;
|
|
63
|
-
const thread = runtime.threads.get(params.id);
|
|
64
|
-
if (!thread) {
|
|
65
|
-
return { content: [{ type: "text", text: `No subagent thread matches run #${params.id}.` }], details: {} };
|
|
66
|
-
}
|
|
67
|
-
const nonBlank = (value: string | undefined): string | undefined => {
|
|
68
|
-
const trimmed = value?.trim();
|
|
69
|
-
return trimmed ? trimmed : undefined;
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
switch (params.action) {
|
|
74
|
-
case "resume": {
|
|
75
|
-
if (thread.retired) {
|
|
76
|
-
return { content: [{ type: "text", text: `Run #${thread.id} was retired by subagent_stop and has no resumable session.` }], details: {} };
|
|
77
|
-
}
|
|
78
|
-
if (!(["parked", "completed", "failed"] as const).includes(thread.state as any)) {
|
|
79
|
-
return { content: [{ type: "text", text: `Run #${thread.id} is ${thread.state}; it must be parked or settled before resume.` }], details: {} };
|
|
80
|
-
}
|
|
81
|
-
const objective = params.objective === undefined ? undefined : nonBlank(params.objective);
|
|
82
|
-
if (params.objective !== undefined && !objective) {
|
|
83
|
-
return { content: [{ type: "text", text: "resume objective must be non-blank when provided." }], details: {} };
|
|
84
|
-
}
|
|
85
|
-
const hadRetainedSession = Boolean(thread.sessionId && thread.sessionDir);
|
|
86
|
-
const pending = await thread.resume(objective, ctx);
|
|
87
|
-
if (pending.exitCode !== -1) {
|
|
88
|
-
return { content: [{ type: "text", text: getResultOutput(pending) }], details: {} };
|
|
89
|
-
}
|
|
90
|
-
const currentObjective = formatTaskSummary(objective ?? thread.task, 80, false);
|
|
91
|
-
const mode = objective
|
|
92
|
-
? `appended objective: ${currentObjective}`
|
|
93
|
-
: `continuing current objective: ${currentObjective}`;
|
|
94
|
-
const context = hadRetainedSession
|
|
95
|
-
? "the same retained session and prior context are preserved"
|
|
96
|
-
: "no prior child session existed, so only the logical run and objective are continued";
|
|
97
|
-
return { content: [{ type: "text", text: `Resumed run #${thread.id}, ${mode}; ${context}, and cumulative active time is preserved. It runs in the background — keep working; the result resumes you automatically.` }], details: {} };
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
} catch (error) {
|
|
101
|
-
throw new Error(`Could not ${params.action} run #${thread.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
102
|
-
}
|
|
103
|
-
},
|
|
104
|
-
|
|
105
|
-
renderCall(args, theme) {
|
|
106
|
-
return new Text(`${theme.fg("toolTitle", theme.bold("subagent_control "))}${theme.fg("accent", `${args.action} #${args.id}`)}`, 0, 0);
|
|
107
|
-
},
|
|
108
|
-
renderResult(result, _options, theme) {
|
|
109
|
-
return renderFirstLine(result, "subagent_control ", theme);
|
|
110
|
-
},
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// Cancel one or more active runs: aborts the queue controller, which
|
|
114
|
-
// terminates the child and delivers an aborted result (with whatever partial
|
|
115
|
-
// output it produced) so the main agent always knows the run stopped.
|
|
116
|
-
const SubagentStopParams = Type.Object({
|
|
117
|
-
id: Type.Optional(
|
|
118
|
-
Type.String({
|
|
119
|
-
description: "Run id or prefix to stop (see subagent dispatch output).",
|
|
120
|
-
}),
|
|
121
|
-
),
|
|
122
|
-
all: Type.Optional(Type.Boolean({ description: "Stop every active run (default false)." })),
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
pi.registerTool({
|
|
126
|
-
name: "subagent_stop",
|
|
127
|
-
label: "Subagent Stop",
|
|
128
|
-
description: [
|
|
129
|
-
"Destructively stop a sub-agent thread: terminate active work, deliver its aborted partial result, and retire any retained session so it cannot be resumed.",
|
|
130
|
-
"Pass id (run id or prefix) to stop one active, parked, or completed thread; all: true stops every active run.",
|
|
131
|
-
].join(" "),
|
|
132
|
-
promptSnippet: "Stop a running background subagent (id from dispatch output; or all: true).",
|
|
133
|
-
promptGuidelines: [
|
|
134
|
-
"Stop a run when its task is obsolete, stuck, or superseded — do not leave it burning tokens. It then reports as failed with 'aborted' plus its partial output.",
|
|
135
|
-
],
|
|
136
|
-
parameters: SubagentStopParams,
|
|
137
|
-
|
|
138
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
139
|
-
await runtime.durableRestore;
|
|
140
|
-
// Start config I/O without yielding: every target below must be claimed
|
|
141
|
-
// synchronously before a resume preflight can cross its next await.
|
|
142
|
-
const configPromise = loadConfig(runtime.configPath).catch(() => undefined);
|
|
143
|
-
const completionResults: SingleResult[] = [];
|
|
144
|
-
const candidateIds = params.all === true
|
|
145
|
-
? [...new Set([
|
|
146
|
-
...runtime.runControllers.keys(),
|
|
147
|
-
...[...runtime.threads.values()]
|
|
148
|
-
.filter((thread) =>
|
|
149
|
-
thread.lifecycleOperation !== undefined ||
|
|
150
|
-
["queued", "resuming", "running", "interrupting"].includes(thread.state),
|
|
151
|
-
)
|
|
152
|
-
.map((thread) => thread.id),
|
|
153
|
-
])]
|
|
154
|
-
: [...runtime.threads.keys()];
|
|
155
|
-
const targets =
|
|
156
|
-
params.all === true
|
|
157
|
-
? candidateIds
|
|
158
|
-
: params.id !== undefined && params.id.trim() !== ""
|
|
159
|
-
? matchRunIds(candidateIds, params.id.trim())
|
|
160
|
-
: [];
|
|
161
|
-
|
|
162
|
-
if (targets.length === 0) {
|
|
163
|
-
const available = [...runtime.threads.keys()].map((id) => `#${id}`).join(", ");
|
|
164
|
-
return {
|
|
165
|
-
content: [{
|
|
166
|
-
type: "text",
|
|
167
|
-
text: params.all === true
|
|
168
|
-
? "No active subagent runs to stop."
|
|
169
|
-
: `No subagent thread matches "${params.id}".${available ? ` Known threads: ${available}.` : ""}`,
|
|
170
|
-
}],
|
|
171
|
-
details: {},
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const claimed: Array<{
|
|
176
|
-
runId: number;
|
|
177
|
-
thread: SubagentThread;
|
|
178
|
-
run: ReturnType<typeof monitor.findRun>;
|
|
179
|
-
previousState: SubagentThread["state"];
|
|
180
|
-
wasQueued: boolean;
|
|
181
|
-
wasResuming: boolean;
|
|
182
|
-
wasActive: boolean;
|
|
183
|
-
generation: number;
|
|
184
|
-
controller: AbortController | undefined;
|
|
185
|
-
completion: Promise<void>;
|
|
186
|
-
stopVersion: number;
|
|
187
|
-
stopMessage: string;
|
|
188
|
-
}> = [];
|
|
189
|
-
for (const runId of targets) {
|
|
190
|
-
const thread = runtime.threads.get(runId);
|
|
191
|
-
if (!thread) continue;
|
|
192
|
-
const previousState = thread.state;
|
|
193
|
-
const wasQueued = previousState === "queued";
|
|
194
|
-
const wasResuming = previousState === "resuming";
|
|
195
|
-
const wasActive =
|
|
196
|
-
thread.lifecycleOperation !== undefined ||
|
|
197
|
-
["queued", "resuming", "running", "interrupting"].includes(previousState);
|
|
198
|
-
const stopVersion = ++thread.lifecycleVersion;
|
|
199
|
-
// Stop-all claims every target before the first await. This invalidates
|
|
200
|
-
// all concurrent resume preflights as one synchronous operation.
|
|
201
|
-
thread.lifecycleOperation = "stop";
|
|
202
|
-
thread.retired = true;
|
|
203
|
-
thread.retireOnSettle = true;
|
|
204
|
-
thread.state = "stopped";
|
|
205
|
-
const stopMessage = wasQueued
|
|
206
|
-
? "Stopped by subagent_stop before the run started."
|
|
207
|
-
: wasResuming
|
|
208
|
-
? "Stopped by subagent_stop while resume was preparing."
|
|
209
|
-
: wasActive
|
|
210
|
-
? "Stopped by subagent_stop."
|
|
211
|
-
: previousState === "parked"
|
|
212
|
-
? "Stopped by subagent_stop from a parked checkpoint."
|
|
213
|
-
: "Retired by subagent_stop.";
|
|
214
|
-
claimed.push({
|
|
215
|
-
runId,
|
|
216
|
-
thread,
|
|
217
|
-
run: monitor.findRun(runId),
|
|
218
|
-
previousState,
|
|
219
|
-
wasQueued,
|
|
220
|
-
wasResuming,
|
|
221
|
-
wasActive,
|
|
222
|
-
generation: thread.generation,
|
|
223
|
-
controller: thread.queueController,
|
|
224
|
-
completion: thread.generationCompletion,
|
|
225
|
-
stopVersion,
|
|
226
|
-
stopMessage,
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Interrupt every claimed generation before awaiting any one of them.
|
|
231
|
-
// An isolated stop may need the repository lane for final integration;
|
|
232
|
-
// cancelling all holders first prevents stop-all from waiting behind a
|
|
233
|
-
// later shared workflow that this same operation has not interrupted yet.
|
|
234
|
-
const interruptionPromises = claimed.map(({ thread, stopMessage, controller }) => {
|
|
235
|
-
const stopping = thread.control.stop(stopMessage).catch(() => undefined);
|
|
236
|
-
runtime.backgroundQueue.cancel(controller);
|
|
237
|
-
return stopping;
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
const stopped: string[] = [];
|
|
241
|
-
const retainedIntegration: string[] = [];
|
|
242
|
-
const pendingIntegration: string[] = [];
|
|
243
|
-
for (const [claimIndex, claim] of claimed.entries()) {
|
|
244
|
-
const {
|
|
245
|
-
runId,
|
|
246
|
-
thread,
|
|
247
|
-
run,
|
|
248
|
-
previousState,
|
|
249
|
-
wasQueued,
|
|
250
|
-
wasResuming,
|
|
251
|
-
wasActive,
|
|
252
|
-
generation,
|
|
253
|
-
controller,
|
|
254
|
-
completion,
|
|
255
|
-
stopVersion,
|
|
256
|
-
stopMessage,
|
|
257
|
-
} = claim;
|
|
258
|
-
// Every wait here is bounded: the queue task can sit for minutes in
|
|
259
|
-
// worktree finalization or behind the managed repository lane, and an
|
|
260
|
-
// unkillable child can stall even the RPC-level stop. Stop owns the
|
|
261
|
-
// lifecycle synchronously, so a stuck tail settles silently after we
|
|
262
|
-
// proceed; none of its late paths can publish a second result.
|
|
263
|
-
await quiesced(interruptionPromises[claimIndex]);
|
|
264
|
-
if (!(await quiesced(completion))) runtime.backgroundQueue.cancel(controller);
|
|
265
|
-
if (runtime.runControllers.get(runId) === controller) runtime.runControllers.delete(runId);
|
|
266
|
-
if (thread.queueController === controller) thread.queueController = undefined;
|
|
267
|
-
|
|
268
|
-
// Dispatch yields publication ownership as soon as stop claims the
|
|
269
|
-
// lifecycle. Synthesize and publish the one aborted result here only when
|
|
270
|
-
// this stop actually interrupted unfinished work.
|
|
271
|
-
let stoppedResult: SingleResult | undefined;
|
|
272
|
-
if (
|
|
273
|
-
wasQueued ||
|
|
274
|
-
wasResuming ||
|
|
275
|
-
previousState === "parked" ||
|
|
276
|
-
!runtime.settledRuns.has(runId)
|
|
277
|
-
) {
|
|
278
|
-
const prior = thread.lastResult;
|
|
279
|
-
stoppedResult = prior
|
|
280
|
-
? {
|
|
281
|
-
...prior,
|
|
282
|
-
exitCode: 1,
|
|
283
|
-
stopReason: "aborted",
|
|
284
|
-
errorMessage: stopMessage,
|
|
285
|
-
runId,
|
|
286
|
-
}
|
|
287
|
-
: {
|
|
288
|
-
agent: thread.agentName,
|
|
289
|
-
task: thread.task,
|
|
290
|
-
exitCode: 1,
|
|
291
|
-
messages: [],
|
|
292
|
-
stderr: stopMessage,
|
|
293
|
-
usage: emptyUsage(),
|
|
294
|
-
model: run?.model,
|
|
295
|
-
thinking: run?.thinking,
|
|
296
|
-
projectCwd: thread.cwd,
|
|
297
|
-
stopReason: "aborted",
|
|
298
|
-
errorMessage: stopMessage,
|
|
299
|
-
runId,
|
|
300
|
-
isolation: thread.isolation,
|
|
301
|
-
};
|
|
302
|
-
const worktree = thread.worktree;
|
|
303
|
-
let finalization: WorktreeFinalization | undefined;
|
|
304
|
-
try {
|
|
305
|
-
finalization = await Promise.race([
|
|
306
|
-
thread.finalizeIsolation(generation, stoppedResult),
|
|
307
|
-
new Promise<undefined>((resolve) => {
|
|
308
|
-
const timer = setTimeout(() => resolve(undefined), CONTROL_QUIESCE_TIMEOUT_MS);
|
|
309
|
-
if (typeof timer.unref === "function") timer.unref();
|
|
310
|
-
}),
|
|
311
|
-
]);
|
|
312
|
-
} catch {
|
|
313
|
-
/* an unexpected finalize rejection must not block the stop */
|
|
314
|
-
}
|
|
315
|
-
if (finalization === undefined) {
|
|
316
|
-
// Integration is still settling in the background. Point a
|
|
317
|
-
// durable recovery record at the artifacts so the isolated work
|
|
318
|
-
// stays findable even if the background tail later fails; a
|
|
319
|
-
// successful tail removes them and the record self-prunes.
|
|
320
|
-
if (thread.isolation === "worktree" && worktree) {
|
|
321
|
-
stoppedResult.integrationStatus = "pending";
|
|
322
|
-
stoppedResult.integrationWorktreePath = worktree.worktreePath;
|
|
323
|
-
await persistRecoveryRecords(runtime.configPath, [
|
|
324
|
-
recoveryRecordFromFinalization(runId, {
|
|
325
|
-
status: "retained",
|
|
326
|
-
integrated: false,
|
|
327
|
-
hadChanges: false,
|
|
328
|
-
...(existsSync(worktree.worktreePath) ? { worktreePath: worktree.worktreePath } : {}),
|
|
329
|
-
...(existsSync(worktree.patchPath) ? { patchPath: worktree.patchPath } : {}),
|
|
330
|
-
error: "subagent_stop timed out waiting for worktree integration; it continues in the background",
|
|
331
|
-
}),
|
|
332
|
-
]).catch(() => undefined);
|
|
333
|
-
}
|
|
334
|
-
pendingIntegration.push(`#${runId}`);
|
|
335
|
-
} else if (finalization.status === "retained") {
|
|
336
|
-
retainedIntegration.push(`#${runId}`);
|
|
337
|
-
}
|
|
338
|
-
runtime.registerRunResult(runId, stoppedResult);
|
|
339
|
-
thread.lastResult = stoppedResult;
|
|
340
|
-
}
|
|
341
|
-
monitor.setStatus(runId, "failed");
|
|
342
|
-
if (stoppedResult) completionResults.push(stoppedResult);
|
|
343
|
-
monitor.removeRun(runId);
|
|
344
|
-
runtime.retireThreadSession(thread);
|
|
345
|
-
// The destructive retire removes the durable record with the session;
|
|
346
|
-
// an id never resurrects after subagent_stop.
|
|
347
|
-
await removeThreadRecord(runtime.configPath, runId).catch(() => undefined);
|
|
348
|
-
if (thread.lifecycleVersion === stopVersion && thread.lifecycleOperation === "stop") {
|
|
349
|
-
thread.lifecycleOperation = undefined;
|
|
350
|
-
}
|
|
351
|
-
stopped.push(`#${runId} ${thread.agentName}${wasQueued ? " (queued)" : wasActive ? "" : ` (${previousState})`}`);
|
|
352
|
-
}
|
|
353
|
-
if (completionResults.length > 0) {
|
|
354
|
-
const maxResultLines = (await configPromise)?.maxResultLines ?? DEFAULT_MAX_RESULT_LINES;
|
|
355
|
-
runtime.sendCompletionGroup(completionResults.map((result) => ({
|
|
356
|
-
agent: result.agent,
|
|
357
|
-
block: formatCompletionBlock(result, maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, result.projectCwd ?? ctx.cwd) }),
|
|
358
|
-
triggerTurn: true,
|
|
359
|
-
usage: result.usage,
|
|
360
|
-
})));
|
|
361
|
-
runtime.completionBatcher.flush();
|
|
362
|
-
}
|
|
363
|
-
return {
|
|
364
|
-
content: [{
|
|
365
|
-
type: "text",
|
|
366
|
-
text: `Stopped ${stopped.length} thread${stopped.length === 1 ? "" : "s"}: ${stopped.join(", ")}. Retained sessions were retired; worktree changes are integrated on settlement.${retainedIntegration.length > 0 ? ` Integration failed for ${retainedIntegration.join(", ")}; inspect its result for retained recovery paths.` : ""}${pendingIntegration.length > 0 ? ` Integration is still settling in the background for ${pendingIntegration.join(", ")}; a recovery record was persisted in case it fails.` : ""}`,
|
|
367
|
-
}],
|
|
368
|
-
details: {},
|
|
369
|
-
};
|
|
370
|
-
},
|
|
371
|
-
|
|
372
|
-
renderCall(args, theme) {
|
|
373
|
-
return new Text(
|
|
374
|
-
`${theme.fg("toolTitle", theme.bold("subagent_stop "))}${theme.fg("accent", args.all === true ? "all" : args.id ? `#${args.id}` : "?")}`,
|
|
375
|
-
0,
|
|
376
|
-
0,
|
|
377
|
-
);
|
|
378
|
-
},
|
|
379
|
-
|
|
380
|
-
renderResult(result, _options, theme) {
|
|
381
|
-
return renderFirstLine(result, "subagent_stop ", theme);
|
|
382
|
-
},
|
|
383
|
-
});
|
|
384
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Thread controls around the subagent runtime: subagent_control (resume) and
|
|
3
|
+
* destructive subagent_stop. There is no status/poll tool — completions carry
|
|
4
|
+
* each result (with an on-disk artifact when truncated) and wake the main
|
|
5
|
+
* model, so waiting is never a tool call; the only in-turn block is `wait:
|
|
6
|
+
* true` on a dispatch, for one-shot parents that exit at end of turn.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
10
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { Type } from "typebox";
|
|
14
|
+
import { DEFAULT_MAX_RESULT_LINES, loadConfig } from "./config.ts";
|
|
15
|
+
import { removeThreadRecord } from "./durable.ts";
|
|
16
|
+
import { formatCompletionBlock, matchRunIds } from "./format.ts";
|
|
17
|
+
import { emptyUsage } from "./rpc-run.ts";
|
|
18
|
+
import { formatTaskSummary, monitor } from "./monitor.ts";
|
|
19
|
+
import { persistRecoveryRecords, recoveryRecordFromFinalization } from "./recovery.ts";
|
|
20
|
+
import type { SubagentRuntime, SubagentThread } from "./runtime.ts";
|
|
21
|
+
import { CONTROL_QUIESCE_TIMEOUT_MS, projectResultsRoot, quiesced } from "./thread-lifecycle.ts";
|
|
22
|
+
import { getResultOutput, isFailedResult, type SingleResult } from "./spawn.ts";
|
|
23
|
+
import type { WorktreeFinalization } from "./worktree.ts";
|
|
24
|
+
|
|
25
|
+
function renderFirstLine(result: { content?: unknown }, label: string, theme: any): Text {
|
|
26
|
+
const parts = (result.content ?? []) as Array<{ type: string; text?: string }>;
|
|
27
|
+
const text = parts
|
|
28
|
+
.map((part) => (typeof part.text === "string" ? part.text : ""))
|
|
29
|
+
.join(" ")
|
|
30
|
+
.trim();
|
|
31
|
+
const firstLine = text.split("\n").find((line) => line.trim()) ?? "(no output)";
|
|
32
|
+
return new Text(`${theme.fg("toolTitle", theme.bold(label))}${theme.fg("dim", firstLine.slice(0, 60))}`, 0, 0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime): void {
|
|
36
|
+
const SubagentControlParams = Type.Object({
|
|
37
|
+
action: StringEnum(["resume"] as const, {
|
|
38
|
+
description: "Control operation for the logical sub-agent thread.",
|
|
39
|
+
}),
|
|
40
|
+
id: Type.Integer({ minimum: 1, description: "Stable run id shown by subagent dispatch output." }),
|
|
41
|
+
objective: Type.Optional(
|
|
42
|
+
Type.String({ description: "Optional appended objective for resume. Omit to continue the current retained objective." }),
|
|
43
|
+
),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
pi.registerTool({
|
|
47
|
+
name: "subagent_control",
|
|
48
|
+
label: "Subagent Control",
|
|
49
|
+
description: [
|
|
50
|
+
"Resume an existing sub-agent thread by stable run id: a parked, completed, or failed retained thread restarts with the same run id and cumulative active time.",
|
|
51
|
+
"Omit objective to continue the current goal, or provide one to append it to retained context and make it the displayed goal. Threads parked or interrupted by a shutdown/reload are restorable; use subagent_stop for destructive cancellation.",
|
|
52
|
+
].join(" "),
|
|
53
|
+
promptSnippet: "Resume a parked or settled subagent thread with its retained context.",
|
|
54
|
+
promptGuidelines: [
|
|
55
|
+
"Resume keeps the run id and retained context; use subagent_stop only for destructive cancellation, which retires that thread's session.",
|
|
56
|
+
],
|
|
57
|
+
parameters: SubagentControlParams,
|
|
58
|
+
|
|
59
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
60
|
+
// A thread parked by the previous process exists only once restore has
|
|
61
|
+
// read the manifest; resuming before that would deny a live run id.
|
|
62
|
+
await runtime.durableRestore;
|
|
63
|
+
const thread = runtime.threads.get(params.id);
|
|
64
|
+
if (!thread) {
|
|
65
|
+
return { content: [{ type: "text", text: `No subagent thread matches run #${params.id}.` }], details: {} };
|
|
66
|
+
}
|
|
67
|
+
const nonBlank = (value: string | undefined): string | undefined => {
|
|
68
|
+
const trimmed = value?.trim();
|
|
69
|
+
return trimmed ? trimmed : undefined;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
switch (params.action) {
|
|
74
|
+
case "resume": {
|
|
75
|
+
if (thread.retired) {
|
|
76
|
+
return { content: [{ type: "text", text: `Run #${thread.id} was retired by subagent_stop and has no resumable session.` }], details: {} };
|
|
77
|
+
}
|
|
78
|
+
if (!(["parked", "completed", "failed"] as const).includes(thread.state as any)) {
|
|
79
|
+
return { content: [{ type: "text", text: `Run #${thread.id} is ${thread.state}; it must be parked or settled before resume.` }], details: {} };
|
|
80
|
+
}
|
|
81
|
+
const objective = params.objective === undefined ? undefined : nonBlank(params.objective);
|
|
82
|
+
if (params.objective !== undefined && !objective) {
|
|
83
|
+
return { content: [{ type: "text", text: "resume objective must be non-blank when provided." }], details: {} };
|
|
84
|
+
}
|
|
85
|
+
const hadRetainedSession = Boolean(thread.sessionId && thread.sessionDir);
|
|
86
|
+
const pending = await thread.resume(objective, ctx);
|
|
87
|
+
if (pending.exitCode !== -1) {
|
|
88
|
+
return { content: [{ type: "text", text: getResultOutput(pending) }], details: {} };
|
|
89
|
+
}
|
|
90
|
+
const currentObjective = formatTaskSummary(objective ?? thread.task, 80, false);
|
|
91
|
+
const mode = objective
|
|
92
|
+
? `appended objective: ${currentObjective}`
|
|
93
|
+
: `continuing current objective: ${currentObjective}`;
|
|
94
|
+
const context = hadRetainedSession
|
|
95
|
+
? "the same retained session and prior context are preserved"
|
|
96
|
+
: "no prior child session existed, so only the logical run and objective are continued";
|
|
97
|
+
return { content: [{ type: "text", text: `Resumed run #${thread.id}, ${mode}; ${context}, and cumulative active time is preserved. It runs in the background — keep working; the result resumes you automatically.` }], details: {} };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch (error) {
|
|
101
|
+
throw new Error(`Could not ${params.action} run #${thread.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
renderCall(args, theme) {
|
|
106
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("subagent_control "))}${theme.fg("accent", `${args.action} #${args.id}`)}`, 0, 0);
|
|
107
|
+
},
|
|
108
|
+
renderResult(result, _options, theme) {
|
|
109
|
+
return renderFirstLine(result, "subagent_control ", theme);
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Cancel one or more active runs: aborts the queue controller, which
|
|
114
|
+
// terminates the child and delivers an aborted result (with whatever partial
|
|
115
|
+
// output it produced) so the main agent always knows the run stopped.
|
|
116
|
+
const SubagentStopParams = Type.Object({
|
|
117
|
+
id: Type.Optional(
|
|
118
|
+
Type.String({
|
|
119
|
+
description: "Run id or prefix to stop (see subagent dispatch output).",
|
|
120
|
+
}),
|
|
121
|
+
),
|
|
122
|
+
all: Type.Optional(Type.Boolean({ description: "Stop every active run (default false)." })),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
pi.registerTool({
|
|
126
|
+
name: "subagent_stop",
|
|
127
|
+
label: "Subagent Stop",
|
|
128
|
+
description: [
|
|
129
|
+
"Destructively stop a sub-agent thread: terminate active work, deliver its aborted partial result, and retire any retained session so it cannot be resumed.",
|
|
130
|
+
"Pass id (run id or prefix) to stop one active, parked, or completed thread; all: true stops every active run.",
|
|
131
|
+
].join(" "),
|
|
132
|
+
promptSnippet: "Stop a running background subagent (id from dispatch output; or all: true).",
|
|
133
|
+
promptGuidelines: [
|
|
134
|
+
"Stop a run when its task is obsolete, stuck, or superseded — do not leave it burning tokens. It then reports as failed with 'aborted' plus its partial output.",
|
|
135
|
+
],
|
|
136
|
+
parameters: SubagentStopParams,
|
|
137
|
+
|
|
138
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
139
|
+
await runtime.durableRestore;
|
|
140
|
+
// Start config I/O without yielding: every target below must be claimed
|
|
141
|
+
// synchronously before a resume preflight can cross its next await.
|
|
142
|
+
const configPromise = loadConfig(runtime.configPath).catch(() => undefined);
|
|
143
|
+
const completionResults: SingleResult[] = [];
|
|
144
|
+
const candidateIds = params.all === true
|
|
145
|
+
? [...new Set([
|
|
146
|
+
...runtime.runControllers.keys(),
|
|
147
|
+
...[...runtime.threads.values()]
|
|
148
|
+
.filter((thread) =>
|
|
149
|
+
thread.lifecycleOperation !== undefined ||
|
|
150
|
+
["queued", "resuming", "running", "interrupting"].includes(thread.state),
|
|
151
|
+
)
|
|
152
|
+
.map((thread) => thread.id),
|
|
153
|
+
])]
|
|
154
|
+
: [...runtime.threads.keys()];
|
|
155
|
+
const targets =
|
|
156
|
+
params.all === true
|
|
157
|
+
? candidateIds
|
|
158
|
+
: params.id !== undefined && params.id.trim() !== ""
|
|
159
|
+
? matchRunIds(candidateIds, params.id.trim())
|
|
160
|
+
: [];
|
|
161
|
+
|
|
162
|
+
if (targets.length === 0) {
|
|
163
|
+
const available = [...runtime.threads.keys()].map((id) => `#${id}`).join(", ");
|
|
164
|
+
return {
|
|
165
|
+
content: [{
|
|
166
|
+
type: "text",
|
|
167
|
+
text: params.all === true
|
|
168
|
+
? "No active subagent runs to stop."
|
|
169
|
+
: `No subagent thread matches "${params.id}".${available ? ` Known threads: ${available}.` : ""}`,
|
|
170
|
+
}],
|
|
171
|
+
details: {},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const claimed: Array<{
|
|
176
|
+
runId: number;
|
|
177
|
+
thread: SubagentThread;
|
|
178
|
+
run: ReturnType<typeof monitor.findRun>;
|
|
179
|
+
previousState: SubagentThread["state"];
|
|
180
|
+
wasQueued: boolean;
|
|
181
|
+
wasResuming: boolean;
|
|
182
|
+
wasActive: boolean;
|
|
183
|
+
generation: number;
|
|
184
|
+
controller: AbortController | undefined;
|
|
185
|
+
completion: Promise<void>;
|
|
186
|
+
stopVersion: number;
|
|
187
|
+
stopMessage: string;
|
|
188
|
+
}> = [];
|
|
189
|
+
for (const runId of targets) {
|
|
190
|
+
const thread = runtime.threads.get(runId);
|
|
191
|
+
if (!thread) continue;
|
|
192
|
+
const previousState = thread.state;
|
|
193
|
+
const wasQueued = previousState === "queued";
|
|
194
|
+
const wasResuming = previousState === "resuming";
|
|
195
|
+
const wasActive =
|
|
196
|
+
thread.lifecycleOperation !== undefined ||
|
|
197
|
+
["queued", "resuming", "running", "interrupting"].includes(previousState);
|
|
198
|
+
const stopVersion = ++thread.lifecycleVersion;
|
|
199
|
+
// Stop-all claims every target before the first await. This invalidates
|
|
200
|
+
// all concurrent resume preflights as one synchronous operation.
|
|
201
|
+
thread.lifecycleOperation = "stop";
|
|
202
|
+
thread.retired = true;
|
|
203
|
+
thread.retireOnSettle = true;
|
|
204
|
+
thread.state = "stopped";
|
|
205
|
+
const stopMessage = wasQueued
|
|
206
|
+
? "Stopped by subagent_stop before the run started."
|
|
207
|
+
: wasResuming
|
|
208
|
+
? "Stopped by subagent_stop while resume was preparing."
|
|
209
|
+
: wasActive
|
|
210
|
+
? "Stopped by subagent_stop."
|
|
211
|
+
: previousState === "parked"
|
|
212
|
+
? "Stopped by subagent_stop from a parked checkpoint."
|
|
213
|
+
: "Retired by subagent_stop.";
|
|
214
|
+
claimed.push({
|
|
215
|
+
runId,
|
|
216
|
+
thread,
|
|
217
|
+
run: monitor.findRun(runId),
|
|
218
|
+
previousState,
|
|
219
|
+
wasQueued,
|
|
220
|
+
wasResuming,
|
|
221
|
+
wasActive,
|
|
222
|
+
generation: thread.generation,
|
|
223
|
+
controller: thread.queueController,
|
|
224
|
+
completion: thread.generationCompletion,
|
|
225
|
+
stopVersion,
|
|
226
|
+
stopMessage,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Interrupt every claimed generation before awaiting any one of them.
|
|
231
|
+
// An isolated stop may need the repository lane for final integration;
|
|
232
|
+
// cancelling all holders first prevents stop-all from waiting behind a
|
|
233
|
+
// later shared workflow that this same operation has not interrupted yet.
|
|
234
|
+
const interruptionPromises = claimed.map(({ thread, stopMessage, controller }) => {
|
|
235
|
+
const stopping = thread.control.stop(stopMessage).catch(() => undefined);
|
|
236
|
+
runtime.backgroundQueue.cancel(controller);
|
|
237
|
+
return stopping;
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const stopped: string[] = [];
|
|
241
|
+
const retainedIntegration: string[] = [];
|
|
242
|
+
const pendingIntegration: string[] = [];
|
|
243
|
+
for (const [claimIndex, claim] of claimed.entries()) {
|
|
244
|
+
const {
|
|
245
|
+
runId,
|
|
246
|
+
thread,
|
|
247
|
+
run,
|
|
248
|
+
previousState,
|
|
249
|
+
wasQueued,
|
|
250
|
+
wasResuming,
|
|
251
|
+
wasActive,
|
|
252
|
+
generation,
|
|
253
|
+
controller,
|
|
254
|
+
completion,
|
|
255
|
+
stopVersion,
|
|
256
|
+
stopMessage,
|
|
257
|
+
} = claim;
|
|
258
|
+
// Every wait here is bounded: the queue task can sit for minutes in
|
|
259
|
+
// worktree finalization or behind the managed repository lane, and an
|
|
260
|
+
// unkillable child can stall even the RPC-level stop. Stop owns the
|
|
261
|
+
// lifecycle synchronously, so a stuck tail settles silently after we
|
|
262
|
+
// proceed; none of its late paths can publish a second result.
|
|
263
|
+
await quiesced(interruptionPromises[claimIndex]);
|
|
264
|
+
if (!(await quiesced(completion))) runtime.backgroundQueue.cancel(controller);
|
|
265
|
+
if (runtime.runControllers.get(runId) === controller) runtime.runControllers.delete(runId);
|
|
266
|
+
if (thread.queueController === controller) thread.queueController = undefined;
|
|
267
|
+
|
|
268
|
+
// Dispatch yields publication ownership as soon as stop claims the
|
|
269
|
+
// lifecycle. Synthesize and publish the one aborted result here only when
|
|
270
|
+
// this stop actually interrupted unfinished work.
|
|
271
|
+
let stoppedResult: SingleResult | undefined;
|
|
272
|
+
if (
|
|
273
|
+
wasQueued ||
|
|
274
|
+
wasResuming ||
|
|
275
|
+
previousState === "parked" ||
|
|
276
|
+
!runtime.settledRuns.has(runId)
|
|
277
|
+
) {
|
|
278
|
+
const prior = thread.lastResult;
|
|
279
|
+
stoppedResult = prior
|
|
280
|
+
? {
|
|
281
|
+
...prior,
|
|
282
|
+
exitCode: 1,
|
|
283
|
+
stopReason: "aborted",
|
|
284
|
+
errorMessage: stopMessage,
|
|
285
|
+
runId,
|
|
286
|
+
}
|
|
287
|
+
: {
|
|
288
|
+
agent: thread.agentName,
|
|
289
|
+
task: thread.task,
|
|
290
|
+
exitCode: 1,
|
|
291
|
+
messages: [],
|
|
292
|
+
stderr: stopMessage,
|
|
293
|
+
usage: emptyUsage(),
|
|
294
|
+
model: run?.model,
|
|
295
|
+
thinking: run?.thinking,
|
|
296
|
+
projectCwd: thread.cwd,
|
|
297
|
+
stopReason: "aborted",
|
|
298
|
+
errorMessage: stopMessage,
|
|
299
|
+
runId,
|
|
300
|
+
isolation: thread.isolation,
|
|
301
|
+
};
|
|
302
|
+
const worktree = thread.worktree;
|
|
303
|
+
let finalization: WorktreeFinalization | undefined;
|
|
304
|
+
try {
|
|
305
|
+
finalization = await Promise.race([
|
|
306
|
+
thread.finalizeIsolation(generation, stoppedResult),
|
|
307
|
+
new Promise<undefined>((resolve) => {
|
|
308
|
+
const timer = setTimeout(() => resolve(undefined), CONTROL_QUIESCE_TIMEOUT_MS);
|
|
309
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
310
|
+
}),
|
|
311
|
+
]);
|
|
312
|
+
} catch {
|
|
313
|
+
/* an unexpected finalize rejection must not block the stop */
|
|
314
|
+
}
|
|
315
|
+
if (finalization === undefined) {
|
|
316
|
+
// Integration is still settling in the background. Point a
|
|
317
|
+
// durable recovery record at the artifacts so the isolated work
|
|
318
|
+
// stays findable even if the background tail later fails; a
|
|
319
|
+
// successful tail removes them and the record self-prunes.
|
|
320
|
+
if (thread.isolation === "worktree" && worktree) {
|
|
321
|
+
stoppedResult.integrationStatus = "pending";
|
|
322
|
+
stoppedResult.integrationWorktreePath = worktree.worktreePath;
|
|
323
|
+
await persistRecoveryRecords(runtime.configPath, [
|
|
324
|
+
recoveryRecordFromFinalization(runId, {
|
|
325
|
+
status: "retained",
|
|
326
|
+
integrated: false,
|
|
327
|
+
hadChanges: false,
|
|
328
|
+
...(existsSync(worktree.worktreePath) ? { worktreePath: worktree.worktreePath } : {}),
|
|
329
|
+
...(existsSync(worktree.patchPath) ? { patchPath: worktree.patchPath } : {}),
|
|
330
|
+
error: "subagent_stop timed out waiting for worktree integration; it continues in the background",
|
|
331
|
+
}),
|
|
332
|
+
]).catch(() => undefined);
|
|
333
|
+
}
|
|
334
|
+
pendingIntegration.push(`#${runId}`);
|
|
335
|
+
} else if (finalization.status === "retained") {
|
|
336
|
+
retainedIntegration.push(`#${runId}`);
|
|
337
|
+
}
|
|
338
|
+
runtime.registerRunResult(runId, stoppedResult);
|
|
339
|
+
thread.lastResult = stoppedResult;
|
|
340
|
+
}
|
|
341
|
+
monitor.setStatus(runId, "failed");
|
|
342
|
+
if (stoppedResult) completionResults.push(stoppedResult);
|
|
343
|
+
monitor.removeRun(runId);
|
|
344
|
+
runtime.retireThreadSession(thread);
|
|
345
|
+
// The destructive retire removes the durable record with the session;
|
|
346
|
+
// an id never resurrects after subagent_stop.
|
|
347
|
+
await removeThreadRecord(runtime.configPath, runId, thread.cwd).catch(() => undefined);
|
|
348
|
+
if (thread.lifecycleVersion === stopVersion && thread.lifecycleOperation === "stop") {
|
|
349
|
+
thread.lifecycleOperation = undefined;
|
|
350
|
+
}
|
|
351
|
+
stopped.push(`#${runId} ${thread.agentName}${wasQueued ? " (queued)" : wasActive ? "" : ` (${previousState})`}`);
|
|
352
|
+
}
|
|
353
|
+
if (completionResults.length > 0) {
|
|
354
|
+
const maxResultLines = (await configPromise)?.maxResultLines ?? DEFAULT_MAX_RESULT_LINES;
|
|
355
|
+
runtime.sendCompletionGroup(completionResults.map((result) => ({
|
|
356
|
+
agent: result.agent,
|
|
357
|
+
block: formatCompletionBlock(result, maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, result.projectCwd ?? ctx.cwd) }),
|
|
358
|
+
triggerTurn: true,
|
|
359
|
+
usage: result.usage,
|
|
360
|
+
})));
|
|
361
|
+
runtime.completionBatcher.flush();
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
content: [{
|
|
365
|
+
type: "text",
|
|
366
|
+
text: `Stopped ${stopped.length} thread${stopped.length === 1 ? "" : "s"}: ${stopped.join(", ")}. Retained sessions were retired; worktree changes are integrated on settlement.${retainedIntegration.length > 0 ? ` Integration failed for ${retainedIntegration.join(", ")}; inspect its result for retained recovery paths.` : ""}${pendingIntegration.length > 0 ? ` Integration is still settling in the background for ${pendingIntegration.join(", ")}; a recovery record was persisted in case it fails.` : ""}`,
|
|
367
|
+
}],
|
|
368
|
+
details: {},
|
|
369
|
+
};
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
renderCall(args, theme) {
|
|
373
|
+
return new Text(
|
|
374
|
+
`${theme.fg("toolTitle", theme.bold("subagent_stop "))}${theme.fg("accent", args.all === true ? "all" : args.id ? `#${args.id}` : "?")}`,
|
|
375
|
+
0,
|
|
376
|
+
0,
|
|
377
|
+
);
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
renderResult(result, _options, theme) {
|
|
381
|
+
return renderFirstLine(result, "subagent_stop ", theme);
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
}
|