@arhen/pi-core-subagent 1.3.31 → 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/package.json +1 -1
- package/src/index.ts +6 -6
- package/src/manager.ts +121 -65
- package/src/worktree.ts +74 -33
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, 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 */
|
package/src/manager.ts
CHANGED
|
@@ -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,8 @@ 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
|
|
61
64
|
const READONLY_TOOLS = ["read", "grep", "find", "ls"];
|
|
62
65
|
const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write"];
|
|
63
66
|
/** Tools that can mutate the tree — their presence is what earns a worktree. */
|
|
@@ -221,7 +224,10 @@ export interface ParkedMsg {
|
|
|
221
224
|
|
|
222
225
|
export class SubagentManager {
|
|
223
226
|
private runs = new Map<string, RunSnapshot>();
|
|
224
|
-
|
|
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>>();
|
|
225
231
|
private pendingReplies = new Map<string, PendingReply>();
|
|
226
232
|
private liveChildren = new Map<
|
|
227
233
|
string,
|
|
@@ -296,8 +302,12 @@ export class SubagentManager {
|
|
|
296
302
|
child.dispose();
|
|
297
303
|
}
|
|
298
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();
|
|
299
308
|
this.runs.clear();
|
|
300
309
|
this.settlers.clear();
|
|
310
|
+
this.settleWaiters.clear();
|
|
301
311
|
this.pendingReplies.clear();
|
|
302
312
|
this.runControllers.clear();
|
|
303
313
|
this.mailboxes = createMailbox();
|
|
@@ -508,31 +518,24 @@ export class SubagentManager {
|
|
|
508
518
|
private makeChildHandlers(run: RunSnapshot, task: TaskSnapshot, ctx: ExtensionContext): ChildHandlers {
|
|
509
519
|
return {
|
|
510
520
|
onAskParent: async (_taskId, question) => {
|
|
521
|
+
const key = `${run.id}:${task.id}`;
|
|
511
522
|
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
|
-
}
|
|
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 });
|
|
528
530
|
}
|
|
529
|
-
|
|
530
|
-
//
|
|
531
|
-
|
|
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);
|
|
532
535
|
try {
|
|
533
|
-
const reply = await this.awaitParentReply(run.id, task.id);
|
|
536
|
+
const reply = await this.awaitParentReply(run.id, task.id, PARENT_REPLY_TIMEOUT_MS);
|
|
534
537
|
this.updateTask(run, task, { status: "running" }, ctx);
|
|
535
|
-
this.liveChildren.get(
|
|
538
|
+
this.liveChildren.get(key)?.touchWatchdog();
|
|
536
539
|
return reply;
|
|
537
540
|
} finally {
|
|
538
541
|
clearInterval(keepAlive);
|
|
@@ -572,9 +575,24 @@ export class SubagentManager {
|
|
|
572
575
|
onPollMailbox: (taskId) => this.mailboxes.poll(`${run.id}:${taskId}`),
|
|
573
576
|
};
|
|
574
577
|
}
|
|
575
|
-
private awaitParentReply(runId: string, taskId: string): Promise<string> {
|
|
578
|
+
private awaitParentReply(runId: string, taskId: string, timeoutMs = 0): Promise<string> {
|
|
579
|
+
const key = `${runId}:${taskId}`;
|
|
576
580
|
return new Promise<string>((resolve) => {
|
|
577
|
-
|
|
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
|
+
});
|
|
578
596
|
});
|
|
579
597
|
}
|
|
580
598
|
deliverReply(runId: string, taskId: string, message: string): boolean {
|
|
@@ -683,8 +701,31 @@ export class SubagentManager {
|
|
|
683
701
|
const canWrite = baseTools.some((t) => WRITE_CAPABLE.includes(t));
|
|
684
702
|
|
|
685
703
|
// Write agents run in an isolated git worktree (branch subagents/<run>/<task>);
|
|
686
|
-
//
|
|
687
|
-
//
|
|
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.
|
|
707
|
+
let model: Model<Api> | undefined;
|
|
708
|
+
try {
|
|
709
|
+
model = resolveChildModel(ctx, file?.model ?? input.model);
|
|
710
|
+
validateThinking(model, thinking);
|
|
711
|
+
} catch (err) {
|
|
712
|
+
this.updateTask(
|
|
713
|
+
run,
|
|
714
|
+
task,
|
|
715
|
+
{
|
|
716
|
+
status: "failed",
|
|
717
|
+
error: err instanceof Error ? err.message : String(err),
|
|
718
|
+
endedAt: Date.now(),
|
|
719
|
+
},
|
|
720
|
+
ctx,
|
|
721
|
+
onUpdate,
|
|
722
|
+
);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
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.
|
|
688
729
|
let wt: Worktree | undefined;
|
|
689
730
|
if (canWrite) {
|
|
690
731
|
try {
|
|
@@ -694,39 +735,23 @@ export class SubagentManager {
|
|
|
694
735
|
}
|
|
695
736
|
}
|
|
696
737
|
// Map a per-task cwd subpath into the worktree so relative paths stay correct.
|
|
697
|
-
//
|
|
698
|
-
//
|
|
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).
|
|
699
741
|
let childCwd = wt?.path ?? task.cwd;
|
|
700
742
|
if (wt) {
|
|
701
|
-
const rel = relative(wt.root, safeRealPath(task.cwd));
|
|
743
|
+
const rel = relative(safeRealPath(wt.root), safeRealPath(task.cwd));
|
|
702
744
|
if (rel.startsWith("..")) {
|
|
703
745
|
removeWorktree(wt);
|
|
704
746
|
wt = undefined;
|
|
747
|
+
childCwd = task.cwd;
|
|
705
748
|
} else if (rel && rel !== ".") {
|
|
706
749
|
childCwd = join(wt.path, rel);
|
|
707
750
|
}
|
|
708
751
|
}
|
|
709
|
-
if (wt)
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
// fails the TASK with a helpful message, not the whole run.
|
|
713
|
-
let model: Model<Api> | undefined;
|
|
714
|
-
try {
|
|
715
|
-
model = resolveChildModel(ctx, file?.model ?? input.model);
|
|
716
|
-
validateThinking(model, thinking);
|
|
717
|
-
} catch (err) {
|
|
718
|
-
this.updateTask(
|
|
719
|
-
run,
|
|
720
|
-
task,
|
|
721
|
-
{
|
|
722
|
-
status: "failed",
|
|
723
|
-
error: err instanceof Error ? err.message : String(err),
|
|
724
|
-
endedAt: Date.now(),
|
|
725
|
-
},
|
|
726
|
-
ctx,
|
|
727
|
-
onUpdate,
|
|
728
|
-
);
|
|
729
|
-
return;
|
|
752
|
+
if (wt) {
|
|
753
|
+
claimWorktree(wt); // pid marker: another pi session must not reap this
|
|
754
|
+
this.liveWorktrees.set(`${run.id}:${task.id}`, wt);
|
|
730
755
|
}
|
|
731
756
|
|
|
732
757
|
this.updateTask(
|
|
@@ -746,6 +771,9 @@ export class SubagentManager {
|
|
|
746
771
|
onUpdate,
|
|
747
772
|
);
|
|
748
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;
|
|
749
777
|
let child: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
|
|
750
778
|
let unsubscribe: (() => void) | undefined;
|
|
751
779
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -865,6 +893,7 @@ export class SubagentManager {
|
|
|
865
893
|
// its work: the error is reported, the status stays completed.
|
|
866
894
|
try {
|
|
867
895
|
commitWorktree(wt, `subagent ${task.agent}: ${truncateText(input.task, 60)}`);
|
|
896
|
+
keepWorktreeDir = true; // committed — dir stays until the leader merges
|
|
868
897
|
const { stat, files } = branchDiff(wt);
|
|
869
898
|
this.updateTask(
|
|
870
899
|
run,
|
|
@@ -874,12 +903,15 @@ export class SubagentManager {
|
|
|
874
903
|
onUpdate,
|
|
875
904
|
);
|
|
876
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;
|
|
877
909
|
this.updateTask(
|
|
878
910
|
run,
|
|
879
911
|
task,
|
|
880
912
|
{
|
|
881
913
|
branch: wt.branch,
|
|
882
|
-
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)}`,
|
|
883
915
|
},
|
|
884
916
|
ctx,
|
|
885
917
|
onUpdate,
|
|
@@ -919,20 +951,23 @@ export class SubagentManager {
|
|
|
919
951
|
watchdog.dispose();
|
|
920
952
|
if (timeout) clearTimeout(timeout);
|
|
921
953
|
child?.dispose();
|
|
922
|
-
this.liveWorktrees.delete(key);
|
|
923
954
|
// Failed/aborted: let the aborted child's last writes land (its tools may
|
|
924
955
|
// still be unwinding), commit whatever partial work exists so the branch
|
|
925
|
-
// really keeps it, then drop the checkout dir.
|
|
956
|
+
// really keeps it, then drop the checkout dir. A commit FAILURE keeps the
|
|
957
|
+
// dir — dropping it would make the work unreachable.
|
|
926
958
|
if (wt && task.status !== "completed") {
|
|
927
959
|
await new Promise((r) => setTimeout(r, 250));
|
|
928
960
|
try {
|
|
929
961
|
commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
|
|
930
962
|
} catch {
|
|
931
|
-
|
|
963
|
+
keepWorktreeDir = true;
|
|
932
964
|
}
|
|
933
965
|
this.updateTask(run, task, { branch: wt.branch }, ctx, onUpdate);
|
|
934
|
-
removeWorktree(wt);
|
|
935
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);
|
|
936
971
|
}
|
|
937
972
|
}
|
|
938
973
|
|
|
@@ -1020,7 +1055,7 @@ export class SubagentManager {
|
|
|
1020
1055
|
});
|
|
1021
1056
|
this.turnActivity = true;
|
|
1022
1057
|
this.runs.set(run.id, run);
|
|
1023
|
-
this.settlers.set(run.id,
|
|
1058
|
+
this.settlers.set(run.id, true);
|
|
1024
1059
|
this.runControllers.set(run.id, new AbortController());
|
|
1025
1060
|
for (const task of run.tasks) this.mailboxes.open(`${run.id}:${task.id}`);
|
|
1026
1061
|
this.emit("subagent:run-created", { run: cloneRun(run) });
|
|
@@ -1195,6 +1230,8 @@ export class SubagentManager {
|
|
|
1195
1230
|
task.status = "aborted";
|
|
1196
1231
|
task.error = task.error || "Canceled from peek";
|
|
1197
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}`);
|
|
1198
1235
|
this.liveChildren.get(`${runId}:${taskId}`)?.abort();
|
|
1199
1236
|
this.mailboxes.close(`${runId}:${taskId}`);
|
|
1200
1237
|
if (ctx) this.flushWidget(run, ctx);
|
|
@@ -1208,6 +1245,14 @@ export class SubagentManager {
|
|
|
1208
1245
|
if (TERMINAL.includes(run.status)) return { aborted: 0 }; // never corrupt a finished run
|
|
1209
1246
|
let aborted = 0;
|
|
1210
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
|
+
}
|
|
1211
1256
|
for (const [key, child] of this.liveChildren) {
|
|
1212
1257
|
if (key.startsWith(`${runId}:`)) {
|
|
1213
1258
|
child.abort();
|
|
@@ -1233,12 +1278,15 @@ export class SubagentManager {
|
|
|
1233
1278
|
return { aborted };
|
|
1234
1279
|
}
|
|
1235
1280
|
|
|
1236
|
-
/** Settle-and-delete:
|
|
1281
|
+
/** Settle-and-delete: every awaiter resolves once, then the set is dropped. */
|
|
1237
1282
|
private settleRun(runId: string, run: RunSnapshot): void {
|
|
1238
|
-
|
|
1239
|
-
if (!s) return;
|
|
1283
|
+
if (!this.settlers.has(runId)) return;
|
|
1240
1284
|
this.settlers.delete(runId);
|
|
1241
|
-
|
|
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);
|
|
1242
1290
|
}
|
|
1243
1291
|
|
|
1244
1292
|
/** Child→leader messages collected while the parent is parked in await_subagent. */
|
|
@@ -1268,14 +1316,22 @@ export class SubagentManager {
|
|
|
1268
1316
|
}
|
|
1269
1317
|
const msgs: ParkedMsg[] = [];
|
|
1270
1318
|
const settled = new Promise<RunSnapshot | undefined>((resolve) => {
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
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);
|
|
1274
1324
|
resolve(r);
|
|
1275
|
-
}
|
|
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);
|
|
1276
1332
|
// A child→leader message while parked wakes the wait: the leader gets it
|
|
1277
1333
|
// IN the await result, no steering queue, no turn boundary needed.
|
|
1278
|
-
this.parked.set(runId, { msgs, wake: () =>
|
|
1334
|
+
this.parked.set(runId, { msgs, wake: () => waiter(cloneRun(run)) });
|
|
1279
1335
|
});
|
|
1280
1336
|
if (timeoutMs !== undefined && timeoutMs > 0) {
|
|
1281
1337
|
return Promise.race([
|
package/src/worktree.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
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";
|
|
13
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
|
|
16
16
|
export interface Worktree {
|
|
@@ -40,15 +40,6 @@ function gitOk(root: string, args: string[]): boolean {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
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
43
|
/** Compare paths through realpath so /var vs /private/var can't diverge. */
|
|
53
44
|
function samePath(a: string, b: string): boolean {
|
|
54
45
|
if (a === b) return true;
|
|
@@ -73,14 +64,20 @@ export function repoRoot(cwd: string): string | undefined {
|
|
|
73
64
|
export function createWorktree(cwd: string, runId: string, taskId: string): Worktree | undefined {
|
|
74
65
|
const root = repoRoot(cwd);
|
|
75
66
|
if (!root) return undefined;
|
|
76
|
-
|
|
77
|
-
|
|
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;
|
|
78
71
|
let base: string;
|
|
79
72
|
try {
|
|
73
|
+
const common = git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
74
|
+
container = join(common, "subagents");
|
|
80
75
|
base = git(root, ["rev-parse", "HEAD"]); // SHA — detached HEAD stays correct
|
|
81
76
|
} catch {
|
|
82
77
|
return undefined; // broken repo — fall back to in-place
|
|
83
78
|
}
|
|
79
|
+
const path = join(container, runId, taskId);
|
|
80
|
+
const branch = `${BRANCH_PREFIX}${runId}/${taskId}`;
|
|
84
81
|
git(root, ["worktree", "add", "-b", branch, path, "HEAD"]);
|
|
85
82
|
// Deps follow the child into the worktree; anything else the task needs is
|
|
86
83
|
// project content already checked out there.
|
|
@@ -98,16 +95,20 @@ export function createWorktree(cwd: string, runId: string, taskId: string): Work
|
|
|
98
95
|
/**
|
|
99
96
|
* Commit the child's changes. Stages first, then commits only when something is
|
|
100
97
|
* actually staged — an untracked node_modules symlink must not fake "dirty" and
|
|
101
|
-
* turn into a failed empty commit.
|
|
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).
|
|
102
101
|
*/
|
|
103
|
-
export function commitWorktree(wt: Worktree, message: string):
|
|
104
|
-
commitIn(wt.path, message);
|
|
102
|
+
export function commitWorktree(wt: Worktree, message: string): "committed" | "empty" {
|
|
103
|
+
return commitIn(wt.path, message);
|
|
105
104
|
}
|
|
106
105
|
|
|
107
|
-
function commitIn(dir: string, message: string):
|
|
108
|
-
|
|
109
|
-
|
|
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
110
|
gitIn(dir, ["commit", "-m", message, "--no-verify"]);
|
|
111
|
+
return "committed";
|
|
111
112
|
}
|
|
112
113
|
|
|
113
114
|
/** Diffstat + changed files of the branch vs its base SHA. */
|
|
@@ -129,7 +130,17 @@ export function removeByBranch(cwd: string, branch: string): void {
|
|
|
129
130
|
if (!branch.startsWith(BRANCH_PREFIX)) return;
|
|
130
131
|
const root = repoRoot(cwd);
|
|
131
132
|
if (!root) return;
|
|
132
|
-
|
|
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
|
+
}
|
|
133
144
|
}
|
|
134
145
|
|
|
135
146
|
function dropDir(root: string, path: string): void {
|
|
@@ -169,11 +180,13 @@ export function cleanupMerged(root: string, opts: { skipBranches?: Set<string>;
|
|
|
169
180
|
.split("\n")
|
|
170
181
|
.map((b) => b.trim().replace(/^[+*]\s*/, ""));
|
|
171
182
|
let cleaned = 0;
|
|
183
|
+
const container = subagentsDir(root);
|
|
172
184
|
for (const branch of merged) {
|
|
173
|
-
if (!branch.startsWith(BRANCH_PREFIX)) continue;
|
|
185
|
+
if (!branch.startsWith(BRANCH_PREFIX) || !container) continue;
|
|
174
186
|
if (opts.skipBranches?.has(branch)) continue; // owned by a live run
|
|
175
187
|
if (isCheckedOut(root, branch)) continue; // fresh re-check, not a stale snapshot
|
|
176
|
-
const path = join(
|
|
188
|
+
const path = join(container, branch.slice(BRANCH_PREFIX.length));
|
|
189
|
+
if (existsSync(path) && ownerAlive(path)) continue; // another session's live checkout
|
|
177
190
|
if (existsSync(path)) {
|
|
178
191
|
try {
|
|
179
192
|
git(root, ["worktree", "remove", "--force", path]);
|
|
@@ -190,10 +203,10 @@ export function cleanupMerged(root: string, opts: { skipBranches?: Set<string>;
|
|
|
190
203
|
/** Branch names currently checked out in any worktree (incl. the main one). */
|
|
191
204
|
function worktreeBranches(root: string): string[] {
|
|
192
205
|
try {
|
|
193
|
-
return git(root, ["worktree", "list", "--porcelain"])
|
|
194
|
-
.split("\
|
|
206
|
+
return git(root, ["worktree", "list", "--porcelain", "-z"])
|
|
207
|
+
.split("\0")
|
|
195
208
|
.filter((l) => l.startsWith("branch "))
|
|
196
|
-
.map((l) => l.slice("branch refs/heads/".length)
|
|
209
|
+
.map((l) => l.slice("branch refs/heads/".length));
|
|
197
210
|
} catch {
|
|
198
211
|
return [];
|
|
199
212
|
}
|
|
@@ -204,17 +217,18 @@ function worktreeBranches(root: string): string[] {
|
|
|
204
217
|
* (nothing of ours can be live yet). Commit whatever the dead child left so the
|
|
205
218
|
* branch keeps it, then drop the dir. Branches always survive.
|
|
206
219
|
*/
|
|
207
|
-
export function reapDeadWorktrees(root: string): number {
|
|
220
|
+
export function reapDeadWorktrees(root: string, isLive: (path: string) => boolean = () => false): number {
|
|
208
221
|
root = realpathSync(root);
|
|
209
|
-
const sub =
|
|
210
|
-
if (!existsSync(sub)) return 0;
|
|
222
|
+
const sub = subagentsDir(root);
|
|
223
|
+
if (!sub || !existsSync(sub)) return 0;
|
|
211
224
|
let reaped = 0;
|
|
212
225
|
for (const path of worktreePaths(root)) {
|
|
213
226
|
if (!isInside(path, sub)) continue; // not ours
|
|
227
|
+
if (isLive(path)) continue; // another pi session owns it
|
|
214
228
|
try {
|
|
215
229
|
commitIn(path, "subagent (recovered after interrupted session)");
|
|
216
230
|
} catch {
|
|
217
|
-
|
|
231
|
+
continue; // git failed — never drop a dir whose work isn't on the branch
|
|
218
232
|
}
|
|
219
233
|
dropDir(root, path);
|
|
220
234
|
reaped += 1;
|
|
@@ -222,6 +236,31 @@ export function reapDeadWorktrees(root: string): number {
|
|
|
222
236
|
return reaped;
|
|
223
237
|
}
|
|
224
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
|
+
|
|
225
264
|
/**
|
|
226
265
|
* Remove worktree dirs that git no longer knows about (partial-crash leftovers).
|
|
227
266
|
* Registered worktrees are never touched here — `reapDeadWorktrees` owns those,
|
|
@@ -229,25 +268,27 @@ export function reapDeadWorktrees(root: string): number {
|
|
|
229
268
|
*/
|
|
230
269
|
export function sweepStale(root: string): void {
|
|
231
270
|
root = realpathSync(root);
|
|
232
|
-
const sub =
|
|
233
|
-
if (!existsSync(sub)) return;
|
|
271
|
+
const sub = subagentsDir(root);
|
|
272
|
+
if (!sub || !existsSync(sub)) return;
|
|
234
273
|
const registered = worktreePaths(root);
|
|
235
274
|
for (const runDir of readDirs(sub)) {
|
|
236
275
|
for (const taskDir of readDirs(join(sub, runDir))) {
|
|
237
276
|
const dir = join(sub, runDir, taskDir);
|
|
238
277
|
if (registered.some((p) => samePath(p, dir))) continue; // live/registered worktree
|
|
278
|
+
if (ownerAlive(dir)) continue; // claimed by a running session
|
|
239
279
|
rmSync(dir, { recursive: true, force: true });
|
|
240
280
|
}
|
|
241
281
|
}
|
|
242
282
|
prune(root);
|
|
243
283
|
}
|
|
244
284
|
|
|
285
|
+
/** Registered worktree paths. `-z` keeps paths verbatim (no C-quoting to undo). */
|
|
245
286
|
function worktreePaths(root: string): string[] {
|
|
246
287
|
try {
|
|
247
|
-
return git(root, ["worktree", "list", "--porcelain"])
|
|
248
|
-
.split("\
|
|
288
|
+
return git(root, ["worktree", "list", "--porcelain", "-z"])
|
|
289
|
+
.split("\0")
|
|
249
290
|
.filter((l) => l.startsWith("worktree "))
|
|
250
|
-
.map((l) =>
|
|
291
|
+
.map((l) => l.slice("worktree ".length));
|
|
251
292
|
} catch {
|
|
252
293
|
return [];
|
|
253
294
|
}
|