@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/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
- "Handle simple work inline with direct tools: one-line lookups, known-target reads/edits, and quick questions do not justify a child process.",
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 when reconnaissance becomes broad or crosses files: mapping unfamiliar code, tracing symbols/dependencies, or answering multi-file location/reference questions. Treat its output only as a retrieval index; re-read load-bearing files before edits or decisions about deletion, security, compatibility, persistence, or dynamic reachability. Use a stronger model/specialist for complex dynamic, concurrent, migration, or security-sensitive analysis.",
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 task whose separate context pays for itself."]
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. Once dispatched, it applies every safe proven in-scope cut without item-by-item approval; zero edits is valid only if none is proved. 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.`,
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 ? ` Successful ${codeWriterNames.join("/")} runs already auto-sync the actual diff once after the review gate; never dispatch a duplicate.` : ""} Zero edits is valid and broad mode is never inferred. It changes docs/comments only and never runtime behavior, versions, or release state.`,
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 an explicit gate.${reviewedWriterNames.length > 0 ? ` Successful ${reviewedWriterNames.join("/")} runs already get a fresh read-only reviewer gate.` : ""} Advisory output has no VERDICT: it stays read-only and does not authorize follow-up edits.`,
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 every child with the complete goal, exact paths, constraints, and expected output. It has no memory of this conversation.",
77
- "Children are leaf processes without delegation tools. Do not ask them to spawn sub-agents; use `subagent_control fork` on a parked/settled retained thread for an independent continuation.",
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 returns immediately and ends this turn. Never sleep, poll, or call `subagent_wait` to hold the turn; results arrive as messages that automatically resume the main agent, even mid-turn.",
90
- "Use `subagent_wait` with explicit `timeoutMs` only when the user specifically asks you to remain in-turn and wait. Its default lookup is non-blocking.",
91
- "A result is already shown to the user. Do not restate, paraphrase, or re-summarize it; add only your conclusion or next action, often one line.",
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
- ...(automaticWriterRoute && reviewedWriterNames.length > 0
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 is final for code: runtime runs the final documentation sync once and delivers. A direct REVIEW_FAIL ${autoFixEnabled ? "keeps auto-fix; maxFixRounds limits worker fixes only, not the final documentation sync." : "cannot start fixes while worker/fix rounds are disabled."}`,
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 specialized leaf agents in isolated Pi child processes and context windows. It returns immediately; completion messages automatically resume the main agent.
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
- const accepted = await abortAcceptedPrompt();
792
- if (!accepted && !closed) await processClosed.promise;
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");
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Stable logical-thread generation lifecycle for background sub-agents.
3
3
  *
4
- * Dispatch owns workflow policy and internal role briefs; this module owns one
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 agent = resolveLiveAgentTools(discoveredAgent);
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 ??= runInManagedRepositoryLane(
416
- generationWorktree.originalRoot,
417
- () => generationWorktree.finalize(),
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
- await completion;
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
- await thread.generationCompletion;
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
- await thread.generationCompletion;
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: route.agent,
990
+ agent: activeRoute.agent,
906
991
  resolveAgentForAttempt: resolveLiveAgentTools,
907
992
  agentName,
908
993
  task,
909
994
  cwd: executionCwd,
910
- thinkingLevel,
911
- thinkingLevelForModel: route.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: runConfig.idleTimeoutSec * 1000,
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
- route.mainFallbackRef,
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 stages: every child sees the same worktree, then one
1056
- // lifecycle owner integrates the complete writer+fixes+docs state exactly once.
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
- await interruptionPromises[claimIndex];
667
- await completion;
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 finalization = await thread.finalizeIsolation(generation, stoppedResult);
707
- if (finalization?.status === "retained") retainedIntegration.push(`#${runId}`);
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
  };