@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/README.md +5 -6
- package/package.json +1 -1
- package/src/agentfile.ts +4 -41
- package/src/child.ts +2 -17
- package/src/format.ts +11 -58
- package/src/graph.ts +7 -36
- package/src/index.ts +16 -56
- package/src/mailbox.ts +0 -7
- package/src/manager.ts +93 -348
- package/src/peek.ts +13 -69
- package/src/schemas.ts +1 -4
- package/src/types.ts +0 -19
- package/src/worktree.ts +37 -164
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;
|
|
19
|
-
path: string;
|
|
20
|
-
branch: string;
|
|
21
|
-
base: string;
|
|
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
|
-
|
|
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;
|
|
80
|
+
if (!baseRef) return undefined;
|
|
113
81
|
try {
|
|
114
|
-
base = git(root, ["rev-parse", "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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|
|
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;
|
|
262
|
-
if (isCheckedOut(root, branch)) continue;
|
|
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;
|
|
265
|
-
|
|
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;
|
|
232
|
+
if (!registered) return 0;
|
|
322
233
|
let reaped = 0;
|
|
323
234
|
for (const path of registered) {
|
|
324
|
-
if (!isInside(path, sub)) continue;
|
|
325
|
-
if (isLive(path)) continue;
|
|
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;
|
|
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;
|
|
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;
|
|
381
|
-
if (marker.boot !== bootId()) return false;
|
|
382
|
-
|
|
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";
|
|
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
|
-
|
|
426
|
-
// single most destructive thing this module can do; bail instead.
|
|
305
|
+
|
|
427
306
|
if (!registered) return;
|
|
428
|
-
|
|
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;
|
|
442
|
-
if (ownerAlive(dir)) continue;
|
|
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;
|
|
339
|
+
return undefined;
|
|
467
340
|
}
|
|
468
341
|
}
|
|
469
342
|
}
|