@ferris1225/pi-subagents 4.3.0 → 4.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,7 +11,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
11
  import { existsSync } from "node:fs";
12
12
  import { rm } from "node:fs/promises";
13
13
  import { realpath } from "node:fs/promises";
14
- import { join, resolve, dirname } from "node:path";
14
+ import { join, resolve } from "node:path";
15
15
  import {
16
16
  discoverAgents,
17
17
  isWriteCapableAgent,
@@ -27,7 +27,6 @@ import {
27
27
  } from "./config.ts";
28
28
  import {
29
29
  isCurrentBoot,
30
- migrateLegacyThreadsManifest,
31
30
  readThreadRecords,
32
31
  referencedDurablePaths,
33
32
  removeThreadRecord,
@@ -35,7 +34,7 @@ import {
35
34
  pruneThreadRecords,
36
35
  restoredResultFromSummary,
37
36
  threadRecordFromThread,
38
- ThreadRecord,
37
+ type ThreadRecord,
39
38
  upsertThreadRecord,
40
39
  } from "./durable.ts";
41
40
  import {
@@ -54,13 +53,15 @@ import {
54
53
  resolveThinkingLevel,
55
54
  } from "./models.ts";
56
55
  import { monitor } from "./monitor.ts";
56
+ import { findDuplicateActiveDispatch } from "./prompt.ts";
57
57
  import { persistRecoveryRecords, recoveryRecordFromFinalization } from "./recovery.ts";
58
+ import { emptyUsage } from "./rpc-run.ts";
58
59
  import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts";
59
60
  import { forkRetainedSession } from "./session-fork.ts";
60
61
  import {
61
62
  buildResumePrompt,
62
63
  getProjectRoot,
63
- PROJECT_ROOTS_DIR_NAME,
64
+ getSubagentsRoot,
64
65
  RpcRunControl,
65
66
  isFailedResult,
66
67
  isModelLevelFailure,
@@ -273,6 +274,8 @@ export interface StartBackgroundOptions {
273
274
  environment?: DispatchEnvironment;
274
275
  seed?: SessionSeed;
275
276
  resumeReservation?: ResumeReservation;
277
+ /** Chosen by the tool call before the queue can start a fast child. */
278
+ deliveryRoute?: "background" | "await";
276
279
  }
277
280
 
278
281
  export type StartBackgroundInternal = (
@@ -372,6 +375,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
372
375
  environment,
373
376
  seed,
374
377
  resumeReservation,
378
+ deliveryRoute = "background",
375
379
  } = startOptions;
376
380
  if (!runtime.sessionActive) {
377
381
  return failedStartResult(agentName, task, "Parent session shut down before this subagent generation could start.");
@@ -396,33 +400,31 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
396
400
  }
397
401
 
398
402
  const originalCwd = resolve(cwd ?? runCtx.cwd);
403
+ if (!existingThread) {
404
+ const duplicate = findDuplicateActiveDispatch(runtime.threads.values(), task, originalCwd);
405
+ if (duplicate) {
406
+ return failedStartResult(
407
+ agentName,
408
+ task,
409
+ `Duplicate active dispatch matches run #${duplicate.id} (${duplicate.agentName}). Use that logical thread instead; resume #${duplicate.id} when it is eligible.`,
410
+ );
411
+ }
412
+ }
399
413
  const projectRoot = getProjectRoot(runtime.configPath, originalCwd);
400
414
  const sessionsRoot = join(projectRoot, "sessions");
401
415
  const worktreesRoot = join(projectRoot, "worktrees");
402
416
  const scratchRoot = join(projectRoot, "tmp");
403
417
  const previousWorktree = existingThread?.worktree;
404
418
  let worktree = seed?.worktree ?? previousWorktree;
405
- if (isolation === "worktree") {
406
- if (worktree && worktree.state !== "active") {
407
- return {
408
- ...failedStartResult(agentName, task, `Run #${existingThread?.id ?? "?"} has no active continuation worktree.`),
409
- isolation,
410
- integrationStatus: worktree.state === "finalizing" ? "pending" : worktree.state,
411
- };
412
- }
413
- if (!worktree) {
414
- try {
415
- worktree = await createWorktreeIsolation(originalCwd, { tempBaseDir: worktreesRoot });
416
- } catch (error) {
417
- return {
418
- ...failedStartResult(agentName, task, error instanceof Error ? error.message : String(error)),
419
- isolation,
420
- };
421
- }
422
- }
419
+ if (isolation === "worktree" && worktree && worktree.state !== "active") {
420
+ return {
421
+ ...failedStartResult(agentName, task, `Run #${existingThread?.id ?? "?"} has no active continuation worktree.`),
422
+ isolation,
423
+ integrationStatus: worktree.state === "finalizing" ? "pending" : worktree.state,
424
+ };
423
425
  }
424
- const executionCwd = worktree?.cwd ?? originalCwd;
425
- const worktreeGroup = worktree ? worktreeGroupId(worktree) : undefined;
426
+ let executionCwd = worktree?.cwd ?? originalCwd;
427
+ let worktreeGroup = worktree ? worktreeGroupId(worktree) : undefined;
426
428
  // A resume re-runs at the strength its dispatch asked for, so the retained
427
429
  // request survives generations (and, via the durable record, restarts).
428
430
  const resolvedRoute = resolveDispatchModelRoute(agent, runConfig, runCtx);
@@ -440,6 +442,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
440
442
  isolation,
441
443
  ...(worktreeGroup ? { worktreeId: worktreeGroup } : {}),
442
444
  });
445
+ runtime.claimRunDelivery(runId, deliveryRoute);
443
446
  const generation = (existingThread?.generation ?? 0) + 1;
444
447
  const pending: SingleResult = {
445
448
  ...queuedResult(route.agent, task, thinkingLevel),
@@ -523,11 +526,12 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
523
526
  };
524
527
  runtime.threads.set(runId, thread);
525
528
  }
526
- installThreadLifecycle(thread, {
529
+ const installCurrentLifecycle = (): void => installThreadLifecycle(thread, {
527
530
  runtime,
528
531
  runCtx,
529
532
  startBackground: (...args) => startBackground(...args),
530
533
  });
534
+ if (isolation === "shared" || worktree) installCurrentLifecycle();
531
535
 
532
536
  const onLive = makeLiveHandler(runId, generation);
533
537
  // Shared write-capable runs serialize on the repository lane so their
@@ -536,6 +540,20 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
536
540
  const reserveManagedLane = isolation === "shared" && isWriteCapableAgent(agent);
537
541
  const runGeneration = async (backgroundSignal: AbortSignal, controller: AbortController): Promise<void> => {
538
542
  if (runtime.threads.get(runId)?.generation !== generation) return;
543
+ if (isolation === "worktree" && !worktree) {
544
+ const prepared = await createWorktreeIsolation(originalCwd, { tempBaseDir: worktreesRoot });
545
+ if (runtime.threads.get(runId)?.generation !== generation || backgroundSignal.aborted) {
546
+ await prepared.discard().catch(() => undefined);
547
+ return;
548
+ }
549
+ worktree = prepared;
550
+ executionCwd = prepared.cwd;
551
+ worktreeGroup = worktreeGroupId(prepared);
552
+ thread.worktree = prepared;
553
+ thread.executionCwd = executionCwd;
554
+ monitor.setIsolation(runId, "worktree", "pending", worktreeGroup);
555
+ installCurrentLifecycle();
556
+ }
539
557
  // The generation body owns a process slot from here (a lane wait, if
540
558
  // any, was granted above). Recording the transition synchronously keeps
541
559
  // every "queued" surface truthful: only runs still pending in the pool
@@ -649,10 +667,10 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
649
667
  thread.lifecycleOperation === "settle" &&
650
668
  !thread.retired;
651
669
  try {
652
- // For isolated writers the apply runs only after the child settled,
653
- // so this lifecycle owner integrates the complete settled state
654
- // exactly once under the repository lane.
655
- await thread.finalizeIsolation(generation, result);
670
+ // The child process has exited. Isolated Git finalization remains
671
+ // lifecycle-owned and awaited, but no longer consumes a process slot.
672
+ if (isolation === "worktree") runtime.backgroundQueue.suspend(controller);
673
+ await thread.finalizeIsolation(generation, result);
656
674
  if (!ownsSettlement()) return;
657
675
 
658
676
  const failed = isFailedResult(result);
@@ -683,41 +701,33 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
683
701
  block: modelLevel
684
702
  ? `${formatCompletionBlock(result, runConfig.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, result.projectCwd ?? originalCwd) })}\n\n${modelLevelTakeoverNote(result, { runId })}`
685
703
  : formatCompletionBlock(result, runConfig.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, result.projectCwd ?? originalCwd) }),
686
- triggerTurn: true,
687
704
  usage: result.usage,
688
705
  };
689
- if (modelLevel) {
690
- const detail = result.errorMessage?.trim() || "model unavailable or broken";
691
- runCtx.ui.notify(`✗ ${result.agent} dispatch failed: ${detail} — task handed to the main window`, "error");
692
- } else if (dispatchFailed) {
693
- runCtx.ui.notify(`✗ ${result.agent} dispatch failed: ${result.errorMessage ?? "dispatch crashed"}`, "error");
694
- }
695
- if (failed) {
696
- runtime.sendCompletionGroup([completion]);
697
- runtime.completionBatcher.flush();
698
- } else {
699
- runtime.completionBatcher.push(completion);
700
- }
706
+ if (modelLevel) {
707
+ const detail = result.errorMessage?.trim() || "model unavailable or broken";
708
+ runCtx.ui.notify(`✗ ${result.agent} dispatch failed: ${detail} — task handed to the main window`, "error");
709
+ } else if (dispatchFailed) {
710
+ runCtx.ui.notify(`✗ ${result.agent} dispatch failed: ${result.errorMessage ?? "dispatch crashed"}`, "error");
711
+ }
712
+ runtime.publishRunCompletion(runId, completion, failed);
701
713
  } finally {
702
714
  if (ownsSettlement()) thread.lifecycleOperation = undefined;
703
715
  }
704
716
  };
705
717
  const queuedGeneration = reserveManagedLane
706
718
  ? async (backgroundSignal: AbortSignal, controller: AbortController): Promise<void> => {
707
- // This task may spend its whole life waiting for the repository
708
- // lane (every shared write-capable generation serializes on it).
709
- // Holding a process slot while only waiting let a batch of shared
710
- // writers park the entire pool and starve independent read-only
711
- // dispatches that could have started immediately, so the slot is
712
- // released up front; the lane itself still serializes same-repo
713
- // writers, and abort/quiesce guarantees are unchanged.
719
+ // Waiting for repository serialization must not consume a process
720
+ // slot. Once the lane is granted, reacquire through the same FIFO
721
+ // scheduler before any child process can spawn.
714
722
  runtime.backgroundQueue.suspend(controller);
715
- // A lane wait is write serialization, not slot pacing: the model
716
- // must never read it as an exhausted pool.
717
723
  monitor.setWaitReason(runId, "repository-lane");
718
724
  await runInManagedRepositoryLane(
719
725
  originalCwd,
720
- () => runGeneration(backgroundSignal, controller),
726
+ async () => {
727
+ monitor.setWaitReason(runId, "process-slot");
728
+ if (!(await runtime.backgroundQueue.acquire(controller))) return;
729
+ await runGeneration(backgroundSignal, controller);
730
+ },
721
731
  backgroundSignal,
722
732
  );
723
733
  }
@@ -776,6 +786,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
776
786
  };
