@ferris1225/pi-subagents 4.1.15 → 4.1.17

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.
@@ -164,31 +164,3 @@ export function sweepOrphanTempDirs(
164
164
  return removed;
165
165
  }
166
166
 
167
- /** Remove state-root directories no manifest record references. Fresh
168
- * directories (a run just created but not yet recorded) are protected by the
169
- * age cap, since the sweep only runs at extension load before new work. */
170
- export function sweepUnreferencedState(
171
- stateRoot: string,
172
- referencedPaths: ReadonlySet<string>,
173
- options: SweepOptions = {},
174
- ): number {
175
- const now = options.now ?? Date.now();
176
- const maxAgeMs = options.unmarkedMaxAgeMs ?? UNREFERENCED_STATE_MAX_AGE_MS;
177
- const pathKey = (path: string): string => (process.platform === "win32" ? path.toLowerCase() : path);
178
- const referenced = new Set([...referencedPaths].map(pathKey));
179
- let entries: Dirent[];
180
- try {
181
- entries = readdirSync(stateRoot, { withFileTypes: true });
182
- } catch {
183
- return 0;
184
- }
185
- let removed = 0;
186
- for (const entry of entries) {
187
- if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
188
- const path = join(stateRoot, entry.name);
189
- if (referenced.has(pathKey(path))) continue;
190
- const ageMs = directoryAgeMs(entry, stateRoot, now);
191
- if (ageMs !== undefined && ageMs > maxAgeMs && removeDir(path)) removed++;
192
- }
193
- return removed;
194
- }
@@ -12,7 +12,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
12
  import { existsSync } from "node:fs";
13
13
  import { rm } from "node:fs/promises";
14
14
  import { realpath } from "node:fs/promises";
