@arhen/pi-core-subagent 1.3.37 → 1.3.39
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 +6 -0
- package/package.json +1 -1
- package/src/agentfile.ts +17 -21
- package/src/format.ts +12 -2
- package/src/index.ts +4 -1
- package/src/manager.ts +156 -37
- package/src/types.ts +3 -0
- package/src/worktree.ts +31 -8
package/README.md
CHANGED
|
@@ -147,6 +147,12 @@ 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 (basing on main `HEAD` would hand it a tree without them, and its merge would revert the upstream). The summary says `Stacked on <branch> — merge that branch FIRST`; merge in dependency order.
|
|
151
|
+
|
|
152
|
+
Same-wave write tasks are **siblings**: both branch from the same base, so they are independent, not stacked. When two siblings changed the same file the summary emits `CONFLICT RISK` naming the overlap — the second `git merge` will be a real 3-way. Siblings that touched *different* but coupled files (a schema and its consumer) merge cleanly and can still break at runtime; that one is on you.
|
|
153
|
+
|
|
154
|
+
**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.
|
|
155
|
+
|
|
150
156
|
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
157
|
|
|
152
158
|
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.39",
|
|
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/agentfile.ts
CHANGED
|
@@ -44,24 +44,12 @@ const STOP = new Set([
|
|
|
44
44
|
"how",
|
|
45
45
|
"what",
|
|
46
46
|
"who",
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
47
|
+
// Pure connectors only. Words like create/user/work/run were stopped here to
|
|
48
|
+
// kill one false positive and took real routing signal with them (a
|
|
49
|
+
// scaffolder agent is legitimately "creates", a research agent "user") — the
|
|
50
|
+
// coverage gate handles weak matches without blinding whole categories.
|
|
50
51
|
"from",
|
|
51
52
|
"into",
|
|
52
|
-
"user",
|
|
53
|
-
"note",
|
|
54
|
-
"creat",
|
|
55
|
-
"create",
|
|
56
|
-
"make",
|
|
57
|
-
"use",
|
|
58
|
-
"work",
|
|
59
|
-
"run",
|
|
60
|
-
"new",
|
|
61
|
-
"other",
|
|
62
|
-
"single",
|
|
63
|
-
"formal",
|
|
64
|
-
"description",
|
|
65
53
|
"this",
|
|
66
54
|
"that",
|
|
67
55
|
"it",
|
|
@@ -69,6 +57,7 @@ const STOP = new Set([
|
|
|
69
57
|
"any",
|
|
70
58
|
"per",
|
|
71
59
|
"via",
|
|
60
|
+
"other",
|
|
72
61
|
]);
|
|
73
62
|
|
|
74
63
|
/** A body becomes the child's ENTIRE system prompt — an oversized reference file
|
|
@@ -77,7 +66,7 @@ const MAX_BODY_CHARS = 64_000;
|
|
|
77
66
|
/** A file takes over the prompt AND the model, so a weak match is expensive.
|
|
78
67
|
* Both gates must pass: distinct shared terms, and share of the description. */
|
|
79
68
|
const MIN_SHARED_TERMS = 2;
|
|
80
|
-
const
|
|
69
|
+
const MIN_COVERAGE = 0.4;
|
|
81
70
|
|
|
82
71
|
/** Per-cwd memo of the ancestor walk (project dirs then home), since the files
|
|
83
72
|
* can't meaningfully change within one run and the walk costs 3 sync stats per
|
|
@@ -95,8 +84,11 @@ function tokens(text: string): string[] {
|
|
|
95
84
|
.filter((t) => !STOP.has(t) && t.length > 1)
|
|
96
85
|
.map((t) => {
|
|
97
86
|
if (t.endsWith("ing") && t.length > 5) t = t.slice(0, -3);
|
|
98
|
-
|
|
99
|
-
|
|
87
|
+
// Only -es after a sibilant is a two-char plural (batches, boxes). Blindly
|
|
88
|
+
// stripping "es" mangles every -e noun — services→servic vs service→service
|
|
89
|
+
// never matched, silently breaking the most common routing words.
|
|
90
|
+
if (/(?:ch|sh|ss|x|z|s)es$/.test(t) && t.length > 4) t = t.slice(0, -2);
|
|
91
|
+
else if (t.endsWith("s") && !t.endsWith("ss") && t.length > 3) t = t.slice(0, -1);
|
|
100
92
|
return t;
|
|
101
93
|
});
|
|
102
94
|
}
|
|
@@ -117,8 +109,12 @@ function score(query: string[], desc: string[]): number {
|
|
|
117
109
|
const shared = new Set<string>();
|
|
118
110
|
for (const t of desc) if (q.has(t)) shared.add(t);
|
|
119
111
|
if (shared.size < MIN_SHARED_TERMS) return 0;
|
|
120
|
-
|
|
121
|
-
|
|
112
|
+
// Normalize by the SMALLER side. Dividing by the description's length alone
|
|
113
|
+
// punished well-written descriptions: a 20-token description needed 4 shared
|
|
114
|
+
// terms while a lazy 3-token one needed 2, so better docs routed worse — and
|
|
115
|
+
// a 2-word goal could never match a detailed description at all.
|
|
116
|
+
const denom = Math.min(new Set(desc).size, q.size);
|
|
117
|
+
if (denom === 0 || shared.size / denom < MIN_COVERAGE) return 0;
|
|
122
118
|
return shared.size;
|
|
123
119
|
}
|
|
124
120
|
|
package/src/format.ts
CHANGED
|
@@ -197,13 +197,23 @@ 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
|
+
if (task.stackedOn) parts.push(`Stacked on ${task.stackedOn} — merge that branch FIRST.`);
|
|
208
|
+
// Sibling branches are independent, not stacked: overlapping files mean the
|
|
209
|
+
// second merge is a real 3-way, and non-overlapping-but-coupled edits break
|
|
210
|
+
// silently. Both are computable from changedFiles, so say so.
|
|
211
|
+
const overlap = (siblings ?? [])
|
|
212
|
+
.filter((s) => s.id !== task.id && s.branch && !s.stackedOn && !task.stackedOn)
|
|
213
|
+
.flatMap((s) => (s.changedFiles ?? []).filter((f) => task.changedFiles?.includes(f)).map((f) => `${s.id}:${f}`));
|
|
214
|
+
if (overlap.length > 0) {
|
|
215
|
+
parts.push(`CONFLICT RISK — sibling branches touched the same file(s): ${truncateText(overlap.join(", "), 200)}`);
|
|
216
|
+
}
|
|
207
217
|
} else if (task.isolation === "in-place") {
|
|
208
218
|
parts.push(
|
|
209
219
|
`Applied IN PLACE (no branch) — ${task.isolationReason ?? "worktree unavailable"}. Review the working tree directly.`,
|
|
@@ -228,7 +238,7 @@ export function makeSummary(run: RunSnapshot): string {
|
|
|
228
238
|
const edge = task.needs?.length ? ` (${task.id}, needs ${task.needs.join(", ")})` : ` (${task.id})`;
|
|
229
239
|
const fileNote = task.agentFile ? ` [${task.agentFile}]` : "";
|
|
230
240
|
lines.push(
|
|
231
|
-
`\n## ${task.agent}${edge}${fileNote} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}${worktreeLine(task)}`,
|
|
241
|
+
`\n## ${task.agent}${edge}${fileNote} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}${worktreeLine(task, run.tasks)}`,
|
|
232
242
|
);
|
|
233
243
|
}
|
|
234
244
|
// 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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** SubagentManager: run lifecycle, child sessions, intercom, persistence, widget plumbing. */
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
-
import { rename, writeFile } from "node:fs/promises";
|
|
4
|
-
import { join, relative, sep } from "node:path";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
3
|
+
import { rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, 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 {
|
|
@@ -57,8 +57,15 @@ 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
|
-
|
|
61
|
-
|
|
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
|
+
/** Last-resort hang detector, not a latency budget. A healthy child can be
|
|
65
|
+
* silent for minutes (big-context upload, non-streamed reasoning, provider
|
|
66
|
+
* retry backoff), so this is deliberately far above any normal quiet window —
|
|
67
|
+
* killing a working child is much worse than waiting out a dead one. */
|
|
68
|
+
const DEFAULT_STALL_MS = 900_000; // 15 min
|
|
62
69
|
/** Cap on a child's wait for reply_subagent — an ignored question must not pin the run open forever. */
|
|
63
70
|
const PARENT_REPLY_TIMEOUT_MS = 600_000; // 10 min
|
|
64
71
|
/** Intercom messages buffered per park before the followUp path takes over. */
|
|
@@ -244,6 +251,16 @@ export class SubagentManager {
|
|
|
244
251
|
private widgetTimers = new Map<string, ReturnType<typeof setTimeout>>(); // per-run stream throttle
|
|
245
252
|
private widgetRuns: RunSnapshot[] = [];
|
|
246
253
|
private eventSeq = 0;
|
|
254
|
+
/** Sidecar writes are serialized on this chain — ordering the DECISION isn't
|
|
255
|
+
* enough, two renames in flight can still land out of order. */
|
|
256
|
+
private persistSeq = 0;
|
|
257
|
+
private persistedSeq = 0;
|
|
258
|
+
private persistChain: Promise<unknown> = Promise.resolve();
|
|
259
|
+
/** Distinguishes managers sharing a pid (tests, SDK hosts with two sessions)
|
|
260
|
+
* so their tmp paths can't collide. */
|
|
261
|
+
private readonly instanceNonce = Math.random().toString(36).slice(2, 8);
|
|
262
|
+
/** Set by clearRuns — blocks late persists from erasing the sidecar. */
|
|
263
|
+
private cleared = false;
|
|
247
264
|
|
|
248
265
|
/** When false, strip leader-imposed maxRuntimeMs so tasks run unlimited — toggle via `/subagents auto-limit on|off`. */
|
|
249
266
|
private autoLimit = true;
|
|
@@ -335,6 +352,7 @@ export class SubagentManager {
|
|
|
335
352
|
this.settleWaiters.clear();
|
|
336
353
|
this.pendingReplies.clear();
|
|
337
354
|
this.runControllers.clear();
|
|
355
|
+
this.cleared = true; // any persist after this point would write an empty sidecar
|
|
338
356
|
this.mailboxes = createMailbox();
|
|
339
357
|
this.widgetTui = null; // force re-registration on the next session
|
|
340
358
|
if (this.pulseTimer) {
|
|
@@ -348,10 +366,21 @@ export class SubagentManager {
|
|
|
348
366
|
|
|
349
367
|
// ── persistence (sidecar per parent session) ────────────────────────
|
|
350
368
|
async restoreFromSidecar(ctx: ExtensionContext): Promise<void> {
|
|
369
|
+
this.cleared = false; // a new session may persist again
|
|
351
370
|
const parentFile = getParentSessionFile(ctx);
|
|
352
371
|
if (!parentFile) return;
|
|
353
372
|
const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
|
|
354
373
|
let runs: RunSnapshot[];
|
|
374
|
+
// Sweep tmp files a crash left between write and rename (one per dead session).
|
|
375
|
+
try {
|
|
376
|
+
const dir = dirname(sidecar);
|
|
377
|
+
const prefix = `${basename(sidecar)}.`;
|
|
378
|
+
for (const entry of readdirSync(dir)) {
|
|
379
|
+
if (entry.startsWith(prefix) && entry.endsWith(".tmp")) rmSync(join(dir, entry), { force: true });
|
|
380
|
+
}
|
|
381
|
+
} catch {
|
|
382
|
+
/* best-effort */
|
|
383
|
+
}
|
|
355
384
|
try {
|
|
356
385
|
if (!existsSync(sidecar)) return;
|
|
357
386
|
const raw = JSON.parse(readFileSync(sidecar, "utf-8"));
|
|
@@ -392,17 +421,34 @@ export class SubagentManager {
|
|
|
392
421
|
}
|
|
393
422
|
}
|
|
394
423
|
private persist(ctx: ExtensionContext): void {
|
|
424
|
+
// After clearRuns the map is empty by design; a late persist (e.g. the
|
|
425
|
+
// background rejection handler firing after session_shutdown) would write
|
|
426
|
+
// `[]` over a good sidecar and erase the session's history.
|
|
427
|
+
if (this.cleared) return;
|
|
395
428
|
try {
|
|
396
429
|
const parentFile = getParentSessionFile(ctx);
|
|
397
430
|
if (!parentFile) return;
|
|
398
431
|
const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
|
|
399
432
|
// Write-then-rename: a plain writeFile can tear on crash and silently drop
|
|
400
|
-
// ALL run history for the session on the next read.
|
|
401
|
-
|
|
433
|
+
// ALL run history for the session on the next read. The tmp name must be
|
|
434
|
+
// UNIQUE per write — a shared one lets two concurrent persists interleave
|
|
435
|
+
// their bytes (unparseable sidecar) or rename an older snapshot last.
|
|
436
|
+
const seq = ++this.persistSeq;
|
|
437
|
+
const tmp = `${sidecar}.${process.pid}.${this.instanceNonce}.${seq}.tmp`;
|
|
402
438
|
const payload = JSON.stringify(this.listRuns().slice(0, 50).map(cloneRun), null, 2);
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
439
|
+
// Serialized: each write+rename runs after the previous one finishes, so two
|
|
440
|
+
// renames can never be in flight and land out of order.
|
|
441
|
+
this.persistChain = this.persistChain.then(async () => {
|
|
442
|
+
// A newer snapshot already landed — this one is stale, don't write it.
|
|
443
|
+
if (seq < this.persistedSeq) return;
|
|
444
|
+
try {
|
|
445
|
+
await writeFile(tmp, payload);
|
|
446
|
+
await rename(tmp, sidecar);
|
|
447
|
+
this.persistedSeq = seq;
|
|
448
|
+
} catch {
|
|
449
|
+
await rm(tmp, { force: true }).catch(() => {}); // never leak a tmp, never throw
|
|
450
|
+
}
|
|
451
|
+
});
|
|
406
452
|
} catch {
|
|
407
453
|
/* ignore */
|
|
408
454
|
}
|
|
@@ -663,6 +709,11 @@ export class SubagentManager {
|
|
|
663
709
|
watchdog: Watchdog,
|
|
664
710
|
state: ChildEventState,
|
|
665
711
|
): void {
|
|
712
|
+
// ANY event is proof of life. The old allowlist ignored message_start,
|
|
713
|
+
// turn_start/end, compaction and auto-retry, so a child was killed during
|
|
714
|
+
// silent-but-healthy windows — context upload, a provider that doesn't
|
|
715
|
+
// stream reasoning, retry backoff — and surfaced as "Error: terminated".
|
|
716
|
+
watchdog.touch();
|
|
666
717
|
const active =
|
|
667
718
|
event.type === "message_update" ||
|
|
668
719
|
event.type === "message_end" ||
|
|
@@ -672,7 +723,6 @@ export class SubagentManager {
|
|
|
672
723
|
event.type === "bash_execution_update" ||
|
|
673
724
|
event.type === "agent_settled";
|
|
674
725
|
if (active) {
|
|
675
|
-
watchdog.touch();
|
|
676
726
|
this.emit("subagent:session-event", {
|
|
677
727
|
runId: run.id,
|
|
678
728
|
taskId: task.id,
|
|
@@ -724,6 +774,8 @@ export class SubagentManager {
|
|
|
724
774
|
run: RunSnapshot,
|
|
725
775
|
task: TaskSnapshot,
|
|
726
776
|
input: TaskInput,
|
|
777
|
+
/** The task text as WRITTEN, before upstream outputs were spliced in. */
|
|
778
|
+
routingTask: string,
|
|
727
779
|
ctx: ExtensionContext,
|
|
728
780
|
signal: AbortSignal | undefined,
|
|
729
781
|
onUpdate?: (partial: any) => void,
|
|
@@ -733,7 +785,10 @@ export class SubagentManager {
|
|
|
733
785
|
// Matched user agent file (`.agents/agents` etc., by description): the file
|
|
734
786
|
// is authoritative — body = system prompt, frontmatter model/tools win over
|
|
735
787
|
// inline. No match → inline on-demand definition as usual.
|
|
736
|
-
|
|
788
|
+
// Route on the task as written, never on the upstream output spliced into
|
|
789
|
+
// it: a chain step would otherwise match a different file (and a different
|
|
790
|
+
// model) at runtime than the one createRun pre-flighted.
|
|
791
|
+
const file = resolveAgentFile(input.agent, routingTask, task.cwd, getAgentDir());
|
|
737
792
|
if (file?.path) task.agentFile = file.path; // recorded for audit — which file won
|
|
738
793
|
const prompt = file?.body ?? input.prompt?.trim();
|
|
739
794
|
const thinking = input.thinking;
|
|
@@ -782,7 +837,15 @@ export class SubagentManager {
|
|
|
782
837
|
let isolationReason: string | undefined;
|
|
783
838
|
if (canWrite) {
|
|
784
839
|
try {
|
|
785
|
-
|
|
840
|
+
// Stack on the upstream write task's branch, so a chained writer actually
|
|
841
|
+
// SEES the work it was told to build on. Read-only upstreams have no
|
|
842
|
+
// branch, so those stay based on HEAD.
|
|
843
|
+
const upstream = (task.needs ?? [])
|
|
844
|
+
.map((id) => run.tasks.find((t) => t.id === id))
|
|
845
|
+
.filter((t) => t?.branch && t.status === "completed")
|
|
846
|
+
.at(-1);
|
|
847
|
+
wt = createWorktree(task.cwd, run.id, task.id, upstream?.branch);
|
|
848
|
+
if (wt && upstream?.branch) task.stackedOn = upstream.branch;
|
|
786
849
|
if (!wt) isolationReason = "not a git repository";
|
|
787
850
|
} catch (err) {
|
|
788
851
|
wt = undefined; // git failure → in-place
|
|
@@ -823,22 +886,31 @@ export class SubagentManager {
|
|
|
823
886
|
this.liveWorktrees.set(`${run.id}:${task.id}`, wt);
|
|
824
887
|
}
|
|
825
888
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
889
|
+
// Guarded: a throwing event listener or widget failure here would escape
|
|
890
|
+
// runChild BEFORE the try/finally that releases the worktree — leaking a
|
|
891
|
+
// checkout that every reaper then skips (registered + owned by a live pid).
|
|
892
|
+
try {
|
|
893
|
+
this.updateTask(
|
|
894
|
+
run,
|
|
895
|
+
task,
|
|
896
|
+
{
|
|
897
|
+
status: "starting",
|
|
898
|
+
startedAt: Date.now(),
|
|
899
|
+
// Upstream outputs were spliced in by the scheduler; the snapshot must show
|
|
900
|
+
// the prompt the child actually receives.
|
|
901
|
+
task: input.task,
|
|
902
|
+
// RESOLVED model, not the request — an agent file's `model:` overrides it,
|
|
903
|
+
// and this patch used to clobber the resolved id recorded above.
|
|
904
|
+
model: model?.id ?? input.model,
|
|
905
|
+
thinking,
|
|
906
|
+
tools,
|
|
907
|
+
},
|
|
908
|
+
ctx,
|
|
909
|
+
onUpdate,
|
|
910
|
+
);
|
|
911
|
+
} catch {
|
|
912
|
+
/* a listener/widget failure must not strand the checkout */
|
|
913
|
+
}
|
|
842
914
|
|
|
843
915
|
// Set once the dir must outlive this call: committed work awaiting the
|
|
844
916
|
// leader's merge, or a commit failure whose work exists ONLY in the dir.
|
|
@@ -852,9 +924,15 @@ export class SubagentManager {
|
|
|
852
924
|
|
|
853
925
|
const key = `${run.id}:${task.id}`;
|
|
854
926
|
try {
|
|
927
|
+
// node_modules is a SHARED symlink to the leader's real tree, so dep writes
|
|
928
|
+
// escape the worktree entirely and `rm -rf node_modules/` destroys the
|
|
929
|
+
// project's deps. The child is the only thing that can avoid that.
|
|
930
|
+
const worktreeNote = wt
|
|
931
|
+
? ` 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.`
|
|
932
|
+
: "";
|
|
855
933
|
const subagentInstruction = run.allowIntercom
|
|
856
|
-
? `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.${
|
|
857
|
-
: `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.${
|
|
934
|
+
? `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}`
|
|
935
|
+
: `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}`;
|
|
858
936
|
|
|
859
937
|
const loader = new DefaultResourceLoader({
|
|
860
938
|
cwd: childCwd,
|
|
@@ -932,8 +1010,10 @@ export class SubagentManager {
|
|
|
932
1010
|
),
|
|
933
1011
|
});
|
|
934
1012
|
|
|
935
|
-
// auto-limit off =
|
|
936
|
-
|
|
1013
|
+
// auto-limit off = drop the DEFAULT ceiling, but never an explicit request:
|
|
1014
|
+
// discarding the leader's own maxRuntimeMs removed the last escape from a
|
|
1015
|
+
// livelocked child.
|
|
1016
|
+
const maxRuntimeMs = input.maxRuntimeMs ?? (this.autoLimit ? DEFAULT_RUNTIME_MS : 0);
|
|
937
1017
|
const promptPromise = child.prompt(task.task, { source: "extension" });
|
|
938
1018
|
const races: Promise<unknown>[] = [promptPromise, childFailurePromise, childEndPromise, watchdog.promise];
|
|
939
1019
|
if (maxRuntimeMs > 0) {
|
|
@@ -952,7 +1032,15 @@ export class SubagentManager {
|
|
|
952
1032
|
const finalText =
|
|
953
1033
|
task.finalText ||
|
|
954
1034
|
truncateText((child.messages as AssistantMessage[]).map(getFirstText).filter(Boolean).at(-1) || "");
|
|
955
|
-
|
|
1035
|
+
// TERMINAL, not just "aborted": a cancel/timeout that landed while the
|
|
1036
|
+
// final text was being assembled must not be overwritten with "completed"
|
|
1037
|
+
// (which would also skip the partial commit and drop the child's work).
|
|
1038
|
+
// `awaiting_parent` also means the child hadn't finished talking — marking
|
|
1039
|
+
// it completed would publish a truncated finalText to every dependent.
|
|
1040
|
+
if (task.status === "awaiting_parent") {
|
|
1041
|
+
this.pendingReplies.get(key)?.resolve("(your task is being finalized — stop work and return now)");
|
|
1042
|
+
}
|
|
1043
|
+
if (!TERMINAL.includes(task.status)) {
|
|
956
1044
|
this.updateTask(run, task, { status: "completed", finalText, endedAt: Date.now() }, ctx, onUpdate);
|
|
957
1045
|
if (wt) {
|
|
958
1046
|
// Commit the child's changes, then report the branch + diff so the
|
|
@@ -1033,6 +1121,9 @@ export class SubagentManager {
|
|
|
1033
1121
|
);
|
|
1034
1122
|
} finally {
|
|
1035
1123
|
this.liveChildren.delete(key);
|
|
1124
|
+
// Resolve, don't just delete: a bare delete strands the 10-minute reply
|
|
1125
|
+
// timer and leaves the child's await unsettled.
|
|
1126
|
+
this.pendingReplies.get(key)?.resolve("(task ended — stop work now)");
|
|
1036
1127
|
this.pendingReplies.delete(key);
|
|
1037
1128
|
abortListener?.();
|
|
1038
1129
|
unsubscribe?.();
|
|
@@ -1080,6 +1171,20 @@ export class SubagentManager {
|
|
|
1080
1171
|
`Provide one subagent mode: agent+task (single), tasks: [...] (parallel), or chain: [...] (sequential).`,
|
|
1081
1172
|
);
|
|
1082
1173
|
}
|
|
1174
|
+
// Single-mode-only fields alongside an array mode are silently dropped
|
|
1175
|
+
// otherwise: `write: true` next to tasks:[...] produced read-only children
|
|
1176
|
+
// that reported they "cannot edit files", with nothing explaining why.
|
|
1177
|
+
// `cwd` and `maxRuntimeMs` are meaningful run-wide (and the schema advertises
|
|
1178
|
+
// maxRuntimeMs without a single-mode marker), so they FAN OUT as per-task
|
|
1179
|
+
// defaults instead of being refused. The rest genuinely describe one agent.
|
|
1180
|
+
if (hasChain || hasTasks) {
|
|
1181
|
+
const stray = (["write", "prompt", "tools", "model", "thinking"] as const).filter((k) => params[k] !== undefined);
|
|
1182
|
+
if (stray.length > 0) {
|
|
1183
|
+
throw new Error(
|
|
1184
|
+
`${stray.join(", ")} ${stray.length > 1 ? "describe" : "describes"} a single agent. Set ${stray.length > 1 ? "them" : "it"} on each item of ${hasChain ? "chain" : "tasks"} instead.`,
|
|
1185
|
+
);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1083
1188
|
|
|
1084
1189
|
const mode: RunMode = hasChain ? "chain" : hasTasks ? "parallel" : "single";
|
|
1085
1190
|
const inputs: TaskInput[] = hasSingle
|
|
@@ -1096,9 +1201,12 @@ export class SubagentManager {
|
|
|
1096
1201
|
maxRuntimeMs: params.maxRuntimeMs,
|
|
1097
1202
|
},
|
|
1098
1203
|
]
|
|
1099
|
-
: hasTasks
|
|
1100
|
-
|
|
1101
|
-
|
|
1204
|
+
: (hasTasks ? params.tasks! : params.chain!).map((item) => ({
|
|
1205
|
+
// Run-wide defaults; a per-task value always wins.
|
|
1206
|
+
...item,
|
|
1207
|
+
cwd: item.cwd ?? params.cwd,
|
|
1208
|
+
maxRuntimeMs: item.maxRuntimeMs ?? params.maxRuntimeMs,
|
|
1209
|
+
}));
|
|
1102
1210
|
if (inputs.length > MAX_TASKS) throw new Error(`Too many subagent tasks (${inputs.length}). Max is ${MAX_TASKS}.`);
|
|
1103
1211
|
// Task ids become git refs + filesystem paths — refuse anything unsafe.
|
|
1104
1212
|
// Explicit ids are checked against each other; generated ones are checked
|
|
@@ -1120,6 +1228,10 @@ export class SubagentManager {
|
|
|
1120
1228
|
}
|
|
1121
1229
|
}
|
|
1122
1230
|
const edges = resolveNeeds(inputs, mode);
|
|
1231
|
+
// A new run exists, so persisting is meaningful again. Without this, a host
|
|
1232
|
+
// that emits session_shutdown with no following session_start (SIGTERM, SDK
|
|
1233
|
+
// reuse, tests) leaves every later persist a silent no-op.
|
|
1234
|
+
this.cleared = false;
|
|
1123
1235
|
// Pre-flight every task's model BEFORE the run exists. A matched agent file
|
|
1124
1236
|
// overrides the requested model, so an unresolvable one is the leader's
|
|
1125
1237
|
// mistake to see NOW — not N children dying one by one on their first turn
|
|
@@ -1230,6 +1342,7 @@ export class SubagentManager {
|
|
|
1230
1342
|
run,
|
|
1231
1343
|
task,
|
|
1232
1344
|
{ ...input, task: applyUpstream(input.task, task.needs ?? [], outputs) },
|
|
1345
|
+
input.task,
|
|
1233
1346
|
ctx,
|
|
1234
1347
|
signal,
|
|
1235
1348
|
onUpdate,
|
|
@@ -1311,6 +1424,12 @@ export class SubagentManager {
|
|
|
1311
1424
|
}
|
|
1312
1425
|
|
|
1313
1426
|
/** Branches owned by worktrees of still-running children. */
|
|
1427
|
+
/** True only for checkouts THIS manager still runs — a marker carrying our own
|
|
1428
|
+
* pid from a previous session in the same process is not proof of life. */
|
|
1429
|
+
ownsWorktree = (path: string): boolean => {
|
|
1430
|
+
for (const wt of this.liveWorktrees.values()) if (wt.path === path) return true;
|
|
1431
|
+
return false;
|
|
1432
|
+
};
|
|
1314
1433
|
liveBranches(): Set<string> {
|
|
1315
1434
|
return new Set(Array.from(this.liveWorktrees.values(), (wt) => wt.branch));
|
|
1316
1435
|
}
|
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
|
@@ -81,8 +81,15 @@ export function repoRoot(cwd: string): string | undefined {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
/**
|
|
85
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Create an isolated worktree for a write task. Returns undefined when not a git repo.
|
|
86
|
+
*
|
|
87
|
+
* @param baseRef Branch/SHA to start from. A chained write task MUST pass its
|
|
88
|
+
* upstream's branch: basing on main HEAD hands the child a tree without the
|
|
89
|
+
* upstream's edits, so it "builds on" work it cannot see and its merge reverts
|
|
90
|
+
* the upstream.
|
|
91
|
+
*/
|
|
92
|
+
export function createWorktree(cwd: string, runId: string, taskId: string, baseRef?: string): Worktree | undefined {
|
|
86
93
|
const root = repoRoot(cwd);
|
|
87
94
|
if (!root) return undefined;
|
|
88
95
|
// --git-common-dir, not "<root>/.git": inside a linked worktree or a submodule
|
|
@@ -92,17 +99,30 @@ export function createWorktree(cwd: string, runId: string, taskId: string): Work
|
|
|
92
99
|
if (!container) return undefined;
|
|
93
100
|
let base: string;
|
|
94
101
|
try {
|
|
95
|
-
|
|
102
|
+
// Resolve to a SHA so a later commit on the ref can't skew the diff base.
|
|
103
|
+
base = git(root, ["rev-parse", baseRef ?? "HEAD"]);
|
|
96
104
|
} catch {
|
|
97
|
-
return undefined; // broken repo — fall back to in-place
|
|
105
|
+
if (!baseRef) return undefined; // broken repo — fall back to in-place
|
|
106
|
+
try {
|
|
107
|
+
base = git(root, ["rev-parse", "HEAD"]); // upstream branch gone — fall back to HEAD
|
|
108
|
+
} catch {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
98
111
|
}
|
|
99
112
|
const path = join(container, runId, taskId);
|
|
100
113
|
const branch = `${BRANCH_PREFIX}${runId}/${taskId}`;
|
|
101
114
|
// Branch from the recorded SHA, not "HEAD" — a concurrent commit in the main
|
|
102
115
|
// tree between the two would otherwise skew every later diff against base.
|
|
103
116
|
git(root, ["worktree", "add", "-b", branch, path, base]);
|
|
104
|
-
// Deps follow the child into the worktree
|
|
105
|
-
//
|
|
117
|
+
// Deps follow the child into the worktree so it can build without a reinstall.
|
|
118
|
+
//
|
|
119
|
+
// ponytail: this is a SHARED symlink to the main tree's node_modules, not a
|
|
120
|
+
// copy — the cheap option, and it escapes isolation. A child that runs an
|
|
121
|
+
// install, or `rm -rf node_modules/` (trailing slash follows the link),
|
|
122
|
+
// mutates the leader's real deps outside any branch. Ceiling accepted because
|
|
123
|
+
// copying/hardlinking node_modules per worktree costs GBs per task; the
|
|
124
|
+
// upgrade path is a per-worktree install on an explicit opt-in flag.
|
|
125
|
+
// Children are warned in their system prompt (see manager.ts childPrompt).
|
|
106
126
|
const nm = join(root, "node_modules");
|
|
107
127
|
if (existsSync(nm) && !existsSync(join(path, "node_modules"))) {
|
|
108
128
|
try {
|
|
@@ -320,7 +340,7 @@ export function claimWorktree(wt: Worktree): void {
|
|
|
320
340
|
* Guards against pid reuse across reboots (boot id) and other hosts (hostname);
|
|
321
341
|
* EPERM means the pid exists under another user — alive, not reapable.
|
|
322
342
|
*/
|
|
323
|
-
export function ownerAlive(path: string): boolean {
|
|
343
|
+
export function ownerAlive(path: string, ownedHere?: (path: string) => boolean): boolean {
|
|
324
344
|
let marker: { pid?: number; host?: string; boot?: string };
|
|
325
345
|
try {
|
|
326
346
|
marker = JSON.parse(readFileSync(ownerFile(path), "utf8"));
|
|
@@ -331,7 +351,10 @@ export function ownerAlive(path: string): boolean {
|
|
|
331
351
|
if (!pid || !Number.isFinite(pid) || pid <= 0) return false;
|
|
332
352
|
if (marker.host !== hostname()) return true; // another machine's checkout — never ours to reap
|
|
333
353
|
if (marker.boot !== bootId()) return false; // pre-reboot pid: reuse is near-certain
|
|
334
|
-
|
|
354
|
+
// Our OWN pid is not proof: a previous session in this same long-lived process
|
|
355
|
+
// (pi `/new`) leaves markers with this pid, and trusting them made those dirs
|
|
356
|
+
// unreapable for the process lifetime. Callers pass `isLive` for real knowledge.
|
|
357
|
+
if (pid === process.pid) return ownedHere?.(path) ?? true;
|
|
335
358
|
try {
|
|
336
359
|
process.kill(pid, 0);
|
|
337
360
|
return true;
|