@arhen/pi-core-subagent 1.3.40 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arhen/pi-core-subagent",
3
- "version": "1.3.40",
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/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,19 +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
70
  /** "Unlimited" still has a ceiling — an unbounded child pins hasActiveRun() and
65
71
  * its concurrency slot for the life of the session. */
66
72
  const UNLIMITED_RUNTIME_MS = 21_600_000; // 6 h
67
- /** Last-resort hang detector, not a latency budget. A healthy child can be
68
- * silent for minutes (big-context upload, non-streamed reasoning, provider
69
- * retry backoff), so this is deliberately far above any normal quiet window —
70
- * killing a working child is much worse than waiting out a dead one. */
71
- const DEFAULT_STALL_MS = 900_000; // 15 min
72
73
  /** Cap on a child's wait for reply_subagent — an ignored question must not pin the run open forever. */
73
74
  const PARENT_REPLY_TIMEOUT_MS = 600_000; // 10 min
74
75
  /** Intercom messages buffered per park before the followUp path takes over. */
@@ -244,7 +245,7 @@ export class SubagentManager {
244
245
  private pendingReplies = new Map<string, PendingReply>();
245
246
  private liveChildren = new Map<
246
247
  string,
247
- { abort: () => void; dispose: () => void; touchWatchdog: () => void; steer: (message: string) => void }
248
+ { abort: () => void; dispose: () => void; steer: (message: string) => void }
248
249
  >();
249
250
  private mailboxes: Mailbox = createMailbox();
250
251
  /** Live worktrees by `${runId}:${taskId}` — lets cancel drop dirs and keeps
@@ -598,7 +599,6 @@ export class SubagentManager {
598
599
  private makeChildHandlers(run: RunSnapshot, task: TaskSnapshot, ctx: ExtensionContext): ChildHandlers {
599
600
  return {
600
601
  onAskParent: async (_taskId, question) => {
601
- const key = `${run.id}:${task.id}`;
602
602
  // A tool call already in flight can reach here AFTER the task ended
603
603
  // (abort/timeout/cancel). Reviving it would leave a "running" task in a
604
604
  // finished run — hasActiveRun() then never clears.
@@ -606,7 +606,6 @@ export class SubagentManager {
606
606
  return "(your task has already ended — stop work and return immediately)";
607
607
  }
608
608
  this.updateTask(run, task, { status: "awaiting_parent" }, ctx);
609
- this.liveChildren.get(key)?.touchWatchdog();
610
609
  // While the leader is parked in await_subagent the question rides the wait
611
610
  // (no steering queue, no turn boundary); otherwise it goes out as a notice.
612
611
  // Either way the pending reply entry must exist, or reply_subagent has
@@ -614,24 +613,17 @@ export class SubagentManager {
614
613
  if (!this.collectParked(run.id, { kind: "ask", taskId: task.id, agent: task.agent, text: question })) {
615
614
  this.notifyParent(run, "asked", { taskId: task.id, question });
616
615
  }
617
- // A waiting child is not stalled — keep the watchdog fed until the reply.
618
- // But the wait is BOUNDED: an unanswered question would otherwise keep the
619
- // run non-terminal forever (widget never clears, run never settles).
620
- const keepAlive = setInterval(() => this.liveChildren.get(key)?.touchWatchdog(), 30_000);
621
- try {
622
- const reply = await this.awaitParentReply(run.id, task.id, PARENT_REPLY_TIMEOUT_MS);
623
- // Cancel wins over a reply that arrived in the same tick: never move a
624
- // terminal task back to "running" (that would let a canceled task be
625
- // reported as completed).
626
- if (TERMINAL.includes(task.status)) {
627
- return "(your task was canceled while you waited — stop work and return immediately)";
628
- }
629
- this.updateTask(run, task, { status: "running" }, ctx);
630
- this.liveChildren.get(key)?.touchWatchdog();
631
- return reply;
632
- } finally {
633
- 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)";
634
624
  }
625
+ this.updateTask(run, task, { status: "running" }, ctx);
626
+ return reply;
635
627
  },
636
628
  onNotifyParent: (_taskId, message, level) => {
637
629
  this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level, message });
@@ -709,14 +701,8 @@ export class SubagentManager {
709
701
  task: TaskSnapshot,
710
702
  ctx: ExtensionContext,
711
703
  onUpdate: ((partial: any) => void) | undefined,
712
- watchdog: Watchdog,
713
704
  state: ChildEventState,
714
705
  ): void {
715
- // ANY event is proof of life. The old allowlist ignored message_start,
716
- // turn_start/end, compaction and auto-retry, so a child was killed during
717
- // silent-but-healthy windows — context upload, a provider that doesn't
718
- // stream reasoning, retry backoff — and surfaced as "Error: terminated".
719
- watchdog.touch();
720
706
  const active =
721
707
  event.type === "message_update" ||
722
708
  event.type === "message_end" ||
@@ -922,7 +908,6 @@ export class SubagentManager {
922
908
  let unsubscribe: (() => void) | undefined;
923
909
  let timeout: ReturnType<typeof setTimeout> | undefined;
924
910
  let abortListener: (() => void) | undefined;
925
- const watchdog = createWatchdog(DEFAULT_STALL_MS, `Subagent ${task.agent}`);
926
911
  const childState: ChildEventState = {};
927
912
 
928
913
  const key = `${run.id}:${task.id}`;
@@ -981,7 +966,7 @@ export class SubagentManager {
981
966
  });
982
967
 
983
968
  unsubscribe = child.subscribe((event: AgentSessionEvent) =>
984
- this.onChildEvent(event, run, task, ctx, onUpdate, watchdog, childState),
969
+ this.onChildEvent(event, run, task, ctx, onUpdate, childState),
985
970
  );
986
971
 
987
972
  const abortChild = () => {
@@ -1002,8 +987,7 @@ export class SubagentManager {
1002
987
  }
1003
988
  this.liveChildren.set(key, {
1004
989
  abort: () => void child?.abort(),
1005
- dispose: () => watchdog.dispose(),
1006
- touchWatchdog: () => watchdog.touch(),
990
+ dispose: () => child?.dispose(),
1007
991
  // Inject a steering message mid-run; queues as steer if the child is streaming.
1008
992
  steer: (message) =>
1009
993
  void child?.prompt(message, { streamingBehavior: "steer" }).catch((err) =>
@@ -1014,12 +998,11 @@ export class SubagentManager {
1014
998
  });
1015
999
 
1016
1000
  // 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 —
1001
+ // or tool-call livelock). So auto-limit off RAISES it, never removes it —
1019
1002
  // removing it reproduced the immortal-child hang.
1020
1003
  const maxRuntimeMs = input.maxRuntimeMs ?? (this.autoLimit ? DEFAULT_RUNTIME_MS : UNLIMITED_RUNTIME_MS);
1021
1004
  const promptPromise = child.prompt(task.task, { source: "extension" });
1022
- const races: Promise<unknown>[] = [promptPromise, childFailurePromise, childEndPromise, watchdog.promise];
1005
+ const races: Promise<unknown>[] = [promptPromise, childFailurePromise, childEndPromise];
1023
1006
  if (maxRuntimeMs > 0) {
1024
1007
  races.push(
1025
1008
  new Promise<never>((_, reject) => {
@@ -1140,7 +1123,6 @@ export class SubagentManager {
1140
1123
  this.pendingReplies.delete(key);
1141
1124
  abortListener?.();
1142
1125
  unsubscribe?.();
1143
- watchdog.dispose();
1144
1126
  if (timeout) clearTimeout(timeout);
1145
1127
  child?.dispose();
1146
1128
  // Failed/aborted: let the aborted child's last writes land (its tools may