@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 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 (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.
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
- 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.
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.39",
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 + watchdog.
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
- if (task.stackedOn) parts.push(`Stacked on ${task.stackedOn} — merge that branch FIRST.`);
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, createWatchdog, type Watchdog } from "./child.ts";
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
- /** No default wall-clock cap: a subagent runs until its task is done, it stalls, or the user aborts. */
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. */
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
- /** 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
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; touchWatchdog: () => void; steer: (message: string) => 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
- // A waiting child is not stalled — keep the watchdog fed until the reply.
615
- // But the wait is BOUNDED: an unanswered question would otherwise keep the
616
- // run non-terminal forever (widget never clears, run never settles).
617
- const keepAlive = setInterval(() => this.liveChildren.get(key)?.touchWatchdog(), 30_000);
618
- try {
619
- const reply = await this.awaitParentReply(run.id, task.id, PARENT_REPLY_TIMEOUT_MS);
620
- // Cancel wins over a reply that arrived in the same tick: never move a
621
- // terminal task back to "running" (that would let a canceled task be
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, watchdog, childState),
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: () => watchdog.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
- // 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);
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, watchdog.promise];
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. */