@arhen/pi-core-subagent 1.3.32 → 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 +17 -6
- package/src/manager.ts +154 -42
- package/src/types.ts +7 -0
- package/src/worktree.ts +125 -42
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
|
@@ -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 {
|
|
@@ -61,6 +61,8 @@ const DEFAULT_RUNTIME_MS = 0;
|
|
|
61
61
|
const DEFAULT_STALL_MS = 180_000; // 3 min: long model thinking streams emit no events, but they're not stalled.
|
|
62
62
|
/** Cap on a child's wait for reply_subagent — an ignored question must not pin the run open forever. */
|
|
63
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;
|
|
64
66
|
const READONLY_TOOLS = ["read", "grep", "find", "ls"];
|
|
65
67
|
const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write"];
|
|
66
68
|
/** Tools that can mutate the tree — their presence is what earns a worktree. */
|
|
@@ -302,6 +304,16 @@ export class SubagentManager {
|
|
|
302
304
|
child.dispose();
|
|
303
305
|
}
|
|
304
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();
|
|
305
317
|
// Ownership markers stay on disk; the next session reaps those dirs (commit,
|
|
306
318
|
// keep branch, drop dir) once this pid is gone.
|
|
307
319
|
this.liveWorktrees.clear();
|
|
@@ -519,6 +531,12 @@ export class SubagentManager {
|
|
|
519
531
|
return {
|
|
520
532
|
onAskParent: async (_taskId, question) => {
|
|
521
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
|
+
}
|
|
522
540
|
this.updateTask(run, task, { status: "awaiting_parent" }, ctx);
|
|
523
541
|
this.liveChildren.get(key)?.touchWatchdog();
|
|
524
542
|
// While the leader is parked in await_subagent the question rides the wait
|
|
@@ -534,6 +552,12 @@ export class SubagentManager {
|
|
|
534
552
|
const keepAlive = setInterval(() => this.liveChildren.get(key)?.touchWatchdog(), 30_000);
|
|
535
553
|
try {
|
|
536
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
|
+
}
|
|
537
561
|
this.updateTask(run, task, { status: "running" }, ctx);
|
|
538
562
|
this.liveChildren.get(key)?.touchWatchdog();
|
|
539
563
|
return reply;
|
|
@@ -578,28 +602,32 @@ export class SubagentManager {
|
|
|
578
602
|
private awaitParentReply(runId: string, taskId: string, timeoutMs = 0): Promise<string> {
|
|
579
603
|
const key = `${runId}:${taskId}`;
|
|
580
604
|
return new Promise<string>((resolve) => {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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, {
|
|
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 = {
|
|
591
608
|
resolve: (message) => {
|
|
592
609
|
if (timer) clearTimeout(timer);
|
|
610
|
+
if (this.pendingReplies.get(key) === entry) this.pendingReplies.delete(key);
|
|
593
611
|
resolve(message);
|
|
594
612
|
},
|
|
595
|
-
}
|
|
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);
|
|
596
625
|
});
|
|
597
626
|
}
|
|
598
627
|
deliverReply(runId: string, taskId: string, message: string): boolean {
|
|
599
628
|
const pending = this.pendingReplies.get(`${runId}:${taskId}`);
|
|
600
629
|
if (!pending) return false;
|
|
601
|
-
|
|
602
|
-
pending.resolve(message);
|
|
630
|
+
pending.resolve(message); // clears its own entry + timer
|
|
603
631
|
return true;
|
|
604
632
|
}
|
|
605
633
|
|
|
@@ -727,11 +755,14 @@ export class SubagentManager {
|
|
|
727
755
|
// non-git repos fall back to in-place. Created BEFORE session start so the
|
|
728
756
|
// child's cwd + AGENTS.md context chain are the worktree's.
|
|
729
757
|
let wt: Worktree | undefined;
|
|
758
|
+
let isolationReason: string | undefined;
|
|
730
759
|
if (canWrite) {
|
|
731
760
|
try {
|
|
732
761
|
wt = createWorktree(task.cwd, run.id, task.id);
|
|
733
|
-
|
|
762
|
+
if (!wt) isolationReason = "not a git repository";
|
|
763
|
+
} catch (err) {
|
|
734
764
|
wt = undefined; // git failure → in-place
|
|
765
|
+
isolationReason = `git worktree add failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
735
766
|
}
|
|
736
767
|
}
|
|
737
768
|
// Map a per-task cwd subpath into the worktree so relative paths stay correct.
|
|
@@ -741,14 +772,28 @@ export class SubagentManager {
|
|
|
741
772
|
let childCwd = wt?.path ?? task.cwd;
|
|
742
773
|
if (wt) {
|
|
743
774
|
const rel = relative(safeRealPath(wt.root), safeRealPath(task.cwd));
|
|
744
|
-
if (rel.startsWith(
|
|
775
|
+
if (rel === ".." || rel.startsWith(`..${sep}`)) {
|
|
745
776
|
removeWorktree(wt);
|
|
746
777
|
wt = undefined;
|
|
747
778
|
childCwd = task.cwd;
|
|
779
|
+
isolationReason = "task cwd is outside the repository";
|
|
748
780
|
} else if (rel && rel !== ".") {
|
|
749
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
|
+
}
|
|
750
789
|
}
|
|
751
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
|
+
}
|
|
752
797
|
if (wt) {
|
|
753
798
|
claimWorktree(wt); // pid marker: another pi session must not reap this
|
|
754
799
|
this.liveWorktrees.set(`${run.id}:${task.id}`, wt);
|
|
@@ -891,17 +936,12 @@ export class SubagentManager {
|
|
|
891
936
|
// until the branch is merged — cleanupMerged removes both then.
|
|
892
937
|
// Commit/diff failures must NOT downgrade a completed task or destroy
|
|
893
938
|
// its work: the error is reported, the status stays completed.
|
|
939
|
+
let committed: "committed" | "empty" | undefined;
|
|
894
940
|
try {
|
|
895
|
-
commitWorktree(wt, `subagent ${task.agent}: ${truncateText(input.task, 60)}`);
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
run,
|
|
900
|
-
task,
|
|
901
|
-
{ branch: wt.branch, diffStat: stat || undefined, changedFiles: files.length ? files : undefined },
|
|
902
|
-
ctx,
|
|
903
|
-
onUpdate,
|
|
904
|
-
);
|
|
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";
|
|
905
945
|
} catch (commitErr) {
|
|
906
946
|
// Never drop a checkout whose work isn't on the branch — it would be
|
|
907
947
|
// unreachable once the base-tip branch is reaped as "merged".
|
|
@@ -911,12 +951,36 @@ export class SubagentManager {
|
|
|
911
951
|
task,
|
|
912
952
|
{
|
|
913
953
|
branch: wt.branch,
|
|
914
|
-
|
|
954
|
+
worktreeError: `commit failed (uncommitted changes remain in ${wt.path}): ${commitErr instanceof Error ? commitErr.message : String(commitErr)}`,
|
|
915
955
|
},
|
|
916
956
|
ctx,
|
|
917
957
|
onUpdate,
|
|
918
958
|
);
|
|
919
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
|
+
}
|
|
920
984
|
}
|
|
921
985
|
}
|
|
922
986
|
} catch (err) {
|
|
@@ -957,12 +1021,16 @@ export class SubagentManager {
|
|
|
957
1021
|
// dir — dropping it would make the work unreachable.
|
|
958
1022
|
if (wt && task.status !== "completed") {
|
|
959
1023
|
await new Promise((r) => setTimeout(r, 250));
|
|
1024
|
+
let partial: "committed" | "empty" | undefined;
|
|
960
1025
|
try {
|
|
961
|
-
commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
|
|
1026
|
+
partial = commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
|
|
962
1027
|
} catch {
|
|
963
|
-
keepWorktreeDir = true;
|
|
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);
|
|
964
1033
|
}
|
|
965
|
-
this.updateTask(run, task, { branch: wt.branch }, ctx, onUpdate);
|
|
966
1034
|
}
|
|
967
1035
|
if (wt && !keepWorktreeDir) removeWorktree(wt);
|
|
968
1036
|
// Released only after the dir is gone: while it exists, the branch must stay
|
|
@@ -1122,13 +1190,14 @@ export class SubagentManager {
|
|
|
1122
1190
|
// Broken-upstream tasks are detected by the scheduler; mark them after the wave.
|
|
1123
1191
|
for (const s of skipped) {
|
|
1124
1192
|
const task = run.tasks.find((t) => t.id === s.id);
|
|
1125
|
-
if (task) {
|
|
1193
|
+
if (task && !TERMINAL.includes(task.status)) {
|
|
1126
1194
|
this.updateTask(
|
|
1127
1195
|
run,
|
|
1128
1196
|
task,
|
|
1129
1197
|
{
|
|
1130
1198
|
status: "aborted",
|
|
1131
|
-
|
|
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(", ")}`,
|
|
1132
1201
|
endedAt: Date.now(),
|
|
1133
1202
|
},
|
|
1134
1203
|
ctx,
|
|
@@ -1136,6 +1205,19 @@ export class SubagentManager {
|
|
|
1136
1205
|
);
|
|
1137
1206
|
}
|
|
1138
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
|
+
}
|
|
1139
1221
|
|
|
1140
1222
|
const failed = run.tasks.some((t) => t.status === "failed");
|
|
1141
1223
|
const aborted = run.tasks.some((t) => t.status === "aborted") || Boolean(signal?.aborted);
|
|
@@ -1290,15 +1372,30 @@ export class SubagentManager {
|
|
|
1290
1372
|
}
|
|
1291
1373
|
|
|
1292
1374
|
/** Child→leader messages collected while the parent is parked in await_subagent. */
|
|
1293
|
-
|
|
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 }>>();
|
|
1294
1378
|
|
|
1295
1379
|
/** While the parent is parked on this run, deliver the message through the wait instead of the steering queue. */
|
|
1296
1380
|
private collectParked(runId: string, msg: ParkedMsg): boolean {
|
|
1297
|
-
const
|
|
1298
|
-
if (!
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
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;
|
|
1302
1399
|
}
|
|
1303
1400
|
|
|
1304
1401
|
awaitRun(
|
|
@@ -1307,8 +1404,12 @@ export class SubagentManager {
|
|
|
1307
1404
|
): Promise<{ run: RunSnapshot | undefined; intercom: ParkedMsg[] } | undefined> {
|
|
1308
1405
|
const run = this.runs.get(runId);
|
|
1309
1406
|
if (!run) return Promise.resolve(undefined);
|
|
1407
|
+
let entry: { msgs: ParkedMsg[]; wake: () => void } | undefined;
|
|
1310
1408
|
const finish = (): void => {
|
|
1311
|
-
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);
|
|
1312
1413
|
};
|
|
1313
1414
|
if (TERMINAL.includes(run.status)) {
|
|
1314
1415
|
run.awaited = true;
|
|
@@ -1331,17 +1432,28 @@ export class SubagentManager {
|
|
|
1331
1432
|
waiters.add(waiter);
|
|
1332
1433
|
// A child→leader message while parked wakes the wait: the leader gets it
|
|
1333
1434
|
// IN the await result, no steering queue, no turn boundary needed.
|
|
1334
|
-
|
|
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);
|
|
1335
1442
|
});
|
|
1336
1443
|
if (timeoutMs !== undefined && timeoutMs > 0) {
|
|
1444
|
+
let timedOut = false;
|
|
1337
1445
|
return Promise.race([
|
|
1338
1446
|
settled.then((r) => {
|
|
1339
1447
|
finish();
|
|
1340
|
-
|
|
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;
|
|
1341
1452
|
return { run: r, intercom: msgs };
|
|
1342
1453
|
}),
|
|
1343
1454
|
new Promise<{ run: RunSnapshot | undefined; intercom: ParkedMsg[] } | undefined>((resolve) => {
|
|
1344
1455
|
const timer = setTimeout(() => {
|
|
1456
|
+
timedOut = true;
|
|
1345
1457
|
finish();
|
|
1346
1458
|
resolve(this.runs.get(runId) ? { run: cloneRun(this.runs.get(runId)!), intercom: msgs } : undefined);
|
|
1347
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
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
|
|
12
12
|
import { execFileSync } from "node:child_process";
|
|
13
13
|
import { existsSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
14
|
-
import {
|
|
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 {
|
|
@@ -67,18 +88,19 @@ export function createWorktree(cwd: string, runId: string, taskId: string): Work
|
|
|
67
88
|
// --git-common-dir, not "<root>/.git": inside a linked worktree or a submodule
|
|
68
89
|
// `.git` is a FILE, and joining it would make `worktree add` fail (silently
|
|
69
90
|
// dropping isolation).
|
|
70
|
-
|
|
91
|
+
const container = subagentsDir(root);
|
|
92
|
+
if (!container) return undefined;
|
|
71
93
|
let base: string;
|
|
72
94
|
try {
|
|
73
|
-
const common = git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
74
|
-
container = join(common, "subagents");
|
|
75
95
|
base = git(root, ["rev-parse", "HEAD"]); // SHA — detached HEAD stays correct
|
|
76
96
|
} catch {
|
|
77
97
|
return undefined; // broken repo — fall back to in-place
|
|
78
98
|
}
|
|
79
99
|
const path = join(container, runId, taskId);
|
|
80
100
|
const branch = `${BRANCH_PREFIX}${runId}/${taskId}`;
|
|
81
|
-
|
|
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]);
|
|
82
104
|
// Deps follow the child into the worktree; anything else the task needs is
|
|
83
105
|
// project content already checked out there.
|
|
84
106
|
const nm = join(root, "node_modules");
|
|
@@ -100,14 +122,27 @@ export function createWorktree(cwd: string, runId: string, taskId: string): Work
|
|
|
100
122
|
* work would become unreachable once the base-tip branch is reaped as merged).
|
|
101
123
|
*/
|
|
102
124
|
export function commitWorktree(wt: Worktree, message: string): "committed" | "empty" {
|
|
103
|
-
return commitIn(wt.path, message);
|
|
125
|
+
return commitIn(wt.path, message, wt.branch);
|
|
104
126
|
}
|
|
105
127
|
|
|
106
|
-
function commitIn(dir: string, message: string): "committed" | "empty" {
|
|
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
|
+
}
|
|
107
142
|
// Exclude the root dep symlink and any nested node_modules the child created.
|
|
108
143
|
gitIn(dir, ["add", "-A", "--", ".", ":(exclude)node_modules", ":(exclude,glob)**/node_modules/**"]);
|
|
109
144
|
if (gitIn(dir, ["diff", "--cached", "--name-only"]).length === 0) return "empty";
|
|
110
|
-
gitIn(dir, ["commit", "-m", message, "--no-verify"]);
|
|
145
|
+
gitIn(dir, [...COMMIT_CONFIG, "commit", "-m", message, "--no-verify"]);
|
|
111
146
|
return "committed";
|
|
112
147
|
}
|
|
113
148
|
|
|
@@ -134,21 +169,33 @@ export function removeByBranch(cwd: string, branch: string): void {
|
|
|
134
169
|
if (container) dropDir(root, join(container, branch.slice(BRANCH_PREFIX.length)));
|
|
135
170
|
}
|
|
136
171
|
|
|
137
|
-
/** `<git-common-dir>/subagents` — where our worktrees live for this repo.
|
|
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. */
|
|
138
174
|
function subagentsDir(root: string): string | undefined {
|
|
139
175
|
try {
|
|
140
176
|
return join(git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]), "subagents");
|
|
141
177
|
} catch {
|
|
142
|
-
|
|
178
|
+
try {
|
|
179
|
+
return join(resolve(root, git(root, ["rev-parse", "--git-common-dir"])), "subagents");
|
|
180
|
+
} catch {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
143
183
|
}
|
|
144
184
|
}
|
|
145
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`;
|
|
190
|
+
}
|
|
191
|
+
|
|
146
192
|
function dropDir(root: string, path: string): void {
|
|
147
193
|
try {
|
|
148
194
|
git(root, ["worktree", "remove", "--force", path]);
|
|
149
195
|
} catch {
|
|
150
196
|
if (existsSync(path)) rmSync(path, { recursive: true, force: true });
|
|
151
197
|
}
|
|
198
|
+
rmSync(ownerFile(path), { force: true }); // marker lives beside the dir
|
|
152
199
|
prune(root);
|
|
153
200
|
}
|
|
154
201
|
|
|
@@ -161,9 +208,10 @@ function prune(root: string): void {
|
|
|
161
208
|
}
|
|
162
209
|
}
|
|
163
210
|
|
|
164
|
-
/** Is this branch checked out
|
|
211
|
+
/** Is this branch checked out right now? "Unknown" counts as YES (never delete blind). */
|
|
165
212
|
function isCheckedOut(root: string, branch: string): boolean {
|
|
166
|
-
|
|
213
|
+
const branches = worktreeBranches(root);
|
|
214
|
+
return branches === undefined || branches.includes(branch);
|
|
167
215
|
}
|
|
168
216
|
|
|
169
217
|
/**
|
|
@@ -187,29 +235,29 @@ export function cleanupMerged(root: string, opts: { skipBranches?: Set<string>;
|
|
|
187
235
|
if (isCheckedOut(root, branch)) continue; // fresh re-check, not a stale snapshot
|
|
188
236
|
const path = join(container, branch.slice(BRANCH_PREFIX.length));
|
|
189
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;
|
|
190
241
|
if (existsSync(path)) {
|
|
191
242
|
try {
|
|
192
243
|
git(root, ["worktree", "remove", "--force", path]);
|
|
193
244
|
} catch {
|
|
194
|
-
|
|
245
|
+
/* branch is gone; a leftover dir is swept later */
|
|
195
246
|
}
|
|
196
247
|
}
|
|
197
|
-
|
|
248
|
+
rmSync(ownerFile(path), { force: true });
|
|
249
|
+
cleaned += 1;
|
|
198
250
|
}
|
|
199
251
|
prune(root);
|
|
200
252
|
return cleaned;
|
|
201
253
|
}
|
|
202
254
|
|
|
203
|
-
/** Branch names currently checked out in any worktree
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
.map((l) => l.slice("branch refs/heads/".length));
|
|
210
|
-
} catch {
|
|
211
|
-
return [];
|
|
212
|
-
}
|
|
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));
|
|
213
261
|
}
|
|
214
262
|
|
|
215
263
|
/**
|
|
@@ -221,8 +269,10 @@ export function reapDeadWorktrees(root: string, isLive: (path: string) => boolea
|
|
|
221
269
|
root = realpathSync(root);
|
|
222
270
|
const sub = subagentsDir(root);
|
|
223
271
|
if (!sub || !existsSync(sub)) return 0;
|
|
272
|
+
const registered = worktreePaths(root);
|
|
273
|
+
if (!registered) return 0; // listing failed — touch nothing
|
|
224
274
|
let reaped = 0;
|
|
225
|
-
for (const path of
|
|
275
|
+
for (const path of registered) {
|
|
226
276
|
if (!isInside(path, sub)) continue; // not ours
|
|
227
277
|
if (isLive(path)) continue; // another pi session owns it
|
|
228
278
|
try {
|
|
@@ -242,25 +292,45 @@ export function reapDeadWorktrees(root: string, isLive: (path: string) => boolea
|
|
|
242
292
|
*/
|
|
243
293
|
export function claimWorktree(wt: Worktree): void {
|
|
244
294
|
try {
|
|
245
|
-
writeFileSync(
|
|
295
|
+
writeFileSync(
|
|
296
|
+
ownerFile(wt.path),
|
|
297
|
+
JSON.stringify({ pid: process.pid, host: hostname(), boot: bootId(), at: Date.now() }),
|
|
298
|
+
);
|
|
246
299
|
} catch {
|
|
247
300
|
/* best-effort: worst case another session reaps it after a crash */
|
|
248
301
|
}
|
|
249
302
|
}
|
|
250
303
|
|
|
251
|
-
/**
|
|
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
|
+
*/
|
|
252
309
|
export function ownerAlive(path: string): boolean {
|
|
310
|
+
let marker: { pid?: number; host?: string; boot?: string };
|
|
253
311
|
try {
|
|
254
|
-
|
|
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;
|
|
312
|
+
marker = JSON.parse(readFileSync(ownerFile(path), "utf8"));
|
|
259
313
|
} catch {
|
|
260
|
-
return false;
|
|
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
|
|
261
326
|
}
|
|
262
327
|
}
|
|
263
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
|
+
|
|
264
334
|
/**
|
|
265
335
|
* Remove worktree dirs that git no longer knows about (partial-crash leftovers).
|
|
266
336
|
* Registered worktrees are never touched here — `reapDeadWorktrees` owns those,
|
|
@@ -271,26 +341,39 @@ export function sweepStale(root: string): void {
|
|
|
271
341
|
const sub = subagentsDir(root);
|
|
272
342
|
if (!sub || !existsSync(sub)) return;
|
|
273
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;
|
|
274
347
|
for (const runDir of readDirs(sub)) {
|
|
275
348
|
for (const taskDir of readDirs(join(sub, runDir))) {
|
|
276
349
|
const dir = join(sub, runDir, taskDir);
|
|
277
350
|
if (registered.some((p) => samePath(p, dir))) continue; // live/registered worktree
|
|
278
351
|
if (ownerAlive(dir)) continue; // claimed by a running session
|
|
279
352
|
rmSync(dir, { recursive: true, force: true });
|
|
353
|
+
rmSync(ownerFile(dir), { force: true });
|
|
280
354
|
}
|
|
281
355
|
}
|
|
282
356
|
prune(root);
|
|
283
357
|
}
|
|
284
358
|
|
|
285
|
-
/** Registered worktree paths
|
|
286
|
-
function worktreePaths(root: string): string[] {
|
|
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 {
|
|
287
369
|
try {
|
|
288
|
-
return
|
|
289
|
-
.split("\0")
|
|
290
|
-
.filter((l) => l.startsWith("worktree "))
|
|
291
|
-
.map((l) => l.slice("worktree ".length));
|
|
370
|
+
return gitRaw(root, ["worktree", "list", "--porcelain", "-z"]).split("\0").filter(Boolean);
|
|
292
371
|
} catch {
|
|
293
|
-
|
|
372
|
+
try {
|
|
373
|
+
return git(root, ["worktree", "list", "--porcelain"]).split("\n").filter(Boolean);
|
|
374
|
+
} catch {
|
|
375
|
+
return undefined; // unknown — callers must bail out
|
|
376
|
+
}
|
|
294
377
|
}
|
|
295
378
|
}
|
|
296
379
|
|