15
- import { resolve } from "node:path";
15
+ import { join, resolve } from "node:path";
16
16
  import {
17
17
  discoverAgents,
18
18
  isWriteCapableAgent,
@@ -27,10 +27,9 @@ import {
27
27
  type ThinkingLevel,
28
28
  } from "./config.ts";
29
29
  import {
30
- getStateRoot,
31
30
  readThreadRecords,
32
- referencedDurablePaths,
33
31
  removeThreadRecord,
32
+ pruneStaleProjectRoots,
34
33
  pruneThreadRecords,
35
34
  restoredResultFromSummary,
36
35
  threadRecordFromThread,
@@ -67,6 +66,7 @@ import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts"
67
66
  import { forkRetainedSession } from "./session-fork.ts";
68
67
  import {
69
68
  buildResumePrompt,
69
+ getProjectRoot,
70
70
  getResultOutput,
71
71
  RpcRunControl,
72
72
  isFailedResult,
@@ -82,7 +82,6 @@ import {
82
82
  isProcessAlive,
83
83
  killProcessTree,
84
84
  sweepOrphanTempDirs,
85
- sweepUnreferencedState,
86
85
  } from "./temp-hygiene.ts";
87
86
  import {
88
87
  createWorktreeIsolation,
@@ -403,7 +402,6 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
403
402
  const runCtx = baseEnvironment.ctx;
404
403
  const runConfig = baseEnvironment.config;
405
404
  const runAgents = baseEnvironment.agents;
406
- const stateRoot = getStateRoot(runtime.configPath);
407
405
  const discoveredAgent = runAgents.find((candidate) => candidate.name === agentName);
408
406
  if (!discoveredAgent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
409
407
  const resolveLiveAgentTools = (candidate: AgentConfig): AgentConfig =>
@@ -417,6 +415,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
417
415
  }
418
416
 
419
417
  const originalCwd = resolve(cwd ?? runCtx.cwd);
418
+ const projectRoot = getProjectRoot(runtime.configPath, originalCwd);
419
+ const sessionsRoot = join(projectRoot, "sessions");
420
+ const worktreesRoot = join(projectRoot, "worktrees");
420
421
  const previousWorktree = existingThread?.worktree;
421
422
  let worktree = seed?.worktree ?? previousWorktree;
422
423
  if (isolation === "worktree") {
@@ -429,7 +430,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
429
430
  }
430
431
  if (!worktree) {
431
432
  try {
432
- worktree = await createWorktreeIsolation(originalCwd, { tempBaseDir: stateRoot });
433
+ worktree = await createWorktreeIsolation(originalCwd, { tempBaseDir: worktreesRoot });
433
434
  } catch (error) {
434
435
  return {
435
436
  ...failedStartResult(agentName, task, error instanceof Error ? error.message : String(error)),
@@ -548,10 +549,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
548
549
  const workflowAvailability = workflowAgentAvailability(runAgents);
549
550
  const reserveManagedLane =
550
551
  isolation === "shared" && canStartManagedWorkflow(agent, workflowAvailability);
551
- // Assigned once enqueue returns; only read after the generation's first
552
- // await, so the managed-continuation slot suspension always sees it.
553
- let generationController: AbortController | undefined;
554
- const runGeneration = async (backgroundSignal: AbortSignal): Promise<void> => {
552
+ const runGeneration = async (backgroundSignal: AbortSignal, controller: AbortController): Promise<void> => {
555
553
  if (runtime.threads.get(runId)?.generation !== generation) return;
556
554
  // Model/thinking config may have changed while this generation sat
557
555
  // queued behind the concurrency limit. Re-resolve the route at actual
@@ -587,7 +585,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
587
585
  control,
588
586
  makeDetails: makeDetails("single", true),
589
587
  idleTimeoutMs: activeIdleTimeoutMs,
590
- sessionRoot: stateRoot,
588
+ sessionRoot: sessionsRoot,
591
589
  ...(priorSessionId && priorSessionDir
592
590
  ? {
593
591
  sessionId: priorSessionId,
@@ -655,7 +653,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
655
653
  // concurrency slot so managed chains never starve manual
656
654
  // dispatches. Cancellation and quiescence guarantees are
657
655
  // unchanged — the task stays abortable and awaited.
658
- runtime.backgroundQueue.suspend(generationController);
656
+ runtime.backgroundQueue.suspend(controller);
659
657
  thread.state = "running";
660
658
  // The stable parent row now represents workflow ownership, not whichever
661
659
  // model stage ran most recently. Internal rows own their exact role/model/
@@ -771,7 +769,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
771
769
  : undefined;
772
770
  const needsFullFinal = failed || (lastStep.result.agent === "reviewer" && finalVerdict !== "pass");
773
771
  if (needsFullFinal) {
774
- block += `\n\n${formatCompletionBlock(result, runConfig.maxResultLines, originalCwd)}`;
772
+ block += `\n\n${formatCompletionBlock(result, runConfig.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, result.projectCwd ?? originalCwd) })}`;
775
773
  }
776
774
  if (finalVerdict === "fail") {
777
775
  block += `\n\n${reviewFailFollowUpNote()}`;
@@ -790,10 +788,10 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
790
788
  const completion: CompletionMessageItem = {
791
789
  agent: result.agent,
792
790
  block: modelLevel
793
- ? `${formatCompletionBlock(result, runConfig.maxResultLines, result.projectCwd ?? originalCwd)}\n\n${modelLevelTakeoverNote(result, { runId })}`
791
+ ? `${formatCompletionBlock(result, runConfig.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, result.projectCwd ?? originalCwd) })}\n\n${modelLevelTakeoverNote(result, { runId })}`
794
792
  : result.agent === "reviewer" && reviewVerdict(getResultOutput(result)) === "fail"
795
- ? `${formatCompletionBlock(result, runConfig.maxResultLines, result.projectCwd ?? originalCwd)}\n\n${reviewFailFollowUpNote()}`
796
- : formatCompletionBlock(result, runConfig.maxResultLines, result.projectCwd ?? originalCwd),
793
+ ? `${formatCompletionBlock(result, runConfig.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, result.projectCwd ?? originalCwd) })}\n\n${reviewFailFollowUpNote()}`
794
+ : formatCompletionBlock(result, runConfig.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, result.projectCwd ?? originalCwd) }),
797
795
  triggerTurn: completionTriggersTurn(result, runConfig.notifyOnReviewPass),
798
796
  usage: result.usage,
799
797
  };
@@ -814,13 +812,21 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
814
812
  }
815
813
  };
816
814
  const queuedGeneration = reserveManagedLane
817
- ? async (backgroundSignal: AbortSignal): Promise<void> => {
818
- await runInManagedRepositoryLane(
819
- originalCwd,
820
- () => runGeneration(backgroundSignal),
821
- backgroundSignal,
822
- );
823
- }
815
+ ? async (backgroundSignal: AbortSignal, controller: AbortController): Promise<void> => {
816
+ // This task may spend its whole life waiting for the repository
817
+ // lane (every shared write-capable generation serializes on it).
818
+ // Holding a process slot while only waiting let a batch of shared
819
+ // writers park the entire pool and starve independent read-only
820
+ // dispatches that could have started immediately, so the slot is
821
+ // released up front; the lane itself still serializes same-repo
822
+ // writers, and abort/quiesce guarantees are unchanged.
823
+ runtime.backgroundQueue.suspend(controller);
824
+ await runInManagedRepositoryLane(
825
+ originalCwd,
826
+ () => runGeneration(backgroundSignal, controller),
827
+ backgroundSignal,
828
+ );
829
+ }
824
830
  : runGeneration;
825
831
  const queueController = runtime.backgroundQueue.enqueue(
826
832
  queuedGeneration,
@@ -892,7 +898,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
892
898
  runtime.sendCompletionGroup([
893
899
  {
894
900
  agent: crashed.agent,
895
- block: formatCompletionBlock(crashed, runConfig.maxResultLines, crashed.projectCwd ?? originalCwd),
901
+ block: formatCompletionBlock(crashed, runConfig.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, crashed.projectCwd ?? originalCwd) }),
896
902
  triggerTurn: true,
897
903
  usage: crashed.usage,
898
904
  },
@@ -906,7 +912,6 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
906
912
  }
907
913
  },
908
914
  );
909
- generationController = queueController;
910
915
  thread.queueController = queueController;
911
916
  thread.generationCompletion = runtime.backgroundQueue.waitForTask(queueController);
912
917
  runtime.runControllers.set(runId, queueController);
@@ -923,7 +928,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
923
928
  export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifecycleDeps): void {
924
929
  const { runtime, startBackground } = deps;
925
930
  const runId = thread.id;
926
- const stateRoot = getStateRoot(runtime.configPath);
931
+ const projectRoot = getProjectRoot(runtime.configPath, thread.cwd);
932
+ const sessionsRoot = join(projectRoot, "sessions");
933
+ const worktreesRoot = join(projectRoot, "worktrees");
927
934
 
928
935
  thread.notifyIsolationFailure = (finalization) => {
929
936
  const paths = [finalization.worktreePath, finalization.patchPath].filter(Boolean).join(" · ");
@@ -1058,7 +1065,7 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
1058
1065
  return createWorktreeIsolation(thread.cwd, {
1059
1066
  seedCheckpoint,
1060
1067
  seedIsIntegrated,
1061
- tempBaseDir: stateRoot,
1068
+ tempBaseDir: worktreesRoot,
1062
1069
  });
1063
1070
  };
1064
1071
 
@@ -1146,7 +1153,7 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
1146
1153
  targetCwd: continuationWorktree.cwd,
1147
1154
  sessionDir: previousSessionDir,
1148
1155
  sessionId: previousSessionId,
1149
- targetRoot: stateRoot,
1156
+ targetRoot: sessionsRoot,
1150
1157
  });
1151
1158
  runtime.sessionDirs.add(clonedSession.sessionDir);
1152
1159
  if (!ownsResumeReservation(runtime, thread, reservation)) {
@@ -1252,6 +1259,11 @@ async function discardRestoredRecord(runtime: SubagentRuntime, record: ThreadRec
1252
1259
  await removeThreadRecord(runtime.configPath, record.runId).catch(() => undefined);
1253
1260
  }
1254
1261
 
1262
+ /** Project-scoped <projectRoot>/results for a completion's artifacts. */
1263
+ export function projectResultsRoot(configPath: string, cwd: string | undefined): string {
1264
+ return join(getProjectRoot(configPath, cwd), "results");
1265
+ }
1266
+
1255
1267
  function createRestoredThread(
1256
1268
  runtime: SubagentRuntime,
1257
1269
  record: ThreadRecord,
@@ -1377,11 +1389,8 @@ export async function bootstrapDurableState(runtime: SubagentRuntime): Promise<v
1377
1389
  /* temp hygiene is best-effort */
1378
1390
  }
1379
1391
  try {
1380
- sweepUnreferencedState(
1381
- getStateRoot(runtime.configPath),
1382
- referencedDurablePaths(await readThreadRecords(runtime.configPath)),
1383
- );
1392
+ await pruneStaleProjectRoots(runtime.configPath);
1384
1393
  } catch {
1385
- /* state hygiene is best-effort */
1394
+ /* project-root hygiene is best-effort */
1386
1395
  }
1387
1396
  }