777
787
  thread.lastResult = crashed;
778
788
  runtime.retainSession(crashed);
789
+ if (isolation === "worktree" && thread.worktree) runtime.backgroundQueue.suspend(thread.queueController);
779
790
  await thread.finalizeIsolation(generation, crashed);
780
791
  if (!ownsSettlement()) return;
781
792
  thread.state = "failed";
@@ -789,15 +800,11 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
789
800
  if (!runtime.sessionActive || !ownsSettlement()) return;
790
801
  try {
791
802
  runCtx.ui.notify(`✗ ${crashed.agent} dispatch failed: ${crashed.errorMessage}`, "error");
792
- runtime.sendCompletionGroup([
793
- {
794
- agent: crashed.agent,
795
- block: formatCompletionBlock(crashed, runConfig.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, crashed.projectCwd ?? originalCwd) }),
796
- triggerTurn: true,
797
- usage: crashed.usage,
798
- },
799
- ]);
800
- runtime.completionBatcher.flush();
803
+ runtime.publishRunCompletion(runId, {
804
+ agent: crashed.agent,
805
+ block: formatCompletionBlock(crashed, runConfig.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, crashed.projectCwd ?? originalCwd) }),
806
+ usage: crashed.usage,
807
+ }, true);
801
808
  } catch {
802
809
  /* a second delivery failure must not throw through the queue */
803
810
  }
