@ferris1225/pi-subagents 4.0.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 -478
- package/agents/cleaner.md +51 -41
- package/agents/documenter.md +44 -0
- package/agents/reviewer.md +71 -70
- package/agents/worker.md +4 -1
- package/package.json +2 -2
- package/src/agents.ts +12 -0
- package/src/announcements.ts +34 -7
- package/src/completion.ts +160 -160
- package/src/config.ts +86 -15
- package/src/dispatch.ts +637 -704
- package/src/fixloop.ts +266 -52
- package/src/index.ts +93 -93
- package/src/models.ts +189 -189
- package/src/monitor.ts +12 -3
- package/src/prompt.ts +47 -12
- package/src/recovery.ts +145 -145
- package/src/rpc-run.ts +23 -7
- package/src/runtime.ts +8 -7
- package/src/session-fork.ts +80 -80
- package/src/setup.ts +36 -5
- 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/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,
|
package/src/tools.ts
CHANGED
|
@@ -60,16 +60,17 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
60
60
|
label: "Subagent Control",
|
|
61
61
|
description: [
|
|
62
62
|
"Control an existing sub-agent thread by stable run id.",
|
|
63
|
-
"steer queues an instruction after the current child
|
|
64
|
-
"retarget
|
|
63
|
+
"steer queues an instruction after the current tool batch while the top-level RPC child is active.",
|
|
64
|
+
"retarget replaces the objective in that same active top-level child.",
|
|
65
|
+
"Managed downstream documenter/reviewer/fix stages are controlled by the parent queue rather than its settled RPC control: use park or stop there, then resume with an objective to redirect retained context.",
|
|
65
66
|
"park aborts to a stable checkpoint, terminates the child, preserves context, and releases its concurrency slot.",
|
|
66
67
|
"resume restarts a parked, completed, or failed retained thread with the same run id; objective is optional.",
|
|
67
68
|
"fork copies a parked/completed/failed retained session branch into a new logical thread and run id; an isolated checkpoint must be settled and integrated first; objective is optional.",
|
|
68
69
|
].join(" "),
|
|
69
|
-
promptSnippet: "Control a subagent thread: steer
|
|
70
|
+
promptSnippet: "Control a subagent thread: steer/retarget an active top-level child; park/stop a managed downstream stage; resume or fork retained context.",
|
|
70
71
|
promptGuidelines: [
|
|
71
|
-
"Use subagent_control steer to refine active
|
|
72
|
-
"Use subagent_control retarget
|
|
72
|
+
"Use subagent_control steer to refine an active top-level RPC child without restarting it; the instruction is delivered after its current tool batch.",
|
|
73
|
+
"Use subagent_control retarget only while that top-level child is active. During a managed downstream stage, park it and resume with a replacement objective instead.",
|
|
73
74
|
"Use subagent_control park to checkpoint useful context while releasing the process/concurrency slot, and resume to continue the same run id later.",
|
|
74
75
|
"Use subagent_control fork only on a parked or settled retained thread; isolated work must settle and integrate before it can fork. Fork creates a new run id while leaving the source untouched.",
|
|
75
76
|
"Use subagent_stop only for destructive cancellation; it retires that thread's retained session without retiring independent forks.",
|
|
@@ -409,6 +410,8 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
409
410
|
if (active) {
|
|
410
411
|
const parked = active.status === "parked";
|
|
411
412
|
const activeThread = runtime.threads.get(active.id);
|
|
413
|
+
const managedDownstream =
|
|
414
|
+
activeThread?.state === "running" && activeThread.control.getPhase() === "settled";
|
|
412
415
|
const metadata = [
|
|
413
416
|
activeThread?.isolation === "worktree" ? `worktree ${active.integrationStatus ?? activeThread.worktree?.state ?? "active"}` : undefined,
|
|
414
417
|
activeThread?.forkedFromRunId !== undefined ? `forked from #${activeThread.forkedFromRunId}` : undefined,
|
|
@@ -420,7 +423,9 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
420
423
|
type: "text",
|
|
421
424
|
text: parked
|
|
422
425
|
? `Run #${active.id} ${active.agent} is parked with retained context${metadata ? ` (${metadata})` : ""}. Use subagent_control resume to restart it, or subagent_stop to retire it.`
|
|
423
|
-
:
|
|
426
|
+
: managedDownstream
|
|
427
|
+
? `Run #${active.id} ${active.agent} is in a managed downstream stage (${active.activity ?? statusLabel(active.status)}${metadata ? ` · ${metadata}` : ""}). Use subagent_wait for its result, subagent_control park to checkpoint it, or subagent_stop to cancel it. Steer/retarget are unavailable until you park and resume the retained stage.`
|
|
428
|
+
: `Run #${active.id} ${active.agent} is still active (${active.activity ?? statusLabel(active.status)}${metadata ? ` · ${metadata}` : ""}). Use subagent_wait to block for its result, subagent_control to steer/park it, or subagent_stop to cancel it.`,
|
|
424
429
|
},
|
|
425
430
|
],
|
|
426
431
|
details: {},
|
|
@@ -617,9 +622,19 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
617
622
|
});
|
|
618
623
|
}
|
|
619
624
|
|
|
625
|
+
// Interrupt every claimed generation before awaiting any one of them.
|
|
626
|
+
// An isolated stop may need the repository lane for final integration;
|
|
627
|
+
// cancelling all holders first prevents stop-all from waiting behind a
|
|
628
|
+
// later shared workflow that this same operation has not interrupted yet.
|
|
629
|
+
const interruptionPromises = claimed.map(({ thread, stopMessage, controller }) => {
|
|
630
|
+
const stopping = thread.control.stop(stopMessage).catch(() => undefined);
|
|
631
|
+
runtime.backgroundQueue.cancel(controller);
|
|
632
|
+
return stopping;
|
|
633
|
+
});
|
|
634
|
+
|
|
620
635
|
const stopped: string[] = [];
|
|
621
636
|
const retainedIntegration: string[] = [];
|
|
622
|
-
for (const claim of claimed) {
|
|
637
|
+
for (const [claimIndex, claim] of claimed.entries()) {
|
|
623
638
|
const {
|
|
624
639
|
runId,
|
|
625
640
|
thread,
|
|
@@ -634,8 +649,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
634
649
|
stopVersion,
|
|
635
650
|
stopMessage,
|
|
636
651
|
} = claim;
|
|
637
|
-
await
|
|
638
|
-
runtime.backgroundQueue.cancel(controller);
|
|
652
|
+
await interruptionPromises[claimIndex];
|
|
639
653
|
await completion;
|
|
640
654
|
if (runtime.runControllers.get(runId) === controller) runtime.runControllers.delete(runId);
|
|
641
655
|
if (thread.queueController === controller) thread.queueController = undefined;
|
package/src/widget.ts
CHANGED
|
@@ -93,8 +93,8 @@ function runActivityLine(run: RunView, theme: Theme, width: number, indent: stri
|
|
|
93
93
|
return [truncateToWidth(`${indent}${dim(activitySummary)}`, width, "")];
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
/** Render active runs as a tree: main-agent dispatches are roots
|
|
97
|
-
*
|
|
96
|
+
/** Render active runs as a tree: main-agent dispatches are roots and managed
|
|
97
|
+
* documenter/reviewer/fix steps nest under the stable parent row. No run ids
|
|
98
98
|
* appear here — the tree and the task label say what each row is, and ids stay
|
|
99
99
|
* available through subagent_status when a thread must be controlled. */
|
|
100
100
|
export function formatActiveRunLines(
|
|
@@ -120,8 +120,8 @@ export function formatActiveRunLines(
|
|
|
120
120
|
for (const root of roots) {
|
|
121
121
|
const children = childrenOf.get(root.id) ?? [];
|
|
122
122
|
lines.push(runPrimaryLine(root, theme, width, now, ""));
|
|
123
|
-
// The parent's
|
|
124
|
-
//
|
|
123
|
+
// The parent's managed-workflow placeholder is redundant while a child
|
|
124
|
+
// row shows live progress; keep it only between stages.
|
|
125
125
|
if (children.length === 0) lines.push(...runActivityLine(root, theme, width, " "));
|
|
126
126
|
children.forEach((child, index) => {
|
|
127
127
|
const connector = index === children.length - 1 ? "└ " : "├ ";
|