@ferris1225/pi-subagents 2.0.3 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -19
- package/agents/reviewer.md +7 -9
- package/package.json +1 -1
- package/src/announcements.ts +70 -70
- package/src/completion.ts +10 -2
- package/src/dispatch.ts +30 -997
- package/src/fixloop.ts +24 -21
- package/src/format.ts +174 -177
- package/src/index.ts +3 -2
- package/src/monitor.ts +19 -0
- package/src/prompt.ts +2 -2
- package/src/rpc-run.ts +1125 -1125
- package/src/thread-lifecycle.ts +1063 -0
- package/src/tools.ts +1 -0
- package/src/widget.ts +186 -144
- package/src/worktree.ts +687 -687
package/src/dispatch.ts
CHANGED
|
@@ -1,33 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The `subagent` tool: dispatches explore/worker/cleaner/reviewer agents as isolated pi
|
|
3
|
-
* child processes, single or parallel. Owns the dispatch
|
|
4
|
-
* per-
|
|
5
|
-
*
|
|
3
|
+
* child processes, single or parallel. Owns the public dispatch contract,
|
|
4
|
+
* per-run status tracking, the auto-fix chain (REVIEW_FAIL → worker → re-review),
|
|
5
|
+
* and completion delivery. Stable thread generations live in thread-lifecycle.ts.
|
|
6
6
|
*
|
|
7
7
|
* Vision: a task flagged `vision: true` uses the configured vision model, then
|
|
8
8
|
* hands directly to the current main-window model on model/provider failure.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
12
|
-
import type { ExtensionAPI
|
|
12
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { Text } from "@earendil-works/pi-tui";
|
|
14
|
-
import {
|
|
15
|
-
import { realpath, rm } from "node:fs/promises";
|
|
14
|
+
import { realpath } from "node:fs/promises";
|
|
16
15
|
import { resolve } from "node:path";
|
|
17
16
|
import { Type } from "typebox";
|
|
18
|
-
import { discoverAgents
|
|
17
|
+
import { discoverAgents } from "./agents.ts";
|
|
18
|
+
import { loadConfig } from "./config.ts";
|
|
19
19
|
import {
|
|
20
|
-
completionTriggersTurn,
|
|
21
|
-
type CompletionMessageItem,
|
|
22
|
-
} from "./completion.ts";
|
|
23
|
-
import {
|
|
24
|
-
DEFAULT_THINKING_LEVEL,
|
|
25
|
-
loadConfig,
|
|
26
|
-
type SubagentsConfig,
|
|
27
|
-
type ThinkingLevel,
|
|
28
|
-
} from "./config.ts";
|
|
29
|
-
import {
|
|
30
|
-
dispatchFailedResult,
|
|
31
20
|
failedStartResult,
|
|
32
21
|
formatCompletionBlock,
|
|
33
22
|
formatUsage,
|
|
@@ -38,30 +27,18 @@ import {
|
|
|
38
27
|
buildFixTaskBrief,
|
|
39
28
|
buildReReviewBrief,
|
|
40
29
|
formatChainSummary,
|
|
41
|
-
shouldTriggerFixLoop,
|
|
42
30
|
type ChainStep,
|
|
43
31
|
} from "./fixloop.ts";
|
|
44
|
-
import {
|
|
45
|
-
availableModelsInScope,
|
|
46
|
-
currentModelRef,
|
|
47
|
-
findModelByRef,
|
|
48
|
-
modelRef,
|
|
49
|
-
resolveAgentModelRoute,
|
|
50
|
-
resolveThinkingLevel,
|
|
51
|
-
} from "./models.ts";
|
|
52
32
|
import {
|
|
53
33
|
formatTaskSummary,
|
|
54
34
|
formatToolActivity,
|
|
55
35
|
monitor,
|
|
56
36
|
statusIcon,
|
|
37
|
+
sumUsage,
|
|
57
38
|
type RunChainMeta,
|
|
58
39
|
} from "./monitor.ts";
|
|
59
|
-
import type { SubagentRuntime
|
|
60
|
-
import { persistRecoveryRecords, recoveryRecordFromFinalization } from "./recovery.ts";
|
|
61
|
-
import { forkRetainedSession } from "./session-fork.ts";
|
|
40
|
+
import type { SubagentRuntime } from "./runtime.ts";
|
|
62
41
|
import {
|
|
63
|
-
buildResumePrompt,
|
|
64
|
-
RpcRunControl,
|
|
65
42
|
getResultOutput,
|
|
66
43
|
isFailedResult,
|
|
67
44
|
isModelLevelFailure,
|
|
@@ -72,31 +49,14 @@ import {
|
|
|
72
49
|
type SubagentLiveEvent,
|
|
73
50
|
} from "./spawn.ts";
|
|
74
51
|
import {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
type WorktreeIsolation,
|
|
80
|
-
} from "./worktree.ts";
|
|
52
|
+
createBackgroundDispatcher,
|
|
53
|
+
resolveDispatchModelRoute,
|
|
54
|
+
} from "./thread-lifecycle.ts";
|
|
55
|
+
import { resolveWorktreeTarget, type IsolationMode } from "./worktree.ts";
|
|
81
56
|
|
|
82
|
-
|
|
83
|
-
export const FORK_CONTINUATION_PROMPT =
|
|
84
|
-
"Continue from the retained context above. Review the prior work, then take the most useful next step toward completing the existing objective without repeating completed work.";
|
|
85
|
-
const WORKTREE_ISOLATION_INSTRUCTIONS =
|
|
86
|
-
"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.";
|
|
57
|
+
export { FORK_CONTINUATION_PROMPT, isWorktreeCapableAgent } from "./thread-lifecycle.ts";
|
|
87
58
|
|
|
88
|
-
|
|
89
|
-
return {
|
|
90
|
-
...agent,
|
|
91
|
-
systemPrompt: `${agent.systemPrompt.trimEnd()}\n\n${WORKTREE_ISOLATION_INSTRUCTIONS}`.trim(),
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
interface DispatchEnvironment {
|
|
96
|
-
ctx: ExtensionContext;
|
|
97
|
-
config: SubagentsConfig;
|
|
98
|
-
agents: AgentConfig[];
|
|
99
|
-
}
|
|
59
|
+
const NON_BLANK_TASK_OPTIONS = { minLength: 1, pattern: "\\S" } as const;
|
|
100
60
|
|
|
101
61
|
const VISION_DESCRIPTION =
|
|
102
62
|
"Set true when the task may require viewing images (screenshots, mockups, designs) — the configured vision model is used first, then model-level failures hand directly to the current main-window model";
|
|
@@ -135,13 +95,6 @@ export function defaultIsolationMode(mode: "single" | "parallel", agentName: str
|
|
|
135
95
|
return mode === "parallel" && agentName === "worker" ? "worktree" : "shared";
|
|
136
96
|
}
|
|
137
97
|
|
|
138
|
-
export function isWorktreeCapableAgent(agent: AgentConfig): boolean {
|
|
139
|
-
if (agent.name === "explore" || agent.name === "reviewer") return false;
|
|
140
|
-
if (agent.name === "worker") return true;
|
|
141
|
-
if (!agent.tools) return true;
|
|
142
|
-
return agent.tools.includes("edit") || agent.tools.includes("write");
|
|
143
|
-
}
|
|
144
|
-
|
|
145
98
|
const autoFixRootTails = new Map<string, Promise<void>>();
|
|
146
99
|
|
|
147
100
|
async function canonicalAutoFixRoot(cwd: string): Promise<string> {
|
|
@@ -184,42 +137,6 @@ function serializeAutoFixChain(
|
|
|
184
137
|
};
|
|
185
138
|
}
|
|
186
139
|
|
|
187
|
-
interface DispatchModelRoute {
|
|
188
|
-
agent: AgentConfig;
|
|
189
|
-
mainFallbackRef?: string;
|
|
190
|
-
thinkingLevel: ThinkingLevel;
|
|
191
|
-
thinkingLevelForModel: (ref?: string) => ThinkingLevel;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function resolveDispatchModelRoute(
|
|
195
|
-
agent: AgentConfig,
|
|
196
|
-
config: SubagentsConfig,
|
|
197
|
-
ctx: ExtensionContext,
|
|
198
|
-
vision: boolean,
|
|
199
|
-
): DispatchModelRoute {
|
|
200
|
-
const availableModels = availableModelsInScope(ctx);
|
|
201
|
-
const mainRef = currentModelRef(ctx);
|
|
202
|
-
const route = resolveAgentModelRoute({
|
|
203
|
-
selectedRef: vision ? config.visionModel : config.agentModels[agent.name],
|
|
204
|
-
mainRef,
|
|
205
|
-
declaredDefaultRef: agent.model,
|
|
206
|
-
availableRefs: availableModels.map(modelRef),
|
|
207
|
-
});
|
|
208
|
-
const preferred = config.agentThinkingLevels[agent.name] ?? agent.thinking ?? DEFAULT_THINKING_LEVEL;
|
|
209
|
-
const thinkingLevelForModel = (ref?: string): ThinkingLevel => {
|
|
210
|
-
const model = ref === mainRef && ctx.model
|
|
211
|
-
? ctx.model
|
|
212
|
-
: findModelByRef(availableModels, ref);
|
|
213
|
-
return resolveThinkingLevel(model, preferred);
|
|
214
|
-
};
|
|
215
|
-
return {
|
|
216
|
-
agent: { ...agent, model: route.primaryRef },
|
|
217
|
-
mainFallbackRef: route.mainFallbackRef,
|
|
218
|
-
thinkingLevel: thinkingLevelForModel(route.primaryRef),
|
|
219
|
-
thinkingLevelForModel,
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
|
|
223
140
|
export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime): void {
|
|
224
141
|
pi.registerTool({
|
|
225
142
|
name: "subagent",
|
|
@@ -507,6 +424,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
507
424
|
const workerStep = await launchInLoop("worker", fixBrief, executionCwd, signal, {
|
|
508
425
|
groupId: parentGroupId,
|
|
509
426
|
relationLabel: `fix round ${round}`,
|
|
427
|
+
parentRunId,
|
|
510
428
|
}, vision);
|
|
511
429
|
// Preserve the newest sub-step before checking chain ownership. A
|
|
512
430
|
// destructive stop invalidates ownsParent() while this child is
|
|
@@ -526,10 +444,11 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
526
444
|
if (!ownsParent()) return;
|
|
527
445
|
chain.push({ ...workerStep, relation: `fix round ${round}` });
|
|
528
446
|
if (!runtime.sessionActive || isFailedResult(workerStep.result)) break;
|
|
529
|
-
const reReviewBrief = buildReReviewBrief(lastReviewer, round);
|
|
447
|
+
const reReviewBrief = buildReReviewBrief(lastReviewer, round, workerStep.result);
|
|
530
448
|
const reviewStep = await launchInLoop("reviewer", reReviewBrief, executionCwd, signal, {
|
|
531
449
|
groupId: parentGroupId,
|
|
532
450
|
relationLabel: `re-review round ${round}`,
|
|
451
|
+
parentRunId,
|
|
533
452
|
}, vision);
|
|
534
453
|
if (
|
|
535
454
|
runtime.threads.get(parentRunId) === parentThreadAtStart &&
|
|
@@ -611,6 +530,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
611
530
|
agent: `auto-fix chain (${last.result.agent})`,
|
|
612
531
|
block,
|
|
613
532
|
triggerTurn: true,
|
|
533
|
+
usage: sumUsage(chain.map((step) => step.result.usage)),
|
|
614
534
|
},
|
|
615
535
|
]);
|
|
616
536
|
runtime.completionBatcher.flush();
|
|
@@ -654,6 +574,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
654
574
|
agent: initialReviewerResult.agent,
|
|
655
575
|
block: `${formatCompletionBlock(initialReviewerResult, config.maxResultLines, executionCwd)}\n\nAuto-fix chain crashed before completion: ${errorMessage}. The planned fix rounds did not run; the review above is the triggering reviewer's full output.`,
|
|
656
576
|
triggerTurn: true,
|
|
577
|
+
usage: initialReviewerResult.usage,
|
|
657
578
|
},
|
|
658
579
|
]);
|
|
659
580
|
runtime.completionBatcher.flush();
|
|
@@ -673,904 +594,16 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
673
594
|
]).then(() => undefined);
|
|
674
595
|
};
|
|
675
596
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
version: number;
|
|
687
|
-
generation: number;
|
|
688
|
-
sessionId?: string;
|
|
689
|
-
sessionDir?: string;
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
const ownsResumeReservation = (
|
|
693
|
-
thread: SubagentThread,
|
|
694
|
-
reservation: ResumeReservation,
|
|
695
|
-
): boolean =>
|
|
696
|
-
runtime.sessionActive &&
|
|
697
|
-
runtime.threads.get(thread.id) === thread &&
|
|
698
|
-
!thread.retired &&
|
|
699
|
-
thread.lifecycleOperation === "resume" &&
|
|
700
|
-
thread.lifecycleVersion === reservation.version &&
|
|
701
|
-
thread.generation === reservation.generation &&
|
|
702
|
-
thread.sessionId === reservation.sessionId &&
|
|
703
|
-
thread.sessionDir === reservation.sessionDir;
|
|
704
|
-
|
|
705
|
-
const beginPreflight = (): (() => void) => {
|
|
706
|
-
let resolvePreflight!: () => void;
|
|
707
|
-
const preflight = new Promise<void>((resolve) => {
|
|
708
|
-
resolvePreflight = resolve;
|
|
709
|
-
});
|
|
710
|
-
runtime.preflightOperations.add(preflight);
|
|
711
|
-
return () => {
|
|
712
|
-
runtime.preflightOperations.delete(preflight);
|
|
713
|
-
resolvePreflight();
|
|
714
|
-
};
|
|
715
|
-
};
|
|
716
|
-
|
|
717
|
-
const startBackground = async (
|
|
718
|
-
agentName: string,
|
|
719
|
-
task: string,
|
|
720
|
-
cwd: string | undefined,
|
|
721
|
-
vision = false,
|
|
722
|
-
isolation: IsolationMode = "shared",
|
|
723
|
-
existingThread?: SubagentThread,
|
|
724
|
-
newObjectiveOnResume = false,
|
|
725
|
-
environment?: DispatchEnvironment,
|
|
726
|
-
seed?: SessionSeed,
|
|
727
|
-
resumeReservation?: ResumeReservation,
|
|
728
|
-
): Promise<SingleResult> => {
|
|
729
|
-
if (!runtime.sessionActive) {
|
|
730
|
-
return failedStartResult(agentName, task, "Parent session shut down before this subagent generation could start.");
|
|
731
|
-
}
|
|
732
|
-
if (existingThread && (!resumeReservation || !ownsResumeReservation(existingThread, resumeReservation))) {
|
|
733
|
-
return failedStartResult(agentName, task, `Run #${existingThread.id} changed while resume was preparing; no new generation was started.`);
|
|
734
|
-
}
|
|
735
|
-
const runCtx = environment?.ctx ?? ctx;
|
|
736
|
-
const runConfig = environment?.config ?? config;
|
|
737
|
-
const runAgents = environment?.agents ?? agents;
|
|
738
|
-
const agent = runAgents.find((candidate) => candidate.name === agentName);
|
|
739
|
-
if (!agent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
|
|
740
|
-
if (isolation === "worktree" && !isWorktreeCapableAgent(agent)) {
|
|
741
|
-
return {
|
|
742
|
-
...failedStartResult(agentName, task, `Agent "${agentName}" is read-only; worktree isolation is available only to write-capable agents such as worker or cleaner.`),
|
|
743
|
-
isolation,
|
|
744
|
-
};
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
const originalCwd = resolve(cwd ?? runCtx.cwd);
|
|
748
|
-
const previousWorktree = existingThread?.worktree;
|
|
749
|
-
let worktree = seed?.worktree ?? previousWorktree;
|
|
750
|
-
if (isolation === "worktree") {
|
|
751
|
-
if (worktree && worktree.state !== "active") {
|
|
752
|
-
return {
|
|
753
|
-
...failedStartResult(agentName, task, `Run #${existingThread?.id ?? "?"} has no active continuation worktree.`),
|
|
754
|
-
isolation,
|
|
755
|
-
integrationStatus: worktree.state === "finalizing" ? "pending" : worktree.state,
|
|
756
|
-
};
|
|
757
|
-
}
|
|
758
|
-
if (!worktree) {
|
|
759
|
-
try {
|
|
760
|
-
worktree = await createWorktreeIsolation(originalCwd);
|
|
761
|
-
} catch (error) {
|
|
762
|
-
return {
|
|
763
|
-
...failedStartResult(agentName, task, error instanceof Error ? error.message : String(error)),
|
|
764
|
-
isolation,
|
|
765
|
-
};
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
const executionCwd = worktree?.cwd ?? originalCwd;
|
|
770
|
-
const resolvedRoute = resolveDispatchModelRoute(agent, runConfig, runCtx, vision);
|
|
771
|
-
// Isolation is a persistent system-level invariant, not a one-shot task
|
|
772
|
-
// prefix: queued retargets, live retargets, resumes, and main-model
|
|
773
|
-
// handoffs all keep the same worktree boundary.
|
|
774
|
-
const route = isolation === "worktree"
|
|
775
|
-
? { ...resolvedRoute, agent: withWorktreeSystemPrompt(resolvedRoute.agent) }
|
|
776
|
-
: resolvedRoute;
|
|
777
|
-
const thinkingLevel = route.thinkingLevel;
|
|
778
|
-
const priorTask = existingThread?.task;
|
|
779
|
-
const priorSessionId = seed?.sessionId ?? existingThread?.sessionId;
|
|
780
|
-
const priorSessionDir = seed?.sessionDir ?? existingThread?.sessionDir;
|
|
781
|
-
if (existingThread && resumeReservation && !ownsResumeReservation(existingThread, resumeReservation)) {
|
|
782
|
-
return failedStartResult(agentName, task, `Run #${existingThread.id} changed while resume was preparing; no new generation was started.`);
|
|
783
|
-
}
|
|
784
|
-
const runId = existingThread?.id ?? monitor.addRun(agent.name, task, route.agent.model, thinkingLevel, {
|
|
785
|
-
isolation,
|
|
786
|
-
...(seed?.forkedFromRunId !== undefined ? { forkedFromRunId: seed.forkedFromRunId } : {}),
|
|
787
|
-
});
|
|
788
|
-
const generation = (existingThread?.generation ?? 0) + 1;
|
|
789
|
-
const pending: SingleResult = {
|
|
790
|
-
...queuedResult(route.agent, task, thinkingLevel),
|
|
791
|
-
runId,
|
|
792
|
-
projectCwd: originalCwd,
|
|
793
|
-
isolation,
|
|
794
|
-
...(isolation === "worktree" ? { integrationStatus: "pending" as const } : {}),
|
|
795
|
-
...(seed?.sessionId && seed.sessionDir
|
|
796
|
-
? { sessionId: seed.sessionId, sessionDir: seed.sessionDir }
|
|
797
|
-
: {}),
|
|
798
|
-
...(seed?.forkedFromRunId !== undefined ? { forkedFromRunId: seed.forkedFromRunId } : {}),
|
|
799
|
-
};
|
|
800
|
-
if (existingThread) {
|
|
801
|
-
monitor.restartRun(runId, agent.name, task, route.agent.model, thinkingLevel, isolation);
|
|
802
|
-
runtime.settledRuns.delete(runId);
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
let thread!: SubagentThread;
|
|
806
|
-
const control = new RpcRunControl(task, generation, (phase) => {
|
|
807
|
-
if (runtime.threads.get(runId)?.generation !== generation || phase === "settled") return;
|
|
808
|
-
const state: ThreadState =
|
|
809
|
-
phase === "queued" || phase === "starting"
|
|
810
|
-
? "queued"
|
|
811
|
-
: phase === "steering"
|
|
812
|
-
? "steering"
|
|
813
|
-
: phase === "interrupting"
|
|
814
|
-
? "interrupting"
|
|
815
|
-
: phase === "parked"
|
|
816
|
-
? "parked"
|
|
817
|
-
: phase === "stopped"
|
|
818
|
-
? "stopped"
|
|
819
|
-
: "running";
|
|
820
|
-
thread.state = state;
|
|
821
|
-
if (state === "queued") monitor.setStatus(runId, "queued");
|
|
822
|
-
else if (state === "steering") monitor.setStatus(runId, "steering");
|
|
823
|
-
else if (state === "interrupting") monitor.setStatus(runId, "interrupting");
|
|
824
|
-
else if (state === "parked") monitor.setStatus(runId, "parked");
|
|
825
|
-
else if (state === "running") monitor.setStatus(runId, "running");
|
|
826
|
-
});
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
if (existingThread) {
|
|
830
|
-
thread = existingThread;
|
|
831
|
-
thread.generation = generation;
|
|
832
|
-
thread.agentName = agent.name;
|
|
833
|
-
thread.task = task;
|
|
834
|
-
thread.cwd = originalCwd;
|
|
835
|
-
thread.executionCwd = executionCwd;
|
|
836
|
-
thread.vision = vision;
|
|
837
|
-
thread.thinkingLevel = thinkingLevel;
|
|
838
|
-
thread.isolation = isolation;
|
|
839
|
-
thread.worktree = worktree;
|
|
840
|
-
thread.state = "queued";
|
|
841
|
-
thread.control = control;
|
|
842
|
-
// A newly admitted generation owns no output yet. Keeping the prior
|
|
843
|
-
// generation here would make a queued stop publish stale task,
|
|
844
|
-
// session metadata as this generation's partial.
|
|
845
|
-
thread.lastResult = undefined;
|
|
846
|
-
if (seed?.sessionId && seed.sessionDir) {
|
|
847
|
-
thread.sessionId = seed.sessionId;
|
|
848
|
-
thread.sessionDir = seed.sessionDir;
|
|
849
|
-
}
|
|
850
|
-
thread.retireOnSettle = false;
|
|
851
|
-
thread.isolationFailureNotified = false;
|
|
852
|
-
} else {
|
|
853
|
-
thread = {
|
|
854
|
-
id: runId,
|
|
855
|
-
generation,
|
|
856
|
-
agentName: agent.name,
|
|
857
|
-
task,
|
|
858
|
-
cwd: originalCwd,
|
|
859
|
-
executionCwd,
|
|
860
|
-
vision,
|
|
861
|
-
thinkingLevel,
|
|
862
|
-
isolation,
|
|
863
|
-
worktree,
|
|
864
|
-
state: "queued",
|
|
865
|
-
control,
|
|
866
|
-
generationCompletion: Promise.resolve(),
|
|
867
|
-
lifecycleVersion: 0,
|
|
868
|
-
sessionId: seed?.sessionId,
|
|
869
|
-
sessionDir: seed?.sessionDir,
|
|
870
|
-
forkedFromRunId: seed?.forkedFromRunId,
|
|
871
|
-
forkChildRunIds: [],
|
|
872
|
-
park: async () => {
|
|
873
|
-
throw new Error("Thread park was not initialized.");
|
|
874
|
-
},
|
|
875
|
-
resume: async () => failedStartResult(agent.name, task, "Thread resume was not initialized."),
|
|
876
|
-
fork: async () => failedStartResult(agent.name, task, "Thread fork was not initialized."),
|
|
877
|
-
finalizeIsolation: async () => undefined,
|
|
878
|
-
};
|
|
879
|
-
runtime.threads.set(runId, thread);
|
|
880
|
-
}
|
|
881
|
-
thread.notifyIsolationFailure = (finalization) => {
|
|
882
|
-
const paths = [finalization.worktreePath, finalization.patchPath].filter(Boolean).join(" · ");
|
|
883
|
-
runCtx.ui.notify(
|
|
884
|
-
`✗ ${agent.name} worktree ${finalization.integrated ? "cleanup" : "integration"} failed${paths ? ` · retained ${paths}` : ""}: ${finalization.error ?? "unknown Git integration error"}`,
|
|
885
|
-
"error",
|
|
886
|
-
);
|
|
887
|
-
};
|
|
888
|
-
thread.finalizeIsolation = async (
|
|
889
|
-
expectedGeneration: number,
|
|
890
|
-
result?: SingleResult,
|
|
891
|
-
): Promise<WorktreeFinalization | undefined> => {
|
|
892
|
-
if (thread.isolation !== "worktree" || !thread.worktree) return undefined;
|
|
893
|
-
if (thread.generation !== expectedGeneration) return undefined;
|
|
894
|
-
const finalization = await thread.worktree.finalize();
|
|
895
|
-
monitor.setIsolation(runId, "worktree", finalization.status);
|
|
896
|
-
if (result) {
|
|
897
|
-
result.runId = runId;
|
|
898
|
-
result.isolation = "worktree";
|
|
899
|
-
result.integrationStatus = finalization.status;
|
|
900
|
-
result.integrationApplied = finalization.integrated;
|
|
901
|
-
result.integrationError = finalization.error;
|
|
902
|
-
result.integrationWorktreePath = finalization.worktreePath;
|
|
903
|
-
result.integrationPatchPath = finalization.patchPath;
|
|
904
|
-
result.forkedFromRunId = thread.forkedFromRunId;
|
|
905
|
-
result.forkChildRunIds = [...thread.forkChildRunIds];
|
|
906
|
-
if (finalization.status === "retained") {
|
|
907
|
-
const retained = [
|
|
908
|
-
finalization.worktreePath ? `worktree ${finalization.worktreePath}` : undefined,
|
|
909
|
-
finalization.patchPath ? `patch ${finalization.patchPath}` : undefined,
|
|
910
|
-
].filter(Boolean).join(", ");
|
|
911
|
-
const integrationMessage = finalization.integrated
|
|
912
|
-
? `Worktree changes were applied, but cleanup failed${retained ? `; retained ${retained}` : ""}: ${finalization.error ?? "unknown Git cleanup error"}`
|
|
913
|
-
: `Worktree integration failed${retained ? `; retained ${retained}` : ""}: ${finalization.error ?? "unknown Git integration error"}`;
|
|
914
|
-
result.exitCode = 1;
|
|
915
|
-
result.stopReason = "error";
|
|
916
|
-
result.errorMessage = result.errorMessage
|
|
917
|
-
? `${result.errorMessage}\n${integrationMessage}`
|
|
918
|
-
: integrationMessage;
|
|
919
|
-
result.stderr = result.stderr ? `${result.stderr.trimEnd()}\n${integrationMessage}` : integrationMessage;
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
if (finalization.status === "retained") {
|
|
923
|
-
if (!thread.isolationFailureNotified) {
|
|
924
|
-
thread.isolationFailureNotified = true;
|
|
925
|
-
try {
|
|
926
|
-
thread.notifyIsolationFailure?.(finalization);
|
|
927
|
-
} catch {
|
|
928
|
-
/* notification failures do not hide retained artifacts */
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
return finalization;
|
|
933
|
-
};
|
|
934
|
-
|
|
935
|
-
const cleanupTrackedSessionDir = async (sessionDir: string, action: string): Promise<void> => {
|
|
936
|
-
try {
|
|
937
|
-
await rm(sessionDir, { recursive: true, force: true });
|
|
938
|
-
runtime.sessionDirs.delete(sessionDir);
|
|
939
|
-
} catch (error) {
|
|
940
|
-
// Keep ownership so shutdown can retry; losing the path here leaks a
|
|
941
|
-
// cloned session containing retained model context on Windows locks.
|
|
942
|
-
try {
|
|
943
|
-
runCtx.ui.notify(
|
|
944
|
-
`✗ ${action}; retained ${sessionDir} for shutdown cleanup: ${error instanceof Error ? error.message : String(error)}`,
|
|
945
|
-
"error",
|
|
946
|
-
);
|
|
947
|
-
} catch {
|
|
948
|
-
/* cleanup ownership remains tracked even if the UI is unavailable */
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
};
|
|
952
|
-
|
|
953
|
-
const discardUnusedWorktree = async (candidate: WorktreeIsolation | undefined): Promise<void> => {
|
|
954
|
-
if (!candidate) return;
|
|
955
|
-
try {
|
|
956
|
-
await candidate.discard();
|
|
957
|
-
} catch (error) {
|
|
958
|
-
const retainedPath = existsSync(candidate.worktreePath)
|
|
959
|
-
? candidate.worktreePath
|
|
960
|
-
: existsSync(candidate.tempDir)
|
|
961
|
-
? candidate.tempDir
|
|
962
|
-
: undefined;
|
|
963
|
-
const finalization: WorktreeFinalization = {
|
|
964
|
-
status: "retained",
|
|
965
|
-
integrated: false,
|
|
966
|
-
hadChanges: false,
|
|
967
|
-
...(retainedPath ? { worktreePath: retainedPath } : {}),
|
|
968
|
-
...(existsSync(candidate.patchPath) ? { patchPath: candidate.patchPath } : {}),
|
|
969
|
-
error: `Discarding unused continuation failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
970
|
-
};
|
|
971
|
-
await persistRecoveryRecords(runtime.configPath, [
|
|
972
|
-
recoveryRecordFromFinalization(runId, finalization),
|
|
973
|
-
]).catch(() => undefined);
|
|
974
|
-
try {
|
|
975
|
-
thread.notifyIsolationFailure?.(finalization);
|
|
976
|
-
} catch {
|
|
977
|
-
/* parent UI may already be shutting down */
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
};
|
|
981
|
-
|
|
982
|
-
const createContinuationWorktree = async (
|
|
983
|
-
source: WorktreeIsolation,
|
|
984
|
-
seedIsIntegrated: boolean,
|
|
985
|
-
): Promise<WorktreeIsolation> => {
|
|
986
|
-
if (source.state === "finalizing") {
|
|
987
|
-
throw new Error(`Run #${runId}'s worktree is still finalizing.`);
|
|
988
|
-
}
|
|
989
|
-
const seedCheckpoint = await source.snapshotCheckpoint();
|
|
990
|
-
return createWorktreeIsolation(thread.cwd, {
|
|
991
|
-
seedCheckpoint,
|
|
992
|
-
seedIsIntegrated,
|
|
993
|
-
});
|
|
994
|
-
};
|
|
995
|
-
|
|
996
|
-
thread.park = async (): Promise<"queued" | "active"> => {
|
|
997
|
-
if (thread.retired) throw new Error(`Run #${runId} was retired by subagent_stop.`);
|
|
998
|
-
if (thread.lifecycleOperation) throw new Error(`Run #${runId} is already handling ${thread.lifecycleOperation}.`);
|
|
999
|
-
if (thread.state === "parked") return "active";
|
|
1000
|
-
const phase = thread.control.getPhase();
|
|
1001
|
-
const queued = thread.state === "queued" && phase === "queued";
|
|
1002
|
-
if (
|
|
1003
|
-
!queued &&
|
|
1004
|
-
((phase === "settled" && thread.state !== "running") ||
|
|
1005
|
-
!["starting", "running", "steering", "interrupting", "retrying", "settled"].includes(phase))
|
|
1006
|
-
) {
|
|
1007
|
-
throw new Error(`Run #${runId} is ${thread.state}; only active work can be parked.`);
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
const version = ++thread.lifecycleVersion;
|
|
1011
|
-
const generation = thread.generation;
|
|
1012
|
-
const completion = thread.generationCompletion;
|
|
1013
|
-
const controller = thread.queueController;
|
|
1014
|
-
thread.lifecycleOperation = "park";
|
|
1015
|
-
try {
|
|
1016
|
-
if (queued) {
|
|
1017
|
-
thread.control.parkPending();
|
|
1018
|
-
runtime.backgroundQueue.cancel(controller);
|
|
1019
|
-
} else {
|
|
1020
|
-
await thread.control.park();
|
|
1021
|
-
// Auto-fix orchestration has no live RPC attempt once its parent
|
|
1022
|
-
// review settled, so cancel its queue owner explicitly.
|
|
1023
|
-
if (phase === "settled") runtime.backgroundQueue.cancel(controller);
|
|
1024
|
-
}
|
|
1025
|
-
await completion;
|
|
1026
|
-
if (
|
|
1027
|
-
thread.generation !== generation ||
|
|
1028
|
-
thread.lifecycleVersion !== version ||
|
|
1029
|
-
thread.lifecycleOperation !== "park"
|
|
1030
|
-
) {
|
|
1031
|
-
throw new Error(`Run #${runId} changed while parking.`);
|
|
1032
|
-
}
|
|
1033
|
-
thread.state = "parked";
|
|
1034
|
-
thread.queueController = undefined;
|
|
1035
|
-
runtime.runControllers.delete(runId);
|
|
1036
|
-
monitor.setStatus(runId, "parked");
|
|
1037
|
-
return queued ? "queued" : "active";
|
|
1038
|
-
} finally {
|
|
1039
|
-
if (thread.lifecycleVersion === version && thread.lifecycleOperation === "park") {
|
|
1040
|
-
thread.lifecycleOperation = undefined;
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1043
|
-
};
|
|
1044
|
-
|
|
1045
|
-
thread.resume = async (objective?: string, resumeCtx?: ExtensionContext): Promise<SingleResult> => {
|
|
1046
|
-
const requestedObjective = objective?.trim();
|
|
1047
|
-
if (!runtime.sessionActive || runtime.threads.get(runId) !== thread) {
|
|
1048
|
-
return failedStartResult(thread.agentName, thread.task, `Run #${runId} belongs to a parent session that has shut down.`);
|
|
1049
|
-
}
|
|
1050
|
-
if (objective !== undefined && !requestedObjective) {
|
|
1051
|
-
return failedStartResult(thread.agentName, thread.task, "resume objective must be non-blank when provided.");
|
|
1052
|
-
}
|
|
1053
|
-
if (thread.retired) return failedStartResult(thread.agentName, thread.task, `Run #${runId} was retired by subagent_stop.`);
|
|
1054
|
-
if (thread.lifecycleOperation) {
|
|
1055
|
-
return failedStartResult(thread.agentName, thread.task, `Run #${runId} is already ${thread.lifecycleOperation === "resume" ? "resuming" : "being forked"}.`);
|
|
1056
|
-
}
|
|
1057
|
-
if (!["parked", "completed", "failed"].includes(thread.state)) {
|
|
1058
|
-
return failedStartResult(thread.agentName, thread.task, `Run #${runId} is ${thread.state}; it must be parked or settled before resume.`);
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
// Lifecycle CAS: claim synchronously before the first await, then cancel
|
|
1062
|
-
// and fully quiesce any superseded queue/process before cloning or
|
|
1063
|
-
// reusing its session. A second resume/fork sees this claim immediately.
|
|
1064
|
-
const previousState = thread.state;
|
|
1065
|
-
const previousSessionId = thread.sessionId;
|
|
1066
|
-
const previousSessionDir = thread.sessionDir;
|
|
1067
|
-
const previousExecutionCwd = thread.executionCwd;
|
|
1068
|
-
const reservation: ResumeReservation = {
|
|
1069
|
-
version: ++thread.lifecycleVersion,
|
|
1070
|
-
generation: thread.generation,
|
|
1071
|
-
sessionId: previousSessionId,
|
|
1072
|
-
sessionDir: previousSessionDir,
|
|
1073
|
-
};
|
|
1074
|
-
thread.lifecycleOperation = "resume";
|
|
1075
|
-
thread.state = "resuming";
|
|
1076
|
-
const finishPreflight = beginPreflight();
|
|
1077
|
-
const supersededController = thread.queueController;
|
|
1078
|
-
runtime.backgroundQueue.cancel(supersededController);
|
|
1079
|
-
runtime.runControllers.delete(runId);
|
|
1080
|
-
|
|
1081
|
-
let continuationWorktree: WorktreeIsolation | undefined;
|
|
1082
|
-
let clonedSession: Awaited<ReturnType<typeof forkRetainedSession>> | undefined;
|
|
1083
|
-
try {
|
|
1084
|
-
await thread.generationCompletion;
|
|
1085
|
-
if (!ownsResumeReservation(thread, reservation)) {
|
|
1086
|
-
return failedStartResult(
|
|
1087
|
-
thread.agentName,
|
|
1088
|
-
thread.task,
|
|
1089
|
-
thread.retired
|
|
1090
|
-
? `Run #${runId} was retired by subagent_stop; no new generation was started.`
|
|
1091
|
-
: `Run #${runId} changed while resume was preparing; no new generation was started.`,
|
|
1092
|
-
);
|
|
1093
|
-
}
|
|
1094
|
-
thread.state = "resuming";
|
|
1095
|
-
const currentCtx = resumeCtx ?? runCtx;
|
|
1096
|
-
let seed: SessionSeed | undefined;
|
|
1097
|
-
if (thread.isolation === "worktree" && thread.worktree?.state !== "active") {
|
|
1098
|
-
if (!thread.worktree) throw new Error(`Run #${runId} has no isolated worktree checkpoint.`);
|
|
1099
|
-
const seedAlreadyIntegrated =
|
|
1100
|
-
thread.worktree.state === "integrated" ||
|
|
1101
|
-
thread.worktree.state === "no_changes" ||
|
|
1102
|
-
thread.lastResult?.integrationApplied === true;
|
|
1103
|
-
continuationWorktree = await createContinuationWorktree(thread.worktree, seedAlreadyIntegrated);
|
|
1104
|
-
if (!ownsResumeReservation(thread, reservation)) {
|
|
1105
|
-
throw new Error(`Run #${runId} changed while its continuation worktree was being created.`);
|
|
1106
|
-
}
|
|
1107
|
-
seed = { worktree: continuationWorktree };
|
|
1108
|
-
if (previousSessionId && previousSessionDir) {
|
|
1109
|
-
clonedSession = await forkRetainedSession({
|
|
1110
|
-
cwd: previousExecutionCwd,
|
|
1111
|
-
targetCwd: continuationWorktree.cwd,
|
|
1112
|
-
sessionDir: previousSessionDir,
|
|
1113
|
-
sessionId: previousSessionId,
|
|
1114
|
-
});
|
|
1115
|
-
runtime.sessionDirs.add(clonedSession.sessionDir);
|
|
1116
|
-
if (!ownsResumeReservation(thread, reservation)) {
|
|
1117
|
-
throw new Error(`Run #${runId} changed while its retained session was being cloned.`);
|
|
1118
|
-
}
|
|
1119
|
-
seed.sessionId = clonedSession.sessionId;
|
|
1120
|
-
seed.sessionDir = clonedSession.sessionDir;
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
const currentConfig = await loadConfig(runtime.configPath);
|
|
1125
|
-
if (!ownsResumeReservation(thread, reservation)) {
|
|
1126
|
-
throw new Error(`Run #${runId} changed while resume configuration was loading.`);
|
|
1127
|
-
}
|
|
1128
|
-
runtime.backgroundQueue.setConcurrency(currentConfig.maxConcurrency);
|
|
1129
|
-
const currentAgents = discoverAgents(currentCtx.cwd, {
|
|
1130
|
-
scope: currentConfig.agentScope,
|
|
1131
|
-
enabledNames: currentConfig.enabledAgents,
|
|
1132
|
-
projectTrusted: currentCtx.isProjectTrusted?.() === true,
|
|
1133
|
-
}).agents;
|
|
1134
|
-
const nextTask = requestedObjective ?? thread.task;
|
|
1135
|
-
const pending = await startBackground(
|
|
1136
|
-
thread.agentName,
|
|
1137
|
-
nextTask,
|
|
1138
|
-
thread.cwd,
|
|
1139
|
-
thread.vision,
|
|
1140
|
-
thread.isolation,
|
|
1141
|
-
thread,
|
|
1142
|
-
objective !== undefined,
|
|
1143
|
-
{
|
|
1144
|
-
ctx: currentCtx,
|
|
1145
|
-
config: currentConfig,
|
|
1146
|
-
agents: currentAgents,
|
|
1147
|
-
},
|
|
1148
|
-
seed,
|
|
1149
|
-
reservation,
|
|
1150
|
-
);
|
|
1151
|
-
if (pending.exitCode !== -1) {
|
|
1152
|
-
if (clonedSession) {
|
|
1153
|
-
await cleanupTrackedSessionDir(
|
|
1154
|
-
clonedSession.sessionDir,
|
|
1155
|
-
`Could not discard failed resume session clone for run #${runId}`,
|
|
1156
|
-
);
|
|
1157
|
-
}
|
|
1158
|
-
await discardUnusedWorktree(continuationWorktree);
|
|
1159
|
-
if (ownsResumeReservation(thread, reservation)) thread.state = previousState;
|
|
1160
|
-
return pending;
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
// The cloned branch replaces the removed-worktree session for this
|
|
1164
|
-
// logical id. Keep an undeletable old dir in runtime cleanup if needed.
|
|
1165
|
-
if (clonedSession && previousSessionDir && previousSessionDir !== clonedSession.sessionDir) {
|
|
1166
|
-
try {
|
|
1167
|
-
await rm(previousSessionDir, { recursive: true, force: true });
|
|
1168
|
-
runtime.sessionDirs.delete(previousSessionDir);
|
|
1169
|
-
} catch {
|
|
1170
|
-
/* shutdown retries cleanup of the old retained branch */
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
return pending;
|
|
1174
|
-
} catch (error) {
|
|
1175
|
-
if (clonedSession) {
|
|
1176
|
-
await cleanupTrackedSessionDir(
|
|
1177
|
-
clonedSession.sessionDir,
|
|
1178
|
-
`Could not discard interrupted resume session clone for run #${runId}`,
|
|
1179
|
-
);
|
|
1180
|
-
}
|
|
1181
|
-
await discardUnusedWorktree(continuationWorktree);
|
|
1182
|
-
if (ownsResumeReservation(thread, reservation)) {
|
|
1183
|
-
thread.state = previousState;
|
|
1184
|
-
thread.sessionId = previousSessionId;
|
|
1185
|
-
thread.sessionDir = previousSessionDir;
|
|
1186
|
-
thread.executionCwd = previousExecutionCwd;
|
|
1187
|
-
}
|
|
1188
|
-
return failedStartResult(
|
|
1189
|
-
thread.agentName,
|
|
1190
|
-
requestedObjective ?? thread.task,
|
|
1191
|
-
`Could not resume run #${runId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1192
|
-
);
|
|
1193
|
-
} finally {
|
|
1194
|
-
finishPreflight();
|
|
1195
|
-
if (
|
|
1196
|
-
thread.lifecycleOperation === "resume" &&
|
|
1197
|
-
thread.lifecycleVersion === reservation.version
|
|
1198
|
-
) {
|
|
1199
|
-
thread.lifecycleOperation = undefined;
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
};
|
|
1203
|
-
|
|
1204
|
-
thread.fork = async (objective?: string, forkCtx?: ExtensionContext): Promise<SingleResult> => {
|
|
1205
|
-
const forkObjective = objective?.trim();
|
|
1206
|
-
if (!runtime.sessionActive || runtime.threads.get(runId) !== thread) {
|
|
1207
|
-
return failedStartResult(thread.agentName, thread.task, `Run #${runId} belongs to a parent session that has shut down.`);
|
|
1208
|
-
}
|
|
1209
|
-
if (objective !== undefined && !forkObjective) {
|
|
1210
|
-
return failedStartResult(thread.agentName, thread.task, "fork objective must be non-blank when provided.");
|
|
1211
|
-
}
|
|
1212
|
-
if (thread.retired || thread.state === "stopped") {
|
|
1213
|
-
return failedStartResult(thread.agentName, thread.task, `Run #${runId} was retired by subagent_stop and cannot be forked.`);
|
|
1214
|
-
}
|
|
1215
|
-
if (thread.lifecycleOperation) {
|
|
1216
|
-
return failedStartResult(thread.agentName, thread.task, `Run #${runId} is already ${thread.lifecycleOperation === "resume" ? "resuming" : "being forked"}.`);
|
|
1217
|
-
}
|
|
1218
|
-
if (thread.state === "queued" && !thread.sessionId) {
|
|
1219
|
-
return failedStartResult(thread.agentName, thread.task, `Run #${runId} is queued and has no retained session to fork.`);
|
|
1220
|
-
}
|
|
1221
|
-
if (["queued", "running", "steering", "interrupting"].includes(thread.state)) {
|
|
1222
|
-
return failedStartResult(thread.agentName, thread.task, `Run #${runId} is active; park it first with subagent_control { action: "park", id: ${runId} }, then fork the stable session.`);
|
|
1223
|
-
}
|
|
1224
|
-
if (!["parked", "completed", "failed"].includes(thread.state)) {
|
|
1225
|
-
return failedStartResult(thread.agentName, thread.task, `Run #${runId} is ${thread.state} and has no forkable retained checkpoint.`);
|
|
1226
|
-
}
|
|
1227
|
-
if (!thread.sessionId || !thread.sessionDir) {
|
|
1228
|
-
return failedStartResult(thread.agentName, thread.task, `Run #${runId} has no retained session to fork (it may have been parked before starting).`);
|
|
1229
|
-
}
|
|
1230
|
-
if (thread.isolation === "worktree") {
|
|
1231
|
-
const worktreeState = thread.worktree?.state;
|
|
1232
|
-
const seedIntegrated =
|
|
1233
|
-
worktreeState === "integrated" ||
|
|
1234
|
-
worktreeState === "no_changes" ||
|
|
1235
|
-
thread.lastResult?.integrationApplied === true;
|
|
1236
|
-
if (!seedIntegrated) {
|
|
1237
|
-
return failedStartResult(
|
|
1238
|
-
thread.agentName,
|
|
1239
|
-
thread.task,
|
|
1240
|
-
`Run #${runId}'s isolated checkpoint has not been integrated. Resume and settle it before forking so its seed is applied exactly once.`,
|
|
1241
|
-
);
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
// Same lifecycle CAS as resume: a concurrent resume/fork cannot consume
|
|
1246
|
-
// or clone this session while the branch copy is in progress.
|
|
1247
|
-
const forkVersion = ++thread.lifecycleVersion;
|
|
1248
|
-
const forkGeneration = thread.generation;
|
|
1249
|
-
const forkSessionId = thread.sessionId;
|
|
1250
|
-
const forkSessionDir = thread.sessionDir;
|
|
1251
|
-
const ownsFork = (): boolean =>
|
|
1252
|
-
runtime.sessionActive &&
|
|
1253
|
-
runtime.threads.get(runId) === thread &&
|
|
1254
|
-
!thread.retired &&
|
|
1255
|
-
thread.lifecycleOperation === "fork" &&
|
|
1256
|
-
thread.lifecycleVersion === forkVersion &&
|
|
1257
|
-
thread.generation === forkGeneration &&
|
|
1258
|
-
thread.sessionId === forkSessionId &&
|
|
1259
|
-
thread.sessionDir === forkSessionDir;
|
|
1260
|
-
thread.lifecycleOperation = "fork";
|
|
1261
|
-
const finishPreflight = beginPreflight();
|
|
1262
|
-
let childWorktree: WorktreeIsolation | undefined;
|
|
1263
|
-
let forkedSession: Awaited<ReturnType<typeof forkRetainedSession>> | undefined;
|
|
1264
|
-
try {
|
|
1265
|
-
await thread.generationCompletion;
|
|
1266
|
-
if (!ownsFork()) {
|
|
1267
|
-
return failedStartResult(thread.agentName, thread.task, `Run #${runId} changed while fork was preparing; no child was started.`);
|
|
1268
|
-
}
|
|
1269
|
-
const currentCtx = forkCtx ?? runCtx;
|
|
1270
|
-
if (thread.isolation === "worktree") {
|
|
1271
|
-
if (!thread.worktree) throw new Error(`Run #${runId} has no isolated worktree checkpoint.`);
|
|
1272
|
-
const seedAlreadyIntegrated =
|
|
1273
|
-
thread.worktree.state === "integrated" ||
|
|
1274
|
-
thread.worktree.state === "no_changes" ||
|
|
1275
|
-
thread.lastResult?.integrationApplied === true;
|
|
1276
|
-
childWorktree = await createContinuationWorktree(thread.worktree, seedAlreadyIntegrated);
|
|
1277
|
-
if (!ownsFork()) throw new Error(`Run #${runId} changed while its fork worktree was being created.`);
|
|
1278
|
-
}
|
|
1279
|
-
forkedSession = await forkRetainedSession({
|
|
1280
|
-
cwd: thread.executionCwd,
|
|
1281
|
-
targetCwd: childWorktree?.cwd ?? thread.cwd,
|
|
1282
|
-
sessionDir: thread.sessionDir,
|
|
1283
|
-
sessionId: thread.sessionId,
|
|
1284
|
-
});
|
|
1285
|
-
runtime.sessionDirs.add(forkedSession.sessionDir);
|
|
1286
|
-
if (!ownsFork()) throw new Error(`Run #${runId} changed while its retained session was being forked.`);
|
|
1287
|
-
const currentConfig = await loadConfig(runtime.configPath);
|
|
1288
|
-
if (!ownsFork()) throw new Error(`Run #${runId} changed while fork configuration was loading.`);
|
|
1289
|
-
runtime.backgroundQueue.setConcurrency(currentConfig.maxConcurrency);
|
|
1290
|
-
const currentAgents = discoverAgents(currentCtx.cwd, {
|
|
1291
|
-
scope: currentConfig.agentScope,
|
|
1292
|
-
enabledNames: currentConfig.enabledAgents,
|
|
1293
|
-
projectTrusted: currentCtx.isProjectTrusted?.() === true,
|
|
1294
|
-
}).agents;
|
|
1295
|
-
if (!ownsFork()) throw new Error(`Run #${runId} changed while fork was preparing; no child was started.`);
|
|
1296
|
-
const childTask = forkObjective ?? thread.task;
|
|
1297
|
-
const child = await startBackground(
|
|
1298
|
-
thread.agentName,
|
|
1299
|
-
childTask,
|
|
1300
|
-
thread.cwd,
|
|
1301
|
-
thread.vision,
|
|
1302
|
-
thread.isolation,
|
|
1303
|
-
undefined,
|
|
1304
|
-
false,
|
|
1305
|
-
{
|
|
1306
|
-
ctx: currentCtx,
|
|
1307
|
-
config: currentConfig,
|
|
1308
|
-
agents: currentAgents,
|
|
1309
|
-
},
|
|
1310
|
-
{
|
|
1311
|
-
sessionId: forkedSession.sessionId,
|
|
1312
|
-
sessionDir: forkedSession.sessionDir,
|
|
1313
|
-
prompt: forkObjective ?? FORK_CONTINUATION_PROMPT,
|
|
1314
|
-
worktree: childWorktree,
|
|
1315
|
-
forkedFromRunId: runId,
|
|
1316
|
-
forkObjective,
|
|
1317
|
-
},
|
|
1318
|
-
);
|
|
1319
|
-
if (child.exitCode !== -1 || child.runId === undefined) {
|
|
1320
|
-
await cleanupTrackedSessionDir(
|
|
1321
|
-
forkedSession.sessionDir,
|
|
1322
|
-
`Could not discard failed fork session clone for run #${runId}`,
|
|
1323
|
-
);
|
|
1324
|
-
await discardUnusedWorktree(childWorktree);
|
|
1325
|
-
return child;
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
// Once the independent child is enqueued it remains valid even if the
|
|
1329
|
-
// source is retired; just skip source-side relationship mutation.
|
|
1330
|
-
if (!ownsFork()) return child;
|
|
1331
|
-
const childRunId = child.runId;
|
|
1332
|
-
if (!thread.forkChildRunIds.includes(childRunId)) thread.forkChildRunIds.push(childRunId);
|
|
1333
|
-
const childThread = runtime.threads.get(childRunId);
|
|
1334
|
-
if (childThread) childThread.forkedFromRunId = runId;
|
|
1335
|
-
monitor.setForkRelation(runId, childRunId);
|
|
1336
|
-
const sourceResult = runtime.settledRuns.get(runId) ?? thread.lastResult;
|
|
1337
|
-
if (sourceResult) sourceResult.forkChildRunIds = [...thread.forkChildRunIds];
|
|
1338
|
-
return child;
|
|
1339
|
-
} catch (error) {
|
|
1340
|
-
if (forkedSession) {
|
|
1341
|
-
await cleanupTrackedSessionDir(
|
|
1342
|
-
forkedSession.sessionDir,
|
|
1343
|
-
`Could not discard interrupted fork session clone for run #${runId}`,
|
|
1344
|
-
);
|
|
1345
|
-
}
|
|
1346
|
-
await discardUnusedWorktree(childWorktree);
|
|
1347
|
-
return failedStartResult(
|
|
1348
|
-
thread.agentName,
|
|
1349
|
-
forkObjective ?? thread.task,
|
|
1350
|
-
`Could not fork retained session for run #${runId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1351
|
-
);
|
|
1352
|
-
} finally {
|
|
1353
|
-
finishPreflight();
|
|
1354
|
-
if (thread.lifecycleVersion === forkVersion && thread.lifecycleOperation === "fork") {
|
|
1355
|
-
thread.lifecycleOperation = undefined;
|
|
1356
|
-
}
|
|
1357
|
-
}
|
|
1358
|
-
};
|
|
1359
|
-
|
|
1360
|
-
const onLive = makeLiveHandler(runId, generation);
|
|
1361
|
-
const queueController = runtime.backgroundQueue.enqueue(
|
|
1362
|
-
async (backgroundSignal) => {
|
|
1363
|
-
if (runtime.threads.get(runId)?.generation !== generation) return;
|
|
1364
|
-
let result: SingleResult;
|
|
1365
|
-
try {
|
|
1366
|
-
result = await runSingleAgentWithMainFallback(
|
|
1367
|
-
{
|
|
1368
|
-
defaultCwd: executionCwd,
|
|
1369
|
-
agent: route.agent,
|
|
1370
|
-
agentName,
|
|
1371
|
-
task,
|
|
1372
|
-
cwd: executionCwd,
|
|
1373
|
-
thinkingLevel,
|
|
1374
|
-
thinkingLevelForModel: route.thinkingLevelForModel,
|
|
1375
|
-
signal: backgroundSignal,
|
|
1376
|
-
onLive,
|
|
1377
|
-
control,
|
|
1378
|
-
makeDetails: makeDetails("single", true),
|
|
1379
|
-
idleTimeoutMs: runConfig.idleTimeoutSec * 1000,
|
|
1380
|
-
...(priorSessionId && priorSessionDir
|
|
1381
|
-
? {
|
|
1382
|
-
sessionId: priorSessionId,
|
|
1383
|
-
sessionDir: priorSessionDir,
|
|
1384
|
-
stdinText: seed?.prompt ?? (newObjectiveOnResume
|
|
1385
|
-
? task
|
|
1386
|
-
: buildResumePrompt(priorTask ?? task, "the retained thread was resumed")),
|
|
1387
|
-
}
|
|
1388
|
-
: {}),
|
|
1389
|
-
},
|
|
1390
|
-
route.mainFallbackRef,
|
|
1391
|
-
);
|
|
1392
|
-
} catch (error) {
|
|
1393
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1394
|
-
result = {
|
|
1395
|
-
...pending,
|
|
1396
|
-
task: control.getObjective(),
|
|
1397
|
-
exitCode: 1,
|
|
1398
|
-
stderr: errorMessage,
|
|
1399
|
-
stopReason: backgroundSignal.aborted ? "aborted" : "error",
|
|
1400
|
-
errorMessage,
|
|
1401
|
-
dispatchFailed: true,
|
|
1402
|
-
};
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
// A stale process/generation may finish after a park/resume race. It owns
|
|
1406
|
-
// no monitor mutation, result registration, or completion delivery.
|
|
1407
|
-
if (runtime.threads.get(runId)?.generation !== generation) return;
|
|
1408
|
-
result.runId = runId;
|
|
1409
|
-
result.projectCwd = originalCwd;
|
|
1410
|
-
result.isolation = isolation;
|
|
1411
|
-
result.forkedFromRunId = thread.forkedFromRunId;
|
|
1412
|
-
result.forkChildRunIds = [...thread.forkChildRunIds];
|
|
1413
|
-
thread.queueController = undefined;
|
|
1414
|
-
runtime.runControllers.delete(runId);
|
|
1415
|
-
thread.task = result.task;
|
|
1416
|
-
thread.sessionId = result.sessionId;
|
|
1417
|
-
thread.sessionDir = result.sessionDir;
|
|
1418
|
-
thread.lastResult = result;
|
|
1419
|
-
runtime.retainSession(result);
|
|
1420
|
-
monitor.setModel(runId, result.model, result.modelFallbackFrom);
|
|
1421
|
-
monitor.setThinking(runId, result.thinking);
|
|
1422
|
-
|
|
1423
|
-
// Destructive stop owns publication once it has synchronously claimed
|
|
1424
|
-
// the lifecycle. Leave the partial result/session on the thread; the
|
|
1425
|
-
// stop path waits for this queue task, finalizes isolation, and emits
|
|
1426
|
-
// exactly one aborted result.
|
|
1427
|
-
if (thread.lifecycleOperation === "stop") return;
|
|
1428
|
-
|
|
1429
|
-
if (result.parked) {
|
|
1430
|
-
thread.state = "parked";
|
|
1431
|
-
monitor.setStatus(runId, "parked");
|
|
1432
|
-
runtime.settledRuns.delete(runId);
|
|
1433
|
-
return;
|
|
1434
|
-
}
|
|
1435
|
-
|
|
1436
|
-
if (thread.retireOnSettle) runtime.retireThreadSession(thread);
|
|
1437
|
-
const wantsFixLoop = shouldTriggerFixLoop(result, runConfig);
|
|
1438
|
-
if (wantsFixLoop && isolation === "shared" && runtime.sessionActive) {
|
|
1439
|
-
thread.state = "running";
|
|
1440
|
-
// The review being done does not mean the logical run is over:
|
|
1441
|
-
// the same row now represents the chain until it resolves.
|
|
1442
|
-
monitor.setStatus(runId, "running");
|
|
1443
|
-
monitor.setActivity(runId, "auto-fix chain running");
|
|
1444
|
-
startFixLoop(result, `fix-${runId}`, runId, thread.executionCwd, vision);
|
|
1445
|
-
return;
|
|
1446
|
-
}
|
|
1447
|
-
// Claim terminal settlement synchronously before the first slow await.
|
|
1448
|
-
// Park therefore either wins while RPC is still active, or is rejected
|
|
1449
|
-
// once settlement owns the generation. Destructive stop may supersede
|
|
1450
|
-
// this reservation; publication is revalidated after Git finalization.
|
|
1451
|
-
const settlementVersion = ++thread.lifecycleVersion;
|
|
1452
|
-
thread.lifecycleOperation = "settle";
|
|
1453
|
-
const ownsSettlement = (): boolean =>
|
|
1454
|
-
runtime.threads.get(runId) === thread &&
|
|
1455
|
-
thread.generation === generation &&
|
|
1456
|
-
thread.lifecycleVersion === settlementVersion &&
|
|
1457
|
-
thread.lifecycleOperation === "settle" &&
|
|
1458
|
-
!thread.retired;
|
|
1459
|
-
try {
|
|
1460
|
-
// Worktree isolation is rejected for reviewers, the only role that can
|
|
1461
|
-
// trigger auto-fix. Keep that invariant explicit: an isolated result is
|
|
1462
|
-
// finalized once here and can never start a chain that would integrate
|
|
1463
|
-
// the same worktree early.
|
|
1464
|
-
await thread.finalizeIsolation(generation, result);
|
|
1465
|
-
if (!ownsSettlement()) return;
|
|
1466
|
-
|
|
1467
|
-
const failed = isFailedResult(result);
|
|
1468
|
-
thread.state = failed ? "failed" : "completed";
|
|
1469
|
-
// Stamp the terminal monitor state before projecting it. This gives every
|
|
1470
|
-
// path a fixed endedAt even when the row is removed immediately.
|
|
1471
|
-
monitor.setStatus(runId, failed ? "failed" : "done");
|
|
1472
|
-
if (!runtime.sessionActive || !ownsSettlement()) return;
|
|
1473
|
-
|
|
1474
|
-
const modelLevel = failed && isModelLevelFailure(result);
|
|
1475
|
-
const dispatchFailed = result.dispatchFailed === true;
|
|
1476
|
-
finishRun(runId, failed ? "failed" : "done", modelLevel || dispatchFailed ? { silent: true } : undefined);
|
|
1477
|
-
runtime.registerRunResult(runId, result);
|
|
1478
|
-
const completion: CompletionMessageItem = {
|
|
1479
|
-
agent: result.agent,
|
|
1480
|
-
block: modelLevel
|
|
1481
|
-
? `${formatCompletionBlock(result, runConfig.maxResultLines, result.projectCwd ?? originalCwd)}\n\n${modelLevelTakeoverNote(result, { runId })}`
|
|
1482
|
-
: formatCompletionBlock(result, runConfig.maxResultLines, result.projectCwd ?? originalCwd),
|
|
1483
|
-
triggerTurn: completionTriggersTurn(result, runConfig.notifyOnReviewPass),
|
|
1484
|
-
};
|
|
1485
|
-
if (modelLevel) {
|
|
1486
|
-
const detail = result.errorMessage?.trim() || "model unavailable or broken";
|
|
1487
|
-
runCtx.ui.notify(`✗ ${result.agent} dispatch failed: ${detail} — task handed to the main window`, "error");
|
|
1488
|
-
} else if (dispatchFailed) {
|
|
1489
|
-
runCtx.ui.notify(`✗ ${result.agent} dispatch failed: ${result.errorMessage ?? "dispatch crashed"}`, "error");
|
|
1490
|
-
}
|
|
1491
|
-
if (failed) {
|
|
1492
|
-
runtime.sendCompletionGroup([completion]);
|
|
1493
|
-
runtime.completionBatcher.flush();
|
|
1494
|
-
} else {
|
|
1495
|
-
runtime.completionBatcher.push(completion);
|
|
1496
|
-
}
|
|
1497
|
-
} finally {
|
|
1498
|
-
if (ownsSettlement()) thread.lifecycleOperation = undefined;
|
|
1499
|
-
}
|
|
1500
|
-
},
|
|
1501
|
-
() => {
|
|
1502
|
-
if (runtime.threads.get(runId)?.generation !== generation) return;
|
|
1503
|
-
// Queued park/stop owns publication and may still be finalizing an
|
|
1504
|
-
// isolated worktree. Do not expose a terminal monitor state before
|
|
1505
|
-
// that owner records the checkpoint or aborted result.
|
|
1506
|
-
if (thread.lifecycleOperation === "park" || thread.lifecycleOperation === "stop") return;
|
|
1507
|
-
runtime.runControllers.delete(runId);
|
|
1508
|
-
thread.queueController = undefined;
|
|
1509
|
-
if (thread.state === "parked") {
|
|
1510
|
-
monitor.setStatus(runId, "parked");
|
|
1511
|
-
return;
|
|
1512
|
-
}
|
|
1513
|
-
thread.state = "stopped";
|
|
1514
|
-
monitor.setStatus(runId, "failed");
|
|
1515
|
-
if (!runtime.sessionActive) {
|
|
1516
|
-
monitor.removeRun(runId);
|
|
1517
|
-
return;
|
|
1518
|
-
}
|
|
1519
|
-
finishRun(runId, "failed");
|
|
1520
|
-
},
|
|
1521
|
-
async (error) => {
|
|
1522
|
-
if (runtime.threads.get(runId)?.generation !== generation) return;
|
|
1523
|
-
// Queue-level crashes use the same settlement reservation as ordinary
|
|
1524
|
-
// results. A concurrent destructive stop may supersede it while slow
|
|
1525
|
-
// worktree finalization is running, in which case stop publishes once.
|
|
1526
|
-
if (thread.lifecycleOperation === "stop") return;
|
|
1527
|
-
const settlementVersion = ++thread.lifecycleVersion;
|
|
1528
|
-
thread.lifecycleOperation = "settle";
|
|
1529
|
-
const ownsSettlement = (): boolean =>
|
|
1530
|
-
runtime.threads.get(runId) === thread &&
|
|
1531
|
-
thread.generation === generation &&
|
|
1532
|
-
thread.lifecycleVersion === settlementVersion &&
|
|
1533
|
-
thread.lifecycleOperation === "settle" &&
|
|
1534
|
-
!thread.retired;
|
|
1535
|
-
try {
|
|
1536
|
-
const crashed: SingleResult = {
|
|
1537
|
-
...dispatchFailedResult(route.agent, control.getObjective(), error, thinkingLevel),
|
|
1538
|
-
runId,
|
|
1539
|
-
isolation,
|
|
1540
|
-
forkedFromRunId: thread.forkedFromRunId,
|
|
1541
|
-
};
|
|
1542
|
-
await thread.finalizeIsolation(generation, crashed);
|
|
1543
|
-
if (!ownsSettlement()) return;
|
|
1544
|
-
thread.state = "failed";
|
|
1545
|
-
monitor.setStatus(runId, "failed");
|
|
1546
|
-
finishRun(runId, "failed", { silent: true });
|
|
1547
|
-
runtime.registerRunResult(runId, crashed);
|
|
1548
|
-
runtime.runControllers.delete(runId);
|
|
1549
|
-
thread.queueController = undefined;
|
|
1550
|
-
if (!runtime.sessionActive || !ownsSettlement()) return;
|
|
1551
|
-
try {
|
|
1552
|
-
runCtx.ui.notify(`✗ ${agent.name} dispatch failed: ${crashed.errorMessage}`, "error");
|
|
1553
|
-
runtime.sendCompletionGroup([
|
|
1554
|
-
{
|
|
1555
|
-
agent: agent.name,
|
|
1556
|
-
block: formatCompletionBlock(crashed, runConfig.maxResultLines, crashed.projectCwd ?? originalCwd),
|
|
1557
|
-
triggerTurn: true,
|
|
1558
|
-
},
|
|
1559
|
-
]);
|
|
1560
|
-
runtime.completionBatcher.flush();
|
|
1561
|
-
} catch {
|
|
1562
|
-
/* a second delivery failure must not throw through the queue */
|
|
1563
|
-
}
|
|
1564
|
-
} finally {
|
|
1565
|
-
if (ownsSettlement()) thread.lifecycleOperation = undefined;
|
|
1566
|
-
}
|
|
1567
|
-
},
|
|
1568
|
-
);
|
|
1569
|
-
thread.queueController = queueController;
|
|
1570
|
-
thread.generationCompletion = runtime.backgroundQueue.waitForTask(queueController);
|
|
1571
|
-
runtime.runControllers.set(runId, queueController);
|
|
1572
|
-
return pending;
|
|
1573
|
-
};
|
|
597
|
+
const startBackground = createBackgroundDispatcher({
|
|
598
|
+
runtime,
|
|
599
|
+
ctx,
|
|
600
|
+
config,
|
|
601
|
+
agents,
|
|
602
|
+
finishRun,
|
|
603
|
+
makeLiveHandler,
|
|
604
|
+
makeDetails,
|
|
605
|
+
startFixLoop,
|
|
606
|
+
});
|
|
1574
607
|
|
|
1575
608
|
// Sub-agents intentionally detach from the foreground turn. This makes the
|
|
1576
609
|
// editor available immediately; completion messages later wake the main agent.
|