@arhen/pi-core-subagent 1.3.29 → 1.3.31
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 +45 -8
- package/src/manager.ts +147 -38
- package/src/worktree.ts +153 -38
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.31",
|
|
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
|
@@ -18,7 +18,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
18
18
|
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
19
19
|
import { compactLines, formatUsage, makeSummary, statusIcon, taskLine, truncateText } from "./format.ts";
|
|
20
20
|
import { waveNotation } from "./graph.ts";
|
|
21
|
-
import { cloneRun, SubagentManager } from "./manager.ts";
|
|
21
|
+
import { cloneRun, type ParkedMsg, SubagentManager } from "./manager.ts";
|
|
22
22
|
import { createPeekPane, type PeekTask } from "./peek.ts";
|
|
23
23
|
import {
|
|
24
24
|
AwaitParam,
|
|
@@ -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 { repoRoot, sweepStale } from "./worktree.ts";
|
|
33
|
+
import { cleanupMerged, reapDeadWorktrees, repoRoot, sweepStale } from "./worktree.ts";
|
|
34
34
|
|
|
35
35
|
export default function (pi: ExtensionAPI) {
|
|
36
36
|
const manager = new SubagentManager(pi);
|
|
@@ -111,15 +111,31 @@ export default function (pi: ExtensionAPI) {
|
|
|
111
111
|
pi.on("session_start", async (_event, ctx) => {
|
|
112
112
|
await manager.restoreFromSidecar(ctx);
|
|
113
113
|
// Crash leftovers: remove stale worktree dirs (branches survive for merging).
|
|
114
|
-
|
|
114
|
+
// Also reap branches the leader merged in a previous session (H1: cleanup can't
|
|
115
|
+
// fire at run end — the leader merges after).
|
|
116
|
+
const roots = new Set<string>();
|
|
117
|
+
const cwdRoot = repoRoot(ctx.cwd);
|
|
118
|
+
if (cwdRoot) roots.add(cwdRoot);
|
|
115
119
|
for (const run of manager.listRuns()) {
|
|
116
120
|
for (const task of run.tasks) {
|
|
117
121
|
if (task.branch) {
|
|
118
122
|
const root = repoRoot(task.cwd);
|
|
119
|
-
if (root)
|
|
123
|
+
if (root) roots.add(root);
|
|
120
124
|
}
|
|
121
125
|
}
|
|
122
126
|
}
|
|
127
|
+
for (const root of roots) {
|
|
128
|
+
try {
|
|
129
|
+
// Nothing of ours can be live at session start: every registered subagent
|
|
130
|
+
// worktree is a crash leftover — commit its work, keep the branch, drop the
|
|
131
|
+
// dir. Then reap merged branches and dirs git no longer tracks.
|
|
132
|
+
reapDeadWorktrees(root);
|
|
133
|
+
cleanupMerged(root);
|
|
134
|
+
sweepStale(root);
|
|
135
|
+
} catch {
|
|
136
|
+
/* recovery is best-effort — never block session start */
|
|
137
|
+
}
|
|
138
|
+
}
|
|
123
139
|
});
|
|
124
140
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
125
141
|
if (ctx?.hasUI) {
|
|
@@ -158,13 +174,34 @@ export default function (pi: ExtensionAPI) {
|
|
|
158
174
|
const typed = params as SubagentParamsShape;
|
|
159
175
|
const details = manager.startInBackground(typed, ctx);
|
|
160
176
|
if (typed.autoAwait) {
|
|
161
|
-
// awaitRun wakes on every child→leader message (ask/notify/done)
|
|
162
|
-
//
|
|
177
|
+
// awaitRun wakes on every child→leader message (ask/notify/done). Re-park
|
|
178
|
+
// until terminal — but surface an ask_parent: the child is waiting on the
|
|
179
|
+
// leader, so break out, reply via reply_subagent, then await again.
|
|
163
180
|
let run = details.run;
|
|
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[] = [];
|
|
164
184
|
while (!TERMINAL.includes(run.status)) {
|
|
165
|
-
|
|
185
|
+
const awaited = await manager.awaitRun(details.run.id);
|
|
186
|
+
if (!awaited) break; // run gone (session shutdown) — stop, no busy-spin
|
|
187
|
+
if (awaited.run) run = awaited.run;
|
|
188
|
+
intercom.push(...awaited.intercom);
|
|
189
|
+
if (awaited.intercom.some((m) => m.kind === "ask")) break;
|
|
166
190
|
}
|
|
167
|
-
|
|
191
|
+
const asked = intercom.find((m) => m.kind === "ask");
|
|
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");
|
|
204
|
+
return { content: [{ type: "text", text }], details: { run } };
|
|
168
205
|
}
|
|
169
206
|
return {
|
|
170
207
|
content: [
|
package/src/manager.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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
|
-
import { join } from "node:path";
|
|
4
|
+
import { join, relative } 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 {
|
|
@@ -48,10 +48,8 @@ import {
|
|
|
48
48
|
cleanupMerged,
|
|
49
49
|
commitWorktree,
|
|
50
50
|
createWorktree,
|
|
51
|
-
removeByBranch,
|
|
52
51
|
removeWorktree,
|
|
53
52
|
repoRoot,
|
|
54
|
-
sweepStale,
|
|
55
53
|
type Worktree,
|
|
56
54
|
} from "./worktree.ts";
|
|
57
55
|
|
|
@@ -62,6 +60,10 @@ const DEFAULT_RUNTIME_MS = 0;
|
|
|
62
60
|
const DEFAULT_STALL_MS = 180_000; // 3 min: long model thinking streams emit no events, but they're not stalled.
|
|
63
61
|
const READONLY_TOOLS = ["read", "grep", "find", "ls"];
|
|
64
62
|
const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write"];
|
|
63
|
+
/** Tools that can mutate the tree — their presence is what earns a worktree. */
|
|
64
|
+
const WRITE_CAPABLE = ["bash", "edit", "write"];
|
|
65
|
+
/** Task ids become git refs + filesystem paths. */
|
|
66
|
+
const SAFE_TASK_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
65
67
|
const WIDGET_THROTTLE_MS = 150;
|
|
66
68
|
|
|
67
69
|
// ── helpers ──────────────────────────────────────────────────────────────
|
|
@@ -84,6 +86,14 @@ function aggregateUsage(tasks: TaskSnapshot[]): UsageStats {
|
|
|
84
86
|
}
|
|
85
87
|
return total;
|
|
86
88
|
}
|
|
89
|
+
/** realpath when possible; the raw path otherwise (cwd may not exist yet). */
|
|
90
|
+
function safeRealPath(path: string): string {
|
|
91
|
+
try {
|
|
92
|
+
return realpathSync(path);
|
|
93
|
+
} catch {
|
|
94
|
+
return path;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
87
97
|
function getParentSessionFile(ctx: ExtensionContext): string | undefined {
|
|
88
98
|
try {
|
|
89
99
|
return ctx.sessionManager.getSessionFile?.();
|
|
@@ -218,6 +228,9 @@ export class SubagentManager {
|
|
|
218
228
|
{ abort: () => void; dispose: () => void; touchWatchdog: () => void; steer: (message: string) => void }
|
|
219
229
|
>();
|
|
220
230
|
private mailboxes: Mailbox = createMailbox();
|
|
231
|
+
/** Live worktrees by `${runId}:${taskId}` — lets cancel drop dirs and keeps
|
|
232
|
+
* cleanup from touching a branch that a running child owns. */
|
|
233
|
+
private liveWorktrees = new Map<string, Worktree>();
|
|
221
234
|
private runControllers = new Map<string, AbortController>();
|
|
222
235
|
private widgetTimers = new Map<string, ReturnType<typeof setTimeout>>(); // per-run stream throttle
|
|
223
236
|
private widgetRuns: RunSnapshot[] = [];
|
|
@@ -500,7 +513,18 @@ export class SubagentManager {
|
|
|
500
513
|
// While the leader is parked in await_subagent, the question rides the wait
|
|
501
514
|
// instead of the steering queue — no boundary needed, no starvation.
|
|
502
515
|
if (this.collectParked(run.id, { kind: "ask", taskId: task.id, agent: task.agent, text: question })) {
|
|
503
|
-
|
|
516
|
+
// The parked leader gets the question inside its await result and answers
|
|
517
|
+
// with reply_subagent — so the pending entry MUST exist here too, or the
|
|
518
|
+
// reply lands nowhere and the child waits on an answer that never comes.
|
|
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
|
+
}
|
|
504
528
|
}
|
|
505
529
|
this.notifyParent(run, "asked", { taskId: task.id, question });
|
|
506
530
|
// M3: a waiting child is not stalled — keep the watchdog fed until the reply.
|
|
@@ -516,13 +540,13 @@ export class SubagentManager {
|
|
|
516
540
|
},
|
|
517
541
|
onNotifyParent: (_taskId, message, level) => {
|
|
518
542
|
this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level, message });
|
|
543
|
+
// Parked leader gets it through the wait; otherwise queue it. `awaited` must
|
|
544
|
+
// NOT gate this — between two parks the leader is awaited but listening.
|
|
519
545
|
if (this.collectParked(run.id, { kind: "notify", taskId: task.id, agent: task.agent, text: message })) return;
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
/* parent mid-stream */
|
|
525
|
-
}
|
|
546
|
+
try {
|
|
547
|
+
this.pi.sendUserMessage(`[Subagent ${task.agent}] ${message}`, { deliverAs: "followUp" });
|
|
548
|
+
} catch {
|
|
549
|
+
/* parent mid-stream */
|
|
526
550
|
}
|
|
527
551
|
},
|
|
528
552
|
onSendMessage: (_taskId, to, text) => {
|
|
@@ -535,12 +559,10 @@ export class SubagentManager {
|
|
|
535
559
|
message: text,
|
|
536
560
|
});
|
|
537
561
|
if (this.collectParked(run.id, { kind: "notify", taskId: task.id, agent: task.agent, text })) return true;
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
/* parent mid-stream */
|
|
543
|
-
}
|
|
562
|
+
try {
|
|
563
|
+
this.pi.sendUserMessage(`[Subagent ${task.agent}] ${text}`, { deliverAs: "followUp" });
|
|
564
|
+
} catch {
|
|
565
|
+
/* parent mid-stream */
|
|
544
566
|
}
|
|
545
567
|
return true;
|
|
546
568
|
}
|
|
@@ -649,21 +671,42 @@ export class SubagentManager {
|
|
|
649
671
|
const file = resolveAgentFile(input.agent, input.task, task.cwd, getAgentDir());
|
|
650
672
|
const prompt = file?.body ?? input.prompt?.trim();
|
|
651
673
|
const thinking = input.thinking;
|
|
652
|
-
|
|
674
|
+
// Trust boundary: a file can NARROW the toolset (intersect with the leader's
|
|
675
|
+
// intent) but never widen it — a repo-planted agent file can't grant write.
|
|
676
|
+
const allowedTools = input.write ? WRITE_TOOLS : READONLY_TOOLS;
|
|
677
|
+
const fileTools = file?.tools?.filter((t) => allowedTools.includes(t));
|
|
678
|
+
const baseTools = fileTools?.length ? fileTools : (input.tools ?? allowedTools);
|
|
653
679
|
const tools = [...baseTools, ...(run.allowIntercom ? CHILD_TALK_TOOLS : [])];
|
|
680
|
+
// Isolation follows the DELIVERED toolset, never the raw request: explicit
|
|
681
|
+
// tools: [bash] without write:true still gets a worktree, and a file that
|
|
682
|
+
// narrowed the child to read-only never gets the commit/merge ceremony.
|
|
683
|
+
const canWrite = baseTools.some((t) => WRITE_CAPABLE.includes(t));
|
|
654
684
|
|
|
655
685
|
// Write agents run in an isolated git worktree (branch subagents/<run>/<task>);
|
|
656
686
|
// non-git repos fall back to in-place. The worktree is created BEFORE session
|
|
657
687
|
// start so the child's cwd + AGENTS.md context chain are the worktree's.
|
|
658
688
|
let wt: Worktree | undefined;
|
|
659
|
-
if (
|
|
689
|
+
if (canWrite) {
|
|
660
690
|
try {
|
|
661
691
|
wt = createWorktree(task.cwd, run.id, task.id);
|
|
662
692
|
} catch {
|
|
663
693
|
wt = undefined; // git failure → in-place
|
|
664
694
|
}
|
|
665
695
|
}
|
|
666
|
-
|
|
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);
|
|
667
710
|
|
|
668
711
|
// Model + thinking resolve against the pi model registry; a bad request
|
|
669
712
|
// fails the TASK with a helpful message, not the whole run.
|
|
@@ -818,15 +861,30 @@ export class SubagentManager {
|
|
|
818
861
|
// Commit the child's changes, then report the branch + diff so the
|
|
819
862
|
// leader can review and merge (PR-style). The worktree dir stays
|
|
820
863
|
// until the branch is merged — cleanupMerged removes both then.
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
864
|
+
// Commit/diff failures must NOT downgrade a completed task or destroy
|
|
865
|
+
// its work: the error is reported, the status stays completed.
|
|
866
|
+
try {
|
|
867
|
+
commitWorktree(wt, `subagent ${task.agent}: ${truncateText(input.task, 60)}`);
|
|
868
|
+
const { stat, files } = branchDiff(wt);
|
|
869
|
+
this.updateTask(
|
|
870
|
+
run,
|
|
871
|
+
task,
|
|
872
|
+
{ branch: wt.branch, diffStat: stat || undefined, changedFiles: files.length ? files : undefined },
|
|
873
|
+
ctx,
|
|
874
|
+
onUpdate,
|
|
875
|
+
);
|
|
876
|
+
} catch (commitErr) {
|
|
877
|
+
this.updateTask(
|
|
878
|
+
run,
|
|
879
|
+
task,
|
|
880
|
+
{
|
|
881
|
+
branch: wt.branch,
|
|
882
|
+
error: `Worktree commit failed (changes remain in ${wt.path}): ${commitErr instanceof Error ? commitErr.message : String(commitErr)}`,
|
|
883
|
+
},
|
|
884
|
+
ctx,
|
|
885
|
+
onUpdate,
|
|
886
|
+
);
|
|
887
|
+
}
|
|
830
888
|
}
|
|
831
889
|
}
|
|
832
890
|
} catch (err) {
|
|
@@ -861,9 +919,17 @@ export class SubagentManager {
|
|
|
861
919
|
watchdog.dispose();
|
|
862
920
|
if (timeout) clearTimeout(timeout);
|
|
863
921
|
child?.dispose();
|
|
864
|
-
|
|
865
|
-
//
|
|
922
|
+
this.liveWorktrees.delete(key);
|
|
923
|
+
// Failed/aborted: let the aborted child's last writes land (its tools may
|
|
924
|
+
// still be unwinding), commit whatever partial work exists so the branch
|
|
925
|
+
// really keeps it, then drop the checkout dir.
|
|
866
926
|
if (wt && task.status !== "completed") {
|
|
927
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
928
|
+
try {
|
|
929
|
+
commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
|
|
930
|
+
} catch {
|
|
931
|
+
/* nothing to commit */
|
|
932
|
+
}
|
|
867
933
|
this.updateTask(run, task, { branch: wt.branch }, ctx, onUpdate);
|
|
868
934
|
removeWorktree(wt);
|
|
869
935
|
}
|
|
@@ -898,13 +964,25 @@ export class SubagentManager {
|
|
|
898
964
|
? params.tasks!
|
|
899
965
|
: params.chain!;
|
|
900
966
|
if (inputs.length > MAX_TASKS) throw new Error(`Too many subagent tasks (${inputs.length}). Max is ${MAX_TASKS}.`);
|
|
967
|
+
// Task ids become git refs + filesystem paths — refuse anything unsafe.
|
|
968
|
+
// Explicit ids are checked against each other; generated ones are checked
|
|
969
|
+
// against explicit ones so a collision can't silently fall back to in-place.
|
|
901
970
|
const ids = new Set<string>();
|
|
902
971
|
for (const input of inputs) {
|
|
903
972
|
if (input.id !== undefined) {
|
|
973
|
+
if (!SAFE_TASK_ID.test(input.id)) {
|
|
974
|
+
throw new Error(`Unsafe task id: "${input.id}" (allowed: letters, digits, _ and - only).`);
|
|
975
|
+
}
|
|
904
976
|
if (ids.has(input.id)) throw new Error(`Duplicate task id: ${input.id}`);
|
|
905
977
|
ids.add(input.id);
|
|
906
978
|
}
|
|
907
979
|
}
|
|
980
|
+
for (let i = 0; i < inputs.length; i++) {
|
|
981
|
+
const generated = `task_${i + 1}`;
|
|
982
|
+
if (inputs[i]?.id === undefined && ids.has(generated)) {
|
|
983
|
+
throw new Error(`Generated task id ${generated} collides with an explicit id — rename the explicit id.`);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
908
986
|
const edges = resolveNeeds(inputs, mode);
|
|
909
987
|
|
|
910
988
|
const run: RunSnapshot = {
|
|
@@ -968,14 +1046,30 @@ export class SubagentManager {
|
|
|
968
1046
|
for (const task of run.tasks) {
|
|
969
1047
|
if (TERMINAL.includes(task.status)) settled.add(task.id); // canceled before start
|
|
970
1048
|
}
|
|
1049
|
+
// id → input, immune to filtered-array index drift (C4).
|
|
1050
|
+
const inputById = new Map(run.tasks.map((t, i) => [t.id, inputs[i]]));
|
|
971
1051
|
|
|
972
1052
|
const { skipped } = await runWaveScheduler(
|
|
973
1053
|
run.tasks.filter((t) => !TERMINAL.includes(t.status)),
|
|
974
1054
|
run.mode === "single" ? 1 : run.concurrency,
|
|
975
1055
|
outputs,
|
|
976
1056
|
settled,
|
|
977
|
-
async (task
|
|
978
|
-
|
|
1057
|
+
async (task) => {
|
|
1058
|
+
// The scheduler passes the index into the FILTERED list — never use it
|
|
1059
|
+
// against the unfiltered inputs. Look the input up by task id instead.
|
|
1060
|
+
const input = inputById.get(task.id);
|
|
1061
|
+
if (!input) {
|
|
1062
|
+
// Impossible unless ids drift from inputs — fail loudly instead of
|
|
1063
|
+
// leaving the task queued forever (hasActiveRun would never clear).
|
|
1064
|
+
this.updateTask(
|
|
1065
|
+
run,
|
|
1066
|
+
task,
|
|
1067
|
+
{ status: "failed", error: `No input for task ${task.id}`, endedAt: Date.now() },
|
|
1068
|
+
ctx,
|
|
1069
|
+
onUpdate,
|
|
1070
|
+
);
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
979
1073
|
await this.runChild(
|
|
980
1074
|
run,
|
|
981
1075
|
task,
|
|
@@ -1031,14 +1125,26 @@ export class SubagentManager {
|
|
|
1031
1125
|
for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
|
|
1032
1126
|
this.persist(ctx);
|
|
1033
1127
|
// Branches merged by the leader since the run ended: drop worktree dir + branch.
|
|
1034
|
-
for
|
|
1035
|
-
|
|
1128
|
+
// Once per repo, never for a branch another live run owns, never fatal — a
|
|
1129
|
+
// throw here would re-settle an already-finished run as failed.
|
|
1130
|
+
try {
|
|
1131
|
+
const roots = new Set<string>();
|
|
1132
|
+
for (const task of run.tasks) {
|
|
1133
|
+
if (!task.branch) continue;
|
|
1036
1134
|
const root = repoRoot(task.cwd);
|
|
1037
|
-
if (root)
|
|
1135
|
+
if (root) roots.add(root);
|
|
1038
1136
|
}
|
|
1137
|
+
for (const root of roots) cleanupMerged(root, { skipBranches: this.liveBranches() });
|
|
1138
|
+
} catch {
|
|
1139
|
+
/* cleanup is best-effort; the run outcome must stand */
|
|
1039
1140
|
}
|
|
1040
1141
|
}
|
|
1041
1142
|
|
|
1143
|
+
/** Branches owned by worktrees of still-running children. */
|
|
1144
|
+
liveBranches(): Set<string> {
|
|
1145
|
+
return new Set(Array.from(this.liveWorktrees.values(), (wt) => wt.branch));
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1042
1148
|
/** Spawn a run that keeps executing after this call returns. Every run is background. */
|
|
1043
1149
|
startInBackground(params: SubagentParamsShape, ctx: ExtensionContext): RunDetails {
|
|
1044
1150
|
const { run, inputs } = this.createRun(params, ctx);
|
|
@@ -1113,7 +1219,10 @@ export class SubagentManager {
|
|
|
1113
1219
|
task.error = task.error || "Canceled by subagent_cancel"; // never overwrite a real error
|
|
1114
1220
|
task.endedAt = Date.now();
|
|
1115
1221
|
aborted += 1;
|
|
1116
|
-
|
|
1222
|
+
// The branch is recorded here so the leader can still merge partial work;
|
|
1223
|
+
// runChild's finally commits + drops the dir (it owns the live worktree).
|
|
1224
|
+
const wt = this.liveWorktrees.get(`${runId}:${task.id}`);
|
|
1225
|
+
if (wt) task.branch = wt.branch;
|
|
1117
1226
|
}
|
|
1118
1227
|
run.status = "aborted";
|
|
1119
1228
|
run.endedAt = Date.now();
|
|
@@ -1168,7 +1277,7 @@ export class SubagentManager {
|
|
|
1168
1277
|
// IN the await result, no steering queue, no turn boundary needed.
|
|
1169
1278
|
this.parked.set(runId, { msgs, wake: () => resolve(cloneRun(run)) });
|
|
1170
1279
|
});
|
|
1171
|
-
if (timeoutMs) {
|
|
1280
|
+
if (timeoutMs !== undefined && timeoutMs > 0) {
|
|
1172
1281
|
return Promise.race([
|
|
1173
1282
|
settled.then((r) => {
|
|
1174
1283
|
finish();
|
package/src/worktree.ts
CHANGED
|
@@ -2,21 +2,26 @@
|
|
|
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, rmSync, symlinkSync } from "node:fs";
|
|
13
|
+
import { existsSync, readdirSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
|
11
14
|
import { join } from "node:path";
|
|
12
15
|
|
|
13
16
|
export interface Worktree {
|
|
14
17
|
root: string; // repo root (main tree)
|
|
15
18
|
path: string; // worktree checkout dir
|
|
16
19
|
branch: string; // subagents/<runId>/<taskId>
|
|
17
|
-
base: string; // branch
|
|
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,25 @@ function gitOk(root: string, args: string[]): boolean {
|
|
|
35
40
|
}
|
|
36
41
|
}
|
|
37
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
|
+
/** Compare paths through realpath so /var vs /private/var can't diverge. */
|
|
53
|
+
function samePath(a: string, b: string): boolean {
|
|
54
|
+
if (a === b) return true;
|
|
55
|
+
try {
|
|
56
|
+
return realpathSync(a) === realpathSync(b);
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
38
62
|
/** Repo root for cwd, or undefined when not a git repo (or cwd doesn't exist). */
|
|
39
63
|
export function repoRoot(cwd: string): string | undefined {
|
|
40
64
|
if (!existsSync(cwd)) return undefined;
|
|
@@ -50,12 +74,12 @@ export function createWorktree(cwd: string, runId: string, taskId: string): Work
|
|
|
50
74
|
const root = repoRoot(cwd);
|
|
51
75
|
if (!root) return undefined;
|
|
52
76
|
const path = join(root, ".git", "subagents", runId, taskId);
|
|
53
|
-
const branch =
|
|
77
|
+
const branch = `${BRANCH_PREFIX}${runId}/${taskId}`;
|
|
54
78
|
let base: string;
|
|
55
79
|
try {
|
|
56
|
-
base = git(root, ["rev-parse", "
|
|
80
|
+
base = git(root, ["rev-parse", "HEAD"]); // SHA — detached HEAD stays correct
|
|
57
81
|
} catch {
|
|
58
|
-
return undefined; //
|
|
82
|
+
return undefined; // broken repo — fall back to in-place
|
|
59
83
|
}
|
|
60
84
|
git(root, ["worktree", "add", "-b", branch, path, "HEAD"]);
|
|
61
85
|
// Deps follow the child into the worktree; anything else the task needs is
|
|
@@ -71,14 +95,22 @@ export function createWorktree(cwd: string, runId: string, taskId: string): Work
|
|
|
71
95
|
return { root, path, branch, base };
|
|
72
96
|
}
|
|
73
97
|
|
|
74
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Commit the child's changes. Stages first, then commits only when something is
|
|
100
|
+
* actually staged — an untracked node_modules symlink must not fake "dirty" and
|
|
101
|
+
* turn into a failed empty commit.
|
|
102
|
+
*/
|
|
75
103
|
export function commitWorktree(wt: Worktree, message: string): void {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
104
|
+
commitIn(wt.path, message);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function commitIn(dir: string, message: string): void {
|
|
108
|
+
gitIn(dir, ["add", "-A", "--", ".", ":(exclude)node_modules"]); // never stage the dep symlink
|
|
109
|
+
if (gitIn(dir, ["diff", "--cached", "--name-only"]).length === 0) return; // nothing to commit
|
|
110
|
+
gitIn(dir, ["commit", "-m", message, "--no-verify"]);
|
|
79
111
|
}
|
|
80
112
|
|
|
81
|
-
/** Diffstat + changed files of the branch vs its base. */
|
|
113
|
+
/** Diffstat + changed files of the branch vs its base SHA. */
|
|
82
114
|
export function branchDiff(wt: Worktree): { stat: string; files: string[] } {
|
|
83
115
|
const files = git(wt.root, ["diff", "--name-only", `${wt.base}...${wt.branch}`])
|
|
84
116
|
.split("\n")
|
|
@@ -89,34 +121,59 @@ export function branchDiff(wt: Worktree): { stat: string; files: string[] } {
|
|
|
89
121
|
|
|
90
122
|
/** Remove the worktree dir. The branch is KEPT (the work survives for merging). */
|
|
91
123
|
export function removeWorktree(wt: Worktree): void {
|
|
92
|
-
|
|
93
|
-
git(wt.root, ["worktree", "remove", "--force", wt.path]);
|
|
94
|
-
} catch {
|
|
95
|
-
/* already gone */
|
|
96
|
-
}
|
|
124
|
+
dropDir(wt.root, wt.path);
|
|
97
125
|
}
|
|
98
126
|
|
|
99
127
|
/** Remove a worktree dir by branch name (cancel paths that didn't keep a Worktree). */
|
|
100
128
|
export function removeByBranch(cwd: string, branch: string): void {
|
|
129
|
+
if (!branch.startsWith(BRANCH_PREFIX)) return;
|
|
101
130
|
const root = repoRoot(cwd);
|
|
102
131
|
if (!root) return;
|
|
103
|
-
|
|
132
|
+
dropDir(root, join(root, ".git", "subagents", branch.slice(BRANCH_PREFIX.length)));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function dropDir(root: string, path: string): void {
|
|
104
136
|
try {
|
|
105
137
|
git(root, ["worktree", "remove", "--force", path]);
|
|
106
138
|
} catch {
|
|
107
|
-
|
|
139
|
+
if (existsSync(path)) rmSync(path, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
prune(root);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Drop git's stale worktree admin entries (they pile up under .git/worktrees). */
|
|
145
|
+
function prune(root: string): void {
|
|
146
|
+
try {
|
|
147
|
+
git(root, ["worktree", "prune"]);
|
|
148
|
+
} catch {
|
|
149
|
+
/* ignore */
|
|
108
150
|
}
|
|
109
151
|
}
|
|
110
152
|
|
|
111
|
-
/**
|
|
112
|
-
|
|
153
|
+
/** Is this branch checked out in some worktree right now? Checked fresh, per call. */
|
|
154
|
+
function isCheckedOut(root: string, branch: string): boolean {
|
|
155
|
+
return worktreeBranches(root).includes(branch);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Delete branch + worktree for branches already merged into `target`.
|
|
160
|
+
* SAFETY: a branch checked out in a LIVE worktree is skipped — a fresh branch's
|
|
161
|
+
* tip equals its base until the child commits, so it looks "merged". The
|
|
162
|
+
* registration is re-checked immediately before each removal (a concurrent run
|
|
163
|
+
* may have created its worktree after the first listing).
|
|
164
|
+
*/
|
|
165
|
+
export function cleanupMerged(root: string, opts: { skipBranches?: Set<string>; target?: string } = {}): number {
|
|
166
|
+
root = realpathSync(root);
|
|
167
|
+
const target = opts.target ?? "HEAD";
|
|
113
168
|
const merged = git(root, ["branch", "--merged", target])
|
|
114
169
|
.split("\n")
|
|
115
170
|
.map((b) => b.trim().replace(/^[+*]\s*/, ""));
|
|
116
171
|
let cleaned = 0;
|
|
117
172
|
for (const branch of merged) {
|
|
118
|
-
if (!branch.startsWith(
|
|
119
|
-
|
|
173
|
+
if (!branch.startsWith(BRANCH_PREFIX)) continue;
|
|
174
|
+
if (opts.skipBranches?.has(branch)) continue; // owned by a live run
|
|
175
|
+
if (isCheckedOut(root, branch)) continue; // fresh re-check, not a stale snapshot
|
|
176
|
+
const path = join(root, ".git", "subagents", branch.slice(BRANCH_PREFIX.length));
|
|
120
177
|
if (existsSync(path)) {
|
|
121
178
|
try {
|
|
122
179
|
git(root, ["worktree", "remove", "--force", path]);
|
|
@@ -126,30 +183,88 @@ export function cleanupMerged(root: string, target = "HEAD"): number {
|
|
|
126
183
|
}
|
|
127
184
|
if (gitOk(root, ["branch", "-d", branch])) cleaned += 1;
|
|
128
185
|
}
|
|
186
|
+
prune(root);
|
|
129
187
|
return cleaned;
|
|
130
188
|
}
|
|
131
189
|
|
|
132
|
-
/**
|
|
190
|
+
/** Branch names currently checked out in any worktree (incl. the main one). */
|
|
191
|
+
function worktreeBranches(root: string): string[] {
|
|
192
|
+
try {
|
|
193
|
+
return git(root, ["worktree", "list", "--porcelain"])
|
|
194
|
+
.split("\n")
|
|
195
|
+
.filter((l) => l.startsWith("branch "))
|
|
196
|
+
.map((l) => l.slice("branch refs/heads/".length).trim());
|
|
197
|
+
} catch {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Session-start recovery: every registered subagent worktree is a crash leftover
|
|
204
|
+
* (nothing of ours can be live yet). Commit whatever the dead child left so the
|
|
205
|
+
* branch keeps it, then drop the dir. Branches always survive.
|
|
206
|
+
*/
|
|
207
|
+
export function reapDeadWorktrees(root: string): number {
|
|
208
|
+
root = realpathSync(root);
|
|
209
|
+
const sub = join(root, ".git", "subagents");
|
|
210
|
+
if (!existsSync(sub)) return 0;
|
|
211
|
+
let reaped = 0;
|
|
212
|
+
for (const path of worktreePaths(root)) {
|
|
213
|
+
if (!isInside(path, sub)) continue; // not ours
|
|
214
|
+
try {
|
|
215
|
+
commitIn(path, "subagent (recovered after interrupted session)");
|
|
216
|
+
} catch {
|
|
217
|
+
/* nothing committable */
|
|
218
|
+
}
|
|
219
|
+
dropDir(root, path);
|
|
220
|
+
reaped += 1;
|
|
221
|
+
}
|
|
222
|
+
return reaped;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Remove worktree dirs that git no longer knows about (partial-crash leftovers).
|
|
227
|
+
* Registered worktrees are never touched here — `reapDeadWorktrees` owns those,
|
|
228
|
+
* and a live child's checkout must survive.
|
|
229
|
+
*/
|
|
133
230
|
export function sweepStale(root: string): void {
|
|
231
|
+
root = realpathSync(root);
|
|
134
232
|
const sub = join(root, ".git", "subagents");
|
|
135
233
|
if (!existsSync(sub)) return;
|
|
136
|
-
const
|
|
137
|
-
git(root, ["branch", "--list", "subagents/*"])
|
|
138
|
-
.split("\n")
|
|
139
|
-
.map((b) => b.trim().replace(/^[+*]\s*/, "")),
|
|
140
|
-
);
|
|
234
|
+
const registered = worktreePaths(root);
|
|
141
235
|
for (const runDir of readDirs(sub)) {
|
|
142
236
|
for (const taskDir of readDirs(join(sub, runDir))) {
|
|
143
|
-
const
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
git(root, ["worktree", "remove", "--force", join(sub, runDir, taskDir)]);
|
|
147
|
-
} catch {
|
|
148
|
-
// dir not a registered worktree (partial crash) — plain rm
|
|
149
|
-
rmSync(join(sub, runDir, taskDir), { recursive: true, force: true });
|
|
150
|
-
}
|
|
237
|
+
const dir = join(sub, runDir, taskDir);
|
|
238
|
+
if (registered.some((p) => samePath(p, dir))) continue; // live/registered worktree
|
|
239
|
+
rmSync(dir, { recursive: true, force: true });
|
|
151
240
|
}
|
|
152
241
|
}
|
|
242
|
+
prune(root);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function worktreePaths(root: string): string[] {
|
|
246
|
+
try {
|
|
247
|
+
return git(root, ["worktree", "list", "--porcelain"])
|
|
248
|
+
.split("\n")
|
|
249
|
+
.filter((l) => l.startsWith("worktree "))
|
|
250
|
+
.map((l) => unquote(l.slice("worktree ".length).trim()));
|
|
251
|
+
} catch {
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function isInside(path: string, dir: string): boolean {
|
|
257
|
+
const p = safeReal(path);
|
|
258
|
+
const d = safeReal(dir);
|
|
259
|
+
return p === d || p.startsWith(`${d}/`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function safeReal(path: string): string {
|
|
263
|
+
try {
|
|
264
|
+
return realpathSync(path);
|
|
265
|
+
} catch {
|
|
266
|
+
return path;
|
|
267
|
+
}
|
|
153
268
|
}
|
|
154
269
|
|
|
155
270
|
function readDirs(dir: string): string[] {
|