@arhen/pi-core-subagent 1.3.38 → 1.3.40
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 +8 -0
- package/package.json +1 -1
- package/src/format.ts +17 -2
- package/src/index.ts +4 -1
- package/src/manager.ts +109 -42
- package/src/types.ts +3 -0
- package/src/worktree.ts +59 -8
package/README.md
CHANGED
|
@@ -147,6 +147,14 @@ 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
|
+
**Branch relationships — read this before merging.** A write task that `needs` a completed write task is **stacked**: its worktree branches from the upstream's branch, so the child actually sees the files its upstream wrote. A stacked branch *contains* its upstream's commits, so merge order doesn't matter — merging the stacked branch brings both, and the upstream's own merge is then a no-op.
|
|
151
|
+
|
|
152
|
+
Why this matters (verified against real git, not just reasoned about): when a downstream child *can't* see its upstream's work, merging produces spurious conflicts, half-clobbered files where the child's side looks like a phantom delete, and — worst — **clean merges that leave a broken tree**. If the upstream renamed `login`→`signIn` and the downstream wrote new code importing `login`, git reports success with exit 0 and the code doesn't compile. Stacking removes that class by construction.
|
|
153
|
+
|
|
154
|
+
Same-wave write tasks are **siblings**: both branch from the same base, so they are independent. When two siblings changed the same file the summary emits `CONFLICT RISK` naming the overlap — the second `git merge` is a real 3-way and fails loudly, which is the safe outcome. The dangerous case is siblings touching *different* but coupled files: that merges clean and breaks at runtime. Nothing can detect it for you.
|
|
155
|
+
|
|
156
|
+
**Dependencies are shared, not isolated.** `node_modules` is symlinked to the main checkout, so dependency writes escape the worktree: children are instructed never to install, upgrade, or delete deps. A task that genuinely needs a dependency change should edit the manifest and say so.
|
|
157
|
+
|
|
150
158
|
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
159
|
|
|
152
160
|
Cleanup, in order of trust:
|
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.40",
|
|
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
|
@@ -197,13 +197,28 @@ export class SubagentsWidget implements Component {
|
|
|
197
197
|
}
|
|
198
198
|
/** Blocking-call summary: full text, because the model asked for it. */
|
|
199
199
|
/** Where a write child's edits went: a branch to merge, or straight into the tree. */
|
|
200
|
-
function worktreeLine(task: TaskSnapshot): string {
|
|
200
|
+
function worktreeLine(task: TaskSnapshot, siblings?: TaskSnapshot[]): string {
|
|
201
201
|
const parts: string[] = [];
|
|
202
202
|
if (task.branch) {
|
|
203
203
|
const files = task.changedFiles?.length
|
|
204
204
|
? ` (${task.changedFiles.length} file(s): ${truncateText(task.changedFiles.join(", "), 160)})`
|
|
205
205
|
: "";
|
|
206
206
|
parts.push(`Branch: ${task.branch}${files} — merge with \`git merge --no-ff ${task.branch}\` after review.`);
|
|
207
|
+
// Stacked branches CONTAIN their upstream, so either merge order gives the
|
|
208
|
+
// same tree (merging this one pulls the upstream in; the upstream's own merge
|
|
209
|
+
// is then a no-op). Say that, rather than implying an ordering requirement.
|
|
210
|
+
if (task.stackedOn) {
|
|
211
|
+
parts.push(`Stacked on ${task.stackedOn} — contains that branch's commits, so merging this one brings both.`);
|
|
212
|
+
}
|
|
213
|
+
// Sibling branches are independent, not stacked: overlapping files mean the
|
|
214
|
+
// second merge is a real 3-way, and non-overlapping-but-coupled edits break
|
|
215
|
+
// silently. Both are computable from changedFiles, so say so.
|
|
216
|
+
const overlap = (siblings ?? [])
|
|
217
|
+
.filter((s) => s.id !== task.id && s.branch && !s.stackedOn && !task.stackedOn)
|
|
218
|
+
.flatMap((s) => (s.changedFiles ?? []).filter((f) => task.changedFiles?.includes(f)).map((f) => `${s.id}:${f}`));
|
|
219
|
+
if (overlap.length > 0) {
|
|
220
|
+
parts.push(`CONFLICT RISK — sibling branches touched the same file(s): ${truncateText(overlap.join(", "), 200)}`);
|
|
221
|
+
}
|
|
207
222
|
} else if (task.isolation === "in-place") {
|
|
208
223
|
parts.push(
|
|
209
224
|
`Applied IN PLACE (no branch) — ${task.isolationReason ?? "worktree unavailable"}. Review the working tree directly.`,
|
|
@@ -228,7 +243,7 @@ export function makeSummary(run: RunSnapshot): string {
|
|
|
228
243
|
const edge = task.needs?.length ? ` (${task.id}, needs ${task.needs.join(", ")})` : ` (${task.id})`;
|
|
229
244
|
const fileNote = task.agentFile ? ` [${task.agentFile}]` : "";
|
|
230
245
|
lines.push(
|
|
231
|
-
`\n## ${task.agent}${edge}${fileNote} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}${worktreeLine(task)}`,
|
|
246
|
+
`\n## ${task.agent}${edge}${fileNote} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}${worktreeLine(task, run.tasks)}`,
|
|
232
247
|
);
|
|
233
248
|
}
|
|
234
249
|
// Ceiling on the WHOLE summary — 16 tasks × 24KB would otherwise flood the parent context.
|
package/src/index.ts
CHANGED
|
@@ -129,7 +129,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
129
129
|
// A registered subagent worktree is a crash leftover UNLESS another pi
|
|
130
130
|
// session still owns it (pid marker) — commit its work, keep the branch,
|
|
131
131
|
// drop the dir. Then reap merged branches and dirs git no longer tracks.
|
|
132
|
-
|
|
132
|
+
// Pass real ownership: in a long-lived process (pi `/new`) the previous
|
|
133
|
+
// session's markers carry THIS pid, and trusting them made those dirs
|
|
134
|
+
// unreapable until the process exited.
|
|
135
|
+
reapDeadWorktrees(root, (p) => ownerAlive(p, manager.ownsWorktree));
|
|
133
136
|
cleanupMerged(root, { skipBranches: manager.liveBranches() });
|
|
134
137
|
sweepStale(root);
|
|
135
138
|
} catch {
|
package/src/manager.ts
CHANGED
|
@@ -57,7 +57,13 @@ import {
|
|
|
57
57
|
export const DEFAULT_CONCURRENCY = 3;
|
|
58
58
|
export const MAX_CONCURRENCY = 8;
|
|
59
59
|
/** No default wall-clock cap: a subagent runs until its task is done, it stalls, or the user aborts. */
|
|
60
|
-
|
|
60
|
+
/** Hard wall-clock ceiling per child. The stall watchdog is touched by every
|
|
61
|
+
* event, so a child stuck in a retry/compaction livelock emits forever and is
|
|
62
|
+
* never "stalled" — only a cap that events CANNOT reset bounds that. */
|
|
63
|
+
const DEFAULT_RUNTIME_MS = 3_600_000; // 1 h
|
|
64
|
+
/** "Unlimited" still has a ceiling — an unbounded child pins hasActiveRun() and
|
|
65
|
+
* its concurrency slot for the life of the session. */
|
|
66
|
+
const UNLIMITED_RUNTIME_MS = 21_600_000; // 6 h
|
|
61
67
|
/** Last-resort hang detector, not a latency budget. A healthy child can be
|
|
62
68
|
* silent for minutes (big-context upload, non-streamed reasoning, provider
|
|
63
69
|
* retry backoff), so this is deliberately far above any normal quiet window —
|
|
@@ -248,9 +254,14 @@ export class SubagentManager {
|
|
|
248
254
|
private widgetTimers = new Map<string, ReturnType<typeof setTimeout>>(); // per-run stream throttle
|
|
249
255
|
private widgetRuns: RunSnapshot[] = [];
|
|
250
256
|
private eventSeq = 0;
|
|
251
|
-
/** Sidecar
|
|
257
|
+
/** Sidecar writes are serialized on this chain — ordering the DECISION isn't
|
|
258
|
+
* enough, two renames in flight can still land out of order. */
|
|
252
259
|
private persistSeq = 0;
|
|
253
260
|
private persistedSeq = 0;
|
|
261
|
+
private persistChain: Promise<unknown> = Promise.resolve();
|
|
262
|
+
/** Distinguishes managers sharing a pid (tests, SDK hosts with two sessions)
|
|
263
|
+
* so their tmp paths can't collide. */
|
|
264
|
+
private readonly instanceNonce = Math.random().toString(36).slice(2, 8);
|
|
254
265
|
/** Set by clearRuns — blocks late persists from erasing the sidecar. */
|
|
255
266
|
private cleared = false;
|
|
256
267
|
|
|
@@ -425,18 +436,22 @@ export class SubagentManager {
|
|
|
425
436
|
// ALL run history for the session on the next read. The tmp name must be
|
|
426
437
|
// UNIQUE per write — a shared one lets two concurrent persists interleave
|
|
427
438
|
// their bytes (unparseable sidecar) or rename an older snapshot last.
|
|
428
|
-
const
|
|
439
|
+
const seq = ++this.persistSeq;
|
|
440
|
+
const tmp = `${sidecar}.${process.pid}.${this.instanceNonce}.${seq}.tmp`;
|
|
429
441
|
const payload = JSON.stringify(this.listRuns().slice(0, 50).map(cloneRun), null, 2);
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
442
|
+
// Serialized: each write+rename runs after the previous one finishes, so two
|
|
443
|
+
// renames can never be in flight and land out of order.
|
|
444
|
+
this.persistChain = this.persistChain.then(async () => {
|
|
445
|
+
// A newer snapshot already landed — this one is stale, don't write it.
|
|
446
|
+
if (seq < this.persistedSeq) return;
|
|
447
|
+
try {
|
|
448
|
+
await writeFile(tmp, payload);
|
|
449
|
+
await rename(tmp, sidecar);
|
|
436
450
|
this.persistedSeq = seq;
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
451
|
+
} catch {
|
|
452
|
+
await rm(tmp, { force: true }).catch(() => {}); // never leak a tmp, never throw
|
|
453
|
+
}
|
|
454
|
+
});
|
|
440
455
|
} catch {
|
|
441
456
|
/* ignore */
|
|
442
457
|
}
|
|
@@ -825,7 +840,15 @@ export class SubagentManager {
|
|
|
825
840
|
let isolationReason: string | undefined;
|
|
826
841
|
if (canWrite) {
|
|
827
842
|
try {
|
|
828
|
-
|
|
843
|
+
// Stack on the upstream write task's branch, so a chained writer actually
|
|
844
|
+
// SEES the work it was told to build on. Read-only upstreams have no
|
|
845
|
+
// branch, so those stay based on HEAD.
|
|
846
|
+
const upstream = (task.needs ?? [])
|
|
847
|
+
.map((id) => run.tasks.find((t) => t.id === id))
|
|
848
|
+
.filter((t) => t?.branch && t.status === "completed")
|
|
849
|
+
.at(-1);
|
|
850
|
+
wt = createWorktree(task.cwd, run.id, task.id, upstream?.branch);
|
|
851
|
+
if (wt && upstream?.branch) task.stackedOn = upstream.branch;
|
|
829
852
|
if (!wt) isolationReason = "not a git repository";
|
|
830
853
|
} catch (err) {
|
|
831
854
|
wt = undefined; // git failure → in-place
|
|
@@ -866,24 +889,31 @@ export class SubagentManager {
|
|
|
866
889
|
this.liveWorktrees.set(`${run.id}:${task.id}`, wt);
|
|
867
890
|
}
|
|
868
891
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
892
|
+
// Guarded: a throwing event listener or widget failure here would escape
|
|
893
|
+
// runChild BEFORE the try/finally that releases the worktree — leaking a
|
|
894
|
+
// checkout that every reaper then skips (registered + owned by a live pid).
|
|
895
|
+
try {
|
|
896
|
+
this.updateTask(
|
|
897
|
+
run,
|
|
898
|
+
task,
|
|
899
|
+
{
|
|
900
|
+
status: "starting",
|
|
901
|
+
startedAt: Date.now(),
|
|
902
|
+
// Upstream outputs were spliced in by the scheduler; the snapshot must show
|
|
903
|
+
// the prompt the child actually receives.
|
|
904
|
+
task: input.task,
|
|
905
|
+
// RESOLVED model, not the request — an agent file's `model:` overrides it,
|
|
906
|
+
// and this patch used to clobber the resolved id recorded above.
|
|
907
|
+
model: model?.id ?? input.model,
|
|
908
|
+
thinking,
|
|
909
|
+
tools,
|
|
910
|
+
},
|
|
911
|
+
ctx,
|
|
912
|
+
onUpdate,
|
|
913
|
+
);
|
|
914
|
+
} catch {
|
|
915
|
+
/* a listener/widget failure must not strand the checkout */
|
|
916
|
+
}
|
|
887
917
|
|
|
888
918
|
// Set once the dir must outlive this call: committed work awaiting the
|
|
889
919
|
// leader's merge, or a commit failure whose work exists ONLY in the dir.
|
|
@@ -897,9 +927,15 @@ export class SubagentManager {
|
|
|
897
927
|
|
|
898
928
|
const key = `${run.id}:${task.id}`;
|
|
899
929
|
try {
|
|
930
|
+
// node_modules is a SHARED symlink to the leader's real tree, so dep writes
|
|
931
|
+
// escape the worktree entirely and `rm -rf node_modules/` destroys the
|
|
932
|
+
// project's deps. The child is the only thing that can avoid that.
|
|
933
|
+
const worktreeNote = wt
|
|
934
|
+
? ` You work in an isolated git worktree (branch ${wt.branch})${task.stackedOn ? `, stacked on ${task.stackedOn} (its changes are already in your tree)` : ""}. Never run git commands that switch branches, create branches, or move the worktree (git switch/checkout/branch/worktree). The extension commits your changes when you finish. git status/diff are fine for inspecting your own changes. node_modules is a SHARED symlink to the main checkout: never install, upgrade, or delete dependencies (no npm/bun/yarn/pnpm install, no \`rm -rf node_modules\`) — those writes escape your worktree and damage the user's project. If the task truly needs a dependency change, edit the manifest only and say so in your answer.`
|
|
935
|
+
: "";
|
|
900
936
|
const subagentInstruction = run.allowIntercom
|
|
901
|
-
? `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer. You MAY use ask_parent only when truly blocked on information only the parent has; notify_parent for one-way updates; send_agent_message/poll_agent_messages to coordinate with siblings. Your mailbox address and siblings: ${task.roster ?? "(none)"}. Use the exact task ids (e.g. task_2) as send_agent_message targets. Siblings run independently and may start late or finish early — never block indefinitely on their replies: poll at most 5 times, then proceed with your best judgment. A gated sibling (marked ↳ waits in the graph) may not be running yet; do not wait for it. Stalled waits get the whole run killed. When your work is done, call notify_parent ONCE with a concise result summary — key findings, verdicts, file:line evidence — so the leader can start consuming your output before the run finishes.${
|
|
902
|
-
: `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer for the parent agent.${
|
|
937
|
+
? `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer. You MAY use ask_parent only when truly blocked on information only the parent has; notify_parent for one-way updates; send_agent_message/poll_agent_messages to coordinate with siblings. Your mailbox address and siblings: ${task.roster ?? "(none)"}. Use the exact task ids (e.g. task_2) as send_agent_message targets. Siblings run independently and may start late or finish early — never block indefinitely on their replies: poll at most 5 times, then proceed with your best judgment. A gated sibling (marked ↳ waits in the graph) may not be running yet; do not wait for it. Stalled waits get the whole run killed. When your work is done, call notify_parent ONCE with a concise result summary — key findings, verdicts, file:line evidence — so the leader can start consuming your output before the run finishes.${worktreeNote}`
|
|
938
|
+
: `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer for the parent agent.${worktreeNote}`;
|
|
903
939
|
|
|
904
940
|
const loader = new DefaultResourceLoader({
|
|
905
941
|
cwd: childCwd,
|
|
@@ -977,8 +1013,11 @@ export class SubagentManager {
|
|
|
977
1013
|
),
|
|
978
1014
|
});
|
|
979
1015
|
|
|
980
|
-
//
|
|
981
|
-
|
|
1016
|
+
// The ceiling is the ONLY bound on a child that emits events forever (retry
|
|
1017
|
+
// or tool-call livelock): the stall watchdog is touched by every event and
|
|
1018
|
+
// cannot fire for one. So auto-limit off RAISES it, never removes it —
|
|
1019
|
+
// removing it reproduced the immortal-child hang.
|
|
1020
|
+
const maxRuntimeMs = input.maxRuntimeMs ?? (this.autoLimit ? DEFAULT_RUNTIME_MS : UNLIMITED_RUNTIME_MS);
|
|
982
1021
|
const promptPromise = child.prompt(task.task, { source: "extension" });
|
|
983
1022
|
const races: Promise<unknown>[] = [promptPromise, childFailurePromise, childEndPromise, watchdog.promise];
|
|
984
1023
|
if (maxRuntimeMs > 0) {
|
|
@@ -1000,6 +1039,11 @@ export class SubagentManager {
|
|
|
1000
1039
|
// TERMINAL, not just "aborted": a cancel/timeout that landed while the
|
|
1001
1040
|
// final text was being assembled must not be overwritten with "completed"
|
|
1002
1041
|
// (which would also skip the partial commit and drop the child's work).
|
|
1042
|
+
// `awaiting_parent` also means the child hadn't finished talking — marking
|
|
1043
|
+
// it completed would publish a truncated finalText to every dependent.
|
|
1044
|
+
if (task.status === "awaiting_parent") {
|
|
1045
|
+
this.pendingReplies.get(key)?.resolve("(your task is being finalized — stop work and return now)");
|
|
1046
|
+
}
|
|
1003
1047
|
if (!TERMINAL.includes(task.status)) {
|
|
1004
1048
|
this.updateTask(run, task, { status: "completed", finalText, endedAt: Date.now() }, ctx, onUpdate);
|
|
1005
1049
|
if (wt) {
|
|
@@ -1068,12 +1112,21 @@ export class SubagentManager {
|
|
|
1068
1112
|
} catch {
|
|
1069
1113
|
/* ignore */
|
|
1070
1114
|
}
|
|
1115
|
+
// Publish whatever the child DID say before it was killed. A timeout or
|
|
1116
|
+
// abort used to discard it, so a chain dependent received nothing at all
|
|
1117
|
+
// while the child's partial work was still committed to its branch.
|
|
1118
|
+
const salvaged =
|
|
1119
|
+
task.finalText ||
|
|
1120
|
+
truncateText(
|
|
1121
|
+
(child?.messages as AssistantMessage[] | undefined)?.map(getFirstText).filter(Boolean).at(-1) || "",
|
|
1122
|
+
);
|
|
1071
1123
|
this.updateTask(
|
|
1072
1124
|
run,
|
|
1073
1125
|
task,
|
|
1074
1126
|
{
|
|
1075
1127
|
status: aborted ? "aborted" : ((subagentStatus as TaskStatus) ?? "failed"),
|
|
1076
1128
|
error: err instanceof Error ? err.message : String(err),
|
|
1129
|
+
finalText: salvaged || undefined,
|
|
1077
1130
|
endedAt: Date.now(),
|
|
1078
1131
|
},
|
|
1079
1132
|
ctx,
|
|
@@ -1134,13 +1187,14 @@ export class SubagentManager {
|
|
|
1134
1187
|
// Single-mode-only fields alongside an array mode are silently dropped
|
|
1135
1188
|
// otherwise: `write: true` next to tasks:[...] produced read-only children
|
|
1136
1189
|
// that reported they "cannot edit files", with nothing explaining why.
|
|
1190
|
+
// `cwd` and `maxRuntimeMs` are meaningful run-wide (and the schema advertises
|
|
1191
|
+
// maxRuntimeMs without a single-mode marker), so they FAN OUT as per-task
|
|
1192
|
+
// defaults instead of being refused. The rest genuinely describe one agent.
|
|
1137
1193
|
if (hasChain || hasTasks) {
|
|
1138
|
-
const stray = (["write", "prompt", "tools", "model", "thinking"
|
|
1139
|
-
(k) => params[k] !== undefined,
|
|
1140
|
-
);
|
|
1194
|
+
const stray = (["write", "prompt", "tools", "model", "thinking"] as const).filter((k) => params[k] !== undefined);
|
|
1141
1195
|
if (stray.length > 0) {
|
|
1142
1196
|
throw new Error(
|
|
1143
|
-
`${stray.join(", ")} ${stray.length > 1 ? "
|
|
1197
|
+
`${stray.join(", ")} ${stray.length > 1 ? "describe" : "describes"} a single agent. Set ${stray.length > 1 ? "them" : "it"} on each item of ${hasChain ? "chain" : "tasks"} instead.`,
|
|
1144
1198
|
);
|
|
1145
1199
|
}
|
|
1146
1200
|
}
|
|
@@ -1160,9 +1214,12 @@ export class SubagentManager {
|
|
|
1160
1214
|
maxRuntimeMs: params.maxRuntimeMs,
|
|
1161
1215
|
},
|
|
1162
1216
|
]
|
|
1163
|
-
: hasTasks
|
|
1164
|
-
|
|
1165
|
-
|
|
1217
|
+
: (hasTasks ? params.tasks! : params.chain!).map((item) => ({
|
|
1218
|
+
// Run-wide defaults; a per-task value always wins.
|
|
1219
|
+
...item,
|
|
1220
|
+
cwd: item.cwd ?? params.cwd,
|
|
1221
|
+
maxRuntimeMs: item.maxRuntimeMs ?? params.maxRuntimeMs,
|
|
1222
|
+
}));
|
|
1166
1223
|
if (inputs.length > MAX_TASKS) throw new Error(`Too many subagent tasks (${inputs.length}). Max is ${MAX_TASKS}.`);
|
|
1167
1224
|
// Task ids become git refs + filesystem paths — refuse anything unsafe.
|
|
1168
1225
|
// Explicit ids are checked against each other; generated ones are checked
|
|
@@ -1184,6 +1241,10 @@ export class SubagentManager {
|
|
|
1184
1241
|
}
|
|
1185
1242
|
}
|
|
1186
1243
|
const edges = resolveNeeds(inputs, mode);
|
|
1244
|
+
// A new run exists, so persisting is meaningful again. Without this, a host
|
|
1245
|
+
// that emits session_shutdown with no following session_start (SIGTERM, SDK
|
|
1246
|
+
// reuse, tests) leaves every later persist a silent no-op.
|
|
1247
|
+
this.cleared = false;
|
|
1187
1248
|
// Pre-flight every task's model BEFORE the run exists. A matched agent file
|
|
1188
1249
|
// overrides the requested model, so an unresolvable one is the leader's
|
|
1189
1250
|
// mistake to see NOW — not N children dying one by one on their first turn
|
|
@@ -1376,6 +1437,12 @@ export class SubagentManager {
|
|
|
1376
1437
|
}
|
|
1377
1438
|
|
|
1378
1439
|
/** Branches owned by worktrees of still-running children. */
|
|
1440
|
+
/** True only for checkouts THIS manager still runs — a marker carrying our own
|
|
1441
|
+
* pid from a previous session in the same process is not proof of life. */
|
|
1442
|
+
ownsWorktree = (path: string): boolean => {
|
|
1443
|
+
for (const wt of this.liveWorktrees.values()) if (wt.path === path) return true;
|
|
1444
|
+
return false;
|
|
1445
|
+
};
|
|
1379
1446
|
liveBranches(): Set<string> {
|
|
1380
1447
|
return new Set(Array.from(this.liveWorktrees.values(), (wt) => wt.branch));
|
|
1381
1448
|
}
|
package/src/types.ts
CHANGED
|
@@ -53,6 +53,9 @@ export interface TaskSnapshot {
|
|
|
53
53
|
* changes are already in the leader's tree — always surfaced, never silent. */
|
|
54
54
|
isolation?: "worktree" | "in-place";
|
|
55
55
|
isolationReason?: string;
|
|
56
|
+
/** Upstream branch this one was built on top of. Merge that one FIRST — these
|
|
57
|
+
* are stacked, not independent. Absent = branched from the base tree. */
|
|
58
|
+
stackedOn?: string;
|
|
56
59
|
/** Worktree commit/diff trouble. Kept apart from `error` so a completed task
|
|
57
60
|
* still reports its answer. */
|
|
58
61
|
worktreeError?: string;
|
package/src/worktree.ts
CHANGED
|
@@ -28,6 +28,11 @@ const BRANCH_PREFIX = "subagents/";
|
|
|
28
28
|
const COMMIT_CONFIG = ["-c", "commit.gpgsign=false", "-c", "user.name=pi subagent", "-c", "user.email=subagent@local"];
|
|
29
29
|
const GIT_TIMEOUT_MS = 120_000;
|
|
30
30
|
const GIT_MAX_BUFFER = 32 * 1024 * 1024;
|
|
31
|
+
/** Capture stderr instead of inheriting it. execFileSync only redirects stdout by
|
|
32
|
+
* default, so git's progress chatter ("Preparing worktree (new branch ...)")
|
|
33
|
+
* printed straight into the TUI and corrupted the rendered frame. Captured
|
|
34
|
+
* stderr still reaches us on failure via the thrown error. */
|
|
35
|
+
const GIT_STDIO: ("ignore" | "pipe")[] = ["ignore", "pipe", "pipe"];
|
|
31
36
|
|
|
32
37
|
function git(root: string, args: string[]): string {
|
|
33
38
|
return gitRaw(root, args).trim();
|
|
@@ -39,6 +44,7 @@ function gitRaw(root: string, args: string[]): string {
|
|
|
39
44
|
encoding: "utf8",
|
|
40
45
|
timeout: GIT_TIMEOUT_MS,
|
|
41
46
|
maxBuffer: GIT_MAX_BUFFER,
|
|
47
|
+
stdio: GIT_STDIO,
|
|
42
48
|
});
|
|
43
49
|
}
|
|
44
50
|
|
|
@@ -49,6 +55,7 @@ function gitIn(dir: string, args: string[]): string {
|
|
|
49
55
|
encoding: "utf8",
|
|
50
56
|
timeout: GIT_TIMEOUT_MS,
|
|
51
57
|
maxBuffer: GIT_MAX_BUFFER,
|
|
58
|
+
stdio: GIT_STDIO,
|
|
52
59
|
}).trim();
|
|
53
60
|
}
|
|
54
61
|
|
|
@@ -81,8 +88,15 @@ export function repoRoot(cwd: string): string | undefined {
|
|
|
81
88
|
}
|
|
82
89
|
}
|
|
83
90
|
|
|
84
|
-
/**
|
|
85
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Create an isolated worktree for a write task. Returns undefined when not a git repo.
|
|
93
|
+
*
|
|
94
|
+
* @param baseRef Branch/SHA to start from. A chained write task MUST pass its
|
|
95
|
+
* upstream's branch: basing on main HEAD hands the child a tree without the
|
|
96
|
+
* upstream's edits, so it "builds on" work it cannot see and its merge reverts
|
|
97
|
+
* the upstream.
|
|
98
|
+
*/
|
|
99
|
+
export function createWorktree(cwd: string, runId: string, taskId: string, baseRef?: string): Worktree | undefined {
|
|
86
100
|
const root = repoRoot(cwd);
|
|
87
101
|
if (!root) return undefined;
|
|
88
102
|
// --git-common-dir, not "<root>/.git": inside a linked worktree or a submodule
|
|
@@ -92,17 +106,30 @@ export function createWorktree(cwd: string, runId: string, taskId: string): Work
|
|
|
92
106
|
if (!container) return undefined;
|
|
93
107
|
let base: string;
|
|
94
108
|
try {
|
|
95
|
-
|
|
109
|
+
// Resolve to a SHA so a later commit on the ref can't skew the diff base.
|
|
110
|
+
base = git(root, ["rev-parse", baseRef ?? "HEAD"]);
|
|
96
111
|
} catch {
|
|
97
|
-
return undefined; // broken repo — fall back to in-place
|
|
112
|
+
if (!baseRef) return undefined; // broken repo — fall back to in-place
|
|
113
|
+
try {
|
|
114
|
+
base = git(root, ["rev-parse", "HEAD"]); // upstream branch gone — fall back to HEAD
|
|
115
|
+
} catch {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
98
118
|
}
|
|
99
119
|
const path = join(container, runId, taskId);
|
|
100
120
|
const branch = `${BRANCH_PREFIX}${runId}/${taskId}`;
|
|
101
121
|
// Branch from the recorded SHA, not "HEAD" — a concurrent commit in the main
|
|
102
122
|
// tree between the two would otherwise skew every later diff against base.
|
|
103
123
|
git(root, ["worktree", "add", "-b", branch, path, base]);
|
|
104
|
-
// Deps follow the child into the worktree
|
|
105
|
-
//
|
|
124
|
+
// Deps follow the child into the worktree so it can build without a reinstall.
|
|
125
|
+
//
|
|
126
|
+
// ponytail: this is a SHARED symlink to the main tree's node_modules, not a
|
|
127
|
+
// copy — the cheap option, and it escapes isolation. A child that runs an
|
|
128
|
+
// install, or `rm -rf node_modules/` (trailing slash follows the link),
|
|
129
|
+
// mutates the leader's real deps outside any branch. Ceiling accepted because
|
|
130
|
+
// copying/hardlinking node_modules per worktree costs GBs per task; the
|
|
131
|
+
// upgrade path is a per-worktree install on an explicit opt-in flag.
|
|
132
|
+
// Children are warned in their system prompt (see manager.ts childPrompt).
|
|
106
133
|
const nm = join(root, "node_modules");
|
|
107
134
|
if (existsSync(nm) && !existsSync(join(path, "node_modules"))) {
|
|
108
135
|
try {
|
|
@@ -249,9 +276,30 @@ export function cleanupMerged(root: string, opts: { skipBranches?: Set<string>;
|
|
|
249
276
|
cleaned += 1;
|
|
250
277
|
}
|
|
251
278
|
prune(root);
|
|
279
|
+
pruneEmptyRunDirs(root);
|
|
252
280
|
return cleaned;
|
|
253
281
|
}
|
|
254
282
|
|
|
283
|
+
/** Remove `<subagents>/<runId>/` once its task dirs are gone. Cleanup left these
|
|
284
|
+
* behind forever, so `.git/subagents` grew one empty dir per run. */
|
|
285
|
+
function pruneEmptyRunDirs(root: string): void {
|
|
286
|
+
const sub = subagentsDir(root);
|
|
287
|
+
if (!sub || !existsSync(sub)) return;
|
|
288
|
+
try {
|
|
289
|
+
for (const entry of readdirSync(sub, { withFileTypes: true })) {
|
|
290
|
+
if (!entry.isDirectory()) continue;
|
|
291
|
+
const runDir = join(sub, entry.name);
|
|
292
|
+
try {
|
|
293
|
+
if (readdirSync(runDir).length === 0) rmSync(runDir, { recursive: true, force: true });
|
|
294
|
+
} catch {
|
|
295
|
+
/* skip */
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
} catch {
|
|
299
|
+
/* best-effort */
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
255
303
|
/** Branch names currently checked out in any worktree, or undefined when git
|
|
256
304
|
* couldn't be asked — callers MUST treat undefined as "unknown", never as "none",
|
|
257
305
|
* or they will happily delete live checkouts. */
|
|
@@ -320,7 +368,7 @@ export function claimWorktree(wt: Worktree): void {
|
|
|
320
368
|
* Guards against pid reuse across reboots (boot id) and other hosts (hostname);
|
|
321
369
|
* EPERM means the pid exists under another user — alive, not reapable.
|
|
322
370
|
*/
|
|
323
|
-
export function ownerAlive(path: string): boolean {
|
|
371
|
+
export function ownerAlive(path: string, ownedHere?: (path: string) => boolean): boolean {
|
|
324
372
|
let marker: { pid?: number; host?: string; boot?: string };
|
|
325
373
|
try {
|
|
326
374
|
marker = JSON.parse(readFileSync(ownerFile(path), "utf8"));
|
|
@@ -331,7 +379,10 @@ export function ownerAlive(path: string): boolean {
|
|
|
331
379
|
if (!pid || !Number.isFinite(pid) || pid <= 0) return false;
|
|
332
380
|
if (marker.host !== hostname()) return true; // another machine's checkout — never ours to reap
|
|
333
381
|
if (marker.boot !== bootId()) return false; // pre-reboot pid: reuse is near-certain
|
|
334
|
-
|
|
382
|
+
// Our OWN pid is not proof: a previous session in this same long-lived process
|
|
383
|
+
// (pi `/new`) leaves markers with this pid, and trusting them made those dirs
|
|
384
|
+
// unreapable for the process lifetime. Callers pass `isLive` for real knowledge.
|
|
385
|
+
if (pid === process.pid) return ownedHere?.(path) ?? true;
|
|
335
386
|
try {
|
|
336
387
|
process.kill(pid, 0);
|
|
337
388
|
return true;
|