@arhen/pi-core-subagent 1.3.31 → 1.3.33
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/package.json +1 -1
- package/src/format.ts +18 -1
- package/src/index.ts +23 -12
- package/src/manager.ts +259 -91
- package/src/types.ts +7 -0
- package/src/worktree.ts +177 -53
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.33",
|
|
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/format.ts
CHANGED
|
@@ -196,6 +196,23 @@ export class SubagentsWidget implements Component {
|
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
198
|
/** Blocking-call summary: full text, because the model asked for it. */
|
|
199
|
+
/** Where a write child's edits went: a branch to merge, or straight into the tree. */
|
|
200
|
+
function worktreeLine(task: TaskSnapshot): string {
|
|
201
|
+
const parts: string[] = [];
|
|
202
|
+
if (task.branch) {
|
|
203
|
+
const files = task.changedFiles?.length
|
|
204
|
+
? ` (${task.changedFiles.length} file(s): ${truncateText(task.changedFiles.join(", "), 160)})`
|
|
205
|
+
: "";
|
|
206
|
+
parts.push(`Branch: ${task.branch}${files} — merge with \`git merge --no-ff ${task.branch}\` after review.`);
|
|
207
|
+
} else if (task.isolation === "in-place") {
|
|
208
|
+
parts.push(
|
|
209
|
+
`Applied IN PLACE (no branch) — ${task.isolationReason ?? "worktree unavailable"}. Review the working tree directly.`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
if (task.worktreeError) parts.push(`Worktree: ${task.worktreeError}`);
|
|
213
|
+
return parts.length ? `\n${parts.join("\n")}` : "";
|
|
214
|
+
}
|
|
215
|
+
|
|
199
216
|
export function makeSummary(run: RunSnapshot): string {
|
|
200
217
|
const succeeded = run.tasks.filter((t) => t.status === "completed").length;
|
|
201
218
|
const failed = run.tasks.filter((t) => t.status === "failed").length;
|
|
@@ -210,7 +227,7 @@ export function makeSummary(run: RunSnapshot): string {
|
|
|
210
227
|
// Edges are named so the leader can compare what it delegated against what came back.
|
|
211
228
|
const edge = task.needs?.length ? ` (${task.id}, needs ${task.needs.join(", ")})` : ` (${task.id})`;
|
|
212
229
|
lines.push(
|
|
213
|
-
`\n## ${task.agent}${edge} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}${
|
|
230
|
+
`\n## ${task.agent}${edge} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}${worktreeLine(task)}`,
|
|
214
231
|
);
|
|
215
232
|
}
|
|
216
233
|
// Ceiling on the WHOLE summary — 16 tasks × 24KB would otherwise flood the parent context.
|
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, reapDeadWorktrees, 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);
|
|
@@ -126,11 +126,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
126
126
|
}
|
|
127
127
|
for (const root of roots) {
|
|
128
128
|
try {
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
// dir. Then reap merged branches and dirs git no longer tracks.
|
|
132
|
-
reapDeadWorktrees(root);
|
|
133
|
-
cleanupMerged(root);
|
|
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
134
|
sweepStale(root);
|
|
135
135
|
} catch {
|
|
136
136
|
/* recovery is best-effort — never block session start */
|
|
@@ -188,15 +188,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
188
188
|
intercom.push(...awaited.intercom);
|
|
189
189
|
if (awaited.intercom.some((m) => m.kind === "ask")) break;
|
|
190
190
|
}
|
|
191
|
-
|
|
191
|
+
// EVERY ask must surface: siblings that asked in the same wake got no
|
|
192
|
+
// followUp notice (the park swallowed it), so showing only the first
|
|
193
|
+
// leaves the rest blocked until their 10-minute timeout.
|
|
194
|
+
const asks = intercom.filter((m) => m.kind === "ask");
|
|
192
195
|
const heard = intercom.filter((m) => m.kind !== "ask");
|
|
193
196
|
const text = [
|
|
194
197
|
makeSummary(run),
|
|
195
198
|
heard.length > 0
|
|
196
199
|
? `\nIntercom while waiting:\n${heard.map((m) => `- [${m.kind}] ${m.agent} (${m.taskId}): ${truncateText(m.text)}`).join("\n")}`
|
|
197
200
|
: "",
|
|
198
|
-
|
|
199
|
-
? `\
|
|
201
|
+
asks.length > 0
|
|
202
|
+
? `\n${asks.length} child(ren) waiting for your answer:\n${asks
|
|
203
|
+
.map(
|
|
204
|
+
(a) =>
|
|
205
|
+
`- ${a.agent} (${a.taskId}): ${a.text}\n reply_subagent(runId: "${run.id}", taskId: "${a.taskId}", message: ...)`,
|
|
206
|
+
)
|
|
207
|
+
.join("\n")}\nAnswer each, then await_subagent again for the result.`
|
|
200
208
|
: "",
|
|
201
209
|
]
|
|
202
210
|
.filter(Boolean)
|
|
@@ -321,9 +329,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
321
329
|
`Run ${run.id} — ${run.status}`,
|
|
322
330
|
...tasks.map((t) => {
|
|
323
331
|
const wt = t.branch
|
|
324
|
-
? `\nBranch: ${t.branch}\n${t.diffStat || "(no
|
|
325
|
-
: ""
|
|
326
|
-
|
|
332
|
+
? `\nBranch: ${t.branch}\n${t.diffStat || "(no diff available)"}\nMerge after review: \`git merge --no-ff ${t.branch}\``
|
|
333
|
+
: t.isolation === "in-place"
|
|
334
|
+
? `\nApplied IN PLACE (no branch) — ${t.isolationReason ?? "worktree unavailable"}. The changes are already in your working tree.`
|
|
335
|
+
: "";
|
|
336
|
+
const wtErr = t.worktreeError ? `\nWorktree: ${t.worktreeError}` : "";
|
|
337
|
+
return `\n## ${t.agent} ${statusIcon(t.status)}\nGoal: ${truncateText(t.task, 300)}\n${t.error ? `Error: ${t.error}` : t.finalText || "(no output yet)"}${wt}${wtErr}\n${formatUsage(t.usage)}`;
|
|
327
338
|
}),
|
|
328
339
|
].join("\n");
|
|
329
340
|
return { content: [{ type: "text", text: truncateText(text) }], details: { run: cloneRun(run) } };
|
package/src/manager.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** SubagentManager: run lifecycle, child sessions, intercom, persistence, widget plumbing. */
|
|
2
|
-
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { writeFile } from "node:fs/promises";
|
|
4
|
-
import { join, relative } from "node:path";
|
|
4
|
+
import { join, relative, sep } from "node:path";
|
|
5
5
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
6
6
|
import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
|
|
7
7
|
import {
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
} from "./types.ts";
|
|
46
46
|
import {
|
|
47
47
|
branchDiff,
|
|
48
|
+
claimWorktree,
|
|
48
49
|
cleanupMerged,
|
|
49
50
|
commitWorktree,
|
|
50
51
|
createWorktree,
|
|
@@ -58,6 +59,10 @@ export const MAX_CONCURRENCY = 8;
|
|
|
58
59
|
/** No default wall-clock cap: a subagent runs until its task is done, it stalls, or the user aborts. */
|
|
59
60
|
const DEFAULT_RUNTIME_MS = 0;
|
|
60
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
|
|
64
|
+
/** Intercom messages buffered per park before the followUp path takes over. */
|
|
65
|
+
const PARKED_MSG_CAP = 24;
|
|
61
66
|
const READONLY_TOOLS = ["read", "grep", "find", "ls"];
|
|
62
67
|
const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write"];
|
|
63
68
|
/** Tools that can mutate the tree — their presence is what earns a worktree. */
|
|
@@ -221,7 +226,10 @@ export interface ParkedMsg {
|
|
|
221
226
|
|
|
222
227
|
export class SubagentManager {
|
|
223
228
|
private runs = new Map<string, RunSnapshot>();
|
|
224
|
-
|
|
229
|
+
/** Runs that are still settleable (presence = not yet settled). */
|
|
230
|
+
private settlers = new Map<string, true>();
|
|
231
|
+
/** Everyone parked on a run — a set, so re-parking can't build a closure chain. */
|
|
232
|
+
private settleWaiters = new Map<string, Set<(run: RunSnapshot) => void>>();
|
|
225
233
|
private pendingReplies = new Map<string, PendingReply>();
|
|
226
234
|
private liveChildren = new Map<
|
|
227
235
|
string,
|
|
@@ -296,8 +304,22 @@ export class SubagentManager {
|
|
|
296
304
|
child.dispose();
|
|
297
305
|
}
|
|
298
306
|
this.liveChildren.clear();
|
|
307
|
+
// Release anyone parked on a run before the maps go — dropping waiters would
|
|
308
|
+
// leave their promises pending forever (autoAwait / await_subagent hang).
|
|
309
|
+
for (const [runId, waiters] of this.settleWaiters) {
|
|
310
|
+
const run = this.runs.get(runId);
|
|
311
|
+
for (const waiter of waiters) waiter(run ? cloneRun(run) : ({ id: runId, status: "aborted" } as RunSnapshot));
|
|
312
|
+
}
|
|
313
|
+
for (const pending of this.pendingReplies.values()) {
|
|
314
|
+
pending.resolve("(session ended — stop work immediately)");
|
|
315
|
+
}
|
|
316
|
+
this.parked.clear();
|
|
317
|
+
// Ownership markers stay on disk; the next session reaps those dirs (commit,
|
|
318
|
+
// keep branch, drop dir) once this pid is gone.
|
|
319
|
+
this.liveWorktrees.clear();
|
|
299
320
|
this.runs.clear();
|
|
300
321
|
this.settlers.clear();
|
|
322
|
+
this.settleWaiters.clear();
|
|
301
323
|
this.pendingReplies.clear();
|
|
302
324
|
this.runControllers.clear();
|
|
303
325
|
this.mailboxes = createMailbox();
|
|
@@ -508,31 +530,36 @@ export class SubagentManager {
|
|
|
508
530
|
private makeChildHandlers(run: RunSnapshot, task: TaskSnapshot, ctx: ExtensionContext): ChildHandlers {
|
|
509
531
|
return {
|
|
510
532
|
onAskParent: async (_taskId, question) => {
|
|
533
|
+
const key = `${run.id}:${task.id}`;
|
|
534
|
+
// A tool call already in flight can reach here AFTER the task ended
|
|
535
|
+
// (abort/timeout/cancel). Reviving it would leave a "running" task in a
|
|
536
|
+
// finished run — hasActiveRun() then never clears.
|
|
537
|
+
if (TERMINAL.includes(task.status)) {
|
|
538
|
+
return "(your task has already ended — stop work and return immediately)";
|
|
539
|
+
}
|
|
511
540
|
this.updateTask(run, task, { status: "awaiting_parent" }, ctx);
|
|
512
|
-
this.liveChildren.get(
|
|
513
|
-
// While the leader is parked in await_subagent
|
|
514
|
-
//
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
const keepAlive = setInterval(() => this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog(), 30_000);
|
|
520
|
-
try {
|
|
521
|
-
const reply = await this.awaitParentReply(run.id, task.id);
|
|
522
|
-
this.updateTask(run, task, { status: "running" }, ctx);
|
|
523
|
-
this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
|
|
524
|
-
return reply;
|
|
525
|
-
} finally {
|
|
526
|
-
clearInterval(keepAlive);
|
|
527
|
-
}
|
|
541
|
+
this.liveChildren.get(key)?.touchWatchdog();
|
|
542
|
+
// While the leader is parked in await_subagent the question rides the wait
|
|
543
|
+
// (no steering queue, no turn boundary); otherwise it goes out as a notice.
|
|
544
|
+
// Either way the pending reply entry must exist, or reply_subagent has
|
|
545
|
+
// nowhere to land and the child waits on an answer that never comes.
|
|
546
|
+
if (!this.collectParked(run.id, { kind: "ask", taskId: task.id, agent: task.agent, text: question })) {
|
|
547
|
+
this.notifyParent(run, "asked", { taskId: task.id, question });
|
|
528
548
|
}
|
|
529
|
-
|
|
530
|
-
//
|
|
531
|
-
|
|
549
|
+
// A waiting child is not stalled — keep the watchdog fed until the reply.
|
|
550
|
+
// But the wait is BOUNDED: an unanswered question would otherwise keep the
|
|
551
|
+
// run non-terminal forever (widget never clears, run never settles).
|
|
552
|
+
const keepAlive = setInterval(() => this.liveChildren.get(key)?.touchWatchdog(), 30_000);
|
|
532
553
|
try {
|
|
533
|
-
const reply = await this.awaitParentReply(run.id, task.id);
|
|
554
|
+
const reply = await this.awaitParentReply(run.id, task.id, PARENT_REPLY_TIMEOUT_MS);
|
|
555
|
+
// Cancel wins over a reply that arrived in the same tick: never move a
|
|
556
|
+
// terminal task back to "running" (that would let a canceled task be
|
|
557
|
+
// reported as completed).
|
|
558
|
+
if (TERMINAL.includes(task.status)) {
|
|
559
|
+
return "(your task was canceled while you waited — stop work and return immediately)";
|
|
560
|
+
}
|
|
534
561
|
this.updateTask(run, task, { status: "running" }, ctx);
|
|
535
|
-
this.liveChildren.get(
|
|
562
|
+
this.liveChildren.get(key)?.touchWatchdog();
|
|
536
563
|
return reply;
|
|
537
564
|
} finally {
|
|
538
565
|
clearInterval(keepAlive);
|
|
@@ -572,16 +599,35 @@ export class SubagentManager {
|
|
|
572
599
|
onPollMailbox: (taskId) => this.mailboxes.poll(`${run.id}:${taskId}`),
|
|
573
600
|
};
|
|
574
601
|
}
|
|
575
|
-
private awaitParentReply(runId: string, taskId: string): Promise<string> {
|
|
602
|
+
private awaitParentReply(runId: string, taskId: string, timeoutMs = 0): Promise<string> {
|
|
603
|
+
const key = `${runId}:${taskId}`;
|
|
576
604
|
return new Promise<string>((resolve) => {
|
|
577
|
-
|
|
605
|
+
// Identity-tagged: two asks from one child must not delete each other's
|
|
606
|
+
// entry (the loser would hang until its own timer).
|
|
607
|
+
const entry: PendingReply = {
|
|
608
|
+
resolve: (message) => {
|
|
609
|
+
if (timer) clearTimeout(timer);
|
|
610
|
+
if (this.pendingReplies.get(key) === entry) this.pendingReplies.delete(key);
|
|
611
|
+
resolve(message);
|
|
612
|
+
},
|
|
613
|
+
};
|
|
614
|
+
const timer =
|
|
615
|
+
timeoutMs > 0
|
|
616
|
+
? setTimeout(
|
|
617
|
+
() =>
|
|
618
|
+
entry.resolve(
|
|
619
|
+
"The parent did not answer in time. Proceed autonomously with your best judgment and state the assumption you made in your final answer.",
|
|
620
|
+
),
|
|
621
|
+
timeoutMs,
|
|
622
|
+
)
|
|
623
|
+
: undefined;
|
|
624
|
+
this.pendingReplies.set(key, entry);
|
|
578
625
|
});
|
|
579
626
|
}
|
|
580
627
|
deliverReply(runId: string, taskId: string, message: string): boolean {
|
|
581
628
|
const pending = this.pendingReplies.get(`${runId}:${taskId}`);
|
|
582
629
|
if (!pending) return false;
|
|
583
|
-
|
|
584
|
-
pending.resolve(message);
|
|
630
|
+
pending.resolve(message); // clears its own entry + timer
|
|
585
631
|
return true;
|
|
586
632
|
}
|
|
587
633
|
|
|
@@ -683,33 +729,9 @@ export class SubagentManager {
|
|
|
683
729
|
const canWrite = baseTools.some((t) => WRITE_CAPABLE.includes(t));
|
|
684
730
|
|
|
685
731
|
// Write agents run in an isolated git worktree (branch subagents/<run>/<task>);
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
|
|
689
|
-
if (canWrite) {
|
|
690
|
-
try {
|
|
691
|
-
wt = createWorktree(task.cwd, run.id, task.id);
|
|
692
|
-
} catch {
|
|
693
|
-
wt = undefined; // git failure → in-place
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
// Map a per-task cwd subpath into the worktree so relative paths stay correct.
|
|
697
|
-
// If the mapping can't be trusted (cwd outside the repo via symlink), drop the
|
|
698
|
-
// worktree rather than run in the main tree while reporting a branch.
|
|
699
|
-
let childCwd = wt?.path ?? task.cwd;
|
|
700
|
-
if (wt) {
|
|
701
|
-
const rel = relative(wt.root, safeRealPath(task.cwd));
|
|
702
|
-
if (rel.startsWith("..")) {
|
|
703
|
-
removeWorktree(wt);
|
|
704
|
-
wt = undefined;
|
|
705
|
-
} else if (rel && rel !== ".") {
|
|
706
|
-
childCwd = join(wt.path, rel);
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
if (wt) this.liveWorktrees.set(`${run.id}:${task.id}`, wt);
|
|
710
|
-
|
|
711
|
-
// Model + thinking resolve against the pi model registry; a bad request
|
|
712
|
-
// fails the TASK with a helpful message, not the whole run.
|
|
732
|
+
// Model + thinking resolve against the pi model registry BEFORE any worktree
|
|
733
|
+
// exists — a bad request fails the TASK with a helpful message and can't leak a
|
|
734
|
+
// checkout past this early return.
|
|
713
735
|
let model: Model<Api> | undefined;
|
|
714
736
|
try {
|
|
715
737
|
model = resolveChildModel(ctx, file?.model ?? input.model);
|
|
@@ -729,6 +751,54 @@ export class SubagentManager {
|
|
|
729
751
|
return;
|
|
730
752
|
}
|
|
731
753
|
|
|
754
|
+
// Write agents run in an isolated git worktree (branch subagents/<run>/<task>);
|
|
755
|
+
// non-git repos fall back to in-place. Created BEFORE session start so the
|
|
756
|
+
// child's cwd + AGENTS.md context chain are the worktree's.
|
|
757
|
+
let wt: Worktree | undefined;
|
|
758
|
+
let isolationReason: string | undefined;
|
|
759
|
+
if (canWrite) {
|
|
760
|
+
try {
|
|
761
|
+
wt = createWorktree(task.cwd, run.id, task.id);
|
|
762
|
+
if (!wt) isolationReason = "not a git repository";
|
|
763
|
+
} catch (err) {
|
|
764
|
+
wt = undefined; // git failure → in-place
|
|
765
|
+
isolationReason = `git worktree add failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
// Map a per-task cwd subpath into the worktree so relative paths stay correct.
|
|
769
|
+
// Both sides go through realpath — a symlinked root would otherwise look
|
|
770
|
+
// "outside" the repo. If the mapping can't be trusted, drop the worktree AND
|
|
771
|
+
// reset the cwd (never point the child at a dir that was just removed).
|
|
772
|
+
let childCwd = wt?.path ?? task.cwd;
|
|
773
|
+
if (wt) {
|
|
774
|
+
const rel = relative(safeRealPath(wt.root), safeRealPath(task.cwd));
|
|
775
|
+
if (rel === ".." || rel.startsWith(`..${sep}`)) {
|
|
776
|
+
removeWorktree(wt);
|
|
777
|
+
wt = undefined;
|
|
778
|
+
childCwd = task.cwd;
|
|
779
|
+
isolationReason = "task cwd is outside the repository";
|
|
780
|
+
} else if (rel && rel !== ".") {
|
|
781
|
+
childCwd = join(wt.path, rel);
|
|
782
|
+
// The subpath may be gitignored/untracked, so it won't exist in a fresh
|
|
783
|
+
// checkout — create it rather than fail session start.
|
|
784
|
+
try {
|
|
785
|
+
mkdirSync(childCwd, { recursive: true });
|
|
786
|
+
} catch {
|
|
787
|
+
childCwd = wt.path;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
if (canWrite) {
|
|
792
|
+
// Never let isolation lapse quietly: the leader must know its edits landed
|
|
793
|
+
// straight in the working tree with no branch to review.
|
|
794
|
+
task.isolation = wt ? "worktree" : "in-place";
|
|
795
|
+
task.isolationReason = wt ? undefined : (isolationReason ?? "worktree unavailable");
|
|
796
|
+
}
|
|
797
|
+
if (wt) {
|
|
798
|
+
claimWorktree(wt); // pid marker: another pi session must not reap this
|
|
799
|
+
this.liveWorktrees.set(`${run.id}:${task.id}`, wt);
|
|
800
|
+
}
|
|
801
|
+
|
|
732
802
|
this.updateTask(
|
|
733
803
|
run,
|
|
734
804
|
task,
|
|
@@ -746,6 +816,9 @@ export class SubagentManager {
|
|
|
746
816
|
onUpdate,
|
|
747
817
|
);
|
|
748
818
|
|
|
819
|
+
// Set once the dir must outlive this call: committed work awaiting the
|
|
820
|
+
// leader's merge, or a commit failure whose work exists ONLY in the dir.
|
|
821
|
+
let keepWorktreeDir = false;
|
|
749
822
|
let child: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
|
|
750
823
|
let unsubscribe: (() => void) | undefined;
|
|
751
824
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -863,28 +936,51 @@ export class SubagentManager {
|
|
|
863
936
|
// until the branch is merged — cleanupMerged removes both then.
|
|
864
937
|
// Commit/diff failures must NOT downgrade a completed task or destroy
|
|
865
938
|
// its work: the error is reported, the status stays completed.
|
|
939
|
+
let committed: "committed" | "empty" | undefined;
|
|
866
940
|
try {
|
|
867
|
-
commitWorktree(wt, `subagent ${task.agent}: ${truncateText(input.task, 60)}`);
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
task,
|
|
872
|
-
{ branch: wt.branch, diffStat: stat || undefined, changedFiles: files.length ? files : undefined },
|
|
873
|
-
ctx,
|
|
874
|
-
onUpdate,
|
|
875
|
-
);
|
|
941
|
+
committed = commitWorktree(wt, `subagent ${task.agent}: ${truncateText(input.task, 60)}`);
|
|
942
|
+
// Only a real commit is worth a branch: an empty one would send the
|
|
943
|
+
// leader off to review and merge nothing.
|
|
944
|
+
keepWorktreeDir = committed === "committed";
|
|
876
945
|
} catch (commitErr) {
|
|
946
|
+
// Never drop a checkout whose work isn't on the branch — it would be
|
|
947
|
+
// unreachable once the base-tip branch is reaped as "merged".
|
|
948
|
+
keepWorktreeDir = true;
|
|
877
949
|
this.updateTask(
|
|
878
950
|
run,
|
|
879
951
|
task,
|
|
880
952
|
{
|
|
881
953
|
branch: wt.branch,
|
|
882
|
-
|
|
954
|
+
worktreeError: `commit failed (uncommitted changes remain in ${wt.path}): ${commitErr instanceof Error ? commitErr.message : String(commitErr)}`,
|
|
883
955
|
},
|
|
884
956
|
ctx,
|
|
885
957
|
onUpdate,
|
|
886
958
|
);
|
|
887
959
|
}
|
|
960
|
+
// Diff separately: a diff failure must not be reported as a lost commit.
|
|
961
|
+
if (committed === "committed") {
|
|
962
|
+
try {
|
|
963
|
+
const { stat, files } = branchDiff(wt);
|
|
964
|
+
this.updateTask(
|
|
965
|
+
run,
|
|
966
|
+
task,
|
|
967
|
+
{ branch: wt.branch, diffStat: stat || undefined, changedFiles: files.length ? files : undefined },
|
|
968
|
+
ctx,
|
|
969
|
+
onUpdate,
|
|
970
|
+
);
|
|
971
|
+
} catch (diffErr) {
|
|
972
|
+
this.updateTask(
|
|
973
|
+
run,
|
|
974
|
+
task,
|
|
975
|
+
{
|
|
976
|
+
branch: wt.branch,
|
|
977
|
+
worktreeError: `committed, but the diff could not be read: ${diffErr instanceof Error ? diffErr.message : String(diffErr)}`,
|
|
978
|
+
},
|
|
979
|
+
ctx,
|
|
980
|
+
onUpdate,
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
888
984
|
}
|
|
889
985
|
}
|
|
890
986
|
} catch (err) {
|
|
@@ -919,20 +1015,27 @@ export class SubagentManager {
|
|
|
919
1015
|
watchdog.dispose();
|
|
920
1016
|
if (timeout) clearTimeout(timeout);
|
|
921
1017
|
child?.dispose();
|
|
922
|
-
this.liveWorktrees.delete(key);
|
|
923
1018
|
// Failed/aborted: let the aborted child's last writes land (its tools may
|
|
924
1019
|
// still be unwinding), commit whatever partial work exists so the branch
|
|
925
|
-
// really keeps it, then drop the checkout dir.
|
|
1020
|
+
// really keeps it, then drop the checkout dir. A commit FAILURE keeps the
|
|
1021
|
+
// dir — dropping it would make the work unreachable.
|
|
926
1022
|
if (wt && task.status !== "completed") {
|
|
927
1023
|
await new Promise((r) => setTimeout(r, 250));
|
|
1024
|
+
let partial: "committed" | "empty" | undefined;
|
|
928
1025
|
try {
|
|
929
|
-
commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
|
|
1026
|
+
partial = commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
|
|
930
1027
|
} catch {
|
|
931
|
-
|
|
1028
|
+
keepWorktreeDir = true; // work exists only in the dir — keep it
|
|
1029
|
+
}
|
|
1030
|
+
// A branch is only worth reporting when it actually carries something.
|
|
1031
|
+
if (partial === "committed" || keepWorktreeDir) {
|
|
1032
|
+
this.updateTask(run, task, { branch: wt.branch }, ctx, onUpdate);
|
|
932
1033
|
}
|
|
933
|
-
this.updateTask(run, task, { branch: wt.branch }, ctx, onUpdate);
|
|
934
|
-
removeWorktree(wt);
|
|
935
1034
|
}
|
|
1035
|
+
if (wt && !keepWorktreeDir) removeWorktree(wt);
|
|
1036
|
+
// Released only after the dir is gone: while it exists, the branch must stay
|
|
1037
|
+
// in liveBranches() so cleanup can't reap it.
|
|
1038
|
+
this.liveWorktrees.delete(key);
|
|
936
1039
|
}
|
|
937
1040
|
}
|
|
938
1041
|
|
|
@@ -1020,7 +1123,7 @@ export class SubagentManager {
|
|
|
1020
1123
|
});
|
|
1021
1124
|
this.turnActivity = true;
|
|
1022
1125
|
this.runs.set(run.id, run);
|
|
1023
|
-
this.settlers.set(run.id,
|
|
1126
|
+
this.settlers.set(run.id, true);
|
|
1024
1127
|
this.runControllers.set(run.id, new AbortController());
|
|
1025
1128
|
for (const task of run.tasks) this.mailboxes.open(`${run.id}:${task.id}`);
|
|
1026
1129
|
this.emit("subagent:run-created", { run: cloneRun(run) });
|
|
@@ -1087,13 +1190,14 @@ export class SubagentManager {
|
|
|
1087
1190
|
// Broken-upstream tasks are detected by the scheduler; mark them after the wave.
|
|
1088
1191
|
for (const s of skipped) {
|
|
1089
1192
|
const task = run.tasks.find((t) => t.id === s.id);
|
|
1090
|
-
if (task) {
|
|
1193
|
+
if (task && !TERMINAL.includes(task.status)) {
|
|
1091
1194
|
this.updateTask(
|
|
1092
1195
|
run,
|
|
1093
1196
|
task,
|
|
1094
1197
|
{
|
|
1095
1198
|
status: "aborted",
|
|
1096
|
-
|
|
1199
|
+
// Don't overwrite a real reason (e.g. "Canceled by subagent_cancel").
|
|
1200
|
+
error: task.error || `Skipped: upstream task(s) did not complete: ${s.needs.join(", ")}`,
|
|
1097
1201
|
endedAt: Date.now(),
|
|
1098
1202
|
},
|
|
1099
1203
|
ctx,
|
|
@@ -1101,6 +1205,19 @@ export class SubagentManager {
|
|
|
1101
1205
|
);
|
|
1102
1206
|
}
|
|
1103
1207
|
}
|
|
1208
|
+
// Belt and braces: the wave loop breaks out when no frontier is ready, which
|
|
1209
|
+
// would otherwise leave tasks queued inside a terminal run — hasActiveRun()
|
|
1210
|
+
// then never clears and the widget stays pinned.
|
|
1211
|
+
for (const task of run.tasks) {
|
|
1212
|
+
if (TERMINAL.includes(task.status)) continue;
|
|
1213
|
+
this.updateTask(
|
|
1214
|
+
run,
|
|
1215
|
+
task,
|
|
1216
|
+
{ status: "aborted", error: task.error || "Never ran: no runnable wave", endedAt: Date.now() },
|
|
1217
|
+
ctx,
|
|
1218
|
+
onUpdate,
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
1104
1221
|
|
|
1105
1222
|
const failed = run.tasks.some((t) => t.status === "failed");
|
|
1106
1223
|
const aborted = run.tasks.some((t) => t.status === "aborted") || Boolean(signal?.aborted);
|
|
@@ -1195,6 +1312,8 @@ export class SubagentManager {
|
|
|
1195
1312
|
task.status = "aborted";
|
|
1196
1313
|
task.error = task.error || "Canceled from peek";
|
|
1197
1314
|
task.endedAt = Date.now();
|
|
1315
|
+
this.pendingReplies.get(`${runId}:${taskId}`)?.resolve("(task canceled by the parent — stop work now)");
|
|
1316
|
+
this.pendingReplies.delete(`${runId}:${taskId}`);
|
|
1198
1317
|
this.liveChildren.get(`${runId}:${taskId}`)?.abort();
|
|
1199
1318
|
this.mailboxes.close(`${runId}:${taskId}`);
|
|
1200
1319
|
if (ctx) this.flushWidget(run, ctx);
|
|
@@ -1208,6 +1327,14 @@ export class SubagentManager {
|
|
|
1208
1327
|
if (TERMINAL.includes(run.status)) return { aborted: 0 }; // never corrupt a finished run
|
|
1209
1328
|
let aborted = 0;
|
|
1210
1329
|
this.runControllers.get(runId)?.abort();
|
|
1330
|
+
// Release children parked in ask_parent first — an unresolved wait would keep
|
|
1331
|
+
// the child alive past the abort.
|
|
1332
|
+
for (const [key, pending] of this.pendingReplies) {
|
|
1333
|
+
if (key.startsWith(`${runId}:`)) {
|
|
1334
|
+
this.pendingReplies.delete(key);
|
|
1335
|
+
pending.resolve("(run canceled by the parent — stop work and return what you have)");
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1211
1338
|
for (const [key, child] of this.liveChildren) {
|
|
1212
1339
|
if (key.startsWith(`${runId}:`)) {
|
|
1213
1340
|
child.abort();
|
|
@@ -1233,24 +1360,42 @@ export class SubagentManager {
|
|
|
1233
1360
|
return { aborted };
|
|
1234
1361
|
}
|
|
1235
1362
|
|
|
1236
|
-
/** Settle-and-delete:
|
|
1363
|
+
/** Settle-and-delete: every awaiter resolves once, then the set is dropped. */
|
|
1237
1364
|
private settleRun(runId: string, run: RunSnapshot): void {
|
|
1238
|
-
|
|
1239
|
-
if (!s) return;
|
|
1365
|
+
if (!this.settlers.has(runId)) return;
|
|
1240
1366
|
this.settlers.delete(runId);
|
|
1241
|
-
|
|
1367
|
+
const waiters = this.settleWaiters.get(runId);
|
|
1368
|
+
this.settleWaiters.delete(runId);
|
|
1369
|
+
if (!waiters) return;
|
|
1370
|
+
const snapshot = cloneRun(run);
|
|
1371
|
+
for (const waiter of waiters) waiter(snapshot);
|
|
1242
1372
|
}
|
|
1243
1373
|
|
|
1244
1374
|
/** Child→leader messages collected while the parent is parked in await_subagent. */
|
|
1245
|
-
|
|
1375
|
+
/** Every awaiter parked on a run — a SET, so two concurrent awaits can't
|
|
1376
|
+
* overwrite each other's buffer and silently swallow one side's intercom. */
|
|
1377
|
+
private parked = new Map<string, Set<{ msgs: ParkedMsg[]; wake: () => void }>>();
|
|
1246
1378
|
|
|
1247
1379
|
/** While the parent is parked on this run, deliver the message through the wait instead of the steering queue. */
|
|
1248
1380
|
private collectParked(runId: string, msg: ParkedMsg): boolean {
|
|
1249
|
-
const
|
|
1250
|
-
if (!
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1381
|
+
const parked = this.parked.get(runId);
|
|
1382
|
+
if (!parked || parked.size === 0) return false;
|
|
1383
|
+
let delivered = false;
|
|
1384
|
+
for (const p of parked) {
|
|
1385
|
+
if (p.msgs.length < PARKED_MSG_CAP) {
|
|
1386
|
+
p.msgs.push(msg);
|
|
1387
|
+
delivered = true;
|
|
1388
|
+
} else if (msg.kind === "ask") {
|
|
1389
|
+
// An unanswered ask blocks a child for 10 minutes — it must never be the
|
|
1390
|
+
// message that gets dropped by the cap.
|
|
1391
|
+
p.msgs[p.msgs.length - 1] = msg;
|
|
1392
|
+
delivered = true;
|
|
1393
|
+
}
|
|
1394
|
+
p.wake(); // resolve the parked await early — the leader breathes on every message
|
|
1395
|
+
}
|
|
1396
|
+
// Not buffered anywhere → report undelivered so the caller falls back to a
|
|
1397
|
+
// followUp notice instead of assuming the leader saw it.
|
|
1398
|
+
return delivered;
|
|
1254
1399
|
}
|
|
1255
1400
|
|
|
1256
1401
|
awaitRun(
|
|
@@ -1259,8 +1404,12 @@ export class SubagentManager {
|
|
|
1259
1404
|
): Promise<{ run: RunSnapshot | undefined; intercom: ParkedMsg[] } | undefined> {
|
|
1260
1405
|
const run = this.runs.get(runId);
|
|
1261
1406
|
if (!run) return Promise.resolve(undefined);
|
|
1407
|
+
let entry: { msgs: ParkedMsg[]; wake: () => void } | undefined;
|
|
1262
1408
|
const finish = (): void => {
|
|
1263
|
-
this.parked.
|
|
1409
|
+
const parked = this.parked.get(runId);
|
|
1410
|
+
if (!parked || !entry) return;
|
|
1411
|
+
parked.delete(entry); // only our own park — a sibling await keeps receiving
|
|
1412
|
+
if (parked.size === 0) this.parked.delete(runId);
|
|
1264
1413
|
};
|
|
1265
1414
|
if (TERMINAL.includes(run.status)) {
|
|
1266
1415
|
run.awaited = true;
|
|
@@ -1268,24 +1417,43 @@ export class SubagentManager {
|
|
|
1268
1417
|
}
|
|
1269
1418
|
const msgs: ParkedMsg[] = [];
|
|
1270
1419
|
const settled = new Promise<RunSnapshot | undefined>((resolve) => {
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1420
|
+
// Waiters are a SET, not a chain: the autoAwait loop re-parks on every
|
|
1421
|
+
// child message, and wrapping the previous settler each time grew an
|
|
1422
|
+
// unbounded closure chain (each holding a snapshot clone).
|
|
1423
|
+
const waiter = (r: RunSnapshot) => {
|
|
1424
|
+
this.settleWaiters.get(runId)?.delete(waiter);
|
|
1274
1425
|
resolve(r);
|
|
1275
|
-
}
|
|
1426
|
+
};
|
|
1427
|
+
let waiters = this.settleWaiters.get(runId);
|
|
1428
|
+
if (!waiters) {
|
|
1429
|
+
waiters = new Set();
|
|
1430
|
+
this.settleWaiters.set(runId, waiters);
|
|
1431
|
+
}
|
|
1432
|
+
waiters.add(waiter);
|
|
1276
1433
|
// A child→leader message while parked wakes the wait: the leader gets it
|
|
1277
1434
|
// IN the await result, no steering queue, no turn boundary needed.
|
|
1278
|
-
|
|
1435
|
+
entry = { msgs, wake: () => waiter(cloneRun(run)) };
|
|
1436
|
+
let parked = this.parked.get(runId);
|
|
1437
|
+
if (!parked) {
|
|
1438
|
+
parked = new Set();
|
|
1439
|
+
this.parked.set(runId, parked);
|
|
1440
|
+
}
|
|
1441
|
+
parked.add(entry);
|
|
1279
1442
|
});
|
|
1280
1443
|
if (timeoutMs !== undefined && timeoutMs > 0) {
|
|
1444
|
+
let timedOut = false;
|
|
1281
1445
|
return Promise.race([
|
|
1282
1446
|
settled.then((r) => {
|
|
1283
1447
|
finish();
|
|
1284
|
-
|
|
1448
|
+
// Only mark awaited when this call actually hands the run back to the
|
|
1449
|
+
// leader. A slice that already timed out is abandoned — setting it here
|
|
1450
|
+
// would suppress the run's completion notice the leader still needs.
|
|
1451
|
+
if (!timedOut) run.awaited = true;
|
|
1285
1452
|
return { run: r, intercom: msgs };
|
|
1286
1453
|
}),
|
|
1287
1454
|
new Promise<{ run: RunSnapshot | undefined; intercom: ParkedMsg[] } | undefined>((resolve) => {
|
|
1288
1455
|
const timer = setTimeout(() => {
|
|
1456
|
+
timedOut = true;
|
|
1289
1457
|
finish();
|
|
1290
1458
|
resolve(this.runs.get(runId) ? { run: cloneRun(this.runs.get(runId)!), intercom: msgs } : undefined);
|
|
1291
1459
|
}, timeoutMs);
|
package/src/types.ts
CHANGED
|
@@ -45,6 +45,13 @@ export interface TaskSnapshot {
|
|
|
45
45
|
branch?: string;
|
|
46
46
|
diffStat?: string;
|
|
47
47
|
changedFiles?: string[];
|
|
48
|
+
/** How a write child's edits were applied. "in-place" means NO branch: the
|
|
49
|
+
* changes are already in the leader's tree — always surfaced, never silent. */
|
|
50
|
+
isolation?: "worktree" | "in-place";
|
|
51
|
+
isolationReason?: string;
|
|
52
|
+
/** Worktree commit/diff trouble. Kept apart from `error` so a completed task
|
|
53
|
+
* still reports its answer. */
|
|
54
|
+
worktreeError?: string;
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
export interface RunSnapshot {
|
package/src/worktree.ts
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* touching a checked-out one), `sweepStale` (dirs git no longer knows about). */
|
|
11
11
|
|
|
12
12
|
import { execFileSync } from "node:child_process";
|
|
13
|
-
import { existsSync, readdirSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
|
14
|
-
import {
|
|
13
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { hostname, uptime } from "node:os";
|
|
15
|
+
import { join, resolve } from "node:path";
|
|
15
16
|
|
|
16
17
|
export interface Worktree {
|
|
17
18
|
root: string; // repo root (main tree)
|
|
@@ -22,13 +23,33 @@ export interface Worktree {
|
|
|
22
23
|
|
|
23
24
|
const BRANCH_PREFIX = "subagents/";
|
|
24
25
|
|
|
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
|
+
const COMMIT_CONFIG = ["-c", "commit.gpgsign=false", "-c", "user.name=pi subagent", "-c", "user.email=subagent@local"];
|
|
29
|
+
const GIT_TIMEOUT_MS = 120_000;
|
|
30
|
+
const GIT_MAX_BUFFER = 32 * 1024 * 1024;
|
|
31
|
+
|
|
25
32
|
function git(root: string, args: string[]): string {
|
|
26
|
-
return
|
|
33
|
+
return gitRaw(root, args).trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Untrimmed git output — required for -z parsing, where a path may end in a space. */
|
|
37
|
+
function gitRaw(root: string, args: string[]): string {
|
|
38
|
+
return execFileSync("git", ["-C", root, ...args], {
|
|
39
|
+
encoding: "utf8",
|
|
40
|
+
timeout: GIT_TIMEOUT_MS,
|
|
41
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
42
|
+
});
|
|
27
43
|
}
|
|
28
44
|
|
|
29
45
|
/** Run git directly inside a directory (worktree ops). */
|
|
30
46
|
function gitIn(dir: string, args: string[]): string {
|
|
31
|
-
return execFileSync("git", [...args], {
|
|
47
|
+
return execFileSync("git", [...args], {
|
|
48
|
+
cwd: dir,
|
|
49
|
+
encoding: "utf8",
|
|
50
|
+
timeout: GIT_TIMEOUT_MS,
|
|
51
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
52
|
+
}).trim();
|
|
32
53
|
}
|
|
33
54
|
|
|
34
55
|
function gitOk(root: string, args: string[]): boolean {
|
|
@@ -40,15 +61,6 @@ function gitOk(root: string, args: string[]): boolean {
|
|
|
40
61
|
}
|
|
41
62
|
}
|
|
42
63
|
|
|
43
|
-
/** git C-quotes porcelain paths containing spaces/specials — undo that. */
|
|
44
|
-
function unquote(path: string): string {
|
|
45
|
-
if (!path.startsWith('"') || !path.endsWith('"')) return path;
|
|
46
|
-
return path
|
|
47
|
-
.slice(1, -1)
|
|
48
|
-
.replace(/\\([0-7]{3})/g, (_, o) => String.fromCharCode(Number.parseInt(o, 8)))
|
|
49
|
-
.replace(/\\(.)/g, (_, c) => ({ n: "\n", t: "\t", r: "\r" })[c as string] ?? c);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
64
|
/** Compare paths through realpath so /var vs /private/var can't diverge. */
|
|
53
65
|
function samePath(a: string, b: string): boolean {
|
|
54
66
|
if (a === b) return true;
|
|
@@ -73,15 +85,22 @@ export function repoRoot(cwd: string): string | undefined {
|
|
|
73
85
|
export function createWorktree(cwd: string, runId: string, taskId: string): Worktree | undefined {
|
|
74
86
|
const root = repoRoot(cwd);
|
|
75
87
|
if (!root) return undefined;
|
|
76
|
-
|
|
77
|
-
|
|
88
|
+
// --git-common-dir, not "<root>/.git": inside a linked worktree or a submodule
|
|
89
|
+
// `.git` is a FILE, and joining it would make `worktree add` fail (silently
|
|
90
|
+
// dropping isolation).
|
|
91
|
+
const container = subagentsDir(root);
|
|
92
|
+
if (!container) return undefined;
|
|
78
93
|
let base: string;
|
|
79
94
|
try {
|
|
80
95
|
base = git(root, ["rev-parse", "HEAD"]); // SHA — detached HEAD stays correct
|
|
81
96
|
} catch {
|
|
82
97
|
return undefined; // broken repo — fall back to in-place
|
|
83
98
|
}
|
|
84
|
-
|
|
99
|
+
const path = join(container, runId, taskId);
|
|
100
|
+
const branch = `${BRANCH_PREFIX}${runId}/${taskId}`;
|
|
101
|
+
// Branch from the recorded SHA, not "HEAD" — a concurrent commit in the main
|
|
102
|
+
// tree between the two would otherwise skew every later diff against base.
|
|
103
|
+
git(root, ["worktree", "add", "-b", branch, path, base]);
|
|
85
104
|
// Deps follow the child into the worktree; anything else the task needs is
|
|
86
105
|
// project content already checked out there.
|
|
87
106
|
const nm = join(root, "node_modules");
|
|
@@ -98,16 +117,33 @@ export function createWorktree(cwd: string, runId: string, taskId: string): Work
|
|
|
98
117
|
/**
|
|
99
118
|
* Commit the child's changes. Stages first, then commits only when something is
|
|
100
119
|
* actually staged — an untracked node_modules symlink must not fake "dirty" and
|
|
101
|
-
* turn into a failed empty commit.
|
|
120
|
+
* turn into a failed empty commit. Returns "committed" | "empty"; a real git
|
|
121
|
+
* failure THROWS, and callers must not delete the checkout in that case (the
|
|
122
|
+
* work would become unreachable once the base-tip branch is reaped as merged).
|
|
102
123
|
*/
|
|
103
|
-
export function commitWorktree(wt: Worktree, message: string):
|
|
104
|
-
commitIn(wt.path, message);
|
|
124
|
+
export function commitWorktree(wt: Worktree, message: string): "committed" | "empty" {
|
|
125
|
+
return commitIn(wt.path, message, wt.branch);
|
|
105
126
|
}
|
|
106
127
|
|
|
107
|
-
function commitIn(dir: string, message: string):
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
128
|
+
function commitIn(dir: string, message: string, expectBranch?: string): "committed" | "empty" {
|
|
129
|
+
// A child that detached HEAD or switched branches would commit somewhere the
|
|
130
|
+
// leader is never told about — refuse rather than report a branch without the work.
|
|
131
|
+
if (expectBranch) {
|
|
132
|
+
// `symbolic-ref` EXITS NON-ZERO on a detached HEAD — catch it rather than
|
|
133
|
+
// letting the raw git failure masquerade as a commit error.
|
|
134
|
+
let head = "detached";
|
|
135
|
+
try {
|
|
136
|
+
head = gitIn(dir, ["symbolic-ref", "--quiet", "--short", "HEAD"]) || "detached";
|
|
137
|
+
} catch {
|
|
138
|
+
head = "detached";
|
|
139
|
+
}
|
|
140
|
+
if (head !== expectBranch) throw new Error(`worktree HEAD is "${head}", expected ${expectBranch}`);
|
|
141
|
+
}
|
|
142
|
+
// Exclude the root dep symlink and any nested node_modules the child created.
|
|
143
|
+
gitIn(dir, ["add", "-A", "--", ".", ":(exclude)node_modules", ":(exclude,glob)**/node_modules/**"]);
|
|
144
|
+
if (gitIn(dir, ["diff", "--cached", "--name-only"]).length === 0) return "empty";
|
|
145
|
+
gitIn(dir, [...COMMIT_CONFIG, "commit", "-m", message, "--no-verify"]);
|
|
146
|
+
return "committed";
|
|
111
147
|
}
|
|
112
148
|
|
|
113
149
|
/** Diffstat + changed files of the branch vs its base SHA. */
|
|
@@ -129,7 +165,28 @@ export function removeByBranch(cwd: string, branch: string): void {
|
|
|
129
165
|
if (!branch.startsWith(BRANCH_PREFIX)) return;
|
|
130
166
|
const root = repoRoot(cwd);
|
|
131
167
|
if (!root) return;
|
|
132
|
-
|
|
168
|
+
const container = subagentsDir(root);
|
|
169
|
+
if (container) dropDir(root, join(container, branch.slice(BRANCH_PREFIX.length)));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** `<git-common-dir>/subagents` — where our worktrees live for this repo.
|
|
173
|
+
* `--path-format` needs git ≥ 2.31; fall back to resolving the relative form. */
|
|
174
|
+
function subagentsDir(root: string): string | undefined {
|
|
175
|
+
try {
|
|
176
|
+
return join(git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]), "subagents");
|
|
177
|
+
} catch {
|
|
178
|
+
try {
|
|
179
|
+
return join(resolve(root, git(root, ["rev-parse", "--git-common-dir"])), "subagents");
|
|
180
|
+
} catch {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Marker path lives BESIDE the checkout, never inside it — a file in the worktree
|
|
187
|
+
* would be staged by `add -A`, committed into the branch, and merged into main. */
|
|
188
|
+
function ownerFile(path: string): string {
|
|
189
|
+
return `${path}.owner`;
|
|
133
190
|
}
|
|
134
191
|
|
|
135
192
|
function dropDir(root: string, path: string): void {
|
|
@@ -138,6 +195,7 @@ function dropDir(root: string, path: string): void {
|
|
|
138
195
|
} catch {
|
|
139
196
|
if (existsSync(path)) rmSync(path, { recursive: true, force: true });
|
|
140
197
|
}
|
|
198
|
+
rmSync(ownerFile(path), { force: true }); // marker lives beside the dir
|
|
141
199
|
prune(root);
|
|
142
200
|
}
|
|
143
201
|
|
|
@@ -150,9 +208,10 @@ function prune(root: string): void {
|
|
|
150
208
|
}
|
|
151
209
|
}
|
|
152
210
|
|
|
153
|
-
/** Is this branch checked out
|
|
211
|
+
/** Is this branch checked out right now? "Unknown" counts as YES (never delete blind). */
|
|
154
212
|
function isCheckedOut(root: string, branch: string): boolean {
|
|
155
|
-
|
|
213
|
+
const branches = worktreeBranches(root);
|
|
214
|
+
return branches === undefined || branches.includes(branch);
|
|
156
215
|
}
|
|
157
216
|
|
|
158
217
|
/**
|
|
@@ -169,34 +228,36 @@ export function cleanupMerged(root: string, opts: { skipBranches?: Set<string>;
|
|
|
169
228
|
.split("\n")
|
|
170
229
|
.map((b) => b.trim().replace(/^[+*]\s*/, ""));
|
|
171
230
|
let cleaned = 0;
|
|
231
|
+
const container = subagentsDir(root);
|
|
172
232
|
for (const branch of merged) {
|
|
173
|
-
if (!branch.startsWith(BRANCH_PREFIX)) continue;
|
|
233
|
+
if (!branch.startsWith(BRANCH_PREFIX) || !container) continue;
|
|
174
234
|
if (opts.skipBranches?.has(branch)) continue; // owned by a live run
|
|
175
235
|
if (isCheckedOut(root, branch)) continue; // fresh re-check, not a stale snapshot
|
|
176
|
-
const path = join(
|
|
236
|
+
const path = join(container, branch.slice(BRANCH_PREFIX.length));
|
|
237
|
+
if (existsSync(path) && ownerAlive(path)) continue; // another session's live checkout
|
|
238
|
+
// Branch FIRST: `-d` refuses anything not truly merged, so a stale "merged"
|
|
239
|
+
// listing can no longer cost us a checkout that still holds work.
|
|
240
|
+
if (!gitOk(root, ["branch", "-d", branch])) continue;
|
|
177
241
|
if (existsSync(path)) {
|
|
178
242
|
try {
|
|
179
243
|
git(root, ["worktree", "remove", "--force", path]);
|
|
180
244
|
} catch {
|
|
181
|
-
|
|
245
|
+
/* branch is gone; a leftover dir is swept later */
|
|
182
246
|
}
|
|
183
247
|
}
|
|
184
|
-
|
|
248
|
+
rmSync(ownerFile(path), { force: true });
|
|
249
|
+
cleaned += 1;
|
|
185
250
|
}
|
|
186
251
|
prune(root);
|
|
187
252
|
return cleaned;
|
|
188
253
|
}
|
|
189
254
|
|
|
190
|
-
/** Branch names currently checked out in any worktree
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
.map((l) => l.slice("branch refs/heads/".length).trim());
|
|
197
|
-
} catch {
|
|
198
|
-
return [];
|
|
199
|
-
}
|
|
255
|
+
/** Branch names currently checked out in any worktree, or undefined when git
|
|
256
|
+
* couldn't be asked — callers MUST treat undefined as "unknown", never as "none",
|
|
257
|
+
* or they will happily delete live checkouts. */
|
|
258
|
+
function worktreeBranches(root: string): string[] | undefined {
|
|
259
|
+
const listing = worktreeListing(root);
|
|
260
|
+
return listing?.filter((l) => l.startsWith("branch ")).map((l) => l.slice("branch refs/heads/".length));
|
|
200
261
|
}
|
|
201
262
|
|
|
202
263
|
/**
|
|
@@ -204,17 +265,20 @@ function worktreeBranches(root: string): string[] {
|
|
|
204
265
|
* (nothing of ours can be live yet). Commit whatever the dead child left so the
|
|
205
266
|
* branch keeps it, then drop the dir. Branches always survive.
|
|
206
267
|
*/
|
|
207
|
-
export function reapDeadWorktrees(root: string): number {
|
|
268
|
+
export function reapDeadWorktrees(root: string, isLive: (path: string) => boolean = () => false): number {
|
|
208
269
|
root = realpathSync(root);
|
|
209
|
-
const sub =
|
|
210
|
-
if (!existsSync(sub)) return 0;
|
|
270
|
+
const sub = subagentsDir(root);
|
|
271
|
+
if (!sub || !existsSync(sub)) return 0;
|
|
272
|
+
const registered = worktreePaths(root);
|
|
273
|
+
if (!registered) return 0; // listing failed — touch nothing
|
|
211
274
|
let reaped = 0;
|
|
212
|
-
for (const path of
|
|
275
|
+
for (const path of registered) {
|
|
213
276
|
if (!isInside(path, sub)) continue; // not ours
|
|
277
|
+
if (isLive(path)) continue; // another pi session owns it
|
|
214
278
|
try {
|
|
215
279
|
commitIn(path, "subagent (recovered after interrupted session)");
|
|
216
280
|
} catch {
|
|
217
|
-
|
|
281
|
+
continue; // git failed — never drop a dir whose work isn't on the branch
|
|
218
282
|
}
|
|
219
283
|
dropDir(root, path);
|
|
220
284
|
reaped += 1;
|
|
@@ -222,6 +286,51 @@ export function reapDeadWorktrees(root: string): number {
|
|
|
222
286
|
return reaped;
|
|
223
287
|
}
|
|
224
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Ownership marker: a live worktree gets `<dir>/.subagent-owner` holding the
|
|
291
|
+
* owning pid. Another pi session must not reap a checkout whose owner is alive.
|
|
292
|
+
*/
|
|
293
|
+
export function claimWorktree(wt: Worktree): void {
|
|
294
|
+
try {
|
|
295
|
+
writeFileSync(
|
|
296
|
+
ownerFile(wt.path),
|
|
297
|
+
JSON.stringify({ pid: process.pid, host: hostname(), boot: bootId(), at: Date.now() }),
|
|
298
|
+
);
|
|
299
|
+
} catch {
|
|
300
|
+
/* best-effort: worst case another session reaps it after a crash */
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* True when the worktree is claimed by a process that still exists HERE.
|
|
306
|
+
* Guards against pid reuse across reboots (boot id) and other hosts (hostname);
|
|
307
|
+
* EPERM means the pid exists under another user — alive, not reapable.
|
|
308
|
+
*/
|
|
309
|
+
export function ownerAlive(path: string): boolean {
|
|
310
|
+
let marker: { pid?: number; host?: string; boot?: string };
|
|
311
|
+
try {
|
|
312
|
+
marker = JSON.parse(readFileSync(ownerFile(path), "utf8"));
|
|
313
|
+
} catch {
|
|
314
|
+
return false; // no marker (or unreadable) → nobody claims it
|
|
315
|
+
}
|
|
316
|
+
const pid = marker.pid;
|
|
317
|
+
if (!pid || !Number.isFinite(pid) || pid <= 0) return false;
|
|
318
|
+
if (marker.host !== hostname()) return true; // another machine's checkout — never ours to reap
|
|
319
|
+
if (marker.boot !== bootId()) return false; // pre-reboot pid: reuse is near-certain
|
|
320
|
+
if (pid === process.pid) return true;
|
|
321
|
+
try {
|
|
322
|
+
process.kill(pid, 0);
|
|
323
|
+
return true;
|
|
324
|
+
} catch (err) {
|
|
325
|
+
return (err as NodeJS.ErrnoException)?.code === "EPERM"; // exists, other user
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Stable per-boot id, so a recycled pid from before a reboot can't look alive. */
|
|
330
|
+
function bootId(): string {
|
|
331
|
+
return String(Math.floor(Date.now() / 1000 - Math.floor(uptime())));
|
|
332
|
+
}
|
|
333
|
+
|
|
225
334
|
/**
|
|
226
335
|
* Remove worktree dirs that git no longer knows about (partial-crash leftovers).
|
|
227
336
|
* Registered worktrees are never touched here — `reapDeadWorktrees` owns those,
|
|
@@ -229,27 +338,42 @@ export function reapDeadWorktrees(root: string): number {
|
|
|
229
338
|
*/
|
|
230
339
|
export function sweepStale(root: string): void {
|
|
231
340
|
root = realpathSync(root);
|
|
232
|
-
const sub =
|
|
233
|
-
if (!existsSync(sub)) return;
|
|
341
|
+
const sub = subagentsDir(root);
|
|
342
|
+
if (!sub || !existsSync(sub)) return;
|
|
234
343
|
const registered = worktreePaths(root);
|
|
344
|
+
// Unknown registration = every dir might be live. Deleting here would be the
|
|
345
|
+
// single most destructive thing this module can do; bail instead.
|
|
346
|
+
if (!registered) return;
|
|
235
347
|
for (const runDir of readDirs(sub)) {
|
|
236
348
|
for (const taskDir of readDirs(join(sub, runDir))) {
|
|
237
349
|
const dir = join(sub, runDir, taskDir);
|
|
238
350
|
if (registered.some((p) => samePath(p, dir))) continue; // live/registered worktree
|
|
351
|
+
if (ownerAlive(dir)) continue; // claimed by a running session
|
|
239
352
|
rmSync(dir, { recursive: true, force: true });
|
|
353
|
+
rmSync(ownerFile(dir), { force: true });
|
|
240
354
|
}
|
|
241
355
|
}
|
|
242
356
|
prune(root);
|
|
243
357
|
}
|
|
244
358
|
|
|
245
|
-
|
|
359
|
+
/** Registered worktree paths, or undefined when the listing failed (see above). */
|
|
360
|
+
function worktreePaths(root: string): string[] | undefined {
|
|
361
|
+
const listing = worktreeListing(root);
|
|
362
|
+
return listing?.filter((l) => l.startsWith("worktree ")).map((l) => l.slice("worktree ".length));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** `worktree list --porcelain -z`; `-z` needs git ≥ 2.36, so fall back to the
|
|
366
|
+
* newline form (which C-quotes exotic paths — those simply won't match, and a
|
|
367
|
+
* non-match is safe: it only ever means "treat as live"). */
|
|
368
|
+
function worktreeListing(root: string): string[] | undefined {
|
|
246
369
|
try {
|
|
247
|
-
return
|
|
248
|
-
.split("\n")
|
|
249
|
-
.filter((l) => l.startsWith("worktree "))
|
|
250
|
-
.map((l) => unquote(l.slice("worktree ".length).trim()));
|
|
370
|
+
return gitRaw(root, ["worktree", "list", "--porcelain", "-z"]).split("\0").filter(Boolean);
|
|
251
371
|
} catch {
|
|
252
|
-
|
|
372
|
+
try {
|
|
373
|
+
return git(root, ["worktree", "list", "--porcelain"]).split("\n").filter(Boolean);
|
|
374
|
+
} catch {
|
|
375
|
+
return undefined; // unknown — callers must bail out
|
|
376
|
+
}
|
|
253
377
|
}
|
|
254
378
|
}
|
|
255
379
|
|