@arhen/pi-core-subagent 1.3.30 → 1.3.32
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 +11 -1
- package/package.json +1 -1
- package/src/index.ts +28 -9
- package/src/manager.ts +185 -71
- package/src/worktree.ts +157 -44
package/README.md
CHANGED
|
@@ -147,7 +147,17 @@ On completion the extension commits the child's changes (the child is told not t
|
|
|
147
147
|
git merge --no-ff subagents/<run>/<task>
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
-
|
|
150
|
+
Isolation follows the toolset the child actually receives: explicit `tools: ["bash", "edit", "write"]` earns a worktree even without `write: true`, and an agent file that narrows the child to read-only gets no branch at all.
|
|
151
|
+
|
|
152
|
+
Cleanup, in order of trust:
|
|
153
|
+
|
|
154
|
+
| When | What |
|
|
155
|
+
|---|---|
|
|
156
|
+
| Session start | Registered worktrees can't be live yet — an interrupted child's uncommitted work is committed, the branch kept, the dir dropped. Then merged branches are reaped and dirs git no longer tracks are removed. |
|
|
157
|
+
| After a run | Merged branches + their dirs, never touching a branch a live run owns (checkout state re-checked per branch). |
|
|
158
|
+
| Task failed/canceled | Partial work is committed first, so the branch keeps it; the dir is dropped. |
|
|
159
|
+
|
|
160
|
+
Non-git repos fall back to in-place edits.
|
|
151
161
|
|
|
152
162
|
## Graph mode — `needs`
|
|
153
163
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arhen/pi-core-subagent",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.32",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "pi extension: fast in-process subagents with a dependency-graph scheduler (needs edges gate tasks and carry upstream output into dependent prompts), plus background runs, intercom and agent-to-agent mailbox. Leader defines agents inline.",
|
|
6
6
|
"license": "MIT",
|
package/src/index.ts
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
type SubagentParamsShape,
|
|
31
31
|
} from "./schemas.ts";
|
|
32
32
|
import { type RunDetails, type RunSnapshot, TERMINAL } from "./types.ts";
|
|
33
|
-
import { cleanupMerged, repoRoot, sweepStale } from "./worktree.ts";
|
|
33
|
+
import { cleanupMerged, ownerAlive, reapDeadWorktrees, repoRoot, sweepStale } from "./worktree.ts";
|
|
34
34
|
|
|
35
35
|
export default function (pi: ExtensionAPI) {
|
|
36
36
|
const manager = new SubagentManager(pi);
|
|
@@ -125,8 +125,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
for (const root of roots) {
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
try {
|
|
129
|
+
// A registered subagent worktree is a crash leftover UNLESS another pi
|
|
130
|
+
// session still owns it (pid marker) — commit its work, keep the branch,
|
|
131
|
+
// drop the dir. Then reap merged branches and dirs git no longer tracks.
|
|
132
|
+
reapDeadWorktrees(root, ownerAlive);
|
|
133
|
+
cleanupMerged(root, { skipBranches: manager.liveBranches() });
|
|
134
|
+
sweepStale(root);
|
|
135
|
+
} catch {
|
|
136
|
+
/* recovery is best-effort — never block session start */
|
|
137
|
+
}
|
|
130
138
|
}
|
|
131
139
|
});
|
|
132
140
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
@@ -170,18 +178,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
170
178
|
// until terminal — but surface an ask_parent: the child is waiting on the
|
|
171
179
|
// leader, so break out, reply via reply_subagent, then await again.
|
|
172
180
|
let run = details.run;
|
|
173
|
-
|
|
181
|
+
// Each park gets a FRESH msgs array — accumulate, or every wake but the
|
|
182
|
+
// last is lost (they were consumed by the park, never sent as followUp).
|
|
183
|
+
const intercom: ParkedMsg[] = [];
|
|
174
184
|
while (!TERMINAL.includes(run.status)) {
|
|
175
185
|
const awaited = await manager.awaitRun(details.run.id);
|
|
176
186
|
if (!awaited) break; // run gone (session shutdown) — stop, no busy-spin
|
|
177
187
|
if (awaited.run) run = awaited.run;
|
|
178
|
-
intercom
|
|
179
|
-
if (intercom.some((m) => m.kind === "ask")) break;
|
|
188
|
+
intercom.push(...awaited.intercom);
|
|
189
|
+
if (awaited.intercom.some((m) => m.kind === "ask")) break;
|
|
180
190
|
}
|
|
181
191
|
const asked = intercom.find((m) => m.kind === "ask");
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
192
|
+
const heard = intercom.filter((m) => m.kind !== "ask");
|
|
193
|
+
const text = [
|
|
194
|
+
makeSummary(run),
|
|
195
|
+
heard.length > 0
|
|
196
|
+
? `\nIntercom while waiting:\n${heard.map((m) => `- [${m.kind}] ${m.agent} (${m.taskId}): ${truncateText(m.text)}`).join("\n")}`
|
|
197
|
+
: "",
|
|
198
|
+
asked
|
|
199
|
+
? `\nA child is waiting for your answer (${asked.agent}, ${asked.taskId}): ${asked.text}\nReply with reply_subagent(runId: "${run.id}", taskId: "${asked.taskId}", message: ...), then await_subagent again for the result.`
|
|
200
|
+
: "",
|
|
201
|
+
]
|
|
202
|
+
.filter(Boolean)
|
|
203
|
+
.join("\n");
|
|
185
204
|
return { content: [{ type: "text", text }], details: { run } };
|
|
186
205
|
}
|
|
187
206
|
return {
|
package/src/manager.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** SubagentManager: run lifecycle, child sessions, intercom, persistence, widget plumbing. */
|
|
2
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { writeFile } from "node:fs/promises";
|
|
4
4
|
import { join, relative } from "node:path";
|
|
5
5
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
@@ -45,13 +45,12 @@ import {
|
|
|
45
45
|
} from "./types.ts";
|
|
46
46
|
import {
|
|
47
47
|
branchDiff,
|
|
48
|
+
claimWorktree,
|
|
48
49
|
cleanupMerged,
|
|
49
50
|
commitWorktree,
|
|
50
51
|
createWorktree,
|
|
51
|
-
removeByBranch,
|
|
52
52
|
removeWorktree,
|
|
53
53
|
repoRoot,
|
|
54
|
-
sweepStale,
|
|
55
54
|
type Worktree,
|
|
56
55
|
} from "./worktree.ts";
|
|
57
56
|
|
|
@@ -60,8 +59,12 @@ export const MAX_CONCURRENCY = 8;
|
|
|
60
59
|
/** No default wall-clock cap: a subagent runs until its task is done, it stalls, or the user aborts. */
|
|
61
60
|
const DEFAULT_RUNTIME_MS = 0;
|
|
62
61
|
const DEFAULT_STALL_MS = 180_000; // 3 min: long model thinking streams emit no events, but they're not stalled.
|
|
62
|
+
/** Cap on a child's wait for reply_subagent — an ignored question must not pin the run open forever. */
|
|
63
|
+
const PARENT_REPLY_TIMEOUT_MS = 600_000; // 10 min
|
|
63
64
|
const READONLY_TOOLS = ["read", "grep", "find", "ls"];
|
|
64
65
|
const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write"];
|
|
66
|
+
/** Tools that can mutate the tree — their presence is what earns a worktree. */
|
|
67
|
+
const WRITE_CAPABLE = ["bash", "edit", "write"];
|
|
65
68
|
/** Task ids become git refs + filesystem paths. */
|
|
66
69
|
const SAFE_TASK_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
67
70
|
const WIDGET_THROTTLE_MS = 150;
|
|
@@ -86,6 +89,14 @@ function aggregateUsage(tasks: TaskSnapshot[]): UsageStats {
|
|
|
86
89
|
}
|
|
87
90
|
return total;
|
|
88
91
|
}
|
|
92
|
+
/** realpath when possible; the raw path otherwise (cwd may not exist yet). */
|
|
93
|
+
function safeRealPath(path: string): string {
|
|
94
|
+
try {
|
|
95
|
+
return realpathSync(path);
|
|
96
|
+
} catch {
|
|
97
|
+
return path;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
89
100
|
function getParentSessionFile(ctx: ExtensionContext): string | undefined {
|
|
90
101
|
try {
|
|
91
102
|
return ctx.sessionManager.getSessionFile?.();
|
|
@@ -213,13 +224,19 @@ export interface ParkedMsg {
|
|
|
213
224
|
|
|
214
225
|
export class SubagentManager {
|
|
215
226
|
private runs = new Map<string, RunSnapshot>();
|
|
216
|
-
|
|
227
|
+
/** Runs that are still settleable (presence = not yet settled). */
|
|
228
|
+
private settlers = new Map<string, true>();
|
|
229
|
+
/** Everyone parked on a run — a set, so re-parking can't build a closure chain. */
|
|
230
|
+
private settleWaiters = new Map<string, Set<(run: RunSnapshot) => void>>();
|
|
217
231
|
private pendingReplies = new Map<string, PendingReply>();
|
|
218
232
|
private liveChildren = new Map<
|
|
219
233
|
string,
|
|
220
234
|
{ abort: () => void; dispose: () => void; touchWatchdog: () => void; steer: (message: string) => void }
|
|
221
235
|
>();
|
|
222
236
|
private mailboxes: Mailbox = createMailbox();
|
|
237
|
+
/** Live worktrees by `${runId}:${taskId}` — lets cancel drop dirs and keeps
|
|
238
|
+
* cleanup from touching a branch that a running child owns. */
|
|
239
|
+
private liveWorktrees = new Map<string, Worktree>();
|
|
223
240
|
private runControllers = new Map<string, AbortController>();
|
|
224
241
|
private widgetTimers = new Map<string, ReturnType<typeof setTimeout>>(); // per-run stream throttle
|
|
225
242
|
private widgetRuns: RunSnapshot[] = [];
|
|
@@ -285,8 +302,12 @@ export class SubagentManager {
|
|
|
285
302
|
child.dispose();
|
|
286
303
|
}
|
|
287
304
|
this.liveChildren.clear();
|
|
305
|
+
// Ownership markers stay on disk; the next session reaps those dirs (commit,
|
|
306
|
+
// keep branch, drop dir) once this pid is gone.
|
|
307
|
+
this.liveWorktrees.clear();
|
|
288
308
|
this.runs.clear();
|
|
289
309
|
this.settlers.clear();
|
|
310
|
+
this.settleWaiters.clear();
|
|
290
311
|
this.pendingReplies.clear();
|
|
291
312
|
this.runControllers.clear();
|
|
292
313
|
this.mailboxes = createMailbox();
|
|
@@ -497,20 +518,24 @@ export class SubagentManager {
|
|
|
497
518
|
private makeChildHandlers(run: RunSnapshot, task: TaskSnapshot, ctx: ExtensionContext): ChildHandlers {
|
|
498
519
|
return {
|
|
499
520
|
onAskParent: async (_taskId, question) => {
|
|
521
|
+
const key = `${run.id}:${task.id}`;
|
|
500
522
|
this.updateTask(run, task, { status: "awaiting_parent" }, ctx);
|
|
501
|
-
this.liveChildren.get(
|
|
502
|
-
// While the leader is parked in await_subagent
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
523
|
+
this.liveChildren.get(key)?.touchWatchdog();
|
|
524
|
+
// While the leader is parked in await_subagent the question rides the wait
|
|
525
|
+
// (no steering queue, no turn boundary); otherwise it goes out as a notice.
|
|
526
|
+
// Either way the pending reply entry must exist, or reply_subagent has
|
|
527
|
+
// nowhere to land and the child waits on an answer that never comes.
|
|
528
|
+
if (!this.collectParked(run.id, { kind: "ask", taskId: task.id, agent: task.agent, text: question })) {
|
|
529
|
+
this.notifyParent(run, "asked", { taskId: task.id, question });
|
|
506
530
|
}
|
|
507
|
-
|
|
508
|
-
//
|
|
509
|
-
|
|
531
|
+
// A waiting child is not stalled — keep the watchdog fed until the reply.
|
|
532
|
+
// But the wait is BOUNDED: an unanswered question would otherwise keep the
|
|
533
|
+
// run non-terminal forever (widget never clears, run never settles).
|
|
534
|
+
const keepAlive = setInterval(() => this.liveChildren.get(key)?.touchWatchdog(), 30_000);
|
|
510
535
|
try {
|
|
511
|
-
const reply = await this.awaitParentReply(run.id, task.id);
|
|
536
|
+
const reply = await this.awaitParentReply(run.id, task.id, PARENT_REPLY_TIMEOUT_MS);
|
|
512
537
|
this.updateTask(run, task, { status: "running" }, ctx);
|
|
513
|
-
this.liveChildren.get(
|
|
538
|
+
this.liveChildren.get(key)?.touchWatchdog();
|
|
514
539
|
return reply;
|
|
515
540
|
} finally {
|
|
516
541
|
clearInterval(keepAlive);
|
|
@@ -518,13 +543,13 @@ export class SubagentManager {
|
|
|
518
543
|
},
|
|
519
544
|
onNotifyParent: (_taskId, message, level) => {
|
|
520
545
|
this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level, message });
|
|
546
|
+
// Parked leader gets it through the wait; otherwise queue it. `awaited` must
|
|
547
|
+
// NOT gate this — between two parks the leader is awaited but listening.
|
|
521
548
|
if (this.collectParked(run.id, { kind: "notify", taskId: task.id, agent: task.agent, text: message })) return;
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
/* parent mid-stream */
|
|
527
|
-
}
|
|
549
|
+
try {
|
|
550
|
+
this.pi.sendUserMessage(`[Subagent ${task.agent}] ${message}`, { deliverAs: "followUp" });
|
|
551
|
+
} catch {
|
|
552
|
+
/* parent mid-stream */
|
|
528
553
|
}
|
|
529
554
|
},
|
|
530
555
|
onSendMessage: (_taskId, to, text) => {
|
|
@@ -537,12 +562,10 @@ export class SubagentManager {
|
|
|
537
562
|
message: text,
|
|
538
563
|
});
|
|
539
564
|
if (this.collectParked(run.id, { kind: "notify", taskId: task.id, agent: task.agent, text })) return true;
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
/* parent mid-stream */
|
|
545
|
-
}
|
|
565
|
+
try {
|
|
566
|
+
this.pi.sendUserMessage(`[Subagent ${task.agent}] ${text}`, { deliverAs: "followUp" });
|
|
567
|
+
} catch {
|
|
568
|
+
/* parent mid-stream */
|
|
546
569
|
}
|
|
547
570
|
return true;
|
|
548
571
|
}
|
|
@@ -552,9 +575,24 @@ export class SubagentManager {
|
|
|
552
575
|
onPollMailbox: (taskId) => this.mailboxes.poll(`${run.id}:${taskId}`),
|
|
553
576
|
};
|
|
554
577
|
}
|
|
555
|
-
private awaitParentReply(runId: string, taskId: string): Promise<string> {
|
|
578
|
+
private awaitParentReply(runId: string, taskId: string, timeoutMs = 0): Promise<string> {
|
|
579
|
+
const key = `${runId}:${taskId}`;
|
|
556
580
|
return new Promise<string>((resolve) => {
|
|
557
|
-
|
|
581
|
+
const timer =
|
|
582
|
+
timeoutMs > 0
|
|
583
|
+
? setTimeout(() => {
|
|
584
|
+
this.pendingReplies.delete(key);
|
|
585
|
+
resolve(
|
|
586
|
+
"The parent did not answer in time. Proceed autonomously with your best judgment and state the assumption you made in your final answer.",
|
|
587
|
+
);
|
|
588
|
+
}, timeoutMs)
|
|
589
|
+
: undefined;
|
|
590
|
+
this.pendingReplies.set(key, {
|
|
591
|
+
resolve: (message) => {
|
|
592
|
+
if (timer) clearTimeout(timer);
|
|
593
|
+
resolve(message);
|
|
594
|
+
},
|
|
595
|
+
});
|
|
558
596
|
});
|
|
559
597
|
}
|
|
560
598
|
deliverReply(runId: string, taskId: string, message: string): boolean {
|
|
@@ -657,30 +695,15 @@ export class SubagentManager {
|
|
|
657
695
|
const fileTools = file?.tools?.filter((t) => allowedTools.includes(t));
|
|
658
696
|
const baseTools = fileTools?.length ? fileTools : (input.tools ?? allowedTools);
|
|
659
697
|
const tools = [...baseTools, ...(run.allowIntercom ? CHILD_TALK_TOOLS : [])];
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
|
|
698
|
+
// Isolation follows the DELIVERED toolset, never the raw request: explicit
|
|
699
|
+
// tools: [bash] without write:true still gets a worktree, and a file that
|
|
700
|
+
// narrowed the child to read-only never gets the commit/merge ceremony.
|
|
701
|
+
const canWrite = baseTools.some((t) => WRITE_CAPABLE.includes(t));
|
|
663
702
|
|
|
664
703
|
// Write agents run in an isolated git worktree (branch subagents/<run>/<task>);
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
|
|
668
|
-
if (canWrite) {
|
|
669
|
-
try {
|
|
670
|
-
wt = createWorktree(task.cwd, run.id, task.id);
|
|
671
|
-
} catch {
|
|
672
|
-
wt = undefined; // git failure → in-place
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
// Map a per-task cwd subpath into the worktree so relative paths stay correct.
|
|
676
|
-
let childCwd = wt?.path ?? task.cwd;
|
|
677
|
-
if (wt) {
|
|
678
|
-
const rel = relative(wt.root, task.cwd);
|
|
679
|
-
if (rel && !rel.startsWith("..") && rel !== ".") childCwd = join(wt.path, rel);
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
// Model + thinking resolve against the pi model registry; a bad request
|
|
683
|
-
// fails the TASK with a helpful message, not the whole run.
|
|
704
|
+
// Model + thinking resolve against the pi model registry BEFORE any worktree
|
|
705
|
+
// exists — a bad request fails the TASK with a helpful message and can't leak a
|
|
706
|
+
// checkout past this early return.
|
|
684
707
|
let model: Model<Api> | undefined;
|
|
685
708
|
try {
|
|
686
709
|
model = resolveChildModel(ctx, file?.model ?? input.model);
|
|
@@ -700,6 +723,37 @@ export class SubagentManager {
|
|
|
700
723
|
return;
|
|
701
724
|
}
|
|
702
725
|
|
|
726
|
+
// Write agents run in an isolated git worktree (branch subagents/<run>/<task>);
|
|
727
|
+
// non-git repos fall back to in-place. Created BEFORE session start so the
|
|
728
|
+
// child's cwd + AGENTS.md context chain are the worktree's.
|
|
729
|
+
let wt: Worktree | undefined;
|
|
730
|
+
if (canWrite) {
|
|
731
|
+
try {
|
|
732
|
+
wt = createWorktree(task.cwd, run.id, task.id);
|
|
733
|
+
} catch {
|
|
734
|
+
wt = undefined; // git failure → in-place
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
// Map a per-task cwd subpath into the worktree so relative paths stay correct.
|
|
738
|
+
// Both sides go through realpath — a symlinked root would otherwise look
|
|
739
|
+
// "outside" the repo. If the mapping can't be trusted, drop the worktree AND
|
|
740
|
+
// reset the cwd (never point the child at a dir that was just removed).
|
|
741
|
+
let childCwd = wt?.path ?? task.cwd;
|
|
742
|
+
if (wt) {
|
|
743
|
+
const rel = relative(safeRealPath(wt.root), safeRealPath(task.cwd));
|
|
744
|
+
if (rel.startsWith("..")) {
|
|
745
|
+
removeWorktree(wt);
|
|
746
|
+
wt = undefined;
|
|
747
|
+
childCwd = task.cwd;
|
|
748
|
+
} else if (rel && rel !== ".") {
|
|
749
|
+
childCwd = join(wt.path, rel);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
if (wt) {
|
|
753
|
+
claimWorktree(wt); // pid marker: another pi session must not reap this
|
|
754
|
+
this.liveWorktrees.set(`${run.id}:${task.id}`, wt);
|
|
755
|
+
}
|
|
756
|
+
|
|
703
757
|
this.updateTask(
|
|
704
758
|
run,
|
|
705
759
|
task,
|
|
@@ -717,6 +771,9 @@ export class SubagentManager {
|
|
|
717
771
|
onUpdate,
|
|
718
772
|
);
|
|
719
773
|
|
|
774
|
+
// Set once the dir must outlive this call: committed work awaiting the
|
|
775
|
+
// leader's merge, or a commit failure whose work exists ONLY in the dir.
|
|
776
|
+
let keepWorktreeDir = false;
|
|
720
777
|
let child: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
|
|
721
778
|
let unsubscribe: (() => void) | undefined;
|
|
722
779
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -836,6 +893,7 @@ export class SubagentManager {
|
|
|
836
893
|
// its work: the error is reported, the status stays completed.
|
|
837
894
|
try {
|
|
838
895
|
commitWorktree(wt, `subagent ${task.agent}: ${truncateText(input.task, 60)}`);
|
|
896
|
+
keepWorktreeDir = true; // committed — dir stays until the leader merges
|
|
839
897
|
const { stat, files } = branchDiff(wt);
|
|
840
898
|
this.updateTask(
|
|
841
899
|
run,
|
|
@@ -845,12 +903,15 @@ export class SubagentManager {
|
|
|
845
903
|
onUpdate,
|
|
846
904
|
);
|
|
847
905
|
} catch (commitErr) {
|
|
906
|
+
// Never drop a checkout whose work isn't on the branch — it would be
|
|
907
|
+
// unreachable once the base-tip branch is reaped as "merged".
|
|
908
|
+
keepWorktreeDir = true;
|
|
848
909
|
this.updateTask(
|
|
849
910
|
run,
|
|
850
911
|
task,
|
|
851
912
|
{
|
|
852
913
|
branch: wt.branch,
|
|
853
|
-
error: `Worktree commit failed (changes remain in ${wt.path}): ${commitErr instanceof Error ? commitErr.message : String(commitErr)}`,
|
|
914
|
+
error: `Worktree commit failed (uncommitted changes remain in ${wt.path}): ${commitErr instanceof Error ? commitErr.message : String(commitErr)}`,
|
|
854
915
|
},
|
|
855
916
|
ctx,
|
|
856
917
|
onUpdate,
|
|
@@ -890,17 +951,23 @@ export class SubagentManager {
|
|
|
890
951
|
watchdog.dispose();
|
|
891
952
|
if (timeout) clearTimeout(timeout);
|
|
892
953
|
child?.dispose();
|
|
893
|
-
// Failed/aborted:
|
|
894
|
-
//
|
|
954
|
+
// Failed/aborted: let the aborted child's last writes land (its tools may
|
|
955
|
+
// still be unwinding), commit whatever partial work exists so the branch
|
|
956
|
+
// really keeps it, then drop the checkout dir. A commit FAILURE keeps the
|
|
957
|
+
// dir — dropping it would make the work unreachable.
|
|
895
958
|
if (wt && task.status !== "completed") {
|
|
959
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
896
960
|
try {
|
|
897
961
|
commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
|
|
898
962
|
} catch {
|
|
899
|
-
|
|
963
|
+
keepWorktreeDir = true;
|
|
900
964
|
}
|
|
901
965
|
this.updateTask(run, task, { branch: wt.branch }, ctx, onUpdate);
|
|
902
|
-
removeWorktree(wt);
|
|
903
966
|
}
|
|
967
|
+
if (wt && !keepWorktreeDir) removeWorktree(wt);
|
|
968
|
+
// Released only after the dir is gone: while it exists, the branch must stay
|
|
969
|
+
// in liveBranches() so cleanup can't reap it.
|
|
970
|
+
this.liveWorktrees.delete(key);
|
|
904
971
|
}
|
|
905
972
|
}
|
|
906
973
|
|
|
@@ -988,7 +1055,7 @@ export class SubagentManager {
|
|
|
988
1055
|
});
|
|
989
1056
|
this.turnActivity = true;
|
|
990
1057
|
this.runs.set(run.id, run);
|
|
991
|
-
this.settlers.set(run.id,
|
|
1058
|
+
this.settlers.set(run.id, true);
|
|
992
1059
|
this.runControllers.set(run.id, new AbortController());
|
|
993
1060
|
for (const task of run.tasks) this.mailboxes.open(`${run.id}:${task.id}`);
|
|
994
1061
|
this.emit("subagent:run-created", { run: cloneRun(run) });
|
|
@@ -1026,7 +1093,18 @@ export class SubagentManager {
|
|
|
1026
1093
|
// The scheduler passes the index into the FILTERED list — never use it
|
|
1027
1094
|
// against the unfiltered inputs. Look the input up by task id instead.
|
|
1028
1095
|
const input = inputById.get(task.id);
|
|
1029
|
-
if (!input)
|
|
1096
|
+
if (!input) {
|
|
1097
|
+
// Impossible unless ids drift from inputs — fail loudly instead of
|
|
1098
|
+
// leaving the task queued forever (hasActiveRun would never clear).
|
|
1099
|
+
this.updateTask(
|
|
1100
|
+
run,
|
|
1101
|
+
task,
|
|
1102
|
+
{ status: "failed", error: `No input for task ${task.id}`, endedAt: Date.now() },
|
|
1103
|
+
ctx,
|
|
1104
|
+
onUpdate,
|
|
1105
|
+
);
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1030
1108
|
await this.runChild(
|
|
1031
1109
|
run,
|
|
1032
1110
|
task,
|
|
@@ -1082,14 +1160,26 @@ export class SubagentManager {
|
|
|
1082
1160
|
for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
|
|
1083
1161
|
this.persist(ctx);
|
|
1084
1162
|
// Branches merged by the leader since the run ended: drop worktree dir + branch.
|
|
1085
|
-
for
|
|
1086
|
-
|
|
1163
|
+
// Once per repo, never for a branch another live run owns, never fatal — a
|
|
1164
|
+
// throw here would re-settle an already-finished run as failed.
|
|
1165
|
+
try {
|
|
1166
|
+
const roots = new Set<string>();
|
|
1167
|
+
for (const task of run.tasks) {
|
|
1168
|
+
if (!task.branch) continue;
|
|
1087
1169
|
const root = repoRoot(task.cwd);
|
|
1088
|
-
if (root)
|
|
1170
|
+
if (root) roots.add(root);
|
|
1089
1171
|
}
|
|
1172
|
+
for (const root of roots) cleanupMerged(root, { skipBranches: this.liveBranches() });
|
|
1173
|
+
} catch {
|
|
1174
|
+
/* cleanup is best-effort; the run outcome must stand */
|
|
1090
1175
|
}
|
|
1091
1176
|
}
|
|
1092
1177
|
|
|
1178
|
+
/** Branches owned by worktrees of still-running children. */
|
|
1179
|
+
liveBranches(): Set<string> {
|
|
1180
|
+
return new Set(Array.from(this.liveWorktrees.values(), (wt) => wt.branch));
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1093
1183
|
/** Spawn a run that keeps executing after this call returns. Every run is background. */
|
|
1094
1184
|
startInBackground(params: SubagentParamsShape, ctx: ExtensionContext): RunDetails {
|
|
1095
1185
|
const { run, inputs } = this.createRun(params, ctx);
|
|
@@ -1140,6 +1230,8 @@ export class SubagentManager {
|
|
|
1140
1230
|
task.status = "aborted";
|
|
1141
1231
|
task.error = task.error || "Canceled from peek";
|
|
1142
1232
|
task.endedAt = Date.now();
|
|
1233
|
+
this.pendingReplies.get(`${runId}:${taskId}`)?.resolve("(task canceled by the parent — stop work now)");
|
|
1234
|
+
this.pendingReplies.delete(`${runId}:${taskId}`);
|
|
1143
1235
|
this.liveChildren.get(`${runId}:${taskId}`)?.abort();
|
|
1144
1236
|
this.mailboxes.close(`${runId}:${taskId}`);
|
|
1145
1237
|
if (ctx) this.flushWidget(run, ctx);
|
|
@@ -1153,6 +1245,14 @@ export class SubagentManager {
|
|
|
1153
1245
|
if (TERMINAL.includes(run.status)) return { aborted: 0 }; // never corrupt a finished run
|
|
1154
1246
|
let aborted = 0;
|
|
1155
1247
|
this.runControllers.get(runId)?.abort();
|
|
1248
|
+
// Release children parked in ask_parent first — an unresolved wait would keep
|
|
1249
|
+
// the child alive past the abort.
|
|
1250
|
+
for (const [key, pending] of this.pendingReplies) {
|
|
1251
|
+
if (key.startsWith(`${runId}:`)) {
|
|
1252
|
+
this.pendingReplies.delete(key);
|
|
1253
|
+
pending.resolve("(run canceled by the parent — stop work and return what you have)");
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1156
1256
|
for (const [key, child] of this.liveChildren) {
|
|
1157
1257
|
if (key.startsWith(`${runId}:`)) {
|
|
1158
1258
|
child.abort();
|
|
@@ -1164,7 +1264,10 @@ export class SubagentManager {
|
|
|
1164
1264
|
task.error = task.error || "Canceled by subagent_cancel"; // never overwrite a real error
|
|
1165
1265
|
task.endedAt = Date.now();
|
|
1166
1266
|
aborted += 1;
|
|
1167
|
-
|
|
1267
|
+
// The branch is recorded here so the leader can still merge partial work;
|
|
1268
|
+
// runChild's finally commits + drops the dir (it owns the live worktree).
|
|
1269
|
+
const wt = this.liveWorktrees.get(`${runId}:${task.id}`);
|
|
1270
|
+
if (wt) task.branch = wt.branch;
|
|
1168
1271
|
}
|
|
1169
1272
|
run.status = "aborted";
|
|
1170
1273
|
run.endedAt = Date.now();
|
|
@@ -1175,12 +1278,15 @@ export class SubagentManager {
|
|
|
1175
1278
|
return { aborted };
|
|
1176
1279
|
}
|
|
1177
1280
|
|
|
1178
|
-
/** Settle-and-delete:
|
|
1281
|
+
/** Settle-and-delete: every awaiter resolves once, then the set is dropped. */
|
|
1179
1282
|
private settleRun(runId: string, run: RunSnapshot): void {
|
|
1180
|
-
|
|
1181
|
-
if (!s) return;
|
|
1283
|
+
if (!this.settlers.has(runId)) return;
|
|
1182
1284
|
this.settlers.delete(runId);
|
|
1183
|
-
|
|
1285
|
+
const waiters = this.settleWaiters.get(runId);
|
|
1286
|
+
this.settleWaiters.delete(runId);
|
|
1287
|
+
if (!waiters) return;
|
|
1288
|
+
const snapshot = cloneRun(run);
|
|
1289
|
+
for (const waiter of waiters) waiter(snapshot);
|
|
1184
1290
|
}
|
|
1185
1291
|
|
|
1186
1292
|
/** Child→leader messages collected while the parent is parked in await_subagent. */
|
|
@@ -1210,16 +1316,24 @@ export class SubagentManager {
|
|
|
1210
1316
|
}
|
|
1211
1317
|
const msgs: ParkedMsg[] = [];
|
|
1212
1318
|
const settled = new Promise<RunSnapshot | undefined>((resolve) => {
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1319
|
+
// Waiters are a SET, not a chain: the autoAwait loop re-parks on every
|
|
1320
|
+
// child message, and wrapping the previous settler each time grew an
|
|
1321
|
+
// unbounded closure chain (each holding a snapshot clone).
|
|
1322
|
+
const waiter = (r: RunSnapshot) => {
|
|
1323
|
+
this.settleWaiters.get(runId)?.delete(waiter);
|
|
1216
1324
|
resolve(r);
|
|
1217
|
-
}
|
|
1325
|
+
};
|
|
1326
|
+
let waiters = this.settleWaiters.get(runId);
|
|
1327
|
+
if (!waiters) {
|
|
1328
|
+
waiters = new Set();
|
|
1329
|
+
this.settleWaiters.set(runId, waiters);
|
|
1330
|
+
}
|
|
1331
|
+
waiters.add(waiter);
|
|
1218
1332
|
// A child→leader message while parked wakes the wait: the leader gets it
|
|
1219
1333
|
// IN the await result, no steering queue, no turn boundary needed.
|
|
1220
|
-
this.parked.set(runId, { msgs, wake: () =>
|
|
1334
|
+
this.parked.set(runId, { msgs, wake: () => waiter(cloneRun(run)) });
|
|
1221
1335
|
});
|
|
1222
|
-
if (timeoutMs) {
|
|
1336
|
+
if (timeoutMs !== undefined && timeoutMs > 0) {
|
|
1223
1337
|
return Promise.race([
|
|
1224
1338
|
settled.then((r) => {
|
|
1225
1339
|
finish();
|
package/src/worktree.ts
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
* Worktrees live inside `<repo>/.git/subagents/<runId>/<taskId>` so the child's
|
|
3
3
|
* ancestor walk still finds the project AGENTS.md chain. node_modules is
|
|
4
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
|
-
*
|
|
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). */
|
|
8
11
|
|
|
9
12
|
import { execFileSync } from "node:child_process";
|
|
10
|
-
import { existsSync, readdirSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
|
13
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
11
14
|
import { join } from "node:path";
|
|
12
15
|
|
|
13
16
|
export interface Worktree {
|
|
@@ -17,6 +20,8 @@ export interface Worktree {
|
|
|
17
20
|
base: string; // SHA the branch was created from
|
|
18
21
|
}
|
|
19
22
|
|
|
23
|
+
const BRANCH_PREFIX = "subagents/";
|
|
24
|
+
|
|
20
25
|
function git(root: string, args: string[]): string {
|
|
21
26
|
return execFileSync("git", ["-C", root, ...args], { encoding: "utf8" }).trim();
|
|
22
27
|
}
|
|
@@ -35,6 +40,16 @@ function gitOk(root: string, args: string[]): boolean {
|
|
|
35
40
|
}
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
/** Compare paths through realpath so /var vs /private/var can't diverge. */
|
|
44
|
+
function samePath(a: string, b: string): boolean {
|
|
45
|
+
if (a === b) return true;
|
|
46
|
+
try {
|
|
47
|
+
return realpathSync(a) === realpathSync(b);
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
38
53
|
/** Repo root for cwd, or undefined when not a git repo (or cwd doesn't exist). */
|
|
39
54
|
export function repoRoot(cwd: string): string | undefined {
|
|
40
55
|
if (!existsSync(cwd)) return undefined;
|
|
@@ -49,14 +64,20 @@ export function repoRoot(cwd: string): string | undefined {
|
|
|
49
64
|
export function createWorktree(cwd: string, runId: string, taskId: string): Worktree | undefined {
|
|
50
65
|
const root = repoRoot(cwd);
|
|
51
66
|
if (!root) return undefined;
|
|
52
|
-
|
|
53
|
-
|
|
67
|
+
// --git-common-dir, not "<root>/.git": inside a linked worktree or a submodule
|
|
68
|
+
// `.git` is a FILE, and joining it would make `worktree add` fail (silently
|
|
69
|
+
// dropping isolation).
|
|
70
|
+
let container: string;
|
|
54
71
|
let base: string;
|
|
55
72
|
try {
|
|
73
|
+
const common = git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
74
|
+
container = join(common, "subagents");
|
|
56
75
|
base = git(root, ["rev-parse", "HEAD"]); // SHA — detached HEAD stays correct
|
|
57
76
|
} catch {
|
|
58
77
|
return undefined; // broken repo — fall back to in-place
|
|
59
78
|
}
|
|
79
|
+
const path = join(container, runId, taskId);
|
|
80
|
+
const branch = `${BRANCH_PREFIX}${runId}/${taskId}`;
|
|
60
81
|
git(root, ["worktree", "add", "-b", branch, path, "HEAD"]);
|
|
61
82
|
// Deps follow the child into the worktree; anything else the task needs is
|
|
62
83
|
// project content already checked out there.
|
|
@@ -71,11 +92,23 @@ export function createWorktree(cwd: string, runId: string, taskId: string): Work
|
|
|
71
92
|
return { root, path, branch, base };
|
|
72
93
|
}
|
|
73
94
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Commit the child's changes. Stages first, then commits only when something is
|
|
97
|
+
* actually staged — an untracked node_modules symlink must not fake "dirty" and
|
|
98
|
+
* turn into a failed empty commit. Returns "committed" | "empty"; a real git
|
|
99
|
+
* failure THROWS, and callers must not delete the checkout in that case (the
|
|
100
|
+
* work would become unreachable once the base-tip branch is reaped as merged).
|
|
101
|
+
*/
|
|
102
|
+
export function commitWorktree(wt: Worktree, message: string): "committed" | "empty" {
|
|
103
|
+
return commitIn(wt.path, message);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function commitIn(dir: string, message: string): "committed" | "empty" {
|
|
107
|
+
// Exclude the root dep symlink and any nested node_modules the child created.
|
|
108
|
+
gitIn(dir, ["add", "-A", "--", ".", ":(exclude)node_modules", ":(exclude,glob)**/node_modules/**"]);
|
|
109
|
+
if (gitIn(dir, ["diff", "--cached", "--name-only"]).length === 0) return "empty";
|
|
110
|
+
gitIn(dir, ["commit", "-m", message, "--no-verify"]);
|
|
111
|
+
return "committed";
|
|
79
112
|
}
|
|
80
113
|
|
|
81
114
|
/** Diffstat + changed files of the branch vs its base SHA. */
|
|
@@ -89,23 +122,32 @@ export function branchDiff(wt: Worktree): { stat: string; files: string[] } {
|
|
|
89
122
|
|
|
90
123
|
/** Remove the worktree dir. The branch is KEPT (the work survives for merging). */
|
|
91
124
|
export function removeWorktree(wt: Worktree): void {
|
|
92
|
-
|
|
93
|
-
git(wt.root, ["worktree", "remove", "--force", wt.path]);
|
|
94
|
-
} catch {
|
|
95
|
-
/* already gone */
|
|
96
|
-
}
|
|
97
|
-
prune(wt.root);
|
|
125
|
+
dropDir(wt.root, wt.path);
|
|
98
126
|
}
|
|
99
127
|
|
|
100
128
|
/** Remove a worktree dir by branch name (cancel paths that didn't keep a Worktree). */
|
|
101
129
|
export function removeByBranch(cwd: string, branch: string): void {
|
|
130
|
+
if (!branch.startsWith(BRANCH_PREFIX)) return;
|
|
102
131
|
const root = repoRoot(cwd);
|
|
103
132
|
if (!root) return;
|
|
104
|
-
const
|
|
133
|
+
const container = subagentsDir(root);
|
|
134
|
+
if (container) dropDir(root, join(container, branch.slice(BRANCH_PREFIX.length)));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `<git-common-dir>/subagents` — where our worktrees live for this repo. */
|
|
138
|
+
function subagentsDir(root: string): string | undefined {
|
|
139
|
+
try {
|
|
140
|
+
return join(git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]), "subagents");
|
|
141
|
+
} catch {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function dropDir(root: string, path: string): void {
|
|
105
147
|
try {
|
|
106
148
|
git(root, ["worktree", "remove", "--force", path]);
|
|
107
149
|
} catch {
|
|
108
|
-
|
|
150
|
+
if (existsSync(path)) rmSync(path, { recursive: true, force: true });
|
|
109
151
|
}
|
|
110
152
|
prune(root);
|
|
111
153
|
}
|
|
@@ -119,21 +161,32 @@ function prune(root: string): void {
|
|
|
119
161
|
}
|
|
120
162
|
}
|
|
121
163
|
|
|
164
|
+
/** Is this branch checked out in some worktree right now? Checked fresh, per call. */
|
|
165
|
+
function isCheckedOut(root: string, branch: string): boolean {
|
|
166
|
+
return worktreeBranches(root).includes(branch);
|
|
167
|
+
}
|
|
168
|
+
|
|
122
169
|
/**
|
|
123
170
|
* Delete branch + worktree for branches already merged into `target`.
|
|
124
|
-
* SAFETY:
|
|
125
|
-
*
|
|
171
|
+
* SAFETY: a branch checked out in a LIVE worktree is skipped — a fresh branch's
|
|
172
|
+
* tip equals its base until the child commits, so it looks "merged". The
|
|
173
|
+
* registration is re-checked immediately before each removal (a concurrent run
|
|
174
|
+
* may have created its worktree after the first listing).
|
|
126
175
|
*/
|
|
127
|
-
export function cleanupMerged(root: string, target =
|
|
176
|
+
export function cleanupMerged(root: string, opts: { skipBranches?: Set<string>; target?: string } = {}): number {
|
|
128
177
|
root = realpathSync(root);
|
|
178
|
+
const target = opts.target ?? "HEAD";
|
|
129
179
|
const merged = git(root, ["branch", "--merged", target])
|
|
130
180
|
.split("\n")
|
|
131
181
|
.map((b) => b.trim().replace(/^[+*]\s*/, ""));
|
|
132
|
-
const live = new Set(worktreeBranches(root));
|
|
133
182
|
let cleaned = 0;
|
|
183
|
+
const container = subagentsDir(root);
|
|
134
184
|
for (const branch of merged) {
|
|
135
|
-
if (!branch.startsWith(
|
|
136
|
-
|
|
185
|
+
if (!branch.startsWith(BRANCH_PREFIX) || !container) continue;
|
|
186
|
+
if (opts.skipBranches?.has(branch)) continue; // owned by a live run
|
|
187
|
+
if (isCheckedOut(root, branch)) continue; // fresh re-check, not a stale snapshot
|
|
188
|
+
const path = join(container, branch.slice(BRANCH_PREFIX.length));
|
|
189
|
+
if (existsSync(path) && ownerAlive(path)) continue; // another session's live checkout
|
|
137
190
|
if (existsSync(path)) {
|
|
138
191
|
try {
|
|
139
192
|
git(root, ["worktree", "remove", "--force", path]);
|
|
@@ -150,51 +203,111 @@ export function cleanupMerged(root: string, target = "HEAD"): number {
|
|
|
150
203
|
/** Branch names currently checked out in any worktree (incl. the main one). */
|
|
151
204
|
function worktreeBranches(root: string): string[] {
|
|
152
205
|
try {
|
|
153
|
-
return git(root, ["worktree", "list", "--porcelain"])
|
|
154
|
-
.split("\
|
|
206
|
+
return git(root, ["worktree", "list", "--porcelain", "-z"])
|
|
207
|
+
.split("\0")
|
|
155
208
|
.filter((l) => l.startsWith("branch "))
|
|
156
|
-
.map((l) => l.slice("branch refs/heads/".length)
|
|
209
|
+
.map((l) => l.slice("branch refs/heads/".length));
|
|
157
210
|
} catch {
|
|
158
211
|
return [];
|
|
159
212
|
}
|
|
160
213
|
}
|
|
161
214
|
|
|
162
215
|
/**
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
216
|
+
* Session-start recovery: every registered subagent worktree is a crash leftover
|
|
217
|
+
* (nothing of ours can be live yet). Commit whatever the dead child left so the
|
|
218
|
+
* branch keeps it, then drop the dir. Branches always survive.
|
|
219
|
+
*/
|
|
220
|
+
export function reapDeadWorktrees(root: string, isLive: (path: string) => boolean = () => false): number {
|
|
221
|
+
root = realpathSync(root);
|
|
222
|
+
const sub = subagentsDir(root);
|
|
223
|
+
if (!sub || !existsSync(sub)) return 0;
|
|
224
|
+
let reaped = 0;
|
|
225
|
+
for (const path of worktreePaths(root)) {
|
|
226
|
+
if (!isInside(path, sub)) continue; // not ours
|
|
227
|
+
if (isLive(path)) continue; // another pi session owns it
|
|
228
|
+
try {
|
|
229
|
+
commitIn(path, "subagent (recovered after interrupted session)");
|
|
230
|
+
} catch {
|
|
231
|
+
continue; // git failed — never drop a dir whose work isn't on the branch
|
|
232
|
+
}
|
|
233
|
+
dropDir(root, path);
|
|
234
|
+
reaped += 1;
|
|
235
|
+
}
|
|
236
|
+
return reaped;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Ownership marker: a live worktree gets `<dir>/.subagent-owner` holding the
|
|
241
|
+
* owning pid. Another pi session must not reap a checkout whose owner is alive.
|
|
242
|
+
*/
|
|
243
|
+
export function claimWorktree(wt: Worktree): void {
|
|
244
|
+
try {
|
|
245
|
+
writeFileSync(join(wt.path, ".subagent-owner"), String(process.pid));
|
|
246
|
+
} catch {
|
|
247
|
+
/* best-effort: worst case another session reaps it after a crash */
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** True when the worktree dir is claimed by a process that still exists. */
|
|
252
|
+
export function ownerAlive(path: string): boolean {
|
|
253
|
+
try {
|
|
254
|
+
const pid = Number.parseInt(readFileSync(join(path, ".subagent-owner"), "utf8").trim(), 10);
|
|
255
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
256
|
+
if (pid === process.pid) return true;
|
|
257
|
+
process.kill(pid, 0); // throws ESRCH when the owner is gone
|
|
258
|
+
return true;
|
|
259
|
+
} catch {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Remove worktree dirs that git no longer knows about (partial-crash leftovers).
|
|
266
|
+
* Registered worktrees are never touched here — `reapDeadWorktrees` owns those,
|
|
267
|
+
* and a live child's checkout must survive.
|
|
166
268
|
*/
|
|
167
269
|
export function sweepStale(root: string): void {
|
|
168
270
|
root = realpathSync(root);
|
|
169
|
-
const sub =
|
|
170
|
-
if (!existsSync(sub)) return;
|
|
171
|
-
const registered =
|
|
271
|
+
const sub = subagentsDir(root);
|
|
272
|
+
if (!sub || !existsSync(sub)) return;
|
|
273
|
+
const registered = worktreePaths(root);
|
|
172
274
|
for (const runDir of readDirs(sub)) {
|
|
173
275
|
for (const taskDir of readDirs(join(sub, runDir))) {
|
|
174
276
|
const dir = join(sub, runDir, taskDir);
|
|
175
|
-
if (registered.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
} catch {
|
|
179
|
-
// dir not a registered worktree (partial crash) — plain rm
|
|
180
|
-
rmSync(dir, { recursive: true, force: true });
|
|
181
|
-
}
|
|
277
|
+
if (registered.some((p) => samePath(p, dir))) continue; // live/registered worktree
|
|
278
|
+
if (ownerAlive(dir)) continue; // claimed by a running session
|
|
279
|
+
rmSync(dir, { recursive: true, force: true });
|
|
182
280
|
}
|
|
183
281
|
}
|
|
184
282
|
prune(root);
|
|
185
283
|
}
|
|
186
284
|
|
|
285
|
+
/** Registered worktree paths. `-z` keeps paths verbatim (no C-quoting to undo). */
|
|
187
286
|
function worktreePaths(root: string): string[] {
|
|
188
287
|
try {
|
|
189
|
-
return git(root, ["worktree", "list", "--porcelain"])
|
|
190
|
-
.split("\
|
|
288
|
+
return git(root, ["worktree", "list", "--porcelain", "-z"])
|
|
289
|
+
.split("\0")
|
|
191
290
|
.filter((l) => l.startsWith("worktree "))
|
|
192
|
-
.map((l) => l.slice("worktree ".length)
|
|
291
|
+
.map((l) => l.slice("worktree ".length));
|
|
193
292
|
} catch {
|
|
194
293
|
return [];
|
|
195
294
|
}
|
|
196
295
|
}
|
|
197
296
|
|
|
297
|
+
function isInside(path: string, dir: string): boolean {
|
|
298
|
+
const p = safeReal(path);
|
|
299
|
+
const d = safeReal(dir);
|
|
300
|
+
return p === d || p.startsWith(`${d}/`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function safeReal(path: string): string {
|
|
304
|
+
try {
|
|
305
|
+
return realpathSync(path);
|
|
306
|
+
} catch {
|
|
307
|
+
return path;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
198
311
|
function readDirs(dir: string): string[] {
|
|
199
312
|
try {
|
|
200
313
|
return readdirSync(dir, { withFileTypes: true })
|