@@ -977,6 +984,9 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
977
984
  return failedStartResult(thread.agentName, thread.task, "resume objective must be non-blank when provided.");
978
985
  }
979
986
  if (thread.retired) return failedStartResult(thread.agentName, thread.task, `Run #${runId} was retired by subagent_stop.`);
987
+ if (thread.resumeUnavailableReason) {
988
+ return failedStartResult(thread.agentName, thread.task, thread.resumeUnavailableReason);
989
+ }
980
990
  if (thread.lifecycleOperation) {
981
991
  return failedStartResult(thread.agentName, thread.task, `Run #${runId} is already resuming.`);
982
992
  }
@@ -1237,16 +1247,53 @@ export async function restoreDurableThreads(runtime: SubagentRuntime): Promise<n
1237
1247
  const worktree = record.worktree
1238
1248
  ? await restoreWorktreeIsolation(record.worktree).catch(() => undefined)
1239
1249
  : undefined;
1240
- // A worktree thread whose isolated filesystem is gone cannot continue its
1241
- // isolation invariant; surface it as failed instead of pretending.
1242
- const state: ThreadState = worktree
1243
- ? "parked"
1244
- : record.isolation === "worktree" && record.worktree
1245
- ? "failed"
1246
- : "parked";
1247
- const thread = createRestoredThread(runtime, record, worktree, state);
1250
+ const restorationFailed = record.isolation === "worktree" && record.worktree !== undefined && !worktree;
1251
+ const thread = createRestoredThread(runtime, record, worktree, restorationFailed ? "failed" : "parked");
1248
1252
  runtime.threads.set(record.runId, thread);
