@signalridge/pi-subagents 1.15.0 → 1.15.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.15.1
4
+ ### Patch Changes
5
+
6
+ - 4d37546: Retain worktree contents and Git registration when preservation fails, verify directory removal and deregistration before reporting cleanup success, and replace destructive recovery advice with inspection and save guidance. Report the preservation branch if removal is refused. Preserve missing-checkout registrations for detached HEAD, reflog, and index recovery, using metadata-only Git recovery locks across shutdown, reload, and later pruning, including when execution or descendant pins prevent checkout cleanup. Preserve existing locks, report lock failures, and defer all global orphan pruning while any worktree cleanup remains unresolved. Quote recovery paths literally so shell metacharacters cannot execute.
7
+
3
8
  ## 1.15.0
4
9
  ### Minor Changes
5
10
 
package/README.md CHANGED
@@ -912,7 +912,7 @@ pi.events.emit("subagents:rpc:spawn", {
912
912
 
913
913
  `options.model` accepts either a `Model` object (e.g. `ctx.model`) or a `"provider/modelId"` string — strings are resolved against `ctx.modelRegistry` at the RPC boundary, so cross-extension callers can forward serializable values without losing auth context.
914
914
 
915
- `options.cwd` (absolute path to an existing directory — anything else returns an error envelope; `null` means unset) runs the agent in a different working directory than the parent session. Its tools operate there and the prompt's environment block describes it, but **`.pi` config still loads from the parent session's project** — the target directory's `.pi` extensions never execute, and its agents/skills/settings are not picked up. Combined with `isolation: "worktree"`, the worktree is created *from* the target directory's repo, the agent works at the equivalent subdirectory inside the copy (a monorepo-package cwd stays scoped to that package), and the resulting `pi-agent-*` branch lands in that repo — the completion message names it. On session end, worktree registrations are pruned in every repo that received one; only a hard crash can leave a stale entry (then: `git worktree prune` in the target repo). Agents with `memory:` keep reading/writing the parent project's memory.
915
+ `options.cwd` (absolute path to an existing directory — anything else returns an error envelope; `null` means unset) runs the agent in a different working directory than the parent session. Its tools operate there and the prompt's environment block describes it, but **`.pi` config still loads from the parent session's project** — the target directory's `.pi` extensions never execute, and its agents/skills/settings are not picked up. Combined with `isolation: "worktree"`, the worktree is created *from* the target directory's repo, the agent works at the equivalent subdirectory inside the copy (a monorepo-package cwd stays scoped to that package), and the resulting `pi-agent-*` branch lands in that repo — the completion message names it. On session end, cleanup is attempted in every repo that received a worktree. Failed preservation or removal retains the worktree reference and recovery diagnostics; inspect and save its contents before repairing Git or retrying cleanup. Agents with `memory:` keep reading/writing the parent project's memory.
916
916
 
917
917
  ### Stop
918
918
 
@@ -976,7 +976,7 @@ The agent gets a full, isolated copy of the repository. On completion:
976
976
  The automatic preservation commit uses `--no-verify`, so local pre-commit hooks can't block it — the commit is local-only and never pushed, and pre-push/server-side hooks still apply.
977
977
 
978
978
  If the worktree cannot be created (not a git repo, no commits, or `git worktree add` fails), the `Agent` tool returns a clear error instead of running unisolated — `isolation: "worktree"` is a strict guarantee, not a hint. Initialize git and commit at least once, or omit `isolation`.
979
- If cleanup cannot be confirmed, the result keeps the worktree path and includes a bounded diagnostic plus recovery commands. Cleanup first tries `git worktree remove --force`; when that fails, pi-subagents only uses direct filesystem removal for a verified package-created temporary path, then prunes and verifies Git's worktree registration. It never recursively removes an arbitrary caller-supplied path automatically. At session shutdown, failed removals are retried deepest-first without blocking siblings or ancestors; late provider continuations are quarantined, and remaining failures are reported as immutable path- and repository-specific diagnostics via `getWorktreeCleanupFailures()`.
979
+ If status, commit, or both preservation-branch attempts fail, cleanup retains the checkout and its Git registration; it does not attempt destructive error cleanup. The result keeps the worktree path, diagnostic, and non-destructive inspection/recovery guidance. Inspect and copy the checkout, including untracked files, before repairing Git or retrying cleanup. After preservation (or confirming no changes), cleanup uses `git worktree remove --force` without overriding Git worktree locks and reports success only after verifying both directory removal and deregistration. A missing checkout with a retained registration reports failure because its detached HEAD, reflog, or index may still be recoverable. A metadata-only helper locks an observed missing registration with a fixed recovery reason so later sessions and Git pruning retain it, even when provider, native-input, child-teardown, or descendant pins prevent checkout cleanup. Existing locks are preserved and never automatically unlocked. Failure to establish a recovery lock is reported explicitly and requires manual recovery. A repeated call after verified directory removal and deregistration is a successful no-op. A removal failure reports the preservation branch when one was created; no direct filesystem-removal fallback runs. At session shutdown, failed removals are retried deepest-first while dependent ancestors remain pinned, and global orphan pruning across all targets is deferred while any worktree reference remains attached; late provider continuations are quarantined, and remaining failures are reported as immutable path- and repository-specific diagnostics via `getWorktreeCleanupFailures()`.
980
980
 
981
981
  The child prompt also states that the temporary worktree is the only writable checkout and names the original base repository as off-limits. This prevents inherited parent instructions from sending the agent back into the shared tree.
982
982
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalridge/pi-subagents",
3
- "version": "1.15.0",
3
+ "version": "1.15.1",
4
4
  "description": "Signalridge's managed subagent runtime with workflow-owned orchestration RPC.",
5
5
  "author": "tintinweb and signalridge contributors",
6
6
  "license": "MIT",
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { createHash, randomUUID } from "node:crypto";
11
- import { realpathSync, statSync } from "node:fs";
11
+ import { existsSync, realpathSync, statSync } from "node:fs";
12
12
  import { isAbsolute, resolve } from "node:path";
13
13
  import type { Model } from "@earendil-works/pi-ai";
14
14
  import type {
@@ -66,6 +66,7 @@ import {
66
66
  cleanupWorktreeAsync,
67
67
  createWorktree,
68
68
  isWorktreeIsolationEnabled,
69
+ protectMissingWorktree,
69
70
  pruneWorktrees,
70
71
  pruneWorktreesAsync,
71
72
  type WorktreeCleanupResult,
@@ -266,9 +267,10 @@ function worktreeRecoveryCommands(
266
267
  const c = cwd ? shellQuote(cwd) : "'.'";
267
268
  const p = path ? shellQuote(path) : "''";
268
269
  return Object.freeze([
269
- `git -C ${c} worktree remove --force ${p}`,
270
- `rm -rf -- ${p}`,
271
- `git -C ${c} worktree prune`,
270
+ `git -C ${c} worktree list --porcelain`,
271
+ `git -C ${p} status --short`,
272
+ `git -C ${p} log -5 --oneline`,
273
+ "# Inspect and copy the checkout (including untracked files) to a safe location before repairing Git or retrying cleanup.",
272
274
  ]);
273
275
  }
274
276
 
@@ -3719,13 +3721,23 @@ export class AgentManager {
3719
3721
  sameFilesystemPath(child.worktree.repoRoot, worktree.path)) ||
3720
3722
  child.worktree === undefined),
3721
3723
  );
3724
+ let recoveryDiagnostic: string | undefined;
3725
+ if (!existsSync(worktree.path)) {
3726
+ try {
3727
+ // Metadata-only protection is safe before quiescence. Never run actual
3728
+ // checkout cleanup just to retain recovery across manager replacement.
3729
+ recoveryDiagnostic = protectMissingWorktree(worktree.repoRoot, worktree.path);
3730
+ } catch (error) {
3731
+ recoveryDiagnostic = error instanceof Error ? error.message : String(error);
3732
+ }
3733
+ }
3734
+ const pinnedDiagnostic = diagnostic ??
3735
+ `Worktree cleanup is pinned because descendant ${dependent?.id ?? "agent"} still depends on ${worktree.path}`;
3722
3736
  const result: WorktreeCleanupResult = {
3723
3737
  hasChanges: false,
3724
3738
  path: worktree.path,
3725
3739
  cleanupSucceeded: false,
3726
- cleanupDiagnostic:
3727
- diagnostic ??
3728
- `Worktree cleanup is pinned because descendant ${dependent?.id ?? "agent"} still depends on ${worktree.path}`,
3740
+ cleanupDiagnostic: recoveryDiagnostic ? `${pinnedDiagnostic} ${recoveryDiagnostic}` : pinnedDiagnostic,
3729
3741
  recoveryCommands: worktreeRecoveryCommands(
3730
3742
  worktree.repoRoot,
3731
3743
  worktree.path,
@@ -4669,11 +4681,16 @@ export class AgentManager {
4669
4681
  }
4670
4682
  };
4671
4683
  const pruneRepositories = async (): Promise<void> => {
4684
+ // Defer all global orphan pruning while any worktree remains attached:
4685
+ // its registration may hold recoverable HEAD/reflog/index data. Paths
4686
+ // cannot establish Git identity; cwd may be a subdirectory or another
4687
+ // linked checkout of the same repository as a retained failure.
4688
+ if (orderedRecords.some((record) => record.worktree !== undefined)) return;
4672
4689
  await Promise.all([...reposToPrune].map((repo) => pruneRepository(repo)));
4673
4690
  };
4674
4691
 
4675
- // Pruning after the first pass can release stale child registrations. Retry
4676
- // only records whose worktree reference remains, still deepest-first.
4692
+ // Retry retained worktrees deepest-first even when global orphan pruning
4693
+ // is deferred. Individual cleanup of siblings remains independent.
4677
4694
  await pruneRepositories();
4678
4695
  for (const record of orderedRecords) {
4679
4696
  if (record.worktree) await attemptCleanup(record);
package/src/worktree.ts CHANGED
@@ -10,7 +10,7 @@ import { execFileSync } from "node:child_process";
10
10
  import { randomUUID } from "node:crypto";
11
11
  import { existsSync, realpathSync } from "node:fs";
12
12
  import { tmpdir } from "node:os";
13
- import { join, relative } from "node:path";
13
+ import { basename, dirname, join, relative, resolve } from "node:path";
14
14
 
15
15
  export interface WorktreeInfo {
16
16
  /** Absolute path to the worktree directory (the copied repo's root). */
@@ -65,6 +65,10 @@ export interface WorktreeCleanupResult {
65
65
  recoveryCommands?: readonly string[] | string[];
66
66
  }
67
67
 
68
+ function shellQuote(value: string): string {
69
+ return `'${value.replaceAll("'", "'\\''")}'`;
70
+ }
71
+
68
72
  /**
69
73
  * Create a temporary git worktree for an agent.
70
74
  * Returns the worktree path, or undefined if not in a git repo.
@@ -119,12 +123,15 @@ export function cleanupWorktree(
119
123
  worktree: WorktreeInfo,
120
124
  agentDescription: string,
121
125
  ): WorktreeCleanupResult {
122
- if (!existsSync(worktree.path)) {
123
- return { hasChanges: false, cleanupSucceeded: true };
124
- }
125
-
126
126
  let hasChanges = false;
127
+ let preservedBranch: string | undefined;
127
128
  try {
129
+ if (!existsSync(worktree.path)) {
130
+ // A vanished directory can still have recoverable detached HEAD/index
131
+ // state. Only an already-deregistered checkout is a confirmed no-op.
132
+ removeWorktree(cwd, worktree.path);
133
+ return { hasChanges: false, cleanupSucceeded: true };
134
+ }
128
135
  // Check for uncommitted changes in the worktree
129
136
  const status = execFileSync("git", ["status", "--porcelain"], {
130
137
  cwd: worktree.path,
@@ -179,6 +186,7 @@ export function cleanupWorktree(
179
186
  }
180
187
  // Update branch name in worktree info for the caller
181
188
  worktree.branch = branchName;
189
+ preservedBranch = branchName;
182
190
 
183
191
  // Remove the worktree (branch persists in main repo)
184
192
  removeWorktree(cwd, worktree.path);
@@ -190,17 +198,19 @@ export function cleanupWorktree(
190
198
  cleanupSucceeded: true,
191
199
  };
192
200
  } catch (error) {
193
- // Best effort cleanup on error
194
- try { removeWorktree(cwd, worktree.path); } catch { /* ignore */ }
201
+ // Preservation failed or removal was refused. Keep the checkout and Git
202
+ // registration as recovery references; never remove unpreserved work here.
195
203
  return {
196
204
  hasChanges,
205
+ ...(preservedBranch ? { branch: preservedBranch } : {}),
197
206
  path: worktree.path,
198
207
  cleanupSucceeded: false,
199
208
  cleanupDiagnostic: error instanceof Error ? error.message : String(error),
200
209
  recoveryCommands: [
201
- `git -C ${JSON.stringify(cwd)} worktree remove --force ${JSON.stringify(worktree.path)}`,
202
- `git -C ${JSON.stringify(cwd)} worktree prune`,
203
- `rm -rf -- ${JSON.stringify(worktree.path)}`,
210
+ `git -C ${shellQuote(cwd)} worktree list --porcelain`,
211
+ `git -C ${shellQuote(worktree.path)} status --short`,
212
+ `git -C ${shellQuote(worktree.path)} log -5 --oneline`,
213
+ "# Inspect and copy the checkout (including untracked files) to a safe location before repairing Git or retrying cleanup.",
204
214
  ],
205
215
  };
206
216
  }
@@ -217,21 +227,59 @@ export async function cleanupWorktreeAsync(
217
227
  return cleanupWorktree(cwd, worktree, agentDescription);
218
228
  }
219
229
 
230
+ function worktreeRegistration(cwd: string, worktreePath: string): string[] | undefined {
231
+ // Git reports canonical paths (e.g. /private/var on macOS). Resolve the
232
+ // parent so verification also works after the directory has disappeared.
233
+ const canonicalPath = join(realpathSync(dirname(worktreePath)), basename(worktreePath));
234
+ return execFileSync("git", ["worktree", "list", "--porcelain", "-z"], {
235
+ cwd,
236
+ stdio: "pipe",
237
+ timeout: 5000,
238
+ }).toString().split("\0\0").map((record) => record.split("\0")).find((fields) =>
239
+ fields.some((field) => field.startsWith("worktree ") && [canonicalPath, resolve(worktreePath)].includes(field.slice(9))),
240
+ );
241
+ }
242
+
220
243
  /**
221
- * Force-remove a worktree.
244
+ * Protect observed missing-checkout metadata, even while execution is pinned.
245
+ * Only inspects registration and establishes a Git recovery lock; never stages,
246
+ * commits, removes, or unlocks anything. Lock failures propagate to the owner.
222
247
  */
248
+ export function protectMissingWorktree(cwd: string, worktreePath: string): string | undefined {
249
+ if (existsSync(worktreePath)) return undefined;
250
+ const registered = worktreeRegistration(cwd, worktreePath);
251
+ if (!registered) return undefined;
252
+ // A Git-owned lock survives manager/session replacement. Preserve existing
253
+ // locks and never automatically unlock them, including after repair/retry.
254
+ if (!registered.some((field) => field === "locked" || field.startsWith("locked "))) {
255
+ try {
256
+ execFileSync("git", ["worktree", "lock", "--reason", "pi-subagents: missing checkout retained for HEAD/index recovery", worktreePath], {
257
+ cwd,
258
+ stdio: "pipe",
259
+ timeout: 5000,
260
+ });
261
+ } catch (error) {
262
+ throw new Error(`Missing worktree recovery registration could not be locked for ${worktreePath}: ${error instanceof Error ? error.message : String(error)}`);
263
+ }
264
+ }
265
+ return `Worktree checkout is missing; its Git registration remains locked for HEAD/index recovery: ${worktreePath}`;
266
+ }
267
+
268
+ /** Remove a preserved/unchanged checkout and positively verify deregistration. */
223
269
  function removeWorktree(cwd: string, worktreePath: string): void {
224
- try {
270
+ const recoveryDiagnostic = protectMissingWorktree(cwd, worktreePath);
271
+ if (recoveryDiagnostic) throw new Error(recoveryDiagnostic);
272
+ if (worktreeRegistration(cwd, worktreePath) || existsSync(worktreePath)) {
273
+ // One --force permits a preserved dirty checkout, but does not override a
274
+ // Git worktree lock. Propagate refusal rather than pretending prune fixed it.
225
275
  execFileSync("git", ["worktree", "remove", "--force", worktreePath], {
226
276
  cwd,
227
277
  stdio: "pipe",
228
278
  timeout: 10000,
229
279
  });
230
- } catch {
231
- // If git worktree remove fails, try pruning
232
- try {
233
- execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
234
- } catch { /* ignore */ }
280
+ }
281
+ if (existsSync(worktreePath) || worktreeRegistration(cwd, worktreePath)) {
282
+ throw new Error(`Worktree removal was not confirmed for ${worktreePath}`);
235
283
  }
236
284
  }
237
285