@ferris1225/pi-subagents 4.1.1 → 4.1.2
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 +506 -481
- package/agents/cleaner.md +14 -4
- package/agents/documenter.md +44 -0
- package/agents/reviewer.md +3 -2
- package/agents/worker.md +4 -1
- package/package.json +2 -2
- package/src/agents.ts +12 -0
- package/src/announcements.ts +18 -1
- package/src/config.ts +43 -13
- package/src/dispatch.ts +637 -704
- package/src/fixloop.ts +266 -52
- package/src/index.ts +3 -3
- package/src/monitor.ts +12 -3
- package/src/prompt.ts +47 -12
- package/src/rpc-run.ts +23 -7
- package/src/runtime.ts +8 -7
- package/src/setup.ts +24 -7
- package/src/spawn.ts +45 -11
- package/src/thread-lifecycle.ts +203 -49
- package/src/tools.ts +23 -9
- package/src/widget.ts +4 -4
- package/src/worktree.ts +27 -4
package/src/runtime.ts
CHANGED
|
@@ -58,9 +58,9 @@ export interface SubagentThread {
|
|
|
58
58
|
state: ThreadState;
|
|
59
59
|
control: RpcRunControl;
|
|
60
60
|
queueController?: AbortController;
|
|
61
|
-
/** Resolves only after the current generation's
|
|
62
|
-
*
|
|
63
|
-
*
|
|
61
|
+
/** Resolves only after the current generation's top-level child, downstream
|
|
62
|
+
* managed workflow, and queue work have fully quiesced and released their
|
|
63
|
+
* concurrency slot. */
|
|
64
64
|
generationCompletion: Promise<void>;
|
|
65
65
|
/** Synchronous CAS used by lifecycle controls across their async preflight. */
|
|
66
66
|
lifecycleVersion: number;
|
|
@@ -81,7 +81,8 @@ export interface SubagentThread {
|
|
|
81
81
|
fork: (objective?: string, ctx?: ExtensionContext) => Promise<SingleResult>;
|
|
82
82
|
forkedFromRunId?: number;
|
|
83
83
|
forkChildRunIds: number[];
|
|
84
|
-
/** Dispatch-owned, generation-guarded worktree settlement hook.
|
|
84
|
+
/** Dispatch-owned, generation-guarded worktree settlement hook. Its apply
|
|
85
|
+
* runs under the canonical original-repository lane. */
|
|
85
86
|
finalizeIsolation: (generation: number, result?: SingleResult) => Promise<WorktreeFinalization | undefined>;
|
|
86
87
|
/** Best-effort shutdown notification for retained integration artifacts. */
|
|
87
88
|
notifyIsolationFailure?: (finalization: WorktreeFinalization) => void;
|
|
@@ -110,7 +111,7 @@ export interface SubagentRuntime {
|
|
|
110
111
|
* next generation. Shutdown invalidates these claims and waits for cleanup. */
|
|
111
112
|
preflightOperations: Set<Promise<void>>;
|
|
112
113
|
/** Every session directory retained for this parent session, including
|
|
113
|
-
*
|
|
114
|
+
* managed-workflow internals that are not directly controllable. */
|
|
114
115
|
sessionDirs: Set<string>;
|
|
115
116
|
retainSession: (result: Pick<SingleResult, "sessionDir">) => void;
|
|
116
117
|
retireThreadSession: (thread: SubagentThread) => void;
|
|
@@ -134,8 +135,8 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
134
135
|
// Computing this at delivery (emit) time — not when the item was
|
|
135
136
|
// pushed — reflects the current monitor state, since finishing runs
|
|
136
137
|
// are removed from the monitor before their completion is pushed.
|
|
137
|
-
//
|
|
138
|
-
//
|
|
138
|
+
// Managed-workflow parents remain "running" through documenter,
|
|
139
|
+
// reviewer, and any fix rounds, so they are included without a special case.
|
|
139
140
|
const active = monitor
|
|
140
141
|
.getRuns()
|
|
141
142
|
.filter((run) => isRunActiveStatus(run.status))
|
package/src/setup.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
AGENT_SCOPE_VALUES,
|
|
15
15
|
BUILTIN_AGENT_NAMES,
|
|
16
16
|
CLEANER_DEFAULTED_FEATURE,
|
|
17
|
+
DOCUMENTER_DEFAULTED_FEATURE,
|
|
17
18
|
DEFAULT_CONFIG,
|
|
18
19
|
DEFAULT_ENABLED_AGENTS,
|
|
19
20
|
DEFAULT_IDLE_TIMEOUT_SEC,
|
|
@@ -61,7 +62,8 @@ function actualAgentThinkingDefault(
|
|
|
61
62
|
const MODULE_HINTS: Record<string, string> = {
|
|
62
63
|
explorer: "read-only codebase recon (fast model)",
|
|
63
64
|
worker: "implement / fix / refactor / test (full tools)",
|
|
64
|
-
cleaner: "
|
|
65
|
+
cleaner: "apply proven cleanup and deduplicate code (full tools)",
|
|
66
|
+
documenter: "sync diff or whole-codebase comments/docs (full tools)",
|
|
65
67
|
reviewer: "read-only audits and pre-commit gates",
|
|
66
68
|
};
|
|
67
69
|
|
|
@@ -303,7 +305,7 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
|
|
|
303
305
|
if (maxConcurrency === undefined) return notifyCancelled(ctx);
|
|
304
306
|
const maxFixRounds = await pickCount(
|
|
305
307
|
ctx,
|
|
306
|
-
"Reviewer
|
|
308
|
+
"Reviewer worker-fix rounds? (0 = no automatic fixes)",
|
|
307
309
|
FIX_ROUNDS_STEPS,
|
|
308
310
|
base.maxFixRounds,
|
|
309
311
|
DEFAULT_MAX_FIX_ROUNDS,
|
|
@@ -330,9 +332,13 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
|
|
|
330
332
|
maxConcurrency,
|
|
331
333
|
maxFixRounds,
|
|
332
334
|
idleTimeoutSec,
|
|
333
|
-
// Full setup is an explicit decision point: mark
|
|
334
|
-
//
|
|
335
|
-
announcedFeatures: [...new Set([
|
|
335
|
+
// Full setup is an explicit decision point: mark role-enable migrations as
|
|
336
|
+
// processed so the user's saved selection is kept as-is.
|
|
337
|
+
announcedFeatures: [...new Set([
|
|
338
|
+
...base.announcedFeatures,
|
|
339
|
+
CLEANER_DEFAULTED_FEATURE,
|
|
340
|
+
DOCUMENTER_DEFAULTED_FEATURE,
|
|
341
|
+
])],
|
|
336
342
|
};
|
|
337
343
|
await saveConfig(next, configPath);
|
|
338
344
|
ctx.ui.notify(`pi-subagents configured with Auto thinking. Saved to ${configPath}`, "info");
|
|
@@ -346,7 +352,7 @@ async function updateRuntimeSetting(
|
|
|
346
352
|
"Proactive injection",
|
|
347
353
|
"Agent scope",
|
|
348
354
|
"Max concurrency",
|
|
349
|
-
"Reviewer
|
|
355
|
+
"Reviewer worker-fix rounds",
|
|
350
356
|
"Idle timeout",
|
|
351
357
|
]);
|
|
352
358
|
if (choice === undefined) return undefined;
|
|
@@ -364,7 +370,7 @@ async function updateRuntimeSetting(
|
|
|
364
370
|
if (value === undefined) return undefined;
|
|
365
371
|
next.maxConcurrency = value;
|
|
366
372
|
} else if (choice.startsWith("Reviewer")) {
|
|
367
|
-
const value = await pickCount(ctx, "Reviewer
|
|
373
|
+
const value = await pickCount(ctx, "Reviewer worker-fix rounds?", FIX_ROUNDS_STEPS, config.maxFixRounds, DEFAULT_MAX_FIX_ROUNDS);
|
|
368
374
|
if (value === undefined) return undefined;
|
|
369
375
|
next.maxFixRounds = value;
|
|
370
376
|
} else {
|
|
@@ -405,6 +411,17 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
|
|
|
405
411
|
next.agentThinkingLevels.cleaner = config.agentThinkingLevels.reviewer;
|
|
406
412
|
}
|
|
407
413
|
}
|
|
414
|
+
// Documenter intentionally follows the faster explorer route. Fresh
|
|
415
|
+
// installs leave it unselected; enabling it later inherits any explorer
|
|
416
|
+
// overrides instead of silently choosing a stronger model.
|
|
417
|
+
if (!config.enabledAgents.includes("documenter") && enabled.includes("documenter")) {
|
|
418
|
+
if (!next.agentModels.documenter && config.agentModels.explorer) {
|
|
419
|
+
next.agentModels.documenter = config.agentModels.explorer;
|
|
420
|
+
}
|
|
421
|
+
if (!next.agentThinkingLevels.documenter && config.agentThinkingLevels.explorer) {
|
|
422
|
+
next.agentThinkingLevels.documenter = config.agentThinkingLevels.explorer;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
408
425
|
next.agentModels = keepAgentEntries(next.agentModels, enabled);
|
|
409
426
|
next.agentThinkingLevels = keepAgentEntries(next.agentThinkingLevels, enabled);
|
|
410
427
|
} else if (choice.startsWith("Configure")) {
|
package/src/spawn.ts
CHANGED
|
@@ -49,8 +49,23 @@ export type { SubagentLiveEvent, UsageStats };
|
|
|
49
49
|
export const SUBAGENT_THINKING_LEVEL: ThinkingLevel = DEFAULT_THINKING_LEVEL;
|
|
50
50
|
/** 0 disables the watchdog; dispatch supplies the configured timeout. */
|
|
51
51
|
export const SUBAGENT_DEFAULT_IDLE_TIMEOUT_MS = 0;
|
|
52
|
-
|
|
52
|
+
/** Base delays cover Pi's stale-lock window and leave headroom beyond the
|
|
53
|
+
* default four-way launch fan-out. Additive jitter below reduces the chance
|
|
54
|
+
* that contenders retry in the same lockstep waves. */
|
|
55
|
+
export const SUBAGENT_STARTUP_RETRY_DELAYS_MS = [250, 750, 1500, 3000, 6000] as const;
|
|
53
56
|
export const MAX_SUBAGENT_STARTUP_FAILURE_DURATION_MS = 2000;
|
|
57
|
+
export const MAX_SUBAGENT_STARTUP_RETRY_JITTER_MS = 1000;
|
|
58
|
+
|
|
59
|
+
function normalizeStartupRetryDelay(delayMs: number): number {
|
|
60
|
+
return Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function addStartupRetryJitter(delayMs: number, randomValue = Math.random()): number {
|
|
64
|
+
const baseDelay = Math.floor(normalizeStartupRetryDelay(delayMs));
|
|
65
|
+
if (baseDelay === 0) return 0;
|
|
66
|
+
const boundedRandom = Number.isFinite(randomValue) ? Math.max(0, Math.min(1, randomValue)) : 0;
|
|
67
|
+
return baseDelay + Math.floor(Math.min(baseDelay, MAX_SUBAGENT_STARTUP_RETRY_JITTER_MS) * boundedRandom);
|
|
68
|
+
}
|
|
54
69
|
|
|
55
70
|
export interface SingleResult extends RpcSingleResult {}
|
|
56
71
|
|
|
@@ -204,10 +219,17 @@ export function isModelLevelFailure(result: SingleResult): boolean {
|
|
|
204
219
|
if (result.stopReason === "aborted") return false;
|
|
205
220
|
if (result.dispatchFailed) return false;
|
|
206
221
|
if (result.rpcStartupFailed) return false;
|
|
222
|
+
// A negative RPC response proves Pi rejected the prompt before execution. It
|
|
223
|
+
// remains safe to hand off even though dispatch was attempted; local write,
|
|
224
|
+
// close, timeout, and lost-ACK failures never set this explicit flag.
|
|
225
|
+
if (result.rpcPromptRejected) return true;
|
|
226
|
+
// The prompt write completed but its ACK never arrived. With no later activity
|
|
227
|
+
// we cannot know whether Pi started the model or tools, so neither startup
|
|
228
|
+
// retry nor selected→main fallback may replay this objective.
|
|
229
|
+
if (result.rpcPromptDispatched && !result.rpcPromptAccepted && !result.rpcActivity) return false;
|
|
207
230
|
if (isRpcCommandTimeoutError(result.errorMessage)) return false;
|
|
208
231
|
if (result.integrationStatus === "retained") return false;
|
|
209
232
|
if (result.errorMessage?.includes("idle timeout")) return true;
|
|
210
|
-
if (result.rpcPromptRejected) return true;
|
|
211
233
|
|
|
212
234
|
// Classification belongs to the final assistant turn, not the whole attempt.
|
|
213
235
|
// Earlier useful text or failed tool calls are retained session history and
|
|
@@ -236,7 +258,7 @@ export function isRetryableStartupFailure(result: SingleResult, durationMs: numb
|
|
|
236
258
|
if (result.exitCode === 0) return false;
|
|
237
259
|
if (result.stopReason === "aborted") return false;
|
|
238
260
|
if (result.dispatchFailed) return false;
|
|
239
|
-
if (result.rpcPromptAccepted || result.rpcActivity) return false;
|
|
261
|
+
if (result.rpcPromptDispatched || result.rpcPromptAccepted || result.rpcActivity) return false;
|
|
240
262
|
if (result.errorMessage?.includes("idle timeout")) return false;
|
|
241
263
|
if (getFinalOutput(result.messages)) return false;
|
|
242
264
|
if (result.messages.length > 0) return false;
|
|
@@ -250,14 +272,15 @@ export function isRetryableStartupFailure(result: SingleResult, durationMs: numb
|
|
|
250
272
|
}
|
|
251
273
|
|
|
252
274
|
export function formatStartupRetryExhaustedError(model: string, attempts: number): string {
|
|
253
|
-
return `Subagent failed to start after ${attempts} attempt${attempts === 1 ? "" : "s"} on ${model}: the child
|
|
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 temporarily lower maxConcurrency in /subagents-setup.`;
|
|
254
276
|
}
|
|
255
277
|
|
|
256
278
|
export async function waitForStartupRetry(delayMs: number, signal?: AbortSignal): Promise<boolean> {
|
|
257
|
-
|
|
279
|
+
const normalizedDelay = normalizeStartupRetryDelay(delayMs);
|
|
280
|
+
if (normalizedDelay === 0) return !signal?.aborted;
|
|
258
281
|
if (!signal) {
|
|
259
282
|
return new Promise<boolean>((resolve) => {
|
|
260
|
-
const timer = setTimeout(() => resolve(true),
|
|
283
|
+
const timer = setTimeout(() => resolve(true), normalizedDelay);
|
|
261
284
|
if (typeof timer.unref === "function") timer.unref();
|
|
262
285
|
});
|
|
263
286
|
}
|
|
@@ -272,7 +295,7 @@ export async function waitForStartupRetry(delayMs: number, signal?: AbortSignal)
|
|
|
272
295
|
resolve(shouldRetry);
|
|
273
296
|
};
|
|
274
297
|
const onAbort = (): void => finish(false);
|
|
275
|
-
const timer = setTimeout(() => finish(true),
|
|
298
|
+
const timer = setTimeout(() => finish(true), normalizedDelay);
|
|
276
299
|
if (typeof timer.unref === "function") timer.unref();
|
|
277
300
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
278
301
|
});
|
|
@@ -283,7 +306,7 @@ async function waitForControlledRetry(
|
|
|
283
306
|
signal: AbortSignal | undefined,
|
|
284
307
|
control: RpcRunControl | undefined,
|
|
285
308
|
): Promise<boolean> {
|
|
286
|
-
let remaining = delayMs;
|
|
309
|
+
let remaining = normalizeStartupRetryDelay(delayMs);
|
|
287
310
|
while (remaining > 0) {
|
|
288
311
|
if (control?.isParkRequested() || control?.isStopRequested()) return false;
|
|
289
312
|
const slice = Math.min(remaining, 50);
|
|
@@ -368,6 +391,15 @@ function controlledDisposition(options: RunSingleOptions, base?: SingleResult):
|
|
|
368
391
|
return result;
|
|
369
392
|
}
|
|
370
393
|
|
|
394
|
+
function signalAbortDisposition(options: RunSingleOptions, base: SingleResult): SingleResult | undefined {
|
|
395
|
+
if (!options.signal?.aborted) return undefined;
|
|
396
|
+
base.parked = undefined;
|
|
397
|
+
base.exitCode = 1;
|
|
398
|
+
base.stopReason = "aborted";
|
|
399
|
+
base.errorMessage = "Subagent was aborted";
|
|
400
|
+
return base;
|
|
401
|
+
}
|
|
402
|
+
|
|
371
403
|
/** Spawn one RPC attempt and wait for stable settlement. */
|
|
372
404
|
export async function runSingleAgent(options: RunSingleOptions): Promise<SingleResult> {
|
|
373
405
|
const {
|
|
@@ -420,7 +452,8 @@ export async function runSingleAgentWithMainFallback(
|
|
|
420
452
|
): Promise<SingleResult> {
|
|
421
453
|
const agent = options.agent;
|
|
422
454
|
const launchedRef = agent?.model;
|
|
423
|
-
const
|
|
455
|
+
const customStartupDelays = options.startupRetryDelaysMs;
|
|
456
|
+
const startupDelays = customStartupDelays ?? SUBAGENT_STARTUP_RETRY_DELAYS_MS;
|
|
424
457
|
|
|
425
458
|
const sessionId = options.sessionId ?? randomUUID();
|
|
426
459
|
const sessionDir = options.sessionDir ?? (await mkdtemp(join(tmpdir(), "pi-subagent-session-")));
|
|
@@ -492,8 +525,9 @@ export async function runSingleAgentWithMainFallback(
|
|
|
492
525
|
} catch {
|
|
493
526
|
/* never throw from event handling */
|
|
494
527
|
}
|
|
495
|
-
|
|
496
|
-
|
|
528
|
+
const retryDelay = customStartupDelays ? delay : addStartupRetryJitter(delay);
|
|
529
|
+
if (!(await waitForControlledRetry(retryDelay, opts.signal, opts.control))) {
|
|
530
|
+
return controlledDisposition(opts, lastResult) ?? signalAbortDisposition(opts, lastResult) ?? lastResult;
|
|
497
531
|
}
|
|
498
532
|
retries++;
|
|
499
533
|
}
|
package/src/thread-lifecycle.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Stable logical-thread generation lifecycle for background sub-agents.
|
|
3
3
|
*
|
|
4
|
-
* Dispatch owns
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Dispatch owns workflow policy and internal role briefs; this module owns one
|
|
5
|
+
* stable parent generation end to end: managed-repository lane use,
|
|
6
|
+
* worktree setup/finalization after downstream review, queue/process ownership,
|
|
7
|
+
* retained-session resume/fork, and guarded one-time terminal publication.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
11
|
import { existsSync } from "node:fs";
|
|
11
12
|
import { rm } from "node:fs/promises";
|
|
12
13
|
import { resolve } from "node:path";
|
|
13
|
-
import { discoverAgents, type AgentConfig } from "./agents.ts";
|
|
14
|
+
import { discoverAgents, isWriteCapableAgent, type AgentConfig } from "./agents.ts";
|
|
14
15
|
import { completionTriggersTurn, type CompletionMessageItem } from "./completion.ts";
|
|
15
16
|
import {
|
|
16
17
|
DEFAULT_THINKING_LEVEL,
|
|
@@ -25,7 +26,15 @@ import {
|
|
|
25
26
|
modelLevelTakeoverNote,
|
|
26
27
|
queuedResult,
|
|
27
28
|
} from "./format.ts";
|
|
28
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
canStartManagedWorkflow,
|
|
31
|
+
formatChainSummary,
|
|
32
|
+
formatManagedWorkflowSummary,
|
|
33
|
+
getManagedWorkflowPlan,
|
|
34
|
+
workflowAgentAvailability,
|
|
35
|
+
type ManagedWorkflowOutcome,
|
|
36
|
+
type ManagedWorkflowPlan,
|
|
37
|
+
} from "./fixloop.ts";
|
|
29
38
|
import {
|
|
30
39
|
availableModelsInScope,
|
|
31
40
|
currentModelRef,
|
|
@@ -34,15 +43,17 @@ import {
|
|
|
34
43
|
resolveAgentModelRoute,
|
|
35
44
|
resolveThinkingLevel,
|
|
36
45
|
} from "./models.ts";
|
|
37
|
-
import { monitor } from "./monitor.ts";
|
|
46
|
+
import { monitor, sumUsage } from "./monitor.ts";
|
|
38
47
|
import { persistRecoveryRecords, recoveryRecordFromFinalization } from "./recovery.ts";
|
|
39
48
|
import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts";
|
|
40
49
|
import { forkRetainedSession } from "./session-fork.ts";
|
|
41
50
|
import {
|
|
42
51
|
buildResumePrompt,
|
|
52
|
+
getResultOutput,
|
|
43
53
|
RpcRunControl,
|
|
44
54
|
isFailedResult,
|
|
45
55
|
isModelLevelFailure,
|
|
56
|
+
reviewVerdict,
|
|
46
57
|
runSingleAgentWithMainFallback,
|
|
47
58
|
type SingleResult,
|
|
48
59
|
type SubagentDetails,
|
|
@@ -61,7 +72,7 @@ export const FORK_CONTINUATION_PROMPT =
|
|
|
61
72
|
const WORKTREE_ISOLATION_INSTRUCTIONS =
|
|
62
73
|
"You are running in a temporary detached Git worktree. Work only in the current cwd; do not create another worktree or manually copy/apply changes to the original checkout. The parent dispatcher will integrate your tracked, deleted, and untracked changes when this thread finally settles.";
|
|
63
74
|
|
|
64
|
-
function withWorktreeSystemPrompt(agent: AgentConfig): AgentConfig {
|
|
75
|
+
export function withWorktreeSystemPrompt(agent: AgentConfig): AgentConfig {
|
|
65
76
|
return {
|
|
66
77
|
...agent,
|
|
67
78
|
systemPrompt: `${agent.systemPrompt.trimEnd()}\n\n${WORKTREE_ISOLATION_INSTRUCTIONS}`.trim(),
|
|
@@ -69,10 +80,7 @@ function withWorktreeSystemPrompt(agent: AgentConfig): AgentConfig {
|
|
|
69
80
|
}
|
|
70
81
|
|
|
71
82
|
export function isWorktreeCapableAgent(agent: AgentConfig): boolean {
|
|
72
|
-
|
|
73
|
-
if (agent.name === "worker") return true;
|
|
74
|
-
if (!agent.tools) return true;
|
|
75
|
-
return agent.tools.includes("edit") || agent.tools.includes("write");
|
|
83
|
+
return isWriteCapableAgent(agent);
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
interface DispatchEnvironment {
|
|
@@ -116,6 +124,23 @@ export function resolveDispatchModelRoute(
|
|
|
116
124
|
};
|
|
117
125
|
}
|
|
118
126
|
|
|
127
|
+
export interface ManagedWorkflowRequest extends DispatchEnvironment {
|
|
128
|
+
plan: ManagedWorkflowPlan;
|
|
129
|
+
initialResult: SingleResult;
|
|
130
|
+
groupId: string;
|
|
131
|
+
parentRunId: number;
|
|
132
|
+
executionCwd: string;
|
|
133
|
+
projectCwd: string;
|
|
134
|
+
isolation: IsolationMode;
|
|
135
|
+
signal: AbortSignal;
|
|
136
|
+
rememberLatest: (result: SingleResult) => void;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface ManagedRepositoryLaneRunner {
|
|
140
|
+
<T>(cwd: string, task: () => Promise<T>): Promise<T>;
|
|
141
|
+
<T>(cwd: string, task: () => Promise<T>, signal: AbortSignal): Promise<T | undefined>;
|
|
142
|
+
}
|
|
143
|
+
|
|
119
144
|
interface BackgroundDispatcherOptions extends DispatchEnvironment {
|
|
120
145
|
runtime: SubagentRuntime;
|
|
121
146
|
finishRun: (
|
|
@@ -131,12 +156,8 @@ interface BackgroundDispatcherOptions extends DispatchEnvironment {
|
|
|
131
156
|
mode: "single" | "parallel",
|
|
132
157
|
background?: boolean,
|
|
133
158
|
) => (results: SingleResult[]) => SubagentDetails;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
parentGroupId: string,
|
|
137
|
-
parentRunId: number,
|
|
138
|
-
executionCwd: string,
|
|
139
|
-
) => void;
|
|
159
|
+
runManagedWorkflow: (request: ManagedWorkflowRequest) => Promise<ManagedWorkflowOutcome>;
|
|
160
|
+
runInManagedRepositoryLane: ManagedRepositoryLaneRunner;
|
|
140
161
|
}
|
|
141
162
|
|
|
142
163
|
type BackgroundStarter = (
|
|
@@ -155,7 +176,8 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
155
176
|
finishRun,
|
|
156
177
|
makeLiveHandler,
|
|
157
178
|
makeDetails,
|
|
158
|
-
|
|
179
|
+
runManagedWorkflow,
|
|
180
|
+
runInManagedRepositoryLane,
|
|
159
181
|
} = options;
|
|
160
182
|
interface SessionSeed {
|
|
161
183
|
sessionId?: string;
|
|
@@ -221,7 +243,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
221
243
|
if (!agent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
|
|
222
244
|
if (isolation === "worktree" && !isWorktreeCapableAgent(agent)) {
|
|
223
245
|
return {
|
|
224
|
-
...failedStartResult(agentName, task, `Agent "${agentName}" is read-only; worktree isolation is available only to write-capable agents such as worker or
|
|
246
|
+
...failedStartResult(agentName, task, `Agent "${agentName}" is read-only; worktree isolation is available only to write-capable agents such as worker, cleaner, or documenter.`),
|
|
225
247
|
isolation,
|
|
226
248
|
};
|
|
227
249
|
}
|
|
@@ -365,13 +387,22 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
365
387
|
"error",
|
|
366
388
|
);
|
|
367
389
|
};
|
|
390
|
+
const generationWorktree = worktree;
|
|
391
|
+
let generationFinalization: Promise<WorktreeFinalization> | undefined;
|
|
368
392
|
thread.finalizeIsolation = async (
|
|
369
393
|
expectedGeneration: number,
|
|
370
394
|
result?: SingleResult,
|
|
371
395
|
): Promise<WorktreeFinalization | undefined> => {
|
|
372
|
-
if (thread.isolation !== "worktree" || !
|
|
373
|
-
if (thread.generation !== expectedGeneration) return undefined;
|
|
374
|
-
|
|
396
|
+
if (thread.isolation !== "worktree" || !generationWorktree) return undefined;
|
|
397
|
+
if (thread.generation !== expectedGeneration || thread.worktree !== generationWorktree) return undefined;
|
|
398
|
+
// All normal, destructive-stop, and shutdown owners converge here. Cache
|
|
399
|
+
// the lane-protected apply itself so superseding lifecycle paths can project
|
|
400
|
+
// the same finalization onto their own result without acquiring twice.
|
|
401
|
+
generationFinalization ??= runInManagedRepositoryLane(
|
|
402
|
+
generationWorktree.originalRoot,
|
|
403
|
+
() => generationWorktree.finalize(),
|
|
404
|
+
);
|
|
405
|
+
const finalization = await generationFinalization;
|
|
375
406
|
monitor.setIsolation(runId, "worktree", finalization.status);
|
|
376
407
|
if (result) {
|
|
377
408
|
result.runId = runId;
|
|
@@ -498,8 +529,8 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
498
529
|
runtime.backgroundQueue.cancel(controller);
|
|
499
530
|
} else {
|
|
500
531
|
await thread.control.park();
|
|
501
|
-
//
|
|
502
|
-
//
|
|
532
|
+
// A managed downstream child does not attach to the top-level RPC
|
|
533
|
+
// control after that child settles, so cancel its queue owner explicitly.
|
|
503
534
|
if (phase === "settled") runtime.backgroundQueue.cancel(controller);
|
|
504
535
|
}
|
|
505
536
|
await completion;
|
|
@@ -835,8 +866,10 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
835
866
|
};
|
|
836
867
|
|
|
837
868
|
const onLive = makeLiveHandler(runId, generation);
|
|
838
|
-
const
|
|
839
|
-
|
|
869
|
+
const workflowAvailability = workflowAgentAvailability(runAgents);
|
|
870
|
+
const reserveManagedLane =
|
|
871
|
+
isolation === "shared" && canStartManagedWorkflow(agent, workflowAvailability);
|
|
872
|
+
const runGeneration = async (backgroundSignal: AbortSignal): Promise<void> => {
|
|
840
873
|
if (runtime.threads.get(runId)?.generation !== generation) return;
|
|
841
874
|
let result: SingleResult;
|
|
842
875
|
try {
|
|
@@ -887,8 +920,6 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
887
920
|
result.isolation = isolation;
|
|
888
921
|
result.forkedFromRunId = thread.forkedFromRunId;
|
|
889
922
|
result.forkChildRunIds = [...thread.forkChildRunIds];
|
|
890
|
-
thread.queueController = undefined;
|
|
891
|
-
runtime.runControllers.delete(runId);
|
|
892
923
|
thread.task = result.task;
|
|
893
924
|
thread.sessionId = result.sessionId;
|
|
894
925
|
thread.sessionDir = result.sessionDir;
|
|
@@ -897,6 +928,11 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
897
928
|
monitor.setModel(runId, result.model, result.modelFallbackFrom);
|
|
898
929
|
monitor.setThinking(runId, result.thinking);
|
|
899
930
|
|
|
931
|
+
const lifecycleInterrupted = (): boolean =>
|
|
932
|
+
thread.lifecycleOperation === "park" ||
|
|
933
|
+
thread.lifecycleOperation === "stop" ||
|
|
934
|
+
thread.state === "parked" ||
|
|
935
|
+
thread.state === "stopped";
|
|
900
936
|
// Destructive stop owns publication once it has synchronously claimed
|
|
901
937
|
// the lifecycle. Leave the partial result/session on the thread; the
|
|
902
938
|
// stop path waits for this queue task, finalizes isolation, and emits
|
|
@@ -909,17 +945,68 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
909
945
|
runtime.settledRuns.delete(runId);
|
|
910
946
|
return;
|
|
911
947
|
}
|
|
948
|
+
// A park/shutdown can win in the microtask gap after the top-level RPC
|
|
949
|
+
// settles. Do not launch an obsolete documenter/reviewer or replace the
|
|
950
|
+
// stable top-level session with an aborted downstream attempt.
|
|
951
|
+
if (backgroundSignal.aborted || lifecycleInterrupted() || !runtime.sessionActive) return;
|
|
912
952
|
|
|
913
953
|
if (thread.retireOnSettle) runtime.retireThreadSession(thread);
|
|
914
|
-
|
|
915
|
-
|
|
954
|
+
let workflowOutcome: ManagedWorkflowOutcome | undefined;
|
|
955
|
+
const workflowPlan = getManagedWorkflowPlan(result, runConfig, workflowAvailability);
|
|
956
|
+
if (workflowPlan && runtime.sessionActive) {
|
|
916
957
|
thread.state = "running";
|
|
917
|
-
// The
|
|
918
|
-
//
|
|
958
|
+
// The stable parent row remains active until every internal writer and
|
|
959
|
+
// reviewer settles. Internal rows are independently queryable but never
|
|
960
|
+
// enter this top-level lifecycle or publish completions.
|
|
919
961
|
monitor.setStatus(runId, "running");
|
|
920
|
-
monitor.setActivity(
|
|
921
|
-
|
|
922
|
-
|
|
962
|
+
monitor.setActivity(
|
|
963
|
+
runId,
|
|
964
|
+
workflowPlan.kind === "auto-fix" ? "auto-fix chain running" : "managed workflow running",
|
|
965
|
+
);
|
|
966
|
+
workflowOutcome = await runManagedWorkflow({
|
|
967
|
+
plan: workflowPlan,
|
|
968
|
+
initialResult: result,
|
|
969
|
+
groupId: `workflow-${runId}`,
|
|
970
|
+
parentRunId: runId,
|
|
971
|
+
executionCwd: thread.executionCwd,
|
|
972
|
+
projectCwd: originalCwd,
|
|
973
|
+
isolation,
|
|
974
|
+
signal: backgroundSignal,
|
|
975
|
+
ctx: runCtx,
|
|
976
|
+
config: runConfig,
|
|
977
|
+
agents: runAgents,
|
|
978
|
+
rememberLatest: (latest) => {
|
|
979
|
+
if (runtime.threads.get(runId) !== thread || thread.generation !== generation) return;
|
|
980
|
+
thread.lastResult = latest;
|
|
981
|
+
thread.agentName = latest.agent;
|
|
982
|
+
monitor.setAgent(runId, latest.agent);
|
|
983
|
+
thread.task = latest.task;
|
|
984
|
+
thread.sessionId = latest.sessionId;
|
|
985
|
+
thread.sessionDir = latest.sessionDir;
|
|
986
|
+
runtime.retainSession(latest);
|
|
987
|
+
},
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
// Park/stop/shutdown owns this generation once it cancels the queue
|
|
991
|
+
// signal. The newest internal partial is already on thread.lastResult;
|
|
992
|
+
// never replace it with the old top-level result or publish stale output.
|
|
993
|
+
if (backgroundSignal.aborted || lifecycleInterrupted() || !runtime.sessionActive) return;
|
|
994
|
+
|
|
995
|
+
const finalStep = workflowOutcome.steps[workflowOutcome.steps.length - 1]!;
|
|
996
|
+
result = {
|
|
997
|
+
...finalStep.result,
|
|
998
|
+
runId,
|
|
999
|
+
projectCwd: originalCwd,
|
|
1000
|
+
isolation,
|
|
1001
|
+
forkedFromRunId: thread.forkedFromRunId,
|
|
1002
|
+
forkChildRunIds: [...thread.forkChildRunIds],
|
|
1003
|
+
};
|
|
1004
|
+
thread.lastResult = result;
|
|
1005
|
+
thread.agentName = result.agent;
|
|
1006
|
+
thread.task = result.task;
|
|
1007
|
+
thread.sessionId = result.sessionId;
|
|
1008
|
+
thread.sessionDir = result.sessionDir;
|
|
1009
|
+
runtime.retainSession(result);
|
|
923
1010
|
}
|
|
924
1011
|
// Claim terminal settlement synchronously before the first slow await.
|
|
925
1012
|
// Park therefore either wins while RPC is still active, or is rejected
|
|
@@ -934,12 +1021,20 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
934
1021
|
thread.lifecycleOperation === "settle" &&
|
|
935
1022
|
!thread.retired;
|
|
936
1023
|
try {
|
|
937
|
-
//
|
|
938
|
-
//
|
|
939
|
-
//
|
|
940
|
-
// the same worktree early.
|
|
1024
|
+
// For isolated writers this is deliberately after the managed documenter
|
|
1025
|
+
// and reviewer stages: every child sees the same worktree, then one
|
|
1026
|
+
// lifecycle owner integrates the complete writer+docs state exactly once.
|
|
941
1027
|
await thread.finalizeIsolation(generation, result);
|
|
942
1028
|
if (!ownsSettlement()) return;
|
|
1029
|
+
if (workflowOutcome && isolation === "worktree") {
|
|
1030
|
+
for (const step of workflowOutcome.steps) {
|
|
1031
|
+
step.result.integrationStatus = result.integrationStatus;
|
|
1032
|
+
step.result.integrationApplied = result.integrationApplied;
|
|
1033
|
+
step.result.integrationError = result.integrationError;
|
|
1034
|
+
step.result.integrationWorktreePath = result.integrationWorktreePath;
|
|
1035
|
+
step.result.integrationPatchPath = result.integrationPatchPath;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
943
1038
|
|
|
944
1039
|
const failed = isFailedResult(result);
|
|
945
1040
|
thread.state = failed ? "failed" : "completed";
|
|
@@ -950,8 +1045,39 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
950
1045
|
|
|
951
1046
|
const modelLevel = failed && isModelLevelFailure(result);
|
|
952
1047
|
const dispatchFailed = result.dispatchFailed === true;
|
|
953
|
-
|
|
1048
|
+
const ownedController = thread.queueController;
|
|
1049
|
+
if (runtime.runControllers.get(runId) === ownedController) runtime.runControllers.delete(runId);
|
|
1050
|
+
thread.queueController = undefined;
|
|
1051
|
+
finishRun(
|
|
1052
|
+
runId,
|
|
1053
|
+
failed ? "failed" : "done",
|
|
1054
|
+
workflowOutcome || modelLevel || dispatchFailed ? { silent: true } : undefined,
|
|
1055
|
+
);
|
|
954
1056
|
runtime.registerRunResult(runId, result);
|
|
1057
|
+
|
|
1058
|
+
if (workflowOutcome) {
|
|
1059
|
+
const lastStep = workflowOutcome.steps[workflowOutcome.steps.length - 1]!;
|
|
1060
|
+
let block = workflowOutcome.kind === "auto-fix"
|
|
1061
|
+
? formatChainSummary(workflowOutcome.steps, result)
|
|
1062
|
+
: formatManagedWorkflowSummary(workflowOutcome.steps, result);
|
|
1063
|
+
const finalVerdict = lastStep.result.agent === "reviewer"
|
|
1064
|
+
? reviewVerdict(getResultOutput(lastStep.result))
|
|
1065
|
+
: undefined;
|
|
1066
|
+
const needsFullFinal = failed || (lastStep.result.agent === "reviewer" && finalVerdict !== "pass");
|
|
1067
|
+
if (needsFullFinal) {
|
|
1068
|
+
block += `\n\n${formatCompletionBlock(result, runConfig.maxResultLines, originalCwd)}`;
|
|
1069
|
+
}
|
|
1070
|
+
if (modelLevel) block += `\n\n${modelLevelTakeoverNote(result, { runId })}`;
|
|
1071
|
+
runtime.sendCompletionGroup([{
|
|
1072
|
+
agent: `${workflowOutcome.kind === "auto-fix" ? "auto-fix chain" : "managed workflow"} (${result.agent})`,
|
|
1073
|
+
block,
|
|
1074
|
+
triggerTurn: true,
|
|
1075
|
+
usage: sumUsage(workflowOutcome.steps.map((step) => step.result.usage)),
|
|
1076
|
+
}]);
|
|
1077
|
+
runtime.completionBatcher.flush();
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
955
1081
|
const completion: CompletionMessageItem = {
|
|
956
1082
|
agent: result.agent,
|
|
957
1083
|
block: modelLevel
|
|
@@ -975,7 +1101,18 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
975
1101
|
} finally {
|
|
976
1102
|
if (ownsSettlement()) thread.lifecycleOperation = undefined;
|
|
977
1103
|
}
|
|
978
|
-
}
|
|
1104
|
+
};
|
|
1105
|
+
const queuedGeneration = reserveManagedLane
|
|
1106
|
+
? async (backgroundSignal: AbortSignal): Promise<void> => {
|
|
1107
|
+
await runInManagedRepositoryLane(
|
|
1108
|
+
originalCwd,
|
|
1109
|
+
() => runGeneration(backgroundSignal),
|
|
1110
|
+
backgroundSignal,
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
: runGeneration;
|
|
1114
|
+
const queueController = runtime.backgroundQueue.enqueue(
|
|
1115
|
+
queuedGeneration,
|
|
979
1116
|
() => {
|
|
980
1117
|
if (runtime.threads.get(runId)?.generation !== generation) return;
|
|
981
1118
|
// Queued park/stop owns publication and may still be finalizing an
|
|
@@ -1011,12 +1148,29 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
1011
1148
|
thread.lifecycleOperation === "settle" &&
|
|
1012
1149
|
!thread.retired;
|
|
1013
1150
|
try {
|
|
1014
|
-
const
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1151
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1152
|
+
const latest = thread.lastResult;
|
|
1153
|
+
const crashed: SingleResult = latest
|
|
1154
|
+
? {
|
|
1155
|
+
...latest,
|
|
1156
|
+
runId,
|
|
1157
|
+
projectCwd: originalCwd,
|
|
1158
|
+
isolation,
|
|
1159
|
+
exitCode: 1,
|
|
1160
|
+
stopReason: "error",
|
|
1161
|
+
errorMessage: `Managed workflow dispatch failed: ${errorMessage}`,
|
|
1162
|
+
dispatchFailed: true,
|
|
1163
|
+
forkedFromRunId: thread.forkedFromRunId,
|
|
1164
|
+
}
|
|
1165
|
+
: {
|
|
1166
|
+
...dispatchFailedResult(route.agent, control.getObjective(), error, thinkingLevel),
|
|
1167
|
+
runId,
|
|
1168
|
+
projectCwd: originalCwd,
|
|
1169
|
+
isolation,
|
|
1170
|
+
forkedFromRunId: thread.forkedFromRunId,
|
|
1171
|
+
};
|
|
1172
|
+
thread.lastResult = crashed;
|
|
1173
|
+
runtime.retainSession(crashed);
|
|
1020
1174
|
await thread.finalizeIsolation(generation, crashed);
|
|
1021
1175
|
if (!ownsSettlement()) return;
|
|
1022
1176
|
thread.state = "failed";
|
|
@@ -1027,10 +1181,10 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
1027
1181
|
thread.queueController = undefined;
|
|
1028
1182
|
if (!runtime.sessionActive || !ownsSettlement()) return;
|
|
1029
1183
|
try {
|
|
1030
|
-
runCtx.ui.notify(`✗ ${agent
|
|
1184
|
+
runCtx.ui.notify(`✗ ${crashed.agent} dispatch failed: ${crashed.errorMessage}`, "error");
|
|
1031
1185
|
runtime.sendCompletionGroup([
|
|
1032
1186
|
{
|
|
1033
|
-
agent: agent
|
|
1187
|
+
agent: crashed.agent,
|
|
1034
1188
|
block: formatCompletionBlock(crashed, runConfig.maxResultLines, crashed.projectCwd ?? originalCwd),
|
|
1035
1189
|
triggerTurn: true,
|
|
1036
1190
|
usage: crashed.usage,
|