@arhen/pi-core-subagent 1.3.39 → 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 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.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
@@ -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
@@ -61,6 +61,9 @@ export const MAX_CONCURRENCY = 8;
61
61
  * event, so a child stuck in a retry/compaction livelock emits forever and is
62
62
  * never "stalled" — only a cap that events CANNOT reset bounds that. */
63
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
64
67
  /** Last-resort hang detector, not a latency budget. A healthy child can be
65
68
  * silent for minutes (big-context upload, non-streamed reasoning, provider
66
69
  * retry backoff), so this is deliberately far above any normal quiet window —
@@ -1010,10 +1013,11 @@ export class SubagentManager {
1010
1013
  ),
1011
1014
  });
1012
1015
 
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);
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);
1017
1021
  const promptPromise = child.prompt(task.task, { source: "extension" });
1018
1022
  const races: Promise<unknown>[] = [promptPromise, childFailurePromise, childEndPromise, watchdog.promise];
1019
1023
  if (maxRuntimeMs > 0) {
@@ -1108,12 +1112,21 @@ export class SubagentManager {
1108
1112
  } catch {
1109
1113
  /* ignore */
1110
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
+ );
1111
1123
  this.updateTask(
1112
1124
  run,
1113
1125
  task,
1114
1126
  {
1115
1127
  status: aborted ? "aborted" : ((subagentStatus as TaskStatus) ?? "failed"),
1116
1128
  error: err instanceof Error ? err.message : String(err),
1129
+ finalText: salvaged || undefined,
1117
1130
  endedAt: Date.now(),
1118
1131
  },
1119
1132
  ctx,
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. */