@ferris1225/pi-subagents 4.1.4 → 4.1.6
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 +129 -116
- package/agents/cleaner.md +3 -2
- package/agents/documenter.md +6 -6
- package/agents/reviewer.md +8 -3
- package/agents/worker.md +4 -4
- package/package.json +1 -1
- package/src/config.ts +7 -5
- package/src/dispatch.ts +143 -43
- package/src/fixloop.ts +58 -32
- package/src/monitor.ts +54 -6
- package/src/prompt.ts +24 -26
- package/src/rpc-run.ts +26 -2
- package/src/thread-lifecycle.ts +103 -17
- package/src/tools.ts +49 -5
- package/src/widget.ts +94 -13
- package/src/worktree.ts +12 -0
package/src/prompt.ts
CHANGED
|
@@ -29,14 +29,7 @@ export function buildDelegationDirective(
|
|
|
29
29
|
...(hasWorker ? ["worker"] : []),
|
|
30
30
|
...(hasCleaner ? ["cleaner"] : []),
|
|
31
31
|
];
|
|
32
|
-
const reviewedWriterNames = [
|
|
33
|
-
...codeWriterNames,
|
|
34
|
-
...(hasDocumenter ? ["documenter"] : []),
|
|
35
|
-
];
|
|
36
|
-
const automaticWriterRoute = [
|
|
37
|
-
...(hasReviewer ? ["reviewer"] : []),
|
|
38
|
-
...(hasDocumenter ? ["documenter"] : []),
|
|
39
|
-
].join(" → ");
|
|
32
|
+
const reviewedWriterNames = [...codeWriterNames];
|
|
40
33
|
const namedWorktreeTargets = [
|
|
41
34
|
...(hasWorker ? ["worker"] : []),
|
|
42
35
|
...(hasCleaner ? ["cleaner"] : []),
|
|
@@ -47,34 +40,43 @@ export function buildDelegationDirective(
|
|
|
47
40
|
: namedWorktreeTargets.length === 1
|
|
48
41
|
? `${namedWorktreeTargets[0]} or another`
|
|
49
42
|
: `${namedWorktreeTargets.slice(0, -1).join(", ")}, ${namedWorktreeTargets.at(-1)}, or another`;
|
|
43
|
+
const managedWriterWorkflowRule = reviewedWriterNames.length === 0
|
|
44
|
+
? undefined
|
|
45
|
+
: hasReviewer && hasDocumenter
|
|
46
|
+
? `Successful top-level ${reviewedWriterNames.join("/")} runs continue through the enabled reviewer gate. Only REVIEW_PASS can authorize documenter, which runs for DOCUMENTATION: NEEDED or a missing marker; the workflow delivers once. Never duplicate stages.`
|
|
47
|
+
: hasReviewer
|
|
48
|
+
? `Successful top-level ${reviewedWriterNames.join("/")} runs continue through the enabled reviewer gate and then deliver once; never duplicate the gate.`
|
|
49
|
+
: hasDocumenter
|
|
50
|
+
? `With reviewer disabled, successful top-level ${reviewedWriterNames.join("/")} runs use documenter as the conservative final fallback and then deliver once; never duplicate the fallback.`
|
|
51
|
+
: undefined;
|
|
50
52
|
|
|
51
53
|
const dispatchRules = [
|
|
52
|
-
"
|
|
54
|
+
"Keep small, known-target work in the main thread with direct tools: lookups and focused reads/edits do not justify a child context.",
|
|
53
55
|
...(hasExplorer
|
|
54
56
|
? [
|
|
55
|
-
"Use `explorer` proactively
|
|
57
|
+
"Use `explorer` proactively only for broad or cross-file reconnaissance: mapping unfamiliar code, tracing symbols/dependencies, or finding multi-file references. It is a lightweight retrieval index, never an automatic gate. Re-read load-bearing files before edits or high-risk decisions. Use a stronger model/specialist for dynamic, concurrent, migration, or security analysis.",
|
|
56
58
|
]
|
|
57
59
|
: []),
|
|
58
60
|
...(hasWorker
|
|
59
|
-
? ["Use `worker` for a self-contained implementation, fix, refactor, or test
|
|
61
|
+
? ["Use `worker` for a self-contained implementation, fix, refactor, or test whose separate context pays for itself—not a small known-target edit."]
|
|
60
62
|
: []),
|
|
61
63
|
...(hasCleaner
|
|
62
64
|
? [
|
|
63
|
-
`Use \`cleaner\` only for user-authorized cleanup, removal, simplification, duplicate-code consolidation, or maintenance
|
|
65
|
+
`Use \`cleaner\` only as the separate evidence-first entry for user-authorized cleanup, removal, simplification, duplicate-code consolidation, or maintenance; never substitute it for \`worker\`. It applies every safe proven in-scope cut without item-by-item approval. Generic or read-only audit, review, code-health, plan, or cleanup-candidate assessment goes to ${hasReviewer ? "`reviewer`" : "direct main-context inspection because `reviewer` is disabled"}. Never dispatch cleaner by PR count or as the pre-commit gate.`,
|
|
64
66
|
]
|
|
65
67
|
: []),
|
|
66
68
|
...(hasDocumenter
|
|
67
69
|
? [
|
|
68
|
-
`Use \`documenter\` directly for explicit whole-codebase maintenance or standalone documentation work.${codeWriterNames.length > 0 ? `
|
|
70
|
+
`Use \`documenter\` directly only for explicit whole-codebase maintenance or standalone documentation/comment work; a top-level documenter delivers directly without an automatic reviewer.${codeWriterNames.length > 0 ? ` ${codeWriterNames.join("/")} must sync existing docs they directly affect; runtime runs documenter only after REVIEW_PASS with DOCUMENTATION: NEEDED or a missing marker, or as the reviewer-disabled fallback—never dispatch a duplicate.` : ""} It never changes runtime behavior, versions, or release state.`,
|
|
69
71
|
]
|
|
70
72
|
: []),
|
|
71
73
|
...(hasReviewer
|
|
72
74
|
? [
|
|
73
|
-
`Use \`reviewer\` for read-only assessments or
|
|
75
|
+
`Use \`reviewer\` for read-only assessments or a gate.${reviewedWriterNames.length > 0 ? ` Successful ${reviewedWriterNames.join("/")} runs already get one fresh read-only reviewer gate, independent of the writer.` : ""} Advisory output has no VERDICT and cannot authorize follow-up edits${hasDocumenter ? "; gates classify docs separately for the enabled documenter." : "."}`,
|
|
74
76
|
]
|
|
75
77
|
: []),
|
|
76
|
-
"Brief
|
|
77
|
-
"Children are leaf processes without delegation tools
|
|
78
|
+
"Brief each child with the complete goal, exact paths, constraints, and expected output; it has no conversation memory.",
|
|
79
|
+
"Children are leaf processes without delegation tools; use `subagent_control fork` on a parked/settled thread for an independent continuation.",
|
|
78
80
|
...(hasMultiple
|
|
79
81
|
? [
|
|
80
82
|
"Dispatch independent work in one `tasks` array and let the resumed main agent start dependent work only after prerequisites finish.",
|
|
@@ -86,24 +88,20 @@ export function buildDelegationDirective(
|
|
|
86
88
|
];
|
|
87
89
|
|
|
88
90
|
const handoffRules = [
|
|
89
|
-
"Dispatch
|
|
90
|
-
"Use `subagent_wait` with explicit `timeoutMs` only when the user
|
|
91
|
-
"
|
|
91
|
+
"Dispatch ends this turn; results resume the main agent, even mid-turn. Never sleep, poll, or call `subagent_wait` to hold the turn.",
|
|
92
|
+
"Use `subagent_wait` with explicit `timeoutMs` only when the user asks to wait in-turn; its default lookup is non-blocking.",
|
|
93
|
+
"Results are already shown. Do not restate, paraphrase, or re-summarize them; add only your conclusion or next action.",
|
|
92
94
|
"A delivered result does not mean siblings are finished. Before declaring the overall task done, use `subagent_status` to confirm that no runs remain active.",
|
|
93
95
|
];
|
|
94
96
|
|
|
95
97
|
const verificationRules = [
|
|
96
98
|
"Never report an unrun check as passed; identify unavailable checks and pre-existing failures honestly.",
|
|
97
|
-
...(
|
|
98
|
-
? [
|
|
99
|
-
`Successful top-level write roles automatically continue through enabled downstream roles (${automaticWriterRoute}) to one final delivery; never duplicate stages.`,
|
|
100
|
-
]
|
|
101
|
-
: []),
|
|
99
|
+
...(managedWriterWorkflowRule ? [managedWriterWorkflowRule] : []),
|
|
102
100
|
...(hasReviewer
|
|
103
101
|
? [
|
|
104
102
|
...(hasDocumenter
|
|
105
103
|
? [
|
|
106
|
-
`A direct REVIEW_PASS
|
|
104
|
+
`A direct REVIEW_PASS with DOCUMENTATION: CLEAN delivers immediately; NEEDED or a missing marker runs one documentation sync. A direct REVIEW_FAIL ${autoFixEnabled ? "keeps bounded worker/reviewer auto-fix, with docs considered only after its terminal REVIEW_PASS." : "cannot start fixes while worker/fix rounds are disabled."}`,
|
|
107
105
|
]
|
|
108
106
|
: []),
|
|
109
107
|
"Resolve every gate finding; do not bypass the configured auto-fix/re-review cap. A reviewer report without a standalone VERDICT is advisory and cannot trigger writes.",
|
|
@@ -116,7 +114,7 @@ export function buildDelegationDirective(
|
|
|
116
114
|
return `
|
|
117
115
|
## Sub-agent delegation (pi-subagents)
|
|
118
116
|
|
|
119
|
-
The \`subagent\` tool starts
|
|
117
|
+
The \`subagent\` tool starts isolated Pi child processes and context windows. Completions automatically resume the main agent.
|
|
120
118
|
|
|
121
119
|
Available agents:
|
|
122
120
|
${catalog}
|
package/src/rpc-run.ts
CHANGED
|
@@ -788,8 +788,32 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
788
788
|
if (!closed) await processClosed.promise;
|
|
789
789
|
return;
|
|
790
790
|
}
|
|
791
|
-
|
|
792
|
-
|
|
791
|
+
// Bound the abort settlement exactly like stop: a child that never
|
|
792
|
+
// settles after abort must not hold the control operation forever.
|
|
793
|
+
let parkTimer: ReturnType<typeof setTimeout> | undefined;
|
|
794
|
+
let parkTimedOut = false;
|
|
795
|
+
const parkDeadline = new Promise<boolean>((resolve) => {
|
|
796
|
+
parkTimer = setTimeout(() => {
|
|
797
|
+
parkTimedOut = true;
|
|
798
|
+
resolve(false);
|
|
799
|
+
}, RPC_ABORT_SETTLE_TIMEOUT_MS);
|
|
800
|
+
if (typeof parkTimer.unref === "function") parkTimer.unref();
|
|
801
|
+
});
|
|
802
|
+
let accepted: boolean;
|
|
803
|
+
try {
|
|
804
|
+
accepted = await Promise.race([abortAcceptedPrompt(), parkDeadline]);
|
|
805
|
+
} catch {
|
|
806
|
+
/* a rejected abort still parks; termination below is the bounded fallback */
|
|
807
|
+
accepted = false;
|
|
808
|
+
} finally {
|
|
809
|
+
if (parkTimer) clearTimeout(parkTimer);
|
|
810
|
+
}
|
|
811
|
+
if (abortSettlement) {
|
|
812
|
+
const stable = abortSettlement;
|
|
813
|
+
abortSettlement = undefined;
|
|
814
|
+
stable.resolve();
|
|
815
|
+
}
|
|
816
|
+
if (!accepted && !parkTimedOut && !closed) await processClosed.promise;
|
|
793
817
|
if (finished && accepted) throw new Error("Thread exited while parking.");
|
|
794
818
|
markParked();
|
|
795
819
|
setAttemptPhase("parked");
|
package/src/thread-lifecycle.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Stable logical-thread generation lifecycle for background sub-agents.
|
|
3
3
|
*
|
|
4
|
-
* Dispatch owns workflow policy
|
|
4
|
+
* Dispatch owns workflow policy, the live stage projection, and internal role
|
|
5
|
+
* briefs; this module owns one
|
|
5
6
|
* stable parent generation end to end: managed-repository lane use,
|
|
6
7
|
* worktree setup/finalization after downstream review, queue/process ownership,
|
|
7
8
|
* retained-session resume/fork, and guarded one-time terminal publication.
|
|
@@ -66,6 +67,7 @@ import {
|
|
|
66
67
|
} from "./spawn.ts";
|
|
67
68
|
import {
|
|
68
69
|
createWorktreeIsolation,
|
|
70
|
+
worktreeGroupId,
|
|
69
71
|
type IsolationMode,
|
|
70
72
|
type WorktreeFinalization,
|
|
71
73
|
type WorktreeIsolation,
|
|
@@ -74,6 +76,24 @@ import {
|
|
|
74
76
|
export const FORK_CONTINUATION_PROMPT =
|
|
75
77
|
"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.";
|
|
76
78
|
|
|
79
|
+
/** Control operations must never wait forever on a settling generation: the
|
|
80
|
+
* queue task can legitimately spend minutes in worktree finalization (bounded
|
|
81
|
+
* per-Git-command timeouts) or wait behind the managed repository lane. After
|
|
82
|
+
* this deadline the control path owns the lifecycle synchronously and proceeds
|
|
83
|
+
* while the stuck tail settles silently in the background. */
|
|
84
|
+
export const CONTROL_QUIESCE_TIMEOUT_MS = 20_000;
|
|
85
|
+
|
|
86
|
+
/** Resolve true when the promise settles, or false after the bounded deadline. */
|
|
87
|
+
export function quiesced(promise: Promise<unknown>, timeoutMs: number = CONTROL_QUIESCE_TIMEOUT_MS): Promise<boolean> {
|
|
88
|
+
return Promise.race([
|
|
89
|
+
promise.then(() => true, () => true),
|
|
90
|
+
new Promise<boolean>((resolve) => {
|
|
91
|
+
const timer = setTimeout(() => resolve(false), timeoutMs);
|
|
92
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
93
|
+
}),
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
96
|
+
|
|
77
97
|
const WORKTREE_ISOLATION_INSTRUCTIONS =
|
|
78
98
|
"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.";
|
|
79
99
|
|
|
@@ -88,6 +108,16 @@ export function isWorktreeCapableAgent(agent: AgentConfig): boolean {
|
|
|
88
108
|
return isWriteCapableAgent(agent);
|
|
89
109
|
}
|
|
90
110
|
|
|
111
|
+
/** A direct reviewer otherwise cannot infer enabled-role availability from its
|
|
112
|
+
* isolated task. Managed internal gates receive the same contract in their
|
|
113
|
+
* generated briefs. Advisory reviews still emit neither machine marker. */
|
|
114
|
+
function withEnabledDocumenterReviewContract(agent: AgentConfig): AgentConfig {
|
|
115
|
+
return {
|
|
116
|
+
...agent,
|
|
117
|
+
systemPrompt: `${agent.systemPrompt.trimEnd()}\n\nRuntime workflow context: documenter is enabled. In gate reviews, documentation drift is non-gating: emit DOCUMENTATION: NEEDED with ## Documentation notes, or DOCUMENTATION: CLEAN when no sync is needed. Advisory reviews still emit neither VERDICT nor DOCUMENTATION markers.`.trim(),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
91
121
|
interface DispatchEnvironment {
|
|
92
122
|
ctx: ExtensionContext;
|
|
93
123
|
config: SubagentsConfig;
|
|
@@ -137,6 +167,8 @@ export interface ManagedWorkflowRequest extends DispatchEnvironment {
|
|
|
137
167
|
executionCwd: string;
|
|
138
168
|
projectCwd: string;
|
|
139
169
|
isolation: IsolationMode;
|
|
170
|
+
/** Short identity of the isolated worktree shared by every workflow stage. */
|
|
171
|
+
worktreeId?: string;
|
|
140
172
|
signal: AbortSignal;
|
|
141
173
|
rememberLatest: (result: SingleResult) => void;
|
|
142
174
|
}
|
|
@@ -249,7 +281,10 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
249
281
|
if (!discoveredAgent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
|
|
250
282
|
const resolveLiveAgentTools = (candidate: AgentConfig): AgentConfig =>
|
|
251
283
|
resolveAgentTools({ ...candidate, tools: discoveredAgent.tools }, runtime.getActiveTools());
|
|
252
|
-
const
|
|
284
|
+
const resolvedAgent = resolveLiveAgentTools(discoveredAgent);
|
|
285
|
+
const agent = agentName === "reviewer" && runAgents.some((candidate) => candidate.name === "documenter")
|
|
286
|
+
? withEnabledDocumenterReviewContract(resolvedAgent)
|
|
287
|
+
: resolvedAgent;
|
|
253
288
|
if (isolation === "worktree" && !isWorktreeCapableAgent(agent)) {
|
|
254
289
|
return {
|
|
255
290
|
...failedStartResult(agentName, task, `Agent "${agentName}" is read-only; worktree isolation is available only to write-capable agents such as worker, cleaner, or documenter.`),
|
|
@@ -280,6 +315,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
280
315
|
}
|
|
281
316
|
}
|
|
282
317
|
const executionCwd = worktree?.cwd ?? originalCwd;
|
|
318
|
+
const worktreeGroup = worktree ? worktreeGroupId(worktree) : undefined;
|
|
283
319
|
const resolvedRoute = resolveDispatchModelRoute(agent, runConfig, runCtx);
|
|
284
320
|
// Isolation is a persistent system-level invariant, not a one-shot task
|
|
285
321
|
// prefix: queued retargets, live retargets, resumes, and main-model
|
|
@@ -296,6 +332,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
296
332
|
}
|
|
297
333
|
const runId = existingThread?.id ?? monitor.addRun(agent.name, task, route.agent.model, thinkingLevel, {
|
|
298
334
|
isolation,
|
|
335
|
+
...(worktreeGroup ? { worktreeId: worktreeGroup } : {}),
|
|
299
336
|
...(seed?.forkedFromRunId !== undefined ? { forkedFromRunId: seed.forkedFromRunId } : {}),
|
|
300
337
|
...(seed?.continuationKind ? { continuationKind: seed.continuationKind } : {}),
|
|
301
338
|
});
|
|
@@ -315,6 +352,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
315
352
|
monitor.restartRun(runId, agent.name, task, route.agent.model, thinkingLevel, isolation, {
|
|
316
353
|
elapsedMs: existingThread.elapsedMs,
|
|
317
354
|
continuationKind: appendedObjectiveOnResume ? "resume-appended" : "resume-retained",
|
|
355
|
+
...(worktreeGroup ? { worktreeId: worktreeGroup } : {}),
|
|
318
356
|
});
|
|
319
357
|
runtime.settledRuns.delete(runId);
|
|
320
358
|
}
|
|
@@ -412,12 +450,20 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
412
450
|
// All normal, destructive-stop, and shutdown owners converge here. Cache
|
|
413
451
|
// the lane-protected apply itself so superseding lifecycle paths can project
|
|
414
452
|
// the same finalization onto their own result without acquiring twice.
|
|
415
|
-
generationFinalization
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
453
|
+
if (!generationFinalization) {
|
|
454
|
+
monitor.setIsolation(
|
|
455
|
+
runId,
|
|
456
|
+
"worktree",
|
|
457
|
+
"finalizing",
|
|
458
|
+
worktreeGroupId(generationWorktree),
|
|
459
|
+
);
|
|
460
|
+
generationFinalization = runInManagedRepositoryLane(
|
|
461
|
+
generationWorktree.originalRoot,
|
|
462
|
+
() => generationWorktree.finalize(),
|
|
463
|
+
);
|
|
464
|
+
}
|
|
419
465
|
const finalization = await generationFinalization;
|
|
420
|
-
monitor.setIsolation(runId, "worktree", finalization.status);
|
|
466
|
+
monitor.setIsolation(runId, "worktree", finalization.status, worktreeGroupId(generationWorktree));
|
|
421
467
|
if (result) {
|
|
422
468
|
result.runId = runId;
|
|
423
469
|
result.isolation = "worktree";
|
|
@@ -551,7 +597,12 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
551
597
|
// control after that child settles, so cancel its queue owner explicitly.
|
|
552
598
|
if (phase === "settled") runtime.backgroundQueue.cancel(controller);
|
|
553
599
|
}
|
|
554
|
-
|
|
600
|
+
// The settling tail may be blocked on worktree finalization or the
|
|
601
|
+
// managed repository lane. Park already owns the lifecycle, so proceed
|
|
602
|
+
// after a bounded wait and let the tail finish silently in the background.
|
|
603
|
+
if (!(await quiesced(completion))) {
|
|
604
|
+
runtime.backgroundQueue.cancel(controller);
|
|
605
|
+
}
|
|
555
606
|
if (
|
|
556
607
|
thread.generation !== generation ||
|
|
557
608
|
thread.lifecycleVersion !== version ||
|
|
@@ -617,7 +668,15 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
617
668
|
let continuationWorktree: WorktreeIsolation | undefined;
|
|
618
669
|
let clonedSession: Awaited<ReturnType<typeof forkRetainedSession>> | undefined;
|
|
619
670
|
try {
|
|
620
|
-
|
|
671
|
+
// Never wait forever on a previous generation that is still settling
|
|
672
|
+
// (e.g. blocked behind the managed repository lane in finalization).
|
|
673
|
+
if (!(await quiesced(thread.generationCompletion))) {
|
|
674
|
+
return failedStartResult(
|
|
675
|
+
thread.agentName,
|
|
676
|
+
thread.task,
|
|
677
|
+
`Run #${runId}'s previous generation is still settling; retry the resume shortly.`,
|
|
678
|
+
);
|
|
679
|
+
}
|
|
621
680
|
if (!ownsResumeReservation(thread, reservation)) {
|
|
622
681
|
return failedStartResult(
|
|
623
682
|
thread.agentName,
|
|
@@ -797,7 +856,15 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
797
856
|
let childWorktree: WorktreeIsolation | undefined;
|
|
798
857
|
let forkedSession: Awaited<ReturnType<typeof forkRetainedSession>> | undefined;
|
|
799
858
|
try {
|
|
800
|
-
|
|
859
|
+
// Same bounded preflight as resume: a still-settling source generation
|
|
860
|
+
// must not block the control operation forever.
|
|
861
|
+
if (!(await quiesced(thread.generationCompletion))) {
|
|
862
|
+
return failedStartResult(
|
|
863
|
+
thread.agentName,
|
|
864
|
+
thread.task,
|
|
865
|
+
`Run #${runId}'s previous generation is still settling; retry the fork shortly.`,
|
|
866
|
+
);
|
|
867
|
+
}
|
|
801
868
|
if (!ownsFork()) {
|
|
802
869
|
return failedStartResult(thread.agentName, thread.task, `Run #${runId} changed while fork was preparing; no child was started.`);
|
|
803
870
|
}
|
|
@@ -897,23 +964,41 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
897
964
|
isolation === "shared" && canStartManagedWorkflow(agent, workflowAvailability);
|
|
898
965
|
const runGeneration = async (backgroundSignal: AbortSignal): Promise<void> => {
|
|
899
966
|
if (runtime.threads.get(runId)?.generation !== generation) return;
|
|
967
|
+
// Model/thinking config may have changed while this generation sat
|
|
968
|
+
// queued behind the concurrency limit. Re-resolve the route at actual
|
|
969
|
+
// start so /subagents-setup edits apply to not-yet-started runs.
|
|
970
|
+
let activeRoute = route;
|
|
971
|
+
let activeIdleTimeoutMs = runConfig.idleTimeoutSec * 1000;
|
|
972
|
+
try {
|
|
973
|
+
const startConfig = await loadConfig(runtime.configPath);
|
|
974
|
+
runtime.backgroundQueue.setConcurrency(startConfig.maxConcurrency);
|
|
975
|
+
const resolvedStart = resolveDispatchModelRoute(agent, startConfig, runCtx);
|
|
976
|
+
activeRoute = isolation === "worktree"
|
|
977
|
+
? { ...resolvedStart, agent: withWorktreeSystemPrompt(resolvedStart.agent) }
|
|
978
|
+
: resolvedStart;
|
|
979
|
+
activeIdleTimeoutMs = startConfig.idleTimeoutSec * 1000;
|
|
980
|
+
monitor.setModel(runId, activeRoute.agent.model);
|
|
981
|
+
monitor.setThinking(runId, activeRoute.thinkingLevel);
|
|
982
|
+
} catch {
|
|
983
|
+
/* keep the dispatch-time route when fresh config is unavailable */
|
|
984
|
+
}
|
|
900
985
|
let result: SingleResult;
|
|
901
986
|
try {
|
|
902
987
|
result = await runSingleAgentWithMainFallback(
|
|
903
988
|
{
|
|
904
989
|
defaultCwd: executionCwd,
|
|
905
|
-
agent:
|
|
990
|
+
agent: activeRoute.agent,
|
|
906
991
|
resolveAgentForAttempt: resolveLiveAgentTools,
|
|
907
992
|
agentName,
|
|
908
993
|
task,
|
|
909
994
|
cwd: executionCwd,
|
|
910
|
-
thinkingLevel,
|
|
911
|
-
thinkingLevelForModel:
|
|
995
|
+
thinkingLevel: activeRoute.thinkingLevel,
|
|
996
|
+
thinkingLevelForModel: activeRoute.thinkingLevelForModel,
|
|
912
997
|
signal: backgroundSignal,
|
|
913
998
|
onLive,
|
|
914
999
|
control,
|
|
915
1000
|
makeDetails: makeDetails("single", true),
|
|
916
|
-
idleTimeoutMs:
|
|
1001
|
+
idleTimeoutMs: activeIdleTimeoutMs,
|
|
917
1002
|
...(priorSessionId && priorSessionDir
|
|
918
1003
|
? {
|
|
919
1004
|
sessionId: priorSessionId,
|
|
@@ -924,7 +1009,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
924
1009
|
}
|
|
925
1010
|
: {}),
|
|
926
1011
|
},
|
|
927
|
-
|
|
1012
|
+
activeRoute.mainFallbackRef,
|
|
928
1013
|
);
|
|
929
1014
|
} catch (error) {
|
|
930
1015
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
@@ -999,6 +1084,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
999
1084
|
executionCwd: thread.executionCwd,
|
|
1000
1085
|
projectCwd: originalCwd,
|
|
1001
1086
|
isolation,
|
|
1087
|
+
...(worktree ? { worktreeId: worktreeGroupId(worktree) } : {}),
|
|
1002
1088
|
signal: backgroundSignal,
|
|
1003
1089
|
ctx: runCtx,
|
|
1004
1090
|
config: runConfig,
|
|
@@ -1052,8 +1138,8 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
1052
1138
|
!thread.retired;
|
|
1053
1139
|
try {
|
|
1054
1140
|
// For isolated writers this is deliberately after the managed reviewer
|
|
1055
|
-
// and documentation
|
|
1056
|
-
// lifecycle owner integrates the complete
|
|
1141
|
+
// and any needed documentation stage: every child sees the same worktree,
|
|
1142
|
+
// then one lifecycle owner integrates the complete settled state exactly once.
|
|
1057
1143
|
await thread.finalizeIsolation(generation, result);
|
|
1058
1144
|
if (!ownsSettlement()) return;
|
|
1059
1145
|
if (workflowOutcome && isolation === "worktree") {
|
package/src/tools.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
8
8
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { Text } from "@earendil-works/pi-tui";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
10
11
|
import { Type } from "typebox";
|
|
11
12
|
import { DEFAULT_MAX_RESULT_LINES, loadConfig } from "./config.ts";
|
|
12
13
|
import { formatCompletionBlock, formatUsage, matchRunIds } from "./format.ts";
|
|
@@ -19,8 +20,11 @@ import {
|
|
|
19
20
|
statusLabel,
|
|
20
21
|
type RunStatus,
|
|
21
22
|
} from "./monitor.ts";
|
|
23
|
+
import { persistRecoveryRecords, recoveryRecordFromFinalization } from "./recovery.ts";
|
|
22
24
|
import type { SubagentRuntime, SubagentThread } from "./runtime.ts";
|
|
25
|
+
import { CONTROL_QUIESCE_TIMEOUT_MS, quiesced } from "./thread-lifecycle.ts";
|
|
23
26
|
import { getResultOutput, isFailedResult, type SingleResult } from "./spawn.ts";
|
|
27
|
+
import type { WorktreeFinalization } from "./worktree.ts";
|
|
24
28
|
|
|
25
29
|
/** In-turn result lookup. Dispatch already ended the turn and results arrive as
|
|
26
30
|
* wake-up messages, so the default must NOT block: a settled run returns its
|
|
@@ -648,6 +652,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
648
652
|
|
|
649
653
|
const stopped: string[] = [];
|
|
650
654
|
const retainedIntegration: string[] = [];
|
|
655
|
+
const pendingIntegration: string[] = [];
|
|
651
656
|
for (const [claimIndex, claim] of claimed.entries()) {
|
|
652
657
|
const {
|
|
653
658
|
runId,
|
|
@@ -663,8 +668,13 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
663
668
|
stopVersion,
|
|
664
669
|
stopMessage,
|
|
665
670
|
} = claim;
|
|
666
|
-
|
|
667
|
-
|
|
671
|
+
// Every wait here is bounded: the queue task can sit for minutes in
|
|
672
|
+
// worktree finalization or behind the managed repository lane, and an
|
|
673
|
+
// unkillable child can stall even the RPC-level stop. Stop owns the
|
|
674
|
+
// lifecycle synchronously, so a stuck tail settles silently after we
|
|
675
|
+
// proceed; none of its late paths can publish a second result.
|
|
676
|
+
await quiesced(interruptionPromises[claimIndex]);
|
|
677
|
+
if (!(await quiesced(completion))) runtime.backgroundQueue.cancel(controller);
|
|
668
678
|
if (runtime.runControllers.get(runId) === controller) runtime.runControllers.delete(runId);
|
|
669
679
|
if (thread.queueController === controller) thread.queueController = undefined;
|
|
670
680
|
|
|
@@ -703,8 +713,42 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
703
713
|
runId,
|
|
704
714
|
isolation: thread.isolation,
|
|
705
715
|
};
|
|
706
|
-
const
|
|
707
|
-
|
|
716
|
+
const worktree = thread.worktree;
|
|
717
|
+
let finalization: WorktreeFinalization | undefined;
|
|
718
|
+
try {
|
|
719
|
+
finalization = await Promise.race([
|
|
720
|
+
thread.finalizeIsolation(generation, stoppedResult),
|
|
721
|
+
new Promise<undefined>((resolve) => {
|
|
722
|
+
const timer = setTimeout(() => resolve(undefined), CONTROL_QUIESCE_TIMEOUT_MS);
|
|
723
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
724
|
+
}),
|
|
725
|
+
]);
|
|
726
|
+
} catch {
|
|
727
|
+
/* an unexpected finalize rejection must not block the stop */
|
|
728
|
+
}
|
|
729
|
+
if (finalization === undefined) {
|
|
730
|
+
// Integration is still settling in the background. Point a
|
|
731
|
+
// durable recovery record at the artifacts so the isolated work
|
|
732
|
+
// stays findable even if the background tail later fails; a
|
|
733
|
+
// successful tail removes them and the record self-prunes.
|
|
734
|
+
if (thread.isolation === "worktree" && worktree) {
|
|
735
|
+
stoppedResult.integrationStatus = "pending";
|
|
736
|
+
stoppedResult.integrationWorktreePath = worktree.worktreePath;
|
|
737
|
+
await persistRecoveryRecords(runtime.configPath, [
|
|
738
|
+
recoveryRecordFromFinalization(runId, {
|
|
739
|
+
status: "retained",
|
|
740
|
+
integrated: false,
|
|
741
|
+
hadChanges: false,
|
|
742
|
+
...(existsSync(worktree.worktreePath) ? { worktreePath: worktree.worktreePath } : {}),
|
|
743
|
+
...(existsSync(worktree.patchPath) ? { patchPath: worktree.patchPath } : {}),
|
|
744
|
+
error: "subagent_stop timed out waiting for worktree integration; it continues in the background",
|
|
745
|
+
}),
|
|
746
|
+
]).catch(() => undefined);
|
|
747
|
+
}
|
|
748
|
+
pendingIntegration.push(`#${runId}`);
|
|
749
|
+
} else if (finalization.status === "retained") {
|
|
750
|
+
retainedIntegration.push(`#${runId}`);
|
|
751
|
+
}
|
|
708
752
|
runtime.registerRunResult(runId, stoppedResult);
|
|
709
753
|
thread.lastResult = stoppedResult;
|
|
710
754
|
}
|
|
@@ -730,7 +774,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
730
774
|
return {
|
|
731
775
|
content: [{
|
|
732
776
|
type: "text",
|
|
733
|
-
text: `Stopped ${stopped.length} thread${stopped.length === 1 ? "" : "s"}: ${stopped.join(", ")}. Retained sessions were retired; worktree changes are integrated on settlement.${retainedIntegration.length > 0 ? ` Integration failed for ${retainedIntegration.join(", ")}; inspect its result for retained recovery paths.` : ""}`,
|
|
777
|
+
text: `Stopped ${stopped.length} thread${stopped.length === 1 ? "" : "s"}: ${stopped.join(", ")}. Retained sessions were retired; worktree changes are integrated on settlement.${retainedIntegration.length > 0 ? ` Integration failed for ${retainedIntegration.join(", ")}; inspect its result for retained recovery paths.` : ""}${pendingIntegration.length > 0 ? ` Integration is still settling in the background for ${pendingIntegration.join(", ")}; a recovery record was persisted in case it fails.` : ""}`,
|
|
734
778
|
}],
|
|
735
779
|
details: {},
|
|
736
780
|
};
|