@ferris1225/pi-subagents 4.1.7 → 4.1.9
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 +94 -65
- package/agents/cleaner.md +13 -14
- package/agents/documenter.md +10 -17
- package/agents/explorer.md +6 -16
- package/agents/reviewer.md +28 -29
- package/agents/worker.md +14 -33
- package/package.json +1 -1
- package/src/announcements.ts +30 -67
- package/src/background.ts +25 -12
- package/src/config.ts +9 -170
- package/src/dispatch.ts +721 -747
- package/src/durable.ts +336 -0
- package/src/fixloop.ts +37 -37
- package/src/format.ts +1 -8
- package/src/index.ts +8 -1
- package/src/models.ts +16 -0
- package/src/monitor.ts +28 -29
- package/src/prompt.ts +7 -8
- package/src/rpc-run.ts +22 -228
- package/src/runtime.ts +72 -50
- package/src/session-fork.ts +7 -2
- package/src/setup.ts +0 -41
- package/src/spawn.ts +32 -29
- package/src/temp-hygiene.ts +194 -0
- package/src/thread-lifecycle.ts +1410 -1327
- package/src/tools.ts +21 -108
- package/src/widget.ts +3 -3
- package/src/worktree.ts +144 -4
package/src/runtime.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { rmSync } from "node:fs";
|
|
13
|
-
import { BackgroundTaskQueue } from "./background.ts";
|
|
13
|
+
import { BackgroundTaskQueue, MAX_CONCURRENT_SUBAGENTS } from "./background.ts";
|
|
14
14
|
import {
|
|
15
15
|
completionGroupTriggersTurn,
|
|
16
16
|
createCompletionBatcher,
|
|
@@ -19,29 +19,25 @@ import {
|
|
|
19
19
|
type CompletionBatcher,
|
|
20
20
|
type CompletionMessageItem,
|
|
21
21
|
} from "./completion.ts";
|
|
22
|
-
import {
|
|
22
|
+
import { type ThinkingLevel } from "./config.ts";
|
|
23
|
+
import { threadRecordFromThread, upsertThreadRecord, type ThreadRecord } from "./durable.ts";
|
|
23
24
|
import { isRunActiveStatus, monitor } from "./monitor.ts";
|
|
24
|
-
import {
|
|
25
|
-
persistRecoveryRecords,
|
|
26
|
-
recoveryRecordFromFinalization,
|
|
27
|
-
type RecoveryRecord,
|
|
28
|
-
} from "./recovery.ts";
|
|
29
25
|
import type { RpcRunControl } from "./rpc-run.ts";
|
|
30
|
-
import type {
|
|
26
|
+
import type { StartBackgroundInternal } from "./thread-lifecycle.ts";
|
|
27
|
+
import { isFailedResult, type SingleResult } from "./spawn.ts";
|
|
31
28
|
import type { IsolationMode, WorktreeFinalization, WorktreeIsolation } from "./worktree.ts";
|
|
32
29
|
|
|
33
30
|
export type ThreadState =
|
|
34
31
|
| "queued"
|
|
35
32
|
| "resuming"
|
|
36
33
|
| "running"
|
|
37
|
-
| "steering"
|
|
38
34
|
| "interrupting"
|
|
39
35
|
| "parked"
|
|
40
36
|
| "completed"
|
|
41
37
|
| "failed"
|
|
42
38
|
| "stopped";
|
|
43
39
|
|
|
44
|
-
export type ThreadLifecycleOperation = "park" | "resume" | "
|
|
40
|
+
export type ThreadLifecycleOperation = "park" | "resume" | "stop" | "settle";
|
|
45
41
|
|
|
46
42
|
export interface SubagentThread {
|
|
47
43
|
id: number;
|
|
@@ -54,6 +50,8 @@ export interface SubagentThread {
|
|
|
54
50
|
executionCwd: string;
|
|
55
51
|
thinkingLevel?: ThinkingLevel;
|
|
56
52
|
isolation: IsolationMode;
|
|
53
|
+
/** Report-only reviewer dispatch: verdicts never chain into auto-fix. */
|
|
54
|
+
advisoryReview: boolean;
|
|
57
55
|
worktree?: WorktreeIsolation;
|
|
58
56
|
state: ThreadState;
|
|
59
57
|
control: RpcRunControl;
|
|
@@ -74,15 +72,8 @@ export interface SubagentThread {
|
|
|
74
72
|
/** A destructive stop retires context even if the active child settles later. */
|
|
75
73
|
retireOnSettle?: boolean;
|
|
76
74
|
retired?: boolean;
|
|
77
|
-
/** Abort the active generation to a stable checkpoint and wait until its
|
|
78
|
-
* queue work has published that checkpoint and released its slot. */
|
|
79
|
-
park: () => Promise<"queued" | "active">;
|
|
80
75
|
/** Installed by dispatch so the control tool can restart the same logical id. */
|
|
81
76
|
resume: (objective?: string, ctx?: ExtensionContext) => Promise<SingleResult>;
|
|
82
|
-
/** Create a new logical thread from this thread's retained Pi session branch. */
|
|
83
|
-
fork: (objective?: string, ctx?: ExtensionContext) => Promise<SingleResult>;
|
|
84
|
-
forkedFromRunId?: number;
|
|
85
|
-
forkChildRunIds: number[];
|
|
86
77
|
/** Dispatch-owned, generation-guarded worktree settlement hook. Its apply
|
|
87
78
|
* runs under the canonical original-repository lane. */
|
|
88
79
|
finalizeIsolation: (generation: number, result?: SingleResult) => Promise<WorktreeFinalization | undefined>;
|
|
@@ -98,6 +89,13 @@ export interface SubagentRuntime {
|
|
|
98
89
|
getActiveTools: () => string[];
|
|
99
90
|
/** False after session_shutdown; guards delivery and queue work. */
|
|
100
91
|
sessionActive: boolean;
|
|
92
|
+
/** The process-wide background dispatcher. Set at tool registration so
|
|
93
|
+
* threads restored from the durable manifest can resume before any dispatch. */
|
|
94
|
+
dispatcher?: StartBackgroundInternal;
|
|
95
|
+
/** Run ids restored from the durable manifest at load; consumed by the
|
|
96
|
+
* one-time session-start notice. */
|
|
97
|
+
restoredRunIds: number[];
|
|
98
|
+
restoredNotified: boolean;
|
|
101
99
|
/** Deliver a batch of completion messages to the main window, waking it only
|
|
102
100
|
* when the batch needs a turn. */
|
|
103
101
|
sendCompletionGroup: (items: CompletionMessageItem[]) => void;
|
|
@@ -111,7 +109,7 @@ export interface SubagentRuntime {
|
|
|
111
109
|
registerRunResult: (runId: number, result: SingleResult) => void;
|
|
112
110
|
/** Logical threads outlive process attempts and completed generations. */
|
|
113
111
|
threads: Map<number, SubagentThread>;
|
|
114
|
-
/** Resume
|
|
112
|
+
/** Resume setup that has claimed a thread but has not yet enqueued its
|
|
115
113
|
* next generation. Shutdown invalidates these claims and waits for cleanup. */
|
|
116
114
|
preflightOperations: Set<Promise<void>>;
|
|
117
115
|
/** Every session directory retained for this parent session, including
|
|
@@ -124,16 +122,15 @@ export interface SubagentRuntime {
|
|
|
124
122
|
}
|
|
125
123
|
|
|
126
124
|
export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRuntime {
|
|
127
|
-
|
|
128
|
-
// async load runs per tool call.
|
|
129
|
-
const initialConfig = loadConfigSync(configPath);
|
|
130
|
-
const backgroundQueue = new BackgroundTaskQueue(initialConfig.maxConcurrency);
|
|
125
|
+
const backgroundQueue = new BackgroundTaskQueue(MAX_CONCURRENT_SUBAGENTS);
|
|
131
126
|
|
|
132
127
|
const runtime: SubagentRuntime = {
|
|
133
128
|
configPath,
|
|
134
129
|
backgroundQueue,
|
|
135
130
|
getActiveTools: () => pi.getActiveTools(),
|
|
136
131
|
sessionActive: true,
|
|
132
|
+
restoredRunIds: [],
|
|
133
|
+
restoredNotified: false,
|
|
137
134
|
sendCompletionGroup: (items) => {
|
|
138
135
|
if (!runtime.sessionActive || items.length === 0) return;
|
|
139
136
|
// A result arriving for one run does not mean sibling runs are done.
|
|
@@ -206,61 +203,86 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
206
203
|
if (!runtime.sessionActive) return;
|
|
207
204
|
runtime.sessionActive = false;
|
|
208
205
|
const shutdownThreads = [...runtime.threads.values()];
|
|
206
|
+
const liveStates = new Set(["queued", "resuming", "running", "interrupting"]);
|
|
207
|
+
const previousStates = new Map(shutdownThreads.map((thread) => [thread.id, thread.state] as const));
|
|
209
208
|
// Invalidate every lifecycle claim synchronously before the first await.
|
|
210
|
-
// Resume
|
|
209
|
+
// Resume preflight checks both this version and sessionActive, then
|
|
211
210
|
// cleans any worktree/session it created before resolving its tracker.
|
|
211
|
+
// A generation already inside its settlement keeps its own claim: it
|
|
212
|
+
// finalizes its worktree and persists its terminal record itself.
|
|
213
|
+
const interrupting = shutdownThreads.filter((thread) =>
|
|
214
|
+
!thread.retired &&
|
|
215
|
+
thread.lifecycleOperation !== "settle" &&
|
|
216
|
+
liveStates.has(thread.state),
|
|
217
|
+
);
|
|
212
218
|
for (const thread of shutdownThreads) {
|
|
213
219
|
thread.lifecycleVersion++;
|
|
220
|
+
if (thread.retired) {
|
|
221
|
+
thread.lifecycleOperation = "stop";
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (thread.lifecycleOperation === "settle") continue;
|
|
214
225
|
thread.lifecycleOperation = "stop";
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
thread.
|
|
226
|
+
// Deliberately NOT retireOnSettle: shutdown interrupts to the last
|
|
227
|
+
// checkpoint but keeps the session/worktree resumable across reload.
|
|
228
|
+
thread.retireOnSettle = false;
|
|
229
|
+
if (liveStates.has(thread.state)) thread.state = "stopped";
|
|
218
230
|
}
|
|
219
231
|
const preflights = [...runtime.preflightOperations];
|
|
220
232
|
runtime.completionBatcher.dispose();
|
|
221
233
|
runtime.backgroundQueue.cancelAll();
|
|
222
234
|
// Await live RPC process-tree cleanup and continuation preflight rollback
|
|
223
|
-
// before
|
|
235
|
+
// before persisting records or releasing ownership maps.
|
|
224
236
|
await Promise.all([
|
|
225
237
|
Promise.all(
|
|
226
|
-
|
|
238
|
+
interrupting.map((thread) =>
|
|
227
239
|
thread.control.stop("Parent session shut down").catch(() => undefined),
|
|
228
240
|
),
|
|
229
241
|
),
|
|
230
242
|
Promise.allSettled(preflights),
|
|
231
243
|
runtime.backgroundQueue.waitForIdle(),
|
|
232
244
|
]);
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
|
|
245
|
+
// Persist one record per non-retired thread, then keep exactly the
|
|
246
|
+
// artifacts those records reference. A thread whose settlement finished
|
|
247
|
+
// during the wait above already wrote its own terminal record; the
|
|
248
|
+
// lastResult-derived state below matches it.
|
|
249
|
+
const records: ThreadRecord[] = [];
|
|
237
250
|
for (const thread of runtime.threads.values()) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
}
|
|
251
|
+
if (thread.retired) continue;
|
|
252
|
+
const previous = previousStates.get(thread.id) ?? thread.state;
|
|
253
|
+
let state: "parked" | "completed" | "failed";
|
|
254
|
+
if (previous === "completed" || previous === "failed") {
|
|
255
|
+
state = previous;
|
|
256
|
+
} else if (thread.lifecycleOperation === "settle" && thread.lastResult) {
|
|
257
|
+
state = isFailedResult(thread.lastResult) ? "failed" : "completed";
|
|
258
|
+
} else {
|
|
259
|
+
state = "parked";
|
|
249
260
|
}
|
|
261
|
+
records.push(threadRecordFromThread(thread, state));
|
|
250
262
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
263
|
+
await Promise.all(
|
|
264
|
+
records.map((record) => upsertThreadRecord(runtime.configPath, record).catch(() => undefined)),
|
|
265
|
+
);
|
|
266
|
+
// Retained-failure recovery records are persisted by the finalization
|
|
267
|
+
// itself; shutdown only drops sessions no record claims anymore.
|
|
268
|
+
const referenced = new Set(
|
|
269
|
+
records.flatMap((record) =>
|
|
270
|
+
[record.sessionDir, record.worktree?.tempDir].filter(Boolean) as string[],
|
|
271
|
+
),
|
|
272
|
+
);
|
|
257
273
|
for (const sessionDir of runtime.sessionDirs) {
|
|
274
|
+
if (referenced.has(sessionDir)) continue;
|
|
258
275
|
try {
|
|
259
276
|
rmSync(sessionDir, { recursive: true, force: true });
|
|
260
277
|
} catch {
|
|
261
|
-
/* best-effort */
|
|
278
|
+
/* best-effort; the state-root sweep catches leftovers later */
|
|
262
279
|
}
|
|
263
280
|
}
|
|
281
|
+
runtime.settledRuns.clear();
|
|
282
|
+
runtime.settledListeners.clear();
|
|
283
|
+
runtime.runControllers.clear();
|
|
284
|
+
// sessionDirs entries still referenced by records stay owned by the
|
|
285
|
+
// manifest; the next process re-registers them at restore.
|
|
264
286
|
runtime.sessionDirs.clear();
|
|
265
287
|
runtime.preflightOperations.clear();
|
|
266
288
|
runtime.threads.clear();
|
package/src/session-fork.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
5
|
+
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
|
|
@@ -43,12 +43,17 @@ export async function forkRetainedSession(options: {
|
|
|
43
43
|
targetCwd?: string;
|
|
44
44
|
sessionDir: string;
|
|
45
45
|
sessionId: string;
|
|
46
|
+
/** Parent directory for the cloned branch. Defaults to the OS temp dir;
|
|
47
|
+
* dispatch passes the durable state root. */
|
|
48
|
+
targetRoot?: string;
|
|
46
49
|
}): Promise<ForkedSession> {
|
|
47
50
|
const sourceSessionFile = await findRetainedSessionFile(
|
|
48
51
|
options.sessionDir,
|
|
49
52
|
options.sessionId,
|
|
50
53
|
);
|
|
51
|
-
const
|
|
54
|
+
const root = options.targetRoot ?? tmpdir();
|
|
55
|
+
await mkdir(root, { recursive: true });
|
|
56
|
+
const sessionDir = await mkdtemp(join(root, "pi-subagent-session-fork-"));
|
|
52
57
|
try {
|
|
53
58
|
// Supplying the new directory makes createBranchedSession write there.
|
|
54
59
|
// cwdOverride rewrites the cloned header so a settled isolated session can
|
package/src/setup.ts
CHANGED
|
@@ -13,13 +13,9 @@ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
|
13
13
|
import {
|
|
14
14
|
AGENT_SCOPE_VALUES,
|
|
15
15
|
BUILTIN_AGENT_NAMES,
|
|
16
|
-
CLEANER_DEFAULTED_FEATURE,
|
|
17
|
-
DOCUMENTER_DEFAULTED_FEATURE,
|
|
18
16
|
DEFAULT_CONFIG,
|
|
19
17
|
DEFAULT_ENABLED_AGENTS,
|
|
20
18
|
DEFAULT_IDLE_TIMEOUT_SEC,
|
|
21
|
-
DEFAULT_MAX_CONCURRENCY,
|
|
22
|
-
DEFAULT_MAX_FIX_ROUNDS,
|
|
23
19
|
DEFAULT_THINKING_LEVEL,
|
|
24
20
|
type AgentScope,
|
|
25
21
|
type SubagentsConfig,
|
|
@@ -243,8 +239,6 @@ async function pickInjection(ctx: ExtensionCommandContext, current: boolean): Pr
|
|
|
243
239
|
return choice.startsWith("On");
|
|
244
240
|
}
|
|
245
241
|
|
|
246
|
-
const CONCURRENCY_STEPS = [1, 2, 3, 4, 6, 8, 12, 16];
|
|
247
|
-
const FIX_ROUNDS_STEPS = [0, 1, 2, 3, 5];
|
|
248
242
|
const IDLE_TIMEOUT_STEPS = [0, 30, 60, 90, 120, 180, 300, 600];
|
|
249
243
|
|
|
250
244
|
async function pickCount(
|
|
@@ -299,22 +293,6 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
|
|
|
299
293
|
if (injection === undefined) return false;
|
|
300
294
|
const scope = await pickScope(ctx, base.agentScope);
|
|
301
295
|
if (scope === undefined) return false;
|
|
302
|
-
const maxConcurrency = await pickCount(
|
|
303
|
-
ctx,
|
|
304
|
-
"Max sub-agents running at once?",
|
|
305
|
-
CONCURRENCY_STEPS,
|
|
306
|
-
base.maxConcurrency,
|
|
307
|
-
DEFAULT_MAX_CONCURRENCY,
|
|
308
|
-
);
|
|
309
|
-
if (maxConcurrency === undefined) return false;
|
|
310
|
-
const maxFixRounds = await pickCount(
|
|
311
|
-
ctx,
|
|
312
|
-
"Reviewer worker-fix rounds? (0 = no automatic fixes)",
|
|
313
|
-
FIX_ROUNDS_STEPS,
|
|
314
|
-
base.maxFixRounds,
|
|
315
|
-
DEFAULT_MAX_FIX_ROUNDS,
|
|
316
|
-
);
|
|
317
|
-
if (maxFixRounds === undefined) return false;
|
|
318
296
|
const idleTimeoutSec = await pickCount(
|
|
319
297
|
ctx,
|
|
320
298
|
"Idle timeout in seconds? (0 = disabled)",
|
|
@@ -333,16 +311,7 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
|
|
|
333
311
|
maxResultLines: base.maxResultLines,
|
|
334
312
|
proactiveInjection: injection,
|
|
335
313
|
agentScope: scope,
|
|
336
|
-
maxConcurrency,
|
|
337
|
-
maxFixRounds,
|
|
338
314
|
idleTimeoutSec,
|
|
339
|
-
// Full setup is an explicit decision point: mark role-enable migrations as
|
|
340
|
-
// processed so the user's saved selection is kept as-is.
|
|
341
|
-
announcedFeatures: [...new Set([
|
|
342
|
-
...base.announcedFeatures,
|
|
343
|
-
CLEANER_DEFAULTED_FEATURE,
|
|
344
|
-
DOCUMENTER_DEFAULTED_FEATURE,
|
|
345
|
-
])],
|
|
346
315
|
};
|
|
347
316
|
await saveConfig(next, configPath);
|
|
348
317
|
ctx.ui.notify(`pi-subagents configured with Auto thinking. Saved to ${configPath}`, "info");
|
|
@@ -357,8 +326,6 @@ async function updateRuntimeSetting(
|
|
|
357
326
|
const choice = await ctx.ui.select("Runtime setting", [
|
|
358
327
|
"Proactive injection",
|
|
359
328
|
"Agent scope",
|
|
360
|
-
"Max concurrency",
|
|
361
|
-
"Reviewer worker-fix rounds",
|
|
362
329
|
"Idle timeout",
|
|
363
330
|
]);
|
|
364
331
|
if (choice === undefined) return undefined;
|
|
@@ -371,14 +338,6 @@ async function updateRuntimeSetting(
|
|
|
371
338
|
const value = await pickScope(ctx, config.agentScope);
|
|
372
339
|
if (value === undefined) continue;
|
|
373
340
|
next.agentScope = value;
|
|
374
|
-
} else if (choice.startsWith("Max concurrency")) {
|
|
375
|
-
const value = await pickCount(ctx, "Max sub-agents running at once?", CONCURRENCY_STEPS, config.maxConcurrency, DEFAULT_MAX_CONCURRENCY);
|
|
376
|
-
if (value === undefined) continue;
|
|
377
|
-
next.maxConcurrency = value;
|
|
378
|
-
} else if (choice.startsWith("Reviewer")) {
|
|
379
|
-
const value = await pickCount(ctx, "Reviewer worker-fix rounds?", FIX_ROUNDS_STEPS, config.maxFixRounds, DEFAULT_MAX_FIX_ROUNDS);
|
|
380
|
-
if (value === undefined) continue;
|
|
381
|
-
next.maxFixRounds = value;
|
|
382
341
|
} else {
|
|
383
342
|
const value = await pickCount(ctx, "Idle timeout in seconds?", IDLE_TIMEOUT_STEPS, config.idleTimeoutSec, DEFAULT_IDLE_TIMEOUT_SEC);
|
|
384
343
|
if (value === undefined) continue;
|
package/src/spawn.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { createHash, randomUUID } from "node:crypto";
|
|
12
12
|
import { type Dirent, mkdirSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
13
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
13
|
+
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
14
14
|
import { tmpdir } from "node:os";
|
|
15
15
|
import { basename, join, resolve } from "node:path";
|
|
16
16
|
import type { Message } from "@earendil-works/pi-ai";
|
|
@@ -202,7 +202,7 @@ export function writeResultArtifact(output: string, agentName: string, cwd?: str
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
export function isFailedResult(result: SingleResult): boolean {
|
|
205
|
-
|
|
205
|
+
|
|
206
206
|
return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
|
207
207
|
}
|
|
208
208
|
|
|
@@ -272,7 +272,7 @@ export function isRetryableStartupFailure(result: SingleResult, durationMs: numb
|
|
|
272
272
|
}
|
|
273
273
|
|
|
274
274
|
export function formatStartupRetryExhaustedError(model: string, attempts: number): string {
|
|
275
|
-
return `Subagent failed to start after ${attempts} attempt${attempts === 1 ? "" : "s"} on ${model}: the child failed before its initial RPC prompt was dispatched and produced no model, tool, output, or usage activity. This is typically a concurrent pi startup race (several sub-agents starting at once). Retry the dispatch, or
|
|
275
|
+
return `Subagent failed to start after ${attempts} attempt${attempts === 1 ? "" : "s"} on ${model}: the child failed before its initial RPC prompt was dispatched and produced no model, tool, output, or usage activity. This is typically a concurrent pi startup race (several sub-agents starting at once). Retry the dispatch, or dispatch fewer sub-agents at once.`;
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
export async function waitForStartupRetry(delayMs: number, signal?: AbortSignal): Promise<boolean> {
|
|
@@ -308,12 +308,12 @@ async function waitForControlledRetry(
|
|
|
308
308
|
): Promise<boolean> {
|
|
309
309
|
let remaining = normalizeStartupRetryDelay(delayMs);
|
|
310
310
|
while (remaining > 0) {
|
|
311
|
-
if (control?.
|
|
311
|
+
if (control?.isStopRequested()) return false;
|
|
312
312
|
const slice = Math.min(remaining, 50);
|
|
313
313
|
if (!(await waitForStartupRetry(slice, signal))) return false;
|
|
314
314
|
remaining -= slice;
|
|
315
315
|
}
|
|
316
|
-
return !signal?.aborted && !control?.
|
|
316
|
+
return !signal?.aborted && !control?.isStopRequested();
|
|
317
317
|
}
|
|
318
318
|
|
|
319
319
|
export function getResultOutput(result: SingleResult): string {
|
|
@@ -330,6 +330,12 @@ export function buildResumePrompt(task: string, reason: string): string {
|
|
|
330
330
|
return `You are resuming an earlier sub-agent session after ${reason}. Your earlier work — searches, reads, edits, and reasoning — is preserved in this session's history above; review it before acting. Current objective: ${task}. Pick up exactly where you left off and finish it. Do NOT redo searches, reads, or edits you already completed unless a step clearly failed. Continue now.`;
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
+
/** Create a fresh private session directory under the given root. */
|
|
334
|
+
export async function createSessionDir(root: string = tmpdir()): Promise<string> {
|
|
335
|
+
await mkdir(root, { recursive: true });
|
|
336
|
+
return mkdtemp(join(root, "pi-subagent-session-"));
|
|
337
|
+
}
|
|
338
|
+
|
|
333
339
|
export function buildFallbackResumeReason(fromModel?: string): string {
|
|
334
340
|
return fromModel
|
|
335
341
|
? `the selected model (${fromModel}) failed at the model/provider level, so the current main model is continuing`
|
|
@@ -349,6 +355,10 @@ export interface RunSingleOptions {
|
|
|
349
355
|
startupRetryDelaysMs?: readonly number[];
|
|
350
356
|
sessionDir?: string;
|
|
351
357
|
sessionId?: string;
|
|
358
|
+
/** Parent directory for a fresh session directory. Defaults to the OS temp
|
|
359
|
+
* dir; dispatch passes the durable state root so retained sessions survive
|
|
360
|
+
* reloads and restarts. */
|
|
361
|
+
sessionRoot?: string;
|
|
352
362
|
/** Initial RPC prompt. Kept under the old name to limit caller churn. */
|
|
353
363
|
stdinText?: string;
|
|
354
364
|
/** Refresh parent-derived tools immediately before every startup retry and
|
|
@@ -366,7 +376,7 @@ export interface RunSingleOptions {
|
|
|
366
376
|
|
|
367
377
|
function controlledDisposition(options: RunSingleOptions, base?: SingleResult): SingleResult | undefined {
|
|
368
378
|
const control = options.control;
|
|
369
|
-
if (!control?.
|
|
379
|
+
if (!control?.isStopRequested()) return undefined;
|
|
370
380
|
const result: SingleResult = base ?? {
|
|
371
381
|
agent: options.agentName,
|
|
372
382
|
task: control.getObjective(),
|
|
@@ -380,23 +390,14 @@ function controlledDisposition(options: RunSingleOptions, base?: SingleResult):
|
|
|
380
390
|
sessionDir: options.sessionDir,
|
|
381
391
|
};
|
|
382
392
|
result.task = control.getObjective();
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
result.stopReason = undefined;
|
|
387
|
-
result.errorMessage = undefined;
|
|
388
|
-
} else {
|
|
389
|
-
result.parked = undefined;
|
|
390
|
-
result.exitCode = 1;
|
|
391
|
-
result.stopReason = "aborted";
|
|
392
|
-
result.errorMessage = control.getStopMessage();
|
|
393
|
-
}
|
|
393
|
+
result.exitCode = 1;
|
|
394
|
+
result.stopReason = "aborted";
|
|
395
|
+
result.errorMessage = control.getStopMessage();
|
|
394
396
|
return result;
|
|
395
397
|
}
|
|
396
398
|
|
|
397
399
|
function signalAbortDisposition(options: RunSingleOptions, base: SingleResult): SingleResult | undefined {
|
|
398
400
|
if (!options.signal?.aborted) return undefined;
|
|
399
|
-
base.parked = undefined;
|
|
400
401
|
base.exitCode = 1;
|
|
401
402
|
base.stopReason = "aborted";
|
|
402
403
|
base.errorMessage = "Subagent was aborted";
|
|
@@ -459,8 +460,17 @@ export async function runSingleAgentWithMainFallback(
|
|
|
459
460
|
const startupDelays = customStartupDelays ?? SUBAGENT_STARTUP_RETRY_DELAYS_MS;
|
|
460
461
|
|
|
461
462
|
const sessionId = options.sessionId ?? randomUUID();
|
|
462
|
-
const sessionDir = options.sessionDir ?? (await
|
|
463
|
+
const sessionDir = options.sessionDir ?? (await createSessionDir(options.sessionRoot));
|
|
463
464
|
const baseOptions: RunSingleOptions = { ...options, sessionDir, sessionId };
|
|
465
|
+
if (!options.sessionDir) {
|
|
466
|
+
// Surface the fresh session immediately so the dispatching thread can
|
|
467
|
+
// persist a durable checkpoint before the child settles.
|
|
468
|
+
try {
|
|
469
|
+
options.onLive?.({ kind: "session", sessionId, sessionDir });
|
|
470
|
+
} catch {
|
|
471
|
+
/* never throw from event handling */
|
|
472
|
+
}
|
|
473
|
+
}
|
|
464
474
|
|
|
465
475
|
const dispatchFailure = async (error: unknown): Promise<SingleResult> => {
|
|
466
476
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
@@ -489,14 +499,7 @@ export async function runSingleAgentWithMainFallback(
|
|
|
489
499
|
let retries = 0;
|
|
490
500
|
for (let attempt = 0; ; attempt++) {
|
|
491
501
|
const immediate = controlledDisposition(opts);
|
|
492
|
-
if (immediate)
|
|
493
|
-
if (immediate.parked && !options.sessionDir && !sessionExists(sessionDir, sessionId)) {
|
|
494
|
-
await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
|
|
495
|
-
immediate.sessionId = undefined;
|
|
496
|
-
immediate.sessionDir = undefined;
|
|
497
|
-
}
|
|
498
|
-
return immediate;
|
|
499
|
-
}
|
|
502
|
+
if (immediate) return immediate;
|
|
500
503
|
const start = Date.now();
|
|
501
504
|
try {
|
|
502
505
|
const attemptOptions = opts.resolveAgentForAttempt
|
|
@@ -510,7 +513,7 @@ export async function runSingleAgentWithMainFallback(
|
|
|
510
513
|
const durationMs = Date.now() - start;
|
|
511
514
|
const controlled = controlledDisposition(opts, lastResult);
|
|
512
515
|
if (controlled) return controlled;
|
|
513
|
-
if (lastResult.
|
|
516
|
+
if (lastResult.stopReason === "aborted") return lastResult;
|
|
514
517
|
if (!isRetryableStartupFailure(lastResult, durationMs)) {
|
|
515
518
|
if (retries > 0 && !isFailedResult(lastResult)) lastResult.startupRetries = retries;
|
|
516
519
|
return lastResult;
|
|
@@ -627,7 +630,7 @@ export async function runSingleAgentWithMainFallback(
|
|
|
627
630
|
}
|
|
628
631
|
|
|
629
632
|
result = await runWithStartupRetry(candidateOptions);
|
|
630
|
-
if (result.
|
|
633
|
+
if (result.stopReason === "aborted") return finish(result);
|
|
631
634
|
if (!isModelLevelFailure(result)) return finish(result);
|
|
632
635
|
// Any model-level failure advances immediately to the sole fallback (the
|
|
633
636
|
// current main model). Retain selected-attempt tool diagnostics and usage;
|