@arhen/pi-core-subagent 1.3.49 → 1.3.50

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/worktree.ts CHANGED
@@ -1,44 +1,26 @@
1
- /** Git worktree isolation for write subagents.
2
- * Worktrees live inside `<repo>/.git/subagents/<runId>/<taskId>` so the child's
3
- * ancestor walk still finds the project AGENTS.md chain. node_modules is
4
- * symlinked from the main tree. The extension commits the child's changes on
5
- * completion; the leader reviews and merges the branch manually.
6
- *
7
- * Cleanup, in order of trust: `reapDeadWorktrees` (session start — nothing can
8
- * be live yet, so every registered worktree is a crash leftover: commit its
9
- * work, drop the dir, keep the branch), `cleanupMerged` (merged branches, never
10
- * touching a checked-out one), `sweepStale` (dirs git no longer knows about). */
11
-
12
1
  import { execFileSync } from "node:child_process";
13
2
  import { existsSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
14
3
  import { hostname, uptime } from "node:os";
15
4
  import { join, resolve } from "node:path";
16
5
 
17
6
  export interface Worktree {
18
- root: string; // repo root (main tree)
19
- path: string; // worktree checkout dir
20
- branch: string; // subagents/<runId>/<taskId>
21
- base: string; // SHA the branch was created from
7
+ root: string;
8
+ path: string;
9
+ branch: string;
10
+ base: string;
22
11
  }
23
12
 
24
13
  const BRANCH_PREFIX = "subagents/";
25
14
 
26
- /** Identity + signing fallbacks: a machine without user.email (CI, fresh box) or
27
- * with commit.gpgsign set must not fail — or worse, block on a passphrase prompt. */
28
15
  const COMMIT_CONFIG = ["-c", "commit.gpgsign=false", "-c", "user.name=pi subagent", "-c", "user.email=subagent@local"];
29
16
  const GIT_TIMEOUT_MS = 120_000;
30
17
  const GIT_MAX_BUFFER = 32 * 1024 * 1024;
31
- /** Capture stderr instead of inheriting it. execFileSync only redirects stdout by
32
- * default, so git's progress chatter ("Preparing worktree (new branch ...)")
33
- * printed straight into the TUI and corrupted the rendered frame. Captured
34
- * stderr still reaches us on failure via the thrown error. */
35
18
  const GIT_STDIO: ("ignore" | "pipe")[] = ["ignore", "pipe", "pipe"];
36
19
 
37
20
  function git(root: string, args: string[]): string {
38
21
  return gitRaw(root, args).trim();
39
22
  }
40
23
 
41
- /** Untrimmed git output — required for -z parsing, where a path may end in a space. */
42
24
  function gitRaw(root: string, args: string[]): string {
43
25
  return execFileSync("git", ["-C", root, ...args], {
44
26
  encoding: "utf8",
@@ -48,7 +30,6 @@ function gitRaw(root: string, args: string[]): string {
48
30
  });
49
31
  }
50
32
 
51
- /** Run git directly inside a directory (worktree ops). */
52
33
  function gitIn(dir: string, args: string[]): string {
53
34
  return execFileSync("git", [...args], {
54
35
  cwd: dir,
@@ -68,7 +49,6 @@ function gitOk(root: string, args: string[]): boolean {
68
49
  }
69
50
  }
70
51
 
71
- /** Compare paths through realpath so /var vs /private/var can't diverge. */
72
52
  function samePath(a: string, b: string): boolean {
73
53
  if (a === b) return true;
74
54
  try {
@@ -78,7 +58,6 @@ function samePath(a: string, b: string): boolean {
78
58
  }
79
59
  }
80
60
 
81
- /** Repo root for cwd, or undefined when not a git repo (or cwd doesn't exist). */
82
61
  export function repoRoot(cwd: string): string | undefined {
83
62
  if (!existsSync(cwd)) return undefined;
84
63
  try {
@@ -88,76 +67,43 @@ export function repoRoot(cwd: string): string | undefined {
88
67
  }
89
68
  }
90
69
 
91
- /**
92
- * Create an isolated worktree for a write task. Returns undefined when not a git repo.
93
- *
94
- * @param baseRef Branch/SHA to start from. A chained write task MUST pass its
95
- * upstream's branch: basing on main HEAD hands the child a tree without the
96
- * upstream's edits, so it "builds on" work it cannot see and its merge reverts
97
- * the upstream.
98
- */
99
70
  export function createWorktree(cwd: string, runId: string, taskId: string, baseRef?: string): Worktree | undefined {
100
71
  const root = repoRoot(cwd);
101
72
  if (!root) return undefined;
102
- // --git-common-dir, not "<root>/.git": inside a linked worktree or a submodule
103
- // `.git` is a FILE, and joining it would make `worktree add` fail (silently
104
- // dropping isolation).
73
+
105
74
  const container = subagentsDir(root);
106
75
  if (!container) return undefined;
107
76
  let base: string;
108
77
  try {
109
- // Resolve to a SHA so a later commit on the ref can't skew the diff base.
110
78
  base = git(root, ["rev-parse", baseRef ?? "HEAD"]);
111
79
  } catch {
112
- if (!baseRef) return undefined; // broken repo — fall back to in-place
80
+ if (!baseRef) return undefined;
113
81
  try {
114
- base = git(root, ["rev-parse", "HEAD"]); // upstream branch gone — fall back to HEAD
82
+ base = git(root, ["rev-parse", "HEAD"]);
115
83
  } catch {
116
84
  return undefined;
117
85
  }
118
86
  }
119
87
  const path = join(container, runId, taskId);
120
88
  const branch = `${BRANCH_PREFIX}${runId}/${taskId}`;
121
- // Branch from the recorded SHA, not "HEAD" — a concurrent commit in the main
122
- // tree between the two would otherwise skew every later diff against base.
89
+
123
90
  git(root, ["worktree", "add", "-b", branch, path, base]);
124
- // Deps follow the child into the worktree so it can build without a reinstall.
125
- //
126
- // ponytail: this is a SHARED symlink to the main tree's node_modules, not a
127
- // copy — the cheap option, and it escapes isolation. A child that runs an
128
- // install, or `rm -rf node_modules/` (trailing slash follows the link),
129
- // mutates the leader's real deps outside any branch. Ceiling accepted because
130
- // copying/hardlinking node_modules per worktree costs GBs per task; the
131
- // upgrade path is a per-worktree install on an explicit opt-in flag.
132
- // Children are warned in their system prompt (see manager.ts childPrompt).
91
+
133
92
  const nm = join(root, "node_modules");
134
93
  if (existsSync(nm) && !existsSync(join(path, "node_modules"))) {
135
94
  try {
136
95
  symlinkSync(nm, join(path, "node_modules"));
137
- } catch {
138
- /* non-fatal: task may not need deps */
139
- }
96
+ } catch {}
140
97
  }
141
98
  return { root, path, branch, base };
142
99
  }
143
100
 
144
- /**
145
- * Commit the child's changes. Stages first, then commits only when something is
146
- * actually staged — an untracked node_modules symlink must not fake "dirty" and
147
- * turn into a failed empty commit. Returns "committed" | "empty"; a real git
148
- * failure THROWS, and callers must not delete the checkout in that case (the
149
- * work would become unreachable once the base-tip branch is reaped as merged).
150
- */
151
101
  export function commitWorktree(wt: Worktree, message: string): "committed" | "empty" {
152
102
  return commitIn(wt.path, message, wt.branch);
153
103
  }
154
104
 
155
105
  function commitIn(dir: string, message: string, expectBranch?: string): "committed" | "empty" {
156
- // A child that detached HEAD or switched branches would commit somewhere the
157
- // leader is never told about — refuse rather than report a branch without the work.
158
106
  if (expectBranch) {
159
- // `symbolic-ref` EXITS NON-ZERO on a detached HEAD — catch it rather than
160
- // letting the raw git failure masquerade as a commit error.
161
107
  let head = "detached";
162
108
  try {
163
109
  head = gitIn(dir, ["symbolic-ref", "--quiet", "--short", "HEAD"]) || "detached";
@@ -166,14 +112,13 @@ function commitIn(dir: string, message: string, expectBranch?: string): "committ
166
112
  }
167
113
  if (head !== expectBranch) throw new Error(`worktree HEAD is "${head}", expected ${expectBranch}`);
168
114
  }
169
- // Exclude the root dep symlink and any nested node_modules the child created.
115
+
170
116
  gitIn(dir, ["add", "-A", "--", ".", ":(exclude)node_modules", ":(exclude,glob)**/node_modules/**"]);
171
117
  if (gitIn(dir, ["diff", "--cached", "--name-only"]).length === 0) return "empty";
172
118
  gitIn(dir, [...COMMIT_CONFIG, "commit", "-m", message, "--no-verify"]);
173
119
  return "committed";
174
120
  }
175
121
 
176
- /** Diffstat + changed files of the branch vs its base SHA. */
177
122
  export function branchDiff(wt: Worktree): { stat: string; files: string[] } {
178
123
  const files = git(wt.root, ["diff", "--name-only", `${wt.base}...${wt.branch}`])
179
124
  .split("\n")
@@ -182,12 +127,10 @@ export function branchDiff(wt: Worktree): { stat: string; files: string[] } {
182
127
  return { stat, files };
183
128
  }
184
129
 
185
- /** Remove the worktree dir. The branch is KEPT (the work survives for merging). */
186
130
  export function removeWorktree(wt: Worktree): void {
187
131
  dropDir(wt.root, wt.path);
188
132
  }
189
133
 
190
- /** Remove a worktree dir by branch name (cancel paths that didn't keep a Worktree). */
191
134
  export function removeByBranch(cwd: string, branch: string): void {
192
135
  if (!branch.startsWith(BRANCH_PREFIX)) return;
193
136
  const root = repoRoot(cwd);
@@ -196,8 +139,6 @@ export function removeByBranch(cwd: string, branch: string): void {
196
139
  if (container) dropDir(root, join(container, branch.slice(BRANCH_PREFIX.length)));
197
140
  }
198
141
 
199
- /** `<git-common-dir>/subagents` — where our worktrees live for this repo.
200
- * `--path-format` needs git ≥ 2.31; fall back to resolving the relative form. */
201
142
  function subagentsDir(root: string): string | undefined {
202
143
  try {
203
144
  return join(git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]), "subagents");
@@ -210,8 +151,6 @@ function subagentsDir(root: string): string | undefined {
210
151
  }
211
152
  }
212
153
 
213
- /** Marker path lives BESIDE the checkout, never inside it — a file in the worktree
214
- * would be staged by `add -A`, committed into the branch, and merged into main. */
215
154
  function ownerFile(path: string): string {
216
155
  return `${path}.owner`;
217
156
  }
@@ -222,32 +161,21 @@ function dropDir(root: string, path: string): void {
222
161
  } catch {
223
162
  if (existsSync(path)) rmSync(path, { recursive: true, force: true });
224
163
  }
225
- rmSync(ownerFile(path), { force: true }); // marker lives beside the dir
164
+ rmSync(ownerFile(path), { force: true });
226
165
  prune(root);
227
166
  }
228
167
 
229
- /** Drop git's stale worktree admin entries (they pile up under .git/worktrees). */
230
168
  function prune(root: string): void {
231
169
  try {
232
170
  git(root, ["worktree", "prune"]);
233
- } catch {
234
- /* ignore */
235
- }
171
+ } catch {}
236
172
  }
237
173
 
238
- /** Is this branch checked out right now? "Unknown" counts as YES (never delete blind). */
239
174
  function isCheckedOut(root: string, branch: string): boolean {
240
175
  const branches = worktreeBranches(root);
241
176
  return branches === undefined || branches.includes(branch);
242
177
  }
243
178
 
244
- /**
245
- * Delete branch + worktree for branches already merged into `target`.
246
- * SAFETY: a branch checked out in a LIVE worktree is skipped — a fresh branch's
247
- * tip equals its base until the child commits, so it looks "merged". The
248
- * registration is re-checked immediately before each removal (a concurrent run
249
- * may have created its worktree after the first listing).
250
- */
251
179
  export function cleanupMerged(root: string, opts: { skipBranches?: Set<string>; target?: string } = {}): number {
252
180
  root = realpathSync(root);
253
181
  const target = opts.target ?? "HEAD";
@@ -258,19 +186,16 @@ export function cleanupMerged(root: string, opts: { skipBranches?: Set<string>;
258
186
  const container = subagentsDir(root);
259
187
  for (const branch of merged) {
260
188
  if (!branch.startsWith(BRANCH_PREFIX) || !container) continue;
261
- if (opts.skipBranches?.has(branch)) continue; // owned by a live run
262
- if (isCheckedOut(root, branch)) continue; // fresh re-check, not a stale snapshot
189
+ if (opts.skipBranches?.has(branch)) continue;
190
+ if (isCheckedOut(root, branch)) continue;
263
191
  const path = join(container, branch.slice(BRANCH_PREFIX.length));
264
- if (existsSync(path) && ownerAlive(path)) continue; // another session's live checkout
265
- // Branch FIRST: `-d` refuses anything not truly merged, so a stale "merged"
266
- // listing can no longer cost us a checkout that still holds work.
192
+ if (existsSync(path) && ownerAlive(path)) continue;
193
+
267
194
  if (!gitOk(root, ["branch", "-d", branch])) continue;
268
195
  if (existsSync(path)) {
269
196
  try {
270
197
  git(root, ["worktree", "remove", "--force", path]);
271
- } catch {
272
- /* branch is gone; a leftover dir is swept later */
273
- }
198
+ } catch {}
274
199
  }
275
200
  rmSync(ownerFile(path), { force: true });
276
201
  cleaned += 1;
@@ -280,8 +205,6 @@ export function cleanupMerged(root: string, opts: { skipBranches?: Set<string>;
280
205
  return cleaned;
281
206
  }
282
207
 
283
- /** Remove `<subagents>/<runId>/` once its task dirs are gone. Cleanup left these
284
- * behind forever, so `.git/subagents` grew one empty dir per run. */
285
208
  function pruneEmptyRunDirs(root: string): void {
286
209
  const sub = subagentsDir(root);
287
210
  if (!sub || !existsSync(sub)) return;
@@ -291,51 +214,36 @@ function pruneEmptyRunDirs(root: string): void {
291
214
  const runDir = join(sub, entry.name);
292
215
  try {
293
216
  if (readdirSync(runDir).length === 0) rmSync(runDir, { recursive: true, force: true });
294
- } catch {
295
- /* skip */
296
- }
217
+ } catch {}
297
218
  }
298
- } catch {
299
- /* best-effort */
300
- }
219
+ } catch {}
301
220
  }
302
221
 
303
- /** Branch names currently checked out in any worktree, or undefined when git
304
- * couldn't be asked — callers MUST treat undefined as "unknown", never as "none",
305
- * or they will happily delete live checkouts. */
306
222
  function worktreeBranches(root: string): string[] | undefined {
307
223
  const listing = worktreeListing(root);
308
224
  return listing?.filter((l) => l.startsWith("branch ")).map((l) => l.slice("branch refs/heads/".length));
309
225
  }
310
226
 
311
- /**
312
- * Session-start recovery: every registered subagent worktree is a crash leftover
313
- * (nothing of ours can be live yet). Commit whatever the dead child left so the
314
- * branch keeps it, then drop the dir. Branches always survive.
315
- */
316
227
  export function reapDeadWorktrees(root: string, isLive: (path: string) => boolean = () => false): number {
317
228
  root = realpathSync(root);
318
229
  const sub = subagentsDir(root);
319
230
  if (!sub || !existsSync(sub)) return 0;
320
231
  const registered = worktreePaths(root);
321
- if (!registered) return 0; // listing failed — touch nothing
232
+ if (!registered) return 0;
322
233
  let reaped = 0;
323
234
  for (const path of registered) {
324
- if (!isInside(path, sub)) continue; // not ours
325
- if (isLive(path)) continue; // another pi session owns it
235
+ if (!isInside(path, sub)) continue;
236
+ if (isLive(path)) continue;
326
237
  try {
327
238
  commitIn(path, "subagent (recovered after interrupted session)");
328
239
  } catch {
329
- // A dir git refuses to read (half-created by a timed-out worktree add,
330
- // or corrupted) holds no recoverable work — drop it instead of retrying
331
- // forever. The "never drop uncommitted work" rule protects readable dirs.
332
240
  try {
333
241
  execFileSync("git", ["-C", path, "rev-parse", "--git-dir"], {
334
242
  encoding: "utf8",
335
243
  timeout: GIT_TIMEOUT_MS,
336
244
  maxBuffer: GIT_MAX_BUFFER,
337
245
  });
338
- continue; // git still reads it — legitimate work, keep
246
+ continue;
339
247
  } catch {
340
248
  dropDir(root, path);
341
249
  reaped += 1;
@@ -348,98 +256,67 @@ export function reapDeadWorktrees(root: string, isLive: (path: string) => boolea
348
256
  return reaped;
349
257
  }
350
258
 
351
- /**
352
- * Ownership marker: a live worktree gets `<dir>/.subagent-owner` holding the
353
- * owning pid. Another pi session must not reap a checkout whose owner is alive.
354
- */
355
259
  export function claimWorktree(wt: Worktree): void {
356
260
  try {
357
261
  writeFileSync(
358
262
  ownerFile(wt.path),
359
263
  JSON.stringify({ pid: process.pid, host: hostname(), boot: bootId(), at: Date.now() }),
360
264
  );
361
- } catch {
362
- /* best-effort: worst case another session reaps it after a crash */
363
- }
265
+ } catch {}
364
266
  }
365
267
 
366
- /**
367
- * True when the worktree is claimed by a process that still exists HERE.
368
- * Guards against pid reuse across reboots (boot id) and other hosts (hostname);
369
- * EPERM means the pid exists under another user — alive, not reapable.
370
- */
371
268
  export function ownerAlive(path: string, ownedHere?: (path: string) => boolean): boolean {
372
269
  let marker: { pid?: number; host?: string; boot?: string };
373
270
  try {
374
271
  marker = JSON.parse(readFileSync(ownerFile(path), "utf8"));
375
272
  } catch {
376
- return false; // no marker (or unreadable) → nobody claims it
273
+ return false;
377
274
  }
378
275
  const pid = marker.pid;
379
276
  if (!pid || !Number.isFinite(pid) || pid <= 0) return false;
380
- if (marker.host !== hostname()) return true; // another machine's checkout — never ours to reap
381
- if (marker.boot !== bootId()) return false; // pre-reboot pid: reuse is near-certain
382
- // Our OWN pid is not proof: a previous session in this same long-lived process
383
- // (pi `/new`) leaves markers with this pid, and trusting them made those dirs
384
- // unreapable for the process lifetime. Callers pass `isLive` for real knowledge.
277
+ if (marker.host !== hostname()) return true;
278
+ if (marker.boot !== bootId()) return false;
279
+
385
280
  if (pid === process.pid) return ownedHere?.(path) ?? true;
386
281
  try {
387
282
  process.kill(pid, 0);
388
283
  return true;
389
284
  } catch (err) {
390
- return (err as NodeJS.ErrnoException)?.code === "EPERM"; // exists, other user
285
+ return (err as NodeJS.ErrnoException)?.code === "EPERM";
391
286
  }
392
287
  }
393
288
 
394
- /** Stable per-boot id, so a recycled pid from before a reboot can't look alive.
395
- * Prefer the OS's own boot identity — the clock formula (Date.now - uptime)
396
- * breaks on NTP-stepped clocks (CI runners): the step flips the floor and a
397
- * live marker suddenly reads as pre-reboot/dead. The formula stays as the
398
- * last-resort fallback: one floor on the expressed seconds, not two —
399
- * separate floors of walltime and uptime flip by ±1 around integer
400
- * boundaries and would read a live marker as dead on a cross-second read. */
401
289
  function bootId(): string {
402
290
  try {
403
291
  if (process.platform === "linux") return readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
404
292
  if (process.platform === "darwin") {
405
- // kern.boottime = "{ sec = 1756…, usec = … }" — sec alone is stable per boot.
406
293
  const out = execFileSync("sysctl", ["-n", "kern.boottime"], { encoding: "utf8" });
407
294
  return out.match(/sec = (\d+)/)?.[1] ?? out.trim();
408
295
  }
409
- } catch {
410
- /* fall through to the formula */
411
- }
296
+ } catch {}
412
297
  return String(Math.floor((Date.now() - uptime() * 1000) / 1000));
413
298
  }
414
299
 
415
- /**
416
- * Remove worktree dirs that git no longer knows about (partial-crash leftovers).
417
- * Registered worktrees are never touched here — `reapDeadWorktrees` owns those,
418
- * and a live child's checkout must survive.
419
- */
420
300
  export function sweepStale(root: string): void {
421
301
  root = realpathSync(root);
422
302
  const sub = subagentsDir(root);
423
303
  if (!sub || !existsSync(sub)) return;
424
304
  const registered = worktreePaths(root);
425
- // Unknown registration = every dir might be live. Deleting here would be the
426
- // single most destructive thing this module can do; bail instead.
305
+
427
306
  if (!registered) return;
428
- // Empty run dirs are litter — a run dir holds nothing but its task dirs.
307
+
429
308
  for (const runDir of readdirSync(sub, { withFileTypes: true })) {
430
309
  if (!runDir.isDirectory()) continue;
431
310
  const runPath = join(sub, runDir.name);
432
311
  try {
433
312
  if (readdirSync(runPath).length === 0) rmSync(runPath, { recursive: true, force: true });
434
- } catch {
435
- /* skip */
436
- }
313
+ } catch {}
437
314
  }
438
315
  for (const runDir of readDirs(sub)) {
439
316
  for (const taskDir of readDirs(join(sub, runDir))) {
440
317
  const dir = join(sub, runDir, taskDir);
441
- if (registered.some((p) => samePath(p, dir))) continue; // live/registered worktree
442
- if (ownerAlive(dir)) continue; // claimed by a running session
318
+ if (registered.some((p) => samePath(p, dir))) continue;
319
+ if (ownerAlive(dir)) continue;
443
320
  rmSync(dir, { recursive: true, force: true });
444
321
  rmSync(ownerFile(dir), { force: true });
445
322
  }
@@ -447,15 +324,11 @@ export function sweepStale(root: string): void {
447
324
  prune(root);
448
325
  }
449
326
 
450
- /** Registered worktree paths, or undefined when the listing failed (see above). */
451
327
  function worktreePaths(root: string): string[] | undefined {
452
328
  const listing = worktreeListing(root);
453
329
  return listing?.filter((l) => l.startsWith("worktree ")).map((l) => l.slice("worktree ".length));
454
330
  }
455
331
 
456
- /** `worktree list --porcelain -z`; `-z` needs git ≥ 2.36, so fall back to the
457
- * newline form (which C-quotes exotic paths — those simply won't match, and a
458
- * non-match is safe: it only ever means "treat as live"). */
459
332
  function worktreeListing(root: string): string[] | undefined {
460
333
  try {
461
334
  return gitRaw(root, ["worktree", "list", "--porcelain", "-z"]).split("\0").filter(Boolean);
@@ -463,7 +336,7 @@ function worktreeListing(root: string): string[] | undefined {
463
336
  try {
464
337
  return git(root, ["worktree", "list", "--porcelain"]).split("\n").filter(Boolean);
465
338
  } catch {
466
- return undefined; // unknown — callers must bail out
339
+ return undefined;
467
340
  }
468
341
  }
469
342
  }