@arhen/pi-core-subagent 1.3.39 → 1.3.41
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 +4 -2
- package/package.json +1 -1
- package/src/child.ts +1 -47
- package/src/format.ts +6 -1
- package/src/manager.ts +41 -46
- package/src/worktree.ts +28 -0
package/README.md
CHANGED
|
@@ -147,9 +147,11 @@ 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
|
|
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
151
|
|
|
152
|
-
|
|
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.
|
|
153
155
|
|
|
154
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.
|
|
155
157
|
|
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.41",
|
|
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/child.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Child-session tools
|
|
2
|
+
* Child-session tools.
|
|
3
3
|
*
|
|
4
4
|
* When `allowIntercom: true`, children get four talk tools:
|
|
5
5
|
* - ask_parent blocking Q&A with the leader (parent)
|
|
@@ -107,49 +107,3 @@ export function createChildTools(taskId: string, handlers: ChildHandlers): ToolD
|
|
|
107
107
|
},
|
|
108
108
|
];
|
|
109
109
|
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Watchdog — a subagent is "stalled" when it produces no events for stallMs.
|
|
113
|
-
* In-process AgentSessions have no process-exit signal, so we synthesize one
|
|
114
|
-
* via event-heartbeat. Every relevant child event must call touch().
|
|
115
|
-
*/
|
|
116
|
-
export interface Watchdog {
|
|
117
|
-
touch(): void;
|
|
118
|
-
readonly promise: Promise<never>;
|
|
119
|
-
dispose(): void;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export function createWatchdog(stallMs: number, label: string): Watchdog {
|
|
123
|
-
let lastEventAt = Date.now();
|
|
124
|
-
let disposed = false;
|
|
125
|
-
let timer: ReturnType<typeof setInterval> | undefined;
|
|
126
|
-
|
|
127
|
-
const promise = new Promise<never>((_, reject) => {
|
|
128
|
-
const interval = Math.max(1000, Math.min(5000, Math.floor(stallMs / 4)));
|
|
129
|
-
timer = setInterval(() => {
|
|
130
|
-
if (disposed) return;
|
|
131
|
-
if (Date.now() - lastEventAt > stallMs) {
|
|
132
|
-
disposed = true;
|
|
133
|
-
if (timer) clearInterval(timer);
|
|
134
|
-
timer = undefined;
|
|
135
|
-
reject(new Error(`${label} stalled: no activity for ${Math.round(stallMs / 1000)}s`));
|
|
136
|
-
}
|
|
137
|
-
}, interval);
|
|
138
|
-
timer.unref?.();
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
return {
|
|
142
|
-
touch() {
|
|
143
|
-
lastEventAt = Date.now();
|
|
144
|
-
},
|
|
145
|
-
promise,
|
|
146
|
-
dispose() {
|
|
147
|
-
if (disposed) return;
|
|
148
|
-
disposed = true;
|
|
149
|
-
if (timer) {
|
|
150
|
-
clearInterval(timer);
|
|
151
|
-
timer = undefined;
|
|
152
|
-
}
|
|
153
|
-
},
|
|
154
|
-
};
|
|
155
|
-
}
|
package/src/format.ts
CHANGED
|
@@ -204,7 +204,12 @@ function worktreeLine(task: TaskSnapshot, siblings?: TaskSnapshot[]): string {
|
|
|
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
|
-
|
|
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
|
+
}
|
|
208
213
|
// Sibling branches are independent, not stacked: overlapping files mean the
|
|
209
214
|
// second merge is a real 3-way, and non-overlapping-but-coupled edits break
|
|
210
215
|
// silently. Both are computable from changedFiles, so say so.
|
package/src/manager.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import type { TUI } from "@earendil-works/pi-tui";
|
|
19
19
|
import { resolveAgentFile } from "./agentfile.ts";
|
|
20
|
-
import { CHILD_TALK_TOOLS, type ChildHandlers, createChildTools
|
|
20
|
+
import { CHILD_TALK_TOOLS, type ChildHandlers, createChildTools } from "./child.ts";
|
|
21
21
|
import {
|
|
22
22
|
activitySnippet,
|
|
23
23
|
describeCall,
|
|
@@ -56,16 +56,20 @@ import {
|
|
|
56
56
|
|
|
57
57
|
export const DEFAULT_CONCURRENCY = 3;
|
|
58
58
|
export const MAX_CONCURRENCY = 8;
|
|
59
|
-
/**
|
|
60
|
-
|
|
61
|
-
*
|
|
62
|
-
*
|
|
59
|
+
/**
|
|
60
|
+
* Hard wall-clock ceiling per child — the ONLY liveness bound.
|
|
61
|
+
*
|
|
62
|
+
* There used to be a second one, an event-heartbeat "stall" watchdog. It was
|
|
63
|
+
* deleted: it was armed before the child session even existed, so the only
|
|
64
|
+
* window it could fire in was a slow startup (where firing is always wrong),
|
|
65
|
+
* and once events flowed it could never fire at all. Every observed firing
|
|
66
|
+
* across three releases was a false kill. A wedged child that emits events was
|
|
67
|
+
* always bounded by this cap alone; nothing else changed by removing it.
|
|
68
|
+
*/
|
|
63
69
|
const DEFAULT_RUNTIME_MS = 3_600_000; // 1 h
|
|
64
|
-
/**
|
|
65
|
-
*
|
|
66
|
-
|
|
67
|
-
* killing a working child is much worse than waiting out a dead one. */
|
|
68
|
-
const DEFAULT_STALL_MS = 900_000; // 15 min
|
|
70
|
+
/** "Unlimited" still has a ceiling — an unbounded child pins hasActiveRun() and
|
|
71
|
+
* its concurrency slot for the life of the session. */
|
|
72
|
+
const UNLIMITED_RUNTIME_MS = 21_600_000; // 6 h
|
|
69
73
|
/** Cap on a child's wait for reply_subagent — an ignored question must not pin the run open forever. */
|
|
70
74
|
const PARENT_REPLY_TIMEOUT_MS = 600_000; // 10 min
|
|
71
75
|
/** Intercom messages buffered per park before the followUp path takes over. */
|
|
@@ -241,7 +245,7 @@ export class SubagentManager {
|
|
|
241
245
|
private pendingReplies = new Map<string, PendingReply>();
|
|
242
246
|
private liveChildren = new Map<
|
|
243
247
|
string,
|
|
244
|
-
{ abort: () => void; dispose: () => void;
|
|
248
|
+
{ abort: () => void; dispose: () => void; steer: (message: string) => void }
|
|
245
249
|
>();
|
|
246
250
|
private mailboxes: Mailbox = createMailbox();
|
|
247
251
|
/** Live worktrees by `${runId}:${taskId}` — lets cancel drop dirs and keeps
|
|
@@ -595,7 +599,6 @@ export class SubagentManager {
|
|
|
595
599
|
private makeChildHandlers(run: RunSnapshot, task: TaskSnapshot, ctx: ExtensionContext): ChildHandlers {
|
|
596
600
|
return {
|
|
597
601
|
onAskParent: async (_taskId, question) => {
|
|
598
|
-
const key = `${run.id}:${task.id}`;
|
|
599
602
|
// A tool call already in flight can reach here AFTER the task ended
|
|
600
603
|
// (abort/timeout/cancel). Reviving it would leave a "running" task in a
|
|
601
604
|
// finished run — hasActiveRun() then never clears.
|
|
@@ -603,7 +606,6 @@ export class SubagentManager {
|
|
|
603
606
|
return "(your task has already ended — stop work and return immediately)";
|
|
604
607
|
}
|
|
605
608
|
this.updateTask(run, task, { status: "awaiting_parent" }, ctx);
|
|
606
|
-
this.liveChildren.get(key)?.touchWatchdog();
|
|
607
609
|
// While the leader is parked in await_subagent the question rides the wait
|
|
608
610
|
// (no steering queue, no turn boundary); otherwise it goes out as a notice.
|
|
609
611
|
// Either way the pending reply entry must exist, or reply_subagent has
|
|
@@ -611,24 +613,17 @@ export class SubagentManager {
|
|
|
611
613
|
if (!this.collectParked(run.id, { kind: "ask", taskId: task.id, agent: task.agent, text: question })) {
|
|
612
614
|
this.notifyParent(run, "asked", { taskId: task.id, question });
|
|
613
615
|
}
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
// reported as completed).
|
|
623
|
-
if (TERMINAL.includes(task.status)) {
|
|
624
|
-
return "(your task was canceled while you waited — stop work and return immediately)";
|
|
625
|
-
}
|
|
626
|
-
this.updateTask(run, task, { status: "running" }, ctx);
|
|
627
|
-
this.liveChildren.get(key)?.touchWatchdog();
|
|
628
|
-
return reply;
|
|
629
|
-
} finally {
|
|
630
|
-
clearInterval(keepAlive);
|
|
616
|
+
// The wait is BOUNDED: an unanswered question would otherwise keep the run
|
|
617
|
+
// non-terminal forever (widget never clears, run never settles).
|
|
618
|
+
const reply = await this.awaitParentReply(run.id, task.id, PARENT_REPLY_TIMEOUT_MS);
|
|
619
|
+
// Cancel wins over a reply that arrived in the same tick: never move a
|
|
620
|
+
// terminal task back to "running" (that would let a canceled task be
|
|
621
|
+
// reported as completed).
|
|
622
|
+
if (TERMINAL.includes(task.status)) {
|
|
623
|
+
return "(your task was canceled while you waited — stop work and return immediately)";
|
|
631
624
|
}
|
|
625
|
+
this.updateTask(run, task, { status: "running" }, ctx);
|
|
626
|
+
return reply;
|
|
632
627
|
},
|
|
633
628
|
onNotifyParent: (_taskId, message, level) => {
|
|
634
629
|
this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level, message });
|
|
@@ -706,14 +701,8 @@ export class SubagentManager {
|
|
|
706
701
|
task: TaskSnapshot,
|
|
707
702
|
ctx: ExtensionContext,
|
|
708
703
|
onUpdate: ((partial: any) => void) | undefined,
|
|
709
|
-
watchdog: Watchdog,
|
|
710
704
|
state: ChildEventState,
|
|
711
705
|
): 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();
|
|
717
706
|
const active =
|
|
718
707
|
event.type === "message_update" ||
|
|
719
708
|
event.type === "message_end" ||
|
|
@@ -919,7 +908,6 @@ export class SubagentManager {
|
|
|
919
908
|
let unsubscribe: (() => void) | undefined;
|
|
920
909
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
921
910
|
let abortListener: (() => void) | undefined;
|
|
922
|
-
const watchdog = createWatchdog(DEFAULT_STALL_MS, `Subagent ${task.agent}`);
|
|
923
911
|
const childState: ChildEventState = {};
|
|
924
912
|
|
|
925
913
|
const key = `${run.id}:${task.id}`;
|
|
@@ -978,7 +966,7 @@ export class SubagentManager {
|
|
|
978
966
|
});
|
|
979
967
|
|
|
980
968
|
unsubscribe = child.subscribe((event: AgentSessionEvent) =>
|
|
981
|
-
this.onChildEvent(event, run, task, ctx, onUpdate,
|
|
969
|
+
this.onChildEvent(event, run, task, ctx, onUpdate, childState),
|
|
982
970
|
);
|
|
983
971
|
|
|
984
972
|
const abortChild = () => {
|
|
@@ -999,8 +987,7 @@ export class SubagentManager {
|
|
|
999
987
|
}
|
|
1000
988
|
this.liveChildren.set(key, {
|
|
1001
989
|
abort: () => void child?.abort(),
|
|
1002
|
-
dispose: () =>
|
|
1003
|
-
touchWatchdog: () => watchdog.touch(),
|
|
990
|
+
dispose: () => child?.dispose(),
|
|
1004
991
|
// Inject a steering message mid-run; queues as steer if the child is streaming.
|
|
1005
992
|
steer: (message) =>
|
|
1006
993
|
void child?.prompt(message, { streamingBehavior: "steer" }).catch((err) =>
|
|
@@ -1010,12 +997,12 @@ export class SubagentManager {
|
|
|
1010
997
|
),
|
|
1011
998
|
});
|
|
1012
999
|
|
|
1013
|
-
//
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
1016
|
-
const maxRuntimeMs = input.maxRuntimeMs ?? (this.autoLimit ? DEFAULT_RUNTIME_MS :
|
|
1000
|
+
// The ceiling is the ONLY bound on a child that emits events forever (retry
|
|
1001
|
+
// or tool-call livelock). So auto-limit off RAISES it, never removes it —
|
|
1002
|
+
// removing it reproduced the immortal-child hang.
|
|
1003
|
+
const maxRuntimeMs = input.maxRuntimeMs ?? (this.autoLimit ? DEFAULT_RUNTIME_MS : UNLIMITED_RUNTIME_MS);
|
|
1017
1004
|
const promptPromise = child.prompt(task.task, { source: "extension" });
|
|
1018
|
-
const races: Promise<unknown>[] = [promptPromise, childFailurePromise, childEndPromise
|
|
1005
|
+
const races: Promise<unknown>[] = [promptPromise, childFailurePromise, childEndPromise];
|
|
1019
1006
|
if (maxRuntimeMs > 0) {
|
|
1020
1007
|
races.push(
|
|
1021
1008
|
new Promise<never>((_, reject) => {
|
|
@@ -1108,12 +1095,21 @@ export class SubagentManager {
|
|
|
1108
1095
|
} catch {
|
|
1109
1096
|
/* ignore */
|
|
1110
1097
|
}
|
|
1098
|
+
// Publish whatever the child DID say before it was killed. A timeout or
|
|
1099
|
+
// abort used to discard it, so a chain dependent received nothing at all
|
|
1100
|
+
// while the child's partial work was still committed to its branch.
|
|
1101
|
+
const salvaged =
|
|
1102
|
+
task.finalText ||
|
|
1103
|
+
truncateText(
|
|
1104
|
+
(child?.messages as AssistantMessage[] | undefined)?.map(getFirstText).filter(Boolean).at(-1) || "",
|
|
1105
|
+
);
|
|
1111
1106
|
this.updateTask(
|
|
1112
1107
|
run,
|
|
1113
1108
|
task,
|
|
1114
1109
|
{
|
|
1115
1110
|
status: aborted ? "aborted" : ((subagentStatus as TaskStatus) ?? "failed"),
|
|
1116
1111
|
error: err instanceof Error ? err.message : String(err),
|
|
1112
|
+
finalText: salvaged || undefined,
|
|
1117
1113
|
endedAt: Date.now(),
|
|
1118
1114
|
},
|
|
1119
1115
|
ctx,
|
|
@@ -1127,7 +1123,6 @@ export class SubagentManager {
|
|
|
1127
1123
|
this.pendingReplies.delete(key);
|
|
1128
1124
|
abortListener?.();
|
|
1129
1125
|
unsubscribe?.();
|
|
1130
|
-
watchdog.dispose();
|
|
1131
1126
|
if (timeout) clearTimeout(timeout);
|
|
1132
1127
|
child?.dispose();
|
|
1133
1128
|
// Failed/aborted: let the aborted child's last writes land (its tools may
|
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
|
|
|
@@ -269,9 +276,30 @@ export function cleanupMerged(root: string, opts: { skipBranches?: Set<string>;
|
|
|
269
276
|
cleaned += 1;
|
|
270
277
|
}
|
|
271
278
|
prune(root);
|
|
279
|
+
pruneEmptyRunDirs(root);
|
|
272
280
|
return cleaned;
|
|
273
281
|
}
|
|
274
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
|
+
|
|
275
303
|
/** Branch names currently checked out in any worktree, or undefined when git
|
|
276
304
|
* couldn't be asked — callers MUST treat undefined as "unknown", never as "none",
|
|
277
305
|
* or they will happily delete live checkouts. */
|