@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 +1 -1
- package/src/child.ts +1 -47
- package/src/manager.ts +26 -44
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/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,19 +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
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;
|
|
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
|
-
//
|
|
618
|
-
//
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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,
|
|
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: () =>
|
|
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)
|
|
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
|
|
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
|