1249
1253
  runtime.sessionDirs.add(record.sessionDir!);
1254
+ if (restorationFailed) {
1255
+ const reason = `Run #${record.runId}'s recorded worktree could not be restored; isolated edits may be unavailable. The retained session and durable record were kept, but this thread cannot be resumed.`;
1256
+ thread.resumeUnavailableReason = reason;
1257
+ thread.restorationRecord = record;
1258
+ const previous = restoredResultFromSummary(record);
1259
+ const failed: SingleResult = {
1260
+ agent: record.agentName,
1261
+ task: record.task,
1262
+ exitCode: 1,
1263
+ messages: previous?.messages ?? [],
1264
+ stderr: reason,
1265
+ usage: previous?.usage ?? emptyUsage(),
1266
+ model: previous?.model,
1267
+ thinking: previous?.thinking,
1268
+ stopReason: "error",
1269
+ errorMessage: reason,
1270
+ dispatchFailed: true,
1271
+ sessionId: record.sessionId,
1272
+ sessionDir: record.sessionDir,
1273
+ projectCwd: record.cwd,
1274
+ runId: record.runId,
1275
+ isolation: "worktree",
1276
+ integrationStatus: "retained",
1277
+ };
1278
+ thread.lastResult = failed;
1279
+ runtime.registerRunResult(record.runId, failed);
1280
+ monitor.restoreRun({
1281
+ id: record.runId,
1282
+ agent: record.agentName,
1283
+ task: record.task,
1284
+ status: "failed",
1285
+ elapsedMs: record.elapsedMs,
1286
+ isolation: "worktree",
1287
+ integrationStatus: "retained",
1288
+ });
1289
+ runtime.claimRunDelivery(record.runId, "background");
1290
+ runtime.publishRunCompletion(record.runId, {
1291
+ agent: record.agentName,
1292
+ block: `### Subagent restoration failed: #${record.runId} ${record.agentName}\n\n${reason}`,
1293
+ usage: failed.usage,
1294
+ }, true);
1295
+ continue;
1296
+ }
1250
1297
  monitor.restoreRun({
1251
1298
  id: record.runId,
1252
1299
  agent: record.agentName,
@@ -1268,9 +1315,9 @@ export async function restoreDurableThreads(runtime: SubagentRuntime): Promise<n
1268
1315
  return restoredIds;
1269
1316
  }
1270
1317
 
1271
- /** Load-time durable bootstrap: restore threads, age out expired records, and
1318
+ /** Session-start durable bootstrap: restore threads, age out expired records, and
1272
1319
  * sweep leaked temp/state directories. Every stage is best-effort so a broken
1273
- * manifest never blocks extension registration.
1320
+ * manifest never blocks the session.
1274
1321
  *
1275
1322
  * Restore is published on the runtime as `durableRestore` before this returns,
1276
1323
  * so callers that must see restored threads await that pass alone and never the
@@ -1279,9 +1326,6 @@ export async function restoreDurableThreads(runtime: SubagentRuntime): Promise<n
1279
1326
  export function bootstrapDurableState(runtime: SubagentRuntime): Promise<void> {
1280
1327
  const restore = (async () => {
1281
1328
  try {
1282
- // Records from a pre-per-project manifest must land in their project
1283
- // roots before restore reads anything.
1284
- await migrateLegacyThreadsManifest(runtime.configPath);
1285
1329
  runtime.restoredRunIds = await restoreDurableThreads(runtime);
1286
1330
  } catch {
1287
1331
  /* restore is best-effort */
@@ -1295,7 +1339,7 @@ export function bootstrapDurableState(runtime: SubagentRuntime): Promise<void> {
1295
1339
  } catch {
1296
1340
  /* retention is best-effort */
1297
1341
  }
1298
- const projectRoots = join(dirname(runtime.configPath), PROJECT_ROOTS_DIR_NAME);
1342
+ const projectRoots = getSubagentsRoot(runtime.configPath);
1299
1343
  try {
1300
1344
  sweepProjectTempDirs(projectRoots);
1301
1345
  } catch {
package/src/tools.ts CHANGED
@@ -46,14 +46,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
46
46
  pi.registerTool({
47
47
  name: "subagent_control",
48
48
  label: "Subagent Control",
49
- description: [
50
- "Resume an existing sub-agent thread by stable run id: a parked, completed, or failed retained thread restarts with the same run id and cumulative active time.",
51
- "Omit objective to continue the current goal, or provide one to append it to retained context and make it the displayed goal. Threads parked or interrupted by a shutdown/reload are restorable; use subagent_stop for destructive cancellation.",
52
- ].join(" "),
53
- promptSnippet: "Resume a parked or settled subagent thread with its retained context.",
54
- promptGuidelines: [
55
- "Resume keeps the run id and retained context; use subagent_stop only for destructive cancellation, which retires that thread's session.",
56
- ],
49
+ description: "Resume a parked or settled child thread by run id, reusing its retained session when available. An optional objective is appended to its current goal.",
57
50
  parameters: SubagentControlParams,
58
51
 
59
52
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -91,10 +84,8 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
91
84
  const mode = objective
92
85
  ? `appended objective: ${currentObjective}`
93
86
  : `continuing current objective: ${currentObjective}`;
94
- const context = hadRetainedSession
95
- ? "the same retained session and prior context are preserved"
96
- : "no prior child session existed, so only the logical run and objective are continued";
97
- return { content: [{ type: "text", text: `Resumed run #${thread.id}, ${mode}; ${context}, and cumulative active time is preserved. It runs in the background — keep working; the result resumes you automatically.` }], details: {} };
87
+ const context = hadRetainedSession ? "retained context reused" : "no prior child context";
88
+ return { content: [{ type: "text", text: `Resumed run #${thread.id}: ${mode}; ${context}.` }], details: {} };
98
89
  }
99
90
  }
100
91
  } catch (error) {
@@ -125,14 +116,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
125
116
  pi.registerTool({
126
117
  name: "subagent_stop",
127
118
  label: "Subagent Stop",
128
- description: [
129
- "Destructively stop a sub-agent thread: terminate active work, deliver its aborted partial result, and retire any retained session so it cannot be resumed.",
130
- "Pass id (run id or prefix) to stop one active, parked, or completed thread; all: true stops every active run.",
131
- ].join(" "),
132
- promptSnippet: "Stop a running background subagent (id from dispatch output; or all: true).",
133
- promptGuidelines: [
134
- "Stop a run when its task is obsolete, stuck, or superseded — do not leave it burning tokens. It then reports as failed with 'aborted' plus its partial output.",
135
- ],
119
+ description: "Stop and retire one child thread by run id/prefix, or every active thread with all: true.",
136
120
  parameters: SubagentStopParams,
137
121
 
138
122
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {