@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 +5 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/agent-manager.ts +26 -9
- package/src/worktree.ts +65 -17
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,
|
|
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
|
|
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
package/src/agent-manager.ts
CHANGED
|
@@ -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
|
|
270
|
-
`
|
|
271
|
-
`git -C ${
|
|
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
|
-
//
|
|
4676
|
-
//
|
|
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
|
-
//
|
|
194
|
-
|
|
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 ${
|
|
202
|
-
`git -C ${
|
|
203
|
-
`
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
}
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|